fix(accounts): focus dashboard on auth profiles and legacy context onboarding

This commit is contained in:
Tam Nhu Tran
2026-02-26 18:20:07 +07:00
parent 5c6fe20d3f
commit 10c08b9be8
7 changed files with 180 additions and 25 deletions
@@ -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;
}
+7
View File
@@ -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<void>
name,
context_mode: policy.mode,
context_group: policy.group ?? null,
context_inferred: false,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
@@ -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 }>;
+7 -1
View File
@@ -103,6 +103,12 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
return <span className="text-muted-foreground">shared ({group})</span>;
}
if (row.original.context_inferred) {
return (
<span className="text-amber-700 dark:text-amber-400">isolated (legacy default)</span>
);
}
return <span className="text-muted-foreground">isolated</span>;
},
},
@@ -165,7 +171,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
if (data.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No accounts found. Use{' '}
No CCS auth accounts found. Use{' '}
<code className="text-sm bg-muted px-1 rounded">ccs auth create</code> to add accounts.
</div>
);
+31
View File
@@ -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,
};
},
});
}
+3
View File
@@ -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 {
+111 -21
View File
@@ -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 (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Accounts</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage multi-account Claude sessions and shared context groups
</p>
<div className="rounded-xl border bg-gradient-to-br from-background via-background to-muted/40 p-5 sm:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<Badge variant="outline">CCS Auth Accounts</Badge>
<h1 className="text-2xl font-bold tracking-tight">Accounts</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
This page is dedicated to{' '}
<code className="rounded bg-muted px-1 py-0.5">ccs auth</code> accounts. Choose
isolated mode to keep context separate, or shared mode to link context across selected
accounts.
</p>
</div>
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Account
</Button>
{cliproxyCount > 0 && (
<Button variant="outline" onClick={() => navigate('/cliproxy')}>
Manage OAuth ({cliproxyCount})
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
</div>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Account
</Button>
</div>
{isLoading ? (
<div className="text-muted-foreground">Loading accounts...</div>
) : (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
New profile login: <code className="rounded bg-muted px-1 py-0.5">Create Account</code>.
Existing profile context: use the pencil icon in the table.
</p>
<AccountsTable data={data?.accounts || []} defaultAccount={data?.default ?? null} />
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardDescription>Total Auth Accounts</CardDescription>
<CardTitle className="text-2xl font-mono flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
{authAccounts.length}
</CardTitle>
</CardHeader>
</Card>
<Card className="border-emerald-200/70 bg-emerald-50/40 dark:border-emerald-900/40 dark:bg-emerald-900/10">
<CardHeader className="pb-2">
<CardDescription className="text-emerald-800/90 dark:text-emerald-300">
Shared (Linked)
</CardDescription>
<CardTitle className="text-2xl font-mono text-emerald-700 dark:text-emerald-300 flex items-center gap-2">
<Link2 className="w-5 h-5" />
{sharedCount}
</CardTitle>
</CardHeader>
</Card>
<Card className="border-blue-200/70 bg-blue-50/40 dark:border-blue-900/40 dark:bg-blue-900/10">
<CardHeader className="pb-2">
<CardDescription className="text-blue-800/90 dark:text-blue-300">
Isolated (Separate)
</CardDescription>
<CardTitle className="text-2xl font-mono text-blue-700 dark:text-blue-300 flex items-center gap-2">
<Unlink className="w-5 h-5" />
{isolatedCount}
</CardTitle>
</CardHeader>
</Card>
</div>
{cliproxyCount > 0 && (
<Alert variant="info">
<Zap className="h-4 w-4" />
<AlertTitle>OAuth accounts are managed elsewhere</AlertTitle>
<AlertDescription>
This screen hides {cliproxyCount} CLIProxy OAuth account
{cliproxyCount > 1 ? 's' : ''}. Use <strong>CLIProxy Plus</strong> to manage Gemini,
Codex, Antigravity, and other OAuth providers.
</AlertDescription>
</Alert>
)}
{legacyContextCount > 0 && (
<Alert variant="warning">
<Users className="h-4 w-4" />
<AlertTitle>Legacy accounts need context review</AlertTitle>
<AlertDescription>
{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.
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">CCS Auth Accounts</CardTitle>
<CardDescription>
New onboarding: <code className="rounded bg-muted px-1 py-0.5">Create Account</code>.
Existing accounts: use the pencil action to control linkage flexibility per account.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-muted-foreground">Loading accounts...</div>
) : (
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
)}
</CardContent>
</Card>
<CreateAuthProfileDialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} />
</div>
);