Files
AgentHub/client/src/components/settings/AccountSettings.tsx
T
nntrivi2001 7a88b74016 feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:21:33 +07:00

433 lines
14 KiB
TypeScript

/**
* @file AccountSettings.tsx
* @description Settings → Account (server mode, any signed-in role): the
* account's own information (`GET /api/me/account` — username, role, member
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB; the
* sidebar shows it at once), its display name
* and email (`PATCH /api/me/account`), a password change that asks for the
* current one (`POST /api/me/password` — this browser stays signed in, every
* other sign-in ends) and "Sign out other devices" (`POST
* /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an
* admin's to change, on the Users page — never here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react";
import { api, type MyAccount } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
import { confirmDialog } from "../../lib/confirm";
import { isSubmitEnter } from "../../lib/keys";
import { useAuth } from "../AuthGate";
import { ROLE_BADGE } from "../users/UserEditDialog";
import { UserAvatar } from "../users/UserAvatar";
import { SettingsSection } from "./SettingsRow";
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
const errText = (t: (k: string, o?: Record<string, unknown>) => string, e: unknown) => {
const err = e as Error & { code?: string };
return err.code && t(`users:errors.${err.code}`, { defaultValue: "" })
? t(`users:errors.${err.code}`)
: err.message;
};
export function AccountSettings() {
const { t } = useTranslation("settings");
const [account, setAccount] = useState<ServerAccount | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const { updateUser } = useAuth();
const apply = useCallback(
(r: MyAccount) => {
if (r.mode !== "server") return;
setAccount(r);
// The sidebar shows the name and avatar: let them follow a save at once.
updateUser({
displayName: r.user.displayName,
email: r.user.email,
avatarAt: r.user.avatarAt ?? null,
});
},
[updateUser]
);
const load = useCallback(() => {
setLoadError(null);
api.me
.account()
.then(apply)
.catch((e: Error) => setLoadError(e.message));
}, [apply]);
useEffect(load, [load]);
if (loadError) {
return (
<div role="alert" className="card flex items-center gap-3 p-4 text-sm text-status-danger">
<span className="flex-1">{t("account.loadFailed", { message: loadError })}</span>
<button type="button" className="btn-ghost text-xs" onClick={load}>
<RefreshCw className="h-3.5 w-3.5" /> {t("common:retry")}
</button>
</div>
);
}
if (!account) {
return <div className="card h-40 animate-pulse" data-testid="account-loading" />;
}
return (
<div className="space-y-6">
<ProfileCard account={account} onSaved={apply} />
<ProfileForm account={account} onSaved={apply} />
<PasswordForm />
<SignOutOthers logins={account.logins} onDone={load} />
</div>
);
}
/** The largest avatar the server takes (server/lib/avatars.js). */
const MAX_AVATAR_BYTES = 1024 * 1024;
function ProfileCard({
account,
onSaved,
}: {
account: ServerAccount;
onSaved: (r: MyAccount) => void;
}) {
const { t } = useTranslation("settings");
const { user } = account;
const shown = user.displayName || user.username;
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const run = async (op: () => Promise<MyAccount>, done: string) => {
setBusy(true);
try {
onSaved(await op());
toast.success(done);
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
const pick = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again
if (!file) return;
if (file.size > MAX_AVATAR_BYTES) {
toast.error(t("account.avatar.tooBig"));
return;
}
void run(() => api.me.setAvatar(file), t("account.avatar.saved"));
};
return (
<section className="card p-5" data-testid="account-card">
<div className="flex items-center gap-4">
<div className="flex flex-shrink-0 flex-col items-center gap-1.5">
<UserAvatar name={shown} avatarAt={user.avatarAt} className="h-14 w-14 text-lg" />
<div className="flex items-center gap-0.5">
<button
type="button"
className="btn-ghost h-6 w-6 justify-center p-0"
onClick={() => fileRef.current?.click()}
disabled={busy}
title={t("account.avatar.change")}
aria-label={t("account.avatar.change")}
data-testid="account-avatar-change"
>
<Camera className="h-3.5 w-3.5" />
</button>
{user.avatarAt && (
<button
type="button"
className="btn-ghost h-6 w-6 justify-center p-0"
onClick={() => void run(() => api.me.removeAvatar(), t("account.avatar.removed"))}
disabled={busy}
title={t("account.avatar.remove")}
aria-label={t("account.avatar.remove")}
data-testid="account-avatar-remove"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
className="hidden"
onChange={pick}
data-testid="account-avatar-input"
/>
</div>
<div className="min-w-0">
<h3 className="truncate text-base font-semibold text-fg-primary">{shown}</h3>
<p className="truncate text-xs text-fg-muted">
@{user.username}
{user.email ? ` · ${user.email}` : ""}
</p>
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<span className={`badge py-0.5 text-[11px] ${ROLE_BADGE[user.role]}`}>
{t(`users:roles.${user.role}`)}
</span>
<span className="text-[11px] text-fg-muted">{t(`users:roleHint.${user.role}`)}</span>
</div>
</div>
</div>
<dl className="mt-4 grid grid-cols-2 gap-3 border-t border-border pt-4 text-xs sm:grid-cols-5">
<Stat label={t("account.memberSince")}>
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
</Stat>
<Stat label={t("account.workspaces")}>{account.workspaces}</Stat>
<Stat label={t("account.sessions")}>{account.sessions}</Stat>
<Stat label={t("account.logins")}>{account.logins}</Stat>
<Stat label={t("account.lastLogin")}>
{account.lastLoginAt ? formatDateTime(account.lastLoginAt) : "—"}
</Stat>
</dl>
<p className="mt-3 text-[11px] text-fg-muted">{t("account.roleNote")}</p>
</section>
);
}
function ProfileForm({
account,
onSaved,
}: {
account: ServerAccount;
onSaved: (r: MyAccount) => void;
}) {
const { t } = useTranslation("settings");
const [displayName, setDisplayName] = useState(account.user.displayName ?? "");
const [email, setEmail] = useState(account.user.email ?? "");
const [busy, setBusy] = useState(false);
const norm = (v: string) => (v.trim() === "" ? null : v.trim());
const body: { displayName?: string | null; email?: string | null } = {};
if (norm(displayName) !== (account.user.displayName ?? null))
body.displayName = norm(displayName);
if (norm(email) !== (account.user.email ?? null)) body.email = norm(email);
const changed = Object.keys(body).length > 0;
const save = async () => {
if (!changed || busy) return;
setBusy(true);
try {
onSaved(await api.me.updateAccount(body));
toast.success(t("account.profileSaved"));
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void save();
}
};
return (
<SettingsSection
id="account-profile"
title={t("account.profile")}
description={t("account.profileDescription")}
>
<div className="grid gap-3 sm:grid-cols-2" onKeyDown={onKeyDown}>
<Field label={t("users:displayName")}>
<input
className="input w-full text-sm"
value={displayName}
maxLength={100}
onChange={(e) => setDisplayName(e.target.value)}
aria-label={t("users:displayName")}
/>
</Field>
<Field label={t("users:email")}>
<input
type="email"
className="input w-full text-sm"
value={email}
maxLength={254}
onChange={(e) => setEmail(e.target.value)}
aria-label={t("users:email")}
/>
</Field>
</div>
<div className="mt-3 flex justify-end">
<button
type="button"
className="btn-primary disabled:opacity-50"
disabled={!changed || busy}
onClick={() => void save()}
>
{t("account.save")}
</button>
</div>
</SettingsSection>
);
}
function PasswordForm() {
const { t } = useTranslation("settings");
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [again, setAgain] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const mismatch = again.length > 0 && again !== next;
const ready = current.length > 0 && next.length > 0 && again === next && !busy;
const submit = async () => {
if (!ready) return;
setBusy(true);
setError(null);
try {
await api.me.changePassword(current, next);
setCurrent("");
setNext("");
setAgain("");
toast.success(t("account.passwordChanged"));
} catch (e) {
setError(errText(t, e));
} finally {
setBusy(false);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void submit();
}
};
return (
<SettingsSection
id="account-password"
title={t("account.password")}
description={t("account.passwordDescription")}
>
<div className="grid gap-3 sm:grid-cols-3" onKeyDown={onKeyDown}>
<Field label={t("account.currentPassword")}>
<input
type="password"
className="input w-full text-sm"
value={current}
autoComplete="current-password"
onChange={(e) => setCurrent(e.target.value)}
aria-label={t("account.currentPassword")}
/>
</Field>
<Field label={t("account.newPassword")} hint={t("users:passwordHint")}>
<input
type="password"
className="input w-full text-sm"
value={next}
autoComplete="new-password"
onChange={(e) => setNext(e.target.value)}
aria-label={t("account.newPassword")}
/>
</Field>
<Field label={t("account.confirmPassword")}>
<input
type="password"
className="input w-full text-sm"
value={again}
autoComplete="new-password"
onChange={(e) => setAgain(e.target.value)}
aria-label={t("account.confirmPassword")}
/>
</Field>
</div>
{(mismatch || error) && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error ?? t("account.mismatch")}
</p>
)}
<div className="mt-3 flex justify-end">
<button
type="button"
className="btn-primary disabled:opacity-50"
disabled={!ready}
onClick={() => void submit()}
>
{t("account.changePassword")}
</button>
</div>
</SettingsSection>
);
}
function SignOutOthers({ logins, onDone }: { logins: number; onDone: () => void }) {
const { t } = useTranslation("settings");
const [busy, setBusy] = useState(false);
const run = async () => {
const ok = await confirmDialog({
title: t("account.signOutOthersConfirm"),
message: t("account.signOutOthersBody"),
confirmLabel: t("account.signOutOthers"),
cancelLabel: t("common:cancel"),
destructive: true,
});
if (!ok) return;
setBusy(true);
try {
const r = await api.me.signOutOthers();
toast.success(t("account.signedOut", { count: r.ended }));
onDone();
} catch (e) {
toast.error(errText(t, e));
} finally {
setBusy(false);
}
};
return (
<SettingsSection
id="account-sessions"
title={t("account.sessionsTitle")}
description={t("account.sessionsDescription", { n: logins })}
>
<button
type="button"
className="btn-ghost text-status-danger disabled:opacity-50"
disabled={busy || logins <= 1}
onClick={() => void run()}
>
<LogOut className="h-4 w-4" /> {t("account.signOutOthers")}
</button>
</SettingsSection>
);
}
function Stat({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="min-w-0">
<dt className="text-[11px] text-fg-muted">{label}</dt>
<dd className="truncate text-sm tabular-nums text-fg-primary">{children}</dd>
</div>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="text-xs font-medium text-fg-secondary">{label}</span>
<div className="mt-1">{children}</div>
{hint && <span className="mt-0.5 block text-[11px] text-fg-muted">{hint}</span>}
</label>
);
}