From 4bc77ded17a099fc21b3ba6979551fe181b907d8 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Mon, 7 Sep 2026 15:13:38 +0700 Subject: [PATCH] feat(cc-config): switch between Claude Code accounts, keeping the workspace Adds a "Switch profile" button left of Backups on /settings?tool=claude-code. A *profile* is one Anthropic account - one quota - saved under a name, so the local `claude` CLI can be pointed at a different account without disturbing anything else. A switch swaps exactly two identity artifacts and nothing more: `claudeAiOauth` inside `/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s). Those files are key-merged in place, and only where they already exist, so `projects`/`numStartups`/everything else survives. Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here - that is what makes a switch keep the whole workspace and change only the account. `mcpOAuth` stays put too: those are the machine's MCP server tokens, not account identity. Profiles are one JSON file each under `/agenthub-profiles/` (dir 0700, files 0600, atomic temp+rename) and hold live OAuth tokens, so no route ever returns token material - `GET /profiles` answers identity metadata only. The dashboard cannot log an account in; only `claude auth login` mints tokens. Adding a profile is therefore always "log in from a terminal, then snapshot what the CLI wrote". `activateProfile` re-snapshots the outgoing account first so tokens Claude Code refreshed since its last save are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved - that would discard the only copy of its credentials. A switch applies to new sessions only, since a running session rewrites the config on exit. Routes: GET/POST/DELETE /api/cc-config/profiles, POST /profiles/activate. Verified: server 1420/1420 (14 new, incl. token-free responses and 0600/0700 permissions), client 459/459. --- ARCHITECTURE.md | 5 +- client/src/i18n/locales/en/ccConfig.json | 23 ++ client/src/i18n/locales/vi/ccConfig.json | 23 ++ client/src/lib/api.ts | 56 ++++ client/src/pages/CcConfig.tsx | 249 ++++++++++++++++ .../screens.snapshot.test.tsx.snap | 55 ++++ docs/API.md | 10 + openapi.yaml | 261 +++++++++++++++++ server/README.md | 4 + server/__tests__/cc-profiles.test.js | 214 ++++++++++++++ server/lib/cc-profiles.js | 259 +++++++++++++++++ server/openapi-extra/cc-config.js | 275 +++++++++++++++++- server/routes/cc-config.js | 65 +++++ 13 files changed, 1496 insertions(+), 3 deletions(-) create mode 100644 server/__tests__/cc-profiles.test.js create mode 100644 server/lib/cc-profiles.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 371c8b68..c1b99ebb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -348,7 +348,8 @@ graph TD | `server/lib/scoped-stats.js` | Source-scoped variants of the stats/analytics aggregates, used **only** when a `?sources=` scope filter is active — the unscoped fast paths in `routes/stats.js` / `routes/analytics.js` are untouched when no scope is set | | `lib/cc-discovery.js` | Read-only discovery of every Claude Code config surface for the Config Explorer page. Pure file reads; never writes. Surfaces: skills (`/skills//SKILL.md`), subagents (`/agents/*.md`), slash commands (`/commands/*.md`), output styles (`/output-styles/*.md`), plugins (`/plugins/installed_plugins.json` joined with `enabledPlugins` in settings + per-plugin `contributes` counts **and** `contributes.items`, which names every skill / subagent / command / output style the plugin ships — each surface read from the conventional install subdirectory *and* from whatever path `plugin.json` declares for that key, with declared paths outside the plugin's own install dir dropped by `isUnder()`, so a plugin that lists its commands explicitly and keeps `commands/` empty no longer reports zero + `plugin.json` metadata), marketplaces (`known_marketplaces.json` enriched with each `marketplace.json`), MCP servers (top-level + per-project from `~/.claude.json`), hooks (across user / project / project-local settings.json), keybindings (`/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`/hooks/`), settings (with secret-key redaction matching `/token\|secret\|password\|api[_-]?key\|auth/i`), memory (`CLAUDE.md` at user + project **plus** the per-project file-based auto-memory store — every `*.md` under `~/.claude/projects//memory/`, returned as `scope:"auto-memory"` items carrying `project`, `name`, `isIndex`, and parsed `frontmatter`, so a `MEMORY.md` index and one file per remembered fact, often 100+, all surface). Path containment via `isUnder()` — every read must resolve under CLAUDE_HOME, project `.claude/`, or be a project CLAUDE.md. 256 KB read cap. Minimal YAML frontmatter parser handles `key: value` + quoted strings + indented continuation lines | | `lib/cc-mutate.js` | Create / overwrite / delete for the **low-risk text-file surfaces only** (skills, subagents, slash commands, output styles, memory — including the per-project file-based auto-memory store, mutated via `scope: "auto-memory"`, `type: "auto-memory"`, `project`, `name`, with its backups landing in `/.cc-config-backups/auto-memory/`), plus `writeKeybindings()` for the structured `keybindings.json` editor (read-modify-write that preserves top-level metadata, rejects duplicate contexts/keys, and backs up to `/cc-config-backups/keybindings/`). Plugins, MCP, hooks-in-settings, and `settings.json` files are NEVER written from here — they have concurrent-write races with the live Claude Code CLI. Every mutation creates a timestamped backup at `/cc-config-backups//..bak[.dir]` BEFORE the change — backups land outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. Writes are atomic: temp file in same dir → fsync → `renameSync`. Tmp removed on every failure path. Skill dirs are backed up whole (preserving bundled assets) before recursive removal. Strict `name` regex (`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`), 256 KB content cap, double-checked path containment via `isUnder()` | -| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses | +| `routes/cc-config.js` | HTTP surface for the Claude Config Explorer. Read endpoints for every surface (skills, agents, commands, output-styles, plugins, marketplaces, mcp, hooks, hook-scripts, keybindings, statusline, settings, memory, file, overview), plus mutation endpoints (`PUT /file`, `DELETE /file`, and a structured `PUT /keybindings`) that delegate to `cc-mutate.js`, plus a `GET /backups` listing for the recovery modal, plus the auth-profile routes (`GET/POST/DELETE /profiles`, `POST /profiles/activate`) that delegate to `cc-profiles.js`. After every successful PUT/DELETE the route broadcasts `cc_config_changed` over the WebSocket so any open `/cc-config` tab refetches without polling. All errors return structured `{error: {code, message}}` shapes mapped to 400/404/413/500 statuses | +| `lib/cc-profiles.js` | Named Claude Code **auth profiles** — swap which Anthropic account (and therefore which quota) the local `claude` CLI runs as. A profile is exactly two identity artifacts: `claudeAiOauth` out of `/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`.json` and `/.claude.json`, each key-merged in place and only when it already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never read or written here — that is what makes a switch keep the whole workspace and change only the account; `mcpOAuth` stays put too (machine MCP tokens, not account identity). Stored one JSON file per profile under `/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. Cannot mint credentials (only `claude auth login` can): a new profile is captured by logging in, then snapshotting. `activateProfile()` re-snapshots the outgoing account first so refreshed tokens are not lost, and refuses (`EUNSAVED`) to switch away from an account that was never saved — that would discard the only copy of its credentials. Atomic temp + rename at `0600` | | `lib/cc-watcher.js` | Best-effort `fs.watch` over `~/.claude/` (recursive where the platform / Node version honors it — macOS / Windows always; Linux from Node 20) plus `~/.claude.json`. Coalesces bursts at 500 ms and broadcasts `cc_config_changed` with `{ source: "fs", paths: [...] }` so the Config Explorer picks up changes from external tools (CLI installs a plugin, manual `settings.json` edits, dropping a new skill) without a manual refresh. Started from `server/index.js` after the HTTP server boots; failures are caught and logged so a flaky watcher can't take the server down | | `lib/tmux.js` | Wrapper around tmux CLI for session management. `createSession(sessionName, cwd)` creates a new tmux session in the specified working directory. `sendCommand(sessionName, command)` sends a command into the session. `killSession(sessionName)` terminates the session. `listSessions()` returns all active sessions. Session management is the foundation for the PTY transport layer | | `lib/pty-run.js` | PTY lifecycle for tmux-backed runs. Manages one tmux session per lane, named `agenthub-lane-`. Exports `startRun()` to create/attach a session and return a `runId` opaque handle; internally uses tmux to manage the pseudoterminal. Spawned `claude` processes run inside the session and fire the dashboard's hooks like any other CLI session, so they show up in `/api/sessions`, the analytics, the Kanban board, and the Workflows page automatically. The PTY frames (terminal input/output deltas) are streamed to the client over `/ws-pty/:runId` (see `server/websocket.js`) at binary frame granularity rather than as JSON envelopes; the client's xterm.js terminal widget renders these raw PTY updates live | @@ -613,7 +614,7 @@ graph LR | `/sessions/:id` | SessionDetail | `GET /api/sessions/:id` (agents + events), `GET /api/sessions/:id/stats` (overview tiles, top tools, subagent breakdown, token totals — debounced live-refresh on `new_event`/`agent_*`/`session_updated`), `GET /api/sessions/:id/transcripts` (Conversation tab transcript list), `GET /api/sessions/:id/transcript` (cursor-paginated message stream, including inline `session_event` rename markers; optional `run_id` resolves a workflow inner agent's nested transcript). Probes `/api/run` (and listens for `run_status`) to surface a green "Open in Run page" banner when this session is currently being driven by an in-flight Run handle. When the session sits in the Waiting overlay, renders a yellow waiting-for-input banner naming the `awaiting_reason` (Needs input / Turn done / At prompt / Interrupted) and how long it has waited; every Waiting `StatusBadge` across the app carries the same reason as a hover tooltip, with an inline nested chip on the wider surfaces (Sessions table, session-detail header) — compact card badges (Dashboard) stay tooltip-only | | `/activity` | ActivityFeed | `GET /api/events?limit=100` — click row to expand inline payload; "Session →" button navigates to `/sessions/:id` | | `/workflows` | Workflows | `GET /api/workflows?status=active\|completed`, `GET /api/workflows/session/:id` + WebSocket auto-refresh (3s debounce) | -| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title | +| `/cc-config` | CcConfig | 12-tab Claude Code configuration explorer. Reads via `GET /api/cc-config/{overview,skills,agents,commands,output-styles,plugins,marketplaces,mcp,hooks,hook-scripts,keybindings,statusline,settings,memory}`. Mutations for skills/agents/commands/output-styles/memory — including the per-project file-based auto-memory store (`*.md` under `~/.claude/projects//memory/`, grouped by project and searchable in the Memory tab, with clickable `MEMORY.md` index links that scroll to + highlight the matching fact file) — via `PUT /api/cc-config/file` + `DELETE /api/cc-config/file` (timestamped backups, atomic writes). The Keybindings tab additionally offers a structured inline editor that persists via `PUT /api/cc-config/keybindings` (same backup-first, atomic-write guarantees). `GET /api/cc-config/file?path=…` for single-file viewer. `GET /api/cc-config/backups` for the recovery modal. A **Switch profile** button left of Backups opens the auth-profile modal (`/api/cc-config/profiles*`): save the logged-in Anthropic account under a name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. It cannot log in for you (`claude auth login` does that) and a switch applies to new sessions only. Subscribes to `cc_config_changed` WS messages for live refresh on both dashboard mutations and external file edits picked up by `cc-watcher`. The Settings tab leads with a client-side **Current configuration** summary that resolves the `/config` options (model, verbose, theme, output style, effort, auto-compact, notifications, …) across user / project / project-local scopes, showing defaults when unset. Live / Offline indicator next to the title | | `/run` | Workspace | Merged workspace page combining lanes and runs. Attaches to a tmux-backed pseudoterminal tied to a lane: the UI selects a lane, calls `POST /api/run` with that lane's `id`, and receives a `runId` + tmux session name. The Workspace displays a horizontal lane strip at the top, the selected lane's pipeline map, and a real interactive terminal (xterm.js) fed by `/ws-pty/:runId` binary frames below. `TerminalView` re-attaches 1.5 s after any unexpected socket close (server restart included) and stops only on the server's `exit` frame — a dead socket used to leave the pane frozen on its last frame, looking live while swallowing every keystroke and mouse report. It also calls `preventDefault()` on Shift+Tab (keeping xterm's own handling): xterm emits `ESC[Z` for that combo but, unlike plain Tab, never marks the event cancelled, so the browser's default moved focus out of the terminal and the pane saw only the first press — `shift+tab to cycle` (claude's permission mode) looked dead in the browser while working in a real terminal. It also installs a custom wheel handler that returns `false`: without it, xterm falls back on an alt-screen buffer to translating each wheel notch into a cursor-key press (`ESC[A`/`ESC[B`), which a `claude` pane reads as arrow up/down and uses to walk the prompt history instead of scrolling. That handler suppresses only the emulation branch — real mouse reports are emitted by a separate listener xterm registers when the pane's program enables mouse tracking. Its xterm viewport scrollbar is hidden by design (`.xterm-viewport` rule in `client/src/index.css`): tmux repaints the whole pane, so xterm's scrollback is always empty and scrolling happens via mouse reports forwarded to the pane's program, not the DOM. Pre-flight: `GET /api/run/{tmux,binary,cwds,files}` for tmux availability + `claude` binary check + `@`-file autocomplete. Start/resume: `POST /api/run` (requires `laneId`; optionally accepts `prompt` to send immediately); `GET /api/run/:id` (returns handle); `DELETE /api/run/:id` (stops). History: `GET /api/run/history?laneId=` lists only that lane's runs. PTY streaming: `/ws-pty/:runId` delivers raw PTY frames as binary WebSocket frames — no JSON envelope overhead, direct to xterm.js for live rendering; the same tmux session can have multiple simultaneous clients (browser Workspace, `agenthub lanes shell` CLI, other tools), all synced live. Lane self-heal: `GET /api/lanes/:id` auto-corrects `run_id`/`status` if the tmux session has been killed externally. Tier 1 TUI parity: tmux session is a real shell, not headless — supports editors, pagers, interactive subcommands. **The console never writes a lane's stage** — stage moves only through `agenthub stage` commands. Live / Offline indicator next to the title | | `/settings` | Settings | `GET /api/settings/info`, `GET /api/pricing`, `GET /api/pricing/cost` + `localStorage` for notification prefs. Hosts the **Remote Data Sources** panel (`components/RemoteSources.tsx`) — CRUD + test + sync over `/api/remote-sources`, live status from `remote_source.status` WS messages | | `/*` | NotFound | None (static 404 page) | diff --git a/client/src/i18n/locales/en/ccConfig.json b/client/src/i18n/locales/en/ccConfig.json index 0a99d748..212928e5 100644 --- a/client/src/i18n/locales/en/ccConfig.json +++ b/client/src/i18n/locales/en/ccConfig.json @@ -215,6 +215,29 @@ "scopeLabel": "Scope", "filterAll": "All types" }, + "profiles": { + "title": "Claude Code profiles", + "openButton": "Switch profile", + "subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.", + "warning": "Close your running Claude Code sessions before switching - a live session rewrites the config on exit and would put the old account back.", + "activeLabel": "Logged in now", + "noActive": "No Claude account is logged in on this machine.", + "unsavedActive": "This account is not saved as a profile yet - save it below, otherwise you cannot switch away without losing its credentials.", + "saveLabel": "Save the current account as a profile", + "namePlaceholder": "profile name (e.g. work)", + "saveButton": "Save", + "saveHint": "The dashboard cannot log in for you. To add another account: run `claude auth login` in a terminal, sign in, then come back and save it under a new name.", + "saveSuccess": "Saved the current account as profile \"{{name}}\".", + "empty": "No profiles saved yet - save the account you are logged in as to get started.", + "activeBadge": "active", + "switchButton": "Switch to", + "switching": "Switching…", + "switchSuccess": "Switched to profile \"{{name}}\". Start a new Claude Code session to pick it up.", + "deleteButton": "Delete profile", + "deleteSuccess": "Deleted profile \"{{name}}\".", + "savedAt": "Saved {{time}}", + "loginHint": "Add another account by logging in from a terminal first:" + }, "explain": { "hooks": { "title": "Editing hooks writes settings.json", diff --git a/client/src/i18n/locales/vi/ccConfig.json b/client/src/i18n/locales/vi/ccConfig.json index 8b722cd1..f7ca9d71 100644 --- a/client/src/i18n/locales/vi/ccConfig.json +++ b/client/src/i18n/locales/vi/ccConfig.json @@ -210,6 +210,29 @@ "scopeLabel": "Phạm vi", "filterAll": "Tất cả loại" }, + "profiles": { + "title": "Profile Claude Code", + "openButton": "Đổi profile", + "subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.", + "warning": "Hãy đóng các phiên Claude Code đang chạy trước khi đổi - phiên đang chạy sẽ ghi lại config khi thoát và đưa tài khoản cũ quay về.", + "activeLabel": "Đang đăng nhập", + "noActive": "Chưa có tài khoản Claude nào đăng nhập trên máy này.", + "unsavedActive": "Tài khoản này chưa được lưu thành profile - hãy lưu bên dưới, nếu không sẽ không thể đổi sang profile khác mà không mất credentials của nó.", + "saveLabel": "Lưu tài khoản hiện tại thành profile", + "namePlaceholder": "tên profile (vd: work)", + "saveButton": "Lưu", + "saveHint": "Dashboard không thể đăng nhập thay bạn. Để thêm tài khoản khác: chạy `claude auth login` trong terminal, đăng nhập, rồi quay lại đây lưu với tên mới.", + "saveSuccess": "Đã lưu tài khoản hiện tại thành profile \"{{name}}\".", + "empty": "Chưa có profile nào - hãy lưu tài khoản đang đăng nhập để bắt đầu.", + "activeBadge": "đang dùng", + "switchButton": "Chuyển sang", + "switching": "Đang chuyển…", + "switchSuccess": "Đã chuyển sang profile \"{{name}}\". Mở phiên Claude Code mới để áp dụng.", + "deleteButton": "Xoá profile", + "deleteSuccess": "Đã xoá profile \"{{name}}\".", + "savedAt": "Lưu lúc {{time}}", + "loginHint": "Thêm tài khoản khác bằng cách đăng nhập từ terminal trước:" + }, "explain": { "hooks": { "title": "Sửa hooks tức là ghi vào settings.json", diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index cf1b1cb7..deacdeb3 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -1509,6 +1509,38 @@ export const api = { */ backups: (params?: { scope?: "user" | "project"; type?: CcArtifactType }) => requestBackupsHelper(params), + /** + * GET /api/cc-config/profiles - saved auth profiles + the live account. + * @returns `{ items, active }` — {@link CcProfile}s and the currently + * logged-in {@link CcAccountIdentity} (null when nobody is logged in). + */ + profiles: () => + request<{ items: CcProfile[]; active: CcAccountIdentity | null }>("/cc-config/profiles"), + /** + * POST /api/cc-config/profiles - snapshot the logged-in account under + * `name`, overwriting a profile of that name. + */ + saveProfile: (name: string) => + request<{ ok: true; name: string } & CcAccountIdentity>("/cc-config/profiles", { + method: "POST", + body: JSON.stringify({ name }), + }), + /** + * POST /api/cc-config/profiles/activate - make a saved profile the account + * the local `claude` CLI runs as. The outgoing account is re-snapshotted + * into its own profile first (`savedOutgoing`). + */ + activateProfile: (name: string) => + request<{ ok: true; name: string; savedOutgoing: string | null; written: string[] }>( + "/cc-config/profiles/activate", + { method: "POST", body: JSON.stringify({ name }) } + ), + /** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */ + deleteProfile: (name: string) => + request<{ ok: true; name: string }>("/cc-config/profiles", { + method: "DELETE", + body: JSON.stringify({ name }), + }), /** * GET /api/cc-config/plugins/available - every plugin offered by a * registered marketplace, from `claude plugin list --available --json`. @@ -2261,6 +2293,30 @@ export interface CcBackup { project?: string; // present for scope === "auto-memory" } +/** Identity of one Anthropic account, as reported by the auth-profile + * endpoints. Deliberately token-free — the server never sends credentials to + * the browser, only enough to tell the accounts apart. */ +export interface CcAccountIdentity { + accountUuid: string | null; + email: string | null; + displayName: string | null; + organization: string | null; + seatTier: string | null; + subscriptionType: string | null; + /** Access-token expiry, epoch ms; a switch still works past it (the refresh + * token is stored too). */ + expiresAt: number | null; +} + +/** One saved auth profile from GET /api/cc-config/profiles. */ +export interface CcProfile extends CcAccountIdentity { + name: string; + /** When the profile was last snapshotted, epoch ms. */ + savedAt: number | null; + /** True when this profile's account is the one logged in right now. */ + active: boolean; +} + /** Scope filter accepted by most `ccConfig` list endpoints; "all" merges * user + project scope in one response. */ export type CcScope = "user" | "project" | "all"; diff --git a/client/src/pages/CcConfig.tsx b/client/src/pages/CcConfig.tsx index 9791960e..bd032ad6 100644 --- a/client/src/pages/CcConfig.tsx +++ b/client/src/pages/CcConfig.tsx @@ -94,6 +94,7 @@ import { ChevronLeft, ChevronRight, ChevronDown, + UserCog, } from "lucide-react"; import { api } from "../lib/api"; import type { @@ -116,6 +117,8 @@ import type { CcPlugin, CcPluginItem, CcPluginsResponse, + CcProfile, + CcAccountIdentity, CcScope, CcSettingsSource, CcStatusline, @@ -254,6 +257,7 @@ export function CcConfig() { const [confirmDelete, setConfirmDelete] = useState(null); const [toast, setToast] = useState(null); const [backupsOpen, setBackupsOpen] = useState(false); + const [profilesOpen, setProfilesOpen] = useState(false); // Auto-dismiss toasts after 5s useEffect(() => { @@ -492,6 +496,7 @@ export function CcConfig() { onScopeChange={setScope} onRefresh={fetchAll} onOpenBackups={() => setBackupsOpen(true)} + onOpenProfiles={() => setProfilesOpen(true)} wsConnected={wsConnected} /> @@ -565,6 +570,7 @@ export function CcConfig() { )} {toast && setToast(null)} />} {backupsOpen && setBackupsOpen(false)} />} + {profilesOpen && setProfilesOpen(false)} onToast={setToast} />} ); } @@ -578,6 +584,7 @@ interface HeaderProps { onScopeChange: (s: CcScope) => void; onRefresh: () => void; onOpenBackups: () => void; + onOpenProfiles: () => void; wsConnected: boolean; } @@ -588,6 +595,7 @@ function Header({ onScopeChange, onRefresh, onOpenBackups, + onOpenProfiles, wsConnected, }: HeaderProps) { const { t } = useTranslation("ccConfig"); @@ -626,6 +634,13 @@ function Header({
+ +
+ +
+

