Files
AgentHub/client/src/components/settings/AccountSettings.tsx
T
nntrivi2001 26952ae0d9 feat: avatar by link, loaded by the browser; Files nests workspaces under owners' homes
- POST /api/me/avatar/url stores the https link (users.avatar_link) instead
  of downloading it; GET /api/me/avatar and /api/users/:id/avatar answer 302
  to it (no referrer). Link rules: https, no userinfo, <= 2048 chars, no
  localhost or private/reserved IP literal. Upload and link replace each other.
- Settings -> Account first loads the link as an embedded image and refuses
  one this browser cannot show: a picture behind a login (Mattermost's
  /api/v4/users/<id>/image) gets no cookie cross-site and 401s.
- Files sidebar: each workspace under its owner's home, chevrons remembered
  per browser (agenthub.files.homesOpen); ownerless ones stay flat.
2026-10-22 14:34:50 +07:00

526 lines
18 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 — or
* an https link the browser loads itself, `POST /api/me/avatar/url`; the sidebar
* shows it at once), its display name
* and email (`PATCH /api/me/account`), a password change that asks for the
* current one and passes every rule of `lib/passwordRules.ts`, ticked as it is
* typed (`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, Link2, 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 { useSpin } from "../../hooks/useSpin";
import { useAuth } from "../AuthGate";
import { ROLE_BADGE } from "../users/UserEditDialog";
import { UserAvatar } from "../users/UserAvatar";
import { PasswordRules } from "../users/PasswordRules";
import { passwordProblems } from "../../lib/passwordRules";
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;
};
/** Whether `url` loads as an image embedded in this page — the way every
* avatar is shown: no referrer, the cookies a cross-site image gets. */
export function loadsAsImage(url: string, timeoutMs = 10_000): Promise<boolean> {
return new Promise((resolve) => {
const img = new Image();
const done = (ok: boolean) => {
window.clearTimeout(timer);
img.onload = img.onerror = null;
resolve(ok);
};
const timer = window.setTimeout(() => done(false), timeoutMs);
img.referrerPolicy = "no-referrer";
img.onload = () => done(img.naturalWidth > 0);
img.onerror = () => done(false);
img.src = url;
});
}
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);
return api.me
.account()
.then(apply)
.catch((e: Error) => setLoadError(e.message));
}, [apply]);
useEffect(() => {
void load();
}, [load]);
const [retrying, retry] = useSpin(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={retry} disabled={retrying}>
<RefreshCw className={`h-3.5 w-3.5${retrying ? " animate-spin" : ""}`} />{" "}
{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);
// null = the link field is closed.
const [link, setLink] = useState<string | null>(null);
const run = async (op: () => Promise<MyAccount>, done: string) => {
setBusy(true);
try {
onSaved(await op());
toast.success(done);
return true;
} catch (e) {
toast.error(errText(t, e));
return false;
} finally {
setBusy(false);
}
};
const applyLink = async () => {
const url = link?.trim();
if (!url) return;
// The avatar routes only redirect to the link, so it shows only if THIS
// browser can load it as an embedded image: a link behind a login (a
// Mattermost /api/v4/users/<id>/image) gets no cookie from another site
// and fails — say so now instead of saving a picture nobody sees.
setBusy(true);
const ok = await loadsAsImage(url);
setBusy(false);
if (!ok) return void toast.error(t("account.avatar.linkUnreachable"));
if (await run(() => api.me.setAvatarFromUrl(url), t("account.avatar.saved"))) setLink(null);
};
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>
<button
type="button"
className={`btn-ghost h-6 w-6 justify-center p-0 ${link !== null ? "text-accent" : ""}`}
onClick={() => setLink((v) => (v === null ? "" : null))}
disabled={busy}
title={t("account.avatar.fromUrl")}
aria-label={t("account.avatar.fromUrl")}
aria-pressed={link !== null}
data-testid="account-avatar-url-toggle"
>
<Link2 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>
{link !== null && (
<div className="mt-3 flex items-center gap-2">
<input
type="url"
className="input w-full text-sm"
value={link}
placeholder={t("account.avatar.urlPlaceholder")}
aria-label={t("account.avatar.fromUrl")}
autoFocus
disabled={busy}
onChange={(e) => setLink(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void applyLink();
} else if (e.key === "Escape") setLink(null);
}}
data-testid="account-avatar-url-input"
/>
<button
type="button"
className="btn-primary flex-shrink-0 text-sm"
onClick={() => void applyLink()}
disabled={busy || !link.trim()}
data-testid="account-avatar-url-apply"
>
{t("account.avatar.urlApply")}
</button>
</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 { state } = useAuth();
const username = state.mode === "server" ? state.user?.username : undefined;
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;
// The account's own password: every rule (server/lib/auth.js decides too).
const strong = passwordProblems(next, username).length === 0;
const ready = current.length > 0 && strong && 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")}>
<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>
<div className="mt-3">
<PasswordRules password={next} username={username} />
</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>
);
}