mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 02:15:36 +00:00
Merge pull request #860 from kaitranntt/kai/feat/codex-duplicate-email-account-support-refresh
feat: support duplicate-email Codex accounts
This commit is contained in:
36 files changed
+2396
-909
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Provider Integration Flows
|
||||
|
||||
Last Updated: 2026-02-16
|
||||
Last Updated: 2026-03-30
|
||||
|
||||
Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication.
|
||||
|
||||
@@ -76,6 +76,18 @@ CLIProxyAPI is a local OAuth proxy binary that enables seamless integration with
|
||||
| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus |
|
||||
| GitHub Copilot | `ghcp` | Device Code | none | CLIProxyAPIPlus |
|
||||
|
||||
### Codex Duplicate-Email Account Identity
|
||||
|
||||
Codex can legitimately produce multiple auth files for the same email when the user has both a team/business login and a personal/free login. CCS now treats those as separate accounts instead of collapsing them by email.
|
||||
|
||||
- Internal account IDs stay duplicate-aware for Codex only: `email#variant`
|
||||
- Variant keys are derived from the auth filename, for example `kaidu.kd@gmail.com#04a0f049-team` and `kaidu.kd@gmail.com#free`
|
||||
- Dashboard surfaces continue to show the canonical email, with a compact variant badge such as `Team` or `Free`
|
||||
- Quota fetch resolves the exact registry `tokenFile` for the selected account instead of scanning by email and taking the first match
|
||||
- Live usage/account monitor stats key by `provider + account identity`, so duplicate Codex emails no longer merge into one runtime bucket
|
||||
|
||||
This preserves the user-visible distinction between business and personal Codex sessions while keeping other providers on their existing email-backed identity model.
|
||||
|
||||
### Hardcoded Provider Detection
|
||||
|
||||
CCS detects hardcoded providers via `profile-detector.ts` and routes through `execClaudeWithCLIProxy()`.
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { CLIProxyProvider } from '../types';
|
||||
|
||||
const DUPLICATE_EMAIL_ACCOUNT_PROVIDERS = new Set<string>(['codex']);
|
||||
|
||||
// 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.
|
||||
|
||||
function normalizeProvider(provider: CLIProxyProvider | string): string {
|
||||
return provider.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function cleanVariantTokenPart(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '')
|
||||
.replace(/[^a-z0-9._-]+/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function baseNicknameFromEmail(email?: string): string {
|
||||
if (!email) return 'default';
|
||||
return email.split('@')[0].replace(/\s+/g, '').slice(0, 50) || 'default';
|
||||
}
|
||||
|
||||
function formatVariantPart(value: string): string {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (normalized) {
|
||||
case 'team':
|
||||
return 'Team';
|
||||
case 'free':
|
||||
return 'Free';
|
||||
case 'plus':
|
||||
return 'Plus';
|
||||
case 'pro':
|
||||
return 'Pro';
|
||||
default:
|
||||
return /^[a-f0-9]{8}$/i.test(normalized)
|
||||
? normalized
|
||||
: normalized
|
||||
.split(/[._-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsDuplicateEmailAccounts(provider: CLIProxyProvider | string): boolean {
|
||||
return DUPLICATE_EMAIL_ACCOUNT_PROVIDERS.has(normalizeProvider(provider));
|
||||
}
|
||||
|
||||
export function extractCanonicalEmailFromAccountId(accountId: string): string | null {
|
||||
const canonical = accountId.split('#')[0]?.trim();
|
||||
return canonical && canonical.includes('@') ? canonical : null;
|
||||
}
|
||||
|
||||
export function extractEmailAccountVariantKey(
|
||||
provider: CLIProxyProvider | string,
|
||||
tokenFile: string,
|
||||
email?: string
|
||||
): string | null {
|
||||
if (!email || !supportsDuplicateEmailAccounts(provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedProvider = normalizeProvider(provider);
|
||||
const baseName = tokenFile.replace(/\.json$/i, '');
|
||||
const providerPrefix = `${normalizedProvider}-`;
|
||||
const candidate = baseName.toLowerCase().startsWith(providerPrefix)
|
||||
? baseName.slice(providerPrefix.length)
|
||||
: baseName;
|
||||
const emailIndex = candidate.toLowerCase().indexOf(email.toLowerCase());
|
||||
|
||||
if (emailIndex === -1) {
|
||||
const fallback = cleanVariantTokenPart(candidate);
|
||||
return fallback && fallback !== cleanVariantTokenPart(email) ? fallback : null;
|
||||
}
|
||||
|
||||
const before = cleanVariantTokenPart(candidate.slice(0, emailIndex));
|
||||
const after = cleanVariantTokenPart(candidate.slice(emailIndex + email.length));
|
||||
const parts = [before, after].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join('-') : null;
|
||||
}
|
||||
|
||||
export function buildEmailBackedAccountId(
|
||||
provider: CLIProxyProvider | string,
|
||||
tokenFile: string,
|
||||
email?: string,
|
||||
duplicateEmailCount = 1
|
||||
): string {
|
||||
if (!email) {
|
||||
return 'default';
|
||||
}
|
||||
|
||||
if (!supportsDuplicateEmailAccounts(provider) || duplicateEmailCount <= 1) {
|
||||
return email;
|
||||
}
|
||||
|
||||
const variantKey = extractEmailAccountVariantKey(provider, tokenFile, email);
|
||||
return variantKey ? `${email}#${variantKey}` : email;
|
||||
}
|
||||
|
||||
export function buildEmailBackedNickname(
|
||||
provider: CLIProxyProvider | string,
|
||||
tokenFile: string,
|
||||
email?: string,
|
||||
duplicateEmailCount = 1
|
||||
): string {
|
||||
const base = baseNicknameFromEmail(email);
|
||||
if (!supportsDuplicateEmailAccounts(provider) || duplicateEmailCount <= 1) {
|
||||
return base;
|
||||
}
|
||||
|
||||
const variantKey = extractEmailAccountVariantKey(provider, tokenFile, email);
|
||||
if (!variantKey) {
|
||||
return base;
|
||||
}
|
||||
|
||||
return `${base}-${variantKey}`.slice(0, 50);
|
||||
}
|
||||
|
||||
export function formatAccountVariantLabel(accountId: string, email?: string): string | null {
|
||||
const variantKey =
|
||||
extractCanonicalEmailFromAccountId(accountId) === email ? accountId.split('#')[1] : null;
|
||||
if (!variantKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = variantKey.split('-').filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const suffix = parts[parts.length - 1]?.toLowerCase();
|
||||
if (suffix && ['team', 'free', 'plus', 'pro'].includes(suffix)) {
|
||||
return [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
return parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
export function formatAccountDisplayName(account: { id: string; email?: string }): string {
|
||||
const base = account.email || account.id;
|
||||
const variantLabel = formatAccountVariantLabel(account.id, account.email);
|
||||
return variantLabel ? `${base} (${variantLabel})` : base;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import { CLIProxyProvider } from '../types';
|
||||
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { AccountInfo } from './types';
|
||||
import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
|
||||
import { hydrateRegistryFromTokenFiles, loadAccountsRegistry } from './registry';
|
||||
|
||||
/**
|
||||
* Get all accounts for a provider
|
||||
@@ -14,8 +14,8 @@ import { loadAccountsRegistry, syncRegistryWithTokenFiles } from './registry';
|
||||
export function getProviderAccounts(provider: CLIProxyProvider): AccountInfo[] {
|
||||
const registry = loadAccountsRegistry();
|
||||
|
||||
// Sync in-memory view with actual token files without mutating disk on read.
|
||||
syncRegistryWithTokenFiles(registry);
|
||||
// Hydrate the in-memory view from token files without mutating disk on read.
|
||||
hydrateRegistryFromTokenFiles(registry);
|
||||
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
@@ -55,14 +55,16 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
|
||||
const accounts = getProviderAccounts(provider);
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
// Exact match first (id, email, nickname)
|
||||
const exactMatch = accounts.find(
|
||||
(a) =>
|
||||
a.id === query ||
|
||||
a.email?.toLowerCase() === lowerQuery ||
|
||||
a.nickname?.toLowerCase() === lowerQuery
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
const exactIdMatch = accounts.find((a) => a.id === query);
|
||||
if (exactIdMatch) return exactIdMatch;
|
||||
|
||||
const emailMatches = accounts.filter((a) => a.email?.toLowerCase() === lowerQuery);
|
||||
if (emailMatches.length === 1) return emailMatches[0];
|
||||
if (emailMatches.length > 1) return null;
|
||||
|
||||
const nicknameMatches = accounts.filter((a) => a.nickname?.toLowerCase() === lowerQuery);
|
||||
if (nicknameMatches.length === 1) return nicknameMatches[0];
|
||||
if (nicknameMatches.length > 1) return null;
|
||||
|
||||
// Partial match on nickname or email prefix
|
||||
const partialMatches = accounts.filter(
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
deleteTokenFile,
|
||||
listRecoverableTokenFiles,
|
||||
} from './token-file-ops';
|
||||
import { buildEmailBackedAccountId, buildEmailBackedNickname } from './email-account-identity';
|
||||
|
||||
/** Default registry structure */
|
||||
function createDefaultRegistry(): AccountsRegistry {
|
||||
@@ -90,6 +91,16 @@ interface RegistryPopulationIssue {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface ParsedRecoverableTokenFile {
|
||||
tokenFile: string;
|
||||
filePath: string;
|
||||
paused: boolean;
|
||||
provider: CLIProxyProvider;
|
||||
email?: string;
|
||||
projectId: string | null;
|
||||
stats: fs.Stats;
|
||||
}
|
||||
|
||||
function describeRegistryPopulationIssue(issue: RegistryPopulationIssue): string {
|
||||
const sourceDir = issue.paused ? 'auth-paused' : 'auth';
|
||||
return `${sourceDir}/${issue.tokenFile} (${issue.reason})`;
|
||||
@@ -105,10 +116,15 @@ function getRegistryPopulationIssueReason(error: unknown): string {
|
||||
return 'unreadable token file';
|
||||
}
|
||||
|
||||
function populateRegistryFromTokenFiles(
|
||||
registry: AccountsRegistry,
|
||||
options: { includePaused?: boolean } = {}
|
||||
): RegistryPopulationIssue[] {
|
||||
function buildProviderEmailCountKey(provider: CLIProxyProvider, email: string): string {
|
||||
return `${provider}:${email.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function readRecoverableTokenFiles(options: { includePaused?: boolean } = {}): {
|
||||
tokens: ParsedRecoverableTokenFile[];
|
||||
issues: RegistryPopulationIssue[];
|
||||
} {
|
||||
const tokens: ParsedRecoverableTokenFile[] = [];
|
||||
const issues: RegistryPopulationIssue[] = [];
|
||||
|
||||
for (const token of listRecoverableTokenFiles(options)) {
|
||||
@@ -133,69 +149,134 @@ function populateRegistryFromTokenFiles(
|
||||
continue;
|
||||
}
|
||||
|
||||
const providerAccounts = ensureProviderRegistry(registry, provider);
|
||||
const projectId =
|
||||
typeof data.project_id === 'string' && data.project_id.trim()
|
||||
? data.project_id.trim()
|
||||
: null;
|
||||
const email =
|
||||
typeof data.email === 'string' && data.email.trim()
|
||||
? data.email.trim()
|
||||
: inferEmailFromTokenFileName(token.tokenFile, provider);
|
||||
const projectId =
|
||||
typeof data.project_id === 'string' && data.project_id.trim()
|
||||
? data.project_id.trim()
|
||||
: null;
|
||||
|
||||
const existingEntry = Object.entries(providerAccounts.accounts).find(
|
||||
([, account]) => account.tokenFile === token.tokenFile
|
||||
);
|
||||
if (existingEntry) {
|
||||
existingEntry[1].paused = token.paused || undefined;
|
||||
if (!token.paused) {
|
||||
existingEntry[1].pausedAt = undefined;
|
||||
}
|
||||
if (provider === 'agy' && projectId) {
|
||||
existingEntry[1].projectId = projectId;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const accountId =
|
||||
PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email
|
||||
? deriveNoEmailProviderAccountId(provider, token.tokenFile, providerAccounts.accounts)
|
||||
: extractAccountIdFromTokenFile(token.tokenFile, email);
|
||||
|
||||
if (providerAccounts.accounts[accountId]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Object.keys(providerAccounts.accounts).length === 0) {
|
||||
providerAccounts.default = accountId;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(token.filePath);
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email,
|
||||
nickname: email ? generateNickname(email) : accountId,
|
||||
tokens.push({
|
||||
tokenFile: token.tokenFile,
|
||||
createdAt: stats.birthtime?.toISOString() || new Date().toISOString(),
|
||||
lastUsedAt: (stats.mtime || stats.birthtime || new Date()).toISOString(),
|
||||
};
|
||||
|
||||
if (token.paused) {
|
||||
accountMeta.paused = true;
|
||||
}
|
||||
|
||||
if (provider === 'agy' && projectId) {
|
||||
accountMeta.projectId = projectId;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId] = accountMeta;
|
||||
filePath: token.filePath,
|
||||
paused: token.paused,
|
||||
provider,
|
||||
email,
|
||||
projectId,
|
||||
stats: fs.statSync(token.filePath),
|
||||
});
|
||||
} catch (error) {
|
||||
issues.push({
|
||||
tokenFile: token.tokenFile,
|
||||
paused: token.paused,
|
||||
reason: getRegistryPopulationIssueReason(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { tokens, issues };
|
||||
}
|
||||
|
||||
function buildDuplicateEmailCounts(
|
||||
tokens: ParsedRecoverableTokenFile[]
|
||||
): ReadonlyMap<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const token of tokens) {
|
||||
if (!token.email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = buildProviderEmailCountKey(token.provider, token.email);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
function populateRegistryFromTokenFiles(
|
||||
registry: AccountsRegistry,
|
||||
options: { includePaused?: boolean } = {}
|
||||
): RegistryPopulationIssue[] {
|
||||
const { tokens, issues } = readRecoverableTokenFiles(options);
|
||||
const duplicateEmailCounts = buildDuplicateEmailCounts(tokens);
|
||||
|
||||
for (const token of tokens) {
|
||||
const providerAccounts = ensureProviderRegistry(registry, token.provider);
|
||||
const existingEntry = Object.entries(providerAccounts.accounts).find(
|
||||
([, account]) => account.tokenFile === token.tokenFile
|
||||
);
|
||||
const existingAccountId = existingEntry?.[0];
|
||||
const existingAccount = existingEntry?.[1];
|
||||
const resolvedEmail = token.email ?? existingAccount?.email;
|
||||
const duplicateEmailCount = resolvedEmail
|
||||
? (duplicateEmailCounts.get(buildProviderEmailCountKey(token.provider, resolvedEmail)) ?? 1)
|
||||
: 1;
|
||||
|
||||
const desiredAccountId =
|
||||
PROVIDERS_WITHOUT_EMAIL.includes(token.provider) && !resolvedEmail
|
||||
? deriveNoEmailProviderAccountId(token.provider, token.tokenFile, providerAccounts.accounts)
|
||||
: !token.email && existingAccountId
|
||||
? existingAccountId
|
||||
: buildEmailBackedAccountId(
|
||||
token.provider,
|
||||
token.tokenFile,
|
||||
resolvedEmail,
|
||||
duplicateEmailCount
|
||||
);
|
||||
|
||||
if (existingEntry && existingEntry[0] !== desiredAccountId) {
|
||||
if (!providerAccounts.accounts[desiredAccountId]) {
|
||||
providerAccounts.accounts[desiredAccountId] = existingEntry[1];
|
||||
}
|
||||
if (providerAccounts.default === existingEntry[0]) {
|
||||
providerAccounts.default = desiredAccountId;
|
||||
}
|
||||
delete providerAccounts.accounts[existingEntry[0]];
|
||||
}
|
||||
|
||||
if (Object.keys(providerAccounts.accounts).length === 0) {
|
||||
providerAccounts.default = desiredAccountId;
|
||||
}
|
||||
|
||||
const hydratedAccount = providerAccounts.accounts[desiredAccountId];
|
||||
const accountMeta: Omit<AccountInfo, 'id' | 'provider' | 'isDefault'> = {
|
||||
email: resolvedEmail,
|
||||
nickname:
|
||||
hydratedAccount?.nickname ||
|
||||
(resolvedEmail
|
||||
? buildEmailBackedNickname(
|
||||
token.provider,
|
||||
token.tokenFile,
|
||||
resolvedEmail,
|
||||
duplicateEmailCount
|
||||
)
|
||||
: desiredAccountId),
|
||||
tokenFile: token.tokenFile,
|
||||
createdAt:
|
||||
hydratedAccount?.createdAt ||
|
||||
token.stats.birthtime?.toISOString() ||
|
||||
new Date().toISOString(),
|
||||
lastUsedAt:
|
||||
hydratedAccount?.lastUsedAt ||
|
||||
(token.stats.mtime || token.stats.birthtime || new Date()).toISOString(),
|
||||
};
|
||||
|
||||
if (token.paused) {
|
||||
accountMeta.paused = true;
|
||||
accountMeta.pausedAt = hydratedAccount?.pausedAt || new Date().toISOString();
|
||||
} else {
|
||||
accountMeta.paused = undefined;
|
||||
accountMeta.pausedAt = undefined;
|
||||
}
|
||||
|
||||
if (token.provider === 'agy') {
|
||||
accountMeta.projectId = token.projectId || hydratedAccount?.projectId;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[desiredAccountId] = accountMeta;
|
||||
}
|
||||
|
||||
return issues;
|
||||
@@ -384,6 +465,17 @@ export function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean
|
||||
return modified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory view that includes both stale-entry cleanup and any token
|
||||
* files not yet persisted into accounts.json. Used by read paths so duplicate
|
||||
* email accounts stay visible without forcing a disk write.
|
||||
*/
|
||||
export function hydrateRegistryFromTokenFiles(registry: AccountsRegistry): boolean {
|
||||
const removedStaleEntries = syncRegistryWithTokenFiles(registry);
|
||||
const populationIssues = populateRegistryFromTokenFiles(registry);
|
||||
return removedStaleEntries || populationIssues.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new account
|
||||
* Called after successful OAuth to record the account
|
||||
@@ -448,8 +540,60 @@ export function registerAccount(
|
||||
accountNickname =
|
||||
nickname || existingAccount?.nickname || (email ? generateNickname(email) : accountId);
|
||||
} else {
|
||||
accountId = extractAccountIdFromTokenFile(tokenFile, email);
|
||||
accountNickname = nickname || generateNickname(email);
|
||||
const sameEmailEntries = email
|
||||
? Object.entries(providerAccounts.accounts).filter(
|
||||
([, account]) => account.email?.toLowerCase() === email.toLowerCase()
|
||||
)
|
||||
: [];
|
||||
const duplicateEmailCount = email
|
||||
? new Set([...sameEmailEntries.map(([, account]) => account.tokenFile), tokenFile]).size
|
||||
: 1;
|
||||
|
||||
if (email && duplicateEmailCount > 1) {
|
||||
for (const [existingId, existingMeta] of sameEmailEntries) {
|
||||
const migratedId = buildEmailBackedAccountId(
|
||||
provider,
|
||||
existingMeta.tokenFile,
|
||||
email,
|
||||
duplicateEmailCount
|
||||
);
|
||||
if (migratedId === existingId || providerAccounts.accounts[migratedId]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[migratedId] = existingMeta;
|
||||
if (providerAccounts.default === existingId) {
|
||||
providerAccounts.default = migratedId;
|
||||
}
|
||||
delete providerAccounts.accounts[existingId];
|
||||
}
|
||||
}
|
||||
|
||||
accountId = buildEmailBackedAccountId(provider, tokenFile, email, duplicateEmailCount);
|
||||
const existingAccount = providerAccounts.accounts[accountId];
|
||||
|
||||
if (nickname) {
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
|
||||
const existingAccounts = Object.entries(providerAccounts.accounts).map(([id, account]) => ({
|
||||
id,
|
||||
nickname: account.nickname,
|
||||
}));
|
||||
if (hasAccountNameConflict(existingAccounts, nickname, accountId)) {
|
||||
throw new Error(
|
||||
`An account with nickname "${nickname}" already exists for ${provider}. ` +
|
||||
`Choose a different nickname.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
accountNickname =
|
||||
nickname ||
|
||||
existingAccount?.nickname ||
|
||||
buildEmailBackedNickname(provider, tokenFile, email, duplicateEmailCount);
|
||||
}
|
||||
|
||||
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
|
||||
import { getProviderAuthDir } from '../config-generator';
|
||||
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
|
||||
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
|
||||
import { buildEmailBackedAccountId } from '../accounts/email-account-identity';
|
||||
import {
|
||||
AuthStatus,
|
||||
PROVIDER_AUTH_PREFIXES,
|
||||
@@ -215,6 +216,7 @@ export function registerAccountFromToken(
|
||||
mtimeMs: number;
|
||||
alreadyRegistered: boolean;
|
||||
};
|
||||
type RawTokenCandidate = Omit<TokenCandidate, 'accountId'>;
|
||||
|
||||
const { registerAccount } = require('../account-manager');
|
||||
let selectedCandidate: Omit<TokenCandidate, 'mtimeMs'> | null = null;
|
||||
@@ -222,33 +224,71 @@ export function registerAccountFromToken(
|
||||
const files = fs.readdirSync(tokenDir);
|
||||
const jsonFiles = files.filter((f: string) => f.endsWith('.json'));
|
||||
const existingAccounts = getProviderAccounts(provider);
|
||||
const candidates: TokenCandidate[] = jsonFiles
|
||||
.map((file): TokenCandidate | null => {
|
||||
const filePath = path.join(tokenDir, file);
|
||||
if (!isTokenFileForProvider(filePath, provider)) return null;
|
||||
const rawCandidates: RawTokenCandidate[] = jsonFiles.flatMap((file) => {
|
||||
const filePath = path.join(tokenDir, file);
|
||||
if (!isTokenFileForProvider(filePath, provider)) return [];
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as { email?: string; project_id?: string };
|
||||
const email = data.email || undefined;
|
||||
const projectId = data.project_id || undefined;
|
||||
const accountId = extractAccountIdFromTokenFile(file, email);
|
||||
const stats = fs.statSync(filePath);
|
||||
return {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content) as { email?: string; project_id?: string };
|
||||
const email = data.email || undefined;
|
||||
const projectId = data.project_id || undefined;
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
return [
|
||||
{
|
||||
file,
|
||||
filePath,
|
||||
email,
|
||||
projectId,
|
||||
accountId,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
alreadyRegistered: existingAccounts.some((account) => account.tokenFile === file),
|
||||
},
|
||||
];
|
||||
});
|
||||
const duplicateEmailCounts = new Map<string, number>();
|
||||
const duplicateEmailTokenSets = new Map<string, Set<string>>();
|
||||
for (const account of existingAccounts) {
|
||||
if (!account.email) continue;
|
||||
const key = account.email.toLowerCase();
|
||||
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
|
||||
tokenSet.add(account.tokenFile);
|
||||
duplicateEmailTokenSets.set(key, tokenSet);
|
||||
}
|
||||
for (const candidate of rawCandidates) {
|
||||
if (!candidate.email) continue;
|
||||
const key = candidate.email.toLowerCase();
|
||||
const tokenSet = duplicateEmailTokenSets.get(key) ?? new Set<string>();
|
||||
tokenSet.add(candidate.file);
|
||||
duplicateEmailTokenSets.set(key, tokenSet);
|
||||
}
|
||||
for (const [key, tokenSet] of duplicateEmailTokenSets) {
|
||||
duplicateEmailCounts.set(key, tokenSet.size);
|
||||
}
|
||||
const candidates: TokenCandidate[] = rawCandidates
|
||||
.map((rawCandidate) => {
|
||||
const duplicateEmailCount = rawCandidate.email
|
||||
? (duplicateEmailCounts.get(rawCandidate.email.toLowerCase()) ?? 1)
|
||||
: 1;
|
||||
const accountId = rawCandidate.email
|
||||
? buildEmailBackedAccountId(
|
||||
provider,
|
||||
rawCandidate.file,
|
||||
rawCandidate.email,
|
||||
duplicateEmailCount
|
||||
)
|
||||
: extractAccountIdFromTokenFile(rawCandidate.file, rawCandidate.email);
|
||||
|
||||
return {
|
||||
...rawCandidate,
|
||||
accountId,
|
||||
};
|
||||
})
|
||||
.filter((candidate): candidate is TokenCandidate => candidate !== null)
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
|
||||
if (expectedAccountId) {
|
||||
selectedCandidate =
|
||||
candidates.find((candidate) => candidate.accountId === expectedAccountId) ||
|
||||
candidates.find((candidate) => candidate.file === expectedAccountId) ||
|
||||
candidates.find((candidate) => {
|
||||
const existingAccount = existingAccounts.find(
|
||||
(account) => account.id === expectedAccountId
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
renameAccount,
|
||||
getDefaultAccount,
|
||||
} from '../account-manager';
|
||||
import { formatAccountDisplayName } from '../accounts/email-account-identity';
|
||||
import {
|
||||
ensureMcpWebSearch,
|
||||
installWebSearchHook,
|
||||
@@ -422,7 +423,7 @@ export async function execClaudeWithCLIProxy(
|
||||
for (const acct of accounts) {
|
||||
const defaultMark = acct.isDefault ? ' (default)' : '';
|
||||
const nickname = acct.nickname ? `[${acct.nickname}]` : '';
|
||||
console.log(` ${nickname.padEnd(12)} ${acct.email || acct.id}${defaultMark}`);
|
||||
console.log(` ${nickname.padEnd(12)} ${formatAccountDisplayName(acct)}${defaultMark}`);
|
||||
}
|
||||
console.log(`\n Use "ccs ${provider} --use <nickname-or-id>" to switch accounts`);
|
||||
}
|
||||
@@ -438,14 +439,19 @@ export async function execClaudeWithCLIProxy(
|
||||
if (accounts.length > 0) {
|
||||
console.error(` Available accounts:`);
|
||||
for (const acct of accounts) {
|
||||
console.error(` - ${acct.nickname || acct.id} (${acct.email || 'no email'})`);
|
||||
const displayName = formatAccountDisplayName(acct);
|
||||
const label = acct.nickname ? `${acct.nickname} (${displayName})` : displayName;
|
||||
console.error(` - ${label}`);
|
||||
}
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
setDefaultAccount(provider, account.id);
|
||||
touchAccount(provider, account.id);
|
||||
console.log(ok(`Switched to account: ${account.nickname || account.email || account.id}`));
|
||||
const switchedLabel = account.nickname
|
||||
? `${account.nickname} (${formatAccountDisplayName(account)})`
|
||||
: formatAccountDisplayName(account);
|
||||
console.log(ok(`Switched to account: ${switchedLabel}`));
|
||||
}
|
||||
|
||||
// Handle --nickname (rename account)
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getAuthDir } from './config-generator';
|
||||
import { getProviderAccounts, getPausedDir } from './account-manager';
|
||||
import { getAccount, getProviderAccounts, getPausedDir } from './account-manager';
|
||||
import { sanitizeEmail, isTokenExpired } from './auth-utils';
|
||||
import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types';
|
||||
import { extractCanonicalEmailFromAccountId } from './accounts/email-account-identity';
|
||||
|
||||
/** ChatGPT backend API base URL */
|
||||
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
|
||||
@@ -174,9 +175,46 @@ export function buildCodexCoreUsageSummary(windows: CodexQuotaWindow[]): CodexCo
|
||||
/**
|
||||
* Read auth data from Codex auth file
|
||||
*/
|
||||
function readCodexAuthFile(filePath: string): CodexAuthData | null {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (!data.access_token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
accountId: data.account_id || data.accountId || '',
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCodexAuthData(accountId: string): CodexAuthData | null {
|
||||
const authDirs = [getAuthDir(), getPausedDir()];
|
||||
const sanitizedId = sanitizeEmail(accountId);
|
||||
const registryAccount = getAccount('codex', accountId);
|
||||
const canonicalEmail = extractCanonicalEmailFromAccountId(accountId);
|
||||
const hasExplicitVariant = canonicalEmail !== null && canonicalEmail !== accountId;
|
||||
if (registryAccount?.tokenFile) {
|
||||
for (const authDir of authDirs) {
|
||||
const filePath = path.join(authDir, registryAccount.tokenFile);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const authData = readCodexAuthFile(filePath);
|
||||
if (authData) {
|
||||
return authData;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacyEmail = canonicalEmail ?? accountId;
|
||||
const sanitizedId = sanitizeEmail(legacyEmail);
|
||||
const expectedFile = `codex-${sanitizedId}.json`;
|
||||
|
||||
for (const authDir of authDirs) {
|
||||
@@ -184,23 +222,19 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
|
||||
|
||||
const filePath = path.join(authDir, expectedFile);
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (!data.access_token) continue;
|
||||
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
accountId: data.account_id || data.accountId || '',
|
||||
isExpired: isTokenExpired(data.expired),
|
||||
expiresAt: data.expired || null,
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
const authData = readCodexAuthFile(filePath);
|
||||
if (authData) {
|
||||
return authData;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan directory for matching email in file content
|
||||
// Fallback is only safe for legacy email-only IDs. Variant-backed IDs must resolve
|
||||
// through the registry-backed token file so duplicate-email accounts stay deterministic.
|
||||
if (hasExplicitVariant) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: scan directory for matching email in file content.
|
||||
const files = fs.readdirSync(authDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('codex-') && file.endsWith('.json')) {
|
||||
@@ -208,7 +242,7 @@ function readCodexAuthData(accountId: string): CodexAuthData | null {
|
||||
try {
|
||||
const content = fs.readFileSync(candidatePath, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
if (data.email === accountId && data.access_token) {
|
||||
if (data.email === legacyEmail && data.access_token) {
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
accountId: data.account_id || data.accountId || '',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { buildQualifiedAccountStatsKey } from './account-stats-key';
|
||||
import { mapExternalProviderName } from './provider-capabilities';
|
||||
import { buildEmailBackedAccountId } from './accounts/email-account-identity';
|
||||
import type {
|
||||
AccountUsageStats,
|
||||
CliproxyManagementAuthFile,
|
||||
@@ -15,6 +16,9 @@ interface BuildCliproxyStatsOptions {
|
||||
interface ResolvedAuthFile {
|
||||
provider?: string;
|
||||
source?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
duplicateEmailCount?: number;
|
||||
}
|
||||
|
||||
function normalizeProvider(provider: string): string {
|
||||
@@ -30,6 +34,16 @@ function buildAuthIndexLookup(
|
||||
authFiles: CliproxyManagementAuthFile[] | undefined
|
||||
): ReadonlyMap<string, ResolvedAuthFile> {
|
||||
const lookup = new Map<string, ResolvedAuthFile>();
|
||||
const duplicateEmailCounts = new Map<string, number>();
|
||||
|
||||
for (const authFile of authFiles ?? []) {
|
||||
if (!authFile.provider || !authFile.email) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${normalizeProvider(authFile.provider)}:${authFile.email.trim().toLowerCase()}`;
|
||||
duplicateEmailCounts.set(key, (duplicateEmailCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
|
||||
for (const authFile of authFiles ?? []) {
|
||||
if (authFile.auth_index === undefined || authFile.auth_index === null) {
|
||||
@@ -37,6 +51,8 @@ function buildAuthIndexLookup(
|
||||
}
|
||||
|
||||
const provider = authFile.provider ? normalizeProvider(authFile.provider) : undefined;
|
||||
const email = authFile.email?.trim() || undefined;
|
||||
const name = authFile.name?.trim() || undefined;
|
||||
const source = authFile.email?.trim() || authFile.name?.trim() || undefined;
|
||||
if (!provider && !source) {
|
||||
continue;
|
||||
@@ -45,6 +61,12 @@ function buildAuthIndexLookup(
|
||||
lookup.set(String(authFile.auth_index), {
|
||||
provider,
|
||||
source,
|
||||
email,
|
||||
name,
|
||||
duplicateEmailCount:
|
||||
provider && email
|
||||
? (duplicateEmailCounts.get(`${provider}:${email.toLowerCase()}`) ?? 1)
|
||||
: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,15 +87,29 @@ function resolveProviderForDetail(
|
||||
}
|
||||
|
||||
function resolveSourceForDetail(
|
||||
resolvedProvider: string,
|
||||
detail: CliproxyRequestDetail,
|
||||
authIndexLookup: ReadonlyMap<string, ResolvedAuthFile>
|
||||
): string {
|
||||
const resolvedAuthFile = authIndexLookup.get(String(detail.auth_index));
|
||||
if (resolvedAuthFile?.email && resolvedAuthFile?.name) {
|
||||
const derivedSource = buildEmailBackedAccountId(
|
||||
resolvedProvider,
|
||||
resolvedAuthFile.name,
|
||||
resolvedAuthFile.email,
|
||||
resolvedAuthFile.duplicateEmailCount ?? 1
|
||||
);
|
||||
if (derivedSource) {
|
||||
return derivedSource;
|
||||
}
|
||||
}
|
||||
|
||||
const source = detail.source?.trim();
|
||||
if (source) {
|
||||
return source;
|
||||
}
|
||||
|
||||
return authIndexLookup.get(String(detail.auth_index))?.source ?? 'unknown';
|
||||
return resolvedAuthFile?.source ?? 'unknown';
|
||||
}
|
||||
|
||||
export function buildCliproxyStatsFromUsageResponse(
|
||||
@@ -110,8 +146,8 @@ export function buildCliproxyStatsFromUsageResponse(
|
||||
for (const detail of modelData.details) {
|
||||
sawAnyDetail = true;
|
||||
sawProviderDetail = true;
|
||||
const source = resolveSourceForDetail(detail, authIndexLookup);
|
||||
const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup);
|
||||
const source = resolveSourceForDetail(resolvedProvider, detail, authIndexLookup);
|
||||
const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source);
|
||||
requestsByProvider[resolvedProvider] = (requestsByProvider[resolvedProvider] ?? 0) + 1;
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
QUOTA_SUPPORTED_PROVIDER_IDS,
|
||||
type QuotaSupportedProvider,
|
||||
} from '../../cliproxy/provider-capabilities';
|
||||
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
|
||||
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
|
||||
|
||||
interface CliproxyProfileArgs {
|
||||
@@ -97,6 +98,11 @@ function formatResetTimeISO(isoTime: string): string {
|
||||
return formatResetTime(seconds);
|
||||
}
|
||||
|
||||
function formatCliAccountLabel(account: { id: string; email?: string; nickname?: string }): string {
|
||||
const displayName = formatAccountDisplayName(account);
|
||||
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
|
||||
}
|
||||
|
||||
interface QuotaFailureDisplayEntry {
|
||||
tone: 'error' | 'info' | 'dim';
|
||||
text: string;
|
||||
@@ -360,13 +366,7 @@ function displayAntigravityQuotaSection(
|
||||
const tier = account.tier || 'unknown';
|
||||
const status = statusParts.join(', ');
|
||||
|
||||
rows.push([
|
||||
defaultMark,
|
||||
account.nickname || account.email || account.id,
|
||||
tier,
|
||||
avgQuota,
|
||||
status,
|
||||
]);
|
||||
rows.push([defaultMark, formatCliAccountLabel(account), tier, avgQuota, status]);
|
||||
}
|
||||
|
||||
console.log(
|
||||
@@ -384,10 +384,11 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('codex', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(account)}${defaultMark}`);
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
@@ -406,7 +407,7 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
|
||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
|
||||
|
||||
const coreUsageSummary = quota.coreUsage ?? {
|
||||
fiveHour: fiveHourWindow
|
||||
@@ -539,10 +540,11 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('claude', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(account)}${defaultMark}`);
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
@@ -562,7 +564,7 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot
|
||||
const statusIcon =
|
||||
minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}`);
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
|
||||
|
||||
const resetParts: string[] = [];
|
||||
if (fiveHourWindow?.resetAt)
|
||||
@@ -616,10 +618,11 @@ function displayGeminiCliQuotaSection(
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('gemini', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(account)}${defaultMark}`);
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
@@ -631,7 +634,7 @@ function displayGeminiCliQuotaSection(
|
||||
: 0;
|
||||
const statusIcon = avgQuota > 50 ? ok('') : avgQuota > 10 ? warn('') : fail('');
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}`);
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}`);
|
||||
if (quota.projectId) {
|
||||
console.log(` Project: ${dim(quota.projectId)}`);
|
||||
}
|
||||
@@ -667,10 +670,11 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
|
||||
|
||||
for (const { account, quota } of results) {
|
||||
const accountInfo = findAccountByQuery('ghcp', account);
|
||||
const accountLabel = accountInfo ? formatCliAccountLabel(accountInfo) : account;
|
||||
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
console.log(` ${fail(account)}${defaultMark}`);
|
||||
console.log(` ${fail(accountLabel)}${defaultMark}`);
|
||||
displayQuotaFailure(quota);
|
||||
console.log('');
|
||||
continue;
|
||||
@@ -685,7 +689,7 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
|
||||
const statusIcon = minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
|
||||
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
|
||||
console.log(` ${statusIcon}${accountLabel}${defaultMark}${planBadge}`);
|
||||
if (quota.quotaResetDate) {
|
||||
console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`);
|
||||
}
|
||||
@@ -840,7 +844,7 @@ export async function handleDoctor(verbose = false): Promise<void> {
|
||||
const quotaResult = await fetchAllProviderQuotas(provider, verbose);
|
||||
|
||||
for (const { account, quota } of quotaResult.accounts) {
|
||||
const accountLabel = account.email || account.id || 'Unknown Account';
|
||||
const accountLabel = formatCliAccountLabel(account);
|
||||
const defaultBadge = account.isDefault ? color(' (default)', 'info') : '';
|
||||
|
||||
if (!quota.success) {
|
||||
@@ -933,7 +937,7 @@ export async function handleSetDefault(args: string[]): Promise<void> {
|
||||
console.log('Available accounts:');
|
||||
for (const acc of accounts) {
|
||||
const badge = acc.isDefault ? color(' (current default)', 'info') : '';
|
||||
console.log(` - ${acc.email || acc.id}${badge}`);
|
||||
console.log(` - ${formatCliAccountLabel(acc)}${badge}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`No accounts found for provider: ${provider}`);
|
||||
@@ -945,7 +949,7 @@ export async function handleSetDefault(args: string[]): Promise<void> {
|
||||
const success = setDefaultAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Default account set to: ${account.email || account.id}`));
|
||||
console.log(ok(`Default account set to: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info(`Provider: ${provider}`));
|
||||
} else {
|
||||
console.log(fail('Failed to set default account'));
|
||||
@@ -973,7 +977,7 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
if (account.paused) {
|
||||
console.log(warn(`Account already paused: ${account.email || account.id}`));
|
||||
console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`));
|
||||
return;
|
||||
}
|
||||
@@ -981,7 +985,7 @@ export async function handlePauseAccount(args: string[]): Promise<void> {
|
||||
const success = pauseAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Account paused: ${account.email || account.id}`));
|
||||
console.log(ok(`Account paused: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info('Account will be skipped in quota rotation'));
|
||||
} else {
|
||||
console.log(fail('Failed to pause account'));
|
||||
@@ -1009,14 +1013,14 @@ export async function handleResumeAccount(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
if (!account.paused) {
|
||||
console.log(warn(`Account is not paused: ${account.email || account.id}`));
|
||||
console.log(warn(`Account is not paused: ${formatCliAccountLabel(account)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const success = resumeAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Account resumed: ${account.email || account.id}`));
|
||||
console.log(ok(`Account resumed: ${formatCliAccountLabel(account)}`));
|
||||
console.log(info('Account is now active in quota rotation'));
|
||||
} else {
|
||||
console.log(fail('Failed to resume account'));
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '../../cliproxy/services';
|
||||
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
|
||||
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
|
||||
|
||||
interface CliproxyProfileArgs {
|
||||
name?: string;
|
||||
@@ -118,6 +119,15 @@ function getBackendLabel(backend: CLIProxyBackend): string {
|
||||
return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy';
|
||||
}
|
||||
|
||||
function formatVariantAccountLabel(account: {
|
||||
id: string;
|
||||
email?: string;
|
||||
nickname?: string;
|
||||
}): string {
|
||||
const displayName = formatAccountDisplayName(account);
|
||||
return account.nickname ? `${account.nickname} (${displayName})` : displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive prompt to select provider + model for a single tier.
|
||||
* Returns a CompositeTierConfig, or null if user cancelled auth.
|
||||
@@ -158,7 +168,7 @@ async function selectTierConfig(
|
||||
console.log(fail('Authentication failed'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
|
||||
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
|
||||
}
|
||||
|
||||
// Select model
|
||||
@@ -364,7 +374,7 @@ export async function handleCreate(
|
||||
}
|
||||
account = newAccount.id;
|
||||
console.log('');
|
||||
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
|
||||
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
|
||||
} else if (providerAccounts.length === 1) {
|
||||
account = providerAccounts[0].id;
|
||||
} else {
|
||||
@@ -372,7 +382,7 @@ export async function handleCreate(
|
||||
const accountOptions = [
|
||||
...providerAccounts.map((acc) => ({
|
||||
id: acc.id,
|
||||
label: `${acc.email || acc.id}${acc.isDefault ? ' (default)' : ''}`,
|
||||
label: `${formatVariantAccountLabel(acc)}${acc.isDefault ? ' (default)' : ''}`,
|
||||
})),
|
||||
{ id: ADD_NEW_ID, label: color('[+ Add new account...]', 'info') },
|
||||
];
|
||||
@@ -394,7 +404,7 @@ export async function handleCreate(
|
||||
}
|
||||
account = newAccount.id;
|
||||
console.log('');
|
||||
console.log(ok(`Authenticated as ${newAccount.email || newAccount.id}`));
|
||||
console.log(ok(`Authenticated as ${formatVariantAccountLabel(newAccount)}`));
|
||||
} else {
|
||||
account = selectedAccount;
|
||||
}
|
||||
@@ -406,7 +416,7 @@ export async function handleCreate(
|
||||
console.log('');
|
||||
console.log('Available accounts:');
|
||||
providerAccounts.forEach((a) =>
|
||||
console.log(` - ${a.email || a.id}${a.isDefault ? ' (default)' : ''}`)
|
||||
console.log(` - ${formatVariantAccountLabel(a)}${a.isDefault ? ' (default)' : ''}`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -450,9 +460,13 @@ export async function handleCreate(
|
||||
? '~/.ccs/config.yaml'
|
||||
: `~/.ccs/${path.basename(result.settingsPath || '')}`;
|
||||
const portInfo = result.variant?.port ? `Port: ${result.variant.port}\n` : '';
|
||||
const selectedAccount =
|
||||
account && provider
|
||||
? getProviderAccounts(provider as CLIProxyProvider).find((acc) => acc.id === account)
|
||||
: null;
|
||||
console.log(
|
||||
infoBox(
|
||||
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
|
||||
`Variant: ${name}\nProvider: ${provider}\nModel: ${model}\nTarget: ${resolvedTarget}\n${portInfo}${selectedAccount ? `Account: ${formatVariantAccountLabel(selectedAccount)}\n` : account ? `Account: ${account}\n` : ''}${isUnifiedMode() ? 'Config' : 'Settings'}: ${settingsDisplay}`,
|
||||
configType
|
||||
)
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
bulkResumeAccounts,
|
||||
soloAccount,
|
||||
} from '../../cliproxy/account-manager';
|
||||
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
|
||||
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
||||
import {
|
||||
DEFAULT_ACCOUNT_CONTINUITY_MODE,
|
||||
@@ -122,7 +123,7 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
continue;
|
||||
}
|
||||
// Use unique ID for key to prevent collisions between accounts with same nickname/email
|
||||
const displayName = acct.nickname || acct.email || acct.id;
|
||||
const displayName = acct.nickname || formatAccountDisplayName(acct);
|
||||
const rawKey = `${provider}:${acct.id}`;
|
||||
const key = buildCliproxyAccountKey(rawKey, merged);
|
||||
if (!key) {
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
pauseAccount as pauseAccountFn,
|
||||
resumeAccount as resumeAccountFn,
|
||||
touchAccount,
|
||||
extractAccountIdFromTokenFile,
|
||||
hasAccountNameConflict,
|
||||
PROVIDERS_WITHOUT_EMAIL,
|
||||
validateNickname,
|
||||
@@ -932,7 +931,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
||||
getProviderTokenDir(localProvider),
|
||||
pendingAuth.nickname,
|
||||
false,
|
||||
extractAccountIdFromTokenFile(tokenSnapshot.file, tokenSnapshot.email)
|
||||
tokenSnapshot.file
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
|
||||
@@ -116,4 +116,41 @@ describe('registerAccount optional nickname flow', () => {
|
||||
|
||||
expect(match).toBeNull();
|
||||
});
|
||||
|
||||
it('treats duplicate Codex emails as distinct accounts and rejects bare-email lookups', async () => {
|
||||
const result = await withIsolatedHome(async (homeDir) => {
|
||||
writeTokenFile(homeDir, 'codex-04a0f049-kaidu.kd@gmail.com-team.json');
|
||||
writeTokenFile(homeDir, 'codex-kaidu.kd@gmail.com-free.json');
|
||||
const { registerAccount, getProviderAccounts, findAccountByQuery } = await loadAccountManager();
|
||||
const team = registerAccount(
|
||||
'codex',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
const free = registerAccount(
|
||||
'codex',
|
||||
'codex-kaidu.kd@gmail.com-free.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
|
||||
return {
|
||||
team,
|
||||
free,
|
||||
accounts: getProviderAccounts('codex'),
|
||||
ambiguousLookup: findAccountByQuery('codex', 'kaidu.kd@gmail.com'),
|
||||
teamLookup: findAccountByQuery('codex', 'kaidu.kd@gmail.com#04a0f049-team'),
|
||||
freeLookup: findAccountByQuery('codex', free.id),
|
||||
};
|
||||
});
|
||||
|
||||
expect(result.team.id).toBe('kaidu.kd@gmail.com');
|
||||
expect(result.free.id).toBe('kaidu.kd@gmail.com#free');
|
||||
expect(result.accounts.map((account) => account.id).sort()).toEqual([
|
||||
'kaidu.kd@gmail.com#04a0f049-team',
|
||||
'kaidu.kd@gmail.com#free',
|
||||
]);
|
||||
expect(result.ambiguousLookup).toBeNull();
|
||||
expect(result.teamLookup?.id).toBe('kaidu.kd@gmail.com#04a0f049-team');
|
||||
expect(result.freeLookup?.id).toBe('kaidu.kd@gmail.com#free');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ let buildCodexQuotaWindows: typeof import('../../../src/cliproxy/quota-fetcher-c
|
||||
let buildCodexCoreUsageSummary: typeof import('../../../src/cliproxy/quota-fetcher-codex').buildCodexCoreUsageSummary;
|
||||
let fetchCodexQuota: typeof import('../../../src/cliproxy/quota-fetcher-codex').fetchCodexQuota;
|
||||
let getUnknownCodexWindowLabels: typeof import('../../../src/cliproxy/quota-fetcher-codex').getUnknownCodexWindowLabels;
|
||||
let registerAccount: typeof import('../../../src/cliproxy/account-manager').registerAccount;
|
||||
|
||||
function createCodexAccount(
|
||||
accountId: string,
|
||||
@@ -40,6 +41,7 @@ beforeEach(async () => {
|
||||
);
|
||||
mock.module('../../../src/cliproxy/config-generator', () => configGenerator);
|
||||
mock.module('../../../src/cliproxy/account-manager', () => accountManager);
|
||||
({ registerAccount } = accountManager);
|
||||
|
||||
({
|
||||
buildCodexQuotaWindows,
|
||||
@@ -363,14 +365,18 @@ describe('Codex Quota Fetcher', () => {
|
||||
});
|
||||
|
||||
describe('fetchCodexQuota failure mapping', () => {
|
||||
function createValidCodexAccount(email: string, accountId = `workspace-${email}`): void {
|
||||
function createValidCodexAccount(
|
||||
email: string,
|
||||
accountId = `workspace-${email}`,
|
||||
tokenFile?: string
|
||||
): void {
|
||||
createCodexAccount(email, {
|
||||
access_token: 'test-token',
|
||||
account_id: accountId,
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
email,
|
||||
type: 'codex',
|
||||
});
|
||||
}, tokenFile);
|
||||
}
|
||||
|
||||
it('maps deactivated workspace 402 responses to structured metadata', async () => {
|
||||
@@ -409,6 +415,92 @@ describe('Codex Quota Fetcher', () => {
|
||||
expect(result.actionHint).toContain('ccs cliproxy auth codex');
|
||||
});
|
||||
|
||||
it('uses the registry token file for duplicate-email Codex accounts', async () => {
|
||||
createValidCodexAccount(
|
||||
'kaidu.kd@gmail.com',
|
||||
'workspace-team',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json'
|
||||
);
|
||||
createValidCodexAccount(
|
||||
'kaidu.kd@gmail.com',
|
||||
'workspace-free',
|
||||
'codex-kaidu.kd@gmail.com-free.json'
|
||||
);
|
||||
|
||||
registerAccount(
|
||||
'codex',
|
||||
'codex-04a0f049-kaidu.kd@gmail.com-team.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
const freeAccount = registerAccount(
|
||||
'codex',
|
||||
'codex-kaidu.kd@gmail.com-free.json',
|
||||
'kaidu.kd@gmail.com'
|
||||
);
|
||||
|
||||
const fetchSpy = mock((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
plan_type: 'free',
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10, reset_after_seconds: 3600 },
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const result = await fetchCodexQuota(freeAccount.id);
|
||||
const requestInit = fetchSpy.mock.calls[0]?.[1] as RequestInit | undefined;
|
||||
const headers = new Headers(requestInit?.headers);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(headers.get('ChatGPT-Account-Id')).toBe('workspace-free');
|
||||
});
|
||||
|
||||
it('does not guess a duplicate-email Codex auth file when the registry entry is missing', async () => {
|
||||
createValidCodexAccount(
|
||||
'kaidu.kd@gmail.com',
|
||||
'workspace-team',
|
||||
'codex-legacy-slot-a.json'
|
||||
);
|
||||
createValidCodexAccount(
|
||||
'kaidu.kd@gmail.com',
|
||||
'workspace-free',
|
||||
'codex-legacy-slot-b.json'
|
||||
);
|
||||
|
||||
const fetchSpy = mock(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
plan_type: 'free',
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10, reset_after_seconds: 3600 },
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
)
|
||||
) as typeof fetch;
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const result = await fetchCodexQuota('kaidu.kd@gmail.com#04a0f049-team');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errorCode).toBe('auth_file_missing');
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps 403 responses to forbidden metadata', async () => {
|
||||
createValidCodexAccount('forbidden@example.com', 'workspace-forbidden');
|
||||
|
||||
|
||||
@@ -288,4 +288,45 @@ describe('buildCliproxyStatsFromUsageResponse', () => {
|
||||
});
|
||||
expect(stats.requestsByProvider).toEqual({ codex: 1, 'ccs-internal-managed': 2 });
|
||||
});
|
||||
|
||||
it('derives duplicate-email Codex account ids from auth file metadata', () => {
|
||||
const usage = createInternallyBucketedUsage([
|
||||
createDetail({ auth_index: 'codex-team', source: 'kaidu.kd@gmail.com' }),
|
||||
createDetail({
|
||||
auth_index: 'codex-free',
|
||||
source: 'kaidu.kd@gmail.com',
|
||||
timestamp: '2025-03-26T10:01:00.000Z',
|
||||
failed: true,
|
||||
}),
|
||||
]);
|
||||
const authFiles: CliproxyManagementAuthFile[] = [
|
||||
{
|
||||
auth_index: 'codex-team',
|
||||
provider: 'codex',
|
||||
email: 'kaidu.kd@gmail.com',
|
||||
name: 'codex-04a0f049-kaidu.kd@gmail.com-team.json',
|
||||
},
|
||||
{
|
||||
auth_index: 'codex-free',
|
||||
provider: 'codex',
|
||||
email: 'kaidu.kd@gmail.com',
|
||||
name: 'codex-kaidu.kd@gmail.com-free.json',
|
||||
},
|
||||
];
|
||||
|
||||
const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles });
|
||||
|
||||
expect(stats.accountStats['codex:kaidu.kd@gmail.com#04a0f049-team']).toMatchObject({
|
||||
provider: 'codex',
|
||||
source: 'kaidu.kd@gmail.com#04a0f049-team',
|
||||
successCount: 1,
|
||||
failureCount: 0,
|
||||
});
|
||||
expect(stats.accountStats['codex:kaidu.kd@gmail.com#free']).toMatchObject({
|
||||
provider: 'codex',
|
||||
source: 'kaidu.kd@gmail.com#free',
|
||||
successCount: 0,
|
||||
failureCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,38 +2,25 @@
|
||||
* Account Card Component for Flow Visualization
|
||||
*/
|
||||
|
||||
import {
|
||||
cn,
|
||||
formatQuotaPercent,
|
||||
getCodexQuotaBreakdown,
|
||||
getQuotaFailureInfo,
|
||||
getProviderMinQuota,
|
||||
getProviderResetTime,
|
||||
isClaudeQuotaResult,
|
||||
isCodexQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import {
|
||||
GripVertical,
|
||||
Loader2,
|
||||
Pause,
|
||||
Play,
|
||||
KeyRound,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats';
|
||||
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { formatQuotaPercent, getProviderMinQuota, getQuotaFailureInfo, cn } from '@/lib/utils';
|
||||
import { GripVertical, Loader2, Pause, Play } from 'lucide-react';
|
||||
import {
|
||||
useAccountQuota,
|
||||
useAccountQuotas,
|
||||
QUOTA_SUPPORTED_PROVIDERS,
|
||||
} from '@/hooks/use-cliproxy-stats';
|
||||
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||
|
||||
import type { AccountData, DragOffset } from './types';
|
||||
import { cleanEmail } from './utils';
|
||||
import { AccountCardStats } from './account-card-stats';
|
||||
import { cleanEmail } from './utils';
|
||||
|
||||
type Zone = 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
const QUOTA_PROVIDER_ALIASES = [
|
||||
'antigravity',
|
||||
'anthropic',
|
||||
@@ -56,7 +43,7 @@ interface AccountCardProps {
|
||||
onPointerDown: (e: React.PointerEvent) => void;
|
||||
onPointerMove: (e: React.PointerEvent) => void;
|
||||
onPointerUp: () => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
onPauseToggle?: (accountIds: string[], paused: boolean) => void;
|
||||
isPausingAccount?: boolean;
|
||||
}
|
||||
|
||||
@@ -87,6 +74,40 @@ function getBorderColorStyle(zone: Zone, color: string): React.CSSProperties {
|
||||
}
|
||||
}
|
||||
|
||||
function getCompactQuotaColor(percentage: number) {
|
||||
if (percentage > 50) return 'bg-emerald-500';
|
||||
if (percentage > 20) return 'bg-amber-500';
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
function getVariantMarkerLabel(audience: string, fallbackLabel?: string | null) {
|
||||
if (audience === 'business') return 'Biz';
|
||||
if (audience === 'personal') return 'Pers';
|
||||
|
||||
const normalizedFallback = fallbackLabel?.trim();
|
||||
return normalizedFallback?.[0]?.toUpperCase() ?? '?';
|
||||
}
|
||||
|
||||
function getGroupedVariantSummaryLabel(
|
||||
variants: Array<{ audience: string; audienceLabel?: string | null; detailLabel?: string | null }>
|
||||
) {
|
||||
const audiences = new Set(variants.map((variant) => variant.audience));
|
||||
|
||||
if (audiences.size === 2 && audiences.has('business') && audiences.has('personal')) {
|
||||
return 'B|P';
|
||||
}
|
||||
|
||||
if (variants.length === 1) {
|
||||
const [variant] = variants;
|
||||
return getVariantMarkerLabel(
|
||||
variant.audience,
|
||||
variant.audienceLabel ?? variant.detailLabel ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AccountCard({
|
||||
account,
|
||||
zone,
|
||||
@@ -108,86 +129,155 @@ export function AccountCard({
|
||||
const borderSide = BORDER_SIDE_MAP[zone];
|
||||
const borderColor = getBorderColorStyle(zone, account.color);
|
||||
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
|
||||
|
||||
// Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
|
||||
const normalizedProvider = account.provider.toLowerCase();
|
||||
const isCliproxyProvider =
|
||||
const showQuota =
|
||||
QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) ||
|
||||
QUOTA_PROVIDER_ALIASES.includes(normalizedProvider);
|
||||
const isCodexProvider = normalizedProvider === 'codex';
|
||||
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
|
||||
const hasGroupedVariants = (account.variants?.length ?? 0) > 1;
|
||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||
normalizedProvider,
|
||||
account.id,
|
||||
isCliproxyProvider
|
||||
showQuota && !hasGroupedVariants
|
||||
);
|
||||
const variantQuotaQueries = useAccountQuotas(
|
||||
(account.variants ?? []).map((variant) => ({
|
||||
provider: account.provider,
|
||||
accountId: variant.id,
|
||||
})),
|
||||
showQuota && hasGroupedVariants
|
||||
);
|
||||
const groupedHeaderVariants = hasGroupedVariants
|
||||
? Array.from(
|
||||
new Map(
|
||||
(account.variants ?? [])
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const order = { business: 0, personal: 1, unknown: 2 } as const;
|
||||
return order[left.audience] - order[right.audience];
|
||||
})
|
||||
.map((variant) => [variant.audienceLabel ?? variant.detailLabel ?? variant.id, variant])
|
||||
).values()
|
||||
)
|
||||
: [];
|
||||
const groupedVariantSummaryLabel = getGroupedVariantSummaryLabel(groupedHeaderVariants);
|
||||
|
||||
// Use shared helper for provider-specific minimum quota
|
||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
||||
const resetTime = getProviderResetTime(account.provider, quota);
|
||||
const codexBreakdown =
|
||||
isCodexProvider && quota && isCodexQuotaResult(quota)
|
||||
? getCodexQuotaBreakdown(quota.windows)
|
||||
const compactMetaBadges = hasGroupedVariants ? (
|
||||
<>
|
||||
<div
|
||||
className="inline-flex shrink-0 items-center overflow-hidden rounded-md border border-border/60 bg-muted/60 shadow-sm shadow-black/5 dark:bg-zinc-900/80"
|
||||
title={groupedHeaderVariants
|
||||
.map((variant) => variant.audienceLabel ?? variant.detailLabel ?? 'Variant')
|
||||
.join(' • ')}
|
||||
>
|
||||
{groupedVariantSummaryLabel ? (
|
||||
<span className="inline-flex min-w-[2.2rem] items-center justify-center px-1.5 py-1 text-[9px] font-semibold leading-none text-foreground/80">
|
||||
{groupedVariantSummaryLabel}
|
||||
</span>
|
||||
) : (
|
||||
groupedHeaderVariants.map((variant, index) => (
|
||||
<span
|
||||
key={variant.id}
|
||||
className={cn(
|
||||
'inline-flex min-w-[1.9rem] items-center justify-center px-1.5 py-1 text-[9px] font-semibold leading-none',
|
||||
index > 0 && 'border-l border-border/50',
|
||||
variant.audience === 'business'
|
||||
? 'bg-sky-500/12 text-sky-700 dark:bg-sky-500/20 dark:text-sky-300'
|
||||
: variant.audience === 'personal'
|
||||
? 'bg-emerald-500/12 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{getVariantMarkerLabel(
|
||||
variant.audience,
|
||||
variant.audienceLabel ?? variant.detailLabel ?? null
|
||||
)}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{account.paused && (
|
||||
<span className="text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
const groupedQuotaRows =
|
||||
hasGroupedVariants && showQuota
|
||||
? (account.variants ?? []).map((variant, index) => {
|
||||
const quotaQuery = variantQuotaQueries[index];
|
||||
const minQuota = getProviderMinQuota(account.provider, quotaQuery?.data);
|
||||
const quotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
||||
const quotaValue = quotaLabel !== null ? Number(quotaLabel) : null;
|
||||
const failureInfo = getQuotaFailureInfo(quotaQuery?.data);
|
||||
const label = variant.audienceLabel ?? variant.detailLabel ?? cleanEmail(variant.email);
|
||||
|
||||
return (
|
||||
<div key={variant.id} className="space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-2 text-[8px]">
|
||||
<span className="text-muted-foreground/80 truncate">{label}</span>
|
||||
<span className="font-mono text-foreground/80 shrink-0">
|
||||
{quotaQuery?.isLoading
|
||||
? t('accountCard.quotaLoading')
|
||||
: quotaValue !== null
|
||||
? `${quotaLabel}%`
|
||||
: failureInfo?.label || t('accountCard.quotaUnavailable')}
|
||||
</span>
|
||||
</div>
|
||||
{quotaValue !== null && (
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
getCompactQuotaColor(quotaValue)
|
||||
)}
|
||||
style={{ width: `${quotaValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null;
|
||||
const codexQuotaRows = [
|
||||
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
|
||||
{ label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
|
||||
].filter((row): row is { label: string; value: number } => row.value !== null);
|
||||
const claudeQuotaRows =
|
||||
isClaudeProvider && quota && isClaudeQuotaResult(quota)
|
||||
? [
|
||||
{
|
||||
label: '5h',
|
||||
value:
|
||||
quota.coreUsage?.fiveHour?.remainingPercent ??
|
||||
quota.windows.find((window) => window.rateLimitType === 'five_hour')
|
||||
?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
{
|
||||
label: 'Wk',
|
||||
value:
|
||||
quota.coreUsage?.weekly?.remainingPercent ??
|
||||
quota.windows.find((window) =>
|
||||
[
|
||||
'seven_day',
|
||||
'seven_day_opus',
|
||||
'seven_day_sonnet',
|
||||
'seven_day_oauth_apps',
|
||||
'seven_day_cowork',
|
||||
].includes(window.rateLimitType)
|
||||
)?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
].filter((row): row is { label: string; value: number } => row.value !== null)
|
||||
: [];
|
||||
const compactQuotaRows = isCodexProvider
|
||||
? codexQuotaRows
|
||||
: isClaudeProvider
|
||||
? claudeQuotaRows
|
||||
: [];
|
||||
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
||||
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
|
||||
const failureInfo = getQuotaFailureInfo(quota);
|
||||
const FailureIcon =
|
||||
failureInfo?.label === 'Reauth'
|
||||
? KeyRound
|
||||
: failureInfo?.tone === 'warning'
|
||||
? AlertTriangle
|
||||
: AlertCircle;
|
||||
const failureTextClass =
|
||||
failureInfo?.tone === 'warning'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: failureInfo?.tone === 'destructive'
|
||||
? 'text-destructive'
|
||||
: 'text-muted-foreground/70';
|
||||
|
||||
// Tier badge (AGY only) - show P for Pro, U for Ultra
|
||||
const showTierBadge =
|
||||
account.provider === 'agy' &&
|
||||
account.tier &&
|
||||
account.tier !== 'unknown' &&
|
||||
account.tier !== 'free';
|
||||
const headerEnd = (
|
||||
<>
|
||||
{onPauseToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 transition-all rounded-full',
|
||||
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPauseToggle(account.memberIds ?? [account.id], !account.paused);
|
||||
}}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{isPausingAccount ? (
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
) : account.paused ? (
|
||||
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -214,171 +304,35 @@ export function AccountCard({
|
||||
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
|
||||
}}
|
||||
>
|
||||
{/* Header row: Email + Tier | Pause button | Drag handle */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{/* Email with tier badge inline */}
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold text-foreground tracking-tight truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{cleanEmail(account.email)}
|
||||
</span>
|
||||
{showTierBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0',
|
||||
account.tier === 'ultra'
|
||||
? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
|
||||
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
|
||||
)}
|
||||
>
|
||||
{account.tier}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pause/Resume button */}
|
||||
{onPauseToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
'transition-all rounded-full',
|
||||
account.paused ? 'bg-amber-500/20 hover:bg-amber-500/30' : 'hover:bg-muted'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPauseToggle(account.id, !account.paused);
|
||||
}}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{isPausingAccount ? (
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
) : account.paused ? (
|
||||
<Play className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<Pause className="w-2.5 h-2.5 text-muted-foreground/50 hover:text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{account.paused ? t('accountCard.resumeAccount') : t('accountCard.pauseAccount')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
{/* Drag handle */}
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
|
||||
</div>
|
||||
|
||||
<AccountCardStats
|
||||
success={account.successCount}
|
||||
failure={account.failureCount}
|
||||
showDetails={showDetails}
|
||||
<AccountSurfaceCard
|
||||
mode="compact"
|
||||
provider={account.provider}
|
||||
accountId={account.id}
|
||||
email={account.email}
|
||||
displayEmail={cleanEmail(account.email)}
|
||||
tokenFile={account.tokenFile}
|
||||
tier={account.tier}
|
||||
isDefault={account.isDefault}
|
||||
paused={account.paused}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={showQuota && !hasGroupedVariants}
|
||||
quota={quota}
|
||||
quotaLoading={quotaLoading}
|
||||
runtimeLastUsed={account.lastUsedAt}
|
||||
headerEnd={headerEnd}
|
||||
compactMetaBadges={compactMetaBadges}
|
||||
footerSlot={
|
||||
<>
|
||||
<AccountCardStats
|
||||
success={account.successCount}
|
||||
failure={account.failureCount}
|
||||
showDetails={showDetails}
|
||||
/>
|
||||
{groupedQuotaRows && <div className="mt-2 px-0.5 space-y-1">{groupedQuotaRows}</div>}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{/* Quota bar for CLIProxy accounts */}
|
||||
{isCliproxyProvider && (
|
||||
<div className="mt-2 px-0.5">
|
||||
{quotaLoading ? (
|
||||
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
<span>{t('accountCard.quotaLoading')}</span>
|
||||
</div>
|
||||
) : minQuotaValue !== null ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="space-y-0.5 cursor-help">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
||||
{t('accountCard.quota')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono font-bold',
|
||||
minQuotaValue > 50
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: minQuotaValue > 20
|
||||
? 'text-amber-500'
|
||||
: 'text-red-500'
|
||||
)}
|
||||
>
|
||||
{minQuotaLabel}%
|
||||
</span>
|
||||
</div>
|
||||
{compactQuotaRows.length > 0 && (
|
||||
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
|
||||
{compactQuotaRows.map((row) => (
|
||||
<span key={row.label}>
|
||||
{row.label} {row.value}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
minQuotaValue > 50
|
||||
? 'bg-emerald-500'
|
||||
: minQuotaValue > 20
|
||||
? 'bg-amber-500'
|
||||
: 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${minQuotaValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : quota?.success ? (
|
||||
<div className="text-[8px] text-muted-foreground/60">
|
||||
{t('accountCard.quotaUnavailable')}
|
||||
</div>
|
||||
) : failureInfo ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className={cn('flex items-center gap-1 text-[8px]', failureTextClass)}>
|
||||
<FailureIcon className="w-2.5 h-2.5" />
|
||||
<span>{failureInfo.label}</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[220px]">
|
||||
<div className="space-y-1 text-xs">
|
||||
<p>{failureInfo.summary}</p>
|
||||
{failureInfo.actionHint && (
|
||||
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
|
||||
)}
|
||||
{failureInfo.technicalDetail && (
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
{failureInfo.technicalDetail}
|
||||
</p>
|
||||
)}
|
||||
{failureInfo.rawDetail && (
|
||||
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
|
||||
{failureInfo.rawDetail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'absolute w-3 h-3 rounded-full transform z-20 transition-colors border',
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Type definitions for Account Flow Visualization
|
||||
*/
|
||||
|
||||
import type { AccountVisualVariant } from '@/lib/account-visual-groups';
|
||||
|
||||
/** Account tier for subscription level */
|
||||
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
|
||||
@@ -14,7 +16,9 @@ export interface DragOffset {
|
||||
export interface AccountData {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenFile?: string;
|
||||
provider: string;
|
||||
isDefault?: boolean;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
lastUsedAt?: string;
|
||||
@@ -22,6 +26,10 @@ export interface AccountData {
|
||||
paused?: boolean;
|
||||
/** Account tier (Antigravity only) */
|
||||
tier?: AccountTier;
|
||||
/** Raw member IDs when one visual card represents multiple underlying auth records */
|
||||
memberIds?: string[];
|
||||
/** Raw variant details shown inside grouped visual cards */
|
||||
variants?: AccountVisualVariant[];
|
||||
}
|
||||
|
||||
export interface ProviderData {
|
||||
@@ -34,7 +42,7 @@ export interface ProviderData {
|
||||
export interface AccountFlowVizProps {
|
||||
providerData: ProviderData;
|
||||
onBack?: () => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
onPauseToggle?: (accountIds: string[], paused: boolean) => void;
|
||||
isPausingAccount?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import {
|
||||
cn,
|
||||
formatQuotaPercent,
|
||||
getCodexQuotaBreakdown,
|
||||
getProviderMinQuota,
|
||||
getProviderResetTime,
|
||||
getQuotaFailureInfo,
|
||||
isClaudeQuotaResult,
|
||||
isCodexQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||
import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
HelpCircle,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type AccountSurfaceMode = 'compact' | 'detailed';
|
||||
|
||||
interface AccountQuotaPanelProps {
|
||||
provider: string;
|
||||
quota?: UnifiedQuotaResult;
|
||||
quotaLoading?: boolean;
|
||||
runtimeLastUsed?: string;
|
||||
mode: AccountSurfaceMode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getQuotaColor(percentage: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, percentage));
|
||||
if (clamped <= 20) return 'bg-destructive';
|
||||
if (clamped <= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const diff = Date.now() - date.getTime();
|
||||
if (diff < 0) return 'just now';
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return 'just now';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
|
||||
if (!lastUsedAt) return false;
|
||||
|
||||
try {
|
||||
return Date.now() - new Date(lastUsedAt).getTime() < 60 * 60 * 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function AccountQuotaPanel({
|
||||
provider,
|
||||
quota,
|
||||
quotaLoading,
|
||||
runtimeLastUsed,
|
||||
mode,
|
||||
className,
|
||||
}: AccountQuotaPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
const isCodexProvider = normalizedProvider === 'codex';
|
||||
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
|
||||
const minQuota = getProviderMinQuota(provider, quota);
|
||||
const resetTime = getProviderResetTime(provider, quota);
|
||||
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
||||
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
|
||||
const failureInfo = getQuotaFailureInfo(quota);
|
||||
const FailureIcon =
|
||||
failureInfo?.label === 'Reauth'
|
||||
? KeyRound
|
||||
: failureInfo?.tone === 'warning'
|
||||
? AlertTriangle
|
||||
: AlertCircle;
|
||||
|
||||
const codexBreakdown =
|
||||
isCodexProvider && quota && isCodexQuotaResult(quota)
|
||||
? getCodexQuotaBreakdown(quota.windows)
|
||||
: null;
|
||||
const compactQuotaRows = isCodexProvider
|
||||
? [
|
||||
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
|
||||
{
|
||||
label: mode === 'compact' ? 'Wk' : 'Weekly',
|
||||
value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null,
|
||||
},
|
||||
]
|
||||
: isClaudeProvider && quota && isClaudeQuotaResult(quota)
|
||||
? [
|
||||
{
|
||||
label: '5h',
|
||||
value:
|
||||
quota.coreUsage?.fiveHour?.remainingPercent ??
|
||||
quota.windows.find((window) => window.rateLimitType === 'five_hour')
|
||||
?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
{
|
||||
label: mode === 'compact' ? 'Wk' : 'Weekly',
|
||||
value:
|
||||
quota.coreUsage?.weekly?.remainingPercent ??
|
||||
quota.windows.find((window) =>
|
||||
[
|
||||
'seven_day',
|
||||
'seven_day_opus',
|
||||
'seven_day_sonnet',
|
||||
'seven_day_oauth_apps',
|
||||
'seven_day_cowork',
|
||||
].includes(window.rateLimitType)
|
||||
)?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const quotaRows = compactQuotaRows.filter(
|
||||
(row): row is { label: string; value: number } => row.value !== null
|
||||
);
|
||||
|
||||
if (quotaLoading) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1.5 text-xs text-muted-foreground', className)}>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>{mode === 'compact' ? t('accountCard.quotaLoading') : 'Loading quota...'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (minQuotaValue !== null) {
|
||||
return (
|
||||
<div className={cn(mode === 'compact' ? 'px-0.5' : '', className)}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{mode === 'compact' ? (
|
||||
<div className="space-y-0.5 cursor-help">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[8px] text-muted-foreground/70 uppercase font-bold tracking-tight">
|
||||
{t('accountCard.quota')}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono font-bold',
|
||||
minQuotaValue > 50
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: minQuotaValue > 20
|
||||
? 'text-amber-500'
|
||||
: 'text-red-500'
|
||||
)}
|
||||
>
|
||||
{minQuotaLabel}%
|
||||
</span>
|
||||
</div>
|
||||
{quotaRows.length > 0 && (
|
||||
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
|
||||
{quotaRows.map((row) => (
|
||||
<span key={row.label}>
|
||||
{row.label} {row.value}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
minQuotaValue > 50
|
||||
? 'bg-emerald-500'
|
||||
: minQuotaValue > 20
|
||||
? 'bg-amber-500'
|
||||
: 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${minQuotaValue}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5 cursor-help">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{isRecentlyUsed(runtimeLastUsed) ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
Active · {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : runtimeLastUsed ? (
|
||||
<>
|
||||
<Clock className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Last used {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HelpCircle className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Not used yet</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{quotaRows.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{quotaRows.map((row) => (
|
||||
<div key={row.label} className="flex items-center gap-2">
|
||||
<span className="w-10 text-[10px] text-muted-foreground">
|
||||
{row.label}
|
||||
</span>
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, row.value))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(row.value)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">{row.value}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, minQuotaValue))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(minQuotaValue)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">{minQuotaLabel}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-xs">
|
||||
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (quota?.success) {
|
||||
return mode === 'compact' ? (
|
||||
<div className={cn('text-[8px] text-muted-foreground/60', className)}>
|
||||
{t('accountCard.quotaUnavailable')}
|
||||
</div>
|
||||
) : (
|
||||
<div className={className}>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
|
||||
>
|
||||
<HelpCircle className="w-3 h-3" />
|
||||
{t('accountCard.quotaUnavailable')}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!failureInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const failureClass =
|
||||
failureInfo.tone === 'warning'
|
||||
? 'text-amber-600 dark:text-amber-400 border-amber-500/50'
|
||||
: failureInfo.tone === 'destructive'
|
||||
? 'text-destructive border-destructive/50'
|
||||
: 'text-muted-foreground/70 border-muted-foreground/50';
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{mode === 'compact' ? (
|
||||
<div className={cn('flex items-center gap-1 text-[8px]', failureClass, className)}>
|
||||
<FailureIcon className="w-2.5 h-2.5" />
|
||||
<span>{failureInfo.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={className}>
|
||||
<Badge variant="outline" className={cn('text-[10px] h-5 px-2 gap-1', failureClass)}>
|
||||
<FailureIcon className="w-3 h-3" />
|
||||
{failureInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={mode === 'compact' ? 'top' : 'bottom'} className="max-w-[260px]">
|
||||
<div className="space-y-1 text-xs">
|
||||
<p>{failureInfo.summary}</p>
|
||||
{failureInfo.actionHint && (
|
||||
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
|
||||
)}
|
||||
{failureInfo.technicalDetail && (
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
{failureInfo.technicalDetail}
|
||||
</p>
|
||||
)}
|
||||
{failureInfo.rawDetail && (
|
||||
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
|
||||
{failureInfo.rawDetail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import type { UnifiedQuotaResult } from '@/hooks/use-cliproxy-stats';
|
||||
import { getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Pause, Star, User } from 'lucide-react';
|
||||
|
||||
import { AccountQuotaPanel } from './account-quota-panel';
|
||||
|
||||
type AccountSurfaceMode = 'compact' | 'detailed';
|
||||
type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
|
||||
interface AccountSurfaceCardProps {
|
||||
mode: AccountSurfaceMode;
|
||||
provider: string;
|
||||
accountId: string;
|
||||
email?: string;
|
||||
displayEmail?: string;
|
||||
tokenFile?: string;
|
||||
tier?: AccountTier;
|
||||
isDefault?: boolean;
|
||||
paused?: boolean;
|
||||
privacyMode?: boolean;
|
||||
showQuota?: boolean;
|
||||
quota?: UnifiedQuotaResult;
|
||||
quotaLoading?: boolean;
|
||||
runtimeLastUsed?: string;
|
||||
beforeIdentity?: ReactNode;
|
||||
headerEnd?: ReactNode;
|
||||
compactMetaBadges?: ReactNode;
|
||||
bodySlot?: ReactNode;
|
||||
footerSlot?: ReactNode;
|
||||
quotaInsetClassName?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function getAudienceBadgeClass(audience: 'business' | 'personal' | 'unknown') {
|
||||
if (audience === 'business') {
|
||||
return 'bg-sky-500/12 text-sky-700 dark:text-sky-300';
|
||||
}
|
||||
|
||||
if (audience === 'personal') {
|
||||
return 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300';
|
||||
}
|
||||
|
||||
return 'bg-muted text-muted-foreground';
|
||||
}
|
||||
|
||||
function getTierBadgeClass(tier: AccountTier | undefined) {
|
||||
return tier === 'ultra'
|
||||
? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
|
||||
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400';
|
||||
}
|
||||
|
||||
function getCompactAudienceBadgeLabel(audience: 'business' | 'personal' | 'unknown') {
|
||||
if (audience === 'business') return 'Biz';
|
||||
if (audience === 'personal') return 'Pers';
|
||||
return '?';
|
||||
}
|
||||
|
||||
export function AccountSurfaceCard({
|
||||
mode,
|
||||
provider,
|
||||
accountId,
|
||||
email,
|
||||
displayEmail,
|
||||
tokenFile,
|
||||
tier,
|
||||
isDefault,
|
||||
paused,
|
||||
privacyMode,
|
||||
showQuota,
|
||||
quota,
|
||||
quotaLoading,
|
||||
runtimeLastUsed,
|
||||
beforeIdentity,
|
||||
headerEnd,
|
||||
compactMetaBadges,
|
||||
bodySlot,
|
||||
footerSlot,
|
||||
quotaInsetClassName,
|
||||
className,
|
||||
}: AccountSurfaceCardProps) {
|
||||
const identity = getAccountIdentityPresentation(accountId, email, tokenFile);
|
||||
const title = displayEmail || identity.email || accountId;
|
||||
const normalizedProvider = provider.toLowerCase();
|
||||
const showTierBadge =
|
||||
(normalizedProvider === 'agy' || normalizedProvider === 'antigravity') &&
|
||||
tier &&
|
||||
tier !== 'unknown' &&
|
||||
tier !== 'free';
|
||||
const isCompact = mode === 'compact';
|
||||
const defaultCompactMetaBadges = (
|
||||
<>
|
||||
{showTierBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
|
||||
getTierBadgeClass(tier)
|
||||
)}
|
||||
>
|
||||
{tier}
|
||||
</span>
|
||||
)}
|
||||
{identity.audienceLabel && (
|
||||
<span
|
||||
title={identity.audienceLabel}
|
||||
className={cn(
|
||||
'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0',
|
||||
identity.audience === 'business'
|
||||
? 'bg-sky-500/15 text-sky-700 dark:bg-sky-500/25 dark:text-sky-300'
|
||||
: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/25 dark:text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{getCompactAudienceBadgeLabel(identity.audience)}
|
||||
</span>
|
||||
)}
|
||||
{paused && (
|
||||
<span className="text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0 bg-amber-500/15 text-amber-700 dark:bg-amber-500/25 dark:text-amber-300">
|
||||
Paused
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-2', className)}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className={cn('flex min-w-0 flex-1', isCompact ? 'gap-2' : 'gap-3')}>
|
||||
{beforeIdentity}
|
||||
{!isCompact && (
|
||||
<div className="relative shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full',
|
||||
isDefault ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
{showTierBadge && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded ring-1 ring-background',
|
||||
tier === 'ultra'
|
||||
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
|
||||
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
|
||||
)}
|
||||
>
|
||||
{tier === 'ultra' ? 'U' : 'P'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn('flex items-center min-w-0', isCompact ? 'gap-1.5' : 'gap-2 flex-wrap')}
|
||||
>
|
||||
<span
|
||||
title={title}
|
||||
className={cn(
|
||||
isCompact
|
||||
? 'flex-1 min-w-0 text-xs font-semibold tracking-tight truncate leading-none'
|
||||
: 'font-medium text-sm truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{isCompact && (compactMetaBadges ?? defaultCompactMetaBadges)}
|
||||
{!isCompact && identity.audienceLabel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] h-4 px-1.5 border-transparent',
|
||||
getAudienceBadgeClass(identity.audience)
|
||||
)}
|
||||
>
|
||||
{identity.audienceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{!isCompact && identity.detailLabel && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||
{identity.detailLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{!isCompact && isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
{!isCompact && paused && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
|
||||
>
|
||||
<Pause className="w-2 h-2 mr-0.5" />
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bodySlot && <div className="mt-1">{bodySlot}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{headerEnd && (
|
||||
<div className={cn('flex items-center shrink-0', isCompact ? 'gap-0.5' : 'gap-1')}>
|
||||
{headerEnd}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{footerSlot}
|
||||
|
||||
{showQuota && (
|
||||
<AccountQuotaPanel
|
||||
provider={provider}
|
||||
quota={quota}
|
||||
quotaLoading={quotaLoading}
|
||||
runtimeLastUsed={runtimeLastUsed}
|
||||
mode={mode}
|
||||
className={quotaInsetClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||
import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config';
|
||||
import { isDeniedAgyModelId } from '@/lib/utils';
|
||||
|
||||
@@ -220,7 +221,9 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||
<option value="">{t('cliproxyDialog.useDefaultAccount')}</option>
|
||||
{providerAccounts.map((acc) => (
|
||||
<option key={acc.id} value={acc.id}>
|
||||
{privacyMode ? '••••••' : acc.email || acc.id}
|
||||
{privacyMode
|
||||
? '••••••'
|
||||
: formatAccountDisplayName(acc.id, acc.email, acc.tokenFile)}
|
||||
{acc.isDefault ? ` ${t('cliproxyDialog.defaultSuffix')}` : ''}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
* Displays a single OAuth account with actions and quota bar
|
||||
*/
|
||||
|
||||
import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -13,87 +12,69 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { getAccountStats } from '@/lib/cliproxy-account-stats';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import {
|
||||
User,
|
||||
Star,
|
||||
MoreHorizontal,
|
||||
Clock,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Check,
|
||||
FolderCode,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
FolderCode,
|
||||
Check,
|
||||
KeyRound,
|
||||
Star,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
cn,
|
||||
formatQuotaPercent,
|
||||
getCodexQuotaBreakdown,
|
||||
getQuotaFailureInfo,
|
||||
getProviderMinQuota,
|
||||
getProviderResetTime,
|
||||
isClaudeQuotaResult,
|
||||
isCodexQuotaResult,
|
||||
} from '@/lib/utils';
|
||||
import { getAccountStats } from '@/lib/cliproxy-account-stats';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { AccountItemProps } from './types';
|
||||
|
||||
/**
|
||||
* Get color class based on quota percentage
|
||||
*/
|
||||
function getQuotaColor(percentage: number): string {
|
||||
const clamped = Math.max(0, Math.min(100, percentage));
|
||||
if (clamped <= 20) return 'bg-destructive';
|
||||
if (clamped <= 50) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "5m ago", "2h ago")
|
||||
*/
|
||||
function formatRelativeTime(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
if (diff < 0) return 'just now';
|
||||
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return 'just now';
|
||||
} catch {
|
||||
return '';
|
||||
function renderProjectId(projectId: string | undefined, privacyMode: boolean | undefined) {
|
||||
if (projectId) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<FolderCode className="w-3 h-3" aria-hidden="true" />
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono max-w-[180px] truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
title={projectId}
|
||||
>
|
||||
{projectId}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">GCP Project ID (read-only)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account was used recently (within last hour = token likely refreshed)
|
||||
*/
|
||||
function isRecentlyUsed(lastUsedAt: string | undefined): boolean {
|
||||
if (!lastUsedAt) return false;
|
||||
try {
|
||||
const lastUsed = new Date(lastUsedAt);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - lastUsed.getTime();
|
||||
return diff < 60 * 60 * 1000; // Within last hour
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
|
||||
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
|
||||
<span>Project ID: N/A</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[250px]">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium text-amber-600">Missing Project ID</p>
|
||||
<p>This may cause errors. Remove the account and re-add it to fetch the project ID.</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccountItem({
|
||||
@@ -109,390 +90,119 @@ export function AccountItem({
|
||||
selected,
|
||||
onSelectChange,
|
||||
}: AccountItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const normalizedProvider = account.provider.toLowerCase();
|
||||
const isCodexProvider = normalizedProvider === 'codex';
|
||||
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
|
||||
|
||||
// Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
|
||||
const { data: stats } = useCliproxyStats(showQuota);
|
||||
|
||||
// Fetch quota for all provider accounts
|
||||
const { data: quota, isLoading: quotaLoading } = useAccountQuota(
|
||||
normalizedProvider,
|
||||
account.id,
|
||||
showQuota
|
||||
);
|
||||
|
||||
// Get last used time from runtime stats (more accurate than file)
|
||||
const runtimeLastUsed = getAccountStats(stats, account)?.lastUsedAt;
|
||||
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
|
||||
|
||||
// Use shared utility functions for provider-specific quota handling
|
||||
const minQuota = getProviderMinQuota(account.provider, quota);
|
||||
const nextReset = getProviderResetTime(account.provider, quota);
|
||||
const codexBreakdown =
|
||||
isCodexProvider && quota && isCodexQuotaResult(quota)
|
||||
? getCodexQuotaBreakdown(quota.windows)
|
||||
: null;
|
||||
const codexQuotaRows = [
|
||||
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
|
||||
{ label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
|
||||
].filter((row): row is { label: string; value: number } => row.value !== null);
|
||||
const claudeQuotaRows =
|
||||
isClaudeProvider && quota && isClaudeQuotaResult(quota)
|
||||
? [
|
||||
{
|
||||
label: '5h',
|
||||
value:
|
||||
quota.coreUsage?.fiveHour?.remainingPercent ??
|
||||
quota.windows.find((window) => window.rateLimitType === 'five_hour')
|
||||
?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
{
|
||||
label: 'Weekly',
|
||||
value:
|
||||
quota.coreUsage?.weekly?.remainingPercent ??
|
||||
quota.windows.find((window) =>
|
||||
[
|
||||
'seven_day',
|
||||
'seven_day_opus',
|
||||
'seven_day_sonnet',
|
||||
'seven_day_oauth_apps',
|
||||
'seven_day_cowork',
|
||||
].includes(window.rateLimitType)
|
||||
)?.remainingPercent ??
|
||||
null,
|
||||
},
|
||||
].filter((row): row is { label: string; value: number } => row.value !== null)
|
||||
: [];
|
||||
const dualWindowQuotaRows = isCodexProvider
|
||||
? codexQuotaRows
|
||||
: isClaudeProvider
|
||||
? claudeQuotaRows
|
||||
: [];
|
||||
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
|
||||
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
|
||||
const failureInfo = getQuotaFailureInfo(quota);
|
||||
const FailureIcon =
|
||||
failureInfo?.label === 'Reauth'
|
||||
? KeyRound
|
||||
: failureInfo?.tone === 'warning'
|
||||
? AlertTriangle
|
||||
: AlertCircle;
|
||||
const failureBadgeClass =
|
||||
failureInfo?.tone === 'warning'
|
||||
? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
|
||||
: failureInfo?.tone === 'destructive'
|
||||
? 'border-destructive/50 text-destructive'
|
||||
: 'border-muted-foreground/50 text-muted-foreground';
|
||||
const beforeIdentity =
|
||||
selectable || onPauseToggle ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{selectable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectChange?.(!selected)}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
|
||||
selected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/30 hover:border-primary/50'
|
||||
)}
|
||||
aria-label={selected ? 'Deselect account' : 'Select account'}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
{onPauseToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => onPauseToggle(!account.paused)}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{isPausingAccount ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : account.paused ? (
|
||||
<Play className="w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{account.paused ? 'Resume account' : 'Pause account'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
const headerEnd = (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!account.isDefault && (
|
||||
<DropdownMenuItem onClick={onSetDefault}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 p-3 rounded-lg border transition-colors overflow-hidden',
|
||||
'rounded-lg border p-3 transition-colors overflow-hidden',
|
||||
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30',
|
||||
account.paused && 'opacity-75',
|
||||
selected && 'ring-2 ring-primary/50 bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
{/* Selection checkbox for bulk actions */}
|
||||
{selectable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectChange?.(!selected)}
|
||||
className={cn(
|
||||
'flex items-center justify-center w-5 h-5 rounded border-2 transition-colors shrink-0',
|
||||
selected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'border-muted-foreground/30 hover:border-primary/50'
|
||||
)}
|
||||
aria-label={selected ? 'Deselect account' : 'Select account'}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Pause/Resume toggle button - visible left of avatar */}
|
||||
{onPauseToggle && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => onPauseToggle(!account.paused)}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{isPausingAccount ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : account.paused ? (
|
||||
<Play className="w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
<Pause className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{account.paused ? 'Resume account' : 'Pause account'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{/* Avatar with tier badge overlay */}
|
||||
<div className="relative shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full',
|
||||
account.isDefault ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
{/* Tier badge - fixed position on avatar */}
|
||||
{account.tier && account.tier !== 'unknown' && account.tier !== 'free' && (
|
||||
<span
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded',
|
||||
'ring-1 ring-background',
|
||||
account.tier === 'ultra'
|
||||
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
|
||||
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
|
||||
)}
|
||||
>
|
||||
{account.tier === 'ultra' ? 'U' : 'P'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn('font-medium text-sm truncate', privacyMode && PRIVACY_BLUR_CLASS)}
|
||||
>
|
||||
{account.email || account.id}
|
||||
</span>
|
||||
{account.isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
{account.paused && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
|
||||
>
|
||||
<Pause className="w-2 h-2 mr-0.5" />
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Project ID for Antigravity accounts - read-only */}
|
||||
{account.provider === 'agy' && (
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{account.projectId ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<FolderCode className="w-3 h-3" aria-hidden="true" />
|
||||
<span
|
||||
className={cn(
|
||||
'font-mono max-w-[180px] truncate',
|
||||
privacyMode && PRIVACY_BLUR_CLASS
|
||||
)}
|
||||
title={account.projectId}
|
||||
>
|
||||
{account.projectId}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">GCP Project ID (read-only)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-500">
|
||||
<AlertTriangle className="w-3 h-3" aria-label="Warning" />
|
||||
<span>Project ID: N/A</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[250px]">
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-medium text-amber-600">Missing Project ID</p>
|
||||
<p>
|
||||
This may cause errors. Remove the account and re-add it to fetch the
|
||||
project ID.
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{account.lastUsedAt && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!account.isDefault && (
|
||||
<DropdownMenuItem onClick={onSetDefault}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Quota bar - supports all providers with quota API */}
|
||||
{showQuota && (
|
||||
<div className="pl-11">
|
||||
{quotaLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>Loading quota...</span>
|
||||
</div>
|
||||
) : minQuotaValue !== null ? (
|
||||
<div className="space-y-1.5">
|
||||
{/* Status indicator based on runtime usage, not file state */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
{wasRecentlyUsed ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
Active · {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : runtimeLastUsed ? (
|
||||
<>
|
||||
<Clock className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Last used {formatRelativeTime(runtimeLastUsed)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<HelpCircle className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Not used yet</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Quota bar */}
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{dualWindowQuotaRows.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
{dualWindowQuotaRows.map((row) => (
|
||||
<div key={row.label} className="flex items-center gap-2">
|
||||
<span className="w-10 text-[10px] text-muted-foreground">
|
||||
{row.label}
|
||||
</span>
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, row.value))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(row.value)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">
|
||||
{row.value}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={Math.max(0, Math.min(100, minQuotaValue))}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(minQuotaValue)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">
|
||||
{minQuotaLabel}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
<QuotaTooltipContent quota={quota} resetTime={nextReset} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : quota?.success ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
|
||||
>
|
||||
<HelpCircle className="w-3 h-3" />
|
||||
{t('accountCard.quotaUnavailable')}
|
||||
</Badge>
|
||||
</div>
|
||||
) : failureInfo ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-[10px] h-5 px-2 gap-1', failureBadgeClass)}
|
||||
>
|
||||
<FailureIcon className="w-3 h-3" />
|
||||
{failureInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[260px]">
|
||||
<div className="space-y-1 text-xs">
|
||||
<p>{failureInfo.summary}</p>
|
||||
{failureInfo.actionHint && (
|
||||
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
|
||||
)}
|
||||
{failureInfo.technicalDetail && (
|
||||
<p className="font-mono text-[11px] text-muted-foreground">
|
||||
{failureInfo.technicalDetail}
|
||||
</p>
|
||||
)}
|
||||
{failureInfo.rawDetail && (
|
||||
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
|
||||
{failureInfo.rawDetail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<AccountSurfaceCard
|
||||
mode="detailed"
|
||||
provider={account.provider}
|
||||
accountId={account.id}
|
||||
email={account.email}
|
||||
displayEmail={account.email || account.id}
|
||||
tokenFile={account.tokenFile}
|
||||
tier={account.tier}
|
||||
isDefault={account.isDefault}
|
||||
paused={account.paused}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={showQuota}
|
||||
quota={quota}
|
||||
quotaLoading={quotaLoading}
|
||||
runtimeLastUsed={runtimeLastUsed}
|
||||
beforeIdentity={beforeIdentity}
|
||||
headerEnd={headerEnd}
|
||||
bodySlot={
|
||||
account.provider === 'agy' ? renderProjectId(account.projectId, privacyMode) : null
|
||||
}
|
||||
quotaInsetClassName="pl-11"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,13 @@
|
||||
|
||||
import type React from 'react';
|
||||
import { ChevronRight, AlertTriangle } from 'lucide-react';
|
||||
import { formatAccountDisplayName } from '@/lib/account-identity';
|
||||
import { cn, STATUS_COLORS } from '@/lib/utils';
|
||||
import { PROVIDER_COLORS } from '@/lib/provider-config';
|
||||
import { ProviderIcon } from '@/components/shared/provider-icon';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { ProviderStats } from '../types';
|
||||
import { getSuccessRate, cleanEmail } from '../utils';
|
||||
import { getSuccessRate } from '../utils';
|
||||
import { InlineStatsBadge } from './inline-stats-badge';
|
||||
|
||||
interface ProviderCardProps {
|
||||
@@ -112,7 +113,11 @@ export function ProviderCard({
|
||||
<div
|
||||
className={cn('w-2 h-2 rounded-full', acc.paused && 'opacity-50')}
|
||||
style={{ backgroundColor: acc.color }}
|
||||
title={privacyMode ? '••••••' : cleanEmail(acc.email)}
|
||||
title={
|
||||
privacyMode
|
||||
? '••••••'
|
||||
: formatAccountDisplayName(acc.id, acc.email, acc.tokenFile)
|
||||
}
|
||||
/>
|
||||
{isMissingProjectId && (
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
|
||||
import { getProviderDisplayName } from '@/lib/provider-config';
|
||||
import { getAccountStats } from '@/lib/cliproxy-account-stats';
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
import type { AccountRow, ProviderStats } from './types';
|
||||
import { ACCOUNT_COLORS } from './utils';
|
||||
@@ -74,28 +74,33 @@ export function useAuthMonitorData(): AuthMonitorData {
|
||||
const providerData = providerMap.get(providerKey);
|
||||
if (!providerData) return;
|
||||
|
||||
status.accounts?.forEach((account: OAuthAccount) => {
|
||||
const realStats = getAccountStats(statsData, account);
|
||||
const success = realStats?.successCount ?? 0;
|
||||
const failure = realStats?.failureCount ?? 0;
|
||||
tSuccess += success;
|
||||
tFailure += failure;
|
||||
providerData.success += success;
|
||||
providerData.failure += failure;
|
||||
const normalizedAccounts = (status.accounts ?? []).map((account: OAuthAccount) => ({
|
||||
...account,
|
||||
provider: account.provider || status.provider,
|
||||
}));
|
||||
|
||||
buildAccountVisualGroups(normalizedAccounts, statsData).forEach((groupedAccount) => {
|
||||
tSuccess += groupedAccount.successCount;
|
||||
tFailure += groupedAccount.failureCount;
|
||||
providerData.success += groupedAccount.successCount;
|
||||
providerData.failure += groupedAccount.failureCount;
|
||||
|
||||
const row: AccountRow = {
|
||||
id: account.id,
|
||||
email: account.email || account.id,
|
||||
id: groupedAccount.id,
|
||||
email: groupedAccount.email,
|
||||
tokenFile: groupedAccount.tokenFile,
|
||||
provider: status.provider,
|
||||
displayName: status.displayName,
|
||||
isDefault: account.isDefault,
|
||||
successCount: success,
|
||||
failureCount: failure,
|
||||
lastUsedAt: realStats?.lastUsedAt ?? account.lastUsedAt,
|
||||
isDefault: groupedAccount.isDefault,
|
||||
successCount: groupedAccount.successCount,
|
||||
failureCount: groupedAccount.failureCount,
|
||||
lastUsedAt: groupedAccount.lastUsedAt,
|
||||
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
|
||||
projectId: account.projectId,
|
||||
paused: account.paused,
|
||||
tier: account.tier,
|
||||
projectId: groupedAccount.projectId,
|
||||
paused: groupedAccount.paused,
|
||||
tier: groupedAccount.tier,
|
||||
memberIds: groupedAccount.memberIds,
|
||||
variants: groupedAccount.variants,
|
||||
};
|
||||
accountsList.push(row);
|
||||
providerData.accounts.push(row);
|
||||
|
||||
@@ -10,7 +10,12 @@ import { STATUS_COLORS } from '@/lib/utils';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AccountFlowViz } from '@/components/account-flow-viz';
|
||||
import { usePrivacy } from '@/contexts/privacy-context';
|
||||
import { usePauseAccount, useResumeAccount } from '@/hooks/use-cliproxy';
|
||||
import {
|
||||
useBulkPauseAccounts,
|
||||
useBulkResumeAccounts,
|
||||
usePauseAccount,
|
||||
useResumeAccount,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react';
|
||||
|
||||
import { useAuthMonitorData } from './hooks';
|
||||
@@ -66,14 +71,37 @@ export function AuthMonitor() {
|
||||
// Account control mutations for flow viz
|
||||
const pauseMutation = usePauseAccount();
|
||||
const resumeMutation = useResumeAccount();
|
||||
const bulkPauseMutation = useBulkPauseAccounts();
|
||||
const bulkResumeMutation = useBulkResumeAccounts();
|
||||
|
||||
// Get selected provider data for detail view
|
||||
const selectedProviderData = effectiveProvider
|
||||
? providerStats.find((ps) => ps.provider === effectiveProvider)
|
||||
: null;
|
||||
|
||||
const handlePauseToggle = (accountId: string, paused: boolean) => {
|
||||
if (!effectiveProvider || pauseMutation.isPending || resumeMutation.isPending) return;
|
||||
const handlePauseToggle = (accountIds: string[], paused: boolean) => {
|
||||
if (
|
||||
!effectiveProvider ||
|
||||
pauseMutation.isPending ||
|
||||
resumeMutation.isPending ||
|
||||
bulkPauseMutation.isPending ||
|
||||
bulkResumeMutation.isPending
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (accountIds.length > 1) {
|
||||
if (paused) {
|
||||
bulkPauseMutation.mutate({ provider: effectiveProvider, accountIds });
|
||||
} else {
|
||||
bulkResumeMutation.mutate({ provider: effectiveProvider, accountIds });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const [accountId] = accountIds;
|
||||
if (!accountId) return;
|
||||
|
||||
if (paused) {
|
||||
pauseMutation.mutate({ provider: effectiveProvider, accountId });
|
||||
} else {
|
||||
@@ -165,7 +193,12 @@ export function AuthMonitor() {
|
||||
providerData={selectedProviderData}
|
||||
onBack={() => setSelectedProvider(null)}
|
||||
onPauseToggle={handlePauseToggle}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isPausingAccount={
|
||||
pauseMutation.isPending ||
|
||||
resumeMutation.isPending ||
|
||||
bulkPauseMutation.isPending ||
|
||||
bulkResumeMutation.isPending
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-6">
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* Type definitions for Auth Monitor components
|
||||
*/
|
||||
|
||||
import type { AccountVisualVariant } from '@/lib/account-visual-groups';
|
||||
|
||||
/** Account tier for subscription level */
|
||||
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
|
||||
|
||||
export interface AccountRow {
|
||||
id: string;
|
||||
email: string;
|
||||
tokenFile: string;
|
||||
provider: string;
|
||||
displayName: string;
|
||||
isDefault: boolean;
|
||||
@@ -21,6 +24,10 @@ export interface AccountRow {
|
||||
paused?: boolean;
|
||||
/** Account tier (Antigravity only) */
|
||||
tier?: AccountTier;
|
||||
/** Raw member IDs when one visual card represents multiple underlying auth records */
|
||||
memberIds?: string[];
|
||||
/** Raw variant details shown inside grouped visual cards */
|
||||
variants?: AccountVisualVariant[];
|
||||
}
|
||||
|
||||
export interface ProviderStats {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
*/
|
||||
|
||||
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 { cn } from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import type { AccountStepProps } from '../types';
|
||||
@@ -24,29 +26,52 @@ export function AccountStep({
|
||||
|
||||
{/* Scrollable account list with max-height for many accounts */}
|
||||
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
{accounts.map((acc) => (
|
||||
<button
|
||||
key={acc.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(acc)}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{acc.email || acc.id}
|
||||
{accounts.map((acc) => {
|
||||
const identity = getAccountIdentityPresentation(acc.id, acc.email, acc.tokenFile);
|
||||
return (
|
||||
<button
|
||||
key={acc.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(acc)}
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center">
|
||||
<User className="w-4 h-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className={cn('font-medium', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{identity.email}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{identity.audienceLabel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] h-4 px-1.5 border-transparent',
|
||||
identity.audience === 'business'
|
||||
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
|
||||
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{identity.audienceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{identity.detailLabel && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||
{identity.detailLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{acc.isDefault && (
|
||||
<span className="text-xs text-muted-foreground">Default account</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{acc.isDefault && (
|
||||
<div className="text-xs text-muted-foreground">Default account</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { getAccountIdentityPresentation } from '@/lib/account-identity';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -44,6 +46,13 @@ export function VariantStep({
|
||||
selectedProvider === 'agy' && modelName.trim().length > 0
|
||||
? isDeniedAgyModelId(modelName)
|
||||
: false;
|
||||
const selectedAccountIdentity = selectedAccount
|
||||
? getAccountIdentityPresentation(
|
||||
selectedAccount.id,
|
||||
selectedAccount.email,
|
||||
selectedAccount.tokenFile
|
||||
)
|
||||
: null;
|
||||
|
||||
const handleModelSelect = (value: string) => {
|
||||
if (value === CUSTOM_MODEL_VALUE) {
|
||||
@@ -58,14 +67,38 @@ export function VariantStep({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{selectedAccount && (
|
||||
<div className="flex items-center gap-2 p-2 bg-muted/50 rounded-md text-sm">
|
||||
<div className="flex items-start gap-2 p-2 bg-muted/50 rounded-md text-sm">
|
||||
<User className="w-4 h-4" />
|
||||
<span>
|
||||
{t('setupVariant.using')}{' '}
|
||||
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{selectedAccount.email || selectedAccount.id}
|
||||
<div className="space-y-1">
|
||||
<span>
|
||||
{t('setupVariant.using')}{' '}
|
||||
<span className={cn(privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||
{selectedAccountIdentity?.email}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{(selectedAccountIdentity?.audienceLabel || selectedAccountIdentity?.detailLabel) && (
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{selectedAccountIdentity?.audienceLabel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] h-4 px-1.5 border-transparent',
|
||||
selectedAccountIdentity.audience === 'business'
|
||||
? 'bg-sky-500/12 text-sky-700 dark:text-sky-300'
|
||||
: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
|
||||
)}
|
||||
>
|
||||
{selectedAccountIdentity.audienceLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedAccountIdentity?.detailLabel && (
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5">
|
||||
{selectedAccountIdentity.detailLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* React Query hook for CLIProxyAPI stats
|
||||
*/
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import type {
|
||||
ModelQuota,
|
||||
QuotaResult,
|
||||
@@ -334,6 +334,21 @@ async function fetchGhcpQuotaApi(accountId: string): Promise<GhcpQuotaResult> {
|
||||
// Re-export unified type from utils for consumers
|
||||
export type { UnifiedQuotaResult } from '@/lib/utils';
|
||||
|
||||
function getAccountQuotaQueryOptions(provider: string, accountId: string, enabled = true) {
|
||||
const canonicalProvider = normalizeQuotaProvider(provider);
|
||||
|
||||
return {
|
||||
queryKey: ['account-quota', canonicalProvider ?? provider, accountId],
|
||||
queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId),
|
||||
enabled: enabled && !!canonicalProvider && !!accountId,
|
||||
staleTime: 60000,
|
||||
refetchInterval: 60000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
retry: 1 as const,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota by provider (dispatcher)
|
||||
*/
|
||||
@@ -365,16 +380,16 @@ async function fetchQuotaByProvider(
|
||||
* Supports agy, codex, claude, gemini, and ghcp providers
|
||||
*/
|
||||
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
|
||||
const canonicalProvider = normalizeQuotaProvider(provider);
|
||||
return useQuery(getAccountQuotaQueryOptions(provider, accountId, enabled));
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['account-quota', canonicalProvider ?? provider, accountId],
|
||||
queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId),
|
||||
enabled: enabled && !!canonicalProvider && !!accountId,
|
||||
staleTime: 60000, // Match refetchInterval to prevent early refetching
|
||||
refetchInterval: 60000, // Refresh every 1 minute
|
||||
refetchOnWindowFocus: false, // Don't refetch on tab switch
|
||||
refetchOnMount: false, // Don't refetch on component remount (AuthMonitor re-renders)
|
||||
retry: 1,
|
||||
export function useAccountQuotas(
|
||||
accounts: Array<{ provider: string; accountId: string }>,
|
||||
enabled = true
|
||||
) {
|
||||
return useQueries({
|
||||
queries: accounts.map((account) =>
|
||||
getAccountQuotaQueryOptions(account.provider, account.accountId, enabled)
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
const PERSONAL_PLAN_PARTS = new Set(['free', '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 interface AccountIdentityPresentation {
|
||||
email: string;
|
||||
audience: AccountAudience;
|
||||
audienceLabel: string | null;
|
||||
detailLabel: string | null;
|
||||
compactDetailLabel: string | null;
|
||||
inlineLabel: string | null;
|
||||
}
|
||||
|
||||
function normalizeVariantTokenPart(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, '')
|
||||
.replace(/[^a-z0-9._-]+/gi, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function formatVariantPart(part: string): string {
|
||||
const normalized = part.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch (normalized) {
|
||||
case 'team':
|
||||
return 'Team';
|
||||
case 'free':
|
||||
return 'Free';
|
||||
case 'plus':
|
||||
return 'Plus';
|
||||
case 'pro':
|
||||
return 'Pro';
|
||||
default:
|
||||
return /^[a-f0-9]{8}$/i.test(normalized)
|
||||
? normalized
|
||||
: normalized
|
||||
.split(/[._-]+/)
|
||||
.filter(Boolean)
|
||||
.map((segment) => segment[0]?.toUpperCase() + segment.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
function extractCanonicalEmailFromAccountId(accountId: string): string | null {
|
||||
const canonical = accountId.split('#')[0]?.trim();
|
||||
return canonical && canonical.includes('@') ? canonical : null;
|
||||
}
|
||||
|
||||
function extractVariantKeyFromAccountId(accountId: string, email?: string): string | null {
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const prefix = `${email}#`;
|
||||
return accountId.startsWith(prefix) ? accountId.slice(prefix.length) : null;
|
||||
}
|
||||
|
||||
function extractVariantKeyFromTokenFile(tokenFile?: string, email?: string): string | null {
|
||||
if (!tokenFile || !email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileName = tokenFile.split(/[\\/]/).pop() ?? tokenFile;
|
||||
const baseName = fileName.replace(/\.json$/i, '');
|
||||
if (!baseName.toLowerCase().startsWith('codex-')) {
|
||||
return null;
|
||||
}
|
||||
const firstDashIndex = baseName.indexOf('-');
|
||||
const candidate =
|
||||
firstDashIndex > 0 && !baseName.slice(0, firstDashIndex).includes('@')
|
||||
? baseName.slice(firstDashIndex + 1)
|
||||
: baseName;
|
||||
const emailIndex = candidate.toLowerCase().indexOf(email.toLowerCase());
|
||||
|
||||
if (emailIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const before = normalizeVariantTokenPart(candidate.slice(0, emailIndex));
|
||||
const after = normalizeVariantTokenPart(candidate.slice(emailIndex + email.length));
|
||||
const parts = [before, after].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join('-') : null;
|
||||
}
|
||||
|
||||
function formatWorkspaceLabel(parts: string[]): {
|
||||
detailLabel: string | null;
|
||||
compactDetailLabel: string | null;
|
||||
} {
|
||||
const workspaceId = parts.find((part) => /^[a-f0-9]{8}$/i.test(part));
|
||||
if (workspaceId) {
|
||||
return {
|
||||
detailLabel: `Workspace ${workspaceId.toLowerCase()}`,
|
||||
compactDetailLabel: workspaceId.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
const extraLabel = parts.map(formatVariantPart).filter(Boolean).join(' · ');
|
||||
return {
|
||||
detailLabel: extraLabel || 'Team',
|
||||
compactDetailLabel: extraLabel || 'Team',
|
||||
};
|
||||
}
|
||||
|
||||
export function extractAccountVariantKey(
|
||||
accountId: string,
|
||||
email?: string,
|
||||
tokenFile?: string
|
||||
): string | null {
|
||||
const resolvedEmail = email?.trim() || extractCanonicalEmailFromAccountId(accountId) || undefined;
|
||||
return (
|
||||
extractVariantKeyFromTokenFile(tokenFile, resolvedEmail) ??
|
||||
extractVariantKeyFromAccountId(accountId, resolvedEmail)
|
||||
);
|
||||
}
|
||||
|
||||
export function getAccountIdentityPresentation(
|
||||
accountId: string,
|
||||
email?: string,
|
||||
tokenFile?: string
|
||||
): AccountIdentityPresentation {
|
||||
const resolvedEmail = email?.trim() || extractCanonicalEmailFromAccountId(accountId) || accountId;
|
||||
const variantKey = extractAccountVariantKey(accountId, resolvedEmail, tokenFile);
|
||||
if (!variantKey) {
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'unknown',
|
||||
audienceLabel: null,
|
||||
detailLabel: null,
|
||||
compactDetailLabel: null,
|
||||
inlineLabel: null,
|
||||
};
|
||||
}
|
||||
|
||||
const parts = variantKey.split('-').filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'unknown',
|
||||
audienceLabel: null,
|
||||
detailLabel: null,
|
||||
compactDetailLabel: null,
|
||||
inlineLabel: null,
|
||||
};
|
||||
}
|
||||
|
||||
const suffix = parts[parts.length - 1]?.toLowerCase();
|
||||
if (suffix && BUSINESS_PLAN_PARTS.has(suffix)) {
|
||||
const workspace = formatWorkspaceLabel(parts.slice(0, -1));
|
||||
const inlineLabel = ['Business', workspace.detailLabel].filter(Boolean).join(' · ');
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'business',
|
||||
audienceLabel: 'Business',
|
||||
detailLabel: workspace.detailLabel,
|
||||
compactDetailLabel: workspace.compactDetailLabel,
|
||||
inlineLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (suffix && PERSONAL_PLAN_PARTS.has(suffix)) {
|
||||
const detailParts = [formatVariantPart(suffix), ...parts.slice(0, -1).map(formatVariantPart)]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const detailLabel = detailParts || formatVariantPart(suffix);
|
||||
const inlineLabel = ['Personal', detailLabel].filter(Boolean).join(' · ');
|
||||
return {
|
||||
email: resolvedEmail,
|
||||
audience: 'personal',
|
||||
audienceLabel: 'Personal',
|
||||
detailLabel,
|
||||
compactDetailLabel: detailLabel,
|
||||
inlineLabel,
|
||||
};
|
||||
}
|
||||
|
||||
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 formatAccountVariantLabel(
|
||||
accountId: string,
|
||||
email?: string,
|
||||
tokenFile?: string
|
||||
): string | null {
|
||||
return getAccountIdentityPresentation(accountId, email, tokenFile).inlineLabel;
|
||||
}
|
||||
|
||||
export function formatAccountDisplayName(
|
||||
accountId: string,
|
||||
email?: string,
|
||||
tokenFile?: string
|
||||
): string {
|
||||
const presentation = getAccountIdentityPresentation(accountId, email, tokenFile);
|
||||
return presentation.inlineLabel
|
||||
? `${presentation.email} (${presentation.inlineLabel})`
|
||||
: presentation.email;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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[];
|
||||
}
|
||||
|
||||
const AUDIENCE_ORDER: Record<AccountAudience, number> = {
|
||||
business: 0,
|
||||
personal: 1,
|
||||
unknown: 2,
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function sortAccountVariants(variants: AccountVisualVariant[]): AccountVisualVariant[] {
|
||||
return [...variants].sort((left, right) => {
|
||||
const audienceDelta = AUDIENCE_ORDER[left.audience] - AUDIENCE_ORDER[right.audience];
|
||||
if (audienceDelta !== 0) {
|
||||
return audienceDelta;
|
||||
}
|
||||
|
||||
const leftLabel = left.audienceLabel ?? left.detailLabel ?? left.id;
|
||||
const rightLabel = right.audienceLabel ?? right.detailLabel ?? right.id;
|
||||
|
||||
return leftLabel.localeCompare(rightLabel);
|
||||
});
|
||||
}
|
||||
|
||||
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 orderedVariants = sortAccountVariants(variants);
|
||||
const canonicalEmail = orderedVariants[0]?.email ?? bucketKey;
|
||||
const originalProvider = accountMeta.get(orderedVariants[0]?.id ?? '')?.provider ?? 'codex';
|
||||
|
||||
return {
|
||||
id: bucketKey,
|
||||
email: canonicalEmail,
|
||||
tokenFile: orderedVariants[0]?.tokenFile ?? '',
|
||||
provider: originalProvider,
|
||||
isDefault: orderedVariants.some((variant) => variant.isDefault),
|
||||
successCount: orderedVariants.reduce((sum, variant) => sum + variant.successCount, 0),
|
||||
failureCount: orderedVariants.reduce((sum, variant) => sum + variant.failureCount, 0),
|
||||
lastUsedAt: orderedVariants.reduce<string | undefined>(
|
||||
(latest, variant) => getLatestTimestamp(latest, variant.lastUsedAt),
|
||||
undefined
|
||||
),
|
||||
paused: orderedVariants.every((variant) => Boolean(variant.paused)),
|
||||
memberIds: orderedVariants.map((variant) => variant.id),
|
||||
variants: orderedVariants,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -912,15 +912,17 @@ export const api = {
|
||||
body: JSON.stringify({ accountId }),
|
||||
}),
|
||||
remove: (provider: string, accountId: string) =>
|
||||
request(`/cliproxy/auth/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
|
||||
request(`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
pause: (provider: string, accountId: string) =>
|
||||
request<{ provider: string; accountId: string; paused: boolean }>(
|
||||
`/cliproxy/auth/accounts/${provider}/${accountId}/pause`,
|
||||
`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}/pause`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
resume: (provider: string, accountId: string) =>
|
||||
request<{ provider: string; accountId: string; paused: boolean }>(
|
||||
`/cliproxy/auth/accounts/${provider}/${accountId}/resume`,
|
||||
`/cliproxy/auth/accounts/${provider}/${encodeURIComponent(accountId)}/resume`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
/** Solo mode: activate one account, pause all others */
|
||||
|
||||
@@ -9,8 +9,17 @@ export function getAccountStats(
|
||||
stats: Pick<CliproxyStats, 'accountStats'> | null | undefined,
|
||||
account: Pick<OAuthAccount, 'provider' | 'email' | 'id'>
|
||||
): AccountUsageStats | undefined {
|
||||
const source = account.email || account.id;
|
||||
const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source);
|
||||
const sources = Array.from(
|
||||
new Set([account.id, account.email].filter((value): value is string => Boolean(value?.trim())))
|
||||
);
|
||||
|
||||
return stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source];
|
||||
for (const source of sources) {
|
||||
const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source);
|
||||
const match = stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source];
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -22,7 +22,9 @@ function mockRandom(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function makeAccount(overrides: Partial<AccountData> & Pick<AccountData, 'id' | 'email'>): AccountData {
|
||||
function makeAccount(
|
||||
overrides: Partial<AccountData> & Pick<AccountData, 'id' | 'email'>
|
||||
): AccountData {
|
||||
return {
|
||||
provider: 'agy',
|
||||
successCount: 0,
|
||||
@@ -170,9 +172,7 @@ describe('generateConnectionEvents()', () => {
|
||||
|
||||
it('all generated timestamps are not in the future', () => {
|
||||
const now = new Date();
|
||||
const accounts: AccountData[] = [
|
||||
makeAccount({ id: 'a1', email: 'a@x.com', successCount: 10 }),
|
||||
];
|
||||
const accounts: AccountData[] = [makeAccount({ id: 'a1', email: 'a@x.com', successCount: 10 })];
|
||||
|
||||
const events = generateConnectionEvents(accounts);
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { OAuthAccount } from '@/lib/api-client';
|
||||
import { buildAccountVisualGroups } from '@/lib/account-visual-groups';
|
||||
|
||||
function makeAccount(overrides: Partial<OAuthAccount> & Pick<OAuthAccount, 'id' | 'tokenFile'>) {
|
||||
return {
|
||||
id: overrides.id,
|
||||
email: 'kaidu.kd@gmail.com',
|
||||
provider: 'codex',
|
||||
isDefault: false,
|
||||
tokenFile: overrides.tokenFile,
|
||||
createdAt: '2026-03-30T00:00:00.000Z',
|
||||
...overrides,
|
||||
} satisfies OAuthAccount;
|
||||
}
|
||||
|
||||
describe('buildAccountVisualGroups', () => {
|
||||
it('orders grouped codex variants by audience consistently', () => {
|
||||
const groups = buildAccountVisualGroups([
|
||||
makeAccount({
|
||||
id: 'kaidu.kd@gmail.com#free',
|
||||
tokenFile: 'codex-kaidu.kd@gmail.com-free.json',
|
||||
}),
|
||||
makeAccount({
|
||||
id: 'kaidu.kd@gmail.com#04a0f049-team',
|
||||
tokenFile: 'codex-04a0f049-kaidu.kd@gmail.com-team.json',
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual([
|
||||
'business',
|
||||
'personal',
|
||||
]);
|
||||
expect(groups[0]?.memberIds).toEqual([
|
||||
'kaidu.kd@gmail.com#04a0f049-team',
|
||||
'kaidu.kd@gmail.com#free',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { api } from '@/lib/api-client';
|
||||
|
||||
function createEmptyJsonResponse(status = 200): Response {
|
||||
return new Response('{}', {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
describe('cliproxy account API client', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('encodes duplicate-email account ids for account auth actions', async () => {
|
||||
const fetchMock = vi.fn(() => Promise.resolve(createEmptyJsonResponse()));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const accountId = 'kaidu.kd@gmail.com#04a0f049-team';
|
||||
const encodedAccountId = encodeURIComponent(accountId);
|
||||
|
||||
await api.cliproxy.accounts.remove('codex', accountId);
|
||||
await api.cliproxy.accounts.pause('codex', accountId);
|
||||
await api.cliproxy.accounts.resume('codex', accountId);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}`
|
||||
);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe(
|
||||
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}/pause`
|
||||
);
|
||||
expect(fetchMock.mock.calls[2]?.[0]).toBe(
|
||||
`/api/cliproxy/auth/accounts/codex/${encodedAccountId}/resume`
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user