{t("profiles.subtitle")}

+

+ + {t("profiles.warning")} +

+
+ +
+ {error && ( +
+ + {error} +
+ )} + +
+
+ {t("profiles.activeLabel")} +
+ {active ? ( + <> +
+ {active.email || active.displayName || active.accountUuid} +
+ + + ) : ( +
{t("profiles.noActive")}
+ )} + {active && !activeSaved && ( +
+ {t("profiles.unsavedActive")} +
+ )} +
+ +
+
+ {t("profiles.saveLabel")} +
+
+ setNewName(e.target.value)} + placeholder={t("profiles.namePlaceholder")} + className="h-7 flex-1 min-w-0 rounded-md border border-border bg-surface-1 px-2 text-xs text-fg-primary placeholder:text-fg-muted focus:outline-none focus:ring-1 focus:ring-accent/40" + /> + +
+

+ {t("profiles.saveHint")} +

+
+ + {items === null && !error && } + {items !== null && items.length === 0 && ( +
+ {t("profiles.empty")} +
+ )} + {items?.map((p) => ( +
+
+ {p.name} + {p.active && ( + + {t("profiles.activeBadge")} + + )} + + {p.email || p.displayName || p.accountUuid} + + {!p.active && ( + + )} + {!p.active && ( + + )} +
+ + {p.savedAt && ( +
+ {t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })} +
+ )} +
+ ))} + +
+
{t("profiles.loginHint")}
+ +
+
+
+ + ); +} + +/** Org / seat / plan line shared by the active card and each profile row. */ +function IdentityLine({ identity }: { identity: CcAccountIdentity }) { + const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter( + Boolean + ); + if (parts.length === 0) return null; + return
{parts.join(" · ")}
; +} + // ── Backups modal ────────────────────────────────────────────────────── function BackupsModal({ onClose }: { onClose: () => void }) { diff --git a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap index d32b60f7..fc9340e4 100644 --- a/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap +++ b/client/src/pages/__tests__/__snapshots__/screens.snapshot.test.tsx.snap @@ -795,6 +795,61 @@ exports[`screen snapshots > Claude Config 1`] = ` Project +