mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 20:20:09 +00:00
fix(accounts): improve shared-context editing and discoverability
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
Reference in New Issue
Block a user