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
+66 -39
View File
@@ -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,43 +4639,22 @@ 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 className="pt-1.5 space-y-1.5">
<div className="text-[11px] text-status-warning">{t("profiles.unsavedActive")}</div>
<button
disabled={busy !== null}
onClick={() =>
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" />
{busy === "save" ? t("profiles.saving") : t("profiles.saveButton")}
</button>
</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"
/>
<button
disabled={!newName.trim() || busy !== null}
onClick={() =>
void run(
"save",
() => api.ccConfig.saveProfile(newName.trim()),
t("profiles.saveSuccess", { name: newName.trim() })
).then(() => setNewName(""))
}
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")}
</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} />}
{items !== null && items.length === 0 && (
<div className="rounded-lg border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-fg-muted">
@@ -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")}