fix(desktop): plan payment options unexpectedly exposed to non-owner users (#6752)

Signed-off-by: Nixieboluo <me@sagirii.me>
This commit is contained in:
Nixieboluo
2026-03-03 16:42:32 +08:00
committed by GitHub
parent a8ee36730e
commit 7feffe422b
11 changed files with 147 additions and 340 deletions
@@ -55,7 +55,8 @@
"trial_expiry_upgrade_tip_one": "Your trial will expire in {{count}} day. Upgrade to keep your services online.",
"trial_expiry_upgrade_tip_other": "Your trial will expire in {{count}} days. Upgrade to keep your services online.",
"upgrade_button": "Upgrade Plan",
"upgrade_tip": "To upgrade your plan, you can visit the Cost Center."
"upgrade_tip": "To upgrade your plan, you can visit the Cost Center.",
"can_not_manage_payments": "Please contact the namespace owner to update or cancel the subscription."
},
"balance_text": "Balance",
"bank_name": "Bank",
@@ -54,7 +54,8 @@
"trial_expiry_upgrade_tip_one": "你的试用将在 {{count}} 天后过期. 升级套餐以保证服务在线.",
"trial_expiry_upgrade_tip_other": "你的试用将在 {{count}} 天后过期. 升级套餐以保证服务在线.",
"upgrade_button": "升级套餐",
"upgrade_tip": "跳转到费用中心以升级套餐"
"upgrade_tip": "跳转到费用中心以升级套餐",
"can_not_manage_payments": "请联系空间管理员执行取消或升级订阅操作"
},
"balance_text": "余额",
"bank_name": "开户银行",
@@ -95,6 +95,7 @@ export default function SecondaryLinks() {
const isFreePlan = (subscription?.PlanName || '').toLowerCase() === 'free';
const isCancelled = !!subscription?.CancelAtPeriodEnd && !isFreePlan;
const isDebt = subscription?.Status?.toLowerCase() === 'debt';
const canManagePayment = subscription?.role === 'OWNER';
useEffect(() => {
if (workspace) {
@@ -126,7 +127,7 @@ export default function SecondaryLinks() {
<Flex gap={'4px'} ml={'auto'}>
<BalancePopover
openCostCenterApp={() =>
layoutConfig?.common.subscriptionEnabled
layoutConfig?.common.subscriptionEnabled && canManagePayment
? openCostCenterApp('upgrade')
: openCostCenterApp()
}
@@ -144,7 +145,7 @@ export default function SecondaryLinks() {
fontWeight={'500'}
cursor={'pointer'}
onClick={() =>
layoutConfig?.common.subscriptionEnabled
layoutConfig?.common.subscriptionEnabled && canManagePayment
? openCostCenterApp('upgrade')
: openCostCenterApp()
}
@@ -13,7 +13,7 @@ import useSessionStore from '@/stores/session';
import { useSubscriptionStore } from '@/stores/subscription';
import { useMemo, useEffect } from 'react';
import { Sparkles } from 'lucide-react';
import { Button, cn, Separator } from '@sealos/shadcn-ui';
import { Tooltip, Button, cn, Separator, TooltipContent, TooltipTrigger } from '@sealos/shadcn-ui';
import { WorkspaceSubscription } from '@/types/plan';
import Decimal from 'decimal.js';
import { useQuery } from '@tanstack/react-query';
@@ -75,6 +75,8 @@ export function BalancePopover({
const isFreePlan = (subscription?.PlanName || '').toLowerCase() === 'free';
const isCancelled = !!subscription?.CancelAtPeriodEnd && !isFreePlan;
const canManagePayment = subscription?.role === 'OWNER';
const formatDate = (dateStr?: string) => {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleString('zh-CN', {
@@ -255,10 +257,24 @@ export function BalancePopover({
</div>
)}
<Button variant="outline" onClick={openCostCenterApp}>
<Sparkles size={16} />
{t('common:balance_popover.upgrade_button')}
</Button>
<Tooltip open={canManagePayment ? false : undefined}>
<TooltipTrigger asChild>
<div>
<Button
className="w-full"
variant="outline"
onClick={openCostCenterApp}
disabled={!canManagePayment}
>
<Sparkles size={16} />
{t('common:balance_popover.upgrade_button')}
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('common:balance_popover.can_not_manage_payments')}</p>
</TooltipContent>
</Tooltip>
</>
)}
</>
@@ -1,252 +0,0 @@
import { getAmount } from '@/api/auth';
import { getUserBilling } from '@/api/platform';
import useAppStore from '@/stores/app';
import { useConfigStore } from '@/stores/config';
import useSessionStore from '@/stores/session';
import { formatMoney } from '@/utils/format';
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Center,
Flex,
Text,
useBreakpointValue
} from '@chakra-ui/react';
import { CurrencySymbol, MonitorIcon } from '@sealos/ui';
import { useQuery } from '@tanstack/react-query';
import { Decimal } from 'decimal.js';
import { useTranslation } from 'next-i18next';
import { useMemo } from 'react';
import CustomTooltip from '../AppDock/CustomTooltip';
import { blurBackgroundStyles } from '../desktop_content';
import Monitor from '../desktop_content/monitor';
import { ClockIcon, HelpIcon, InfiniteIcon } from '../icons';
import { BalancePopover } from './BalancePopover';
/**
* @deprecated This component is deprecated. Use Cost Center to monitor balance instead.
*/
export default function Cost() {
const { t } = useTranslation();
const rechargeEnabled = useConfigStore().commonConfig?.rechargeEnabled;
const openApp = useAppStore((s) => s.openApp);
const installApp = useAppStore((s) => s.installedApps);
const { session } = useSessionStore();
const user = session?.user;
const isLargerThanXl = useBreakpointValue({ base: true, xl: false });
const currencySymbol = useConfigStore(
(state) => state.layoutConfig?.currencySymbol || 'shellCoin'
);
const { data } = useQuery({
queryKey: ['getAmount', { userId: user?.userCrUid }],
queryFn: getAmount,
enabled: !!user,
staleTime: 60 * 1000
});
const { data: billing } = useQuery(['getUserBilling'], () => getUserBilling(), {
cacheTime: 5 * 60 * 1000,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false
});
const balance = useMemo(() => {
let realBalance = new Decimal(data?.data?.balance || 0);
if (data?.data?.deductionBalance) {
realBalance = realBalance.minus(new Decimal(data.data.deductionBalance));
}
return realBalance.toNumber();
}, [data]);
const calculations = useMemo(() => {
const prevDayAmount = new Decimal(billing?.data?.prevDayTime || 0);
const estimatedNextMonthAmount = prevDayAmount.times(30).toNumber();
const _balance = new Decimal(balance || 0);
let estimatedDaysUsable;
if (_balance.isNegative()) {
estimatedDaysUsable = 0;
} else if (prevDayAmount.isZero()) {
estimatedDaysUsable = Number.POSITIVE_INFINITY;
} else {
estimatedDaysUsable = _balance.div(prevDayAmount).ceil().toNumber();
}
return {
prevMonthAmount: new Decimal(billing?.data?.prevMonthTime || 0).toNumber(),
estimatedNextMonthAmount,
estimatedDaysUsable
};
}, [billing?.data?.prevDayTime, billing?.data?.prevMonthTime, , balance]);
return (
<Box
position={'relative'}
flex={'0 1 400px'}
overflowY={'auto'}
style={{
scrollbarWidth: 'none'
}}
>
<Flex
{...blurBackgroundStyles}
position={'relative'}
zIndex={2}
fontSize={'base'}
fontWeight={'bold'}
px={'16px'}
pt={'20px'}
flexDirection={'column'}
>
<BalancePopover
openCostCenterApp={() => {
const costcenter = installApp.find((t) => t.key === 'system-costcenter');
if (!costcenter) return;
openApp(costcenter);
}}
openCostCenterTopup={() => {}}
>
<Flex
borderRadius={'6px'}
p="16px"
bg={'rgba(255, 255, 255, 0.05)'}
justifyContent={'space-between'}
_hover={{
background: 'rgba(255, 255, 255, 0.10)'
}}
cursor="pointer"
>
<Box flex={1}>
<Text color={'rgba(255, 255, 255, 0.90)'} fontSize={'11px'}>
{t('common:balance_popover.balance')}
</Text>
<Flex alignItems={'center'} gap={'8px'}>
<Text fontSize={'20px'} color={'#7CE7FF'}>
{formatMoney(balance).toFixed(2)}
</Text>
<CurrencySymbol type={currencySymbol} color={'white'} fontSize={'16px'} />
</Flex>
</Box>
{rechargeEnabled && (
<Center
ml="auto"
onClick={(e) => {
e.stopPropagation();
const costcenter = installApp.find((t) => t.key === 'system-costcenter');
if (!costcenter) return;
openApp(costcenter, {
query: {
openRecharge: 'true'
}
});
}}
color={'rgba(255, 255, 255, 0.90)'}
cursor={'pointer'}
>
{t('common:charge')}
</Center>
)}
</Flex>
</BalancePopover>
{calculations && (
<Flex flexDirection={'column'}>
<Flex
alignItems={'center'}
px={'16px'}
py={'18px'}
borderBottom={'1px solid rgba(255, 255, 255, 0.05)'}
>
<ClockIcon mr={'4px'} />
<Text fontSize={'12px'} fontWeight={'bold'} color={'rgba(255, 255, 255, 0.90)'}>
{t('common:expected_used')}
</Text>
<Text mr={'4px'} ml={'auto'} color={'white'} fontSize={'14px'} fontWeight={700}>
{calculations.estimatedDaysUsable === Number.POSITIVE_INFINITY ? (
<>
<InfiniteIcon /> {t('common:day')}
</>
) : (
<>
{calculations.estimatedDaysUsable} {t('common:day')}
</>
)}
</Text>
</Flex>
<Flex
alignItems={'center'}
px={'16px'}
py={'18px'}
borderBottom={'1px solid rgba(255, 255, 255, 0.05)'}
>
<Center
mr={'4px'}
w={'7px'}
height={'7px'}
bg={'#4DB4FF'}
borderRadius={'2px'}
></Center>
<Text fontSize={'12px'} fontWeight={'bold'} color={'rgba(255, 255, 255, 0.90)'}>
{t('common:used_last_month')}
</Text>
<Text mr={'4px'} ml={'auto'} color={'white'} fontSize={'14px'} fontWeight={700}>
{formatMoney(calculations.prevMonthAmount).toFixed(2)}
</Text>
<CurrencySymbol type={currencySymbol} color={'white'} fontSize={'14px'} />
</Flex>
<Flex alignItems={'center'} px={'16px'} py={'18px'}>
<Center
mr={'4px'}
w={'7px'}
height={'7px'}
bg={'#C74FFF'}
borderRadius={'2px'}
></Center>
<Flex alignItems={'center'} gap={'4px'} position={'relative'}>
<Text fontSize={'12px'} fontWeight={'bold'} color={'rgba(255, 255, 255, 0.90)'}>
{t('common:expected_to_use_next_month')}
</Text>
<CustomTooltip placement="bottom" label={t('common:amount_forecast')}>
<Box cursor={'pointer'}>
<HelpIcon />
</Box>
</CustomTooltip>
</Flex>
<Text mr={'4px'} ml={'auto'} color={'white'} fontSize={'14px'} fontWeight={700}>
{formatMoney(calculations.estimatedNextMonthAmount).toFixed(2)}
</Text>
<CurrencySymbol type={currencySymbol} color={'white'} fontSize={'14px'} />
</Flex>
</Flex>
)}
</Flex>
{isLargerThanXl && (
<Accordion allowMultiple mt={'8px'}>
<AccordionItem py={'19px'} {...blurBackgroundStyles}>
<AccordionButton
gap={'6px'}
_hover={{
bg: ''
}}
>
<MonitorIcon />
<Text color={'rgba(255, 255, 255, 0.90)'} fontWeight={'bold'} fontSize={'14px'}>
{t('common:monitor')}
</Text>
<AccordionIcon ml={'auto'} color={'white'} />
</AccordionButton>
<AccordionPanel p={0}>
<Monitor needStyles={false} />
</AccordionPanel>
</AccordionItem>
</Accordion>
)}
</Box>
);
}
+5 -1
View File
@@ -16,6 +16,9 @@ export const OperatorSchema = z.enum([
]);
export type Operator = z.infer<typeof OperatorSchema>;
export const WorkspaceRoleSchema = z.enum(['MANAGER', 'DEVELOPER', 'OWNER']);
export type WorkspaceRole = z.infer<typeof WorkspaceRoleSchema>;
export const StripeInfoSchema = z.object({
subscriptionId: z.string(),
customerId: z.string()
@@ -68,7 +71,8 @@ export const WorkspaceSubscriptionSchema = z.object({
UpdateAt: z.string(),
ExpireAt: z.string().nullable(),
Traffic: z.array(z.any()).nullable(),
type: SubscriptionTypeSchema
type: SubscriptionTypeSchema,
role: WorkspaceRoleSchema
});
export type WorkspaceSubscription = z.infer<typeof WorkspaceSubscriptionSchema>;
@@ -445,5 +445,6 @@
"jcb": "JCB",
"unionpay": "UnionPay"
},
"downgrade_warning_message": "Please ensure resources remain within {{planName}} plan limits by {{date}} to avoid charges."
"downgrade_warning_message": "Please ensure resources remain within {{planName}} plan limits by {{date}} to avoid charges.",
"can_not_manage_payments": "Please contact the namespace owner to update or cancel the subscription."
}
@@ -442,5 +442,6 @@
"jcb": "JCB",
"unionpay": "银联"
},
"downgrade_warning_message": "请确保资源在 {{date}} 之前保持在 {{planName}} 套餐限制内,以避免产生费用。"
"downgrade_warning_message": "请确保资源在 {{date}} 之前保持在 {{planName}} 套餐限制内,以避免产生费用。",
"can_not_manage_payments": "请联系空间管理员执行取消或升级订阅操作"
}
@@ -1,4 +1,4 @@
import { Button } from '@sealos/shadcn-ui';
import { Button, Tooltip, TooltipContent, TooltipTrigger } from '@sealos/shadcn-ui';
import { useQuery, useMutation } from '@tanstack/react-query';
import { getCardInfo, createCardManageSession } from '@/api/plan';
import { useTranslation } from 'next-i18next';
@@ -8,6 +8,7 @@ import useBillingStore from '@/stores/billing';
import { BankCardIcon } from '../BankCardIcon';
import { BankCardBrand } from '../BankCardBrand';
import { openInNewWindow } from '@/utils/windowUtils';
import usePlanStore from '@/stores/plan';
interface CardInfoSectionProps {
workspace?: string;
@@ -21,6 +22,9 @@ export function CardInfoSection({ workspace, regionDomain }: CardInfoSectionProp
const { getRegion } = useBillingStore();
const region = getRegion();
const subscriptionData = usePlanStore((state) => state.subscriptionData);
const canManagePayment = subscriptionData?.subscription.role === 'OWNER';
const effectiveWorkspace = workspace || session?.user?.nsid || '';
const effectiveRegionDomain = regionDomain || region?.domain || '';
@@ -133,14 +137,25 @@ export function CardInfoSection({ workspace, regionDomain }: CardInfoSectionProp
</div>
{hasCard && (
<Button
variant="outline"
onClick={handleManageCards}
disabled={manageCardMutation.isLoading}
className="h-10"
>
{manageCardMutation.isLoading ? t('common:loading') : t('common:manage_card_info')}
</Button>
<Tooltip open={canManagePayment ? false : undefined}>
<TooltipTrigger asChild>
<div>
<Button
variant="outline"
onClick={handleManageCards}
disabled={manageCardMutation.isLoading || !canManagePayment}
className="h-10"
>
{manageCardMutation.isLoading
? t('common:loading')
: t('common:manage_card_info')}
</Button>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('common:can_not_manage_payments')}</p>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
@@ -54,6 +54,7 @@ export function PlanHeader({ children, onRenewSuccess }: PlanHeaderProps) {
const plans = plansData?.plans;
const subscription = subscriptionData?.subscription;
const canManagePayment = subscription?.role === 'OWNER';
const lastTransaction = lastTransactionData?.transaction;
const planName = subscription?.PlanName || t('common:free_plan');
const isFreePlan = (subscription?.PlanName || '').toLowerCase() === 'free';
@@ -196,9 +197,16 @@ export function PlanHeader({ children, onRenewSuccess }: PlanHeaderProps) {
{children?.({
trigger: (
<Button size="lg" variant="outline">
<span>{inDebt ? t('common:renew') : t('common:subscribe_plan')}</span>
</Button>
<Tooltip open={canManagePayment ? false : undefined}>
<TooltipTrigger asChild>
<Button size="lg" variant="outline" disabled={!canManagePayment}>
<span>{inDebt ? t('common:renew') : t('common:subscribe_plan')}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t('common:can_not_manage_payments')}</p>
</TooltipContent>
</Tooltip>
)
})}
</div>
@@ -222,74 +230,81 @@ export function PlanHeader({ children, onRenewSuccess }: PlanHeaderProps) {
</div>
</div>
<div className="flex items-center gap-3">
{isNormal && !isFreePlan && (
<Button
size="lg"
variant="outline"
disabled={isCancelled}
onClick={() => {
if (isCancelled) return;
setCancelModalOpen(true);
}}
>
<span>{isCancelled ? t('common:cancelled') : t('common:cancel_plan')}</span>
</Button>
)}
{isCancelled && (
<Button
size="lg"
disabled={resumePlanMutation.isLoading}
onClick={() => {
if (!subscription) return;
<Tooltip open={canManagePayment ? false : undefined}>
<TooltipTrigger asChild>
<div className="flex items-center gap-3">
{isNormal && !isFreePlan && (
<Button
size="lg"
variant="outline"
disabled={isCancelled || !canManagePayment}
onClick={() => {
if (isCancelled) return;
setCancelModalOpen(true);
}}
>
<span>{isCancelled ? t('common:cancelled') : t('common:cancel_plan')}</span>
</Button>
)}
{isCancelled && (
<Button
size="lg"
disabled={resumePlanMutation.isLoading || !canManagePayment}
onClick={() => {
if (!subscription) return;
const statusLower = subscription.Status?.toLowerCase?.() || '';
const isDeleted = statusLower === 'deleted';
const periodEndMs = subscription.CurrentPeriodEndAt
? new Date(subscription.CurrentPeriodEndAt).getTime()
: 0;
const isExpired = !periodEndMs || periodEndMs <= Date.now();
const statusLower = subscription.Status?.toLowerCase?.() || '';
const isDeleted = statusLower === 'deleted';
const periodEndMs = subscription.CurrentPeriodEndAt
? new Date(subscription.CurrentPeriodEndAt).getTime()
: 0;
const isExpired = !periodEndMs || periodEndMs <= Date.now();
if (isDeleted || isExpired) {
toast({
title: t('common:resume_plan_expired_title'),
description: t('common:resume_plan_expired_desc'),
variant: 'destructive'
});
return;
}
if (isDeleted || isExpired) {
toast({
title: t('common:resume_plan_expired_title'),
description: t('common:resume_plan_expired_desc'),
variant: 'destructive'
});
return;
}
const payMethod =
subscription.PayMethod === 'balance' || subscription.PayMethod === 'stripe'
? subscription.PayMethod
: 'stripe';
const payMethod =
subscription.PayMethod === 'balance' || subscription.PayMethod === 'stripe'
? subscription.PayMethod
: 'stripe';
resumePlanMutation.mutate({
workspace: subscription.Workspace,
regionDomain: subscription.RegionDomain,
planName: subscription.PlanName,
payMethod,
operator: 'resumed'
});
}}
>
<Sparkles />
<span>{t('common:renew_plan')}</span>
</Button>
)}
resumePlanMutation.mutate({
workspace: subscription.Workspace,
regionDomain: subscription.RegionDomain,
planName: subscription.PlanName,
payMethod,
operator: 'resumed'
});
}}
>
<Sparkles />
<span>{t('common:renew_plan')}</span>
</Button>
)}
{/* Keep UpgradePlanDialog mounted even when cancelled, so message-driven open works */}
{children?.({
trigger: isCancelled ? (
<span className="hidden" />
) : (
<Button size="lg">
<Sparkles />
<span>{inDebt ? t('common:renew') : t('common:upgrade_plan')}</span>
</Button>
)
})}
</div>
{/* Keep UpgradePlanDialog mounted even when cancelled, so message-driven open works */}
{children?.({
trigger: isCancelled ? (
<span className="hidden" />
) : (
<Button size="lg" disabled={!canManagePayment}>
<Sparkles />
<span>{inDebt ? t('common:renew') : t('common:upgrade_plan')}</span>
</Button>
)
})}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('common:can_not_manage_payments')}</p>
</TooltipContent>
</Tooltip>
</div>
<Separator className="border-slate-200" />
@@ -17,6 +17,9 @@ export const OperatorSchema = z.enum([
]);
export type Operator = z.infer<typeof OperatorSchema>;
export const WorkspaceRoleSchema = z.enum(['MANAGER', 'DEVELOPER', 'OWNER']);
export type WorkspaceRole = z.infer<typeof WorkspaceRoleSchema>;
// Stripe 信息
export const StripeInfoSchema = z.object({
subscriptionId: z.string(),
@@ -95,7 +98,8 @@ export const WorkspaceSubscriptionSchema = z.object({
ExpireAt: z.string().nullable(),
Traffic: z.array(z.any()).nullable(),
type: SubscriptionTypeSchema,
InvoiceInfo: InvoiceInfoSchema.optional()
InvoiceInfo: InvoiceInfoSchema.optional(),
role: WorkspaceRoleSchema
});
export type WorkspaceSubscription = z.infer<typeof WorkspaceSubscriptionSchema>;