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:
2026-09-07 16:29:11 +07:00
parent 4bc77ded17
commit 11dcc04ac4
12 changed files with 560 additions and 102 deletions
+135 -14
View File
@@ -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