mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 20:20:09 +00:00
fix(accounts): focus dashboard on auth profiles and legacy context onboarding
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user