fix(cc-config): one account is one profile, and rename it in place
Four defects, all from one design mistake: a profile's display name and its filename were the same string. - Saving the logged-in account made a SECOND profile of an account another profile already held. Both then reported themselves active, so there was nothing to switch between. `POST /profiles` now refuses `EDUPLICATE` and names the profile that already holds the account. Re-saving into the account's own profile stays allowed - that is the token-refresh path. - Worse, an accidental duplicate could not be cleaned up: two profiles of one account are both active, and the delete guard refused any active profile, so neither was ever deletable. The guard now blocks only the LAST saved copy of the live account, which is the actual credential-loss condition it exists for. The trash button had to follow, or the server would allow what the UI hid. - A name could not contain spaces or diacritics, because it had to be filename-safe. A profile now carries `name` (internal identifier, still the filename, still what every route takes) and `label` (what the user reads and edits: 1-60 free-form characters, control characters rejected, stored trimmed, unique case-insensitively). A label never touches a path, which is what makes "Tài khoản chính" a legal name. - Naming happened up front in a "save current account as" field. That field is gone. The server derives the identifier, the save button lives on the live account's card and appears only while it is unsaved, and the label is edited by clicking it (Enter saves, Escape cancels). Renaming rewrites the label in place and deliberately does NOT move `name`, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report `label` equal to their `name`, so nothing needs migrating. Adds `PATCH /api/cc-config/profiles`; `POST` now takes an optional `name`. Verified: server 1431/1431 (11 new), client 459/459, tsc clean. Also exercised end to end against a COPY of the real profile store on this machine: the duplicate deleted, the last copy still refused, a Vietnamese label accepted, and the stored access/refresh tokens and 0600 mode intact after a rename.
This commit is contained in:
+3
-3
@@ -348,8 +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 (`<root>/skills/<name>/SKILL.md`), subagents (`<root>/agents/*.md`), slash commands (`<root>/commands/*.md`), output styles (`<root>/output-styles/*.md`), plugins (`<CLAUDE_HOME>/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 (`<CLAUDE_HOME>/keybindings.json`), statusline config + `statusline.py` / `statusline-command.sh` content, hook scripts dir (`<CLAUDE_HOME>/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/<slug>/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 `<memory-dir>/.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 `<CLAUDE_HOME>/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 `<root>/cc-config-backups/<type>/<base>.<ISO>.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, 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 `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.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 `<CLAUDE_HOME>/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` |
|
||||
| `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/PATCH/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 `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` out of the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.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 `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`), holding live OAuth tokens, so **no route ever returns token material** — `listProfiles()` answers identity metadata only. A profile carries two names: `name`, the filename and the id every route takes, and `label`, the free-form display name (spaces and diacritics fine, since it never touches a path) that `renameProfile()` edits in place without moving the file. One account is one profile — `saveProfile()` refuses `EDUPLICATE` rather than making a second copy, because duplicates both report themselves active, and `deleteProfile()` blocks only the LAST copy of the live account so a duplicate stays cleanable. 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-<lane.id>`. 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 |
|
||||
@@ -614,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/<slug>/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 |
|
||||
| `/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/<slug>/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, rename it by clicking its name, then switch between accounts — only the login is swapped, so skills / subagents / memory / plugins / marketplaces / settings stay put. There is no name field (the server derives the identifier) and the save button shows only while the live account is unsaved, since one account is one profile. 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=<n>` 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) |
|
||||
|
||||
@@ -222,12 +222,9 @@
|
||||
"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}}\".",
|
||||
"unsavedActive": "This account is not saved as a profile yet - save it, otherwise you cannot switch away without losing its credentials.",
|
||||
"saveButton": "Save this account as a profile",
|
||||
"saveSuccess": "Saved the account you are logged in as. Click its name to rename it.",
|
||||
"empty": "No profiles saved yet - save the account you are logged in as to get started.",
|
||||
"activeBadge": "active",
|
||||
"switchButton": "Switch to",
|
||||
@@ -236,7 +233,11 @@
|
||||
"deleteButton": "Delete profile",
|
||||
"deleteSuccess": "Deleted profile \"{{name}}\".",
|
||||
"savedAt": "Saved {{time}}",
|
||||
"loginHint": "Add another account by logging in from a terminal first:"
|
||||
"loginHint": "Add another account by logging in from a terminal first:",
|
||||
"saving": "Saving…",
|
||||
"renameLabel": "Profile name",
|
||||
"renameHint": "Click to rename",
|
||||
"renameSuccess": "Renamed to \"{{name}}\"."
|
||||
},
|
||||
"explain": {
|
||||
"hooks": {
|
||||
|
||||
@@ -217,12 +217,9 @@
|
||||
"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}}\".",
|
||||
"unsavedActive": "Tài khoản này chưa được lưu thành profile - hãy lưu lại, nếu không sẽ không thể đổi sang profile khác mà không mất credentials của nó.",
|
||||
"saveButton": "Lưu tài khoản này thành profile",
|
||||
"saveSuccess": "Đã lưu tài khoản đang đăng nhập. Bấm vào tên của nó để đổi tên.",
|
||||
"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",
|
||||
@@ -231,7 +228,11 @@
|
||||
"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:"
|
||||
"loginHint": "Thêm tài khoản khác bằng cách đăng nhập từ terminal trước:",
|
||||
"saving": "Đang lưu…",
|
||||
"renameLabel": "Tên profile",
|
||||
"renameHint": "Bấm để đổi tên",
|
||||
"renameSuccess": "Đã đổi tên thành \"{{name}}\"."
|
||||
},
|
||||
"explain": {
|
||||
"hooks": {
|
||||
|
||||
+19
-6
@@ -1517,13 +1517,23 @@ export const api = {
|
||||
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.
|
||||
* POST /api/cc-config/profiles - snapshot the logged-in account as a
|
||||
* profile. The identifier and initial label are derived from the account;
|
||||
* saving an account another profile already holds fails `EDUPLICATE`.
|
||||
*/
|
||||
saveProfile: (name: string) =>
|
||||
request<{ ok: true; name: string } & CcAccountIdentity>("/cc-config/profiles", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name }),
|
||||
saveProfile: () =>
|
||||
request<{ ok: true; name: string; label: string } & CcAccountIdentity>(
|
||||
"/cc-config/profiles",
|
||||
{ method: "POST", body: JSON.stringify({}) }
|
||||
),
|
||||
/**
|
||||
* PATCH /api/cc-config/profiles - change a profile's display label. The
|
||||
* `name` identifier does not move; spaces and diacritics are allowed.
|
||||
*/
|
||||
renameProfile: (name: string, label: string) =>
|
||||
request<{ ok: true; name: string; label: string }>("/cc-config/profiles", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name, label }),
|
||||
}),
|
||||
/**
|
||||
* POST /api/cc-config/profiles/activate - make a saved profile the account
|
||||
@@ -2310,7 +2320,10 @@ export interface CcAccountIdentity {
|
||||
|
||||
/** One saved auth profile from GET /api/cc-config/profiles. */
|
||||
export interface CcProfile extends CcAccountIdentity {
|
||||
/** Internal identifier (also its filename) — what the mutation routes take. */
|
||||
name: string;
|
||||
/** Display name the user edits; falls back to `name` for older profiles. */
|
||||
label: 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. */
|
||||
|
||||
@@ -4521,7 +4521,12 @@ function CommandSnippet({ command, label }: { command: string; label?: string })
|
||||
//
|
||||
// The dashboard cannot log a new account in (only `claude auth login` can mint
|
||||
// tokens), so adding a profile is always "log in, then snapshot what the CLI
|
||||
// wrote". Hence the save-current field rather than an add-account flow.
|
||||
// wrote" — hence a save button on the live account rather than an add-account
|
||||
// flow. There is no name field: the server derives the identifier, and the user
|
||||
// edits the display label in place by clicking it. That keeps the one string
|
||||
// the user types free of filename rules, so spaces and diacritics are fine.
|
||||
// The save button only appears while the live account is unsaved, because one
|
||||
// account is one profile — a second copy would just report itself active too.
|
||||
|
||||
function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t: Toast) => void }) {
|
||||
const { t } = useTranslation("ccConfig");
|
||||
@@ -4529,7 +4534,9 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
/** `name` of the profile whose label is being edited inline, plus its draft. */
|
||||
const [renaming, setRenaming] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
@@ -4576,6 +4583,10 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
|
||||
const activeSaved = items?.some((p) => p.active) ?? false;
|
||||
|
||||
/** How many saved profiles hold the same account as `p`. */
|
||||
const copies = (p: CcProfile) =>
|
||||
items?.filter((q) => q.accountUuid && q.accountUuid === p.accountUuid).length ?? 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
|
||||
@@ -4628,41 +4639,20 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
<div className="text-sm text-fg-muted">{t("profiles.noActive")}</div>
|
||||
)}
|
||||
{active && !activeSaved && (
|
||||
<div className="text-[11px] text-status-warning pt-1">
|
||||
{t("profiles.unsavedActive")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
|
||||
<div className="text-[10px] uppercase tracking-wide text-fg-muted mb-1.5">
|
||||
{t("profiles.saveLabel")}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="pt-1.5 space-y-1.5">
|
||||
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
|
||||
<button
|
||||
disabled={!newName.trim() || busy !== null}
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run(
|
||||
"save",
|
||||
() => api.ccConfig.saveProfile(newName.trim()),
|
||||
t("profiles.saveSuccess", { name: newName.trim() })
|
||||
).then(() => setNewName(""))
|
||||
void run("save", () => api.ccConfig.saveProfile(), t("profiles.saveSuccess"))
|
||||
}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||
>
|
||||
<Save className="w-3 h-3" />
|
||||
{t("profiles.saveButton")}
|
||||
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-fg-muted mt-1.5 leading-relaxed">
|
||||
{t("profiles.saveHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items === null && !error && <SkeletonRows n={3} />}
|
||||
@@ -4674,7 +4664,41 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
{items?.map((p) => (
|
||||
<div key={p.name} className="rounded-lg border border-border bg-surface-2 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-mono text-xs text-fg-primary">{p.name}</span>
|
||||
{renaming === p.name ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => setRenaming(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setRenaming(null);
|
||||
if (e.key !== "Enter") return;
|
||||
const next = draft.trim();
|
||||
setRenaming(null);
|
||||
if (!next || next === p.label) return;
|
||||
void run(
|
||||
`rename:${p.name}`,
|
||||
() => api.ccConfig.renameProfile(p.name, next),
|
||||
t("profiles.renameSuccess", { name: next })
|
||||
);
|
||||
}}
|
||||
maxLength={60}
|
||||
aria-label={t("profiles.renameLabel")}
|
||||
className="h-7 w-48 rounded-md border border-accent/40 bg-surface-1 px-2 text-xs text-fg-primary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDraft(p.label);
|
||||
setRenaming(p.name);
|
||||
}}
|
||||
title={t("profiles.renameHint")}
|
||||
className="text-xs font-medium text-fg-primary hover:text-accent inline-flex items-center gap-1 group"
|
||||
>
|
||||
{p.label}
|
||||
<Pencil className="w-3 h-3 opacity-0 group-hover:opacity-100 text-fg-muted" />
|
||||
</button>
|
||||
)}
|
||||
{p.active && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-status-success/10 border border-status-success/20 text-status-success">
|
||||
{t("profiles.activeBadge")}
|
||||
@@ -4690,7 +4714,7 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
void run(
|
||||
`switch:${p.name}`,
|
||||
() => api.ccConfig.activateProfile(p.name),
|
||||
t("profiles.switchSuccess", { name: p.name })
|
||||
t("profiles.switchSuccess", { name: p.label })
|
||||
)
|
||||
}
|
||||
className="h-7 text-[11px] font-medium px-2.5 rounded-md border border-accent/30 bg-accent/10 hover:bg-accent/20 text-accent disabled:opacity-50"
|
||||
@@ -4700,14 +4724,17 @@ function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t:
|
||||
: t("profiles.switchButton")}
|
||||
</button>
|
||||
)}
|
||||
{!p.active && (
|
||||
{/* Deletable unless it is the ONLY saved copy of the live
|
||||
account — a redundant copy has to be removable, or an
|
||||
accidental duplicate could never be cleaned up. */}
|
||||
{(!p.active || copies(p) > 1) && (
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
void run(
|
||||
`del:${p.name}`,
|
||||
() => api.ccConfig.deleteProfile(p.name),
|
||||
t("profiles.deleteSuccess", { name: p.name })
|
||||
t("profiles.deleteSuccess", { name: p.label })
|
||||
)
|
||||
}
|
||||
title={t("profiles.deleteButton")}
|
||||
|
||||
+8
-3
@@ -1581,7 +1581,8 @@ GET /api/cc-config/memory
|
||||
GET /api/cc-config/file?path=<absolute-path>
|
||||
GET /api/cc-config/backups[?scope=&type=]
|
||||
GET /api/cc-config/profiles
|
||||
POST /api/cc-config/profiles Body: { name }
|
||||
POST /api/cc-config/profiles Body: { name? }
|
||||
PATCH /api/cc-config/profiles Body: { name, label }
|
||||
POST /api/cc-config/profiles/activate Body: { name }
|
||||
DELETE /api/cc-config/profiles Body: { name }
|
||||
PUT /api/cc-config/file Body: { scope, type, name?, content }
|
||||
@@ -1608,9 +1609,13 @@ POST /api/cc-config/marketplaces/action Body: { name, action: update|remove }
|
||||
|
||||
**Auth profiles (`/profiles*`).** A *profile* is one Anthropic account — one quota — under a name. Switching swaps exactly two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (`<CLAUDE_HOME>.json` and `<CLAUDE_HOME>/.claude.json`, each rewritten only if it already exists, key-merged 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` also stays put: those are the machine's MCP server tokens, not account identity.
|
||||
|
||||
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`). `name` must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$` (it is the filename).
|
||||
Profiles live one JSON file per profile under `<CLAUDE_HOME>/agenthub-profiles/` (dir `0700`, files `0600`) and hold live OAuth tokens, so **no route ever returns token material** — `GET /profiles` answers `{ items, active }` with identity metadata only (`name`, `label`, `savedAt`, `active`, `accountUuid`, `email`, `displayName`, `organization`, `seatTier`, `subscriptionType`, `expiresAt`).
|
||||
|
||||
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 `POST /profiles` to snapshot what the CLI wrote". `POST /profiles/activate` re-snapshots the outgoing account into its own profile first (reported as `savedOutgoing`), so tokens Claude Code refreshed since the last save are not lost, and returns `{ ok, name, savedOutgoing, written }`. Errors: `EBADNAME` (400), `ENOTFOUND` (404 — no such profile), `EBADJSON` (409 — a config file on disk is unparseable; nothing is written), `ENOAUTH` (409 — nobody is logged in to snapshot), `EUNSAVED` (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), `EACTIVE` (409 — refusing to delete the active profile). Mutations broadcast `cc_config_changed` with `type: "profile"`. A switch only takes effect for **new** Claude Code sessions, and a session still running will rewrite the config on exit — close them first.
|
||||
A profile has two names, and the split is what lets a profile be called `Tài khoản chính`. `name` is the internal identifier every mutation route takes, and it is the filename, so it must match `^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$`; callers normally never choose it, because `POST /profiles` derives it from the account. `label` is the display name the user edits with `PATCH /profiles`: free-form 1-60 characters, spaces and diacritics allowed since it is never used as a path, stored trimmed, rejected for control characters (`EBADLABEL`), and unique across profiles case-insensitively (`ELABELTAKEN`). A rename rewrites the label in place and **does not move `name`**, so it cannot half-fail and leave a profile unreachable. Profiles written before labels existed report `label` equal to their `name`.
|
||||
|
||||
One account is one profile: `POST /profiles` refuses with `EDUPLICATE` when another profile already holds the logged-in account, naming the one that does. Two profiles of a single account would both report `active: true`, leaving nothing to switch between — and, under a blanket active-guard on delete, neither would ever be removable. Re-posting an account into its OWN profile is the token-refresh path, stays allowed, and preserves that profile's label.
|
||||
|
||||
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 `POST /profiles` to snapshot what the CLI wrote". `POST /profiles/activate` re-snapshots the outgoing account into its own profile first (reported as `savedOutgoing`), so tokens Claude Code refreshed since the last save are not lost, and returns `{ ok, name, savedOutgoing, written }`. Errors: `EBADNAME` / `EBADLABEL` (400), `ENOTFOUND` (404 — no such profile), `EBADJSON` (409 — a config file on disk is unparseable; nothing is written), `ENOAUTH` (409 — nobody is logged in to snapshot), `EDUPLICATE` (409 — another profile already holds this account), `ELABELTAKEN` (409 — another profile already uses that label), `EUNSAVED` (409 — the live account is not saved as a profile, and switching away would discard the only copy of its credentials), `EACTIVE` (409 — refusing to delete the **last** saved copy of the live account; a redundant copy is deletable, which is what makes an accidental duplicate cleanable). Mutations broadcast `cc_config_changed` with `type: "profile"`. A switch only takes effect for **new** Claude Code sessions, and a session still running will rewrite the config on exit — close them first.
|
||||
|
||||
Backup paths look like `<root>/cc-config-backups/<type>/<base>.<ISO>.bak[.dir]` — outside the directories Claude Code scans, so a deleted skill cannot resurface as a backup-named one. The Backups modal in the UI auto-builds `mv` restore commands.
|
||||
|
||||
|
||||
+85
-10
@@ -2942,13 +2942,18 @@ components:
|
||||
- type: object
|
||||
required:
|
||||
- name
|
||||
- label
|
||||
- active
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Profile name; also its filename under `<CLAUDE_HOME>/agenthub-profiles/`.
|
||||
description: Internal identifier; also its filename under `<CLAUDE_HOME>/agenthub-profiles/`. Every mutation route takes this, and a rename never changes it.
|
||||
pattern: ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$
|
||||
example: work
|
||||
label:
|
||||
type: string
|
||||
description: Display name the user edits. Free-form (spaces and diacritics allowed) because it is never used as a path; falls back to `name` for profiles saved before labels existed.
|
||||
example: Tài khoản chính
|
||||
savedAt:
|
||||
type: integer
|
||||
nullable: true
|
||||
@@ -8211,21 +8216,24 @@ paths:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Save the logged-in account as a profile
|
||||
description: 'Snapshots the account currently logged in (its `claudeAiOauth` blob plus `oauthAccount`/`userID`) into a profile, overwriting a profile of the same name. The dashboard CANNOT log an account in - only `claude auth login` mints tokens - so adding another account is always: log in from a terminal, then call this to capture what the CLI wrote.'
|
||||
description: |-
|
||||
Snapshots the account currently logged in (its `claudeAiOauth` blob plus `oauthAccount`/`userID`) into a profile. The dashboard CANNOT log an account in - only `claude auth login` mints tokens - so adding another account is always: log in from a terminal, then call this to capture what the CLI wrote.
|
||||
|
||||
One account is one profile. Saving an account that another profile already holds is refused with `EDUPLICATE` rather than making a second copy: duplicates both report themselves `active`, leaving nothing to switch between. Re-posting the same account into its OWN profile is the token-refresh path and is allowed - and it preserves that profile's `label`.
|
||||
|
||||
`name` is optional and normally omitted: the server derives a filename-safe identifier plus a readable initial `label` from the account, and the user renames the label afterwards via PATCH. That is what keeps the only string a user types free of filename rules.
|
||||
operationId: ccConfigSaveProfile
|
||||
requestBody:
|
||||
required: true
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
pattern: ^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$
|
||||
description: Profile name; also its filename.
|
||||
description: Optional explicit identifier/filename. Omit it to have one derived from the account.
|
||||
example: work
|
||||
responses:
|
||||
'200':
|
||||
@@ -8241,14 +8249,81 @@ paths:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
'400':
|
||||
description: '`EBADNAME` - the name is not filename-safe.'
|
||||
description: '`EBADNAME` - an explicit name was given and is not filename-safe.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: '`ENOAUTH` - no Claude account is logged in to snapshot; `EBADJSON` - a config file is unparseable.'
|
||||
description: '`EDUPLICATE` - another profile already holds this account (its name is in the message); `ENOAUTH` - no Claude account is logged in to snapshot; `EBADJSON` - a config file is unparseable.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
patch:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Rename a saved auth profile
|
||||
description: |-
|
||||
Changes a profile's display `label` only. The `name` identifier deliberately does not move: a rename is a cosmetic edit, not a re-keying, so it cannot fail halfway and leave a profile unreachable - and every stored credential is rewritten untouched.
|
||||
|
||||
A label is free-form: 1-60 characters, spaces and diacritics allowed, because it is never used as a path. Control characters are rejected (`EBADLABEL`), it is stored trimmed, and it must be unique across profiles case-insensitively (`ELABELTAKEN`).
|
||||
operationId: ccConfigRenameProfile
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- label
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Profile to rename (its identifier, not its label).
|
||||
example: work
|
||||
label:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 60
|
||||
description: New display label.
|
||||
example: Tài khoản chính
|
||||
responses:
|
||||
'200':
|
||||
description: Profile renamed.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
example:
|
||||
ok: true
|
||||
name: work
|
||||
label: Tài khoản chính
|
||||
'400':
|
||||
description: '`EBADLABEL` - the label is empty, longer than 60 characters, or contains control characters; `EBADNAME` - the name is not filename-safe.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: '`ENOTFOUND` - no profile by that name.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: '`ELABELTAKEN` - another profile already uses that label.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -8257,7 +8332,7 @@ paths:
|
||||
tags:
|
||||
- CcConfig
|
||||
summary: Forget a saved auth profile
|
||||
description: 'Deletes the stored profile file. The identifier travels in the body, not the URL (same reason as `DELETE /file`). Refuses the ACTIVE profile: it holds the live account''s only credential copy outside the files a switch is about to overwrite.'
|
||||
description: Deletes the stored profile file. The identifier travels in the body, not the URL (same reason as `DELETE /file`). Refused (`EACTIVE`) only when it is the LAST saved copy of the account logged in right now - that copy is those credentials' only home outside the files a switch is about to overwrite. A redundant copy of the live account IS deletable, so an accidental duplicate can be cleaned up; a blanket active-guard would leave two profiles of one account undeletable forever.
|
||||
operationId: ccConfigDeleteProfile
|
||||
requestBody:
|
||||
required: true
|
||||
@@ -8299,7 +8374,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: '`EACTIVE` - refusing to delete the profile that is currently active.'
|
||||
description: '`EACTIVE` - refusing to delete the only saved copy of the account that is logged in right now.'
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
+3
-2
@@ -678,9 +678,10 @@ Reads — and carefully gated mutations for low-risk text-file artifacts — for
|
||||
| `PUT` | `/api/cc-config/file` | Create or overwrite a text-file artifact (skills/agents/commands/output-styles/memory). Body: `{ scope, type, name?, content }`. Auto-backs-up if file exists. Atomic temp + rename. 256 KB cap. Per-project file-based memory is also editable via `{ scope: "auto-memory", type: "auto-memory", project, name }` — backups land under `<memory-dir>/.cc-config-backups/auto-memory/`, and an invalid project slug returns `EBADPROJECT` |
|
||||
| `DELETE` | `/api/cc-config/file` | Backup-then-delete a text-file artifact. Skill dirs are backed up whole before recursive removal |
|
||||
| `GET` | `/api/cc-config/profiles` | `{ items, active }` — saved auth profiles + the account logged in right now. Identity metadata only, **never token material** |
|
||||
| `POST` | `/api/cc-config/profiles` | Snapshot the logged-in account as a profile. Body: `{ name }`. `ENOAUTH` when nobody is logged in |
|
||||
| `POST` | `/api/cc-config/profiles` | Snapshot the logged-in account as a profile. Body: `{ name? }` — omitted, the identifier and initial label are derived from the account. `EDUPLICATE` when another profile already holds this account, `ENOAUTH` when nobody is logged in |
|
||||
| `PATCH` | `/api/cc-config/profiles` | Rename a profile's display `label` (1-60 chars, spaces and diacritics fine, unique case-insensitively). Body: `{ name, label }`. The `name` identifier never moves; `EBADLABEL` / `ELABELTAKEN` |
|
||||
| `POST` | `/api/cc-config/profiles/activate` | Make a saved profile the live account — swaps `claudeAiOauth` + `oauthAccount`/`userID` and nothing else. Body: `{ name }`. Re-snapshots the outgoing account first (`savedOutgoing`); `EUNSAVED` if it was never saved |
|
||||
| `DELETE` | `/api/cc-config/profiles` | Forget a saved profile. Body: `{ name }`. `EACTIVE` for the active one |
|
||||
| `DELETE` | `/api/cc-config/profiles` | Forget a saved profile. Body: `{ name }`. `EACTIVE` only for the **last** saved copy of the live account — a redundant copy stays deletable |
|
||||
|
||||
### Run a lane's CLI (`/api/run`)
|
||||
|
||||
|
||||
@@ -195,6 +195,125 @@ describe("cc-profiles", () => {
|
||||
assert.throws(() => profiles.deleteProfile("work"), { code: "ENOTFOUND" });
|
||||
});
|
||||
|
||||
it("refuses to save an account that is already saved under another name", () => {
|
||||
login("uuid-a", "a@example.com");
|
||||
profiles.saveProfile("work");
|
||||
assert.throws(() => profiles.saveProfile("work-again"), { code: "EDUPLICATE" });
|
||||
// The rejected name left no file behind.
|
||||
assert.deepEqual(
|
||||
profiles.listProfiles().items.map((p) => p.name),
|
||||
["work"]
|
||||
);
|
||||
});
|
||||
|
||||
it("names the profile that already holds the account, so the error is actionable", () => {
|
||||
login("uuid-a", "a@example.com");
|
||||
profiles.saveProfile("work");
|
||||
assert.throws(() => profiles.saveProfile("dup"), /work/);
|
||||
});
|
||||
|
||||
it("still re-snapshots an account into its own existing profile", () => {
|
||||
login("uuid-a", "a@example.com");
|
||||
profiles.saveProfile("work");
|
||||
assert.equal(profiles.saveProfile("work").name, "work");
|
||||
});
|
||||
|
||||
it("derives a filename-safe slug and a readable label when no name is given", () => {
|
||||
login("uuid-a", "trí.vĩ@example.com");
|
||||
const saved = profiles.saveProfile();
|
||||
assert.match(saved.name, /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/);
|
||||
const item = profiles.listProfiles().items[0];
|
||||
assert.equal(item.name, saved.name);
|
||||
assert.ok(item.label, "a saved profile always has a label to show");
|
||||
});
|
||||
|
||||
it("labels an existing profile by its name when the file predates labels", () => {
|
||||
login("uuid-a", "a@example.com");
|
||||
profiles.saveProfile("work");
|
||||
const file = path.join(profiles.profilesDir(), "work.json");
|
||||
const stored = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
delete stored.label;
|
||||
fs.writeFileSync(file, JSON.stringify(stored));
|
||||
assert.equal(profiles.listProfiles().items[0].label, "work");
|
||||
});
|
||||
|
||||
it("renames a profile's label, keeping its credentials and its identifier", () => {
|
||||
login("uuid-a", "a@example.com");
|
||||
profiles.saveProfile("work");
|
||||
const result = profiles.renameProfile("work", "Tài khoản chính");
|
||||
assert.equal(result.name, "work");
|
||||
assert.equal(result.label, "Tài khoản chính");
|
||||
|
||||
const item = profiles.listProfiles().items[0];
|
||||
assert.equal(item.name, "work", "the identifier must not change under a rename");
|
||||
assert.equal(item.label, "Tài khoản chính");
|
||||
assert.equal(item.email, "a@example.com");
|
||||
// Switching still works, so the credentials survived the rewrite.
|
||||
login("uuid-b", "b@example.com");
|
||||
profiles.saveProfile("personal");
|
||||
profiles.activateProfile("work");
|
||||
assert.equal(JSON.parse(fs.readFileSync(CREDS, "utf8")).claudeAiOauth.accessToken, "at-uuid-a");
|
||||
});
|
||||
|
||||
it("accepts spaces and diacritics in a label but not control characters", () => {
|
||||
login("uuid-a", "a@example.com");
|
||||
profiles.saveProfile("work");
|
||||
for (const good of ["Tài khoản chính", "Work / Personal", "công ty ⭐", "a b"]) {
|
||||
assert.equal(profiles.renameProfile("work", good).label, good);
|
||||
}
|
||||
for (const bad of ["", " ", "a\nb", "a\tb", "x".repeat(61), 5, null]) {
|
||||
assert.throws(
|
||||
() => profiles.renameProfile("work", bad),
|
||||
{ code: "EBADLABEL" },
|
||||
`took ${bad}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("trims a label rather than storing the padding", () => {
|
||||
login("uuid-a", "a@example.com");
|
||||
profiles.saveProfile("work");
|
||||
assert.equal(profiles.renameProfile("work", " Chính ").label, "Chính");
|
||||
});
|
||||
|
||||
it("refuses a label another profile already uses, case-insensitively", () => {
|
||||
login("uuid-a", "a@example.com");
|
||||
profiles.saveProfile("work");
|
||||
login("uuid-b", "b@example.com");
|
||||
profiles.saveProfile("personal");
|
||||
|
||||
profiles.renameProfile("work", "Chính");
|
||||
assert.throws(() => profiles.renameProfile("personal", "chính"), { code: "ELABELTAKEN" });
|
||||
// Renaming a profile to the label it already has is not a conflict.
|
||||
assert.equal(profiles.renameProfile("work", "Chính").label, "Chính");
|
||||
});
|
||||
|
||||
it("refuses to rename a profile that does not exist", () => {
|
||||
login("uuid-a", "a@example.com");
|
||||
assert.throws(() => profiles.renameProfile("nope", "X"), { code: "ENOTFOUND" });
|
||||
});
|
||||
|
||||
it("deletes a duplicate of the live account while another copy still holds it", () => {
|
||||
// The state a pre-EDUPLICATE save could leave behind: two profiles, one
|
||||
// account, both flagged active — and with a blanket active-guard, neither
|
||||
// deletable. Cleanup has to be possible.
|
||||
login("uuid-a", "a@example.com");
|
||||
profiles.saveProfile("work");
|
||||
const dup = path.join(profiles.profilesDir(), "work-copy.json");
|
||||
fs.copyFileSync(path.join(profiles.profilesDir(), "work.json"), dup);
|
||||
|
||||
assert.deepEqual(
|
||||
profiles.listProfiles().items.map((p) => [p.name, p.active]),
|
||||
[
|
||||
["work", true],
|
||||
["work-copy", true],
|
||||
]
|
||||
);
|
||||
assert.deepEqual(profiles.deleteProfile("work-copy"), { ok: true, name: "work-copy" });
|
||||
// The last copy of the live account is still protected.
|
||||
assert.throws(() => profiles.deleteProfile("work"), { code: "EACTIVE" });
|
||||
});
|
||||
|
||||
it("skips corrupt profile files instead of failing the whole list", () => {
|
||||
login("uuid-a", "a@example.com");
|
||||
profiles.saveProfile("work");
|
||||
|
||||
+135
-14
@@ -27,9 +27,15 @@ const path = require("node:path");
|
||||
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
/** Profile names double as filenames, so the charset is deliberately narrow. */
|
||||
/**
|
||||
* A profile's `name` is its filename, so the charset is deliberately narrow.
|
||||
* It is an internal identifier: what the user reads and edits is `label`, which
|
||||
* accepts spaces and diacritics precisely because it never touches a path.
|
||||
*/
|
||||
const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$/;
|
||||
|
||||
const LABEL_MAX = 60;
|
||||
|
||||
function err(code, message) {
|
||||
const e = new Error(message);
|
||||
e.code = code;
|
||||
@@ -116,6 +122,40 @@ function profileFile(name) {
|
||||
return path.join(profilesDir(), `${name}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a user-supplied display label. Anything printable is allowed —
|
||||
* spaces and diacritics included — because a label is never used as a path.
|
||||
* Control characters are not: they would corrupt both the stored JSON line and
|
||||
* the row rendered in the UI.
|
||||
*/
|
||||
function normalizeLabel(label) {
|
||||
const clean = typeof label === "string" ? label.trim() : "";
|
||||
if (!clean || clean.length > LABEL_MAX || /[\u0000-\u001f\u007f]/.test(clean)) {
|
||||
throw err("EBADLABEL", `label must be 1-${LABEL_MAX} characters and no control characters`);
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Reduce any text to the narrow NAME_RE charset, for use as a filename. */
|
||||
function slugify(text) {
|
||||
const base = String(text || "")
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "") // drop the combining accents NFD split off
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 32);
|
||||
return NAME_RE.test(base) ? base : "profile";
|
||||
}
|
||||
|
||||
/** `base`, or `base-2`, `base-3`… — the first that is not already a file. */
|
||||
function freeName(base) {
|
||||
let name = base;
|
||||
for (let n = 2; fs.existsSync(path.join(profilesDir(), `${name}.json`)); n += 1) {
|
||||
name = `${base}-${n}`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Read one stored profile, or null when it does not exist. */
|
||||
function readProfile(name) {
|
||||
const stored = readJson(profileFile(name));
|
||||
@@ -151,23 +191,35 @@ function listProfiles() {
|
||||
const identity = identityOf({ oauth: stored.credentials, account: stored.account });
|
||||
items.push({
|
||||
name,
|
||||
// Profiles saved before labels existed fall back to their identifier, so
|
||||
// the UI always has something to show and to put in the rename field.
|
||||
label: typeof stored.label === "string" && stored.label.trim() ? stored.label.trim() : name,
|
||||
savedAt: stored.savedAt || null,
|
||||
active: Boolean(identity.accountUuid) && identity.accountUuid === active.accountUuid,
|
||||
...identity,
|
||||
});
|
||||
}
|
||||
items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
// Label first (that is what the UI shows), name as the tiebreaker so two
|
||||
// profiles sharing a label still come back in a stable order.
|
||||
items.sort((a, b) => a.label.localeCompare(b.label) || a.name.localeCompare(b.name));
|
||||
return { items, active: active.accountUuid || active.email ? active : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the currently logged-in account into a profile, overwriting any
|
||||
* profile of the same name.
|
||||
* @param name Profile name (also its filename).
|
||||
* @returns `{ ok, name, ...identity }`
|
||||
* Snapshot the currently logged-in account into a profile.
|
||||
*
|
||||
* One account is one profile: saving an account that some other profile
|
||||
* already holds is refused (`EDUPLICATE`) rather than silently making a second
|
||||
* copy, because two profiles of one account both report themselves active and
|
||||
* there is then nothing to switch between. Re-saving into the account's OWN
|
||||
* profile is the refresh path and stays allowed.
|
||||
*
|
||||
* @param name Optional identifier/filename. Omitted, one is derived from the
|
||||
* account, which is what the UI does — the user renames the `label`
|
||||
* afterwards instead of having to invent a filename up front.
|
||||
* @returns `{ ok, name, label, ...identity }`
|
||||
*/
|
||||
function saveProfile(name) {
|
||||
const file = profileFile(name);
|
||||
const auth = readActiveAuth();
|
||||
if (!auth.oauth) {
|
||||
throw err(
|
||||
@@ -175,15 +227,69 @@ function saveProfile(name) {
|
||||
"no Claude account is logged in on this machine - run `claude auth login` first"
|
||||
);
|
||||
}
|
||||
const identity = identityOf(auth);
|
||||
const readable = identity.displayName || (identity.email || "").split("@")[0] || "profile";
|
||||
// An explicit name is both identifier and initial label; a derived one keeps
|
||||
// the readable form as the label and only the filename gets flattened.
|
||||
const explicit = name !== undefined && name !== null;
|
||||
let target;
|
||||
if (explicit) {
|
||||
profileFile(name); // validates the charset, throwing EBADNAME
|
||||
target = name;
|
||||
} else {
|
||||
target = freeName(slugify(readable));
|
||||
}
|
||||
|
||||
const clash = listProfiles().items.find(
|
||||
(p) => p.accountUuid && p.accountUuid === identity.accountUuid && p.name !== target
|
||||
);
|
||||
if (clash) {
|
||||
throw err(
|
||||
"EDUPLICATE",
|
||||
`this account is already saved as "${clash.label}" (${clash.name}) - rename that profile instead of saving a second copy of it`
|
||||
);
|
||||
}
|
||||
|
||||
// A re-save must not clobber a label the user has since edited.
|
||||
const existing = readProfile(target);
|
||||
const label = (existing && existing.label) || (explicit ? target : readable);
|
||||
|
||||
fs.mkdirSync(profilesDir(), { recursive: true, mode: 0o700 });
|
||||
writeJson(file, {
|
||||
name,
|
||||
writeJson(profileFile(target), {
|
||||
name: target,
|
||||
label,
|
||||
savedAt: Date.now(),
|
||||
credentials: auth.oauth,
|
||||
account: auth.account,
|
||||
userID: auth.userID,
|
||||
});
|
||||
return { ok: true, name, ...identityOf(auth) };
|
||||
return { ok: true, name: target, label, ...identity };
|
||||
}
|
||||
|
||||
/**
|
||||
* Change a profile's display label. The `name` (its filename, and the id every
|
||||
* other route takes) deliberately does not move: renaming is a cosmetic edit,
|
||||
* not a re-keying, so it cannot fail halfway and leave a profile unreachable.
|
||||
*
|
||||
* @param name Profile to rename.
|
||||
* @param label New display label; spaces and diacritics are fine.
|
||||
* @returns `{ ok, name, label }`
|
||||
*/
|
||||
function renameProfile(name, label) {
|
||||
const file = profileFile(name);
|
||||
const stored = readProfile(name);
|
||||
if (!stored) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
const clean = normalizeLabel(label);
|
||||
|
||||
const taken = listProfiles().items.find(
|
||||
(p) => p.name !== name && p.label.toLowerCase() === clean.toLowerCase()
|
||||
);
|
||||
if (taken) {
|
||||
throw err("ELABELTAKEN", `another profile is already called "${taken.label}"`);
|
||||
}
|
||||
|
||||
writeJson(file, { ...stored, label: clean });
|
||||
return { ok: true, name, label: clean };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -235,14 +341,28 @@ function activateProfile(name) {
|
||||
return { ok: true, name, savedOutgoing, written };
|
||||
}
|
||||
|
||||
/** Delete a stored profile. Refuses the active one — that is the live account's
|
||||
* only credential copy outside the files it is about to be swapped out of. */
|
||||
/**
|
||||
* Delete a stored profile.
|
||||
*
|
||||
* Refused only when it holds the LAST saved copy of the live account: that copy
|
||||
* is the credentials' only home outside the files a switch is about to
|
||||
* overwrite. A redundant copy of the live account is deletable, which is what
|
||||
* makes an accidental duplicate cleanable — with a blanket active-guard, two
|
||||
* profiles of one account would both be undeletable forever.
|
||||
*/
|
||||
function deleteProfile(name) {
|
||||
const file = profileFile(name);
|
||||
if (!readProfile(name)) throw err("ENOTFOUND", `no profile named "${name}"`);
|
||||
const entry = listProfiles().items.find((p) => p.name === name);
|
||||
const items = listProfiles().items;
|
||||
const entry = items.find((p) => p.name === name);
|
||||
if (entry && entry.active) {
|
||||
throw err("EACTIVE", "cannot delete the profile that is currently active - switch away first");
|
||||
const otherCopy = items.some((p) => p.name !== name && p.accountUuid === entry.accountUuid);
|
||||
if (!otherCopy) {
|
||||
throw err(
|
||||
"EACTIVE",
|
||||
"cannot delete the only saved copy of the account that is logged in right now - switch away first"
|
||||
);
|
||||
}
|
||||
}
|
||||
fs.rmSync(file, { force: true });
|
||||
return { ok: true, name };
|
||||
@@ -251,6 +371,7 @@ function deleteProfile(name) {
|
||||
module.exports = {
|
||||
listProfiles,
|
||||
saveProfile,
|
||||
renameProfile,
|
||||
activateProfile,
|
||||
deleteProfile,
|
||||
// exported for tests
|
||||
|
||||
@@ -1213,15 +1213,21 @@ const schemas = {
|
||||
{ $ref: "#/components/schemas/CcConfigAccountIdentity" },
|
||||
{
|
||||
type: "object",
|
||||
required: ["name", "active"],
|
||||
required: ["name", "label", "active"],
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description:
|
||||
"Profile name; also its filename under `<CLAUDE_HOME>/agenthub-profiles/`.",
|
||||
"Internal identifier; also its filename under `<CLAUDE_HOME>/agenthub-profiles/`. Every mutation route takes this, and a rename never changes it.",
|
||||
pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$",
|
||||
example: "work",
|
||||
},
|
||||
label: {
|
||||
type: "string",
|
||||
description:
|
||||
"Display name the user edits. Free-form (spaces and diacritics allowed) because it is never used as a path; falls back to `name` for profiles saved before labels existed.",
|
||||
example: "Tài khoản chính",
|
||||
},
|
||||
savedAt: {
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
@@ -2192,20 +2198,20 @@ const paths = {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Save the logged-in account as a profile",
|
||||
description:
|
||||
"Snapshots the account currently logged in (its `claudeAiOauth` blob plus `oauthAccount`/`userID`) into a profile, overwriting a profile of the same name. The dashboard CANNOT log an account in - only `claude auth login` mints tokens - so adding another account is always: log in from a terminal, then call this to capture what the CLI wrote.",
|
||||
"Snapshots the account currently logged in (its `claudeAiOauth` blob plus `oauthAccount`/`userID`) into a profile. The dashboard CANNOT log an account in - only `claude auth login` mints tokens - so adding another account is always: log in from a terminal, then call this to capture what the CLI wrote.\n\nOne account is one profile. Saving an account that another profile already holds is refused with `EDUPLICATE` rather than making a second copy: duplicates both report themselves `active`, leaving nothing to switch between. Re-posting the same account into its OWN profile is the token-refresh path and is allowed - and it preserves that profile's `label`.\n\n`name` is optional and normally omitted: the server derives a filename-safe identifier plus a readable initial `label` from the account, and the user renames the label afterwards via PATCH. That is what keeps the only string a user types free of filename rules.",
|
||||
operationId: "ccConfigSaveProfile",
|
||||
requestBody: {
|
||||
required: true,
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
pattern: "^[a-zA-Z0-9][a-zA-Z0-9._-]{0,39}$",
|
||||
description: "Profile name; also its filename.",
|
||||
description:
|
||||
"Optional explicit identifier/filename. Omit it to have one derived from the account.",
|
||||
example: "work",
|
||||
},
|
||||
},
|
||||
@@ -2223,7 +2229,11 @@ const paths = {
|
||||
{ $ref: "#/components/schemas/CcConfigAccountIdentity" },
|
||||
{
|
||||
type: "object",
|
||||
properties: { ok: { type: "boolean" }, name: { type: "string" } },
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
name: { type: "string" },
|
||||
label: { type: "string" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -2231,14 +2241,83 @@ const paths = {
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "`EBADNAME` - the name is not filename-safe.",
|
||||
description: "`EBADNAME` - an explicit name was given and is not filename-safe.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description:
|
||||
"`ENOAUTH` - no Claude account is logged in to snapshot; `EBADJSON` - a config file is unparseable.",
|
||||
"`EDUPLICATE` - another profile already holds this account (its name is in the message); `ENOAUTH` - no Claude account is logged in to snapshot; `EBADJSON` - a config file is unparseable.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Rename a saved auth profile",
|
||||
description:
|
||||
"Changes a profile's display `label` only. The `name` identifier deliberately does not move: a rename is a cosmetic edit, not a re-keying, so it cannot fail halfway and leave a profile unreachable - and every stored credential is rewritten untouched.\n\nA label is free-form: 1-60 characters, spaces and diacritics allowed, because it is never used as a path. Control characters are rejected (`EBADLABEL`), it is stored trimmed, and it must be unique across profiles case-insensitively (`ELABELTAKEN`).",
|
||||
operationId: "ccConfigRenameProfile",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["name", "label"],
|
||||
properties: {
|
||||
name: {
|
||||
type: "string",
|
||||
description: "Profile to rename (its identifier, not its label).",
|
||||
example: "work",
|
||||
},
|
||||
label: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: 60,
|
||||
description: "New display label.",
|
||||
example: "Tài khoản chính",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Profile renamed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
name: { type: "string" },
|
||||
label: { type: "string" },
|
||||
},
|
||||
},
|
||||
example: { ok: true, name: "work", label: "Tài khoản chính" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description:
|
||||
"`EBADLABEL` - the label is empty, longer than 60 characters, or contains control characters; `EBADNAME` - the name is not filename-safe.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "`ENOTFOUND` - no profile by that name.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "`ELABELTAKEN` - another profile already uses that label.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
@@ -2249,7 +2328,7 @@ const paths = {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Forget a saved auth profile",
|
||||
description:
|
||||
"Deletes the stored profile file. The identifier travels in the body, not the URL (same reason as `DELETE /file`). Refuses the ACTIVE profile: it holds the live account's only credential copy outside the files a switch is about to overwrite.",
|
||||
"Deletes the stored profile file. The identifier travels in the body, not the URL (same reason as `DELETE /file`). Refused (`EACTIVE`) only when it is the LAST saved copy of the account logged in right now - that copy is those credentials' only home outside the files a switch is about to overwrite. A redundant copy of the live account IS deletable, so an accidental duplicate can be cleaned up; a blanket active-guard would leave two profiles of one account undeletable forever.",
|
||||
operationId: "ccConfigDeleteProfile",
|
||||
requestBody: {
|
||||
required: true,
|
||||
@@ -2289,7 +2368,8 @@ const paths = {
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "`EACTIVE` - refusing to delete the profile that is currently active.",
|
||||
description:
|
||||
"`EACTIVE` - refusing to delete the only saved copy of the account that is logged in right now.",
|
||||
content: {
|
||||
"application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } },
|
||||
},
|
||||
|
||||
@@ -345,12 +345,15 @@ router.patch("/settings", (req, res) => {
|
||||
|
||||
const PROFILE_ERR_TO_STATUS = {
|
||||
EBADNAME: 400,
|
||||
EBADLABEL: 400,
|
||||
EBADREQ: 400,
|
||||
ENOTFOUND: 404,
|
||||
EBADJSON: 409,
|
||||
EACTIVE: 409,
|
||||
EUNSAVED: 409,
|
||||
ENOAUTH: 409,
|
||||
EDUPLICATE: 409,
|
||||
ELABELTAKEN: 409,
|
||||
};
|
||||
|
||||
function profileError(res, e) {
|
||||
@@ -376,6 +379,18 @@ router.post("/profiles", (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Rename is a label edit; the profile's `name` identifier never moves.
|
||||
router.patch("/profiles", (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const result = ccProfiles.renameProfile(body.name, body.label);
|
||||
emitChanged({ action: "rename", type: "profile", name: result.name, project: null });
|
||||
res.json(result);
|
||||
} catch (e) {
|
||||
return profileError(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/profiles/activate", (req, res) => {
|
||||
try {
|
||||
const result = ccProfiles.activateProfile((req.body || {}).name);
|
||||
|
||||
Reference in New Issue
Block a user