mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
feat(ui): unify duplicate-email account surfaces
This commit is contained in:
@@ -2,39 +2,25 @@
|
||||
* Account Card Component for Flow Visualization
|
||||
*/
|
||||
|
||||
import {
|
||||
cn,
|
||||
formatQuotaPercent,
|
||||
getCodexQuotaBreakdown,
|
||||
getQuotaFailureInfo,
|
||||
getProviderMinQuota,
|
||||
getProviderResetTime,
|
||||
isClaudeQuotaResult,
|
||||
isCodexQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import { formatAccountVariantLabel } from '@/lib/account-identity';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import {
|
||||
GripVertical,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
KeyRound,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats';
|
||||
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { formatQuotaPercent, getProviderMinQuota, getQuotaFailureInfo, cn } from '@/lib/utils';
|
||||
import { GripVertical, Loader2, Pause, Play } from 'lucide-react';
|
||||
import {
|
||||
useAccountQuota,
|
||||
useAccountQuotas,
|
||||
QUOTA_SUPPORTED_PROVIDERS,
|
||||
} from '@/hooks/use-cliproxy-stats';
|
||||
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||
|
||||
import type { AccountData, DragOffset } from './types';
|
||||
import { cleanEmail } from './utils';
|
||||
import { AccountCardStats } from './account-card-stats';
|
||||
import { cleanEmail } from './utils';
|
||||
|
||||
type Zone = 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
const QUOTA_PROVIDER_ALIASES = [
|
||||
'antigravity',
|
||||
'anthropic',
|
||||
@@ -57,7 +43,7 @@ interface AccountCardProps {
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerMove: (e: React.PointerEvent) => void;
|
||||
onPointerUp: () => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
onPauseToggle?: (accountIds: string[], paused: boolean) => void;
|
||||
isPausingAccount?: boolean;
|
||||
}
|
||||
|
||||
@@ -88,6 +74,40 @@ function getBorderColorStyle(zone: Zone, color: string): React.CSSProperties {
|
||||
}
|
||||
}
|
||||
|
||||
function getCompactQuotaColor(percentage: number) {
|
||||
if (percentage > 50) return 'bg-emerald-500';
|
||||
if (percentage > 20) return 'bg-amber-500';
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
function getVariantMarkerLabel(audience: string, fallbackLabel?: string | null) {
|
||||
if (audience === 'business') return 'Biz';
|
||||
if (audience === 'personal') return 'Pers';
|
||||
|
||||
const normalizedFallback = fallbackLabel?.trim();
|
||||
return normalizedFallback?.[0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
|
||||
function getGroupedVariantSummaryLabel(
|
||||
variants: Array<{ audience: string; audienceLabel?: string | null; detailLabel?: string | null }>
|
||||
) {
|
||||
const audiences = new Set(variants.map((variant) => variant.audience));
|
||||
|
||||
if (audiences.size === 2 && audiences.has('business') && audiences.has('personal')) {
|
||||
return 'B|P';
|
||||
}
|
||||
|
||||
if (variants.length === 1) {
|
||||
const [variant] = variants;
|
||||
return getVariantMarkerLabel(
|
||||
variant.audience,
|
||||
variant.audienceLabel ?? variant.detailLabel ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AccountCard({
|
||||
account,
|
||||
zone,
|
||||
@@ -109,87 +129,155 @@ export function AccountCard({
|
||||
const borderSide = BORDER_SIDE_MAP[zone];
|
||||
const borderColor = getBorderColorStyle(zone, account.color);
|
||||
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
||||
|
||||
// Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
|
||||
const normalizedProvider = account.provider.toLowerCase();
|
||||
const isCliproxyProvider =
|
||||
const showQuota =
|
||||
QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) ||
|
||||
QUOTA_PROVIDER_ALIASES.includes(normalizedProvider);
|
||||
const isCodexProvider = normalizedProvider === 'codex';
|
||||
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
|
||||
const hasGroupedVariants = (account.variants?.length ?? 0) > 1;
|
||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||
normalizedProvider,
|
||||
account.id,
|
||||
isCliproxyProvider
|
||||
showQuota && !hasGroupedVariants
|
||||
);
|
||||
const variantQuotaQueries = useAccountQuotas(
|
||||
(account.variants ?? []).map((variant) => ({
|
||||
provider: account.provider,
|
||||
accountId: variant.id,
|
||||
})),
|
||||
showQuota && hasGroupedVariants
|
||||
);
|
||||
const groupedHeaderVariants = hasGroupedVariants
|
||||
? Array.from(
|
||||
new Map(
|
||||
(account.variants ?? [])
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const order = { business: 0, personal: 1, unknown: 2 } as const;
|
||||
return order[left.audience] - order[right.audience];
|
||||
})
|
||||
.map((variant) => [variant.audienceLabel ?? variant.detailLabel ?? variant.id, variant])
|
||||
).values()
|
||||
)
|
||||
: [];
|
||||
const groupedVariantSummaryLabel = getGroupedVariantSummaryLabel(groupedHeaderVariants);
|
||||
|
||||
// Use shared helper for provider-specific minimum quota
|
||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
||||
const resetTime = getProviderResetTime(account.provider, quota);
|
||||
const codexBreakdown =
|
||||
isCodexProvider && quota && isCodexQuotaResult(quota)
|
||||
? getCodexQuotaBreakdown(quota.windows)
|
||||
const compactMetaBadges = hasGroupedVariants ? (
|
||||
<>
|
||||
<div
|
||||
className="inline-flex shrink-0 items-center overflow-hidden rounded-md border border-border/60 bg-muted/60 shadow-sm shadow-black/5 dark:bg-zinc-900/80"
|
||||
title={groupedHeaderVariants
|
||||
.map((variant) => variant.audienceLabel ?? variant.detailLabel ?? 'Variant')
|
||||
.join(' • ')}
|
||||
>
|
||||
{groupedVariantSummaryLabel ? (
|
||||
<span className="inline-flex min-w-[2.2rem] items-center justify-center px-1.5 py-1 text-[9px] font-semibold leading-none text-foreground/80">
|
||||
{groupedVariantSummaryLabel}
|
||||
</span>
|
||||
) : (
|
||||
groupedHeaderVariants.map((variant, index) => (
|
||||
<span
|
||||
key={variant.id}
|
||||
className={cn(
|
||||
'inline-flex min-w-[1.9rem] items-center justify-center px-1.5 py-1 text-[9px] font-semibold leading-none',
|
||||
index > 0 && 'border-l border-border/50',
|
||||
variant.audience === 'business'
|
||||
? 'bg-sky-500/12 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300'
|
||||
: variant.audience === 'personal'
|
||||
? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{getVariantMarkerLabel(
|
||||
variant.audience,
|
||||
variant.audienceLabel ?? variant.detailLabel ?? null
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{account.paused && (
|
||||
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
const groupedQuotaRows =
|
||||
hasGroupedVariants && showQuota
|
||||
? (account.variants ?? []).map((variant, index) => {
|
||||
const quotaQuery = variantQuotaQueries[index];
|
||||
const minQuota = getProviderMinQuota(account.provider, quotaQuery?.data);
|
||||
const quotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
||||
const quotaValue = quotaLabel !== null ? Number(quotaLabel) : null;
|
||||
const failureInfo = getQuotaFailureInfo(quotaQuery?.data);
|
||||
const label = variant.audienceLabel ?? variant.detailLabel ?? cleanEmail(variant.email);
|
||||
|
||||
return (
|
||||
<div key={variant.id} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-2 text-[8px]">
|
||||
<span className="text-muted-foreground/80 truncate">{label}</span>
|
||||
<span className="font-mono text-foreground/80 shrink-0">
|
||||
{quotaQuery?.isLoading
|
||||
? t('accountCard.quotaLoading')
|
||||
: quotaValue !== null
|
||||
? `${quotaLabel}%`
|
||||
: failureInfo?.label || t('accountCard.quotaUnavailable')}
|
||||
</span>
|
||||
</div>
|
||||
{quotaValue !== null && (
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
getCompactQuotaColor(quotaValue)
|
||||
)}
|
||||
style={{ width: `${quotaValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null;
|
||||
const codexQuotaRows = [
|
||||
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
|
||||
{ label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
|
||||
].filter((row): row is { label: string; value: number } => row.value !== null);
|
||||
const claudeQuotaRows =
|
||||
isClaudeProvider && quota && isClaudeQuotaResult(quota)
|
||||
? [
|
||||
{
|
||||
label: '5h',
|
||||
value:
|
||||
quota.coreUsage?.fiveHour?.remainingPercent ??
|
||||
quota.windows.find((window) => window.rateLimitType === 'five_hour')
|
||||
?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
{
|
||||
label: 'Wk',
|
||||
value:
|
||||
quota.coreUsage?.weekly?.remainingPercent ??
|
||||
quota.windows.find((window) =>
|
||||
[
|
||||
'seven_day',
|
||||
'seven_day_opus',
|
||||
'seven_day_sonnet',
|
||||
'seven_day_oauth_apps',
|
||||
'seven_day_cowork',
|
||||
].includes(window.rateLimitType)
|
||||
)?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
].filter((row): row is { label: string; value: number } => row.value !== null)
|
||||
: [];
|
||||
const compactQuotaRows = isCodexProvider
|
||||
? codexQuotaRows
|
||||
: isClaudeProvider
|
||||
? claudeQuotaRows
|
||||
: [];
|
||||
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
||||
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
|
||||
const failureInfo = getQuotaFailureInfo(quota);
|
||||
const FailureIcon =
|
||||
failureInfo?.label === 'Reauth'
|
||||
? KeyRound
|
||||
: failureInfo?.tone === 'warning'
|
||||
? AlertTriangle
|
||||
: AlertCircle;
|
||||
const failureTextClass =
|
||||
failureInfo?.tone === 'warning'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: failureInfo?.tone === 'destructive'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground/70';
|
||||
|
||||
// Tier badge (AGY only) - show P for Pro, U for Ultra
|
||||
const showTierBadge =
|
||||
account.provider === 'agy' &&
|
||||
account.tier &&
|
||||
account.tier !== 'unknown' &&
|
||||
account.tier !== 'free';
|
||||
const variantLabel = formatAccountVariantLabel(account.id, account.email);
|
||||
const headerEnd = (
|
||||
<>
|
||||
{onPauseToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 transition-all rounded-full',
|
||||
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPauseToggle(account.memberIds ?? [account.id], !account.paused);
|
||||
}}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{isPausingAccount ? (
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
) : account.paused ? (
|
||||
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -216,176 +304,35 @@ export function AccountCard({
|
||||
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
|
||||
}}
|
||||
>
|
||||
{/* Header row: Email + Tier | Pause button | Drag handle */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{/* Email with tier badge inline */}
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold text-foreground tracking-tight truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{cleanEmail(account.email)}
|
||||
</span>
|
||||
{variantLabel && (
|
||||
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-muted text-muted-foreground">
|
||||
{variantLabel}
|
||||
</span>
|
||||
)}
|
||||
{showTierBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0',
|
||||
account.tier === 'ultra'
|
||||
? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
|
||||
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
|
||||
)}
|
||||
>
|
||||
{account.tier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pause/Resume button */}
|
||||
{onPauseToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
'transition-all rounded-full',
|
||||
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPauseToggle(account.id, !account.paused);
|
||||
}}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{isPausingAccount ? (
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
) : account.paused ? (
|
||||
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Drag handle */}
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
|
||||
</div>
|
||||
|
||||
<AccountCardStats
|
||||
success={account.successCount}
|
||||
failure={account.failureCount}
|
||||
showDetails={showDetails}
|
||||
<AccountSurfaceCard
|
||||
mode="compact"
|
||||
provider={account.provider}
|
||||
accountId={account.id}
|
||||
email={account.email}
|
||||
displayEmail={cleanEmail(account.email)}
|
||||
tokenFile={account.tokenFile}
|
||||
tier={account.tier}
|
||||
isDefault={account.isDefault}
|
||||
paused={account.paused}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={showQuota && !hasGroupedVariants}
|
||||
quota={quota}
|
||||
quotaLoading={quotaLoading}
|
||||
runtimeLastUsed={account.lastUsedAt}
|
||||
headerEnd={headerEnd}
|
||||
compactMetaBadges={compactMetaBadges}
|
||||
footerSlot={
|
||||
<>
|
||||
<AccountCardStats
|
||||
success={account.successCount}
|
||||
failure={account.failureCount}
|
||||
showDetails={showDetails}
|
||||
/>
|
||||
{groupedQuotaRows && <div className="mt-2 px-0.5 space-y-1">{groupedQuotaRows}</div>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{/* Quota bar for CLIProxy accounts */}
|
||||
{isCliproxyProvider && (
|
||||
<div className="mt-2 px-0.5">
|
||||
{quotaLoading ? (
|
||||
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
<span>{t('accountCard.quotaLoading')}</span>
|
||||
</div>
|
||||
) : minQuotaValue !== null ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="space-y-0.5 cursor-help">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
||||
{t('accountCard.quota')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono font-bold',
|
||||
minQuotaValue > 50
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: minQuotaValue > 20
|
||||
? 'text-amber-500'
|
||||
: 'text-red-500'
|
||||
)}
|
||||
>
|
||||
{minQuotaLabel}%
|
||||
</span>
|
||||
</div>
|
||||
{compactQuotaRows.length > 0 && (
|
||||
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
|
||||
{compactQuotaRows.map((row) => (
|
||||
<span key={row.label}>
|
||||
{row.label} {row.value}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
minQuotaValue > 50
|
||||
? 'bg-emerald-500'
|
||||
: minQuotaValue > 20
|
||||
? 'bg-amber-500'
|
||||
: 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${minQuotaValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : quota?.success ? (
|
||||
<div className="text-[8px] text-muted-foreground/60">
|
||||
{t('accountCard.quotaUnavailable')}
|
||||
</div>
|
||||
) : failureInfo ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={cn('flex items-center gap-1 text-[8px]', failureTextClass)}>
|
||||
<FailureIcon className="w-2.5 h-2.5" />
|
||||
<span>{failureInfo.label}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[220px]">
|
||||
<div className="space-y-1 text-xs">
|
||||
<p>{failureInfo.summary}</p>
|
||||
{failureInfo.actionHint && (
|
||||
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
|
||||
)}
|
||||
{failureInfo.technicalDetail && (
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
{failureInfo.technicalDetail}
|
||||
</p>
|
||||
)}
|
||||
{failureInfo.rawDetail && (
|
||||
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
|
||||
{failureInfo.rawDetail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Type definitions for Account Flow Visualization
|
||||
*/
|
||||
|
||||
import type { AccountVisualVariant } from '@/lib/account-visual-groups';
|
||||
|
||||
/** Account tier for subscription level */
|
||||
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
|
||||
@@ -14,7 +16,9 @@ export interface DragOffset {
|
||||
export interface AccountData {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenFile?: string;
|
||||
provider: string;
|
||||
isDefault?: boolean;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
lastUsedAt?: string;
|
||||
@@ -22,6 +26,10 @@ export interface AccountData {
|
||||
paused?: boolean;
|
||||
/** Account tier (Antigravity only) */
|
||||
tier?: AccountTier;
|
||||
/** Raw member IDs when one visual card represents multiple underlying auth records */
|
||||
memberIds?: string[];
|
||||
/** Raw variant details shown inside grouped visual cards */
|
||||
variants?: AccountVisualVariant[];
|
||||
}
|
||||
|
||||
export interface ProviderData {
|
||||
@@ -34,7 +42,7 @@ export interface ProviderData {
|
||||
export interface AccountFlowVizProps {
|
||||
providerData: ProviderData;
|
||||
onBack?: () => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
onPauseToggle?: (accountIds: string[], paused: boolean) => void;
|
||||
isPausingAccount?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
cn,
|
||||
formatQuotaPercent,
|
||||
getCodexQuotaBreakdown,
|
||||
getProviderMinQuota,
|
||||
getProviderResetTime,
|
||||
getQuotaFailureInfo,
|
||||
isClaudeQuotaResult,
|
||||
isCodexQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||
import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
HelpCircle,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type AccountSurfaceMode = 'compact' | 'detailed';
|
||||
|
||||
interface AccountQuotaPanelProps {
|
||||
provider: string;
|
||||
quota?: UnifiedQuotaResult;
|
||||
quotaLoading?: boolean;
|
||||
runtimeLastUsed?: string;
|
||||
mode: AccountSurfaceMode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getQuotaColor(percentage: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, percentage));
|
||||
if (clamped <= 20) return 'bg-destructive';
|
||||
if (clamped <= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const diff = Date.now() - date.getTime();
|
||||
if (diff < 0) return 'just now';
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return 'just now';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
|
||||
if (!lastUsedAt) return false;
|
||||
|
||||
try {
|
||||
return Date.now() - new Date(lastUsedAt).getTime() < 60 * 60 * 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AccountQuotaPanel({
|
||||
provider,
|
||||
quota,
|
||||
quotaLoading,
|
||||
runtimeLastUsed,
|
||||
mode,
|
||||
className,
|
||||
}: AccountQuotaPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
const isCodexProvider = normalizedProvider === 'codex';
|
||||
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
|
||||
const minQuota = getProviderMinQuota(provider, quota);
|
||||
const resetTime = getProviderResetTime(provider, quota);
|
||||
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
||||
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
|
||||
const failureInfo = getQuotaFailureInfo(quota);
|
||||
const FailureIcon =
|
||||
failureInfo?.label === 'Reauth'
|
||||
? KeyRound
|
||||
: failureInfo?.tone === 'warning'
|
||||
? AlertTriangle
|
||||
: AlertCircle;
|
||||
|
||||
const codexBreakdown =
|
||||
isCodexProvider && quota && isCodexQuotaResult(quota)
|
||||
? getCodexQuotaBreakdown(quota.windows)
|
||||
: null;
|
||||
const compactQuotaRows = isCodexProvider
|
||||
? [
|
||||
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
|
||||
{
|
||||
label: mode === 'compact' ? 'Wk' : 'Weekly',
|
||||
value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null,
|
||||
},
|
||||
]
|
||||
: isClaudeProvider && quota && isClaudeQuotaResult(quota)
|
||||
? [
|
||||
{
|
||||
label: '5h',
|
||||
value:
|
||||
quota.coreUsage?.fiveHour?.remainingPercent ??
|
||||
quota.windows.find((window) => window.rateLimitType === 'five_hour')
|
||||
?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
{
|
||||
label: mode === 'compact' ? 'Wk' : 'Weekly',
|
||||
value:
|
||||
quota.coreUsage?.weekly?.remainingPercent ??
|
||||
quota.windows.find((window) =>
|
||||
[
|
||||
'seven_day',
|
||||
'seven_day_opus',
|
||||
'seven_day_sonnet',
|
||||
'seven_day_oauth_apps',
|
||||
'seven_day_cowork',
|
||||
].includes(window.rateLimitType)
|
||||
)?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const quotaRows = compactQuotaRows.filter(
|
||||
(row): row is { label: string; value: number } => row.value !== null
|
||||
);
|
||||
|
||||
if (quotaLoading) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1.5 text-xs text-muted-foreground', className)}>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>{mode === 'compact' ? t('accountCard.quotaLoading') : 'Loading quota...'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (minQuotaValue !== null) {
|
||||
return (
|
||||
<div className={cn(mode === 'compact' ? 'px-0.5' : '', className)}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{mode === 'compact' ? (
|
||||
<div className="space-y-0.5 cursor-help">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
||||
{t('accountCard.quota')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono font-bold',
|
||||
minQuotaValue > 50
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: minQuotaValue > 20
|
||||
? 'text-amber-500'
|
||||
: 'text-red-500'
|
||||
)}
|
||||
>
|
||||
{minQuotaLabel}%
|
||||
</span>
|
||||
</div>
|
||||
{quotaRows.length > 0 && (
|
||||
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
|
||||
{quotaRows.map((row) => (
|
||||
<span key={row.label}>
|
||||
{row.label} {row.value}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
minQuotaValue > 50
|
||||
? 'bg-emerald-500'
|
||||
: minQuotaValue > 20
|
||||
? 'bg-amber-500'
|
||||
: 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${minQuotaValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 cursor-help">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{isRecentlyUsed(runtimeLastUsed) ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
Active · {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : runtimeLastUsed ? (
|
||||
<>
|
||||
<Clock className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Last used {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HelpCircle className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Not used yet</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{quotaRows.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{quotaRows.map((row) => (
|
||||
<div key={row.label} className="flex items-center gap-2">
|
||||
<span className="w-10 text-[10px] text-muted-foreground">
|
||||
{row.label}
|
||||
</span>
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, row.value))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(row.value)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">{row.value}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, minQuotaValue))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(minQuotaValue)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">{minQuotaLabel}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-xs">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (quota?.success) {
|
||||
return mode === 'compact' ? (
|
||||
<div className={cn('text-[8px] text-muted-foreground/60', className)}>
|
||||
{t('accountCard.quotaUnavailable')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={className}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
|
||||
>
|
||||
<HelpCircle className="w-3 h-3" />
|
||||
{t('accountCard.quotaUnavailable')}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!failureInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failureClass =
|
||||
failureInfo.tone === 'warning'
|
||||
? 'text-amber-600 dark:text-amber-400 border-amber-500/50'
|
||||
: failureInfo.tone === 'destructive'
|
||||
? 'text-destructive border-destructive/50'
|
||||
: 'text-muted-foreground/70 border-muted-foreground/50';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{mode === 'compact' ? (
|
||||
<div className={cn('flex items-center gap-1 text-[8px]', failureClass, className)}>
|
||||
<FailureIcon className="w-2.5 h-2.5" />
|
||||
<span>{failureInfo.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={className}>
|
||||
<Badge variant="outline" className={cn('text-[10px] h-5 px-2 gap-1', failureClass)}>
|
||||
<FailureIcon className="w-3 h-3" />
|
||||
{failureInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-[260px]">
|
||||
<div className="space-y-1 text-xs">
|
||||
<p>{failureInfo.summary}</p>
|
||||
{failureInfo.actionHint && (
|
||||
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
|
||||
)}
|
||||
{failureInfo.technicalDetail && (
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
{failureInfo.technicalDetail}
|
||||
</p>
|
||||
)}
|
||||
{failureInfo.rawDetail && (
|
||||
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
|
||||
{failureInfo.rawDetail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
|
||||
import { getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Pause, Star, User } from 'lucide-react';
|
||||
|
||||
import { AccountQuotaPanel } from './account-quota-panel';
|
||||
|
||||
type AccountSurfaceMode = 'compact' | 'detailed';
|
||||
type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
|
||||
interface AccountSurfaceCardProps {
|
||||
mode: AccountSurfaceMode;
|
||||
provider: string;
|
||||
accountId: string;
|
||||
email?: string;
|
||||
displayEmail?: string;
|
||||
tokenFile?: string;
|
||||
tier?: AccountTier;
|
||||
isDefault?: boolean;
|
||||
paused?: boolean;
|
||||
privacyMode?: boolean;
|
||||
showQuota?: boolean;
|
||||
quota?: UnifiedQuotaResult;
|
||||
quotaLoading?: boolean;
|
||||
runtimeLastUsed?: string;
|
||||
beforeIdentity?: ReactNode;
|
||||
headerEnd?: ReactNode;
|
||||
compactMetaBadges?: ReactNode;
|
||||
bodySlot?: ReactNode;
|
||||
footerSlot?: ReactNode;
|
||||
quotaInsetClassName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getAudienceBadgeClass(audience: 'business' | 'personal' | 'unknown') {
|
||||
if (audience === 'business') {
|
||||
return 'bg-sky-500/12 text-sky-700 dark:text-sky-300';
|
||||
}
|
||||
|
||||
if (audience === 'personal') {
|
||||
return 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300';
|
||||
}
|
||||
|
||||
return 'bg-muted text-muted-foreground';
|
||||
}
|
||||
|
||||
function getTierBadgeClass(tier: AccountTier | undefined) {
|
||||
return tier === 'ultra'
|
||||
? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
|
||||
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400';
|
||||
}
|
||||
|
||||
function getCompactAudienceBadgeLabel(audience: 'business' | 'personal' | 'unknown') {
|
||||
if (audience === 'business') return 'Biz';
|
||||
if (audience === 'personal') return 'Pers';
|
||||
return '?';
|
||||
}
|
||||
|
||||
export function AccountSurfaceCard({
|
||||
mode,
|
||||
provider,
|
||||
accountId,
|
||||
email,
|
||||
displayEmail,
|
||||
tokenFile,
|
||||
tier,
|
||||
isDefault,
|
||||
paused,
|
||||
privacyMode,
|
||||
showQuota,
|
||||
quota,
|
||||
quotaLoading,
|
||||
runtimeLastUsed,
|
||||
beforeIdentity,
|
||||
headerEnd,
|
||||
compactMetaBadges,
|
||||
bodySlot,
|
||||
footerSlot,
|
||||
quotaInsetClassName,
|
||||
className,
|
||||
}: AccountSurfaceCardProps) {
|
||||
const identity = getAccountIdentityPresentation(accountId, email, tokenFile);
|
||||
const title = displayEmail || identity.email || accountId;
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
const showTierBadge =
|
||||
(normalizedProvider === 'agy' || normalizedProvider === 'antigravity') &&
|
||||
tier &&
|
||||
tier !== 'unknown' &&
|
||||
tier !== 'free';
|
||||
const isCompact = mode === 'compact';
|
||||
const defaultCompactMetaBadges = (
|
||||
<>
|
||||
{showTierBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
|
||||
getTierBadgeClass(tier)
|
||||
)}
|
||||
>
|
||||
{tier}
|
||||
</span>
|
||||
)}
|
||||
{identity.audienceLabel && (
|
||||
<span
|
||||
title={identity.audienceLabel}
|
||||
className={cn(
|
||||
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
|
||||
identity.audience === 'business'
|
||||
? 'bg-sky-500/15 text-sky-700 dark:bg-sky-500/25 dark:text-sky-300'
|
||||
: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/25 dark:text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{getCompactAudienceBadgeLabel(identity.audience)}
|
||||
</span>
|
||||
)}
|
||||
{paused && (
|
||||
<span className="text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', className)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className={cn('flex min-w-0 flex-1', isCompact ? 'gap-2' : 'gap-3')}>
|
||||
{beforeIdentity}
|
||||
{!isCompact && (
|
||||
<div className="relative shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full',
|
||||
isDefault ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
{showTierBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded ring-1 ring-background',
|
||||
tier === 'ultra'
|
||||
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
|
||||
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
|
||||
)}
|
||||
>
|
||||
{tier === 'ultra' ? 'U' : 'P'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn('flex items-center min-w-0', isCompact ? 'gap-1.5' : 'gap-2 flex-wrap')}
|
||||
>
|
||||
<span
|
||||
title={title}
|
||||
className={cn(
|
||||
isCompact
|
||||
? 'flex-1 min-w-0 text-xs font-semibold tracking-tight truncate leading-none'
|
||||
: 'font-medium text-sm truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)}
|
||||
{!isCompact && identity.audienceLabel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] h-4 px-1.5 border-transparent',
|
||||
getAudienceBadgeClass(identity.audience)
|
||||
)}
|
||||
>
|
||||
{identity.audienceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{!isCompact && identity.detailLabel && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||
{identity.detailLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{!isCompact && isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
{!isCompact && paused && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
|
||||
>
|
||||
<Pause className="w-2 h-2 mr-0.5" />
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bodySlot && <div className="mt-1">{bodySlot}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{headerEnd && (
|
||||
<div className={cn('flex items-center shrink-0', isCompact ? 'gap-0.5' : 'gap-1')}>
|
||||
{headerEnd}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{footerSlot}
|
||||
|
||||
{showQuota && (
|
||||
<AccountQuotaPanel
|
||||
provider={provider}
|
||||
quota={quota}
|
||||
quotaLoading={quotaLoading}
|
||||
runtimeLastUsed={runtimeLastUsed}
|
||||
mode={mode}
|
||||
className={quotaInsetClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -221,7 +221,9 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
|
||||
{providerAccounts.map((acc) => (
|
||||
<option key={acc.id} value={acc.id}>
|
||||
{privacyMode ? '••••••' : formatAccountDisplayName(acc.id, acc.email)}
|
||||
{privacyMode
|
||||
? '••••••'
|
||||
: formatAccountDisplayName(acc.id, acc.email, acc.tokenFile)}
|
||||
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
* Displays a single OAuth account with actions and quota bar
|
||||
*/
|
||||
|
||||
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -13,88 +12,69 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { getAccountStats } from '@/lib/cliproxy-account-stats';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import {
|
||||
User,
|
||||
Star,
|
||||
MoreHorizontal,
|
||||
Clock,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
FolderCode,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
FolderCode,
|
||||
Check,
|
||||
KeyRound,
|
||||
Star,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
cn,
|
||||
formatQuotaPercent,
|
||||
getCodexQuotaBreakdown,
|
||||
getQuotaFailureInfo,
|
||||
getProviderMinQuota,
|
||||
getProviderResetTime,
|
||||
isClaudeQuotaResult,
|
||||
isCodexQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import { formatAccountVariantLabel } from '@/lib/account-identity';
|
||||
import { getAccountStats } from '@/lib/cliproxy-account-stats';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { AccountItemProps } from './types';
|
||||
|
||||
/**
|
||||
* Get color class based on quota percentage
|
||||
*/
|
||||
function getQuotaColor(percentage: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, percentage));
|
||||
if (clamped <= 20) return 'bg-destructive';
|
||||
if (clamped <= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "5m ago", "2h ago")
|
||||
*/
|
||||
function formatRelativeTime(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
if (diff < 0) return 'just now';
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return 'just now';
|
||||
} catch {
|
||||
return '';
|
||||
function renderProjectId(projectId: string | undefined, privacyMode: boolean | undefined) {
|
||||
if (projectId) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<FolderCode className="w-3 h-3" aria-hidden="true" />
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono max-w-[180px] truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
title={projectId}
|
||||
>
|
||||
{projectId}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">GCP Project ID (read-only)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account was used recently (within last hour = token likely refreshed)
|
||||
*/
|
||||
function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
|
||||
if (!lastUsedAt) return false;
|
||||
try {
|
||||
const lastUsed = new Date(lastUsedAt);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - lastUsed.getTime();
|
||||
return diff < 60 * 60 * 1000; // Within last hour
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
|
||||
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
|
||||
<span>Project ID: N/A</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[250px]">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium text-amber-600">Missing Project ID</p>
|
||||
<p>This may cause errors. Remove the account and re-add it to fetch the project ID.</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountItem({
|
||||
@@ -110,396 +90,119 @@ export function AccountItem({
|
||||
selected,
|
||||
onSelectChange,
|
||||
}: AccountItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const normalizedProvider = account.provider.toLowerCase();
|
||||
const isCodexProvider = normalizedProvider === 'codex';
|
||||
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
|
||||
|
||||
// Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
|
||||
const { data: stats } = useCliproxyStats(showQuota);
|
||||
|
||||
// Fetch quota for all provider accounts
|
||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||
normalizedProvider,
|
||||
account.id,
|
||||
showQuota
|
||||
);
|
||||
|
||||
// Get last used time from runtime stats (more accurate than file)
|
||||
const runtimeLastUsed = getAccountStats(stats, account)?.lastUsedAt;
|
||||
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
|
||||
|
||||
// Use shared utility functions for provider-specific quota handling
|
||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
||||
const nextReset = getProviderResetTime(account.provider, quota);
|
||||
const codexBreakdown =
|
||||
isCodexProvider && quota && isCodexQuotaResult(quota)
|
||||
? getCodexQuotaBreakdown(quota.windows)
|
||||
: null;
|
||||
const codexQuotaRows = [
|
||||
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
|
||||
{ label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
|
||||
].filter((row): row is { label: string; value: number } => row.value !== null);
|
||||
const claudeQuotaRows =
|
||||
isClaudeProvider && quota && isClaudeQuotaResult(quota)
|
||||
? [
|
||||
{
|
||||
label: '5h',
|
||||
value:
|
||||
quota.coreUsage?.fiveHour?.remainingPercent ??
|
||||
quota.windows.find((window) => window.rateLimitType === 'five_hour')
|
||||
?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
{
|
||||
label: 'Weekly',
|
||||
value:
|
||||
quota.coreUsage?.weekly?.remainingPercent ??
|
||||
quota.windows.find((window) =>
|
||||
[
|
||||
'seven_day',
|
||||
'seven_day_opus',
|
||||
'seven_day_sonnet',
|
||||
'seven_day_oauth_apps',
|
||||
'seven_day_cowork',
|
||||
].includes(window.rateLimitType)
|
||||
)?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
].filter((row): row is { label: string; value: number } => row.value !== null)
|
||||
: [];
|
||||
const dualWindowQuotaRows = isCodexProvider
|
||||
? codexQuotaRows
|
||||
: isClaudeProvider
|
||||
? claudeQuotaRows
|
||||
: [];
|
||||
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
||||
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
|
||||
const failureInfo = getQuotaFailureInfo(quota);
|
||||
const FailureIcon =
|
||||
failureInfo?.label === 'Reauth'
|
||||
? KeyRound
|
||||
: failureInfo?.tone === 'warning'
|
||||
? AlertTriangle
|
||||
: AlertCircle;
|
||||
const failureBadgeClass =
|
||||
failureInfo?.tone === 'warning'
|
||||
? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
|
||||
: failureInfo?.tone === 'destructive'
|
||||
? 'border-destructive/50 text-destructive'
|
||||
: 'border-muted-foreground/50 text-muted-foreground';
|
||||
const variantLabel = formatAccountVariantLabel(account.id, account.email);
|
||||
const beforeIdentity =
|
||||
selectable || onPauseToggle ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{selectable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectChange?.(!selected)}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
|
||||
selected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/30 hover:border-primary/50'
|
||||
)}
|
||||
aria-label={selected ? 'Deselect account' : 'Select account'}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
{onPauseToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => onPauseToggle(!account.paused)}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{isPausingAccount ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : account.paused ? (
|
||||
<Play className="w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{account.paused ? 'Resume account' : 'Pause account'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const headerEnd = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!account.isDefault && (
|
||||
<DropdownMenuItem onClick={onSetDefault}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 p-3 rounded-lg border transition-colors overflow-hidden',
|
||||
'rounded-lg border p-3 transition-colors overflow-hidden',
|
||||
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30',
|
||||
account.paused && 'opacity-75',
|
||||
selected && 'ring-2 ring-primary/50 bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{/* Selection checkbox for bulk actions */}
|
||||
{selectable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectChange?.(!selected)}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
|
||||
selected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/30 hover:border-primary/50'
|
||||
)}
|
||||
aria-label={selected ? 'Deselect account' : 'Select account'}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Pause/Resume toggle button - visible left of avatar */}
|
||||
{onPauseToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => onPauseToggle(!account.paused)}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{isPausingAccount ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : account.paused ? (
|
||||
<Play className="w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{account.paused ? 'Resume account' : 'Pause account'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{/* Avatar with tier badge overlay */}
|
||||
<div className="relative shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full',
|
||||
account.isDefault ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
{/* Tier badge - fixed position on avatar */}
|
||||
{account.tier && account.tier !== 'unknown' && account.tier !== 'free' && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded',
|
||||
'ring-1 ring-background',
|
||||
account.tier === 'ultra'
|
||||
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
|
||||
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
|
||||
)}
|
||||
>
|
||||
{account.tier === 'ultra' ? 'U' : 'P'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn('font-medium text-sm truncate', privacyMode && PRIVACY_BLUR_CLASS)}
|
||||
>
|
||||
{account.email || account.id}
|
||||
</span>
|
||||
{variantLabel && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||
{variantLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{account.isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
{account.paused && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
|
||||
>
|
||||
<Pause className="w-2 h-2 mr-0.5" />
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Project ID for Antigravity accounts - read-only */}
|
||||
{account.provider === 'agy' && (
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{account.projectId ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FolderCode className="w-3 h-3" aria-hidden="true" />
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono max-w-[180px] truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
title={account.projectId}
|
||||
>
|
||||
{account.projectId}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">GCP Project ID (read-only)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
|
||||
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
|
||||
<span>Project ID: N/A</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[250px]">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium text-amber-600">Missing Project ID</p>
|
||||
<p>
|
||||
This may cause errors. Remove the account and re-add it to fetch the
|
||||
project ID.
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{account.lastUsedAt && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!account.isDefault && (
|
||||
<DropdownMenuItem onClick={onSetDefault}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Quota bar - supports all providers with quota API */}
|
||||
{showQuota && (
|
||||
<div className="pl-11">
|
||||
{quotaLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>Loading quota...</span>
|
||||
</div>
|
||||
) : minQuotaValue !== null ? (
|
||||
<div className="space-y-1.5">
|
||||
{/* Status indicator based on runtime usage, not file state */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{wasRecentlyUsed ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
Active · {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : runtimeLastUsed ? (
|
||||
<>
|
||||
<Clock className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Last used {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HelpCircle className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Not used yet</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Quota bar */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{dualWindowQuotaRows.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{dualWindowQuotaRows.map((row) => (
|
||||
<div key={row.label} className="flex items-center gap-2">
|
||||
<span className="w-10 text-[10px] text-muted-foreground">
|
||||
{row.label}
|
||||
</span>
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, row.value))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(row.value)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">
|
||||
{row.value}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, minQuotaValue))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(minQuotaValue)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">
|
||||
{minQuotaLabel}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<QuotaTooltipContent quota={quota} resetTime={nextReset} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : quota?.success ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
|
||||
>
|
||||
<HelpCircle className="w-3 h-3" />
|
||||
{t('accountCard.quotaUnavailable')}
|
||||
</Badge>
|
||||
</div>
|
||||
) : failureInfo ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-[10px] h-5 px-2 gap-1', failureBadgeClass)}
|
||||
>
|
||||
<FailureIcon className="w-3 h-3" />
|
||||
{failureInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[260px]">
|
||||
<div className="space-y-1 text-xs">
|
||||
<p>{failureInfo.summary}</p>
|
||||
{failureInfo.actionHint && (
|
||||
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
|
||||
)}
|
||||
{failureInfo.technicalDetail && (
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
{failureInfo.technicalDetail}
|
||||
</p>
|
||||
)}
|
||||
{failureInfo.rawDetail && (
|
||||
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
|
||||
{failureInfo.rawDetail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<AccountSurfaceCard
|
||||
mode="detailed"
|
||||
provider={account.provider}
|
||||
accountId={account.id}
|
||||
email={account.email}
|
||||
displayEmail={account.email || account.id}
|
||||
tokenFile={account.tokenFile}
|
||||
tier={account.tier}
|
||||
isDefault={account.isDefault}
|
||||
paused={account.paused}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={showQuota}
|
||||
quota={quota}
|
||||
quotaLoading={quotaLoading}
|
||||
runtimeLastUsed={runtimeLastUsed}
|
||||
beforeIdentity={beforeIdentity}
|
||||
headerEnd={headerEnd}
|
||||
bodySlot={
|
||||
account.provider === 'agy' ? renderProjectId(account.projectId, privacyMode) : null
|
||||
}
|
||||
quotaInsetClassName="pl-11"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,7 +113,11 @@ export function ProviderCard({
|
||||
<div
|
||||
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
|
||||
style={{ backgroundColor: acc.color }}
|
||||
title={privacyMode ? '••••••' : formatAccountDisplayName(acc.id, acc.email)}
|
||||
title={
|
||||
privacyMode
|
||||
? '••••••'
|
||||
: formatAccountDisplayName(acc.id, acc.email, acc.tokenFile)
|
||||
}
|
||||
/>
|
||||
{isMissingProjectId && (
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
|
||||
import { getProviderDisplayName } from '@/lib/provider-config';
|
||||
import { getAccountStats } from '@/lib/cliproxy-account-stats';
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
import type { AccountRow, ProviderStats } from './types';
|
||||
import { ACCOUNT_COLORS } from './utils';
|
||||
@@ -74,28 +74,33 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
const providerData = providerMap.get(providerKey);
|
||||
if (!providerData) return;
|
||||
|
||||
status.accounts?.forEach((account: OAuthAccount) => {
|
||||
const realStats = getAccountStats(statsData, account);
|
||||
const success = realStats?.successCount ?? 0;
|
||||
const failure = realStats?.failureCount ?? 0;
|
||||
tSuccess += success;
|
||||
tFailure += failure;
|
||||
providerData.success += success;
|
||||
providerData.failure += failure;
|
||||
const normalizedAccounts = (status.accounts ?? []).map((account: OAuthAccount) => ({
|
||||
...account,
|
||||
provider: account.provider || status.provider,
|
||||
}));
|
||||
|
||||
buildAccountVisualGroups(normalizedAccounts, statsData).forEach((groupedAccount) => {
|
||||
tSuccess += groupedAccount.successCount;
|
||||
tFailure += groupedAccount.failureCount;
|
||||
providerData.success += groupedAccount.successCount;
|
||||
providerData.failure += groupedAccount.failureCount;
|
||||
|
||||
const row: AccountRow = {
|
||||
id: account.id,
|
||||
email: account.email || account.id,
|
||||
id: groupedAccount.id,
|
||||
email: groupedAccount.email,
|
||||
tokenFile: groupedAccount.tokenFile,
|
||||
provider: status.provider,
|
||||
displayName: status.displayName,
|
||||
isDefault: account.isDefault,
|
||||
successCount: success,
|
||||
failureCount: failure,
|
||||
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
|
||||
isDefault: groupedAccount.isDefault,
|
||||
successCount: groupedAccount.successCount,
|
||||
failureCount: groupedAccount.failureCount,
|
||||
lastUsedAt: groupedAccount.lastUsedAt,
|
||||
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
|
||||
projectId: account.projectId,
|
||||
paused: account.paused,
|
||||
tier: account.tier,
|
||||
projectId: groupedAccount.projectId,
|
||||
paused: groupedAccount.paused,
|
||||
tier: groupedAccount.tier,
|
||||
memberIds: groupedAccount.memberIds,
|
||||
variants: groupedAccount.variants,
|
||||
};
|
||||
accountsList.push(row);
|
||||
providerData.accounts.push(row);
|
||||
|
||||
@@ -10,7 +10,12 @@ import { STATUS_COLORS } from '@/lib/utils';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AccountFlowViz } from '@/components/account-flow-viz';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { usePauseAccount, useResumeAccount } from '@/hooks/use-cliproxy';
|
||||
import {
|
||||
useBulkPauseAccounts,
|
||||
useBulkResumeAccounts,
|
||||
usePauseAccount,
|
||||
useResumeAccount,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
|
||||
|
||||
import { useAuthMonitorData } from './hooks';
|
||||
@@ -66,14 +71,37 @@ export function AuthMonitor() {
|
||||
// Account control mutations for flow viz
|
||||
const pauseMutation = usePauseAccount();
|
||||
const resumeMutation = useResumeAccount();
|
||||
const bulkPauseMutation = useBulkPauseAccounts();
|
||||
const bulkResumeMutation = useBulkResumeAccounts();
|
||||
|
||||
// Get selected provider data for detail view
|
||||
const selectedProviderData = effectiveProvider
|
||||
? providerStats.find((ps) => ps.provider === effectiveProvider)
|
||||
: null;
|
||||
|
||||
const handlePauseToggle = (accountId: string, paused: boolean) => {
|
||||
if (!effectiveProvider || pauseMutation.isPending || resumeMutation.isPending) return;
|
||||
const handlePauseToggle = (accountIds: string[], paused: boolean) => {
|
||||
if (
|
||||
!effectiveProvider ||
|
||||
pauseMutation.isPending ||
|
||||
resumeMutation.isPending ||
|
||||
bulkPauseMutation.isPending ||
|
||||
bulkResumeMutation.isPending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (accountIds.length > 1) {
|
||||
if (paused) {
|
||||
bulkPauseMutation.mutate({ provider: effectiveProvider, accountIds });
|
||||
} else {
|
||||
bulkResumeMutation.mutate({ provider: effectiveProvider, accountIds });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const [accountId] = accountIds;
|
||||
if (!accountId) return;
|
||||
|
||||
if (paused) {
|
||||
pauseMutation.mutate({ provider: effectiveProvider, accountId });
|
||||
} else {
|
||||
@@ -165,7 +193,12 @@ export function AuthMonitor() {
|
||||
providerData={selectedProviderData}
|
||||
onBack={() => setSelectedProvider(null)}
|
||||
onPauseToggle={handlePauseToggle}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isPausingAccount={
|
||||
pauseMutation.isPending ||
|
||||
resumeMutation.isPending ||
|
||||
bulkPauseMutation.isPending ||
|
||||
bulkResumeMutation.isPending
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* Type definitions for Auth Monitor components
|
||||
*/
|
||||
|
||||
import type { AccountVisualVariant } from '@/lib/account-visual-groups';
|
||||
|
||||
/** Account tier for subscription level */
|
||||
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
|
||||
export interface AccountRow {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenFile: string;
|
||||
provider: string;
|
||||
displayName: string;
|
||||
isDefault: boolean;
|
||||
@@ -21,6 +24,10 @@ export interface AccountRow {
|
||||
paused?: boolean;
|
||||
/** Account tier (Antigravity only) */
|
||||
tier?: AccountTier;
|
||||
/** Raw member IDs when one visual card represents multiple underlying auth records */
|
||||
memberIds?: string[];
|
||||
/** Raw variant details shown inside grouped visual cards */
|
||||
variants?: AccountVisualVariant[];
|
||||
}
|
||||
|
||||
export interface ProviderStats {
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react';
|
||||
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||
import { getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import type { AccountStepProps } from '../types';
|
||||
@@ -25,29 +26,52 @@ export function AccountStep({
|
||||
|
||||
{/* Scrollable account list with max-height for many accounts */}
|
||||
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
{accounts.map((acc) => (
|
||||
<button
|
||||
key={acc.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(acc)}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{formatAccountDisplayName(acc.id, acc.email)}
|
||||
{accounts.map((acc) => {
|
||||
const identity = getAccountIdentityPresentation(acc.id, acc.email, acc.tokenFile);
|
||||
return (
|
||||
<button
|
||||
key={acc.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(acc)}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{identity.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{identity.audienceLabel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] h-4 px-1.5 border-transparent',
|
||||
identity.audience === 'business'
|
||||
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
|
||||
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{identity.audienceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{identity.detailLabel && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||
{identity.detailLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{acc.isDefault && (
|
||||
<span className="text-xs text-muted-foreground">Default account</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{acc.isDefault && (
|
||||
<div className="text-xs text-muted-foreground">Default account</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||
import { getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -45,6 +46,13 @@ export function VariantStep({
|
||||
selectedProvider === 'agy' && modelName.trim().length > 0
|
||||
? isDeniedAgyModelId(modelName)
|
||||
: false;
|
||||
const selectedAccountIdentity = selectedAccount
|
||||
? getAccountIdentityPresentation(
|
||||
selectedAccount.id,
|
||||
selectedAccount.email,
|
||||
selectedAccount.tokenFile
|
||||
)
|
||||
: null;
|
||||
|
||||
const handleModelSelect = (value: string) => {
|
||||
if (value === CUSTOM_MODEL_VALUE) {
|
||||
@@ -59,14 +67,38 @@ export function VariantStep({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{selectedAccount && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
|
||||
<div className="flex items-start gap-2 p-2 bg-muted/50 rounded-md text-sm">
|
||||
<User className="w-4 h-4" />
|
||||
<span>
|
||||
{t('setupVariant.using')}{' '}
|
||||
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{formatAccountDisplayName(selectedAccount.id, selectedAccount.email)}
|
||||
<div className="space-y-1">
|
||||
<span>
|
||||
{t('setupVariant.using')}{' '}
|
||||
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{selectedAccountIdentity?.email}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{(selectedAccountIdentity?.audienceLabel || selectedAccountIdentity?.detailLabel) && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{selectedAccountIdentity?.audienceLabel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] h-4 px-1.5 border-transparent',
|
||||
selectedAccountIdentity.audience === 'business'
|
||||
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
|
||||
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{selectedAccountIdentity.audienceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedAccountIdentity?.detailLabel && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||
{selectedAccountIdentity.detailLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* React Query hook for CLIProxyAPI stats
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import type {
|
||||
ModelQuota,
|
||||
QuotaResult,
|
||||
@@ -334,6 +334,21 @@ async function fetchGhcpQuotaApi(accountId: string): Promise<GhcpQuotaResult> {
|
||||
// Re-export unified type from utils for consumers
|
||||
export type { UnifiedQuotaResult } from '@/lib/utils';
|
||||
|
||||
function getAccountQuotaQueryOptions(provider: string, accountId: string, enabled = true) {
|
||||
const canonicalProvider = normalizeQuotaProvider(provider);
|
||||
|
||||
return {
|
||||
queryKey: ['account-quota', canonicalProvider ?? provider, accountId],
|
||||
queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId),
|
||||
enabled: enabled && !!canonicalProvider && !!accountId,
|
||||
staleTime: 60000,
|
||||
refetchInterval: 60000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
retry: 1 as const,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota by provider (dispatcher)
|
||||
*/
|
||||
@@ -365,16 +380,16 @@ async function fetchQuotaByProvider(
|
||||
* Supports agy, codex, claude, gemini, and ghcp providers
|
||||
*/
|
||||
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
|
||||
const canonicalProvider = normalizeQuotaProvider(provider);
|
||||
return useQuery(getAccountQuotaQueryOptions(provider, accountId, enabled));
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['account-quota', canonicalProvider ?? provider, accountId],
|
||||
queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId),
|
||||
enabled: enabled && !!canonicalProvider && !!accountId,
|
||||
staleTime: 60000, // Match refetchInterval to prevent early refetching
|
||||
refetchInterval: 60000, // Refresh every 1 minute
|
||||
refetchOnWindowFocus: false, // Don't refetch on tab switch
|
||||
refetchOnMount: false, // Don't refetch on component remount (AuthMonitor re-renders)
|
||||
retry: 1,
|
||||
export function useAccountQuotas(
|
||||
accounts: Array<{ provider: string; accountId: string }>,
|
||||
enabled = true
|
||||
) {
|
||||
return useQueries({
|
||||
queries: accounts.map((account) =>
|
||||
getAccountQuotaQueryOptions(account.provider, account.accountId, enabled)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
+162
-13
@@ -1,3 +1,26 @@
|
||||
const PERSONAL_PLAN_PARTS = new Set(['free', 'plus', 'pro']);
|
||||
const BUSINESS_PLAN_PARTS = new Set(['team']);
|
||||
|
||||
export type AccountAudience = 'business' | 'personal' | 'unknown';
|
||||
|
||||
export interface AccountIdentityPresentation {
|
||||
email: string;
|
||||
audience: AccountAudience;
|
||||
audienceLabel: string | null;
|
||||
detailLabel: string | null;
|
||||
compactDetailLabel: string | null;
|
||||
inlineLabel: string | null;
|
||||
}
|
||||
|
||||
function normalizeVariantTokenPart(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '')
|
||||
.replace(/[^a-z0-9._-]+/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function formatVariantPart(part: string): string {
|
||||
const normalized = part.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
@@ -24,7 +47,12 @@ function formatVariantPart(part: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function extractAccountVariantKey(accountId: string, email?: string): string | null {
|
||||
function extractCanonicalEmailFromAccountId(accountId: string): string | null {
|
||||
const canonical = accountId.split('#')[0]?.trim();
|
||||
return canonical && canonical.includes('@') ? canonical : null;
|
||||
}
|
||||
|
||||
function extractVariantKeyFromAccountId(accountId: string, email?: string): string | null {
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
@@ -33,29 +61,150 @@ export function extractAccountVariantKey(accountId: string, email?: string): str
|
||||
return accountId.startsWith(prefix) ? accountId.slice(prefix.length) : null;
|
||||
}
|
||||
|
||||
export function formatAccountVariantLabel(accountId: string, email?: string): string | null {
|
||||
const variantKey = extractAccountVariantKey(accountId, email);
|
||||
if (!variantKey) {
|
||||
function extractVariantKeyFromTokenFile(tokenFile?: string, email?: string): string | null {
|
||||
if (!tokenFile || !email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = tokenFile.split(/[\\/]/).pop() ?? tokenFile;
|
||||
const baseName = fileName.replace(/\.json$/i, '');
|
||||
if (!baseName.toLowerCase().startsWith('codex-')) {
|
||||
return null;
|
||||
}
|
||||
const firstDashIndex = baseName.indexOf('-');
|
||||
const candidate =
|
||||
firstDashIndex > 0 && !baseName.slice(0, firstDashIndex).includes('@')
|
||||
? baseName.slice(firstDashIndex + 1)
|
||||
: baseName;
|
||||
const emailIndex = candidate.toLowerCase().indexOf(email.toLowerCase());
|
||||
|
||||
if (emailIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const before = normalizeVariantTokenPart(candidate.slice(0, emailIndex));
|
||||
const after = normalizeVariantTokenPart(candidate.slice(emailIndex + email.length));
|
||||
const parts = [before, after].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join('-') : null;
|
||||
}
|
||||
|
||||
function formatWorkspaceLabel(parts: string[]): {
|
||||
detailLabel: string | null;
|
||||
compactDetailLabel: string | null;
|
||||
} {
|
||||
const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part));
|
||||
if (workspaceId) {
|
||||
return {
|
||||
detailLabel: `Workspace ${workspaceId.toLowerCase()}`,
|
||||
compactDetailLabel: workspaceId.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
const extraLabel = parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||
return {
|
||||
detailLabel: extraLabel || 'Team',
|
||||
compactDetailLabel: extraLabel || 'Team',
|
||||
};
|
||||
}
|
||||
|
||||
export function extractAccountVariantKey(
|
||||
accountId: string,
|
||||
email?: string,
|
||||
tokenFile?: string
|
||||
): string | null {
|
||||
const resolvedEmail = email?.trim() || extractCanonicalEmailFromAccountId(accountId) || undefined;
|
||||
return (
|
||||
extractVariantKeyFromTokenFile(tokenFile, resolvedEmail) ??
|
||||
extractVariantKeyFromAccountId(accountId, resolvedEmail)
|
||||
);
|
||||
}
|
||||
|
||||
export function getAccountIdentityPresentation(
|
||||
accountId: string,
|
||||
email?: string,
|
||||
tokenFile?: string
|
||||
): AccountIdentityPresentation {
|
||||
const resolvedEmail = email?.trim() || extractCanonicalEmailFromAccountId(accountId) || accountId;
|
||||
const variantKey = extractAccountVariantKey(accountId, resolvedEmail, tokenFile);
|
||||
if (!variantKey) {
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'unknown',
|
||||
audienceLabel: null,
|
||||
detailLabel: null,
|
||||
compactDetailLabel: null,
|
||||
inlineLabel: null,
|
||||
};
|
||||
}
|
||||
|
||||
const parts = variantKey.split('-').filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'unknown',
|
||||
audienceLabel: null,
|
||||
detailLabel: null,
|
||||
compactDetailLabel: null,
|
||||
inlineLabel: null,
|
||||
};
|
||||
}
|
||||
|
||||
const genericSuffix = parts[parts.length - 1];
|
||||
if (['team', 'free', 'plus', 'pro'].includes(genericSuffix)) {
|
||||
return [formatVariantPart(genericSuffix), ...parts.slice(0, -1).map(formatVariantPart)]
|
||||
const suffix = parts[parts.length - 1]?.toLowerCase();
|
||||
if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) {
|
||||
const workspace = formatWorkspaceLabel(parts.slice(0, -1));
|
||||
const inlineLabel = ['Business', workspace.detailLabel].filter(Boolean).join(' · ');
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'business',
|
||||
audienceLabel: 'Business',
|
||||
detailLabel: workspace.detailLabel,
|
||||
compactDetailLabel: workspace.compactDetailLabel,
|
||||
inlineLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
||||
const detailParts = [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const detailLabel = detailParts || formatVariantPart(suffix);
|
||||
const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · ');
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'personal',
|
||||
audienceLabel: 'Personal',
|
||||
detailLabel,
|
||||
compactDetailLabel: detailLabel,
|
||||
inlineLabel,
|
||||
};
|
||||
}
|
||||
|
||||
return parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||
const fallbackLabel = parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'unknown',
|
||||
audienceLabel: null,
|
||||
detailLabel: fallbackLabel || null,
|
||||
compactDetailLabel: fallbackLabel || null,
|
||||
inlineLabel: fallbackLabel || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatAccountDisplayName(accountId: string, email?: string): string {
|
||||
const base = email || accountId;
|
||||
const variantLabel = formatAccountVariantLabel(accountId, email);
|
||||
return variantLabel ? `${base} (${variantLabel})` : base;
|
||||
export function formatAccountVariantLabel(
|
||||
accountId: string,
|
||||
email?: string,
|
||||
tokenFile?: string
|
||||
): string | null {
|
||||
return getAccountIdentityPresentation(accountId, email, tokenFile).inlineLabel;
|
||||
}
|
||||
|
||||
export function formatAccountDisplayName(
|
||||
accountId: string,
|
||||
email?: string,
|
||||
tokenFile?: string
|
||||
): string {
|
||||
const presentation = getAccountIdentityPresentation(accountId, email, tokenFile);
|
||||
return presentation.inlineLabel
|
||||
? `${presentation.email} (${presentation.inlineLabel})`
|
||||
: presentation.email;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { OAuthAccount } from '@/lib/api-client';
|
||||
import { getAccountIdentityPresentation, type AccountAudience } from '@/lib/account-identity';
|
||||
import { getAccountStats } from '@/lib/cliproxy-account-stats';
|
||||
import type { CliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
|
||||
export interface AccountVisualVariant {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenFile: string;
|
||||
isDefault: boolean;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
lastUsedAt?: string;
|
||||
paused?: boolean;
|
||||
tier?: OAuthAccount['tier'];
|
||||
audience: AccountAudience;
|
||||
audienceLabel: string | null;
|
||||
detailLabel: string | null;
|
||||
}
|
||||
|
||||
export interface AccountVisualGroup {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenFile: string;
|
||||
provider: OAuthAccount['provider'];
|
||||
isDefault: boolean;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
lastUsedAt?: string;
|
||||
paused?: boolean;
|
||||
tier?: OAuthAccount['tier'];
|
||||
projectId?: string;
|
||||
memberIds?: string[];
|
||||
variants?: AccountVisualVariant[];
|
||||
}
|
||||
|
||||
function getLatestTimestamp(current?: string, candidate?: string): string | undefined {
|
||||
if (!candidate) return current;
|
||||
if (!current) return candidate;
|
||||
|
||||
return new Date(candidate).getTime() > new Date(current).getTime() ? candidate : current;
|
||||
}
|
||||
|
||||
function buildAccountVariant(
|
||||
account: OAuthAccount,
|
||||
statsData?: Pick<CliproxyStats, 'accountStats'> | null
|
||||
): AccountVisualVariant {
|
||||
const identity = getAccountIdentityPresentation(account.id, account.email, account.tokenFile);
|
||||
const runtimeStats = getAccountStats(statsData, account);
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
email: identity.email || account.email || account.id,
|
||||
tokenFile: account.tokenFile,
|
||||
isDefault: account.isDefault,
|
||||
successCount: runtimeStats?.successCount ?? 0,
|
||||
failureCount: runtimeStats?.failureCount ?? 0,
|
||||
lastUsedAt: runtimeStats?.lastUsedAt ?? account.lastUsedAt,
|
||||
paused: account.paused,
|
||||
tier: account.tier,
|
||||
audience: identity.audience,
|
||||
audienceLabel: identity.audienceLabel,
|
||||
detailLabel: identity.detailLabel,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAccountVisualGroups(
|
||||
accounts: OAuthAccount[],
|
||||
statsData?: Pick<CliproxyStats, 'accountStats'> | null
|
||||
): AccountVisualGroup[] {
|
||||
const buckets = new Map<string, AccountVisualVariant[]>();
|
||||
const accountMeta = new Map<string, OAuthAccount>();
|
||||
|
||||
for (const account of accounts) {
|
||||
const variant = buildAccountVariant(account, statsData);
|
||||
const isCodexProvider = account.provider.toLowerCase() === 'codex';
|
||||
const bucketKey = isCodexProvider ? `${account.provider}:${variant.email}` : account.id;
|
||||
|
||||
if (!buckets.has(bucketKey)) {
|
||||
buckets.set(bucketKey, []);
|
||||
}
|
||||
|
||||
buckets.get(bucketKey)?.push(variant);
|
||||
accountMeta.set(account.id, account);
|
||||
}
|
||||
|
||||
return Array.from(buckets.entries()).map(([bucketKey, variants]) => {
|
||||
if (variants.length === 1) {
|
||||
const [variant] = variants;
|
||||
const original = accountMeta.get(variant.id);
|
||||
|
||||
return {
|
||||
id: variant.id,
|
||||
email: variant.email,
|
||||
tokenFile: variant.tokenFile,
|
||||
provider: original?.provider ?? 'codex',
|
||||
isDefault: variant.isDefault,
|
||||
successCount: variant.successCount,
|
||||
failureCount: variant.failureCount,
|
||||
lastUsedAt: variant.lastUsedAt,
|
||||
paused: variant.paused,
|
||||
tier: variant.tier,
|
||||
projectId: original?.projectId,
|
||||
};
|
||||
}
|
||||
|
||||
const canonicalEmail = variants[0]?.email ?? bucketKey;
|
||||
const originalProvider = accountMeta.get(variants[0]?.id ?? '')?.provider ?? 'codex';
|
||||
|
||||
return {
|
||||
id: bucketKey,
|
||||
email: canonicalEmail,
|
||||
tokenFile: variants[0]?.tokenFile ?? '',
|
||||
provider: originalProvider,
|
||||
isDefault: variants.some((variant) => variant.isDefault),
|
||||
successCount: variants.reduce((sum, variant) => sum + variant.successCount, 0),
|
||||
failureCount: variants.reduce((sum, variant) => sum + variant.failureCount, 0),
|
||||
lastUsedAt: variants.reduce<string | undefined>(
|
||||
(latest, variant) => getLatestTimestamp(latest, variant.lastUsedAt),
|
||||
undefined
|
||||
),
|
||||
paused: variants.every((variant) => Boolean(variant.paused)),
|
||||
memberIds: variants.map((variant) => variant.id),
|
||||
variants,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatAccountDisplayName,
|
||||
formatAccountVariantLabel,
|
||||
getAccountIdentityPresentation,
|
||||
} from '@/lib/account-identity';
|
||||
|
||||
describe('account identity presentation', () => {
|
||||
it('formats duplicate-email team accounts as business workspace labels', () => {
|
||||
const presentation = getAccountIdentityPresentation(
|
||||
'kaidu.kd@gmail.com#04a0f049-team',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
|
||||
expect(presentation.audience).toBe('business');
|
||||
expect(presentation.audienceLabel).toBe('Business');
|
||||
expect(presentation.detailLabel).toBe('Workspace 04a0f049');
|
||||
expect(presentation.compactDetailLabel).toBe('04a0f049');
|
||||
expect(presentation.inlineLabel).toBe('Business · Workspace 04a0f049');
|
||||
});
|
||||
|
||||
it('can derive business workspace labels from token file when account id is plain email', () => {
|
||||
const presentation = getAccountIdentityPresentation(
|
||||
'kaidu.kd@gmail.com',
|
||||
'kaidu.kd@gmail.com',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json'
|
||||
);
|
||||
|
||||
expect(presentation.audience).toBe('business');
|
||||
expect(presentation.audienceLabel).toBe('Business');
|
||||
expect(presentation.detailLabel).toBe('Workspace 04a0f049');
|
||||
expect(
|
||||
formatAccountVariantLabel(
|
||||
'kaidu.kd@gmail.com',
|
||||
'kaidu.kd@gmail.com',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json'
|
||||
)
|
||||
).toBe('Business · Workspace 04a0f049');
|
||||
});
|
||||
|
||||
it('formats personal codex plans deliberately instead of leaking raw free suffixes', () => {
|
||||
expect(
|
||||
formatAccountDisplayName(
|
||||
'kaidu.kd@gmail.com',
|
||||
'kaidu.kd@gmail.com',
|
||||
'codex-kaidu.kd@gmail.com-free.json'
|
||||
)
|
||||
).toBe('kaidu.kd@gmail.com (Personal · Free)');
|
||||
});
|
||||
|
||||
it('leaves plain accounts without inferred state untouched', () => {
|
||||
const presentation = getAccountIdentityPresentation('user@example.com', 'user@example.com');
|
||||
|
||||
expect(presentation.audience).toBe('unknown');
|
||||
expect(presentation.audienceLabel).toBeNull();
|
||||
expect(presentation.detailLabel).toBeNull();
|
||||
expect(formatAccountDisplayName('user@example.com', 'user@example.com')).toBe(
|
||||
'user@example.com'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user