diff --git a/src/web-server/routes/account-route-helpers.ts b/src/web-server/routes/account-route-helpers.ts index 645262be..d8c2e9cc 100644 --- a/src/web-server/routes/account-route-helpers.ts +++ b/src/web-server/routes/account-route-helpers.ts @@ -7,6 +7,7 @@ export interface MergedAccountEntry { last_used: string | null; context_mode?: 'isolated' | 'shared'; context_group?: string; + context_inferred?: boolean; provider?: string; displayName?: string; } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 81d263b6..fdf1610a 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -56,24 +56,30 @@ router.get('/', (_req: Request, res: Response): void => { // Add legacy profiles first for (const [name, meta] of Object.entries(legacyProfiles)) { const contextPolicy = resolveAccountContextPolicy(meta); + const hasExplicitContextMode = + meta.context_mode === 'isolated' || meta.context_mode === 'shared'; merged[name] = { type: meta.type || 'account', created: meta.created, last_used: meta.last_used || null, context_mode: contextPolicy.mode, context_group: contextPolicy.group, + context_inferred: !hasExplicitContextMode, }; } // Override with unified config accounts (takes precedence) for (const [name, account] of Object.entries(unifiedAccounts)) { const contextPolicy = resolveAccountContextPolicy(account); + const hasExplicitContextMode = + account.context_mode === 'isolated' || account.context_mode === 'shared'; merged[name] = { type: 'account', created: account.created, last_used: account.last_used, context_mode: contextPolicy.mode, context_group: contextPolicy.group, + context_inferred: !hasExplicitContextMode, }; } @@ -252,6 +258,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise name, context_mode: policy.mode, context_group: policy.group ?? null, + context_inferred: false, }); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/tests/unit/web-server/account-routes-context.test.ts b/tests/unit/web-server/account-routes-context.test.ts index 2456ff37..4f9efb68 100644 --- a/tests/unit/web-server/account-routes-context.test.ts +++ b/tests/unit/web-server/account-routes-context.test.ts @@ -104,12 +104,18 @@ describe('web-server account-routes context normalization', () => { ); const payload = await getJson<{ - accounts: Array<{ name: string; context_mode?: string; context_group?: string }>; + accounts: Array<{ + name: string; + context_mode?: string; + context_group?: string; + context_inferred?: boolean; + }>; }>(baseUrl, '/api/accounts'); const work = payload.accounts.find((account) => account.name === 'work'); expect(work).toBeTruthy(); expect(work?.context_mode).toBe('isolated'); + expect(work?.context_inferred).toBe(true); expect(work && 'context_group' in work).toBe(false); }); @@ -137,13 +143,19 @@ describe('web-server account-routes context normalization', () => { ); const payload = await getJson<{ - accounts: Array<{ name: string; context_mode?: string; context_group?: string }>; + accounts: Array<{ + name: string; + context_mode?: string; + context_group?: string; + context_inferred?: boolean; + }>; }>(baseUrl, '/api/accounts'); const work = payload.accounts.find((account) => account.name === 'work'); expect(work).toBeTruthy(); expect(work?.context_mode).toBe('shared'); expect(work?.context_group).toBe('default'); + expect(work?.context_inferred).toBe(false); }); it('does not delete metadata when instance deletion fails', async () => { @@ -210,9 +222,14 @@ describe('web-server account-routes context normalization', () => { context_group: ' Team Alpha ', }); expect(response.status).toBe(200); - const payload = (await response.json()) as { context_mode: string; context_group: string | null }; + const payload = (await response.json()) as { + context_mode: string; + context_group: string | null; + context_inferred?: boolean; + }; expect(payload.context_mode).toBe('shared'); expect(payload.context_group).toBe('team-alpha'); + expect(payload.context_inferred).toBe(false); const accountsPayload = await getJson<{ accounts: Array<{ name: string; context_mode?: string; context_group?: string }>; diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx index 69467192..7c49ba5d 100644 --- a/ui/src/components/account/accounts-table.tsx +++ b/ui/src/components/account/accounts-table.tsx @@ -103,6 +103,12 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { return shared ({group}); } + if (row.original.context_inferred) { + return ( + isolated (legacy default) + ); + } + return isolated; }, }, @@ -165,7 +171,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { if (data.length === 0) { return (
- No accounts found. Use{' '} + No CCS auth accounts found. Use{' '} ccs auth create to add accounts.
); diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts index 1e616022..82684e3e 100644 --- a/ui/src/hooks/use-accounts.ts +++ b/ui/src/hooks/use-accounts.ts @@ -5,12 +5,43 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api-client'; +import type { Account } from '@/lib/api-client'; import { toast } from 'sonner'; +export interface AuthAccountsView { + accounts: Account[]; + default: string | null; + cliproxyCount: number; + legacyContextCount: number; + sharedCount: number; + isolatedCount: number; +} + export function useAccounts() { return useQuery({ queryKey: ['accounts'], queryFn: () => api.accounts.list(), + select: (data): AuthAccountsView => { + const authAccounts = data.accounts.filter((account) => account.type !== 'cliproxy'); + const cliproxyCount = data.accounts.length - authAccounts.length; + const sharedCount = authAccounts.filter( + (account) => account.context_mode === 'shared' + ).length; + const isolatedCount = authAccounts.length - sharedCount; + const legacyContextCount = authAccounts.filter((account) => account.context_inferred).length; + const defaultAccount = authAccounts.some((account) => account.name === data.default) + ? data.default + : null; + + return { + accounts: authAccounts, + default: defaultAccount, + cliproxyCount, + legacyContextCount, + sharedCount, + isolatedCount, + }; + }, }); } diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index bab3455e..f45600d6 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -455,6 +455,9 @@ export interface Account { last_used?: string | null; context_mode?: 'isolated' | 'shared'; context_group?: string; + context_inferred?: boolean; + provider?: string; + displayName?: string; } export interface UpdateAccountContext { diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index 9a47b1df..9bde14fe 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -4,43 +4,133 @@ */ import { useState } from 'react'; -import { Plus } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowRight, Link2, Plus, Unlink, Users, Zap } from 'lucide-react'; import { AccountsTable } from '@/components/account/accounts-table'; import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { useAccounts } from '@/hooks/use-accounts'; export function AccountsPage() { + const navigate = useNavigate(); const { data, isLoading } = useAccounts(); const [createDialogOpen, setCreateDialogOpen] = useState(false); + const authAccounts = data?.accounts || []; + const cliproxyCount = data?.cliproxyCount || 0; + const legacyContextCount = data?.legacyContextCount || 0; + const sharedCount = data?.sharedCount || 0; + const isolatedCount = data?.isolatedCount || 0; return (
-
-
-

Accounts

-

- Manage multi-account Claude sessions and shared context groups -

+
+
+
+ CCS Auth Accounts +

Accounts

+

+ This page is dedicated to{' '} + ccs auth accounts. Choose + isolated mode to keep context separate, or shared mode to link context across selected + accounts. +

+
+ +
+ + {cliproxyCount > 0 && ( + + )} +
-
- {isLoading ? ( -
Loading accounts...
- ) : ( -
-

- New profile login: Create Account. - Existing profile context: use the pencil icon in the table. -

- -
+
+ + + Total Auth Accounts + + + {authAccounts.length} + + + + + + + + Shared (Linked) + + + + {sharedCount} + + + + + + + + Isolated (Separate) + + + + {isolatedCount} + + + +
+ + {cliproxyCount > 0 && ( + + + OAuth accounts are managed elsewhere + + This screen hides {cliproxyCount} CLIProxy OAuth account + {cliproxyCount > 1 ? 's' : ''}. Use CLIProxy Plus to manage Gemini, + Codex, Antigravity, and other OAuth providers. + + )} + {legacyContextCount > 0 && ( + + + Legacy accounts need context review + + {legacyContextCount} account{legacyContextCount > 1 ? 's were' : ' was'} onboarded + before context controls and currently default to isolated mode. Use the pencil action in + the table to explicitly choose isolated or shared. + + + )} + + + + CCS Auth Accounts + + New onboarding: Create Account. + Existing accounts: use the pencil action to control linkage flexibility per account. + + + + {isLoading ? ( +
Loading accounts...
+ ) : ( + + )} +
+
+ setCreateDialogOpen(false)} />
);