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