mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +00:00
fix(accounts): focus dashboard on auth profiles and legacy context onboarding
This commit is contained in:
1 parent
5c6fe20d3f
commit
10c08b9be8
7 files changed
+180
-25
No files matched your search
@@ -7,6 +7,7 @@ export interface MergedAccountEntry {
|
|||||||
last_used: string | null;
|
last_used: string | null;
|
||||||
context_mode?: 'isolated' | 'shared';
|
context_mode?: 'isolated' | 'shared';
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
|
context_inferred?: boolean;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,24 +56,30 @@ router.get('/', (_req: Request, res: Response): void => {
|
|||||||
// Add legacy profiles first
|
// Add legacy profiles first
|
||||||
for (const [name, meta] of Object.entries(legacyProfiles)) {
|
for (const [name, meta] of Object.entries(legacyProfiles)) {
|
||||||
const contextPolicy = resolveAccountContextPolicy(meta);
|
const contextPolicy = resolveAccountContextPolicy(meta);
|
||||||
|
const hasExplicitContextMode =
|
||||||
|
meta.context_mode === 'isolated' || meta.context_mode === 'shared';
|
||||||
merged[name] = {
|
merged[name] = {
|
||||||
type: meta.type || 'account',
|
type: meta.type || 'account',
|
||||||
created: meta.created,
|
created: meta.created,
|
||||||
last_used: meta.last_used || null,
|
last_used: meta.last_used || null,
|
||||||
context_mode: contextPolicy.mode,
|
context_mode: contextPolicy.mode,
|
||||||
context_group: contextPolicy.group,
|
context_group: contextPolicy.group,
|
||||||
|
context_inferred: !hasExplicitContextMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override with unified config accounts (takes precedence)
|
// Override with unified config accounts (takes precedence)
|
||||||
for (const [name, account] of Object.entries(unifiedAccounts)) {
|
for (const [name, account] of Object.entries(unifiedAccounts)) {
|
||||||
const contextPolicy = resolveAccountContextPolicy(account);
|
const contextPolicy = resolveAccountContextPolicy(account);
|
||||||
|
const hasExplicitContextMode =
|
||||||
|
account.context_mode === 'isolated' || account.context_mode === 'shared';
|
||||||
merged[name] = {
|
merged[name] = {
|
||||||
type: 'account',
|
type: 'account',
|
||||||
created: account.created,
|
created: account.created,
|
||||||
last_used: account.last_used,
|
last_used: account.last_used,
|
||||||
context_mode: contextPolicy.mode,
|
context_mode: contextPolicy.mode,
|
||||||
context_group: contextPolicy.group,
|
context_group: contextPolicy.group,
|
||||||
|
context_inferred: !hasExplicitContextMode,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,6 +258,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
|||||||
name,
|
name,
|
||||||
context_mode: policy.mode,
|
context_mode: policy.mode,
|
||||||
context_group: policy.group ?? null,
|
context_group: policy.group ?? null,
|
||||||
|
context_inferred: false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: (error as Error).message });
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
|||||||
@@ -104,12 +104,18 @@ describe('web-server account-routes context normalization', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const payload = await getJson<{
|
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');
|
}>(baseUrl, '/api/accounts');
|
||||||
|
|
||||||
const work = payload.accounts.find((account) => account.name === 'work');
|
const work = payload.accounts.find((account) => account.name === 'work');
|
||||||
expect(work).toBeTruthy();
|
expect(work).toBeTruthy();
|
||||||
expect(work?.context_mode).toBe('isolated');
|
expect(work?.context_mode).toBe('isolated');
|
||||||
|
expect(work?.context_inferred).toBe(true);
|
||||||
expect(work && 'context_group' in work).toBe(false);
|
expect(work && 'context_group' in work).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,13 +143,19 @@ describe('web-server account-routes context normalization', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const payload = await getJson<{
|
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');
|
}>(baseUrl, '/api/accounts');
|
||||||
|
|
||||||
const work = payload.accounts.find((account) => account.name === 'work');
|
const work = payload.accounts.find((account) => account.name === 'work');
|
||||||
expect(work).toBeTruthy();
|
expect(work).toBeTruthy();
|
||||||
expect(work?.context_mode).toBe('shared');
|
expect(work?.context_mode).toBe('shared');
|
||||||
expect(work?.context_group).toBe('default');
|
expect(work?.context_group).toBe('default');
|
||||||
|
expect(work?.context_inferred).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not delete metadata when instance deletion fails', async () => {
|
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 ',
|
context_group: ' Team Alpha ',
|
||||||
});
|
});
|
||||||
expect(response.status).toBe(200);
|
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_mode).toBe('shared');
|
||||||
expect(payload.context_group).toBe('team-alpha');
|
expect(payload.context_group).toBe('team-alpha');
|
||||||
|
expect(payload.context_inferred).toBe(false);
|
||||||
|
|
||||||
const accountsPayload = await getJson<{
|
const accountsPayload = await getJson<{
|
||||||
accounts: Array<{ name: string; context_mode?: string; context_group?: string }>;
|
accounts: Array<{ name: string; context_mode?: string; context_group?: string }>;
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
return <span className="text-muted-foreground">shared ({group})</span>;
|
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>;
|
return <span className="text-muted-foreground">isolated</span>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -165,7 +171,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<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.
|
<code className="text-sm bg-muted px-1 rounded">ccs auth create</code> to add accounts.
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,12 +5,43 @@
|
|||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api } from '@/lib/api-client';
|
import { api } from '@/lib/api-client';
|
||||||
|
import type { Account } from '@/lib/api-client';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export interface AuthAccountsView {
|
||||||
|
accounts: Account[];
|
||||||
|
default: string | null;
|
||||||
|
cliproxyCount: number;
|
||||||
|
legacyContextCount: number;
|
||||||
|
sharedCount: number;
|
||||||
|
isolatedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function useAccounts() {
|
export function useAccounts() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['accounts'],
|
queryKey: ['accounts'],
|
||||||
queryFn: () => api.accounts.list(),
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -455,6 +455,9 @@ export interface Account {
|
|||||||
last_used?: string | null;
|
last_used?: string | null;
|
||||||
context_mode?: 'isolated' | 'shared';
|
context_mode?: 'isolated' | 'shared';
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
|
context_inferred?: boolean;
|
||||||
|
provider?: string;
|
||||||
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateAccountContext {
|
export interface UpdateAccountContext {
|
||||||
|
|||||||
+111
-21
@@ -4,43 +4,133 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
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 { AccountsTable } from '@/components/account/accounts-table';
|
||||||
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
|
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
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';
|
import { useAccounts } from '@/hooks/use-accounts';
|
||||||
|
|
||||||
export function AccountsPage() {
|
export function AccountsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { data, isLoading } = useAccounts();
|
const { data, isLoading } = useAccounts();
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
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 (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="rounded-xl border bg-gradient-to-br from-background via-background to-muted/40 p-5 sm:p-6">
|
||||||
<div>
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<h1 className="text-2xl font-bold">Accounts</h1>
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<Badge variant="outline">CCS Auth Accounts</Badge>
|
||||||
Manage multi-account Claude sessions and shared context groups
|
<h1 className="text-2xl font-bold tracking-tight">Accounts</h1>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Create Account
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
<div className="text-muted-foreground">Loading accounts...</div>
|
<Card>
|
||||||
) : (
|
<CardHeader className="pb-2">
|
||||||
<div className="space-y-3">
|
<CardDescription>Total Auth Accounts</CardDescription>
|
||||||
<p className="text-xs text-muted-foreground">
|
<CardTitle className="text-2xl font-mono flex items-center gap-2">
|
||||||
New profile login: <code className="rounded bg-muted px-1 py-0.5">Create Account</code>.
|
<Users className="w-5 h-5 text-primary" />
|
||||||
Existing profile context: use the pencil icon in the table.
|
{authAccounts.length}
|
||||||
</p>
|
</CardTitle>
|
||||||
<AccountsTable data={data?.accounts || []} defaultAccount={data?.default ?? null} />
|
</CardHeader>
|
||||||
</div>
|
</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)} />
|
<CreateAuthProfileDialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in new issue
Block a user