mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
Merge pull request #1022 from kaitranntt/kai/fix/codex-free-account-badge
fix(accounts): refine codex plan badges
This commit is contained in:
14 files changed
+523
-133
No files matched your search
@@ -1,6 +1,9 @@
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
|
||||
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
|
||||
const FREE_PLAN_PARTS = new Set(['free']);
|
||||
const PERSONAL_PLAN_PARTS = new Set(['plus', 'pro']);
|
||||
const BUSINESS_PLAN_PARTS = new Set(['team']);
|
||||
|
||||
// Keep variant parsing aligned with ui/src/lib/account-identity.ts. The UI copy is
|
||||
// separate because the browser bundle cannot import this server module directly.
|
||||
@@ -49,6 +52,20 @@ function formatVariantPart(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatWorkspaceLabel(parts: string[]): string | null {
|
||||
const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part));
|
||||
if (workspaceId) {
|
||||
return `Workspace ${workspaceId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
return parts.map(formatVariantPart).filter(Boolean).join(' · ') || null;
|
||||
}
|
||||
|
||||
function formatAudienceDetail(parts: string[]): string | null {
|
||||
const label = parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||
return label || null;
|
||||
}
|
||||
|
||||
export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean {
|
||||
return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider));
|
||||
}
|
||||
@@ -136,8 +153,16 @@ export function formatAccountVariantLabel(accountId: string, email?: string): st
|
||||
}
|
||||
|
||||
const suffix = parts[parts.length - 1]?.toLowerCase();
|
||||
if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) {
|
||||
return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
|
||||
if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) {
|
||||
return ['Business', formatWorkspaceLabel(parts.slice(0, -1))].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
if (suffix && FREE_PLAN_PARTS.has(suffix)) {
|
||||
return ['Free', formatAudienceDetail(parts.slice(0, -1))].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
||||
return ['Personal', formatVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
@@ -624,11 +624,12 @@ export async function fetchCodexQuota(
|
||||
|
||||
// Extract plan type
|
||||
const planTypeRaw = data.plan_type || data.planType;
|
||||
let planType: 'free' | 'plus' | 'team' | null = null;
|
||||
let planType: 'free' | 'plus' | 'pro' | 'team' | null = null;
|
||||
if (planTypeRaw) {
|
||||
const normalized = planTypeRaw.toLowerCase();
|
||||
if (normalized === 'free') planType = 'free';
|
||||
else if (normalized === 'plus') planType = 'plus';
|
||||
else if (normalized === 'pro') planType = 'pro';
|
||||
else if (normalized === 'team') planType = 'team';
|
||||
}
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ export interface CodexQuotaResult extends QuotaErrorMetadata {
|
||||
windows: CodexQuotaWindow[];
|
||||
/** Explicit core usage windows (5h + weekly) for easier reset display */
|
||||
coreUsage?: CodexCoreUsageSummary;
|
||||
/** Plan type: free, plus, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'team' | null;
|
||||
/** Plan type: free, plus, pro, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'pro' | 'team' | null;
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** Error message if fetch failed */
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AccountSurfaceCard } from '@/components/account/shared/account-surface-
|
||||
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { getCodexIdentityBadge, type CodexIdentityBadge } from '@/lib/account-identity';
|
||||
import {
|
||||
cn,
|
||||
formatQuotaPercent,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
getProviderResetTime,
|
||||
getQuotaFailureInfo,
|
||||
} from '@/lib/utils';
|
||||
import { formatAccountVariantPart } from '@/lib/account-identity';
|
||||
import { GripVertical, Loader2, Pause, Play } from 'lucide-react';
|
||||
import {
|
||||
useAccountQuota,
|
||||
@@ -28,6 +28,7 @@ import { AccountCardStats } from './account-card-stats';
|
||||
import { cleanEmail } from './utils';
|
||||
|
||||
type Zone = 'left' | 'right' | 'top' | 'bottom';
|
||||
type AccountAudience = 'business' | 'free' | 'personal' | 'unknown';
|
||||
|
||||
const QUOTA_PROVIDER_ALIASES = [
|
||||
'antigravity',
|
||||
@@ -88,46 +89,124 @@ function getCompactQuotaColor(percentage: number) {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
function getCodexPlanDetailLabel(quota: unknown): string | null {
|
||||
function getCodexQuotaBadge(quota: unknown): CodexIdentityBadge {
|
||||
if (!quota || typeof quota !== 'object' || !('planType' in quota)) {
|
||||
return null;
|
||||
return { audience: 'unknown', label: null };
|
||||
}
|
||||
|
||||
const planType = (quota as { planType?: unknown }).planType;
|
||||
if (typeof planType !== 'string' || planType.trim().length === 0) {
|
||||
return null;
|
||||
return { audience: 'unknown', label: null };
|
||||
}
|
||||
|
||||
return formatAccountVariantPart(planType);
|
||||
if (planType === 'team') {
|
||||
return { audience: 'business', label: 'Business' };
|
||||
}
|
||||
|
||||
if (planType === 'free') {
|
||||
return { audience: 'free', label: 'Free' };
|
||||
}
|
||||
|
||||
if (planType === 'plus') {
|
||||
return { audience: 'personal', label: 'Plus' };
|
||||
}
|
||||
|
||||
if (planType === 'pro') {
|
||||
return { audience: 'personal', label: 'Pro' };
|
||||
}
|
||||
|
||||
return { audience: 'unknown', label: null };
|
||||
}
|
||||
|
||||
function resolveCodexBadge(
|
||||
identityBadge: CodexIdentityBadge,
|
||||
quotaBadge: CodexIdentityBadge
|
||||
): CodexIdentityBadge {
|
||||
if (!quotaBadge.label) {
|
||||
return identityBadge;
|
||||
}
|
||||
|
||||
if (
|
||||
quotaBadge.label === 'Business' ||
|
||||
quotaBadge.label === 'Plus' ||
|
||||
quotaBadge.label === 'Pro'
|
||||
) {
|
||||
return quotaBadge;
|
||||
}
|
||||
|
||||
if (quotaBadge.label === 'Free' && identityBadge.label && identityBadge.label !== 'Free') {
|
||||
return identityBadge;
|
||||
}
|
||||
|
||||
return quotaBadge;
|
||||
}
|
||||
|
||||
function getVariantDetailLabel(
|
||||
variant: {
|
||||
audience: string;
|
||||
audience: AccountAudience;
|
||||
detailLabel?: string | null;
|
||||
compactDetailLabel?: string | null;
|
||||
},
|
||||
quota?: unknown
|
||||
) {
|
||||
const codexPlanDetail = getCodexPlanDetailLabel(quota);
|
||||
return variant.detailLabel ?? variant.compactDetailLabel ?? codexPlanDetail;
|
||||
return resolveCodexBadge(
|
||||
getCodexIdentityBadge({
|
||||
audience: variant.audience,
|
||||
detailLabel: variant.detailLabel ?? null,
|
||||
compactDetailLabel: variant.compactDetailLabel ?? null,
|
||||
}),
|
||||
getCodexQuotaBadge(quota)
|
||||
).label;
|
||||
}
|
||||
|
||||
function getVariantBadgeAudience(
|
||||
variant: {
|
||||
audience: AccountAudience;
|
||||
detailLabel?: string | null;
|
||||
compactDetailLabel?: string | null;
|
||||
},
|
||||
quota?: unknown
|
||||
) {
|
||||
return resolveCodexBadge(
|
||||
getCodexIdentityBadge({
|
||||
audience: variant.audience,
|
||||
detailLabel: variant.detailLabel ?? null,
|
||||
compactDetailLabel: variant.compactDetailLabel ?? null,
|
||||
}),
|
||||
getCodexQuotaBadge(quota)
|
||||
).audience;
|
||||
}
|
||||
|
||||
function getVariantCompactDetailLabel(
|
||||
variant: {
|
||||
audience: string;
|
||||
audience: AccountAudience;
|
||||
compactDetailLabel?: string | null;
|
||||
detailLabel?: string | null;
|
||||
},
|
||||
quota?: unknown
|
||||
) {
|
||||
const codexPlanDetail = getCodexPlanDetailLabel(quota);
|
||||
return variant.compactDetailLabel ?? variant.detailLabel ?? codexPlanDetail;
|
||||
return getVariantDetailLabel(variant, quota);
|
||||
}
|
||||
|
||||
function getDetailedAudienceLabel(audience: AccountAudience): string | null {
|
||||
if (audience === 'business') return 'Business';
|
||||
if (audience === 'free') return 'Free';
|
||||
if (audience === 'personal') return 'Personal';
|
||||
return null;
|
||||
}
|
||||
|
||||
function getVariantAudience(
|
||||
variant: {
|
||||
audience: AccountAudience;
|
||||
},
|
||||
quota?: unknown
|
||||
) {
|
||||
return getVariantBadgeAudience(variant, quota);
|
||||
}
|
||||
|
||||
function getVariantInlineLabel(
|
||||
variant: {
|
||||
audience: string;
|
||||
audience: AccountAudience;
|
||||
audienceLabel?: string | null;
|
||||
detailLabel?: string | null;
|
||||
compactDetailLabel?: string | null;
|
||||
@@ -135,48 +214,49 @@ function getVariantInlineLabel(
|
||||
},
|
||||
quota?: unknown
|
||||
) {
|
||||
const detailLabel = getVariantDetailLabel(variant, quota);
|
||||
const composedLabel = [variant.audienceLabel, detailLabel].filter(Boolean).join(' · ');
|
||||
return composedLabel || variant.inlineLabel || null;
|
||||
return getVariantDetailLabel(variant, quota) || variant.inlineLabel || null;
|
||||
}
|
||||
|
||||
function getVariantMarkerLabel(
|
||||
variant: {
|
||||
audience: string;
|
||||
audience: AccountAudience;
|
||||
audienceLabel?: string | null;
|
||||
detailLabel?: string | null;
|
||||
compactDetailLabel?: string | null;
|
||||
},
|
||||
audienceCounts: Map<string, number>,
|
||||
quota?: unknown
|
||||
) {
|
||||
const audience = getVariantAudience(variant, quota);
|
||||
const compactDetailLabel = getVariantCompactDetailLabel(variant, quota);
|
||||
if (variant.audience === 'business') {
|
||||
const businessVariantCount = audienceCounts.get('business') ?? 0;
|
||||
return businessVariantCount > 1 && compactDetailLabel ? compactDetailLabel : 'Biz';
|
||||
if (audience === 'business') {
|
||||
return 'Biz';
|
||||
}
|
||||
if (variant.audience === 'personal') {
|
||||
if (audience === 'free') {
|
||||
return compactDetailLabel ?? 'Free';
|
||||
}
|
||||
if (audience === 'personal') {
|
||||
return compactDetailLabel ?? 'Pers';
|
||||
}
|
||||
|
||||
const normalizedFallback =
|
||||
compactDetailLabel?.trim() || variant.audienceLabel?.trim() || variant.detailLabel?.trim();
|
||||
const normalizedFallback = compactDetailLabel?.trim() || getDetailedAudienceLabel(audience);
|
||||
return normalizedFallback?.[0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
|
||||
function getGroupedVariantSummaryLabel(
|
||||
variants: Array<{
|
||||
audience: string;
|
||||
audience: AccountAudience;
|
||||
audienceLabel?: string | null;
|
||||
detailLabel?: string | null;
|
||||
compactDetailLabel?: string | null;
|
||||
}>,
|
||||
quotas: Array<unknown>,
|
||||
audienceCounts: Map<string, number>
|
||||
quotas: Array<unknown>
|
||||
) {
|
||||
const audiences = new Set(variants.map((variant) => variant.audience));
|
||||
const hasDistinctDetails = variants.some((variant, index) =>
|
||||
Boolean(getVariantCompactDetailLabel(variant, quotas[index]))
|
||||
Boolean(
|
||||
getVariantCompactDetailLabel(variant, quotas[index]) ||
|
||||
getDetailedAudienceLabel(getVariantAudience(variant, quotas[index]))
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -191,7 +271,7 @@ function getGroupedVariantSummaryLabel(
|
||||
|
||||
if (variants.length === 1) {
|
||||
const [variant] = variants;
|
||||
return getVariantMarkerLabel(variant, audienceCounts, quotas[0]);
|
||||
return getVariantMarkerLabel(variant, quotas[0]);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -239,14 +319,9 @@ export function AccountCard({
|
||||
const groupedVariantQuotas = groupedHeaderVariants.map(
|
||||
(_, index) => variantQuotaQueries[index]?.data
|
||||
);
|
||||
const groupedVariantAudienceCounts = groupedHeaderVariants.reduce((counts, variant) => {
|
||||
counts.set(variant.audience, (counts.get(variant.audience) ?? 0) + 1);
|
||||
return counts;
|
||||
}, new Map<string, number>());
|
||||
const groupedVariantSummaryLabel = getGroupedVariantSummaryLabel(
|
||||
groupedHeaderVariants,
|
||||
groupedVariantQuotas,
|
||||
groupedVariantAudienceCounts
|
||||
groupedVariantQuotas
|
||||
);
|
||||
|
||||
const compactMetaBadges = hasGroupedVariants ? (
|
||||
@@ -274,16 +349,14 @@ export function AccountCard({
|
||||
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'
|
||||
: variant.audience === 'free'
|
||||
? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200'
|
||||
: 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,
|
||||
groupedVariantAudienceCounts,
|
||||
groupedVariantQuotas[index]
|
||||
)}
|
||||
{getVariantMarkerLabel(variant, groupedVariantQuotas[index])}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,11 @@ 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 { formatAccountVariantPart, getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||
import {
|
||||
getAccountIdentityPresentation,
|
||||
getCodexIdentityBadge,
|
||||
type CodexIdentityBadge,
|
||||
} from '@/lib/account-identity';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Pause, Star, User } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -10,6 +14,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AccountQuotaPanel } from './account-quota-panel';
|
||||
|
||||
type AccountSurfaceMode = 'compact' | 'detailed';
|
||||
type AccountAudience = 'business' | 'free' | 'personal' | 'unknown';
|
||||
type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
|
||||
interface AccountSurfaceCardProps {
|
||||
@@ -36,11 +41,15 @@ interface AccountSurfaceCardProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getAudienceBadgeClass(audience: 'business' | 'personal' | 'unknown') {
|
||||
function getAudienceBadgeClass(audience: AccountAudience) {
|
||||
if (audience === 'business') {
|
||||
return 'bg-sky-500/12 text-sky-700 dark:text-sky-300';
|
||||
}
|
||||
|
||||
if (audience === 'free') {
|
||||
return 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200';
|
||||
}
|
||||
|
||||
if (audience === 'personal') {
|
||||
return 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300';
|
||||
}
|
||||
@@ -54,20 +63,22 @@ function getTierBadgeClass(tier: AccountTier | undefined) {
|
||||
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400';
|
||||
}
|
||||
|
||||
function getCompactAudienceBadgeLabel(
|
||||
audience: 'business' | 'personal' | 'unknown',
|
||||
t: (key: string) => string
|
||||
) {
|
||||
function getCompactAudienceBadgeLabel(audience: AccountAudience, t: (key: string) => string) {
|
||||
if (audience === 'business') return t('accountSurfaceCard.business');
|
||||
if (audience === 'free') return t('accountSurfaceCard.free');
|
||||
if (audience === 'personal') return t('accountSurfaceCard.personal');
|
||||
return '?';
|
||||
}
|
||||
|
||||
function getCompactDetailBadgeClass(audience: 'business' | 'personal' | 'unknown') {
|
||||
function getCompactDetailBadgeClass(audience: AccountAudience) {
|
||||
if (audience === 'business') {
|
||||
return 'border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-400/30 dark:bg-sky-500/15 dark:text-sky-200';
|
||||
}
|
||||
|
||||
if (audience === 'free') {
|
||||
return 'border-slate-300/70 bg-slate-100/80 text-slate-700 dark:border-slate-500/40 dark:bg-slate-700/30 dark:text-slate-200';
|
||||
}
|
||||
|
||||
if (audience === 'personal') {
|
||||
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:border-emerald-400/30 dark:bg-emerald-500/15 dark:text-emerald-200';
|
||||
}
|
||||
@@ -88,12 +99,51 @@ function resolveEffectiveTier(
|
||||
return tier;
|
||||
}
|
||||
|
||||
function getCodexPlanDetailLabel(quota: UnifiedQuotaResult | undefined): string | null {
|
||||
function getCodexQuotaBadge(quota: UnifiedQuotaResult | undefined): CodexIdentityBadge {
|
||||
if (!quota || !('planType' in quota) || !quota.planType) {
|
||||
return null;
|
||||
return { audience: 'unknown', label: null };
|
||||
}
|
||||
|
||||
return formatAccountVariantPart(quota.planType);
|
||||
if (quota.planType === 'team') {
|
||||
return { audience: 'business', label: 'Business' };
|
||||
}
|
||||
|
||||
if (quota.planType === 'free') {
|
||||
return { audience: 'free', label: 'Free' };
|
||||
}
|
||||
|
||||
if (quota.planType === 'plus') {
|
||||
return { audience: 'personal', label: 'Plus' };
|
||||
}
|
||||
|
||||
if (quota.planType === 'pro') {
|
||||
return { audience: 'personal', label: 'Pro' };
|
||||
}
|
||||
|
||||
return { audience: 'unknown', label: null };
|
||||
}
|
||||
|
||||
function resolveCodexBadge(
|
||||
identityBadge: CodexIdentityBadge,
|
||||
quotaBadge: CodexIdentityBadge
|
||||
): CodexIdentityBadge {
|
||||
if (!quotaBadge.label) {
|
||||
return identityBadge;
|
||||
}
|
||||
|
||||
if (
|
||||
quotaBadge.label === 'Business' ||
|
||||
quotaBadge.label === 'Plus' ||
|
||||
quotaBadge.label === 'Pro'
|
||||
) {
|
||||
return quotaBadge;
|
||||
}
|
||||
|
||||
if (quotaBadge.label === 'Free' && identityBadge.label && identityBadge.label !== 'Free') {
|
||||
return identityBadge;
|
||||
}
|
||||
|
||||
return quotaBadge;
|
||||
}
|
||||
|
||||
export function AccountSurfaceCard({
|
||||
@@ -124,10 +174,10 @@ export function AccountSurfaceCard({
|
||||
const title = displayEmail || identity.email || accountId;
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
const effectiveTier = resolveEffectiveTier(tier, quota);
|
||||
const codexPlanDetailLabel =
|
||||
normalizedProvider === 'codex' ? getCodexPlanDetailLabel(quota) : null;
|
||||
const resolvedDetailLabel = identity.detailLabel ?? codexPlanDetailLabel;
|
||||
const resolvedCompactDetailLabel = identity.compactDetailLabel ?? codexPlanDetailLabel;
|
||||
const effectiveCodexBadge =
|
||||
normalizedProvider === 'codex'
|
||||
? resolveCodexBadge(getCodexIdentityBadge(identity), getCodexQuotaBadge(quota))
|
||||
: null;
|
||||
const showTierBadge =
|
||||
(normalizedProvider === 'agy' ||
|
||||
normalizedProvider === 'antigravity' ||
|
||||
@@ -148,28 +198,38 @@ export function AccountSurfaceCard({
|
||||
{effectiveTier}
|
||||
</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'
|
||||
{normalizedProvider === 'codex'
|
||||
? effectiveCodexBadge?.label && (
|
||||
<span
|
||||
title={effectiveCodexBadge.label}
|
||||
className={cn(
|
||||
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md border shrink-0',
|
||||
getCompactDetailBadgeClass(effectiveCodexBadge.audience)
|
||||
)}
|
||||
>
|
||||
{effectiveCodexBadge.label}
|
||||
</span>
|
||||
)
|
||||
: identity.audienceLabel && (
|
||||
<span
|
||||
title={identity.audienceLabel}
|
||||
className={cn(
|
||||
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
|
||||
getAudienceBadgeClass(identity.audience)
|
||||
)}
|
||||
>
|
||||
{getCompactAudienceBadgeLabel(identity.audience, t)}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
{getCompactAudienceBadgeLabel(identity.audience, t)}
|
||||
</span>
|
||||
)}
|
||||
{resolvedCompactDetailLabel && (
|
||||
{normalizedProvider !== 'codex' && identity.compactDetailLabel && (
|
||||
<span
|
||||
title={resolvedDetailLabel ?? resolvedCompactDetailLabel}
|
||||
title={identity.detailLabel ?? identity.compactDetailLabel}
|
||||
className={cn(
|
||||
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md border shrink-0',
|
||||
getCompactDetailBadgeClass(identity.audience)
|
||||
)}
|
||||
>
|
||||
{resolvedCompactDetailLabel}
|
||||
{identity.compactDetailLabel}
|
||||
</span>
|
||||
)}
|
||||
{paused && (
|
||||
@@ -227,7 +287,18 @@ export function AccountSurfaceCard({
|
||||
{title}
|
||||
</span>
|
||||
{isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)}
|
||||
{!isCompact && identity.audienceLabel && (
|
||||
{!isCompact && normalizedProvider === 'codex' && effectiveCodexBadge?.label && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] h-4 px-1.5 border-transparent',
|
||||
getAudienceBadgeClass(effectiveCodexBadge.audience)
|
||||
)}
|
||||
>
|
||||
{effectiveCodexBadge.label}
|
||||
</Badge>
|
||||
)}
|
||||
{!isCompact && normalizedProvider !== 'codex' && identity.audienceLabel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
@@ -238,9 +309,9 @@ export function AccountSurfaceCard({
|
||||
{identity.audienceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{!isCompact && resolvedDetailLabel && (
|
||||
{!isCompact && normalizedProvider !== 'codex' && identity.detailLabel && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||
{resolvedDetailLabel}
|
||||
{identity.detailLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{!isCompact && isDefault && (
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ChevronRight, ArrowLeft, User, ExternalLink } from 'lucide-react';
|
||||
import { getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||
import { getAccountIdentityPresentation, getCodexIdentityBadge } from '@/lib/account-identity';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import type { AccountStepProps } from '../types';
|
||||
@@ -30,6 +30,8 @@ export function AccountStep({
|
||||
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
{accounts.map((acc) => {
|
||||
const identity = getAccountIdentityPresentation(acc.id, acc.email, acc.tokenFile);
|
||||
const codexBadge =
|
||||
acc.provider?.toLowerCase() === 'codex' ? getCodexIdentityBadge(identity) : null;
|
||||
return (
|
||||
<button
|
||||
key={acc.id}
|
||||
@@ -46,20 +48,36 @@ export function AccountStep({
|
||||
{identity.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{identity.audienceLabel && (
|
||||
{codexBadge?.label ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] h-4 px-1.5 border-transparent',
|
||||
codexBadge.audience === 'business'
|
||||
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
|
||||
: codexBadge.audience === 'free'
|
||||
? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200'
|
||||
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{codexBadge.label}
|
||||
</Badge>
|
||||
) : 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.audience === 'free'
|
||||
? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200'
|
||||
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{identity.audienceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{identity.detailLabel && (
|
||||
) : null}
|
||||
{!codexBadge?.label && identity.detailLabel && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||
{identity.detailLabel}
|
||||
</Badge>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||
import { getAccountIdentityPresentation, getCodexIdentityBadge } from '@/lib/account-identity';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -55,6 +55,10 @@ export function VariantStep({
|
||||
selectedAccount.tokenFile
|
||||
)
|
||||
: null;
|
||||
const selectedCodexBadge =
|
||||
selectedProvider === 'codex' && selectedAccountIdentity
|
||||
? getCodexIdentityBadge(selectedAccountIdentity)
|
||||
: null;
|
||||
|
||||
const handleModelSelect = (value: string) => {
|
||||
if (value === CUSTOM_MODEL_VALUE) {
|
||||
@@ -80,20 +84,36 @@ export function VariantStep({
|
||||
</span>
|
||||
{(selectedAccountIdentity?.audienceLabel || selectedAccountIdentity?.detailLabel) && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{selectedAccountIdentity?.audienceLabel && (
|
||||
{selectedCodexBadge?.label ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] h-4 px-1.5 border-transparent',
|
||||
selectedCodexBadge.audience === 'business'
|
||||
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
|
||||
: selectedCodexBadge.audience === 'free'
|
||||
? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200'
|
||||
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{selectedCodexBadge.label}
|
||||
</Badge>
|
||||
) : 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.audience === 'free'
|
||||
? 'bg-slate-200/70 text-slate-700 dark:bg-slate-700/40 dark:text-slate-200'
|
||||
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{selectedAccountIdentity.audienceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedAccountIdentity?.detailLabel && (
|
||||
) : null}
|
||||
{!selectedCodexBadge?.label && selectedAccountIdentity?.detailLabel && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||
{selectedAccountIdentity.detailLabel}
|
||||
</Badge>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const PERSONAL_PLAN_PARTS = new Set(['free', 'plus', 'pro']);
|
||||
import i18n from './i18n';
|
||||
|
||||
const FREE_PLAN_PARTS = new Set(['free']);
|
||||
const PERSONAL_PLAN_PARTS = new Set(['plus', 'pro']);
|
||||
const BUSINESS_PLAN_PARTS = new Set(['team']);
|
||||
|
||||
// Keep variant parsing aligned with src/cliproxy/accounts/email-account-identity.ts.
|
||||
// This browser copy stays local because the server module is not bundle-safe for the UI.
|
||||
|
||||
export type AccountAudience = 'business' | 'personal' | 'unknown';
|
||||
export type AccountAudience = 'business' | 'free' | 'personal' | 'unknown';
|
||||
|
||||
export interface AccountIdentityPresentation {
|
||||
email: string;
|
||||
@@ -15,6 +18,11 @@ export interface AccountIdentityPresentation {
|
||||
inlineLabel: string | null;
|
||||
}
|
||||
|
||||
export interface CodexIdentityBadge {
|
||||
audience: AccountAudience;
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
function normalizeVariantTokenPart(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
@@ -32,13 +40,13 @@ export function formatAccountVariantPart(part: string): string {
|
||||
|
||||
switch (normalized) {
|
||||
case 'team':
|
||||
return 'Team'; // TODO i18n: missing key for account variant team
|
||||
return i18n.t('accountIdentity.team');
|
||||
case 'free':
|
||||
return 'Free'; // TODO i18n: missing key for account variant free
|
||||
return i18n.t('accountIdentity.free');
|
||||
case 'plus':
|
||||
return 'Plus'; // TODO i18n: missing key for account variant plus
|
||||
return i18n.t('accountIdentity.plus');
|
||||
case 'pro':
|
||||
return 'Pro'; // TODO i18n: missing key for account variant pro
|
||||
return i18n.t('accountIdentity.pro');
|
||||
default:
|
||||
return /^[a-f0-9]{8}$/i.test(normalized)
|
||||
? normalized
|
||||
@@ -98,18 +106,23 @@ function formatWorkspaceLabel(parts: string[]): {
|
||||
const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part));
|
||||
if (workspaceId) {
|
||||
return {
|
||||
detailLabel: `Workspace ${workspaceId.toLowerCase()}`, // TODO i18n: missing key for workspace label
|
||||
detailLabel: i18n.t('accountIdentity.workspace', { id: workspaceId.toLowerCase() }),
|
||||
compactDetailLabel: workspaceId.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
const extraLabel = parts.map(formatAccountVariantPart).filter(Boolean).join(' · ');
|
||||
return {
|
||||
detailLabel: extraLabel || 'Team', // TODO i18n: missing key for team fallback
|
||||
compactDetailLabel: extraLabel || 'Team',
|
||||
detailLabel: extraLabel || null,
|
||||
compactDetailLabel: extraLabel || null,
|
||||
};
|
||||
}
|
||||
|
||||
function formatAudienceDetail(parts: string[]): string | null {
|
||||
const label = parts.map(formatAccountVariantPart).filter(Boolean).join(' · ');
|
||||
return label || null;
|
||||
}
|
||||
|
||||
export function extractAccountVariantKey(
|
||||
accountId: string,
|
||||
email?: string,
|
||||
@@ -155,32 +168,45 @@ export function getAccountIdentityPresentation(
|
||||
const suffix = parts[parts.length - 1]?.toLowerCase();
|
||||
if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) {
|
||||
const workspace = formatWorkspaceLabel(parts.slice(0, -1));
|
||||
const inlineLabel = ['Business', workspace.detailLabel].filter(Boolean).join(' · '); // TODO i18n: missing keys for Business/Personal audience labels
|
||||
const inlineLabel = [i18n.t('accountIdentity.business'), workspace.detailLabel]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'business',
|
||||
audienceLabel: 'Business',
|
||||
audienceLabel: i18n.t('accountIdentity.business'),
|
||||
detailLabel: workspace.detailLabel,
|
||||
compactDetailLabel: workspace.compactDetailLabel,
|
||||
inlineLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (suffix && FREE_PLAN_PARTS.has(suffix)) {
|
||||
const detailLabel = formatAudienceDetail(parts.slice(0, -1));
|
||||
const inlineLabel = [i18n.t('accountIdentity.free'), detailLabel].filter(Boolean).join(' · ');
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'free',
|
||||
audienceLabel: i18n.t('accountIdentity.free'),
|
||||
detailLabel,
|
||||
compactDetailLabel: detailLabel,
|
||||
inlineLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
||||
const detailParts = [
|
||||
formatAccountVariantPart(suffix),
|
||||
...parts.slice(0, -1).map(formatAccountVariantPart),
|
||||
]
|
||||
const detailLabel = [formatAccountVariantPart(suffix), formatAudienceDetail(parts.slice(0, -1))]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const inlineLabel = [i18n.t('accountIdentity.personal'), detailLabel]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const detailLabel = detailParts || formatAccountVariantPart(suffix);
|
||||
const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · '); // TODO i18n: missing key for Personal
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'personal',
|
||||
audienceLabel: 'Personal',
|
||||
detailLabel,
|
||||
compactDetailLabel: detailLabel,
|
||||
audienceLabel: i18n.t('accountIdentity.personal'),
|
||||
detailLabel: detailLabel || formatAccountVariantPart(suffix),
|
||||
compactDetailLabel: detailLabel || formatAccountVariantPart(suffix),
|
||||
inlineLabel,
|
||||
};
|
||||
}
|
||||
@@ -204,6 +230,30 @@ export function formatAccountVariantLabel(
|
||||
return getAccountIdentityPresentation(accountId, email, tokenFile).inlineLabel;
|
||||
}
|
||||
|
||||
export function getCodexIdentityBadge(
|
||||
presentation: Pick<AccountIdentityPresentation, 'audience' | 'detailLabel' | 'compactDetailLabel'>
|
||||
): CodexIdentityBadge {
|
||||
if (presentation.audience === 'business') {
|
||||
return { audience: 'business', label: i18n.t('accountIdentity.business') };
|
||||
}
|
||||
|
||||
if (presentation.audience === 'free') {
|
||||
return { audience: 'free', label: i18n.t('accountIdentity.free') };
|
||||
}
|
||||
|
||||
if (presentation.audience === 'personal') {
|
||||
return {
|
||||
audience: 'personal',
|
||||
label:
|
||||
presentation.compactDetailLabel ??
|
||||
presentation.detailLabel ??
|
||||
i18n.t('accountIdentity.personal'),
|
||||
};
|
||||
}
|
||||
|
||||
return { audience: 'unknown', label: null };
|
||||
}
|
||||
|
||||
export function formatAccountDisplayName(
|
||||
accountId: string,
|
||||
email?: string,
|
||||
|
||||
@@ -39,7 +39,8 @@ export interface AccountVisualGroup {
|
||||
const AUDIENCE_ORDER: Record<AccountAudience, number> = {
|
||||
business: 0,
|
||||
personal: 1,
|
||||
unknown: 2,
|
||||
free: 2,
|
||||
unknown: 3,
|
||||
};
|
||||
|
||||
function getLatestTimestamp(current?: string, candidate?: string): string | undefined {
|
||||
|
||||
@@ -610,8 +610,8 @@ export interface CodexQuotaResult {
|
||||
windows: CodexQuotaWindow[];
|
||||
/** Explicit core usage windows (5h + weekly) for easier reset display */
|
||||
coreUsage?: CodexCoreUsageSummary;
|
||||
/** Plan type: free, plus, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'team' | null;
|
||||
/** Plan type: free, plus, pro, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'pro' | 'team' | null;
|
||||
/** Timestamp of fetch */
|
||||
lastUpdated: number;
|
||||
/** Upstream HTTP status when available */
|
||||
|
||||
@@ -1701,9 +1701,19 @@ const resources = {
|
||||
},
|
||||
accountSurfaceCard: {
|
||||
business: 'Biz',
|
||||
free: 'Free',
|
||||
personal: 'Pers',
|
||||
variant: 'Variant',
|
||||
},
|
||||
accountIdentity: {
|
||||
business: 'Business',
|
||||
personal: 'Personal',
|
||||
free: 'Free',
|
||||
team: 'Team',
|
||||
plus: 'Plus',
|
||||
pro: 'Pro',
|
||||
workspace: 'Workspace {{id}}',
|
||||
},
|
||||
accountCardStats: {
|
||||
notUsedYet: 'Not used yet',
|
||||
},
|
||||
@@ -4049,9 +4059,19 @@ const resources = {
|
||||
},
|
||||
accountSurfaceCard: {
|
||||
business: '企业',
|
||||
free: '免费',
|
||||
personal: '个人',
|
||||
variant: '变体',
|
||||
},
|
||||
accountIdentity: {
|
||||
business: '企业',
|
||||
personal: '个人',
|
||||
free: '免费',
|
||||
team: '团队',
|
||||
plus: 'Plus',
|
||||
pro: 'Pro',
|
||||
workspace: '工作区 {{id}}',
|
||||
},
|
||||
accountCardStats: {
|
||||
notUsedYet: '尚未使用',
|
||||
},
|
||||
@@ -6469,9 +6489,19 @@ const resources = {
|
||||
},
|
||||
accountSurfaceCard: {
|
||||
business: 'Biz',
|
||||
free: 'Miễn phí',
|
||||
personal: 'Cá nhân',
|
||||
variant: 'Biến thể',
|
||||
},
|
||||
accountIdentity: {
|
||||
business: 'Doanh nghiệp',
|
||||
personal: 'Cá nhân',
|
||||
free: 'Miễn phí',
|
||||
team: 'Nhóm',
|
||||
plus: 'Plus',
|
||||
pro: 'Pro',
|
||||
workspace: 'Không gian {{id}}',
|
||||
},
|
||||
accountCardStats: {
|
||||
notUsedYet: 'Chưa sử dụng',
|
||||
},
|
||||
@@ -8671,9 +8701,19 @@ const resources = {
|
||||
},
|
||||
accountSurfaceCard: {
|
||||
business: 'ビジネス',
|
||||
free: '無料',
|
||||
personal: '個人',
|
||||
variant: 'バリアント',
|
||||
},
|
||||
accountIdentity: {
|
||||
business: 'ビジネス',
|
||||
personal: '個人',
|
||||
free: '無料',
|
||||
team: 'チーム',
|
||||
plus: 'Plus',
|
||||
pro: 'Pro',
|
||||
workspace: 'ワークスペース {{id}}',
|
||||
},
|
||||
aiProvidersEntryCard: {
|
||||
apiKeys: 'API Keys',
|
||||
},
|
||||
|
||||
@@ -20,7 +20,11 @@ vi.mock('@/hooks/use-cliproxy-stats', async () => {
|
||||
const mockedUseAccountQuota = vi.mocked(useAccountQuota);
|
||||
const mockedUseAccountQuotas = vi.mocked(useAccountQuotas);
|
||||
|
||||
function makeCodexQuota(planType: 'free' | 'plus' | 'team', fiveHour: number, weekly: number) {
|
||||
function makeCodexQuota(
|
||||
planType: 'free' | 'plus' | 'pro' | 'team',
|
||||
fiveHour: number,
|
||||
weekly: number
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
planType,
|
||||
@@ -87,11 +91,11 @@ const groupedAccount: AccountData = {
|
||||
isDefault: true,
|
||||
successCount: 4,
|
||||
failureCount: 1,
|
||||
audience: 'personal',
|
||||
audienceLabel: 'Personal',
|
||||
detailLabel: 'Free',
|
||||
compactDetailLabel: 'Free',
|
||||
inlineLabel: 'Personal · Free',
|
||||
audience: 'free',
|
||||
audienceLabel: 'Free',
|
||||
detailLabel: null,
|
||||
compactDetailLabel: null,
|
||||
inlineLabel: 'Free',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -99,9 +103,11 @@ const groupedAccount: AccountData = {
|
||||
const groupedAccountWithProPersonal: AccountData = {
|
||||
...groupedAccount,
|
||||
variants: groupedAccount.variants?.map((variant) =>
|
||||
variant.audience === 'personal'
|
||||
variant.audience === 'free'
|
||||
? {
|
||||
...variant,
|
||||
audience: 'personal',
|
||||
audienceLabel: 'Personal',
|
||||
detailLabel: 'Pro',
|
||||
compactDetailLabel: 'Pro',
|
||||
inlineLabel: 'Personal · Pro',
|
||||
@@ -148,12 +154,10 @@ describe('AccountCard grouped quota tooltip', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTitle('Business · Workspace 04a0f049 • Personal · Free')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Business • Free')).toBeInTheDocument();
|
||||
expect(screen.getByText('Biz')).toBeInTheDocument();
|
||||
|
||||
await userEvent.hover(screen.getByText('Business · Workspace 04a0f049'));
|
||||
await userEvent.hover(screen.getByText('Business'));
|
||||
const businessPlan = (await screen.findAllByText('Plan: team')).find((node) =>
|
||||
node.closest('[data-slot="tooltip-content"]')
|
||||
);
|
||||
@@ -164,7 +168,14 @@ describe('AccountCard grouped quota tooltip', () => {
|
||||
expect(tooltipContent?.className).toContain('text-popover-foreground');
|
||||
expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]');
|
||||
|
||||
await userEvent.hover(screen.getByText('Personal · Free'));
|
||||
const freeLabels = screen.getAllByText('Free');
|
||||
const quotaLabel = freeLabels[freeLabels.length - 1];
|
||||
expect(quotaLabel).toBeDefined();
|
||||
if (!quotaLabel) {
|
||||
throw new Error('Expected a Free quota label');
|
||||
}
|
||||
|
||||
await userEvent.hover(quotaLabel);
|
||||
const personalPlan = (await screen.findAllByText('Plan: free')).find((node) =>
|
||||
node.closest('[data-slot="tooltip-content"]')
|
||||
);
|
||||
@@ -191,8 +202,8 @@ describe('AccountCard grouped quota tooltip', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTitle('Business · Workspace 04a0f049 • Personal · Pro')).toBeInTheDocument();
|
||||
expect(screen.getByText('Personal · Pro')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Personal · Free')).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle('Business • Pro')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Pro').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,7 @@ describe('AccountSurfaceCard', () => {
|
||||
expect(screen.getByText('pro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows both personal identity and free-tier detail for compact Codex cards', () => {
|
||||
it('shows free Codex accounts as a single standalone audience badge', () => {
|
||||
render(
|
||||
<AccountSurfaceCard
|
||||
mode="compact"
|
||||
@@ -70,12 +70,12 @@ describe('AccountSurfaceCard', () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Pers')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Personal')).toBeInTheDocument();
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Free')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps richer token-derived Codex personal detail when live quota planType is coarser', () => {
|
||||
it('keeps token-derived personal detail when live quota planType is coarser', () => {
|
||||
render(
|
||||
<AccountSurfaceCard
|
||||
mode="compact"
|
||||
@@ -91,5 +91,59 @@ describe('AccountSurfaceCard', () => {
|
||||
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Free')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to a single free badge when Codex quota detects a free plan', () => {
|
||||
render(
|
||||
<AccountSurfaceCard
|
||||
mode="compact"
|
||||
provider="codex"
|
||||
accountId="user@example.com"
|
||||
email="user@example.com"
|
||||
displayEmail="user@example.com"
|
||||
quota={createCodexQuotaResult({ planType: 'free' })}
|
||||
showQuota={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to plus or pro detail when Codex quota exposes a paid plan', () => {
|
||||
render(
|
||||
<AccountSurfaceCard
|
||||
mode="compact"
|
||||
provider="codex"
|
||||
accountId="user@example.com"
|
||||
email="user@example.com"
|
||||
displayEmail="user@example.com"
|
||||
quota={createCodexQuotaResult({ planType: 'pro' })}
|
||||
showQuota={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets live paid Codex plans override stale free identity badges', () => {
|
||||
render(
|
||||
<AccountSurfaceCard
|
||||
mode="compact"
|
||||
provider="codex"
|
||||
accountId="user@example.com#free"
|
||||
email="user@example.com"
|
||||
displayEmail="user@example.com"
|
||||
tokenFile="codex-user@example.com-free.json"
|
||||
quota={createCodexQuotaResult({ planType: 'plus' })}
|
||||
showQuota={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Plus')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Pers')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Free')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -39,14 +39,40 @@ describe('account identity presentation', () => {
|
||||
).toBe('Business · Workspace 04a0f049');
|
||||
});
|
||||
|
||||
it('formats personal codex plans deliberately instead of leaking raw free suffixes', () => {
|
||||
it('classifies free codex accounts as a standalone audience', () => {
|
||||
const presentation = getAccountIdentityPresentation(
|
||||
'kaidu.kd@gmail.com',
|
||||
'kaidu.kd@gmail.com',
|
||||
'codex-kaidu.kd@gmail.com-free.json'
|
||||
);
|
||||
|
||||
expect(presentation.audience).toBe('free');
|
||||
expect(presentation.audienceLabel).toBe('Free');
|
||||
expect(presentation.detailLabel).toBeNull();
|
||||
expect(
|
||||
formatAccountDisplayName(
|
||||
'kaidu.kd@gmail.com',
|
||||
'kaidu.kd@gmail.com',
|
||||
'codex-kaidu.kd@gmail.com-free.json'
|
||||
)
|
||||
).toBe('kaidu.kd@gmail.com (Personal · Free)');
|
||||
).toBe('kaidu.kd@gmail.com (Free)');
|
||||
});
|
||||
|
||||
it('keeps plus and pro codex personal plans distinct', () => {
|
||||
expect(
|
||||
formatAccountDisplayName(
|
||||
'kaidu.kd@gmail.com',
|
||||
'kaidu.kd@gmail.com',
|
||||
'codex-kaidu.kd@gmail.com-plus.json'
|
||||
)
|
||||
).toBe('kaidu.kd@gmail.com (Personal · Plus)');
|
||||
expect(
|
||||
formatAccountDisplayName(
|
||||
'kaidu.kd@gmail.com',
|
||||
'kaidu.kd@gmail.com',
|
||||
'codex-kaidu.kd@gmail.com-pro.json'
|
||||
)
|
||||
).toBe('kaidu.kd@gmail.com (Personal · Pro)');
|
||||
});
|
||||
|
||||
it('leaves plain accounts without inferred state untouched', () => {
|
||||
|
||||
Reference in new issue
Block a user