feat: 增加角色管理与奖池配置迁移,优化管理端权限与样式
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
getAdminUsers,
|
||||
postAdminUser,
|
||||
putAdminUser,
|
||||
putAdminUserPermissions,
|
||||
putAdminUserRoles,
|
||||
} from "@/api/admin-users";
|
||||
import { AdminListPaginationFooter } from "@/components/admin/admin-list-pagination-footer";
|
||||
@@ -35,10 +33,10 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
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"]);
|
||||
@@ -57,12 +55,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [draftRoles, setDraftRoles] = useState<string[]>([]);
|
||||
const [draftPermissions, setDraftPermissions] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savingRoles, setSavingRoles] = useState(false);
|
||||
const [permissionOpen, setPermissionOpen] = useState(false);
|
||||
/** `false` = collapsed; default expanded */
|
||||
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
const [accountMode, setAccountMode] = useState<"create" | "edit">("create");
|
||||
@@ -83,11 +77,66 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
[items, selectedId],
|
||||
);
|
||||
|
||||
const selectClassName = cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm",
|
||||
"dark:bg-input/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
);
|
||||
|
||||
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());
|
||||
setDraftPermissions([...row.direct_permissions].sort());
|
||||
setDirectMenuExpanded({});
|
||||
setPermissionOpen(true);
|
||||
}
|
||||
|
||||
@@ -129,8 +178,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
}
|
||||
|
||||
async function submitAccount(): Promise<void> {
|
||||
const nick = formNickname.trim();
|
||||
if (nick === "") {
|
||||
const nickname = formNickname.trim();
|
||||
if (nickname === "") {
|
||||
toast.error(t("nicknameRequired"));
|
||||
return;
|
||||
}
|
||||
@@ -138,7 +187,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
toast.error(t("newPasswordMin"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (accountMode === "create" && formCreateRoles.length === 0) {
|
||||
toast.error(t("roleRequired"));
|
||||
return;
|
||||
@@ -147,8 +195,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
setAccountSaving(true);
|
||||
try {
|
||||
if (accountMode === "create") {
|
||||
const u = formUsername.trim();
|
||||
if (u === "") {
|
||||
const username = formUsername.trim();
|
||||
if (username === "") {
|
||||
toast.error(t("usernameRequired"));
|
||||
return;
|
||||
}
|
||||
@@ -157,15 +205,15 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
const created = await postAdminUser({
|
||||
username: u.toLowerCase(),
|
||||
nickname: nick,
|
||||
username: username.toLowerCase(),
|
||||
nickname,
|
||||
email: formEmail.trim() === "" ? null : formEmail.trim(),
|
||||
password: formPassword,
|
||||
status: formStatus,
|
||||
role_slugs: formCreateRoles,
|
||||
});
|
||||
setItems((prev) => [created, ...prev]);
|
||||
setTotal((t) => t + 1);
|
||||
setTotal((prev) => prev + 1);
|
||||
toast.success(t("createSuccess", { name: created.username }));
|
||||
handleAccountDialogOpenChange(false);
|
||||
} else {
|
||||
@@ -179,7 +227,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
status: number;
|
||||
password?: string;
|
||||
} = {
|
||||
nickname: nick,
|
||||
nickname,
|
||||
email: formEmail.trim() === "" ? null : formEmail.trim(),
|
||||
status: formStatus,
|
||||
};
|
||||
@@ -199,114 +247,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete(): Promise<void> {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
setDeleteBusy(true);
|
||||
try {
|
||||
await deleteAdminUser(deleteTarget.id);
|
||||
setItems((prev) => prev.filter((r) => r.id !== deleteTarget.id));
|
||||
setTotal((t) => Math.max(0, t - 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);
|
||||
}
|
||||
}
|
||||
|
||||
const selectClassName = cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base outline-none transition-colors",
|
||||
"focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 md:text-sm",
|
||||
"dark:bg-input/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
);
|
||||
|
||||
const directPermissionGroups = useMemo(() => {
|
||||
const g = catalog?.permission_menu_groups;
|
||||
if (g && g.length > 0) {
|
||||
return g;
|
||||
}
|
||||
const flat = catalog?.permissions ?? [];
|
||||
if (flat.length > 0) {
|
||||
return [{ key: "all", label: t("allPermissions"), permissions: flat }];
|
||||
}
|
||||
return [];
|
||||
}, [catalog, t]);
|
||||
|
||||
function isDirectGroupOpen(key: string): boolean {
|
||||
return directMenuExpanded[key] !== false;
|
||||
}
|
||||
|
||||
function toggleDirectGroup(key: string): void {
|
||||
setDirectMenuExpanded((prev) => {
|
||||
const wasOpen = prev[key] !== false;
|
||||
return { ...prev, [key]: wasOpen ? false : true };
|
||||
});
|
||||
}
|
||||
|
||||
function toggleFormCreateRole(slug: string, checked: boolean): void {
|
||||
setFormCreateRoles((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, slug])).sort();
|
||||
}
|
||||
return prev.filter((s) => s !== slug);
|
||||
});
|
||||
}
|
||||
|
||||
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 togglePermission(slug: string, checked: boolean): void {
|
||||
setDraftPermissions((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, slug])).sort();
|
||||
}
|
||||
return prev.filter((s) => s !== slug);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRole(slug: string, checked: boolean): void {
|
||||
setDraftRoles((prev) => {
|
||||
if (checked) {
|
||||
return Array.from(new Set([...prev, slug])).sort();
|
||||
}
|
||||
return prev.filter((s) => s !== slug);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveRoles(): Promise<void> {
|
||||
if (!selectedUser) {
|
||||
return;
|
||||
@@ -335,33 +275,22 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePermissions(): Promise<void> {
|
||||
if (!selectedUser) {
|
||||
async function confirmDelete(): Promise<void> {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setDeleteBusy(true);
|
||||
try {
|
||||
const result = await putAdminUserPermissions(selectedUser.id, draftPermissions);
|
||||
setDraftPermissions([...result.direct_permissions].sort());
|
||||
setDraftRoles([...result.roles].sort());
|
||||
setItems((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === result.id
|
||||
? {
|
||||
...row,
|
||||
direct_permissions: result.direct_permissions,
|
||||
effective_permissions: result.effective_permissions,
|
||||
roles: result.roles,
|
||||
}
|
||||
: row,
|
||||
),
|
||||
);
|
||||
toast.success(t("savePermissionSuccess", { name: result.username }));
|
||||
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("savePermissionFailed");
|
||||
const msg = e instanceof LotteryApiBizError ? e.message : t("deleteFailed");
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +344,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<TableHead>{t("table.nickname")}</TableHead>
|
||||
<TableHead className="w-20 whitespace-nowrap">{t("table.status")}</TableHead>
|
||||
<TableHead>{t("table.roles")}</TableHead>
|
||||
<TableHead>{t("table.direct")}</TableHead>
|
||||
<TableHead>{t("table.effective")}</TableHead>
|
||||
<TableHead className="min-w-[11rem]">{t("table.actions")}</TableHead>
|
||||
</TableRow>
|
||||
@@ -423,7 +351,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-muted-foreground">
|
||||
<TableCell colSpan={7} className="text-muted-foreground">
|
||||
{t("states.noData", { ns: "common" })}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -444,7 +372,10 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
{t("status.enabled")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="border-amber-600/50 text-amber-800 dark:text-amber-400">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-600/50 text-amber-800 dark:text-amber-400"
|
||||
>
|
||||
{t("status.disabled")}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -462,7 +393,6 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">{row.direct_permissions.length}</TableCell>
|
||||
<TableCell className="tabular-nums">{row.effective_permissions.length}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -470,18 +400,14 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={permissionOpen && selectedId === row.id ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
openPermissionEditor(row);
|
||||
}}
|
||||
onClick={() => openPermissionEditor(row)}
|
||||
>
|
||||
{t("actions.permissions")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={
|
||||
accountOpen && editingAccountId === row.id ? "secondary" : "outline"
|
||||
}
|
||||
variant={accountOpen && editingAccountId === row.id ? "secondary" : "outline"}
|
||||
onClick={() => openEditAccount(row)}
|
||||
>
|
||||
{t("actions.edit")}
|
||||
@@ -515,8 +441,8 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
lastPage={lastPage}
|
||||
perPage={perPage}
|
||||
loading={loading}
|
||||
onPerPageChange={(n) => {
|
||||
setPerPage(n);
|
||||
onPerPageChange={(value) => {
|
||||
setPerPage(value);
|
||||
setPage(1);
|
||||
}}
|
||||
onPageChange={setPage}
|
||||
@@ -527,7 +453,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<Dialog open={permissionOpen} onOpenChange={handlePermissionDialogOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton
|
||||
className="flex h-[min(88vh,800px)] max-h-[90vh] w-[calc(100%-2rem)] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:h-[min(85vh,780px)] sm:max-w-3xl"
|
||||
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>
|
||||
@@ -541,143 +467,46 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
|
||||
<div className="space-y-6 pb-1">
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium leading-none">{t("permissionDialog.rolesTitle")}</h3>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{t("permissionDialog.rolesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||
{(catalog?.roles ?? []).map((r) => {
|
||||
const checked = draftRoles.includes(r.slug);
|
||||
return (
|
||||
<label key={r.slug} className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleRole(r.slug, v === true)}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none font-medium">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{r.slug}</span>
|
||||
<span className="block text-xs text-muted-foreground/90">
|
||||
{t("permissionDialog.rolePermissionCount", { count: r.permission_slugs.length })}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium leading-none">{t("permissionDialog.directTitle")}</h3>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
{t("permissionDialog.directDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border bg-muted/20 p-2.5 text-xs text-muted-foreground">
|
||||
{t("permissionDialog.selectedRoles")}
|
||||
{draftRoles.length === 0 ? (
|
||||
<span className="ml-1 text-foreground/80">{t("common.none")}</span>
|
||||
) : (
|
||||
<span className="ml-1 inline-flex flex-wrap gap-1 align-middle">
|
||||
{draftRoles.map((slug) => (
|
||||
<Badge key={slug} variant="secondary" className="text-xs font-normal">
|
||||
{slug}
|
||||
</Badge>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{directPermissionGroups.map((group) => {
|
||||
const isOpen = isDirectGroupOpen(group.key);
|
||||
const nSelected = group.permissions.filter((p) =>
|
||||
draftPermissions.includes(p.slug),
|
||||
).length;
|
||||
return (
|
||||
<div key={group.key} className="border-b last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-left text-sm hover:bg-muted/50"
|
||||
onClick={() => toggleDirectGroup(group.key)}
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 font-medium">{group.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
||||
{nSelected}/{group.permissions.length}
|
||||
</span>
|
||||
</button>
|
||||
{isOpen ? (
|
||||
<div className="space-y-2 border-t bg-muted/20 px-3 py-3 sm:grid sm:grid-cols-2 sm:gap-2 sm:gap-y-3">
|
||||
{group.permissions.map((p) => {
|
||||
const checked = draftPermissions.includes(p.slug);
|
||||
return (
|
||||
<label key={p.slug} className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => togglePermission(p.slug, v === true)}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none">{p.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.slug}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<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.slug}</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"
|
||||
data-slot="dialog-footer-actions"
|
||||
>
|
||||
<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);
|
||||
}}
|
||||
onClick={() => handlePermissionDialogOpenChange(false)}
|
||||
>
|
||||
{t("actions.close", { ns: "common" })}
|
||||
</Button>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-nowrap sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
disabled={!selectedUser || savingRoles}
|
||||
onClick={() => void saveRoles()}
|
||||
>
|
||||
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
disabled={!selectedUser || saving}
|
||||
onClick={() => void savePermissions()}
|
||||
>
|
||||
{saving ? t("saving") : t("permissionDialog.saveDirect")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
disabled={!selectedUser || savingRoles}
|
||||
onClick={() => void saveRoles()}
|
||||
>
|
||||
{savingRoles ? t("saving") : t("permissionDialog.saveRoles")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -741,26 +570,26 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
{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>
|
||||
<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((r) => {
|
||||
const checked = formCreateRoles.includes(r.slug);
|
||||
(catalog?.roles ?? []).map((role) => {
|
||||
const checked = formCreateRoles.includes(role.slug);
|
||||
return (
|
||||
<label key={r.slug} className="flex items-start gap-2 text-sm">
|
||||
<label key={role.slug} className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(v) => toggleFormCreateRole(r.slug, v === true)}
|
||||
onCheckedChange={(value) =>
|
||||
toggleFormCreateRole(role.slug, value === true)
|
||||
}
|
||||
/>
|
||||
<span className="space-y-0.5">
|
||||
<span className="block leading-none font-medium">{r.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{r.slug}</span>
|
||||
<span className="block leading-none font-medium">{role.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{role.slug}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
@@ -801,9 +630,7 @@ export function AdminUsersConsole(): React.ReactElement {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("delete.confirmTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteTarget ? (
|
||||
<>{t("delete.confirmDescription", { name: deleteTarget.username })}</>
|
||||
) : null}
|
||||
{deleteTarget ? t("delete.confirmDescription", { name: deleteTarget.username }) : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
|
||||
Reference in New Issue
Block a user