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 type { Role } from "../domain/types";
|
|
|
|
|
|
import { useI18n } from "../i18n/I18nContext";
|
|
|
|
|
|
import { memberRoleSummary, roleLabel } from "../lib/memberRole";
|
|
|
|
|
|
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];
|
|
|
|
|
|
|
2026-07-15 15:23:59 +00:00
|
|
|
|
/** 最近揭示的臨時密碼只保留在記憶體,不寫入瀏覽器儲存空間。 */
|
2026-07-10 05:10:31 +00:00
|
|
|
|
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();
|
2026-07-10 12:54:45 +00:00
|
|
|
|
const formatApiError = useFormatApiError();
|
2026-07-13 01:15:30 +00:00
|
|
|
|
const { tick, refresh } = useData();
|
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("");
|
2026-07-15 15:23:59 +00:00
|
|
|
|
const [revealed, setRevealed] = useState<RevealedTempPassword | null>(null);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
})();
|
2026-07-15 15:23:59 +00:00
|
|
|
|
}, [repos.adminUsers, tick, isAdmin, page, pageSize, query, formatApiError]);
|
2026-07-10 05:10:31 +00:00
|
|
|
|
|
|
|
|
|
|
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-15 15:23:59 +00:00
|
|
|
|
if (!selectedUid || !isAdmin) {
|
2026-07-10 05:10:31 +00:00
|
|
|
|
setUsagePrefs(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
void repos.usage.getMemberPrefs(selectedUid).then(setUsagePrefs).catch(() => setUsagePrefs(null));
|
2026-07-15 15:23:59 +00:00
|
|
|
|
}, [selectedUid, isAdmin, 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",
|
|
|
|
|
|
};
|
|
|
|
|
|
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",
|
|
|
|
|
|
};
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
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>
|
2026-07-30 01:25:34 +00:00
|
|
|
|
<span className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
|
2026-07-10 05:10:31 +00:00
|
|
|
|
{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 ? (
|
2026-07-30 01:25:34 +00:00
|
|
|
|
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
|
2026-07-10 05:10:31 +00:00
|
|
|
|
<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")}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
type="password"
|
2026-07-10 05:10:31 +00:00
|
|
|
|
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 ? (
|
2026-07-30 01:25:34 +00:00
|
|
|
|
<p className="text-muted" style={{ margin: 0, fontSize: "var(--hb-text-xs)" }}>
|
2026-07-10 05:10:31 +00:00
|
|
|
|
{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" }}>
|
2026-07-30 01:25:34 +00:00
|
|
|
|
<Link to="/app/invite" className="text-muted" style={{ fontSize: "var(--hb-text-xs)" }}>
|
2026-07-10 12:54:45 +00:00
|
|
|
|
{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-15 15:23:59 +00:00
|
|
|
|
{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")}
|
2026-07-15 15:23:59 +00:00
|
|
|
|
type="password"
|
2026-07-10 05:10:31 +00:00
|
|
|
|
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>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|