fix(accounts): improve shared-context editing and discoverability

This commit is contained in:
Tam Nhu Tran
2026-02-26 18:10:06 +07:00
parent 821eade594
commit 5c6fe20d3f
14 changed files with 501 additions and 17 deletions
+8
View File
@@ -279,6 +279,12 @@ ccs auth create backup --share-context
ccs auth create backup2 --context-group sprint-a
```
Update existing accounts without recreating login:
1. Run `ccs config`
2. Open `Accounts`
3. Click the pencil icon in Actions and set `isolated` or `shared` mode
Shared mode metadata in `~/.ccs/config.yaml`:
```yaml
@@ -300,6 +306,8 @@ accounts:
Shared context links project workspace data only. Credentials remain isolated per account.
Technical details: [`docs/session-sharing-technical-analysis.md`](docs/session-sharing-technical-analysis.md)
<br>
## Maintenance
+7 -1
View File
@@ -1,6 +1,6 @@
# Dashboard Authentication CLI
Last Updated: 2026-02-24
Last Updated: 2026-02-26
CLI commands for managing CCS dashboard authentication.
@@ -39,6 +39,12 @@ Account context is isolation-first:
- normalizes valid shared `context_group` before save
- rejects `context_group` when mode is not `shared`
Dashboard accounts context editing:
- `PUT /api/accounts/:name/context` updates context mode/group for existing auth accounts
- rejects CLIProxy OAuth account keys for this route
- applies normalization/validation rules above
## Commands
### `ccs config auth setup`
@@ -0,0 +1,70 @@
# Session Sharing Technical Analysis
Last Updated: 2026-02-26
## Summary
CCS supports practical cross-account continuity by sharing workspace context files between selected accounts, while keeping credentials isolated per account.
This is implemented as a context policy per account:
- `isolated` (default): account keeps its own workspace context
- `shared`: account workspace context is linked to a shared context group
## Why This Is Safe Enough
CCS only shares workspace context paths (project/session context files). It does **not** merge or copy authentication credentials between accounts.
Credential storage remains per account instance.
## Implementation Model
Account metadata is stored in `~/.ccs/config.yaml`:
```yaml
accounts:
work:
created: "2026-02-24T00:00:00.000Z"
last_used: null
context_mode: "shared"
context_group: "team-alpha"
```
Rules:
- `context_mode` must be `isolated` or `shared`
- `context_group` is required when `context_mode=shared`
- group normalization: trim, lowercase, internal spaces -> `-`
- group must start with a letter and only include `[a-zA-Z0-9_-]`
- max length: `64`
## User Workflows
### New account with shared context
```bash
ccs auth create work2 --share-context
ccs auth create backup --context-group sprint-a
```
### Existing account
- Open `ccs config`
- Go to `Accounts`
- Click the pencil icon (`Edit Context`)
- Choose `isolated` or `shared` and set group
No account recreation required for this workflow.
## Current Limitations
- Shared context is local filesystem sharing. It does not bypass remote provider permission models.
- Session continuity still depends on what the upstream tool/provider stores and allows.
- Context sharing should only be enabled for accounts you intentionally trust to share workspace history.
## Validation Checklist
- Confirm account row shows `shared (<group>)` in Dashboard Accounts table
- Switch between accounts in the same group and verify workspace continuity
- Run `ccs doctor` if symlink/context health looks inconsistent
+3
View File
@@ -128,6 +128,9 @@ class AuthCommands {
console.log(
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
);
console.log(
` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
);
console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`);
console.log(
` ${color('context_group', 'path')} must be non-empty and <= ${MAX_CONTEXT_GROUP_LENGTH} chars in shared mode.`
+1
View File
@@ -151,6 +151,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
[
['ccs auth --help', 'Show account management commands'],
['ccs auth create <name>', 'Create account profile (supports context sharing flags)'],
['ccs config', 'Dashboard: Accounts table can edit context mode/group'],
['ccs auth list', 'List all account profiles'],
['ccs auth default <name>', 'Set default profile'],
['ccs auth reset-default', 'Restore original CCS default'],
+110 -1
View File
@@ -19,7 +19,11 @@ import {
soloAccount,
} from '../../cliproxy/account-manager';
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
import { resolveAccountContextPolicy } from '../../auth/account-context';
import {
isValidContextGroupName,
normalizeContextGroupName,
resolveAccountContextPolicy,
} from '../../auth/account-context';
import {
buildCliproxyAccountKey,
parseCliproxyKey,
@@ -149,6 +153,111 @@ router.post('/default', (req: Request, res: Response): void => {
}
});
/**
* PUT /api/accounts/:name/context - Update account context mode/group
*/
router.put('/:name/context', async (req: Request, res: Response): Promise<void> => {
try {
const { name } = req.params;
if (!name) {
res.status(400).json({ error: 'Missing account name' });
return;
}
// CLIProxy OAuth accounts do not support local account context metadata.
const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null;
if (cliproxyKey) {
res
.status(400)
.json({ error: `Context mode is not supported for CLIProxy account: ${name}` });
return;
}
const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name);
const existsLegacy = registry.hasProfile(name);
if (!existsUnified && !existsLegacy) {
res.status(404).json({ error: `Account not found: ${name}` });
return;
}
const mode = req.body?.context_mode;
const rawGroup = req.body?.context_group;
if (mode !== 'isolated' && mode !== 'shared') {
res.status(400).json({ error: 'Missing or invalid context_mode: expected isolated|shared' });
return;
}
if (mode !== 'shared' && rawGroup !== undefined) {
res
.status(400)
.json({ error: 'Invalid payload: context_group requires context_mode=shared' });
return;
}
let normalizedGroup: string | undefined;
if (mode === 'shared') {
if (typeof rawGroup !== 'string' || rawGroup.trim().length === 0) {
res
.status(400)
.json({ error: 'Invalid payload: shared context_mode requires non-empty context_group' });
return;
}
normalizedGroup = normalizeContextGroupName(rawGroup);
if (!isValidContextGroupName(normalizedGroup)) {
res.status(400).json({
error:
'Invalid context_group. Use letters/numbers/dash/underscore, start with a letter, max 64 chars.',
});
return;
}
}
const metadata =
mode === 'shared'
? {
context_mode: 'shared' as const,
context_group: normalizedGroup,
}
: {
context_mode: 'isolated' as const,
};
const policy = resolveAccountContextPolicy(metadata);
const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined;
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
try {
if (existsUnified) {
registry.updateAccountUnified(name, metadata);
}
if (existsLegacy) {
registry.updateProfile(name, metadata);
}
await instanceMgr.ensureInstance(name, policy);
} catch (error) {
if (existsUnified && previousUnified) {
registry.updateAccountUnified(name, previousUnified);
}
if (existsLegacy && previousLegacy) {
registry.updateProfile(name, previousLegacy);
}
throw error;
}
res.json({
name,
context_mode: policy.mode,
context_group: policy.group ?? null,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* DELETE /api/accounts/reset-default - Reset to CCS default
*/
@@ -18,6 +18,14 @@ async function deletePath(baseUrl: string, routePath: string): Promise<Response>
return fetch(`${baseUrl}${routePath}`, { method: 'DELETE' });
}
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
return fetch(`${baseUrl}${routePath}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('web-server account-routes context normalization', () => {
let server: Server;
let baseUrl = '';
@@ -27,6 +35,7 @@ describe('web-server account-routes context normalization', () => {
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use('/api/accounts', accountRoutes);
await new Promise<void>((resolve, reject) => {
@@ -173,4 +182,84 @@ describe('web-server account-routes context normalization', () => {
InstanceManager.prototype.deleteInstance = originalDeleteInstance;
}
});
it('updates existing account context metadata and normalizes shared group', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
' context_mode: isolated',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const response = await putJson(baseUrl, '/api/accounts/work/context', {
context_mode: 'shared',
context_group: ' Team Alpha ',
});
expect(response.status).toBe(200);
const payload = (await response.json()) as { context_mode: string; context_group: string | null };
expect(payload.context_mode).toBe('shared');
expect(payload.context_group).toBe('team-alpha');
const accountsPayload = await getJson<{
accounts: Array<{ name: string; context_mode?: string; context_group?: string }>;
}>(baseUrl, '/api/accounts');
const work = accountsPayload.accounts.find((account) => account.name === 'work');
expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('team-alpha');
});
it('rejects shared mode updates without context_group', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const response = await putJson(baseUrl, '/api/accounts/work/context', {
context_mode: 'shared',
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('context_group');
});
it('rejects context updates for CLIProxy account identifiers', async () => {
const response = await putJson(baseUrl, '/api/accounts/gemini:test/context', {
context_mode: 'shared',
context_group: 'default',
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('CLIProxy');
});
});
+22 -4
View File
@@ -24,7 +24,8 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Check, Trash2, RotateCcw } from 'lucide-react';
import { Check, Pencil, Trash2, RotateCcw } from 'lucide-react';
import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog';
import {
useSetDefaultAccount,
useDeleteAccount,
@@ -35,7 +36,6 @@ import type { Account } from '@/lib/api-client';
interface AccountsTableProps {
data: Account[];
defaultAccount: string | null;
onRefresh?: () => void;
}
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
@@ -43,6 +43,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
const deleteMutation = useDeleteAccount();
const resetDefaultMutation = useResetDefaultAccount();
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [contextTarget, setContextTarget] = useState<Account | null>(null);
const columns: ColumnDef<Account>[] = [
{
@@ -108,13 +109,26 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
{
id: 'actions',
header: 'Actions',
size: 180,
size: 220,
cell: ({ row }) => {
const isDefault = row.original.name === defaultAccount;
const isPending = setDefaultMutation.isPending || deleteMutation.isPending;
const isCliproxy = row.original.type === 'cliproxy';
return (
<div className="flex items-center gap-1">
{!isCliproxy && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
disabled={isPending}
onClick={() => setContextTarget(row.original)}
title="Edit context mode"
>
<Pencil className="w-4 h-4" />
</Button>
)}
<Button
variant={isDefault ? 'secondary' : 'default'}
size="sm"
@@ -173,7 +187,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
created: 'w-[150px]',
last_used: 'w-[150px]',
context: 'w-[170px]',
actions: 'w-[180px]',
actions: 'w-[220px]',
}[header.id] || 'w-auto';
return (
@@ -217,6 +231,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
)}
</div>
{contextTarget && (
<EditAccountContextDialog account={contextTarget} onClose={() => setContextTarget(null)} />
)}
{/* Delete confirmation dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
@@ -32,7 +32,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
// Validate profile name: alphanumeric, dash, underscore only
const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName);
const normalizedGroup = contextGroup.trim().toLowerCase();
const normalizedGroup = contextGroup.trim().toLowerCase().replace(/\s+/g, '-');
const isValidContextGroup =
normalizedGroup.length === 0 ||
(normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH &&
@@ -73,7 +73,8 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
<DialogHeader>
<DialogTitle>Create New Account</DialogTitle>
<DialogDescription>
Auth profiles require Claude CLI login. Run the command below in your terminal.
Auth profiles require Claude CLI login. Run the command below in your terminal. You can
edit context mode/group later from the Accounts table.
</DialogDescription>
</DialogHeader>
@@ -118,7 +119,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the default shared group.
Leave empty to use the default shared group. Spaces are normalized to dashes.
</p>
{contextGroup.trim().length > 0 && !isValidContextGroup && (
<p className="text-xs text-destructive">
@@ -0,0 +1,139 @@
import { useMemo, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Account } from '@/lib/api-client';
import { useUpdateAccountContext } from '@/hooks/use-accounts';
type ContextMode = 'isolated' | 'shared';
const MAX_CONTEXT_GROUP_LENGTH = 64;
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
interface EditAccountContextDialogProps {
account: Account;
onClose: () => void;
}
export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) {
const updateContextMutation = useUpdateAccountContext();
const [mode, setMode] = useState<ContextMode>(
account.context_mode === 'shared' ? 'shared' : 'isolated'
);
const [group, setGroup] = useState(account.context_group || 'default');
const normalizedGroup = useMemo(() => group.trim().toLowerCase().replace(/\s+/g, '-'), [group]);
const isSharedGroupValid =
normalizedGroup.length > 0 &&
normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH &&
CONTEXT_GROUP_PATTERN.test(normalizedGroup);
const canSubmit = mode === 'isolated' || isSharedGroupValid;
const handleSave = () => {
if (!canSubmit) {
return;
}
updateContextMutation.mutate(
{
name: account.name,
context_mode: mode,
context_group: mode === 'shared' ? normalizedGroup : undefined,
},
{
onSuccess: () => {
onClose();
},
}
);
};
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
onClose();
}
};
return (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Context Mode</DialogTitle>
<DialogDescription>
Configure how "{account.name}" shares project workspace context with other accounts.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="context-mode">Context Mode</Label>
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
<SelectTrigger id="context-mode">
<SelectValue placeholder="Select context mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="isolated">isolated</SelectItem>
<SelectItem value="shared">shared</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{mode === 'shared'
? 'Shared mode reuses workspace context for accounts in the same group.'
: 'Isolated mode keeps this account workspace context separate.'}
</p>
</div>
{mode === 'shared' && (
<div className="space-y-2">
<Label htmlFor="context-group">Context Group</Label>
<Input
id="context-group"
value={group}
onChange={(event) => setGroup(event.target.value)}
placeholder="default"
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, `_`, `-`
(max {MAX_CONTEXT_GROUP_LENGTH} chars).
</p>
{!isSharedGroupValid && (
<p className="text-xs text-destructive">
Enter a valid group name that starts with a letter.
</p>
)}
</div>
)}
<p className="text-xs text-muted-foreground">
Credentials stay isolated per account. Only project workspace context is shared.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={updateContextMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!canSubmit || updateContextMutation.isPending}>
{updateContextMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+1
View File
@@ -6,6 +6,7 @@
export { AccountsTable } from './accounts-table';
export { AddAccountDialog } from './add-account-dialog';
export { CreateAuthProfileDialog } from './create-auth-profile-dialog';
export { EditAccountContextDialog } from './edit-account-context-dialog';
// Flow visualization (from subdirectory)
export { AccountFlowViz } from './flow-viz';
+28 -1
View File
@@ -1,5 +1,5 @@
/**
* React Query hooks for accounts (profiles.json)
* React Query hooks for account management
* Dashboard parity: Full CRUD operations for auth profiles
*/
@@ -58,3 +58,30 @@ export function useDeleteAccount() {
},
});
}
export function useUpdateAccountContext() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
name,
context_mode,
context_group,
}: {
name: string;
context_mode: 'isolated' | 'shared';
context_group?: string;
}) => api.accounts.updateContext(name, { context_mode, context_group }),
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
const contextSummary =
vars.context_mode === 'shared'
? `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')})`
: 'isolated';
toast.success(`Updated "${vars.name}" context to ${contextSummary}`);
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
+10
View File
@@ -457,6 +457,11 @@ export interface Account {
context_group?: string;
}
export interface UpdateAccountContext {
context_mode: 'isolated' | 'shared';
context_group?: string;
}
// Unified config types
export interface ConfigFormat {
format: 'yaml' | 'json' | 'none';
@@ -785,6 +790,11 @@ export const api = {
}),
resetDefault: () => request('/accounts/reset-default', { method: 'DELETE' }),
delete: (name: string) => request(`/accounts/${name}`, { method: 'DELETE' }),
updateContext: (name: string, data: UpdateAccountContext) =>
request(`/accounts/${encodeURIComponent(name)}/context`, {
method: 'PUT',
body: JSON.stringify(data),
}),
},
// Unified config API
config: {
+9 -7
View File
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button';
import { useAccounts } from '@/hooks/use-accounts';
export function AccountsPage() {
const { data, isLoading, refetch } = useAccounts();
const { data, isLoading } = useAccounts();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
return (
@@ -20,7 +20,7 @@ export function AccountsPage() {
<div>
<h1 className="text-2xl font-bold">Accounts</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage multi-account Claude sessions (profiles.json)
Manage multi-account Claude sessions and shared context groups
</p>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
@@ -32,11 +32,13 @@ export function AccountsPage() {
{isLoading ? (
<div className="text-muted-foreground">Loading accounts...</div>
) : (
<AccountsTable
data={data?.accounts || []}
defaultAccount={data?.default ?? null}
onRefresh={refetch}
/>
<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>
)}
<CreateAuthProfileDialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} />