调整 AdminShell 组件的子组件顺序,提升代码可读性。更新 admin-breadcrumb 组件,简化导航标签翻译逻辑,确保多语言支持的一致性。重构 admin-language-switcher 组件,优化语言切换的用户体验,增强界面交互性。更新多语言配置,新增登录界面的副标题,提升用户体验。
702 lines
27 KiB
TypeScript
702 lines
27 KiB
TypeScript
"use client";
|
|
|
|
import { KeyRound, Pencil, Trash2 } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useConfirmAction } from "@/hooks/use-confirm-action";
|
|
import { useExportLabels } from "@/hooks/use-export-labels";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
deleteAdminUser,
|
|
getAdminUserPermissionCatalog,
|
|
getAdminUsers,
|
|
postAdminUser,
|
|
putAdminUser,
|
|
putAdminUserRoles,
|
|
} from "@/api/admin-users";
|
|
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
|
import { AdminRowActionsMenu } from "@/components/admin/admin-row-actions-menu";
|
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { resolveAdminUserStatusTone } from "@/lib/admin-status-tone";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { adminHasAnyPermission } from "@/lib/admin-permissions";
|
|
import { PRD_ADMIN_USER_MANAGE } from "@/lib/admin-prd";
|
|
import { cn } from "@/lib/utils";
|
|
import { useAdminProfile } from "@/stores/admin-session";
|
|
import type { AdminPermissionCatalogData, AdminUserPermissionRow } from "@/types/api/index";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
|
|
export function AdminUsersConsole(): React.ReactElement {
|
|
const { t } = useTranslation(["adminUsers", "common"]);
|
|
const { request: requestConfirm, ConfirmDialog } = useConfirmAction();
|
|
const exportLabels = useExportLabels("adminUsers");
|
|
const profile = useAdminProfile();
|
|
const canManageUsers = adminHasAnyPermission(profile?.permissions, [PRD_ADMIN_USER_MANAGE]);
|
|
const [page, setPage] = useState(1);
|
|
const [perPage, setPerPage] = useState(10);
|
|
const [keyword, setKeyword] = useState("");
|
|
const [query, setQuery] = useState("");
|
|
|
|
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
|
const [items, setItems] = useState<AdminUserPermissionRow[]>([]);
|
|
const [total, setTotal] = useState(0);
|
|
const [lastPage, setLastPage] = useState(1);
|
|
const [loading, setLoading] = useState(true);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
|
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
|
const [draftRoles, setDraftRoles] = useState<string[]>([]);
|
|
const [savingRoles, setSavingRoles] = useState(false);
|
|
const [permissionOpen, setPermissionOpen] = useState(false);
|
|
|
|
const [accountOpen, setAccountOpen] = useState(false);
|
|
const [accountMode, setAccountMode] = useState<"create" | "edit">("create");
|
|
const [accountSaving, setAccountSaving] = useState(false);
|
|
const [editingAccountId, setEditingAccountId] = useState<number | null>(null);
|
|
const [formUsername, setFormUsername] = useState("");
|
|
const [formNickname, setFormNickname] = useState("");
|
|
const [formEmail, setFormEmail] = useState("");
|
|
const [formPassword, setFormPassword] = useState("");
|
|
const [formStatus, setFormStatus] = useState(0);
|
|
const [formCreateRoles, setFormCreateRoles] = useState<string[]>([]);
|
|
|
|
const [deleteTarget, setDeleteTarget] = useState<AdminUserPermissionRow | null>(null);
|
|
const [deleteBusy, setDeleteBusy] = useState(false);
|
|
|
|
const selectedUser = useMemo(
|
|
() => items.find((u) => u.id === selectedId) ?? null,
|
|
[items, selectedId],
|
|
);
|
|
const roleNameBySlug = useMemo(
|
|
() => new Map((catalog?.roles ?? []).map((role) => [role.slug, role.name])),
|
|
[catalog],
|
|
);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setErr(null);
|
|
try {
|
|
const [catalogData, listData] = await Promise.all([
|
|
getAdminUserPermissionCatalog(),
|
|
getAdminUsers({
|
|
page,
|
|
per_page: perPage,
|
|
keyword: query.trim() || undefined,
|
|
}),
|
|
]);
|
|
setCatalog(catalogData);
|
|
setItems(listData.items);
|
|
setTotal(listData.meta.total);
|
|
setLastPage(Math.max(1, listData.meta.last_page));
|
|
} catch (e) {
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("loadFailed");
|
|
setErr(msg);
|
|
setItems([]);
|
|
setTotal(0);
|
|
setLastPage(1);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [page, perPage, query, t]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void load();
|
|
});
|
|
}, [load]);
|
|
|
|
function toggleFormCreateRole(slug: string, checked: boolean): void {
|
|
setFormCreateRoles((prev) => {
|
|
if (checked) {
|
|
return Array.from(new Set([...prev, slug])).sort();
|
|
}
|
|
return prev.filter((value) => value !== slug);
|
|
});
|
|
}
|
|
|
|
function toggleRole(slug: string, checked: boolean): void {
|
|
setDraftRoles((prev) => {
|
|
if (checked) {
|
|
return Array.from(new Set([...prev, slug])).sort();
|
|
}
|
|
return prev.filter((value) => value !== slug);
|
|
});
|
|
}
|
|
|
|
function openPermissionEditor(row: AdminUserPermissionRow): void {
|
|
setSelectedId(row.id);
|
|
setDraftRoles([...row.roles].sort());
|
|
setPermissionOpen(true);
|
|
}
|
|
|
|
function handlePermissionDialogOpenChange(open: boolean): void {
|
|
setPermissionOpen(open);
|
|
if (!open) {
|
|
setSelectedId(null);
|
|
}
|
|
}
|
|
|
|
function openCreateAccount(): void {
|
|
setAccountMode("create");
|
|
setEditingAccountId(null);
|
|
setFormUsername("");
|
|
setFormNickname("");
|
|
setFormEmail("");
|
|
setFormPassword("");
|
|
setFormStatus(0);
|
|
setFormCreateRoles([]);
|
|
setAccountOpen(true);
|
|
}
|
|
|
|
function openEditAccount(row: AdminUserPermissionRow): void {
|
|
setAccountMode("edit");
|
|
setEditingAccountId(row.id);
|
|
setFormUsername(row.username);
|
|
setFormNickname(row.nickname);
|
|
setFormEmail(row.email ?? "");
|
|
setFormPassword("");
|
|
setFormStatus(row.status);
|
|
setAccountOpen(true);
|
|
}
|
|
|
|
function handleAccountDialogOpenChange(open: boolean): void {
|
|
setAccountOpen(open);
|
|
if (!open) {
|
|
setEditingAccountId(null);
|
|
}
|
|
}
|
|
|
|
async function submitAccount(): Promise<void> {
|
|
const nickname = formNickname.trim();
|
|
if (nickname === "") {
|
|
toast.error(t("nicknameRequired"));
|
|
return;
|
|
}
|
|
if (accountMode === "edit" && formPassword !== "" && formPassword.length < 8) {
|
|
toast.error(t("newPasswordMin"));
|
|
return;
|
|
}
|
|
if (accountMode === "create" && formCreateRoles.length === 0) {
|
|
toast.error(t("roleRequired"));
|
|
return;
|
|
}
|
|
|
|
setAccountSaving(true);
|
|
try {
|
|
if (accountMode === "create") {
|
|
const username = formUsername.trim();
|
|
if (username === "") {
|
|
toast.error(t("usernameRequired"));
|
|
return;
|
|
}
|
|
if (formPassword.length < 8) {
|
|
toast.error(t("passwordMin"));
|
|
return;
|
|
}
|
|
const created = await postAdminUser({
|
|
username: username.toLowerCase(),
|
|
nickname,
|
|
email: formEmail.trim() === "" ? null : formEmail.trim(),
|
|
password: formPassword,
|
|
status: formStatus,
|
|
role_slugs: formCreateRoles,
|
|
});
|
|
setItems((prev) => [created, ...prev]);
|
|
setTotal((prev) => prev + 1);
|
|
toast.success(t("createSuccess", { name: created.username }));
|
|
handleAccountDialogOpenChange(false);
|
|
} else {
|
|
const id = editingAccountId;
|
|
if (id === null) {
|
|
return;
|
|
}
|
|
const body: {
|
|
nickname: string;
|
|
email: string | null;
|
|
status: number;
|
|
password?: string;
|
|
} = {
|
|
nickname,
|
|
email: formEmail.trim() === "" ? null : formEmail.trim(),
|
|
status: formStatus,
|
|
};
|
|
if (formPassword.trim() !== "") {
|
|
body.password = formPassword.trim();
|
|
}
|
|
const updated = await putAdminUser(id, body);
|
|
setItems((prev) => prev.map((row) => (row.id === updated.id ? updated : row)));
|
|
toast.success(t("updateSuccess", { name: updated.username }));
|
|
handleAccountDialogOpenChange(false);
|
|
}
|
|
} catch (e) {
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("saveAccountFailed");
|
|
toast.error(msg);
|
|
} finally {
|
|
setAccountSaving(false);
|
|
}
|
|
}
|
|
|
|
async function saveRoles(): Promise<void> {
|
|
if (!selectedUser) {
|
|
return;
|
|
}
|
|
setSavingRoles(true);
|
|
try {
|
|
const result = await putAdminUserRoles(selectedUser.id, draftRoles);
|
|
setDraftRoles([...result.roles].sort());
|
|
setItems((prev) =>
|
|
prev.map((row) =>
|
|
row.id === result.id
|
|
? {
|
|
...row,
|
|
roles: result.roles,
|
|
effective_permissions: result.effective_permissions,
|
|
}
|
|
: row,
|
|
),
|
|
);
|
|
toast.success(t("saveRoleSuccess", { name: result.username }));
|
|
} catch (e) {
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("saveRoleFailed");
|
|
toast.error(msg);
|
|
} finally {
|
|
setSavingRoles(false);
|
|
}
|
|
}
|
|
|
|
async function confirmDelete(): Promise<void> {
|
|
if (!deleteTarget) {
|
|
return;
|
|
}
|
|
setDeleteBusy(true);
|
|
try {
|
|
await deleteAdminUser(deleteTarget.id);
|
|
setItems((prev) => prev.filter((row) => row.id !== deleteTarget.id));
|
|
setTotal((prev) => Math.max(0, prev - 1));
|
|
toast.success(t("deleteSuccess", { name: deleteTarget.username }));
|
|
setDeleteTarget(null);
|
|
} catch (e) {
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("deleteFailed");
|
|
toast.error(msg);
|
|
} finally {
|
|
setDeleteBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex w-full max-w-none flex-col gap-6">
|
|
<Card className="admin-list-card">
|
|
<CardHeader className="admin-list-header flex flex-col gap-4">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
<CardTitle className="admin-list-title">{t("listTitle")}</CardTitle>
|
|
{canManageUsers ? (
|
|
<Button type="button" size="sm" onClick={() => openCreateAccount()}>
|
|
{t("createAdmin")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
<div className="admin-list-toolbar">
|
|
<div className="admin-list-field xl:min-w-0">
|
|
<Label htmlFor="admin-user-search" className="sm:w-20 sm:shrink-0">
|
|
{t("actions.search", { ns: "common" })}
|
|
</Label>
|
|
<Input
|
|
id="admin-user-search"
|
|
value={keyword}
|
|
className="w-full sm:w-[18rem] xl:w-[24rem]"
|
|
placeholder={t("searchPlaceholder")}
|
|
onChange={(e) => setKeyword(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
setPage(1);
|
|
setQuery(keyword.trim());
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="admin-list-actions">
|
|
<AdminTableExportButton
|
|
tableId="admin-users-table"
|
|
filename={exportLabels.filename}
|
|
sheetName={exportLabels.sheetName}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
setPage(1);
|
|
setQuery(keyword.trim());
|
|
}}
|
|
>
|
|
{t("actions.search", { ns: "common" })}
|
|
</Button>
|
|
<Button type="button" variant="secondary" onClick={() => void load()}>
|
|
{t("actions.refresh", { ns: "common" })}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="admin-list-content">
|
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
|
{loading && items.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
) : null}
|
|
<div className="admin-table-shell">
|
|
<Table id="admin-users-table">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
|
<TableHead>{t("table.account")}</TableHead>
|
|
<TableHead>{t("table.nickname")}</TableHead>
|
|
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
|
|
<TableHead>{t("table.roles")}</TableHead>
|
|
<TableHead>{t("table.effective")}</TableHead>
|
|
<TableHead className="w-14 whitespace-nowrap text-center">{t("table.actions")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={7} className="text-muted-foreground">
|
|
{t("states.noData", { ns: "common" })}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
items.map((row) => (
|
|
<TableRow key={row.id}>
|
|
<TableCell>{row.id}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{row.username}</span>
|
|
<span className="text-xs text-muted-foreground">{row.email ?? ""}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>{row.nickname ?? ""}</TableCell>
|
|
<TableCell>
|
|
<AdminStatusBadge status={row.status} tone={resolveAdminUserStatusTone(row.status)}>
|
|
{row.status === 0 ? t("status.enabled") : t("status.disabled")}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1">
|
|
{row.roles.length === 0 ? (
|
|
<span className="text-xs text-muted-foreground">{t("common.none")}</span>
|
|
) : (
|
|
row.roles.map((slug) => (
|
|
<Badge key={slug} variant="secondary">
|
|
{roleNameBySlug.get(slug) ?? slug}
|
|
</Badge>
|
|
))
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
|
|
<TableCell className="text-center">
|
|
{canManageUsers ? (
|
|
<AdminRowActionsMenu
|
|
actions={[
|
|
{
|
|
key: "permissions",
|
|
label: t("actions.permissions"),
|
|
icon: KeyRound,
|
|
onClick: () => openPermissionEditor(row),
|
|
},
|
|
{
|
|
key: "edit",
|
|
label: t("actions.edit"),
|
|
icon: Pencil,
|
|
onClick: () => openEditAccount(row),
|
|
},
|
|
{
|
|
key: "delete",
|
|
label: t("actions.delete"),
|
|
icon: Trash2,
|
|
destructive: true,
|
|
disabled: profile?.id === row.id,
|
|
onClick: () => setDeleteTarget(row),
|
|
},
|
|
]}
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">—</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
<AdminListPaginationFooter
|
|
selectId="admin-users-per-page"
|
|
total={total}
|
|
page={page}
|
|
lastPage={lastPage}
|
|
perPage={perPage}
|
|
loading={loading}
|
|
onPerPageChange={(value) => {
|
|
setPerPage(value);
|
|
setPage(1);
|
|
}}
|
|
onPageChange={setPage}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={permissionOpen} onOpenChange={handlePermissionDialogOpenChange}>
|
|
<DialogContent
|
|
showCloseButton
|
|
className="flex max-h-[90vh] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"
|
|
>
|
|
<DialogHeader className="shrink-0 space-y-1 border-b px-4 py-3 pr-12">
|
|
<DialogTitle>{t("permissionDialog.title")}</DialogTitle>
|
|
<DialogDescription>
|
|
{selectedUser ? (
|
|
<>
|
|
<span className="font-medium text-foreground">{selectedUser.username}</span>
|
|
<span className="text-muted-foreground"> · {selectedUser.nickname}</span>
|
|
</>
|
|
) : null}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4">
|
|
<div className="space-y-3">
|
|
<p className="text-xs text-muted-foreground">{t("permissionDialog.rolesDescription")}</p>
|
|
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
|
{(catalog?.roles ?? []).map((role) => {
|
|
const checked = draftRoles.includes(role.slug);
|
|
return (
|
|
<label key={role.slug} className="flex items-start gap-2 text-sm">
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(value) => toggleRole(role.slug, value === true)}
|
|
/>
|
|
<span className="space-y-0.5">
|
|
<span className="block leading-none font-medium">{role.name}</span>
|
|
<span className="text-xs text-muted-foreground">{role.description ?? ""}</span>
|
|
</span>
|
|
</label>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 flex-col gap-3 border-t bg-muted/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-full shrink-0 sm:w-auto"
|
|
onClick={() => handlePermissionDialogOpenChange(false)}
|
|
>
|
|
{t("actions.close", { ns: "common" })}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
className="w-full shrink-0 sm:w-auto"
|
|
disabled={!selectedUser || savingRoles}
|
|
onClick={() =>
|
|
selectedUser &&
|
|
requestConfirm({
|
|
title: t("confirmSaveRolesTitle"),
|
|
description: t("confirmSaveRolesDescription", { name: selectedUser.username }),
|
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
|
onConfirm: () => saveRoles(),
|
|
})
|
|
}
|
|
>
|
|
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={accountOpen} onOpenChange={handleAccountDialogOpenChange}>
|
|
<DialogContent showCloseButton className="max-h-[90vh] max-w-lg gap-4 overflow-y-auto sm:max-w-xl">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{accountMode === "create" ? t("accountDialog.createTitle") : t("accountDialog.editTitle")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{accountMode === "create"
|
|
? t("accountDialog.createDescription")
|
|
: t("accountDialog.editDescription")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium leading-none">{t("accountDialog.username")}</div>
|
|
<Input
|
|
value={formUsername}
|
|
disabled={accountMode === "edit"}
|
|
placeholder={t("accountDialog.usernamePlaceholder")}
|
|
autoComplete="off"
|
|
onChange={(e) => setFormUsername(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium leading-none">{t("accountDialog.nickname")}</div>
|
|
<Input
|
|
value={formNickname}
|
|
placeholder={t("accountDialog.nicknamePlaceholder")}
|
|
onChange={(e) => setFormNickname(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium leading-none">{t("accountDialog.emailOptional")}</div>
|
|
<Input
|
|
type="email"
|
|
value={formEmail}
|
|
placeholder={t("accountDialog.emailPlaceholder")}
|
|
onChange={(e) => setFormEmail(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium leading-none">
|
|
{accountMode === "edit" ? t("accountDialog.passwordOptional") : t("accountDialog.password")}
|
|
</div>
|
|
<Input
|
|
type="password"
|
|
value={formPassword}
|
|
placeholder={
|
|
accountMode === "create"
|
|
? t("accountDialog.passwordPlaceholderCreate")
|
|
: t("accountDialog.passwordPlaceholderEdit")
|
|
}
|
|
autoComplete="new-password"
|
|
onChange={(e) => setFormPassword(e.target.value)}
|
|
/>
|
|
</div>
|
|
{accountMode === "create" ? (
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium leading-none">{t("accountDialog.rolesRequired")}</div>
|
|
<p className="text-xs text-muted-foreground">{t("accountDialog.rolesDescription")}</p>
|
|
<div className="max-h-52 space-y-2 overflow-y-auto rounded-md border p-2.5 sm:grid sm:max-h-56 sm:grid-cols-2 sm:gap-2 sm:space-y-0">
|
|
{(catalog?.roles ?? []).length === 0 ? (
|
|
<p className="col-span-full text-xs text-muted-foreground">
|
|
{t("accountDialog.noRoles")}
|
|
</p>
|
|
) : (
|
|
(catalog?.roles ?? []).map((role) => {
|
|
const checked = formCreateRoles.includes(role.slug);
|
|
return (
|
|
<label key={role.slug} className="flex items-start gap-2 text-sm">
|
|
<Checkbox
|
|
checked={checked}
|
|
onCheckedChange={(value) =>
|
|
toggleFormCreateRole(role.slug, value === true)
|
|
}
|
|
/>
|
|
<span className="space-y-0.5">
|
|
<span className="block leading-none font-medium">{role.name}</span>
|
|
<span className="text-xs text-muted-foreground">{role.description ?? ""}</span>
|
|
</span>
|
|
</label>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className="flex items-center justify-between rounded-xl border border-border/70 p-3">
|
|
<div className="space-y-1">
|
|
<p className="text-sm font-medium">{t("table.status")}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{formStatus === 0 ? t("status.enabled") : t("status.disabled")}
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={formStatus === 0}
|
|
disabled={accountSaving}
|
|
aria-label={t("table.status")}
|
|
onCheckedChange={(checked) => setFormStatus(checked ? 0 : 1)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => handleAccountDialogOpenChange(false)}
|
|
>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
disabled={accountSaving}
|
|
onClick={() =>
|
|
requestConfirm({
|
|
title: t("confirmSaveAccountTitle"),
|
|
description:
|
|
accountMode === "create"
|
|
? t("confirmSaveAccountCreateDescription")
|
|
: t("confirmSaveAccountEditDescription", {
|
|
name: formUsername || "—",
|
|
}),
|
|
confirmLabel: t("confirm.confirmSave", { ns: "common" }),
|
|
onConfirm: () => submitAccount(),
|
|
})
|
|
}
|
|
>
|
|
{accountSaving ? t("saving") : t("actions.save")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
|
<DialogContent showCloseButton className="max-w-md gap-4">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("delete.confirmTitle")}</DialogTitle>
|
|
<DialogDescription>
|
|
{deleteTarget ? t("delete.confirmDescription", { name: deleteTarget.username }) : null}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
disabled={deleteBusy}
|
|
onClick={() => setDeleteTarget(null)}
|
|
>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
disabled={deleteBusy}
|
|
onClick={() => void confirmDelete()}
|
|
>
|
|
{deleteBusy ? t("deleting") : t("actions.delete")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<ConfirmDialog />
|
|
</div>
|
|
);
|
|
}
|