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

834 lines
29 KiB
TypeScript
Raw Normal View History

2026-07-10 05:10:31 +00:00
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 { KEYS } from "../data/mock/keys";
import type { Role } from "../domain/types";
import { useI18n } from "../i18n/I18nContext";
import { memberRoleSummary, roleLabel } from "../lib/memberRole";
import { readJson, writeJson } from "../lib/storage";
import type { MemberAdminView } from "../lib/tenantUsers";
2026-07-10 12:54:45 +00:00
import { useFormatApiError } from "../lib/apiErrors";
import { checkPasswordPolicy, isPasswordPolicyOk } from "../lib/passwordPolicy";
2026-07-10 05:10:31 +00:00
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";
};
function loadRevealedTemp(): RevealedTempPassword | null {
return readJson<RevealedTempPassword | null>(KEYS.adminTempPassword, null);
}
function saveRevealedTemp(rec: RevealedTempPassword | null): void {
writeJson(KEYS.adminTempPassword, rec);
}
/**
* uid+
*/
export function AdminUsersPage() {
const repos = useRepos();
const { member, reload } = useAuth();
const { t } = useI18n();
2026-07-10 12:54:45 +00:00
const formatApiError = useFormatApiError();
2026-07-13 01:15:30 +00:00
const { tick, refresh } = useData();
const isLive = true;
2026-07-10 05:10:31 +00:00
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>(() => loadRevealedTemp());
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) {
2026-07-10 12:54:45 +00:00
setError(formatApiError(e, "admin.users.loadFail"));
2026-07-10 05:10:31 +00:00
}
})();
}, [repos.adminUsers, tick, isAdmin, page, pageSize, query]);
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(() => {
2026-07-10 12:54:45 +00:00
if (!selectedUid || !isAdmin || isLive) {
2026-07-10 05:10:31 +00:00
setUsagePrefs(null);
return;
}
void repos.usage.getMemberPrefs(selectedUid).then(setUsagePrefs).catch(() => setUsagePrefs(null));
2026-07-10 12:54:45 +00:00
}, [selectedUid, isAdmin, isLive, repos.usage, tick]);
2026-07-10 05:10:31 +00:00
2026-07-10 12:54:45 +00:00
// 路由已包 RequireAdmin此處僅防守
2026-07-10 05:10:31 +00:00
if (!member) return <Navigate to="/login" replace />;
2026-07-10 12:54:45 +00:00
if (!isAdmin) return <Navigate to="/app/today" replace />;
2026-07-10 05:10:31 +00:00
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("");
2026-07-10 12:54:45 +00:00
const pw = createPassword.trim();
if (pw) {
const policy = checkPasswordPolicy(pw);
if (!policy.ok) {
setError(t(policy.errorKey || "password.policy.hint"));
setBusy("");
return;
}
}
2026-07-10 05:10:31 +00:00
try {
const res = await repos.adminUsers.createMember({
display_name: createName,
email: createEmail,
2026-07-10 12:54:45 +00:00
password: pw || undefined,
2026-07-10 05:10:31 +00:00
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",
};
saveRevealedTemp(rec);
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) {
2026-07-10 12:54:45 +00:00
setError(formatApiError(e, "admin.users.createFail"));
2026-07-10 05:10:31 +00:00
} 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) {
2026-07-10 12:54:45 +00:00
setError(formatApiError(e, "admin.users.updateFail"));
2026-07-10 05:10:31 +00:00
} 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) {
2026-07-10 12:54:45 +00:00
setError(formatApiError(e, "admin.users.updateFail"));
2026-07-10 05:10:31 +00:00
} 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(
2026-07-10 12:54:45 +00:00
formatApiError(
e,
willSuspend ? "admin.users.suspendFail" : "admin.users.unsuspendFail",
),
2026-07-10 05:10:31 +00:00
);
} 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) {
2026-07-10 12:54:45 +00:00
setError(formatApiError(e, "admin.users.updateFail"));
2026-07-10 05:10:31 +00:00
} 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) {
2026-07-10 12:54:45 +00:00
setError(formatApiError(e, "admin.users.rolesFail"));
2026-07-10 05:10:31 +00:00
} finally {
setBusy("");
}
}
async function doReset(u: MemberAdminView, useCustom: boolean) {
if (
!window.confirm(t("admin.users.confirmReset", { name: u.display_name }))
) {
return;
}
2026-07-10 12:54:45 +00:00
if (useCustom) {
const policy = checkPasswordPolicy(customPw.trim());
if (!policy.ok) {
setError(t(policy.errorKey || "password.policy.hint"));
return;
}
}
2026-07-10 05:10:31 +00:00
setBusy(`reset:${u.uid}`);
setMessage("");
setError("");
setCopyHint("");
try {
const res = await repos.adminUsers.resetPassword(
u.uid,
2026-07-10 12:54:45 +00:00
useCustom ? customPw.trim() || undefined : undefined,
2026-07-10 05:10:31 +00:00
);
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",
};
saveRevealedTemp(rec);
setRevealed(rec);
setCustomPw("");
setMessage(t("admin.users.resetDone", { name: res.user.display_name }));
await reloadList();
} catch (e) {
2026-07-10 12:54:45 +00:00
setError(formatApiError(e, "admin.users.resetFail"));
2026-07-10 05:10:31 +00:00
} 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;
}
saveRevealedTemp(null);
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: "0.78rem" }}>
{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: "0.8rem" }}>
<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")}
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: "0.8rem" }}>
{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">
2026-07-10 12:54:45 +00:00
{u.invite_code ? `${u.invite_code} · ` : ""}
2026-07-10 05:10:31 +00:00
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>
2026-07-10 12:54:45 +00:00
<p style={{ margin: "0.35rem 0 0" }}>
<Link to="/app/invite" className="text-muted" style={{ fontSize: "0.82rem" }}>
{t("invite.openFromAdmin")}
</Link>
</p>
2026-07-10 05:10:31 +00:00
</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>
2026-07-10 12:54:45 +00:00
<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>
2026-07-10 05:10:31 +00:00
<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>
2026-07-10 12:54:45 +00:00
{/* 用量:方案 + 不擋額度live 後端 M3 才有) */}
2026-07-10 05:10:31 +00:00
<div className="hb-admin-usage">
<p className="hb-field__label" style={{ margin: 0 }}>
{t("admin.users.usageTitle")}
</p>
2026-07-10 12:54:45 +00:00
{isLive ? (
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-sm)" }}>
{t("admin.users.usageLiveSkip")}
</p>
) : usagePrefs ? (
2026-07-10 05:10:31 +00:00
<>
<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="text"
value={customPw}
onChange={(e) => setCustomPw(e.target.value)}
placeholder={t("admin.users.customPwPh")}
disabled={isSuspended}
/>
<Button
type="button"
variant="ghost"
2026-07-10 12:54:45 +00:00
disabled={Boolean(busy) || isSuspended || !isPasswordPolicyOk(customPw.trim())}
2026-07-10 05:10:31 +00:00
onClick={() => void doReset(selected, true)}
>
{t("admin.users.resetWithCustom")}
</Button>
</div>
</div>
)}
</Card>
</div>
</>
);
}