7a88b74016
- 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>
433 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|