feat(cc-config): switch between Claude Code accounts, keeping the workspace

Adds a "Switch profile" button left of Backups on /settings?tool=claude-code.
A *profile* is one Anthropic account - one quota - saved under a name, so the
local `claude` CLI can be pointed at a different account without disturbing
anything else.

A switch swaps exactly two identity artifacts and nothing more: `claudeAiOauth`
inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside
the `.claude.json` config file(s). Those files are key-merged in place, and only
where they already exist, 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: those are
the machine's MCP server tokens, not account identity.

Profiles are one JSON file each under `<CLAUDE_HOME>/agenthub-profiles/` (dir
0700, files 0600, atomic temp+rename) and hold live OAuth tokens, so no route
ever returns token material - `GET /profiles` answers identity metadata only.

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 snapshot what
the CLI wrote". `activateProfile` re-snapshots the outgoing account first so
tokens Claude Code refreshed since its last save 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. A switch applies to new sessions only,
since a running session rewrites the config on exit.

Routes: GET/POST/DELETE /api/cc-config/profiles, POST /profiles/activate.

Verified: server 1420/1420 (14 new, incl. token-free responses and 0600/0700
permissions), client 459/459.
This commit is contained in:
2026-09-07 15:13:38 +07:00
parent 17d56d0e01
commit 4bc77ded17
13 changed files with 1496 additions and 3 deletions
+23
View File
@@ -215,6 +215,29 @@
"scopeLabel": "Scope",
"filterAll": "All types"
},
"profiles": {
"title": "Claude Code profiles",
"openButton": "Switch profile",
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
"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}}\".",
"empty": "No profiles saved yet - save the account you are logged in as to get started.",
"activeBadge": "active",
"switchButton": "Switch to",
"switching": "Switching…",
"switchSuccess": "Switched to profile \"{{name}}\". Start a new Claude Code session to pick it up.",
"deleteButton": "Delete profile",
"deleteSuccess": "Deleted profile \"{{name}}\".",
"savedAt": "Saved {{time}}",
"loginHint": "Add another account by logging in from a terminal first:"
},
"explain": {
"hooks": {
"title": "Editing hooks writes settings.json",
+23
View File
@@ -210,6 +210,29 @@
"scopeLabel": "Phạm vi",
"filterAll": "Tất cả loại"
},
"profiles": {
"title": "Profile Claude Code",
"openButton": "Đổi profile",
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
"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}}\".",
"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",
"switching": "Đang chuyển…",
"switchSuccess": "Đã chuyển sang profile \"{{name}}\". Mở phiên Claude Code mới để áp dụng.",
"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:"
},
"explain": {
"hooks": {
"title": "Sửa hooks tức là ghi vào settings.json",
+56
View File
@@ -1509,6 +1509,38 @@ export const api = {
*/
backups: (params?: { scope?: "user" | "project"; type?: CcArtifactType }) =>
requestBackupsHelper(params),
/**
* GET /api/cc-config/profiles - saved auth profiles + the live account.
* @returns `{ items, active }` — {@link CcProfile}s and the currently
* logged-in {@link CcAccountIdentity} (null when nobody is logged in).
*/
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.
*/
saveProfile: (name: string) =>
request<{ ok: true; name: string } & CcAccountIdentity>("/cc-config/profiles", {
method: "POST",
body: JSON.stringify({ name }),
}),
/**
* POST /api/cc-config/profiles/activate - make a saved profile the account
* the local `claude` CLI runs as. The outgoing account is re-snapshotted
* into its own profile first (`savedOutgoing`).
*/
activateProfile: (name: string) =>
request<{ ok: true; name: string; savedOutgoing: string | null; written: string[] }>(
"/cc-config/profiles/activate",
{ method: "POST", body: JSON.stringify({ name }) }
),
/** DELETE /api/cc-config/profiles - forget a saved profile (never the active one). */
deleteProfile: (name: string) =>
request<{ ok: true; name: string }>("/cc-config/profiles", {
method: "DELETE",
body: JSON.stringify({ name }),
}),
/**
* GET /api/cc-config/plugins/available - every plugin offered by a
* registered marketplace, from `claude plugin list --available --json`.
@@ -2261,6 +2293,30 @@ export interface CcBackup {
project?: string; // present for scope === "auto-memory"
}
/** Identity of one Anthropic account, as reported by the auth-profile
* endpoints. Deliberately token-free — the server never sends credentials to
* the browser, only enough to tell the accounts apart. */
export interface CcAccountIdentity {
accountUuid: string | null;
email: string | null;
displayName: string | null;
organization: string | null;
seatTier: string | null;
subscriptionType: string | null;
/** Access-token expiry, epoch ms; a switch still works past it (the refresh
* token is stored too). */
expiresAt: number | null;
}
/** One saved auth profile from GET /api/cc-config/profiles. */
export interface CcProfile extends CcAccountIdentity {
name: 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. */
active: boolean;
}
/** Scope filter accepted by most `ccConfig` list endpoints; "all" merges
* user + project scope in one response. */
export type CcScope = "user" | "project" | "all";
+249
View File
@@ -94,6 +94,7 @@ import {
ChevronLeft,
ChevronRight,
ChevronDown,
UserCog,
} from "lucide-react";
import { api } from "../lib/api";
import type {
@@ -116,6 +117,8 @@ import type {
CcPlugin,
CcPluginItem,
CcPluginsResponse,
CcProfile,
CcAccountIdentity,
CcScope,
CcSettingsSource,
CcStatusline,
@@ -254,6 +257,7 @@ export function CcConfig() {
const [confirmDelete, setConfirmDelete] = useState<ConfirmDeleteState>(null);
const [toast, setToast] = useState<Toast>(null);
const [backupsOpen, setBackupsOpen] = useState(false);
const [profilesOpen, setProfilesOpen] = useState(false);
// Auto-dismiss toasts after 5s
useEffect(() => {
@@ -492,6 +496,7 @@ export function CcConfig() {
onScopeChange={setScope}
onRefresh={fetchAll}
onOpenBackups={() => setBackupsOpen(true)}
onOpenProfiles={() => setProfilesOpen(true)}
wsConnected={wsConnected}
/>
@@ -565,6 +570,7 @@ export function CcConfig() {
)}
{toast && <ToastNotice toast={toast} onDismiss={() => setToast(null)} />}
{backupsOpen && <BackupsModal onClose={() => setBackupsOpen(false)} />}
{profilesOpen && <ProfilesModal onClose={() => setProfilesOpen(false)} onToast={setToast} />}
</div>
);
}
@@ -578,6 +584,7 @@ interface HeaderProps {
onScopeChange: (s: CcScope) => void;
onRefresh: () => void;
onOpenBackups: () => void;
onOpenProfiles: () => void;
wsConnected: boolean;
}
@@ -588,6 +595,7 @@ function Header({
onScopeChange,
onRefresh,
onOpenBackups,
onOpenProfiles,
wsConnected,
}: HeaderProps) {
const { t } = useTranslation("ccConfig");
@@ -626,6 +634,13 @@ function Header({
<div className="flex flex-col items-stretch lg:items-end gap-2 flex-shrink-0">
<div className="flex items-center gap-2 justify-end flex-wrap">
<ScopeToggle value={scope} onChange={onScopeChange} />
<button
onClick={onOpenProfiles}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<UserCog className="w-3.5 h-3.5" />
{t("profiles.openButton")}
</button>
<button
onClick={onOpenBackups}
className="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
@@ -4497,6 +4512,240 @@ function CommandSnippet({ command, label }: { command: string; label?: string })
);
}
// ── Auth profiles modal ────────────────────────────────────────────────
//
// Swaps which Anthropic account the local `claude` CLI runs as. Only the two
// identity artifacts move (the OAuth blob + `oauthAccount`), so skills,
// subagents, memory, plugins, marketplaces and settings all stay exactly where
// they are — that is the whole point of the feature.
//
// 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.
function ProfilesModal({ onClose, onToast }: { onClose: () => void; onToast: (t: Toast) => void }) {
const { t } = useTranslation("ccConfig");
const [items, setItems] = useState<CcProfile[] | null>(null);
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("");
const load = useCallback(async () => {
try {
const r = await api.ccConfig.profiles();
setItems(r.items);
setActive(r.active);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "unknown");
}
}, []);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
/** Run one profile mutation, then refresh the list. */
const run = useCallback(
async (key: string, fn: () => Promise<unknown>, success: string) => {
setBusy(key);
try {
await fn();
onToast({ kind: "success", message: success });
await load();
} catch (err: unknown) {
onToast({
kind: "error",
message: err instanceof Error ? err.message : "unknown error",
});
} finally {
setBusy(null);
}
},
[load, onToast]
);
const activeSaved = items?.some((p) => p.active) ?? false;
return (
<div
className="fixed inset-0 bg-black/60 z-40 flex items-center justify-center p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-2xl max-h-[85vh] rounded-xl border border-border bg-surface-1 flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 border-b border-border px-4 py-2.5">
<UserCog className="w-4 h-4 text-fg-muted" />
<span className="text-sm font-medium text-fg-primary flex-1">{t("profiles.title")}</span>
<button
onClick={onClose}
className="text-fg-secondary hover:text-fg-primary p-1 rounded-md hover:bg-surface-3"
aria-label={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-4 py-3 border-b border-border space-y-2">
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
<span>{t("profiles.warning")}</span>
</p>
</div>
<div className="overflow-auto p-4 space-y-3">
{error && (
<div className="text-sm text-status-danger inline-flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2.5 space-y-1">
<div className="text-[10px] uppercase tracking-wide text-fg-muted">
{t("profiles.activeLabel")}
</div>
{active ? (
<>
<div className="text-sm text-fg-primary">
{active.email || active.displayName || active.accountUuid}
</div>
<IdentityLine identity={active} />
</>
) : (
<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"
/>
<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">
{t("profiles.empty")}
</div>
)}
{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>
{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")}
</span>
)}
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{p.email || p.displayName || p.accountUuid}
</span>
{!p.active && (
<button
disabled={busy !== null}
onClick={() =>
void run(
`switch:${p.name}`,
() => api.ccConfig.activateProfile(p.name),
t("profiles.switchSuccess", { name: p.name })
)
}
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"
>
{busy === `switch:${p.name}`
? t("profiles.switching")
: t("profiles.switchButton")}
</button>
)}
{!p.active && (
<button
disabled={busy !== null}
onClick={() =>
void run(
`del:${p.name}`,
() => api.ccConfig.deleteProfile(p.name),
t("profiles.deleteSuccess", { name: p.name })
)
}
title={t("profiles.deleteButton")}
aria-label={t("profiles.deleteButton")}
className="h-7 w-7 inline-flex items-center justify-center rounded-md text-fg-muted hover:text-status-danger hover:bg-surface-3 disabled:opacity-50"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<IdentityLine identity={p} />
{p.savedAt && (
<div className="text-[10px] text-fg-muted mt-1">
{t("profiles.savedAt", { time: new Date(p.savedAt).toLocaleString() })}
</div>
)}
</div>
))}
<div>
<div className="text-[10px] text-fg-muted mb-1">{t("profiles.loginHint")}</div>
<CommandSnippet command="claude auth login" />
</div>
</div>
</div>
</div>
);
}
/** Org / seat / plan line shared by the active card and each profile row. */
function IdentityLine({ identity }: { identity: CcAccountIdentity }) {
const parts = [identity.organization, identity.seatTier, identity.subscriptionType].filter(
Boolean
);
if (parts.length === 0) return null;
return <div className="text-[10px] text-fg-muted mt-0.5">{parts.join(" · ")}</div>;
}
// ── Backups modal ──────────────────────────────────────────────────────
function BackupsModal({ onClose }: { onClose: () => void }) {
@@ -795,6 +795,61 @@ exports[`screen snapshots > Claude Config 1`] = `
Project
</button>
</div>
<button
class="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>
<svg
class="lucide lucide-user-cog w-3.5 h-3.5"
fill="none"
height="24"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
cx="18"
cy="15"
r="3"
/>
<circle
cx="9"
cy="7"
r="4"
/>
<path
d="M10 15H6a4 4 0 0 0-4 4v2"
/>
<path
d="m21.7 16.4-.9-.3"
/>
<path
d="m15.2 13.9-.9-.3"
/>
<path
d="m16.6 18.7.3-.9"
/>
<path
d="m19.1 12.2.3-.9"
/>
<path
d="m19.6 18.7-.4-1"
/>
<path
d="m16.8 12.3-.4-1"
/>
<path
d="m14.3 16.6 1-.4"
/>
<path
d="m20.7 13.8 1-.4"
/>
</svg>
Switch profile
</button>
<button
class="inline-flex items-center gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-xs font-medium text-fg-secondary hover:bg-surface-3 transition-colors"
>