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
-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