fix: add CLIProxy account reauthentication

This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-05-27 10:13:03 -04:00
1 parent d212c72ad8
commit 5f1976f69c
16 files changed
+332 -34

No files matched your search

+2
View File
@@ -389,6 +389,8 @@ export interface OAuthOptions {
account?: string; account?: string;
add?: boolean; add?: boolean;
nickname?: string; nickname?: string;
/** Existing account id to update during reauthentication. */
expectedAccountId?: string;
/** If true, caller explicitly accepts Antigravity OAuth risk for this command/session. */ /** If true, caller explicitly accepts Antigravity OAuth risk for this command/session. */
acceptAgyRisk?: boolean; acceptAgyRisk?: boolean;
/** Kiro auth method override (CLI + Dashboard parity). */ /** Kiro auth method override (CLI + Dashboard parity). */
+6 -5
View File
@@ -1131,8 +1131,9 @@ export async function triggerOAuth(
// Check for existing accounts // Check for existing accounts
const existingAccounts = getProviderAccounts(provider); const existingAccounts = getProviderAccounts(provider);
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null; const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
const targetAccountId = options.expectedAccountId || existingNameMatch?.id;
const nicknameError = !fromUI const nicknameError = !fromUI
? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id) ? getCliAuthNicknameError(provider, nickname, existingAccounts, targetAccountId)
: null; : null;
if (nicknameError) { if (nicknameError) {
console.log(fail(nicknameError)); console.log(fail(nicknameError));
@@ -1144,7 +1145,7 @@ export async function triggerOAuth(
const tokenDir = getProviderTokenDir(provider); const tokenDir = getProviderTokenDir(provider);
const success = await importKiroToken(verbose); const success = await importKiroToken(verbose);
if (success) { if (success) {
return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id); return registerAccountFromToken(provider, tokenDir, nickname, verbose, targetAccountId);
} }
return null; return null;
} }
@@ -1235,7 +1236,7 @@ export async function triggerOAuth(
verbose, verbose,
tokenDir, tokenDir,
nickname, nickname,
existingNameMatch?.id, targetAccountId,
{ {
gitlabBaseUrl: resolvedGitLabBaseUrl, gitlabBaseUrl: resolvedGitLabBaseUrl,
gitlabPersonalAccessToken: options.gitlabPersonalAccessToken, gitlabPersonalAccessToken: options.gitlabPersonalAccessToken,
@@ -1251,7 +1252,7 @@ export async function triggerOAuth(
verbose, verbose,
tokenDir, tokenDir,
nickname, nickname,
existingNameMatch?.id, targetAccountId,
{ {
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined, gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined,
@@ -1336,7 +1337,7 @@ export async function triggerOAuth(
verbose, verbose,
isCLI, isCLI,
nickname, nickname,
expectedAccountId: existingNameMatch?.id, expectedAccountId: targetAccountId,
authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code', authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code',
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined, kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
manualCallback: useSelectedKiroLocalPasteCallback, manualCallback: useSelectedKiroLocalPasteCallback,
+60 -17
View File
@@ -348,6 +348,24 @@ export function getStartAuthNicknameError(
return null; return null;
} }
export function getReauthAccountTarget(
accountId: string | undefined,
existingAccounts: Array<{ id: string; nickname?: string }>
): { account?: { id: string; nickname?: string }; error?: string } {
if (!accountId) {
return {};
}
const account = existingAccounts.find((candidate) => candidate.id === accountId);
if (!account) {
return {
error: `Account '${accountId}' not found for this provider`,
};
}
return { account };
}
/** /**
* GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles * GET /api/cliproxy/auth - Get auth status for built-in CLIProxy profiles
* Also fetches CLIProxyAPI stats to update lastUsedAt for active providers * Also fetches CLIProxyAPI stats to update lastUsedAt for active providers
@@ -623,6 +641,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const requestBody = const requestBody =
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {}; req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined; const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const accountId =
typeof requestBody.accountId === 'string' ? requestBody.accountId.trim() : undefined;
const noIncognitoBody = const noIncognitoBody =
typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined; typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined;
const kiroMethodRaw = requestBody.kiroMethod; const kiroMethodRaw = requestBody.kiroMethod;
@@ -659,6 +679,16 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
return; return;
} }
const localProvider = provider as CLIProxyProvider;
const existingAccounts = getProviderAccounts(localProvider);
const reauthTarget = getReauthAccountTarget(accountId, existingAccounts);
if (reauthTarget.error) {
res.status(404).json({ error: reauthTarget.error });
return;
}
const targetAccountId = reauthTarget.account?.id;
const effectiveNickname = nickname || reauthTarget.account?.nickname;
if (provider === 'kiro' && invalidKiroMethod) { if (provider === 'kiro' && invalidKiroMethod) {
res.status(400).json({ res.status(400).json({
error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github, idc', error: 'Invalid kiroMethod. Supported: aws, aws-authcode, google, github, idc',
@@ -708,7 +738,6 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
} }
try { try {
const localProvider = provider as CLIProxyProvider;
const knownTokenFiles = listProviderTokenSnapshots(localProvider); const knownTokenFiles = listProviderTokenSnapshots(localProvider);
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), { const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST', method: 'POST',
@@ -738,7 +767,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const tokenSnapshot = findNewTokenSnapshot( const tokenSnapshot = findNewTokenSnapshot(
listProviderTokenSnapshots(localProvider), listProviderTokenSnapshots(localProvider),
knownTokenFiles knownTokenFiles,
targetAccountId
); );
if (!tokenSnapshot) { if (!tokenSnapshot) {
res.status(409).json({ res.status(409).json({
@@ -750,9 +780,9 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const account = registerAccountFromToken( const account = registerAccountFromToken(
localProvider, localProvider,
getProviderTokenDir(localProvider), getProviderTokenDir(localProvider),
nickname, effectiveNickname,
false, false,
tokenSnapshot.file targetAccountId || tokenSnapshot.file
); );
if (!account) { if (!account) {
res.status(409).json({ res.status(409).json({
@@ -784,11 +814,11 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
} }
} }
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const nicknameError = getStartAuthNicknameError( const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider, localProvider,
nickname, effectiveNickname,
existingAccounts existingAccounts,
targetAccountId
); );
if (nicknameError) { if (nicknameError) {
res.status(400).json(nicknameError); res.status(400).json(nicknameError);
@@ -808,7 +838,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const account = await triggerOAuth(provider as CLIProxyProvider, { const account = await triggerOAuth(provider as CLIProxyProvider, {
add: true, // Always add mode from UI add: true, // Always add mode from UI
headless: false, // Force interactive mode headless: false, // Force interactive mode
nickname: nickname || undefined, nickname: effectiveNickname || undefined,
expectedAccountId: targetAccountId,
acceptAgyRisk: provider === 'agy', acceptAgyRisk: provider === 'agy',
kiroMethod: provider === 'kiro' ? kiroMethod : undefined, kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined, kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
@@ -971,6 +1002,8 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
const requestBody = const requestBody =
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {}; req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined; const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const accountId =
typeof requestBody.accountId === 'string' ? requestBody.accountId.trim() : undefined;
const kiroMethodRaw = requestBody.kiroMethod; const kiroMethodRaw = requestBody.kiroMethod;
const gitlabAuthModeRaw = requestBody.gitlabAuthMode; const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
const gitlabBaseUrl = const gitlabBaseUrl =
@@ -1037,11 +1070,20 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return; return;
} }
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider); const localProvider = provider as CLIProxyProvider;
const existingAccounts = getProviderAccounts(localProvider);
const reauthTarget = getReauthAccountTarget(accountId, existingAccounts);
if (reauthTarget.error) {
res.status(404).json({ error: reauthTarget.error });
return;
}
const targetAccountId = reauthTarget.account?.id;
const effectiveNickname = nickname || reauthTarget.account?.nickname;
const nicknameError = getStartAuthNicknameError( const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider, localProvider,
nickname, effectiveNickname,
existingAccounts existingAccounts,
targetAccountId
); );
if (nicknameError) { if (nicknameError) {
res.status(400).json(nicknameError); res.status(400).json(nicknameError);
@@ -1136,8 +1178,9 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
if (oauthState) { if (oauthState) {
rememberManualAuthState(oauthState, { rememberManualAuthState(oauthState, {
nickname: nickname || undefined, nickname: effectiveNickname || undefined,
knownTokenFiles: listProviderTokenSnapshots(provider as CLIProxyProvider), expectedAccountId: targetAccountId,
knownTokenFiles: listProviderTokenSnapshots(localProvider),
}); });
} }
@@ -1237,7 +1280,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
getProviderTokenDir(localProvider), getProviderTokenDir(localProvider),
pendingAuth.nickname, pendingAuth.nickname,
false, false,
tokenSnapshot.file pendingAuth.expectedAccountId || tokenSnapshot.file
); );
if (!account) { if (!account) {
@@ -1386,7 +1429,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
getProviderTokenDir(localProvider), getProviderTokenDir(localProvider),
pendingAuth.nickname, pendingAuth.nickname,
false, false,
tokenSnapshot.file pendingAuth.expectedAccountId || tokenSnapshot.file
); );
if (!account) { if (!account) {
@@ -6,6 +6,7 @@ import * as path from 'path';
import * as http from 'http'; import * as http from 'http';
import type { Server } from 'http'; import type { Server } from 'http';
import cliproxyAuthRoutes from '../../../src/web-server/routes/cliproxy-auth-routes'; import cliproxyAuthRoutes from '../../../src/web-server/routes/cliproxy-auth-routes';
import { registerAccountFromToken } from '../../../src/cliproxy/auth/token-manager';
import { import {
clearQuotaCache, clearQuotaCache,
getCachedQuota, getCachedQuota,
@@ -593,6 +594,61 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
} }
}); });
it('reauthenticates a targeted existing Codex account when its token file is rewritten', async () => {
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(tokenDir, { recursive: true });
const tokenPath = path.join(tokenDir, 'codex-existing@example.com.json');
fs.writeFileSync(
tokenPath,
JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 1 }),
'utf8'
);
const initialAccount = registerAccountFromToken('codex', tokenDir, 'work');
expect(initialAccount?.id).toBe('existing@example.com');
mockFetch([
{
url: /\/v0\/management\/codex-auth-url\?is_webui=true$/,
response: {
auth_url: 'https://auth.example.com/authorize?state=state-targeted-rewrite',
state: 'state-targeted-rewrite',
},
},
{
url: /\/v0\/management\/get-auth-status\?state=state-targeted-rewrite$/,
response: { status: 'ok' },
},
]);
const startResponse = await postJson('/api/cliproxy/auth/codex/start-url', {
accountId: 'existing@example.com',
});
expect(startResponse.status).toBe(200);
fs.writeFileSync(
tokenPath,
JSON.stringify({ type: 'codex', email: 'existing@example.com', version: 2 }),
'utf8'
);
const statusResponse = await getJson(
'/api/cliproxy/auth/codex/status?state=state-targeted-rewrite'
);
expect(statusResponse.status).toBe(200);
expect(statusResponse.body).toEqual({
status: 'ok',
account: {
id: 'existing@example.com',
email: 'existing@example.com',
nickname: 'work',
provider: 'codex',
isDefault: true,
},
});
});
it('registers the new account before reporting polled auth success', async () => { it('registers the new account before reporting polled auth success', async () => {
mockFetch([ mockFetch([
{ {
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'bun:test';
import { import {
getKiroStartIDCValidationError, getKiroStartIDCValidationError,
getReauthAccountTarget,
getStartAuthFailureMessage, getStartAuthFailureMessage,
getStartAuthNicknameError, getStartAuthNicknameError,
getStartUrlUnsupportedReason, getStartUrlUnsupportedReason,
@@ -142,3 +143,27 @@ describe('cliproxy-auth-routes nickname validation', () => {
).toBeNull(); ).toBeNull();
}); });
}); });
describe('cliproxy-auth-routes reauth account targeting', () => {
const existingAccounts = [
{ id: 'codex-user@example.com', nickname: 'work' },
{ id: 'codex-personal@example.com', nickname: 'personal' },
];
it('does not require a target for normal add-account auth', () => {
expect(getReauthAccountTarget(undefined, existingAccounts)).toEqual({});
expect(getReauthAccountTarget('', existingAccounts)).toEqual({});
});
it('resolves an existing account target for reauth', () => {
expect(getReauthAccountTarget('codex-user@example.com', existingAccounts)).toEqual({
account: { id: 'codex-user@example.com', nickname: 'work' },
});
});
it('rejects unknown account targets instead of falling back to ambiguous registration', () => {
expect(getReauthAccountTarget('missing', existingAccounts)).toEqual({
error: "Account 'missing' not found for this provider",
});
});
});
@@ -29,7 +29,7 @@ import { Loader2, ExternalLink, User, Download, Copy, Check, ShieldAlert } from
import { useKiroImport } from '@/hooks/use-cliproxy'; import { useKiroImport } from '@/hooks/use-cliproxy';
import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow'; import { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
import { applyDefaultPreset } from '@/lib/preset-utils'; import { applyDefaultPreset } from '@/lib/preset-utils';
import type { CliproxyProviderCatalog } from '@/lib/api-client'; import type { CliproxyProviderCatalog, OAuthAccount } from '@/lib/api-client';
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card'; import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
import { AntigravityResponsibilityChecklist } from '@/components/account/antigravity-responsibility-checklist'; import { AntigravityResponsibilityChecklist } from '@/components/account/antigravity-responsibility-checklist';
import { import {
@@ -58,6 +58,8 @@ interface AddAccountDialogProps {
provider: string; provider: string;
displayName: string; displayName: string;
catalog?: CliproxyProviderCatalog; catalog?: CliproxyProviderCatalog;
/** Existing account to reauthenticate instead of adding a new account. */
account?: OAuthAccount;
/** Whether this is the first account being added (shows different toast message) */ /** Whether this is the first account being added (shows different toast message) */
isFirstAccount?: boolean; isFirstAccount?: boolean;
} }
@@ -77,6 +79,7 @@ export function AddAccountDialog({
provider, provider,
displayName, displayName,
catalog, catalog,
account,
isFirstAccount = false, isFirstAccount = false,
}: AddAccountDialogProps) { }: AddAccountDialogProps) {
const [nickname, setNickname] = useState(''); const [nickname, setNickname] = useState('');
@@ -127,6 +130,8 @@ export function AddAccountDialog({
const gitlabBaseUrlTrimmed = gitlabBaseUrl.trim(); const gitlabBaseUrlTrimmed = gitlabBaseUrl.trim();
const gitlabPersonalAccessTokenTrimmed = gitlabPersonalAccessToken.trim(); const gitlabPersonalAccessTokenTrimmed = gitlabPersonalAccessToken.trim();
const errorMessage = localError || authFlow.error; const errorMessage = localError || authFlow.error;
const isReauth = Boolean(account);
const accountLabel = account?.email || account?.nickname || account?.id || displayName;
const fetchPowerUserModeState = useCallback(async (): Promise<boolean> => { const fetchPowerUserModeState = useCallback(async (): Promise<boolean> => {
const response = await fetch('/api/settings/auth/antigravity-risk'); const response = await fetch('/api/settings/auth/antigravity-risk');
@@ -325,7 +330,8 @@ export function AddAccountDialog({
} }
wasAuthenticatingRef.current = true; wasAuthenticatingRef.current = true;
authFlow.startAuth(provider, { authFlow.startAuth(provider, {
nickname: nicknameTrimmed || undefined, accountId: account?.id,
nickname: account ? account.nickname : nicknameTrimmed || undefined,
kiroMethod: isKiro ? kiroAuthMethod : undefined, kiroMethod: isKiro ? kiroAuthMethod : undefined,
kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined, kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined,
kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined, kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined,
@@ -385,13 +391,19 @@ export function AddAccountDialog({
}} }}
> >
<DialogHeader className="min-w-0 px-6 pt-6 pr-12"> <DialogHeader className="min-w-0 px-6 pt-6 pr-12">
<DialogTitle>{t('addAccountDialog.title', { displayName })}</DialogTitle> <DialogTitle>
{isReauth
? `Reauthenticate ${displayName}`
: t('addAccountDialog.title', { displayName })}
</DialogTitle>
<DialogDescription> <DialogDescription>
{isKiro {isReauth
? t('addAccountDialog.descKiro') ? `Refresh credentials for ${accountLabel}.`
: isDeviceCode : isKiro
? t('addAccountDialog.descDeviceCode') ? t('addAccountDialog.descKiro')
: t('addAccountDialog.descOauth')} : isDeviceCode
? t('addAccountDialog.descDeviceCode')
: t('addAccountDialog.descOauth')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -600,7 +612,16 @@ export function AddAccountDialog({
)} )}
{/* Nickname input - only show before auth starts */} {/* Nickname input - only show before auth starts */}
{!showAuthUI && ( {!showAuthUI && isReauth && (
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
<div className="font-medium">{accountLabel}</div>
<div className="text-xs text-muted-foreground">
This sign-in updates the selected account instead of creating another account.
</div>
</div>
)}
{!showAuthUI && !isReauth && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="nickname">{t('addAccountDialog.nicknameOptional')}</Label> <Label htmlFor="nickname">{t('addAccountDialog.nicknameOptional')}</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -784,7 +805,7 @@ export function AddAccountDialog({
} }
> >
<ExternalLink className="w-4 h-4 mr-2" /> <ExternalLink className="w-4 h-4 mr-2" />
{t('addAccountDialog.authenticate')} {isReauth ? 'Reauthenticate' : t('addAccountDialog.authenticate')}
</Button> </Button>
)} )}
</div> </div>
@@ -24,6 +24,7 @@ import {
MoreHorizontal, MoreHorizontal,
Pause, Pause,
Play, Play,
RefreshCw,
Star, Star,
Trash2, Trash2,
} from 'lucide-react'; } from 'lucide-react';
@@ -87,6 +88,7 @@ export function AccountItem({
account, account,
onSetDefault, onSetDefault,
onRemove, onRemove,
onReauth,
onPauseToggle, onPauseToggle,
isRemoving, isRemoving,
isPausingAccount, isPausingAccount,
@@ -167,6 +169,12 @@ export function AccountItem({
Set as default Set as default
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{onReauth && (
<DropdownMenuItem onClick={onReauth}>
<RefreshCw className="w-4 h-4 mr-2" />
Reauthenticate
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
onClick={onRemove} onClick={onRemove}
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
interface AccountsSectionProps { interface AccountsSectionProps {
accounts: OAuthAccount[]; accounts: OAuthAccount[];
onAddAccount: () => void; onAddAccount: () => void;
onReauthAccount?: (account: OAuthAccount) => void;
onSetDefault: (accountId: string) => void; onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void; onRemoveAccount: (accountId: string) => void;
onPauseToggle?: (accountId: string, paused: boolean) => void; onPauseToggle?: (accountId: string, paused: boolean) => void;
@@ -48,6 +49,7 @@ interface AccountsSectionProps {
export function AccountsSection({ export function AccountsSection({
accounts, accounts,
onAddAccount, onAddAccount,
onReauthAccount,
onSetDefault, onSetDefault,
onRemoveAccount, onRemoveAccount,
onPauseToggle, onPauseToggle,
@@ -175,6 +177,7 @@ export function AccountsSection({
account={account} account={account}
onSetDefault={() => onSetDefault(account.id)} onSetDefault={() => onSetDefault(account.id)}
onRemove={() => onRemoveAccount(account.id)} onRemove={() => onRemoveAccount(account.id)}
onReauth={onReauthAccount ? () => onReauthAccount(account) : undefined}
onPauseToggle={ onPauseToggle={
onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined
} }
@@ -42,6 +42,7 @@ export function ProviderEditor({
defaultTarget, defaultTarget,
topNotice, topNotice,
onAddAccount, onAddAccount,
onReauthAccount,
onSetDefault, onSetDefault,
onRemoveAccount, onRemoveAccount,
onPauseToggle, onPauseToggle,
@@ -282,6 +283,7 @@ export function ProviderEditor({
isDeletePending={deletePresetMutation.isPending} isDeletePending={deletePresetMutation.isPending}
accounts={accounts} accounts={accounts}
onAddAccount={onAddAccount} onAddAccount={onAddAccount}
onReauthAccount={onReauthAccount}
onSetDefault={onSetDefault} onSetDefault={onSetDefault}
onRemoveAccount={onRemoveAccount} onRemoveAccount={onRemoveAccount}
onPauseToggle={onPauseToggle} onPauseToggle={onPauseToggle}
@@ -40,6 +40,7 @@ interface ModelConfigTabProps {
isDeletePending?: boolean; isDeletePending?: boolean;
accounts: OAuthAccount[]; accounts: OAuthAccount[];
onAddAccount: () => void; onAddAccount: () => void;
onReauthAccount?: (account: OAuthAccount) => void;
onSetDefault: (accountId: string) => void; onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void; onRemoveAccount: (accountId: string) => void;
onPauseToggle?: (accountId: string, paused: boolean) => void; onPauseToggle?: (accountId: string, paused: boolean) => void;
@@ -82,6 +83,7 @@ export function ModelConfigTab({
isDeletePending, isDeletePending,
accounts, accounts,
onAddAccount, onAddAccount,
onReauthAccount,
onSetDefault, onSetDefault,
onRemoveAccount, onRemoveAccount,
onPauseToggle, onPauseToggle,
@@ -176,6 +178,7 @@ export function ModelConfigTab({
<AccountsSection <AccountsSection
accounts={accounts} accounts={accounts}
onAddAccount={onAddAccount} onAddAccount={onAddAccount}
onReauthAccount={onReauthAccount}
onSetDefault={onSetDefault} onSetDefault={onSetDefault}
onRemoveAccount={onRemoveAccount} onRemoveAccount={onRemoveAccount}
onPauseToggle={onPauseToggle} onPauseToggle={onPauseToggle}
@@ -39,6 +39,7 @@ export interface ProviderEditorProps {
/** Optional contextual notice shown directly under the editor header */ /** Optional contextual notice shown directly under the editor header */
topNotice?: ReactNode; topNotice?: ReactNode;
onAddAccount: () => void; onAddAccount: () => void;
onReauthAccount?: (account: OAuthAccount) => void;
onSetDefault: (accountId: string) => void; onSetDefault: (accountId: string) => void;
onRemoveAccount: (accountId: string) => void; onRemoveAccount: (accountId: string) => void;
onPauseToggle?: (accountId: string, paused: boolean) => void; onPauseToggle?: (accountId: string, paused: boolean) => void;
@@ -63,6 +64,7 @@ export interface AccountItemProps {
account: OAuthAccount; account: OAuthAccount;
onSetDefault: () => void; onSetDefault: () => void;
onRemove: () => void; onRemove: () => void;
onReauth?: () => void;
onPauseToggle?: (paused: boolean) => void; onPauseToggle?: (paused: boolean) => void;
/** Solo mode: activate this account, pause all others */ /** Solo mode: activate this account, pause all others */
onSoloMode?: () => void; onSoloMode?: () => void;
@@ -16,7 +16,7 @@ import {
usePauseAccount, usePauseAccount,
useResumeAccount, useResumeAccount,
} from '@/hooks/use-cliproxy'; } from '@/hooks/use-cliproxy';
import { Activity, CheckCircle2, XCircle, Radio } from 'lucide-react'; import { Activity, AlertTriangle, CheckCircle2, XCircle, Radio } from 'lucide-react';
import { useAuthMonitorData } from './hooks'; import { useAuthMonitorData } from './hooks';
import { LivePulse } from './components/live-pulse'; import { LivePulse } from './components/live-pulse';
@@ -86,6 +86,8 @@ export function AuthMonitor() {
const displayedSuccessRate = selectedProviderData const displayedSuccessRate = selectedProviderData
? getSuccessRate(selectedProviderData.successCount, selectedProviderData.failureCount) ? getSuccessRate(selectedProviderData.successCount, selectedProviderData.failureCount)
: overallSuccessRate; : overallSuccessRate;
const showHighFailureGuidance =
displayedAccountCount >= 5 && displayedFailure >= 20 && displayedSuccessRate < 50;
const handlePauseToggle = (accountIds: string[], paused: boolean) => { const handlePauseToggle = (accountIds: string[], paused: boolean) => {
if ( if (
@@ -204,6 +206,21 @@ export function AuthMonitor() {
/> />
</div> </div>
{showHighFailureGuidance && (
<div className="flex items-start gap-3 border-b border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="space-y-1">
<p className="font-semibold text-foreground">High CLIProxy failure rate detected</p>
<p className="text-muted-foreground">
Select the affected provider, pause failing accounts, then check{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono">ccs cliproxy routing</code>.
For large Codex pools, prefer a smaller healthy active set and attach CLIProxy logs if
failures continue.
</p>
</div>
</div>
)}
{/* Flow Visualization */} {/* Flow Visualization */}
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
{selectedProviderData ? ( {selectedProviderData ? (
+2
View File
@@ -25,6 +25,7 @@ interface AuthFlowState {
} }
interface StartAuthOptions { interface StartAuthOptions {
accountId?: string;
nickname?: string; nickname?: string;
kiroMethod?: string; kiroMethod?: string;
kiroIDCStartUrl?: string; kiroIDCStartUrl?: string;
@@ -286,6 +287,7 @@ export function useCliproxyAuthFlow() {
const deviceCodeFlow = flowType === 'device_code'; const deviceCodeFlow = flowType === 'device_code';
const startEndpoint = options?.startEndpoint || (deviceCodeFlow ? 'start' : 'start-url'); const startEndpoint = options?.startEndpoint || (deviceCodeFlow ? 'start' : 'start-url');
const payload = { const payload = {
accountId: options?.accountId,
nickname: options?.nickname, nickname: options?.nickname,
kiroMethod: options?.kiroMethod, kiroMethod: options?.kiroMethod,
kiroIDCStartUrl: options?.kiroIDCStartUrl, kiroIDCStartUrl: options?.kiroIDCStartUrl,
+36 -1
View File
@@ -31,7 +31,7 @@ import {
useBulkResumeAccounts, useBulkResumeAccounts,
useDeleteVariant, useDeleteVariant,
} from '@/hooks/use-cliproxy'; } from '@/hooks/use-cliproxy';
import type { AuthStatus, Variant } from '@/lib/api-client'; import type { AuthStatus, OAuthAccount, Variant } from '@/lib/api-client';
import { buildUiCatalogs } from '@/lib/model-catalogs'; import { buildUiCatalogs } from '@/lib/model-catalogs';
import { import {
getProviderDisplayName, getProviderDisplayName,
@@ -256,6 +256,7 @@ export function CliproxyPage() {
provider: string; provider: string;
displayName: string; displayName: string;
isFirstAccount: boolean; isFirstAccount: boolean;
account?: OAuthAccount;
} | null>(() => { } | null>(() => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return null; return null;
@@ -404,6 +405,23 @@ export function CliproxyPage() {
}); });
}; };
const handleReauthAccount = (
provider: string,
displayName: string,
accounts: OAuthAccount[] | undefined,
accountId: string
) => {
const account = accounts?.find((candidate) => candidate.id === accountId);
if (!account) return;
setAddAccountProvider({
provider,
displayName,
isFirstAccount: false,
account,
});
};
return ( return (
<div className="flex h-full min-h-0 overflow-hidden"> <div className="flex h-full min-h-0 overflow-hidden">
{/* Left Sidebar */} {/* Left Sidebar */}
@@ -567,6 +585,14 @@ export function CliproxyPage() {
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0, isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
}) })
} }
onReauthAccount={(account) =>
handleReauthAccount(
selectedVariantData.provider,
parentAuthForVariant.displayName,
parentAuthForVariant.accounts,
account.id
)
}
onSetDefault={(accountId) => onSetDefault={(accountId) =>
setDefaultMutation.mutate({ setDefaultMutation.mutate({
provider: selectedVariantData.provider, provider: selectedVariantData.provider,
@@ -617,6 +643,14 @@ export function CliproxyPage() {
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0, isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
}) })
} }
onReauthAccount={(account) =>
handleReauthAccount(
selectedStatus.provider,
selectedStatus.displayName,
selectedStatus.accounts,
account.id
)
}
onSetDefault={(accountId) => onSetDefault={(accountId) =>
setDefaultMutation.mutate({ setDefaultMutation.mutate({
provider: selectedStatus.provider, provider: selectedStatus.provider,
@@ -663,6 +697,7 @@ export function CliproxyPage() {
: undefined : undefined
} }
isFirstAccount={addAccountProvider?.isFirstAccount || false} isFirstAccount={addAccountProvider?.isFirstAccount || false}
account={addAccountProvider?.account}
/> />
</div> </div>
); );
@@ -86,6 +86,47 @@ describe('useCliproxyAuthFlow', () => {
); );
}); });
it('includes the selected account id when reauth starts from the dashboard', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/codex/start-url')) {
return Promise.resolve(
createJsonResponse({
success: true,
authUrl: 'https://auth.example.com/codex?state=codex-reauth',
state: 'codex-reauth',
})
);
}
if (url.includes('/status?state=codex-reauth')) {
return Promise.resolve(createJsonResponse({ status: 'wait' }));
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
});
vi.stubGlobal('fetch', fetchMock);
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
await act(async () => {
await result.current.startAuth('codex', {
accountId: 'existing@example.com',
startEndpoint: 'start-url',
});
});
expect(fetchMock).toHaveBeenCalledWith(
'/api/cliproxy/auth/codex/start-url',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('"accountId":"existing@example.com"'),
})
);
});
it('surfaces manual OAuth start guidance from the dashboard API', async () => { it('surfaces manual OAuth start guidance from the dashboard API', async () => {
const guidance = [ const guidance = [
'Start local CLIProxy first: ccs cliproxy start', 'Start local CLIProxy first: ccs cliproxy start',
@@ -191,4 +191,41 @@ describe('AuthMonitor', () => {
expect(within(getSummaryCard('Failed')).getByText('3')).toBeInTheDocument(); expect(within(getSummaryCard('Failed')).getByText('3')).toBeInTheDocument();
expect(within(getSummaryCard('Success Rate')).getByText('80%')).toBeInTheDocument(); expect(within(getSummaryCard('Success Rate')).getByText('80%')).toBeInTheDocument();
}); });
it('shows CLIProxy guidance when a large account pool has a high failure rate', () => {
useAuthMonitorDataMock.mockReturnValue({
...authMonitorData,
accounts: Array.from({ length: 5 }, (_, index) => ({
id: `codex-${index}`,
email: `codex-${index}@example.com`,
tokenFile: `/tmp/codex-${index}.json`,
provider: 'codex',
displayName: 'OpenAI Codex',
isDefault: index === 0,
successCount: index === 0 ? 1 : 0,
failureCount: 5,
color: '#10a37f',
})),
totalSuccess: 1,
totalFailure: 25,
totalRequests: 26,
providerStats: [
{
provider: 'codex',
displayName: 'OpenAI Codex',
totalRequests: 26,
successCount: 1,
failureCount: 25,
accountCount: 5,
accounts: [],
},
],
overallSuccessRate: 4,
});
render(<AuthMonitor />);
expect(screen.getByText('High CLIProxy failure rate detected')).toBeInTheDocument();
expect(screen.getByText(/ccs cliproxy routing/)).toBeInTheDocument();
});
}); });