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:
@@ -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" } },
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user