mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 04:14:57 +00:00
fix(accounts): improve shared-context editing and discoverability
This commit is contained in:
1 parent
821eade594
commit
5c6fe20d3f
14 files changed
+501
-17
No files matched your search
@@ -279,6 +279,12 @@ ccs auth create backup --share-context
|
|||||||
ccs auth create backup2 --context-group sprint-a
|
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`:
|
Shared mode metadata in `~/.ccs/config.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -300,6 +306,8 @@ accounts:
|
|||||||
|
|
||||||
Shared context links project workspace data only. Credentials remain isolated per account.
|
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>
|
<br>
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Dashboard Authentication CLI
|
# Dashboard Authentication CLI
|
||||||
|
|
||||||
Last Updated: 2026-02-24
|
Last Updated: 2026-02-26
|
||||||
|
|
||||||
CLI commands for managing CCS dashboard authentication.
|
CLI commands for managing CCS dashboard authentication.
|
||||||
|
|
||||||
@@ -39,6 +39,12 @@ Account context is isolation-first:
|
|||||||
- normalizes valid shared `context_group` before save
|
- normalizes valid shared `context_group` before save
|
||||||
- rejects `context_group` when mode is not `shared`
|
- 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
|
## Commands
|
||||||
|
|
||||||
### `ccs config auth setup`
|
### `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(
|
console.log(
|
||||||
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
|
` 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(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`);
|
||||||
console.log(
|
console.log(
|
||||||
` ${color('context_group', 'path')} must be non-empty and <= ${MAX_CONTEXT_GROUP_LENGTH} chars in shared mode.`
|
` ${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 --help', 'Show account management commands'],
|
||||||
['ccs auth create <name>', 'Create account profile (supports context sharing flags)'],
|
['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 list', 'List all account profiles'],
|
||||||
['ccs auth default <name>', 'Set default profile'],
|
['ccs auth default <name>', 'Set default profile'],
|
||||||
['ccs auth reset-default', 'Restore original CCS default'],
|
['ccs auth reset-default', 'Restore original CCS default'],
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ import {
|
|||||||
soloAccount,
|
soloAccount,
|
||||||
} from '../../cliproxy/account-manager';
|
} from '../../cliproxy/account-manager';
|
||||||
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
||||||
import { resolveAccountContextPolicy } from '../../auth/account-context';
|
import {
|
||||||
|
isValidContextGroupName,
|
||||||
|
normalizeContextGroupName,
|
||||||
|
resolveAccountContextPolicy,
|
||||||
|
} from '../../auth/account-context';
|
||||||
import {
|
import {
|
||||||
buildCliproxyAccountKey,
|
buildCliproxyAccountKey,
|
||||||
parseCliproxyKey,
|
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
|
* 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' });
|
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', () => {
|
describe('web-server account-routes context normalization', () => {
|
||||||
let server: Server;
|
let server: Server;
|
||||||
let baseUrl = '';
|
let baseUrl = '';
|
||||||
@@ -27,6 +35,7 @@ describe('web-server account-routes context normalization', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
app.use('/api/accounts', accountRoutes);
|
app.use('/api/accounts', accountRoutes);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -173,4 +182,84 @@ describe('web-server account-routes context normalization', () => {
|
|||||||
InstanceManager.prototype.deleteInstance = originalDeleteInstance;
|
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,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} 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 {
|
import {
|
||||||
useSetDefaultAccount,
|
useSetDefaultAccount,
|
||||||
useDeleteAccount,
|
useDeleteAccount,
|
||||||
@@ -35,7 +36,6 @@ import type { Account } from '@/lib/api-client';
|
|||||||
interface AccountsTableProps {
|
interface AccountsTableProps {
|
||||||
data: Account[];
|
data: Account[];
|
||||||
defaultAccount: string | null;
|
defaultAccount: string | null;
|
||||||
onRefresh?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||||
@@ -43,6 +43,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
const deleteMutation = useDeleteAccount();
|
const deleteMutation = useDeleteAccount();
|
||||||
const resetDefaultMutation = useResetDefaultAccount();
|
const resetDefaultMutation = useResetDefaultAccount();
|
||||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||||
|
const [contextTarget, setContextTarget] = useState<Account | null>(null);
|
||||||
|
|
||||||
const columns: ColumnDef<Account>[] = [
|
const columns: ColumnDef<Account>[] = [
|
||||||
{
|
{
|
||||||
@@ -108,13 +109,26 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
{
|
{
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
size: 180,
|
size: 220,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const isDefault = row.original.name === defaultAccount;
|
const isDefault = row.original.name === defaultAccount;
|
||||||
const isPending = setDefaultMutation.isPending || deleteMutation.isPending;
|
const isPending = setDefaultMutation.isPending || deleteMutation.isPending;
|
||||||
|
const isCliproxy = row.original.type === 'cliproxy';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<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
|
<Button
|
||||||
variant={isDefault ? 'secondary' : 'default'}
|
variant={isDefault ? 'secondary' : 'default'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -173,7 +187,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
created: 'w-[150px]',
|
created: 'w-[150px]',
|
||||||
last_used: 'w-[150px]',
|
last_used: 'w-[150px]',
|
||||||
context: 'w-[170px]',
|
context: 'w-[170px]',
|
||||||
actions: 'w-[180px]',
|
actions: 'w-[220px]',
|
||||||
}[header.id] || 'w-auto';
|
}[header.id] || 'w-auto';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -217,6 +231,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{contextTarget && (
|
||||||
|
<EditAccountContextDialog account={contextTarget} onClose={() => setContextTarget(null)} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Delete confirmation dialog */}
|
{/* Delete confirmation dialog */}
|
||||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
|
|
||||||
// Validate profile name: alphanumeric, dash, underscore only
|
// Validate profile name: alphanumeric, dash, underscore only
|
||||||
const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName);
|
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 =
|
const isValidContextGroup =
|
||||||
normalizedGroup.length === 0 ||
|
normalizedGroup.length === 0 ||
|
||||||
(normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH &&
|
(normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH &&
|
||||||
@@ -73,7 +73,8 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Create New Account</DialogTitle>
|
<DialogTitle>Create New Account</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<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>
|
</p>
|
||||||
{contextGroup.trim().length > 0 && !isValidContextGroup && (
|
{contextGroup.trim().length > 0 && !isValidContextGroup && (
|
||||||
<p className="text-xs text-destructive">
|
<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 { AccountsTable } from './accounts-table';
|
||||||
export { AddAccountDialog } from './add-account-dialog';
|
export { AddAccountDialog } from './add-account-dialog';
|
||||||
export { CreateAuthProfileDialog } from './create-auth-profile-dialog';
|
export { CreateAuthProfileDialog } from './create-auth-profile-dialog';
|
||||||
|
export { EditAccountContextDialog } from './edit-account-context-dialog';
|
||||||
|
|
||||||
// Flow visualization (from subdirectory)
|
// Flow visualization (from subdirectory)
|
||||||
export { AccountFlowViz } from './flow-viz';
|
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
|
* 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;
|
context_group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateAccountContext {
|
||||||
|
context_mode: 'isolated' | 'shared';
|
||||||
|
context_group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Unified config types
|
// Unified config types
|
||||||
export interface ConfigFormat {
|
export interface ConfigFormat {
|
||||||
format: 'yaml' | 'json' | 'none';
|
format: 'yaml' | 'json' | 'none';
|
||||||
@@ -785,6 +790,11 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
resetDefault: () => request('/accounts/reset-default', { method: 'DELETE' }),
|
resetDefault: () => request('/accounts/reset-default', { method: 'DELETE' }),
|
||||||
delete: (name: string) => request(`/accounts/${name}`, { 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
|
// Unified config API
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { useAccounts } from '@/hooks/use-accounts';
|
import { useAccounts } from '@/hooks/use-accounts';
|
||||||
|
|
||||||
export function AccountsPage() {
|
export function AccountsPage() {
|
||||||
const { data, isLoading, refetch } = useAccounts();
|
const { data, isLoading } = useAccounts();
|
||||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,7 +20,7 @@ export function AccountsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Accounts</h1>
|
<h1 className="text-2xl font-bold">Accounts</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||||
@@ -32,11 +32,13 @@ export function AccountsPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-muted-foreground">Loading accounts...</div>
|
<div className="text-muted-foreground">Loading accounts...</div>
|
||||||
) : (
|
) : (
|
||||||
<AccountsTable
|
<div className="space-y-3">
|
||||||
data={data?.accounts || []}
|
<p className="text-xs text-muted-foreground">
|
||||||
defaultAccount={data?.default ?? null}
|
New profile login: <code className="rounded bg-muted px-1 py-0.5">Create Account</code>.
|
||||||
onRefresh={refetch}
|
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)} />
|
<CreateAuthProfileDialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} />
|
||||||
|
|||||||
Reference in new issue
Block a user