thread-master/apps/web/src/pages/AdminUsersPage.tsx

817 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from "react";
import { Link, Navigate } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { PageHeader } from "../components/layout/PageHeader";
import { Badge, Button, Card, EmptyState, Input, Pager, Select } from "../components/ui";
import { useData, useRepos } from "../data/DataContext";
import type { Role } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { memberRoleSummary, roleLabel } from "../lib/memberRole";
import type { MemberAdminView } from "../lib/tenantUsers";
import { useFormatApiError } from "../lib/apiErrors";
import { checkPasswordPolicy, isPasswordPolicyOk } from "../lib/passwordPolicy";
import { formatLocalDateTime, nowUnixNano } from "../lib/time";
import { PLANS, type PlanId, type UsageMemberPrefs } from "../lib/usageMeter";
const PAGE_SIZE_OPTIONS = [5, 10, 20];
/** 最近揭示的臨時密碼只保留在記憶體,不寫入瀏覽器儲存空間。 */
type RevealedTempPassword = {
uid: string;
display_name: string;
email: string;
password: string;
created_at: number;
reason?: "create" | "reset";
};
/**
* 管理員島民列表搜尋名稱uid+ 新增、停權、權限、驗證、重設密碼。
*/
export function AdminUsersPage() {
const repos = useRepos();
const { member, reload } = useAuth();
const { t } = useI18n();
const formatApiError = useFormatApiError();
const { tick, refresh } = useData();
const [users, setUsers] = useState<MemberAdminView[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const [query, setQuery] = useState("");
const [queryDraft, setQueryDraft] = useState("");
const [selected, setSelected] = useState<MemberAdminView | null>(null);
const [roleDraft, setRoleDraft] = useState<Role[]>(["member"]);
const [busy, setBusy] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [revealed, setRevealed] = useState<RevealedTempPassword | null>(null);
const [copyHint, setCopyHint] = useState("");
const [customPw, setCustomPw] = useState("");
const [showCreate, setShowCreate] = useState(false);
const [createName, setCreateName] = useState("");
const [createEmail, setCreateEmail] = useState("");
const [createPassword, setCreatePassword] = useState("");
const [createVerified, setCreateVerified] = useState(true);
const [createAsAdmin, setCreateAsAdmin] = useState(false);
/** 選中島民的方案/不擋額度 */
const [usagePrefs, setUsagePrefs] = useState<UsageMemberPrefs | null>(null);
const isAdmin = memberRoleSummary(member, t).isAdmin;
useEffect(() => {
if (!isAdmin) return;
void (async () => {
try {
const res = await repos.adminUsers.listUsersPage({
page,
pageSize,
query,
});
setUsers(res.list);
setTotal(res.total);
setPage(res.page);
} catch (e) {
setError(formatApiError(e, "admin.users.loadFail"));
}
})();
}, [repos.adminUsers, tick, isAdmin, page, pageSize, query, formatApiError]);
const selectedUid = selected?.uid ?? "";
const selectedRolesKey = selected?.roles?.slice().sort().join(",") ?? "";
useEffect(() => {
if (!selectedUid || !selected) return;
setRoleDraft(selected.roles.includes("admin") ? ["member", "admin"] : ["member"]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedRolesKey tracks role changes
}, [selectedUid, selectedRolesKey]);
useEffect(() => {
if (!selectedUid || !isAdmin) {
setUsagePrefs(null);
return;
}
void repos.usage.getMemberPrefs(selectedUid).then(setUsagePrefs).catch(() => setUsagePrefs(null));
}, [selectedUid, isAdmin, repos.usage, tick]);
// 路由已包 RequireAdmin此處僅防守
if (!member) return <Navigate to="/login" replace />;
if (!isAdmin) return <Navigate to="/app/today" replace />;
async function reloadList() {
const res = await repos.adminUsers.listUsersPage({ page, pageSize, query });
setUsers(res.list);
setTotal(res.total);
setPage(res.page);
if (selected) {
const u = await repos.adminUsers.getUser(selected.uid);
setSelected(u);
}
refresh();
}
async function createMember() {
setBusy("create");
setMessage("");
setError("");
setCopyHint("");
const pw = createPassword.trim();
if (pw) {
const policy = checkPasswordPolicy(pw);
if (!policy.ok) {
setError(t(policy.errorKey || "password.policy.hint"));
setBusy("");
return;
}
}
try {
const res = await repos.adminUsers.createMember({
display_name: createName,
email: createEmail,
password: pw || undefined,
roles: createAsAdmin ? ["member", "admin"] : ["member"],
email_verified: createVerified,
});
const rec: RevealedTempPassword = {
uid: res.user.uid,
display_name: res.user.display_name,
email: res.user.email,
password: res.temporary_password,
created_at: nowUnixNano(),
reason: "create",
};
setRevealed(rec);
setCreateName("");
setCreateEmail("");
setCreatePassword("");
setCreateVerified(true);
setCreateAsAdmin(false);
setShowCreate(false);
setSelected(res.user);
setQuery("");
setQueryDraft("");
setPage(1);
setMessage(t("admin.users.created", { name: res.user.display_name }));
await reloadList();
} catch (e) {
setError(formatApiError(e, "admin.users.createFail"));
} finally {
setBusy("");
}
}
async function saveUsageUnlimited(unlimited: boolean) {
if (!selected) return;
setBusy(`usage:${selected.uid}`);
setMessage("");
setError("");
try {
const next = await repos.usage.setMemberPrefs(selected.uid, { unlimited });
setUsagePrefs(next);
setMessage(
unlimited
? t("admin.users.unlimitedOn", { name: selected.display_name })
: t("admin.users.unlimitedOff", { name: selected.display_name }),
);
} catch (e) {
setError(formatApiError(e, "admin.users.updateFail"));
} finally {
setBusy("");
}
}
async function saveUsagePlan(plan_id: PlanId) {
if (!selected) return;
setBusy(`usage:${selected.uid}`);
setMessage("");
setError("");
try {
const next = await repos.usage.setMemberPrefs(selected.uid, { plan_id });
setUsagePrefs(next);
setMessage(t("admin.users.planSet", { name: selected.display_name, plan: PLANS[plan_id].name }));
} catch (e) {
setError(formatApiError(e, "admin.users.updateFail"));
} finally {
setBusy("");
}
}
async function toggleSuspended(u: MemberAdminView) {
const willSuspend = u.status !== "suspended";
if (
!window.confirm(
willSuspend
? t("admin.users.confirmSuspend", { name: u.display_name })
: t("admin.users.confirmUnsuspend", { name: u.display_name }),
)
) {
return;
}
setBusy(`suspend:${u.uid}`);
setMessage("");
setError("");
try {
const next = await repos.adminUsers.setSuspended(u.uid, willSuspend);
setMessage(
willSuspend
? t("admin.users.didSuspend", { name: next.display_name })
: t("admin.users.didUnsuspend", { name: next.display_name }),
);
setSelected(next);
await reloadList();
} catch (e) {
setError(
formatApiError(
e,
willSuspend ? "admin.users.suspendFail" : "admin.users.unsuspendFail",
),
);
} finally {
setBusy("");
}
}
async function toggleVerified(u: MemberAdminView) {
setBusy(`verify:${u.uid}`);
setMessage("");
setError("");
try {
const next = await repos.adminUsers.setEmailVerified(u.uid, !u.email_verified);
setMessage(
next.email_verified
? t("admin.users.markedVerified", { name: next.display_name })
: t("admin.users.markedUnverified", { name: next.display_name }),
);
if (member?.uid === u.uid) await reload();
await reloadList();
} catch (e) {
setError(formatApiError(e, "admin.users.updateFail"));
} finally {
setBusy("");
}
}
async function saveRoles(u: MemberAdminView) {
setBusy(`roles:${u.uid}`);
setMessage("");
setError("");
try {
const next = await repos.adminUsers.setRoles(u.uid, roleDraft);
setMessage(
t("admin.users.rolesUpdated", {
name: next.display_name,
roles: next.roles.map((r) => roleLabel(r, t)).join(t("common.listSep")),
}),
);
if (member?.uid === u.uid) await reload();
await reloadList();
} catch (e) {
setError(formatApiError(e, "admin.users.rolesFail"));
} finally {
setBusy("");
}
}
async function doReset(u: MemberAdminView, useCustom: boolean) {
if (
!window.confirm(t("admin.users.confirmReset", { name: u.display_name }))
) {
return;
}
if (useCustom) {
const policy = checkPasswordPolicy(customPw.trim());
if (!policy.ok) {
setError(t(policy.errorKey || "password.policy.hint"));
return;
}
}
setBusy(`reset:${u.uid}`);
setMessage("");
setError("");
setCopyHint("");
try {
const res = await repos.adminUsers.resetPassword(
u.uid,
useCustom ? customPw.trim() || undefined : undefined,
);
const rec: RevealedTempPassword = {
uid: res.user.uid,
display_name: res.user.display_name,
email: res.user.email,
password: res.temporary_password,
created_at: nowUnixNano(),
reason: "reset",
};
setRevealed(rec);
setCustomPw("");
setMessage(t("admin.users.resetDone", { name: res.user.display_name }));
await reloadList();
} catch (e) {
setError(formatApiError(e, "admin.users.resetFail"));
} finally {
setBusy("");
}
}
async function copyTempPassword() {
if (!revealed?.password) return;
try {
await navigator.clipboard.writeText(revealed.password);
setCopyHint(t("admin.users.copied"));
window.setTimeout(() => setCopyHint(""), 2500);
} catch {
try {
const ta = document.createElement("textarea");
ta.value = revealed.password;
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
setCopyHint(t("admin.users.copied"));
window.setTimeout(() => setCopyHint(""), 2500);
} catch {
setCopyHint(t("admin.users.copyFail"));
}
}
}
function dismissTempPassword() {
if (
!window.confirm(t("admin.users.confirmDismissTemp"))
) {
return;
}
setRevealed(null);
setCopyHint("");
}
function applySearch() {
setPage(1);
setQuery(queryDraft.trim());
}
const isSuspended = selected?.status === "suspended";
return (
<>
<PageHeader title={t("admin.users.title")} />
{message ? (
<p className="hb-banner-ok" role="status">
{message}
</p>
) : null}
{error ? (
<p className="hb-form-error" role="alert">
{error}
</p>
) : null}
{revealed ? (
<div className="hb-admin-temp-pw" role="status">
<div className="hb-admin-temp-pw__head">
<p className="hb-field__label" style={{ margin: 0 }}>
{revealed.reason === "create" ? t("admin.users.tempPwNew") : t("admin.users.tempPw")}
{t("admin.users.tempPwPersist")}
</p>
<span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{revealed.display_name} · {revealed.email}
{" · "}
{formatLocalDateTime(revealed.created_at)}
</span>
</div>
<div className="hb-admin-temp-pw__row">
<code className="hb-admin-temp-pw__code" id="hb-admin-temp-pw-value">
{revealed.password}
</code>
<div className="hb-admin-temp-pw__actions">
<Button type="button" onClick={() => void copyTempPassword()}>
{t("admin.users.copyPw")}
</Button>
<Button type="button" variant="ghost" onClick={dismissTempPassword}>
{t("admin.users.close")}
</Button>
</div>
</div>
{copyHint ? (
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
<strong style={{ color: "var(--hb-brand)" }}>{copyHint}</strong>
</p>
) : null}
</div>
) : null}
<div className="hb-admin-create-bar">
<Button
type="button"
variant={showCreate ? "ghost" : "primary"}
onClick={() => {
setShowCreate((v) => !v);
setError("");
}}
>
{showCreate ? t("common.cancel") : t("admin.users.create")}
</Button>
</div>
{showCreate ? (
<Card title={t("admin.users.createTitle")}>
<div className="hb-admin-create-form">
<Input
label={t("admin.users.memberName")}
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder={t("admin.users.displayNamePh")}
autoComplete="off"
/>
<Input
label={t("admin.users.email")}
type="email"
value={createEmail}
onChange={(e) => setCreateEmail(e.target.value)}
placeholder="name@example.com"
autoComplete="off"
/>
<Input
label={t("admin.users.initPassword")}
type="password"
value={createPassword}
onChange={(e) => setCreatePassword(e.target.value)}
placeholder={t("admin.users.initPasswordPh")}
autoComplete="new-password"
/>
<div className="hb-admin-create-checks">
<label className="hb-check-row">
<input
type="checkbox"
checked={createVerified}
onChange={(e) => setCreateVerified(e.target.checked)}
/>
<span>{t("admin.users.markVerifiedCheck")}</span>
</label>
<label className="hb-check-row">
<input
type="checkbox"
checked={createAsAdmin}
onChange={(e) => setCreateAsAdmin(e.target.checked)}
/>
<span>{t("admin.users.alsoAdmin")}</span>
</label>
</div>
<div className="hb-admin-actions">
<Button
type="button"
disabled={Boolean(busy) || !createName.trim() || !createEmail.trim()}
onClick={() => void createMember()}
>
{busy === "create" ? t("admin.users.creating") : t("admin.users.createSubmit")}
</Button>
</div>
</div>
</Card>
) : null}
<div className="hb-admin-users">
<Card title={t("admin.users.list", { n: total })}>
<div className="hb-stack">
<div className="hb-admin-search">
<Input
label={t("admin.users.search")}
value={queryDraft}
onChange={(e) => setQueryDraft(e.target.value)}
placeholder={t("admin.users.searchPh")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applySearch();
}
}}
/>
<Button type="button" onClick={applySearch}>
{t("common.search")}
</Button>
{query ? (
<Button
type="button"
variant="ghost"
onClick={() => {
setQueryDraft("");
setQuery("");
setPage(1);
}}
>
{t("admin.users.clear")}
</Button>
) : null}
</div>
{query ? (
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
{t("admin.users.searchActive", { query })}
</p>
) : null}
{users.length === 0 ? (
<EmptyState title={query ? t("admin.users.noMatch") : t("admin.users.none")} />
) : (
<ul className="hb-admin-user-list">
{users.map((u) => {
const role = memberRoleSummary(u, t);
const active = selected?.uid === u.uid;
const suspended = u.status === "suspended";
return (
<li key={u.uid}>
<button
type="button"
className={`hb-admin-user-row${active ? " is-active" : ""}${suspended ? " is-suspended" : ""}`}
onClick={() => {
setSelected(u);
setError("");
}}
>
<div className="hb-admin-user-row__main">
<strong>{u.display_name}</strong>
<span className="text-muted">{u.email}</span>
<span className="hb-admin-user-row__uid text-muted">
{u.invite_code ? `${u.invite_code} · ` : ""}
uid · {u.uid}
</span>
</div>
<div className="hb-admin-user-row__tags">
{suspended ? (
<Badge tone="danger">{t("admin.users.suspended")}</Badge>
) : null}
<Badge tone={role.isAdmin ? "brand" : "neutral"}>
{role.primaryLabel}
</Badge>
<Badge tone={u.email_verified ? "success" : "warning"}>
{u.email_verified ? t("role.verified") : t("role.unverified")}
</Badge>
</div>
</button>
</li>
);
})}
</ul>
)}
<Pager
total={total}
page={page}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={setPageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
showWhenSingle
/>
</div>
</Card>
<Card title={t("admin.users.detail")}>
{!selected ? (
<p className="text-muted" style={{ margin: 0 }}>
{t("admin.users.pick")}
</p>
) : (
<div className="hb-stack">
<div className="hb-admin-detail-head">
<div>
<h2 className="hb-admin-detail-name">{selected.display_name}</h2>
<p className="text-muted" style={{ margin: "0.2rem 0 0" }}>
{selected.email}
</p>
<p style={{ margin: "0.35rem 0 0" }}>
<Link to="/app/invite" className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
{t("invite.openFromAdmin")}
</Link>
</p>
</div>
<div className="hb-inline-badges">
{selected.uid === member.uid ? <Badge tone="brand">{t("admin.users.you")}</Badge> : null}
{isSuspended ? (
<Badge tone="danger">{t("admin.users.suspended")}</Badge>
) : (
<Badge tone="success">{t("admin.users.active")}</Badge>
)}
</div>
</div>
<dl className="hb-admin-dl">
<div>
<dt>{t("admin.users.status")}</dt>
<dd>{isSuspended ? t("admin.users.suspended") : t("admin.users.active")}</dd>
</div>
<div>
<dt>{t("admin.users.role")}</dt>
<dd>{selected.roles.map((r) => roleLabel(r, t)).join(t("common.listSep"))}</dd>
</div>
<div>
<dt>{t("invite.codeLabel")}</dt>
<dd>
<code className="hb-admin-invite-line">
{selected.invite_code || t("common.dash")}
</code>
</dd>
</div>
<div>
<dt>{t("invite.parentField")}</dt>
<dd>
{selected.parent_uid ? (
<code>{selected.parent_uid}</code>
) : (
t("invite.root")
)}
</dd>
</div>
<div>
<dt>{t("admin.users.emailVerify")}</dt>
<dd>
{selected.email_verified ? t("role.verified") : t("role.unverified")}
</dd>
</div>
<div>
<dt>{t("admin.users.bio")}</dt>
<dd>{selected.bio?.trim() || t("common.dash")}</dd>
</div>
<div>
<dt>{t("admin.users.timezone")}</dt>
<dd>{selected.timezone || t("common.dash")}</dd>
</div>
<div>
<dt>{t("admin.users.notifyEmail")}</dt>
<dd>{selected.notify_email ? t("admin.users.on") : t("admin.users.off")}</dd>
</div>
<div>
<dt>uid</dt>
<dd>
<code>{selected.uid}</code>
</dd>
</div>
<div>
<dt>{t("admin.users.createdAt")}</dt>
<dd>{formatLocalDateTime(selected.created_at)}</dd>
</div>
<div>
<dt>{t("admin.users.updatedAt")}</dt>
<dd>{formatLocalDateTime(selected.updated_at)}</dd>
</div>
</dl>
{/* 停權 */}
<div className="hb-admin-suspend">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("admin.users.accountStatus")}
</p>
<Button
type="button"
variant={isSuspended ? "secondary" : "danger"}
disabled={Boolean(busy) || selected.uid === member.uid}
onClick={() => void toggleSuspended(selected)}
>
{busy === `suspend:${selected.uid}`
? t("admin.users.updating")
: isSuspended
? t("admin.users.unsuspend")
: t("admin.users.suspend")}
</Button>
</div>
{/* 用量:方案 + 不擋額度live 後端 M3 才有) */}
<div className="hb-admin-usage">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("admin.users.usageTitle")}
</p>
{usagePrefs ? (
<>
<div className="hb-admin-usage__row">
<Select
label={t("admin.users.plan")}
value={usagePrefs.plan_id}
disabled={Boolean(busy) || isSuspended}
onChange={(e) => void saveUsagePlan(e.target.value as PlanId)}
>
{(Object.keys(PLANS) as PlanId[]).map((id) => (
<option key={id} value={id}>
{t("admin.users.planOption", {
name: PLANS[id].name,
credits: PLANS[id].monthly_credits,
})}
</option>
))}
</Select>
{usagePrefs.unlimited ? (
<Badge tone="brand">{t("admin.users.unlimited")}</Badge>
) : (
<Badge tone="neutral">{t("admin.users.byPlan")}</Badge>
)}
</div>
<Button
type="button"
variant={usagePrefs.unlimited ? "secondary" : "primary"}
disabled={Boolean(busy) || isSuspended}
onClick={() => void saveUsageUnlimited(!usagePrefs.unlimited)}
>
{busy === `usage:${selected.uid}`
? t("admin.users.updating")
: usagePrefs.unlimited
? t("admin.users.setLimited")
: t("admin.users.setUnlimited")}
</Button>
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
{t("admin.users.unlimitedHint")}
</p>
</>
) : (
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("admin.users.loadingUsage")}
</p>
)}
</div>
{/* 權限 */}
<div className="hb-admin-roles">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("admin.users.assignRoles")}
</p>
<label className="hb-check-row">
<input
type="checkbox"
checked={roleDraft.includes("member")}
disabled
readOnly
/>
<span>{t("admin.users.memberBase", { role: t("role.member") })}</span>
</label>
<label className="hb-check-row">
<input
type="checkbox"
checked={roleDraft.includes("admin")}
onChange={(e) => {
if (e.target.checked) {
setRoleDraft(["member", "admin"]);
} else {
setRoleDraft(["member"]);
}
}}
/>
<span>{t("admin.users.adminDesc", { role: t("role.admin") })}</span>
</label>
<Button
type="button"
disabled={Boolean(busy) || isSuspended}
onClick={() => void saveRoles(selected)}
>
{busy === `roles:${selected.uid}` ? t("admin.users.saving") : t("admin.users.saveRoles")}
</Button>
</div>
<div className="hb-admin-actions">
<Button
type="button"
variant={selected.email_verified ? "ghost" : "primary"}
disabled={Boolean(busy)}
onClick={() => void toggleVerified(selected)}
>
{busy === `verify:${selected.uid}`
? t("admin.users.updating")
: selected.email_verified
? t("admin.users.markUnverifiedBtn")
: t("admin.users.markVerifiedBtn")}
</Button>
<Button
type="button"
variant="danger"
disabled={Boolean(busy) || isSuspended}
onClick={() => void doReset(selected, false)}
>
{busy === `reset:${selected.uid}` ? t("admin.users.resetting") : t("admin.users.resetTemp")}
</Button>
</div>
<div className="hb-admin-custom-pw">
<Input
label={t("admin.users.customPw")}
type="password"
value={customPw}
onChange={(e) => setCustomPw(e.target.value)}
placeholder={t("admin.users.customPwPh")}
disabled={isSuspended}
/>
<Button
type="button"
variant="ghost"
disabled={Boolean(busy) || isSuspended || !isPasswordPolicyOk(customPw.trim())}
onClick={() => void doReset(selected, true)}
>
{t("admin.users.resetWithCustom")}
</Button>
</div>
</div>
)}
</Card>
</div>
</>
);
}