/** * @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ĩ */ 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; const errText = (t: (k: string, o?: Record) => 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(null); const [loadError, setLoadError] = useState(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 (
{t("account.loadFailed", { message: loadError })}
); } if (!account) { return
; } return (
); } /** 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(null); const [busy, setBusy] = useState(false); const run = async (op: () => Promise, 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) => { 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 (
{user.avatarAt && ( )}

{shown}

@{user.username} {user.email ? ` · ${user.email}` : ""}

{t(`users:roles.${user.role}`)} {t(`users:roleHint.${user.role}`)}
{user.createdAt ? formatDateTime(user.createdAt) : "—"} {account.workspaces} {account.sessions} {account.logins} {account.lastLoginAt ? formatDateTime(account.lastLoginAt) : "—"}

{t("account.roleNote")}

); } 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 (
setDisplayName(e.target.value)} aria-label={t("users:displayName")} /> setEmail(e.target.value)} aria-label={t("users:email")} />
); } 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(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 (
setCurrent(e.target.value)} aria-label={t("account.currentPassword")} /> setNext(e.target.value)} aria-label={t("account.newPassword")} /> setAgain(e.target.value)} aria-label={t("account.confirmPassword")} />
{(mismatch || error) && (

{error ?? t("account.mismatch")}

)}
); } 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 ( ); } function Stat({ label, children }: { label: string; children: ReactNode }) { return (
{label}
{children}
); } function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { return ( ); }