mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
fix: add CLIProxy account reauthentication
This commit is contained in:
@@ -389,6 +389,8 @@ export interface OAuthOptions {
|
||||
account?: string;
|
||||
add?: boolean;
|
||||
nickname?: string;
|
||||
/** Existing account id to update during reauthentication. */
|
||||
expectedAccountId?: string;
|
||||
/** If true, caller explicitly accepts Antigravity OAuth risk for this command/session. */
|
||||
acceptAgyRisk?: boolean;
|
||||
/** Kiro auth method override (CLI + Dashboard parity). */
|
||||
|
||||
@@ -1131,8 +1131,9 @@ export async function triggerOAuth(
|
||||
// Check for existing accounts
|
||||
const existingAccounts = getProviderAccounts(provider);
|
||||
const existingNameMatch = nickname ? findAccountNameMatch(existingAccounts, nickname) : null;
|
||||
const targetAccountId = options.expectedAccountId || existingNameMatch?.id;
|
||||
const nicknameError = !fromUI
|
||||
? getCliAuthNicknameError(provider, nickname, existingAccounts, existingNameMatch?.id)
|
||||
? getCliAuthNicknameError(provider, nickname, existingAccounts, targetAccountId)
|
||||
: null;
|
||||
if (nicknameError) {
|
||||
console.log(fail(nicknameError));
|
||||
@@ -1144,7 +1145,7 @@ export async function triggerOAuth(
|
||||
const tokenDir = getProviderTokenDir(provider);
|
||||
const success = await importKiroToken(verbose);
|
||||
if (success) {
|
||||
return registerAccountFromToken(provider, tokenDir, nickname, verbose, existingNameMatch?.id);
|
||||
return registerAccountFromToken(provider, tokenDir, nickname, verbose, targetAccountId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1235,7 +1236,7 @@ export async function triggerOAuth(
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id,
|
||||
targetAccountId,
|
||||
{
|
||||
gitlabBaseUrl: resolvedGitLabBaseUrl,
|
||||
gitlabPersonalAccessToken: options.gitlabPersonalAccessToken,
|
||||
@@ -1251,7 +1252,7 @@ export async function triggerOAuth(
|
||||
verbose,
|
||||
tokenDir,
|
||||
nickname,
|
||||
existingNameMatch?.id,
|
||||
targetAccountId,
|
||||
{
|
||||
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
|
||||
gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined,
|
||||
@@ -1336,7 +1337,7 @@ export async function triggerOAuth(
|
||||
verbose,
|
||||
isCLI,
|
||||
nickname,
|
||||
expectedAccountId: existingNameMatch?.id,
|
||||
expectedAccountId: targetAccountId,
|
||||
authFlowType: isDeviceCodeFlow ? 'device_code' : 'authorization_code',
|
||||
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
|
||||
manualCallback: useSelectedKiroLocalPasteCallback,
|
||||
|
||||
@@ -348,6 +348,24 @@ export function getStartAuthNicknameError(
|
||||
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
|
||||
* 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 =
|
||||
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
|
||||
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
|
||||
const accountId =
|
||||
typeof requestBody.accountId === 'string' ? requestBody.accountId.trim() : undefined;
|
||||
const noIncognitoBody =
|
||||
typeof requestBody.noIncognito === 'boolean' ? requestBody.noIncognito : undefined;
|
||||
const kiroMethodRaw = requestBody.kiroMethod;
|
||||
@@ -659,6 +679,16 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
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) {
|
||||
res.status(400).json({
|
||||
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 {
|
||||
const localProvider = provider as CLIProxyProvider;
|
||||
const knownTokenFiles = listProviderTokenSnapshots(localProvider);
|
||||
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
|
||||
method: 'POST',
|
||||
@@ -738,7 +767,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
|
||||
const tokenSnapshot = findNewTokenSnapshot(
|
||||
listProviderTokenSnapshots(localProvider),
|
||||
knownTokenFiles
|
||||
knownTokenFiles,
|
||||
targetAccountId
|
||||
);
|
||||
if (!tokenSnapshot) {
|
||||
res.status(409).json({
|
||||
@@ -750,9 +780,9 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
const account = registerAccountFromToken(
|
||||
localProvider,
|
||||
getProviderTokenDir(localProvider),
|
||||
nickname,
|
||||
effectiveNickname,
|
||||
false,
|
||||
tokenSnapshot.file
|
||||
targetAccountId || tokenSnapshot.file
|
||||
);
|
||||
if (!account) {
|
||||
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(
|
||||
provider as CLIProxyProvider,
|
||||
nickname,
|
||||
existingAccounts
|
||||
localProvider,
|
||||
effectiveNickname,
|
||||
existingAccounts,
|
||||
targetAccountId
|
||||
);
|
||||
if (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, {
|
||||
add: true, // Always add mode from UI
|
||||
headless: false, // Force interactive mode
|
||||
nickname: nickname || undefined,
|
||||
nickname: effectiveNickname || undefined,
|
||||
expectedAccountId: targetAccountId,
|
||||
acceptAgyRisk: provider === 'agy',
|
||||
kiroMethod: provider === 'kiro' ? kiroMethod : undefined,
|
||||
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
|
||||
@@ -971,6 +1002,8 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
const requestBody =
|
||||
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
|
||||
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
|
||||
const accountId =
|
||||
typeof requestBody.accountId === 'string' ? requestBody.accountId.trim() : undefined;
|
||||
const kiroMethodRaw = requestBody.kiroMethod;
|
||||
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
|
||||
const gitlabBaseUrl =
|
||||
@@ -1037,11 +1070,20 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
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(
|
||||
provider as CLIProxyProvider,
|
||||
nickname,
|
||||
existingAccounts
|
||||
localProvider,
|
||||
effectiveNickname,
|
||||
existingAccounts,
|
||||
targetAccountId
|
||||
);
|
||||
if (nicknameError) {
|
||||
res.status(400).json(nicknameError);
|
||||
@@ -1136,8 +1178,9 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
|
||||
|
||||
if (oauthState) {
|
||||
rememberManualAuthState(oauthState, {
|
||||
nickname: nickname || undefined,
|
||||
knownTokenFiles: listProviderTokenSnapshots(provider as CLIProxyProvider),
|
||||
nickname: effectiveNickname || undefined,
|
||||
expectedAccountId: targetAccountId,
|
||||
knownTokenFiles: listProviderTokenSnapshots(localProvider),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1237,7 +1280,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
||||
getProviderTokenDir(localProvider),
|
||||
pendingAuth.nickname,
|
||||
false,
|
||||
tokenSnapshot.file
|
||||
pendingAuth.expectedAccountId || tokenSnapshot.file
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
@@ -1386,7 +1429,7 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
getProviderTokenDir(localProvider),
|
||||
pendingAuth.nickname,
|
||||
false,
|
||||
tokenSnapshot.file
|
||||
pendingAuth.expectedAccountId || tokenSnapshot.file
|
||||
);
|
||||
|
||||
if (!account) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import type { Server } from 'http';
|
||||
import cliproxyAuthRoutes from '../../../src/web-server/routes/cliproxy-auth-routes';
|
||||
import { registerAccountFromToken } from '../../../src/cliproxy/auth/token-manager';
|
||||
import {
|
||||
clearQuotaCache,
|
||||
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 () => {
|
||||
mockFetch([
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
getKiroStartIDCValidationError,
|
||||
getReauthAccountTarget,
|
||||
getStartAuthFailureMessage,
|
||||
getStartAuthNicknameError,
|
||||
getStartUrlUnsupportedReason,
|
||||
@@ -142,3 +143,27 @@ describe('cliproxy-auth-routes nickname validation', () => {
|
||||
).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 { useCliproxyAuthFlow } from '@/hooks/use-cliproxy-auth-flow';
|
||||
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 { AntigravityResponsibilityChecklist } from '@/components/account/antigravity-responsibility-checklist';
|
||||
import {
|
||||
@@ -58,6 +58,8 @@ interface AddAccountDialogProps {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
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) */
|
||||
isFirstAccount?: boolean;
|
||||
}
|
||||
@@ -77,6 +79,7 @@ export function AddAccountDialog({
|
||||
provider,
|
||||
displayName,
|
||||
catalog,
|
||||
account,
|
||||
isFirstAccount = false,
|
||||
}: AddAccountDialogProps) {
|
||||
const [nickname, setNickname] = useState('');
|
||||
@@ -127,6 +130,8 @@ export function AddAccountDialog({
|
||||
const gitlabBaseUrlTrimmed = gitlabBaseUrl.trim();
|
||||
const gitlabPersonalAccessTokenTrimmed = gitlabPersonalAccessToken.trim();
|
||||
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 response = await fetch('/api/settings/auth/antigravity-risk');
|
||||
@@ -325,7 +330,8 @@ export function AddAccountDialog({
|
||||
}
|
||||
wasAuthenticatingRef.current = true;
|
||||
authFlow.startAuth(provider, {
|
||||
nickname: nicknameTrimmed || undefined,
|
||||
accountId: account?.id,
|
||||
nickname: account ? account.nickname : nicknameTrimmed || undefined,
|
||||
kiroMethod: isKiro ? kiroAuthMethod : undefined,
|
||||
kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined,
|
||||
kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined,
|
||||
@@ -385,13 +391,19 @@ export function AddAccountDialog({
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{isKiro
|
||||
? t('addAccountDialog.descKiro')
|
||||
: isDeviceCode
|
||||
? t('addAccountDialog.descDeviceCode')
|
||||
: t('addAccountDialog.descOauth')}
|
||||
{isReauth
|
||||
? `Refresh credentials for ${accountLabel}.`
|
||||
: isKiro
|
||||
? t('addAccountDialog.descKiro')
|
||||
: isDeviceCode
|
||||
? t('addAccountDialog.descDeviceCode')
|
||||
: t('addAccountDialog.descOauth')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -600,7 +612,16 @@ export function AddAccountDialog({
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<Label htmlFor="nickname">{t('addAccountDialog.nicknameOptional')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -784,7 +805,7 @@ export function AddAccountDialog({
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
{t('addAccountDialog.authenticate')}
|
||||
{isReauth ? 'Reauthenticate' : t('addAccountDialog.authenticate')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
MoreHorizontal,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Star,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
@@ -87,6 +88,7 @@ export function AccountItem({
|
||||
account,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
onReauth,
|
||||
onPauseToggle,
|
||||
isRemoving,
|
||||
isPausingAccount,
|
||||
@@ -167,6 +169,12 @@ export function AccountItem({
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onReauth && (
|
||||
<DropdownMenuItem onClick={onReauth}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Reauthenticate
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
|
||||
interface AccountsSectionProps {
|
||||
accounts: OAuthAccount[];
|
||||
onAddAccount: () => void;
|
||||
onReauthAccount?: (account: OAuthAccount) => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
@@ -48,6 +49,7 @@ interface AccountsSectionProps {
|
||||
export function AccountsSection({
|
||||
accounts,
|
||||
onAddAccount,
|
||||
onReauthAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
@@ -175,6 +177,7 @@ export function AccountsSection({
|
||||
account={account}
|
||||
onSetDefault={() => onSetDefault(account.id)}
|
||||
onRemove={() => onRemoveAccount(account.id)}
|
||||
onReauth={onReauthAccount ? () => onReauthAccount(account) : undefined}
|
||||
onPauseToggle={
|
||||
onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export function ProviderEditor({
|
||||
defaultTarget,
|
||||
topNotice,
|
||||
onAddAccount,
|
||||
onReauthAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
@@ -282,6 +283,7 @@ export function ProviderEditor({
|
||||
isDeletePending={deletePresetMutation.isPending}
|
||||
accounts={accounts}
|
||||
onAddAccount={onAddAccount}
|
||||
onReauthAccount={onReauthAccount}
|
||||
onSetDefault={onSetDefault}
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
onPauseToggle={onPauseToggle}
|
||||
|
||||
@@ -40,6 +40,7 @@ interface ModelConfigTabProps {
|
||||
isDeletePending?: boolean;
|
||||
accounts: OAuthAccount[];
|
||||
onAddAccount: () => void;
|
||||
onReauthAccount?: (account: OAuthAccount) => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
@@ -82,6 +83,7 @@ export function ModelConfigTab({
|
||||
isDeletePending,
|
||||
accounts,
|
||||
onAddAccount,
|
||||
onReauthAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
@@ -176,6 +178,7 @@ export function ModelConfigTab({
|
||||
<AccountsSection
|
||||
accounts={accounts}
|
||||
onAddAccount={onAddAccount}
|
||||
onReauthAccount={onReauthAccount}
|
||||
onSetDefault={onSetDefault}
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
onPauseToggle={onPauseToggle}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface ProviderEditorProps {
|
||||
/** Optional contextual notice shown directly under the editor header */
|
||||
topNotice?: ReactNode;
|
||||
onAddAccount: () => void;
|
||||
onReauthAccount?: (account: OAuthAccount) => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
@@ -63,6 +64,7 @@ export interface AccountItemProps {
|
||||
account: OAuthAccount;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
onReauth?: () => void;
|
||||
onPauseToggle?: (paused: boolean) => void;
|
||||
/** Solo mode: activate this account, pause all others */
|
||||
onSoloMode?: () => void;
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
usePauseAccount,
|
||||
useResumeAccount,
|
||||
} 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 { LivePulse } from './components/live-pulse';
|
||||
@@ -86,6 +86,8 @@ export function AuthMonitor() {
|
||||
const displayedSuccessRate = selectedProviderData
|
||||
? getSuccessRate(selectedProviderData.successCount, selectedProviderData.failureCount)
|
||||
: overallSuccessRate;
|
||||
const showHighFailureGuidance =
|
||||
displayedAccountCount >= 5 && displayedFailure >= 20 && displayedSuccessRate < 50;
|
||||
|
||||
const handlePauseToggle = (accountIds: string[], paused: boolean) => {
|
||||
if (
|
||||
@@ -204,6 +206,21 @@ export function AuthMonitor() {
|
||||
/>
|
||||
</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 */}
|
||||
<div className="relative overflow-hidden">
|
||||
{selectedProviderData ? (
|
||||
|
||||
@@ -25,6 +25,7 @@ interface AuthFlowState {
|
||||
}
|
||||
|
||||
interface StartAuthOptions {
|
||||
accountId?: string;
|
||||
nickname?: string;
|
||||
kiroMethod?: string;
|
||||
kiroIDCStartUrl?: string;
|
||||
@@ -286,6 +287,7 @@ export function useCliproxyAuthFlow() {
|
||||
const deviceCodeFlow = flowType === 'device_code';
|
||||
const startEndpoint = options?.startEndpoint || (deviceCodeFlow ? 'start' : 'start-url');
|
||||
const payload = {
|
||||
accountId: options?.accountId,
|
||||
nickname: options?.nickname,
|
||||
kiroMethod: options?.kiroMethod,
|
||||
kiroIDCStartUrl: options?.kiroIDCStartUrl,
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
useBulkResumeAccounts,
|
||||
useDeleteVariant,
|
||||
} 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 {
|
||||
getProviderDisplayName,
|
||||
@@ -256,6 +256,7 @@ export function CliproxyPage() {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
isFirstAccount: boolean;
|
||||
account?: OAuthAccount;
|
||||
} | null>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
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 (
|
||||
<div className="flex h-full min-h-0 overflow-hidden">
|
||||
{/* Left Sidebar */}
|
||||
@@ -567,6 +585,14 @@ export function CliproxyPage() {
|
||||
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onReauthAccount={(account) =>
|
||||
handleReauthAccount(
|
||||
selectedVariantData.provider,
|
||||
parentAuthForVariant.displayName,
|
||||
parentAuthForVariant.accounts,
|
||||
account.id
|
||||
)
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
@@ -617,6 +643,14 @@ export function CliproxyPage() {
|
||||
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onReauthAccount={(account) =>
|
||||
handleReauthAccount(
|
||||
selectedStatus.provider,
|
||||
selectedStatus.displayName,
|
||||
selectedStatus.accounts,
|
||||
account.id
|
||||
)
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
@@ -663,6 +697,7 @@ export function CliproxyPage() {
|
||||
: undefined
|
||||
}
|
||||
isFirstAccount={addAccountProvider?.isFirstAccount || false}
|
||||
account={addAccountProvider?.account}
|
||||
/>
|
||||
</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 () => {
|
||||
const guidance = [
|
||||
'Start local CLIProxy first: ccs cliproxy start',
|
||||
|
||||
@@ -191,4 +191,41 @@ describe('AuthMonitor', () => {
|
||||
expect(within(getSummaryCard('Failed')).getByText('3')).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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user