mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
feat(ui): unify duplicate-email account surfaces
This commit is contained in:
1 parent
be0c597d25
commit
80341f18c3
16 files changed
+1471
-792
No files matched your search
@@ -2,39 +2,25 @@
|
|||||||
* Account Card Component for Flow Visualization
|
* Account Card Component for Flow Visualization
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
|
||||||
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 { Button } from '@/components/ui/button';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
|
||||||
|
|
||||||
import type { AccountData, DragOffset } from './types';
|
import type { AccountData, DragOffset } from './types';
|
||||||
import { cleanEmail } from './utils';
|
|
||||||
import { AccountCardStats } from './account-card-stats';
|
import { AccountCardStats } from './account-card-stats';
|
||||||
|
import { cleanEmail } from './utils';
|
||||||
|
|
||||||
type Zone = 'left' | 'right' | 'top' | 'bottom';
|
type Zone = 'left' | 'right' | 'top' | 'bottom';
|
||||||
|
|
||||||
const QUOTA_PROVIDER_ALIASES = [
|
const QUOTA_PROVIDER_ALIASES = [
|
||||||
'antigravity',
|
'antigravity',
|
||||||
'anthropic',
|
'anthropic',
|
||||||
@@ -57,7 +43,7 @@ interface AccountCardProps {
|
|||||||
onPointerDown: (e: React.PointerEvent) => void;
|
onPointerDown: (e: React.PointerEvent) => void;
|
||||||
onPointerMove: (e: React.PointerEvent) => void;
|
onPointerMove: (e: React.PointerEvent) => void;
|
||||||
onPointerUp: () => void;
|
onPointerUp: () => void;
|
||||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
onPauseToggle?: (accountIds: string[], paused: boolean) => void;
|
||||||
isPausingAccount?: boolean;
|
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({
|
export function AccountCard({
|
||||||
account,
|
account,
|
||||||
zone,
|
zone,
|
||||||
@@ -109,87 +129,155 @@ export function AccountCard({
|
|||||||
const borderSide = BORDER_SIDE_MAP[zone];
|
const borderSide = BORDER_SIDE_MAP[zone];
|
||||||
const borderColor = getBorderColorStyle(zone, account.color);
|
const borderColor = getBorderColorStyle(zone, account.color);
|
||||||
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
||||||
|
|
||||||
// Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
|
|
||||||
const normalizedProvider = account.provider.toLowerCase();
|
const normalizedProvider = account.provider.toLowerCase();
|
||||||
const isCliproxyProvider =
|
const showQuota =
|
||||||
QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) ||
|
QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) ||
|
||||||
QUOTA_PROVIDER_ALIASES.includes(normalizedProvider);
|
QUOTA_PROVIDER_ALIASES.includes(normalizedProvider);
|
||||||
const isCodexProvider = normalizedProvider === 'codex';
|
const hasGroupedVariants = (account.variants?.length ?? 0) > 1;
|
||||||
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
|
|
||||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||||
normalizedProvider,
|
normalizedProvider,
|
||||||
account.id,
|
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 compactMetaBadges = hasGroupedVariants ? (
|
||||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
<>
|
||||||
const resetTime = getProviderResetTime(account.provider, quota);
|
<div
|
||||||
const codexBreakdown =
|
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"
|
||||||
isCodexProvider && quota && isCodexQuotaResult(quota)
|
title={groupedHeaderVariants
|
||||||
? getCodexQuotaBreakdown(quota.windows)
|
.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;
|
: 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 headerEnd = (
|
||||||
const showTierBadge =
|
<>
|
||||||
account.provider === 'agy' &&
|
{onPauseToggle && (
|
||||||
account.tier &&
|
<TooltipProvider>
|
||||||
account.tier !== 'unknown' &&
|
<Tooltip>
|
||||||
account.tier !== 'free';
|
<TooltipTrigger asChild>
|
||||||
const variantLabel = formatAccountVariantLabel(account.id, account.email);
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -216,176 +304,35 @@ export function AccountCard({
|
|||||||
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
|
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header row: Email + Tier | Pause button | Drag handle */}
|
<AccountSurfaceCard
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
mode="compact"
|
||||||
{/* Email with tier badge inline */}
|
provider={account.provider}
|
||||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
accountId={account.id}
|
||||||
<span
|
email={account.email}
|
||||||
className={cn(
|
displayEmail={cleanEmail(account.email)}
|
||||||
'text-xs font-semibold text-foreground tracking-tight truncate',
|
tokenFile={account.tokenFile}
|
||||||
privacyMode && PRIVACY_BLUR_CLASS
|
tier={account.tier}
|
||||||
)}
|
isDefault={account.isDefault}
|
||||||
>
|
paused={account.paused}
|
||||||
{cleanEmail(account.email)}
|
privacyMode={privacyMode}
|
||||||
</span>
|
showQuota={showQuota && !hasGroupedVariants}
|
||||||
{variantLabel && (
|
quota={quota}
|
||||||
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-muted text-muted-foreground">
|
quotaLoading={quotaLoading}
|
||||||
{variantLabel}
|
runtimeLastUsed={account.lastUsedAt}
|
||||||
</span>
|
headerEnd={headerEnd}
|
||||||
)}
|
compactMetaBadges={compactMetaBadges}
|
||||||
{showTierBadge && (
|
footerSlot={
|
||||||
<span
|
<>
|
||||||
className={cn(
|
<AccountCardStats
|
||||||
'text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0',
|
success={account.successCount}
|
||||||
account.tier === 'ultra'
|
failure={account.failureCount}
|
||||||
? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
|
showDetails={showDetails}
|
||||||
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
|
/>
|
||||||
)}
|
{groupedQuotaRows && <div className="mt-2 px-0.5 space-y-1">{groupedQuotaRows}</div>}
|
||||||
>
|
</>
|
||||||
{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}
|
|
||||||
/>
|
/>
|
||||||
{/* 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
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
|
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* Type definitions for Account Flow Visualization
|
* Type definitions for Account Flow Visualization
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { AccountVisualVariant } from '@/lib/account-visual-groups';
|
||||||
|
|
||||||
/** Account tier for subscription level */
|
/** Account tier for subscription level */
|
||||||
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
||||||
|
|
||||||
@@ -14,7 +16,9 @@ export interface DragOffset {
|
|||||||
export interface AccountData {
|
export interface AccountData {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
tokenFile?: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
|
isDefault?: boolean;
|
||||||
successCount: number;
|
successCount: number;
|
||||||
failureCount: number;
|
failureCount: number;
|
||||||
lastUsedAt?: string;
|
lastUsedAt?: string;
|
||||||
@@ -22,6 +26,10 @@ export interface AccountData {
|
|||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
/** Account tier (Antigravity only) */
|
/** Account tier (Antigravity only) */
|
||||||
tier?: AccountTier;
|
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 {
|
export interface ProviderData {
|
||||||
@@ -34,7 +42,7 @@ export interface ProviderData {
|
|||||||
export interface AccountFlowVizProps {
|
export interface AccountFlowVizProps {
|
||||||
providerData: ProviderData;
|
providerData: ProviderData;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
onPauseToggle?: (accountIds: string[], paused: boolean) => void;
|
||||||
isPausingAccount?: boolean;
|
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>
|
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
|
||||||
{providerAccounts.map((acc) => (
|
{providerAccounts.map((acc) => (
|
||||||
<option key={acc.id} value={acc.id}>
|
<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')}` : ''}
|
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@
|
|||||||
* Displays a single OAuth account with actions and quota bar
|
* 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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -13,88 +12,69 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
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 {
|
import {
|
||||||
User,
|
AlertTriangle,
|
||||||
Star,
|
Check,
|
||||||
MoreHorizontal,
|
FolderCode,
|
||||||
Clock,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle2,
|
MoreHorizontal,
|
||||||
HelpCircle,
|
|
||||||
Pause,
|
Pause,
|
||||||
Play,
|
Play,
|
||||||
AlertCircle,
|
Star,
|
||||||
AlertTriangle,
|
Trash2,
|
||||||
FolderCode,
|
|
||||||
Check,
|
|
||||||
KeyRound,
|
|
||||||
} from 'lucide-react';
|
} 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';
|
import type { AccountItemProps } from './types';
|
||||||
|
|
||||||
/**
|
function renderProjectId(projectId: string | undefined, privacyMode: boolean | undefined) {
|
||||||
* Get color class based on quota percentage
|
if (projectId) {
|
||||||
*/
|
return (
|
||||||
function getQuotaColor(percentage: number): string {
|
<TooltipProvider>
|
||||||
const clamped = Math.max(0, Math.min(100, percentage));
|
<Tooltip>
|
||||||
if (clamped <= 20) return 'bg-destructive';
|
<TooltipTrigger asChild>
|
||||||
if (clamped <= 50) return 'bg-yellow-500';
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
return 'bg-green-500';
|
<FolderCode className="w-3 h-3" aria-hidden="true" />
|
||||||
}
|
<span
|
||||||
|
className={cn(
|
||||||
/**
|
'font-mono max-w-[180px] truncate',
|
||||||
* Format relative time (e.g., "5m ago", "2h ago")
|
privacyMode && PRIVACY_BLUR_CLASS
|
||||||
*/
|
)}
|
||||||
function formatRelativeTime(dateStr: string | undefined): string {
|
title={projectId}
|
||||||
if (!dateStr) return '';
|
>
|
||||||
try {
|
{projectId}
|
||||||
const date = new Date(dateStr);
|
</span>
|
||||||
const now = new Date();
|
</div>
|
||||||
const diff = now.getTime() - date.getTime();
|
</TooltipTrigger>
|
||||||
if (diff < 0) return 'just now';
|
<TooltipContent side="bottom">
|
||||||
|
<p className="text-xs">GCP Project ID (read-only)</p>
|
||||||
const minutes = Math.floor(diff / (1000 * 60));
|
</TooltipContent>
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
</Tooltip>
|
||||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
</TooltipProvider>
|
||||||
|
);
|
||||||
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 '';
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
return (
|
||||||
* Check if account was used recently (within last hour = token likely refreshed)
|
<TooltipProvider>
|
||||||
*/
|
<Tooltip>
|
||||||
function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
|
<TooltipTrigger asChild>
|
||||||
if (!lastUsedAt) return false;
|
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
|
||||||
try {
|
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
|
||||||
const lastUsed = new Date(lastUsedAt);
|
<span>Project ID: N/A</span>
|
||||||
const now = new Date();
|
</div>
|
||||||
const diff = now.getTime() - lastUsed.getTime();
|
</TooltipTrigger>
|
||||||
return diff < 60 * 60 * 1000; // Within last hour
|
<TooltipContent side="bottom" className="max-w-[250px]">
|
||||||
} catch {
|
<div className="text-xs space-y-1">
|
||||||
return false;
|
<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({
|
export function AccountItem({
|
||||||
@@ -110,396 +90,119 @@ export function AccountItem({
|
|||||||
selected,
|
selected,
|
||||||
onSelectChange,
|
onSelectChange,
|
||||||
}: AccountItemProps) {
|
}: AccountItemProps) {
|
||||||
const { t } = useTranslation();
|
|
||||||
const normalizedProvider = account.provider.toLowerCase();
|
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);
|
const { data: stats } = useCliproxyStats(showQuota);
|
||||||
|
|
||||||
// Fetch quota for all provider accounts
|
|
||||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||||
normalizedProvider,
|
normalizedProvider,
|
||||||
account.id,
|
account.id,
|
||||||
showQuota
|
showQuota
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get last used time from runtime stats (more accurate than file)
|
|
||||||
const runtimeLastUsed = getAccountStats(stats, account)?.lastUsedAt;
|
const runtimeLastUsed = getAccountStats(stats, account)?.lastUsedAt;
|
||||||
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
|
|
||||||
|
|
||||||
// Use shared utility functions for provider-specific quota handling
|
const beforeIdentity =
|
||||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
selectable || onPauseToggle ? (
|
||||||
const nextReset = getProviderResetTime(account.provider, quota);
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
const codexBreakdown =
|
{selectable && (
|
||||||
isCodexProvider && quota && isCodexQuotaResult(quota)
|
<button
|
||||||
? getCodexQuotaBreakdown(quota.windows)
|
type="button"
|
||||||
: null;
|
onClick={() => onSelectChange?.(!selected)}
|
||||||
const codexQuotaRows = [
|
className={cn(
|
||||||
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
|
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
|
||||||
{ label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
|
selected
|
||||||
].filter((row): row is { label: string; value: number } => row.value !== null);
|
? 'bg-primary border-primary text-primary-foreground'
|
||||||
const claudeQuotaRows =
|
: 'border-muted-foreground/30 hover:border-primary/50'
|
||||||
isClaudeProvider && quota && isClaudeQuotaResult(quota)
|
)}
|
||||||
? [
|
aria-label={selected ? 'Deselect account' : 'Select account'}
|
||||||
{
|
>
|
||||||
label: '5h',
|
{selected && <Check className="w-3 h-3" />}
|
||||||
value:
|
</button>
|
||||||
quota.coreUsage?.fiveHour?.remainingPercent ??
|
)}
|
||||||
quota.windows.find((window) => window.rateLimitType === 'five_hour')
|
{onPauseToggle && (
|
||||||
?.remainingPercent ??
|
<TooltipProvider>
|
||||||
null,
|
<Tooltip>
|
||||||
},
|
<TooltipTrigger asChild>
|
||||||
{
|
<Button
|
||||||
label: 'Weekly',
|
variant="ghost"
|
||||||
value:
|
size="icon"
|
||||||
quota.coreUsage?.weekly?.remainingPercent ??
|
className="h-7 w-7 shrink-0"
|
||||||
quota.windows.find((window) =>
|
onClick={() => onPauseToggle(!account.paused)}
|
||||||
[
|
disabled={isPausingAccount}
|
||||||
'seven_day',
|
>
|
||||||
'seven_day_opus',
|
{isPausingAccount ? (
|
||||||
'seven_day_sonnet',
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
'seven_day_oauth_apps',
|
) : account.paused ? (
|
||||||
'seven_day_cowork',
|
<Play className="w-4 h-4 text-emerald-500" />
|
||||||
].includes(window.rateLimitType)
|
) : (
|
||||||
)?.remainingPercent ??
|
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||||
null,
|
)}
|
||||||
},
|
</Button>
|
||||||
].filter((row): row is { label: string; value: number } => row.value !== null)
|
</TooltipTrigger>
|
||||||
: [];
|
<TooltipContent side="top">
|
||||||
const dualWindowQuotaRows = isCodexProvider
|
{account.paused ? 'Resume account' : 'Pause account'}
|
||||||
? codexQuotaRows
|
</TooltipContent>
|
||||||
: isClaudeProvider
|
</Tooltip>
|
||||||
? claudeQuotaRows
|
</TooltipProvider>
|
||||||
: [];
|
)}
|
||||||
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
</div>
|
||||||
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
|
) : undefined;
|
||||||
const failureInfo = getQuotaFailureInfo(quota);
|
|
||||||
const FailureIcon =
|
const headerEnd = (
|
||||||
failureInfo?.label === 'Reauth'
|
<DropdownMenu>
|
||||||
? KeyRound
|
<DropdownMenuTrigger asChild>
|
||||||
: failureInfo?.tone === 'warning'
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||||
? AlertTriangle
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
: AlertCircle;
|
</Button>
|
||||||
const failureBadgeClass =
|
</DropdownMenuTrigger>
|
||||||
failureInfo?.tone === 'warning'
|
<DropdownMenuContent align="end">
|
||||||
? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
|
{!account.isDefault && (
|
||||||
: failureInfo?.tone === 'destructive'
|
<DropdownMenuItem onClick={onSetDefault}>
|
||||||
? 'border-destructive/50 text-destructive'
|
<Star className="w-4 h-4 mr-2" />
|
||||||
: 'border-muted-foreground/50 text-muted-foreground';
|
Set as default
|
||||||
const variantLabel = formatAccountVariantLabel(account.id, account.email);
|
</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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30',
|
||||||
account.paused && 'opacity-75',
|
account.paused && 'opacity-75',
|
||||||
selected && 'ring-2 ring-primary/50 bg-primary/5'
|
selected && 'ring-2 ring-primary/50 bg-primary/5'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<AccountSurfaceCard
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
mode="detailed"
|
||||||
{/* Selection checkbox for bulk actions */}
|
provider={account.provider}
|
||||||
{selectable && (
|
accountId={account.id}
|
||||||
<button
|
email={account.email}
|
||||||
type="button"
|
displayEmail={account.email || account.id}
|
||||||
onClick={() => onSelectChange?.(!selected)}
|
tokenFile={account.tokenFile}
|
||||||
className={cn(
|
tier={account.tier}
|
||||||
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
|
isDefault={account.isDefault}
|
||||||
selected
|
paused={account.paused}
|
||||||
? 'bg-primary border-primary text-primary-foreground'
|
privacyMode={privacyMode}
|
||||||
: 'border-muted-foreground/30 hover:border-primary/50'
|
showQuota={showQuota}
|
||||||
)}
|
quota={quota}
|
||||||
aria-label={selected ? 'Deselect account' : 'Select account'}
|
quotaLoading={quotaLoading}
|
||||||
>
|
runtimeLastUsed={runtimeLastUsed}
|
||||||
{selected && <Check className="w-3 h-3" />}
|
beforeIdentity={beforeIdentity}
|
||||||
</button>
|
headerEnd={headerEnd}
|
||||||
)}
|
bodySlot={
|
||||||
{/* Pause/Resume toggle button - visible left of avatar */}
|
account.provider === 'agy' ? renderProjectId(account.projectId, privacyMode) : null
|
||||||
{onPauseToggle && (
|
}
|
||||||
<TooltipProvider>
|
quotaInsetClassName="pl-11"
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,11 @@ export function ProviderCard({
|
|||||||
<div
|
<div
|
||||||
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
|
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
|
||||||
style={{ backgroundColor: acc.color }}
|
style={{ backgroundColor: acc.color }}
|
||||||
title={privacyMode ? '••••••' : formatAccountDisplayName(acc.id, acc.email)}
|
title={
|
||||||
|
privacyMode
|
||||||
|
? '••••••'
|
||||||
|
: formatAccountDisplayName(acc.id, acc.email, acc.tokenFile)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{isMissingProjectId && (
|
{isMissingProjectId && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||||
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||||
|
import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
|
||||||
import { getProviderDisplayName } from '@/lib/provider-config';
|
import { getProviderDisplayName } from '@/lib/provider-config';
|
||||||
import { getAccountStats } from '@/lib/cliproxy-account-stats';
|
|
||||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||||
import type { AccountRow, ProviderStats } from './types';
|
import type { AccountRow, ProviderStats } from './types';
|
||||||
import { ACCOUNT_COLORS } from './utils';
|
import { ACCOUNT_COLORS } from './utils';
|
||||||
@@ -74,28 +74,33 @@ export function useAuthMonitorData(): AuthMonitorData {
|
|||||||
const providerData = providerMap.get(providerKey);
|
const providerData = providerMap.get(providerKey);
|
||||||
if (!providerData) return;
|
if (!providerData) return;
|
||||||
|
|
||||||
status.accounts?.forEach((account: OAuthAccount) => {
|
const normalizedAccounts = (status.accounts ?? []).map((account: OAuthAccount) => ({
|
||||||
const realStats = getAccountStats(statsData, account);
|
...account,
|
||||||
const success = realStats?.successCount ?? 0;
|
provider: account.provider || status.provider,
|
||||||
const failure = realStats?.failureCount ?? 0;
|
}));
|
||||||
tSuccess += success;
|
|
||||||
tFailure += failure;
|
buildAccountVisualGroups(normalizedAccounts, statsData).forEach((groupedAccount) => {
|
||||||
providerData.success += success;
|
tSuccess += groupedAccount.successCount;
|
||||||
providerData.failure += failure;
|
tFailure += groupedAccount.failureCount;
|
||||||
|
providerData.success += groupedAccount.successCount;
|
||||||
|
providerData.failure += groupedAccount.failureCount;
|
||||||
|
|
||||||
const row: AccountRow = {
|
const row: AccountRow = {
|
||||||
id: account.id,
|
id: groupedAccount.id,
|
||||||
email: account.email || account.id,
|
email: groupedAccount.email,
|
||||||
|
tokenFile: groupedAccount.tokenFile,
|
||||||
provider: status.provider,
|
provider: status.provider,
|
||||||
displayName: status.displayName,
|
displayName: status.displayName,
|
||||||
isDefault: account.isDefault,
|
isDefault: groupedAccount.isDefault,
|
||||||
successCount: success,
|
successCount: groupedAccount.successCount,
|
||||||
failureCount: failure,
|
failureCount: groupedAccount.failureCount,
|
||||||
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
|
lastUsedAt: groupedAccount.lastUsedAt,
|
||||||
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
|
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
|
||||||
projectId: account.projectId,
|
projectId: groupedAccount.projectId,
|
||||||
paused: account.paused,
|
paused: groupedAccount.paused,
|
||||||
tier: account.tier,
|
tier: groupedAccount.tier,
|
||||||
|
memberIds: groupedAccount.memberIds,
|
||||||
|
variants: groupedAccount.variants,
|
||||||
};
|
};
|
||||||
accountsList.push(row);
|
accountsList.push(row);
|
||||||
providerData.accounts.push(row);
|
providerData.accounts.push(row);
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import { STATUS_COLORS } from '@/lib/utils';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { AccountFlowViz } from '@/components/account-flow-viz';
|
import { AccountFlowViz } from '@/components/account-flow-viz';
|
||||||
import { usePrivacy } from '@/contexts/privacy-context';
|
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 { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
|
||||||
|
|
||||||
import { useAuthMonitorData } from './hooks';
|
import { useAuthMonitorData } from './hooks';
|
||||||
@@ -66,14 +71,37 @@ export function AuthMonitor() {
|
|||||||
// Account control mutations for flow viz
|
// Account control mutations for flow viz
|
||||||
const pauseMutation = usePauseAccount();
|
const pauseMutation = usePauseAccount();
|
||||||
const resumeMutation = useResumeAccount();
|
const resumeMutation = useResumeAccount();
|
||||||
|
const bulkPauseMutation = useBulkPauseAccounts();
|
||||||
|
const bulkResumeMutation = useBulkResumeAccounts();
|
||||||
|
|
||||||
// Get selected provider data for detail view
|
// Get selected provider data for detail view
|
||||||
const selectedProviderData = effectiveProvider
|
const selectedProviderData = effectiveProvider
|
||||||
? providerStats.find((ps) => ps.provider === effectiveProvider)
|
? providerStats.find((ps) => ps.provider === effectiveProvider)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const handlePauseToggle = (accountId: string, paused: boolean) => {
|
const handlePauseToggle = (accountIds: string[], paused: boolean) => {
|
||||||
if (!effectiveProvider || pauseMutation.isPending || resumeMutation.isPending) return;
|
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) {
|
if (paused) {
|
||||||
pauseMutation.mutate({ provider: effectiveProvider, accountId });
|
pauseMutation.mutate({ provider: effectiveProvider, accountId });
|
||||||
} else {
|
} else {
|
||||||
@@ -165,7 +193,12 @@ export function AuthMonitor() {
|
|||||||
providerData={selectedProviderData}
|
providerData={selectedProviderData}
|
||||||
onBack={() => setSelectedProvider(null)}
|
onBack={() => setSelectedProvider(null)}
|
||||||
onPauseToggle={handlePauseToggle}
|
onPauseToggle={handlePauseToggle}
|
||||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
isPausingAccount={
|
||||||
|
pauseMutation.isPending ||
|
||||||
|
resumeMutation.isPending ||
|
||||||
|
bulkPauseMutation.isPending ||
|
||||||
|
bulkResumeMutation.isPending
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
* Type definitions for Auth Monitor components
|
* Type definitions for Auth Monitor components
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { AccountVisualVariant } from '@/lib/account-visual-groups';
|
||||||
|
|
||||||
/** Account tier for subscription level */
|
/** Account tier for subscription level */
|
||||||
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
||||||
|
|
||||||
export interface AccountRow {
|
export interface AccountRow {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
tokenFile: string;
|
||||||
provider: string;
|
provider: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
isDefault: boolean;
|
isDefault: boolean;
|
||||||
@@ -21,6 +24,10 @@ export interface AccountRow {
|
|||||||
paused?: boolean;
|
paused?: boolean;
|
||||||
/** Account tier (Antigravity only) */
|
/** Account tier (Antigravity only) */
|
||||||
tier?: AccountTier;
|
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 {
|
export interface ProviderStats {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react';
|
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 { cn } from '@/lib/utils';
|
||||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import type { AccountStepProps } from '../types';
|
import type { AccountStepProps } from '../types';
|
||||||
@@ -25,29 +26,52 @@ export function AccountStep({
|
|||||||
|
|
||||||
{/* Scrollable account list with max-height for many accounts */}
|
{/* Scrollable account list with max-height for many accounts */}
|
||||||
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
|
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
|
||||||
{accounts.map((acc) => (
|
{accounts.map((acc) => {
|
||||||
<button
|
const identity = getAccountIdentityPresentation(acc.id, acc.email, acc.tokenFile);
|
||||||
key={acc.id}
|
return (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => onSelect(acc)}
|
key={acc.id}
|
||||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
|
type="button"
|
||||||
>
|
onClick={() => onSelect(acc)}
|
||||||
<div className="flex items-center gap-3">
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
|
||||||
<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 className="flex items-center gap-3">
|
||||||
</div>
|
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||||
<div>
|
<User className="w-4 h-4 text-muted-foreground" />
|
||||||
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
|
</div>
|
||||||
{formatAccountDisplayName(acc.id, acc.email)}
|
<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>
|
</div>
|
||||||
{acc.isDefault && (
|
|
||||||
<div className="text-xs text-muted-foreground">Default account</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
</button>
|
||||||
</button>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
{/* Divider */}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { formatAccountDisplayName } from '@/lib/account-identity';
|
import { getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -45,6 +46,13 @@ export function VariantStep({
|
|||||||
selectedProvider === 'agy' && modelName.trim().length > 0
|
selectedProvider === 'agy' && modelName.trim().length > 0
|
||||||
? isDeniedAgyModelId(modelName)
|
? isDeniedAgyModelId(modelName)
|
||||||
: false;
|
: false;
|
||||||
|
const selectedAccountIdentity = selectedAccount
|
||||||
|
? getAccountIdentityPresentation(
|
||||||
|
selectedAccount.id,
|
||||||
|
selectedAccount.email,
|
||||||
|
selectedAccount.tokenFile
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
const handleModelSelect = (value: string) => {
|
const handleModelSelect = (value: string) => {
|
||||||
if (value === CUSTOM_MODEL_VALUE) {
|
if (value === CUSTOM_MODEL_VALUE) {
|
||||||
@@ -59,14 +67,38 @@ export function VariantStep({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{selectedAccount && (
|
{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" />
|
<User className="w-4 h-4" />
|
||||||
<span>
|
<div className="space-y-1">
|
||||||
{t('setupVariant.using')}{' '}
|
<span>
|
||||||
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
{t('setupVariant.using')}{' '}
|
||||||
{formatAccountDisplayName(selectedAccount.id, selectedAccount.email)}
|
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||||
|
{selectedAccountIdentity?.email}
|
||||||
|
</span>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* React Query hook for CLIProxyAPI stats
|
* React Query hook for CLIProxyAPI stats
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||||
import type {
|
import type {
|
||||||
ModelQuota,
|
ModelQuota,
|
||||||
QuotaResult,
|
QuotaResult,
|
||||||
@@ -334,6 +334,21 @@ async function fetchGhcpQuotaApi(accountId: string): Promise<GhcpQuotaResult> {
|
|||||||
// Re-export unified type from utils for consumers
|
// Re-export unified type from utils for consumers
|
||||||
export type { UnifiedQuotaResult } from '@/lib/utils';
|
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)
|
* Fetch quota by provider (dispatcher)
|
||||||
*/
|
*/
|
||||||
@@ -365,16 +380,16 @@ async function fetchQuotaByProvider(
|
|||||||
* Supports agy, codex, claude, gemini, and ghcp providers
|
* Supports agy, codex, claude, gemini, and ghcp providers
|
||||||
*/
|
*/
|
||||||
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
|
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
|
||||||
const canonicalProvider = normalizeQuotaProvider(provider);
|
return useQuery(getAccountQuotaQueryOptions(provider, accountId, enabled));
|
||||||
|
}
|
||||||
|
|
||||||
return useQuery({
|
export function useAccountQuotas(
|
||||||
queryKey: ['account-quota', canonicalProvider ?? provider, accountId],
|
accounts: Array<{ provider: string; accountId: string }>,
|
||||||
queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId),
|
enabled = true
|
||||||
enabled: enabled && !!canonicalProvider && !!accountId,
|
) {
|
||||||
staleTime: 60000, // Match refetchInterval to prevent early refetching
|
return useQueries({
|
||||||
refetchInterval: 60000, // Refresh every 1 minute
|
queries: accounts.map((account) =>
|
||||||
refetchOnWindowFocus: false, // Don't refetch on tab switch
|
getAccountQuotaQueryOptions(account.provider, account.accountId, enabled)
|
||||||
refetchOnMount: false, // Don't refetch on component remount (AuthMonitor re-renders)
|
),
|
||||||
retry: 1,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+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 {
|
function formatVariantPart(part: string): string {
|
||||||
const normalized = part.trim().toLowerCase();
|
const normalized = part.trim().toLowerCase();
|
||||||
if (!normalized) {
|
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) {
|
if (!email) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -33,29 +61,150 @@ export function extractAccountVariantKey(accountId: string, email?: string): str
|
|||||||
return accountId.startsWith(prefix) ? accountId.slice(prefix.length) : null;
|
return accountId.startsWith(prefix) ? accountId.slice(prefix.length) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAccountVariantLabel(accountId: string, email?: string): string | null {
|
function extractVariantKeyFromTokenFile(tokenFile?: string, email?: string): string | null {
|
||||||
const variantKey = extractAccountVariantKey(accountId, email);
|
if (!tokenFile || !email) {
|
||||||
if (!variantKey) {
|
|
||||||
return null;
|
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);
|
const parts = variantKey.split('-').filter(Boolean);
|
||||||
if (parts.length === 0) {
|
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];
|
const suffix = parts[parts.length - 1]?.toLowerCase();
|
||||||
if (['team', 'free', 'plus', 'pro'].includes(genericSuffix)) {
|
if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) {
|
||||||
return [formatVariantPart(genericSuffix), ...parts.slice(0, -1).map(formatVariantPart)]
|
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)
|
.filter(Boolean)
|
||||||
.join(' · ');
|
.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 {
|
export function formatAccountVariantLabel(
|
||||||
const base = email || accountId;
|
accountId: string,
|
||||||
const variantLabel = formatAccountVariantLabel(accountId, email);
|
email?: string,
|
||||||
return variantLabel ? `${base} (${variantLabel})` : base;
|
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