569 lines
23 KiB
TypeScript
569 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { ChevronDown } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
|
|
import {
|
|
deleteAdminRole,
|
|
getAdminRoles,
|
|
getAdminUserPermissionCatalog,
|
|
postAdminRole,
|
|
putAdminRole,
|
|
putAdminRolePermissions,
|
|
} from "@/api/admin-users";
|
|
import { AdminStatusBadge } from "@/components/admin/admin-status-badge";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { resolveRoleStatusTone } from "@/lib/admin-status-tone";
|
|
import { AdminTableExportButton } from "@/components/admin/admin-table-export-button";
|
|
import { Button } from "@/components/ui/button";
|
|
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 {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { cn } from "@/lib/utils";
|
|
import type { AdminPermissionCatalogData, AdminRoleRow } from "@/types/api/index";
|
|
import { LotteryApiBizError } from "@/types/api/errors";
|
|
|
|
function permissionGroupLabel(key: string, fallback: string, t: (key: string) => string): string {
|
|
const translated = t(`permissionGroups.${key}`);
|
|
return translated === `permissionGroups.${key}` ? fallback : translated;
|
|
}
|
|
|
|
function permissionLabel(slug: string, fallback: string, t: (key: string) => string): string {
|
|
const translated = t(`permissionNames.${slug}`);
|
|
return translated === `permissionNames.${slug}` ? fallback : translated;
|
|
}
|
|
|
|
export function AdminRolesConsole(): React.ReactElement {
|
|
const { t } = useTranslation(["adminUsers", "common"]);
|
|
const [catalog, setCatalog] = useState<AdminPermissionCatalogData | null>(null);
|
|
const [roles, setRoles] = useState<AdminRoleRow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const [directMenuExpanded, setDirectMenuExpanded] = useState<Record<string, boolean>>({});
|
|
|
|
const [rolePermissionOpen, setRolePermissionOpen] = useState(false);
|
|
const [selectedRoleId, setSelectedRoleId] = useState<number | null>(null);
|
|
const [draftRolePermissions, setDraftRolePermissions] = useState<string[]>([]);
|
|
const [roleSaving, setRoleSaving] = useState(false);
|
|
|
|
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
|
|
const [roleMode, setRoleMode] = useState<"create" | "edit">("create");
|
|
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
|
|
const [roleSlug, setRoleSlug] = useState("");
|
|
const [roleName, setRoleName] = useState("");
|
|
const [roleDescription, setRoleDescription] = useState("");
|
|
const [roleStatus, setRoleStatus] = useState(1);
|
|
const [roleFormSaving, setRoleFormSaving] = useState(false);
|
|
|
|
const [roleDeleteTarget, setRoleDeleteTarget] = useState<AdminRoleRow | null>(null);
|
|
const [roleDeleteBusy, setRoleDeleteBusy] = useState(false);
|
|
|
|
const selectedRole = useMemo(
|
|
() => roles.find((role) => role.id === selectedRoleId) ?? null,
|
|
[roles, selectedRoleId],
|
|
);
|
|
const selectedPermissionSet = useMemo(
|
|
() => new Set(draftRolePermissions),
|
|
[draftRolePermissions],
|
|
);
|
|
|
|
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 groups = catalog?.permission_menu_groups;
|
|
if (groups && groups.length > 0) {
|
|
return groups;
|
|
}
|
|
const flatPermissions = catalog?.permissions ?? [];
|
|
if (flatPermissions.length > 0) {
|
|
return [{ key: "all", label: t("allPermissions"), permissions: flatPermissions }];
|
|
}
|
|
return [];
|
|
}, [catalog, t]);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
setErr(null);
|
|
try {
|
|
const [catalogData, roleData] = await Promise.all([
|
|
getAdminUserPermissionCatalog(),
|
|
getAdminRoles(),
|
|
]);
|
|
setCatalog(catalogData);
|
|
setRoles(roleData.items);
|
|
} catch (e) {
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("roleLoadFailed");
|
|
setErr(msg);
|
|
setRoles([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [t]);
|
|
|
|
useEffect(() => {
|
|
queueMicrotask(() => {
|
|
void load();
|
|
});
|
|
}, [load]);
|
|
|
|
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 toggleRolePermission(slug: string, checked: boolean): void {
|
|
setDraftRolePermissions((prev) => {
|
|
if (checked) {
|
|
return Array.from(new Set([...prev, slug])).sort();
|
|
}
|
|
return prev.filter((value) => value !== slug);
|
|
});
|
|
}
|
|
|
|
function toggleGroupPermissions(slugs: string[], checked: boolean): void {
|
|
setDraftRolePermissions((prev) => {
|
|
if (checked) {
|
|
return Array.from(new Set([...prev, ...slugs])).sort();
|
|
}
|
|
const remove = new Set(slugs);
|
|
return prev.filter((value) => !remove.has(value));
|
|
});
|
|
}
|
|
|
|
function openCreateRole(): void {
|
|
setRoleMode("create");
|
|
setEditingRoleId(null);
|
|
setRoleSlug("");
|
|
setRoleName("");
|
|
setRoleDescription("");
|
|
setRoleStatus(1);
|
|
setRoleDialogOpen(true);
|
|
}
|
|
|
|
function openEditRole(role: AdminRoleRow): void {
|
|
setRoleMode("edit");
|
|
setEditingRoleId(role.id);
|
|
setRoleSlug(role.slug);
|
|
setRoleName(role.name);
|
|
setRoleDescription(role.description ?? "");
|
|
setRoleStatus(role.status);
|
|
setRoleDialogOpen(true);
|
|
}
|
|
|
|
function openRolePermissionEditor(role: AdminRoleRow): void {
|
|
setSelectedRoleId(role.id);
|
|
setDraftRolePermissions([...role.permission_slugs].sort());
|
|
setDirectMenuExpanded({});
|
|
setRolePermissionOpen(true);
|
|
}
|
|
|
|
function handleRoleDialogOpenChange(open: boolean): void {
|
|
setRoleDialogOpen(open);
|
|
if (!open) {
|
|
setEditingRoleId(null);
|
|
}
|
|
}
|
|
|
|
function handleRolePermissionDialogOpenChange(open: boolean): void {
|
|
setRolePermissionOpen(open);
|
|
if (!open) {
|
|
setSelectedRoleId(null);
|
|
}
|
|
}
|
|
|
|
function getGroupSelectionState(slugs: string[]): boolean | "indeterminate" {
|
|
if (slugs.length === 0) {
|
|
return false;
|
|
}
|
|
const selectedCount = slugs.filter((slug) => selectedPermissionSet.has(slug)).length;
|
|
if (selectedCount === 0) {
|
|
return false;
|
|
}
|
|
if (selectedCount === slugs.length) {
|
|
return true;
|
|
}
|
|
return "indeterminate";
|
|
}
|
|
|
|
async function saveRolePermissions(): Promise<void> {
|
|
if (!selectedRole) {
|
|
return;
|
|
}
|
|
setRoleSaving(true);
|
|
try {
|
|
const result = await putAdminRolePermissions(selectedRole.id, draftRolePermissions);
|
|
setDraftRolePermissions([...result.permission_slugs].sort());
|
|
setRoles((prev) => prev.map((role) => (role.id === result.id ? result : role)));
|
|
setCatalog((prev) =>
|
|
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === result.id ? result : role)) } : prev,
|
|
);
|
|
toast.success(t("rolePermissionSaveSuccess"));
|
|
} catch (e) {
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("rolePermissionSaveFailed");
|
|
toast.error(msg);
|
|
} finally {
|
|
setRoleSaving(false);
|
|
}
|
|
}
|
|
|
|
async function submitRole(): Promise<void> {
|
|
const name = roleName.trim();
|
|
const slug = roleSlug.trim().toLowerCase();
|
|
if (name === "" || slug === "") {
|
|
toast.error(t("roleFormRequired"));
|
|
return;
|
|
}
|
|
|
|
setRoleFormSaving(true);
|
|
try {
|
|
if (roleMode === "create") {
|
|
const created = await postAdminRole({
|
|
slug,
|
|
name,
|
|
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
|
|
status: roleStatus,
|
|
});
|
|
setRoles((prev) => [...prev, created]);
|
|
setCatalog((prev) => (prev ? { ...prev, roles: [...prev.roles, created] } : prev));
|
|
toast.success(t("roleCreateSuccess", { name: created.name }));
|
|
} else {
|
|
if (editingRoleId === null) {
|
|
return;
|
|
}
|
|
const updated = await putAdminRole(editingRoleId, {
|
|
slug,
|
|
name,
|
|
description: roleDescription.trim() === "" ? null : roleDescription.trim(),
|
|
status: roleStatus,
|
|
});
|
|
setRoles((prev) => prev.map((role) => (role.id === updated.id ? updated : role)));
|
|
setCatalog((prev) =>
|
|
prev ? { ...prev, roles: prev.roles.map((role) => (role.id === updated.id ? updated : role)) } : prev,
|
|
);
|
|
toast.success(t("roleUpdateSuccess", { name: updated.name }));
|
|
}
|
|
handleRoleDialogOpenChange(false);
|
|
} catch (e) {
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("roleSaveFailed");
|
|
toast.error(msg);
|
|
} finally {
|
|
setRoleFormSaving(false);
|
|
}
|
|
}
|
|
|
|
async function confirmRoleDelete(): Promise<void> {
|
|
if (!roleDeleteTarget) {
|
|
return;
|
|
}
|
|
setRoleDeleteBusy(true);
|
|
try {
|
|
await deleteAdminRole(roleDeleteTarget.id);
|
|
setRoles((prev) => prev.filter((role) => role.id !== roleDeleteTarget.id));
|
|
setCatalog((prev) =>
|
|
prev ? { ...prev, roles: prev.roles.filter((role) => role.id !== roleDeleteTarget.id) } : prev,
|
|
);
|
|
toast.success(t("roleDeleteSuccess", { name: roleDeleteTarget.name }));
|
|
setRoleDeleteTarget(null);
|
|
} catch (e) {
|
|
const msg = e instanceof LotteryApiBizError ? e.message : t("roleDeleteFailed");
|
|
toast.error(msg);
|
|
} finally {
|
|
setRoleDeleteBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex w-full max-w-none flex-col gap-6">
|
|
<Card>
|
|
<CardHeader className="flex flex-row flex-wrap items-end justify-between gap-4">
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
|
<CardTitle>{t("roleListTitle")}</CardTitle>
|
|
<Button type="button" size="sm" onClick={() => openCreateRole()}>
|
|
{t("createRole")}
|
|
</Button>
|
|
</div>
|
|
<div className="admin-list-actions">
|
|
<AdminTableExportButton
|
|
tableId="admin-roles-table"
|
|
filename="角色列表"
|
|
sheetName="角色列表"
|
|
/>
|
|
<Button type="button" variant="secondary" onClick={() => void load()}>
|
|
{t("actions.refresh", { ns: "common" })}
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{err ? <p className="text-sm text-red-600 dark:text-red-400">{err}</p> : null}
|
|
{loading && roles.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">{t("states.loading", { ns: "common" })}</p>
|
|
) : null}
|
|
<div className="rounded-md border">
|
|
<Table id="admin-roles-table">
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-16">{t("table.id", { ns: "common" })}</TableHead>
|
|
<TableHead>{t("roleTable.name")}</TableHead>
|
|
<TableHead>{t("roleTable.slug")}</TableHead>
|
|
<TableHead>{t("roleTable.type")}</TableHead>
|
|
<TableHead>{t("roleTable.status")}</TableHead>
|
|
<TableHead>{t("roleTable.users")}</TableHead>
|
|
<TableHead>{t("roleTable.permissions")}</TableHead>
|
|
<TableHead>{t("roleTable.actions")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{roles.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={8} className="text-muted-foreground">
|
|
{t("states.noData", { ns: "common" })}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
roles.map((role) => (
|
|
<TableRow key={role.id}>
|
|
<TableCell>{role.id}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{role.name}</span>
|
|
<span className="text-xs text-muted-foreground">{role.description ?? ""}</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>{role.slug}</TableCell>
|
|
<TableCell>
|
|
{role.is_system ? (
|
|
<Badge variant="secondary">{t("roleType.system")}</Badge>
|
|
) : (
|
|
<Badge variant="outline">{t("roleType.custom")}</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<AdminStatusBadge status={role.status} tone={resolveRoleStatusTone(role.status)}>
|
|
{role.status === 1 ? t("status.enabled") : t("status.disabled")}
|
|
</AdminStatusBadge>
|
|
</TableCell>
|
|
<TableCell className="tabular-nums">{role.user_count}</TableCell>
|
|
<TableCell className="tabular-nums">{role.permission_slugs.length}</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1">
|
|
<Button type="button" size="sm" variant="outline" onClick={() => openRolePermissionEditor(role)}>
|
|
{t("roleActions.permissions")}
|
|
</Button>
|
|
<Button type="button" size="sm" variant="outline" onClick={() => openEditRole(role)}>
|
|
{t("actions.edit")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="destructive"
|
|
disabled={role.is_system || role.user_count > 0}
|
|
onClick={() => setRoleDeleteTarget(role)}
|
|
>
|
|
{t("actions.delete")}
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Dialog open={rolePermissionOpen} onOpenChange={handleRolePermissionDialogOpenChange}>
|
|
<DialogContent
|
|
showCloseButton
|
|
className="flex h-[min(84vh,760px)] !max-w-[min(720px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden rounded-2xl border bg-background p-0 shadow-2xl"
|
|
>
|
|
<DialogHeader className="shrink-0 space-y-1 border-b bg-background px-5 py-4 pr-12">
|
|
<DialogTitle className="text-[15px] font-semibold tracking-tight text-foreground">
|
|
{t("rolePermissionDialog.title")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-sm text-muted-foreground">
|
|
{selectedRole ? selectedRole.name : null}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain bg-muted/15 px-5 py-4">
|
|
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
|
|
{directPermissionGroups.map((group) => {
|
|
const isOpen = isDirectGroupOpen(group.key);
|
|
const groupSlugs = group.permissions.map((permission) => permission.slug);
|
|
const selectedCount = group.permissions.filter((permission) =>
|
|
selectedPermissionSet.has(permission.slug),
|
|
).length;
|
|
const checkedState = getGroupSelectionState(groupSlugs);
|
|
|
|
return (
|
|
<div key={group.key} className={cn("border-b border-border/60 last:border-b-0", isOpen && "bg-muted/10")}>
|
|
<div className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-muted/20">
|
|
<button
|
|
type="button"
|
|
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted"
|
|
onClick={() => toggleDirectGroup(group.key)}
|
|
aria-label={isOpen ? "收起" : "展开"}
|
|
>
|
|
<ChevronDown
|
|
aria-hidden
|
|
className={cn("size-4 transition-transform", isOpen && "rotate-180")}
|
|
/>
|
|
</button>
|
|
<Checkbox
|
|
checked={checkedState === true}
|
|
indeterminate={checkedState === "indeterminate"}
|
|
onCheckedChange={(value) => toggleGroupPermissions(groupSlugs, value === true)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="min-w-0 flex-1 text-left"
|
|
onClick={() => toggleDirectGroup(group.key)}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="min-w-0 truncate text-[15px] font-medium leading-6 text-foreground">
|
|
{permissionGroupLabel(group.key, group.label, t)}
|
|
</span>
|
|
{group.permissions.length > 0 ? (
|
|
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
|
{group.permissions.length}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</button>
|
|
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
|
{selectedCount}/{group.permissions.length}
|
|
</span>
|
|
</div>
|
|
{isOpen ? (
|
|
<div className="pb-2">
|
|
{group.permissions.map((permission, index) => (
|
|
<label
|
|
key={permission.slug}
|
|
className={cn(
|
|
"flex cursor-pointer items-start gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-muted/20",
|
|
index === 0 && "border-t border-border/50",
|
|
selectedPermissionSet.has(permission.slug) && "bg-muted/20",
|
|
)}
|
|
>
|
|
<span className="mt-1 flex h-4 w-8 shrink-0 items-center">
|
|
<span className="h-px w-full bg-border/70" />
|
|
</span>
|
|
<Checkbox
|
|
className="mt-0.5"
|
|
checked={selectedPermissionSet.has(permission.slug)}
|
|
onCheckedChange={(value) =>
|
|
toggleRolePermission(permission.slug, value === true)
|
|
}
|
|
/>
|
|
<span className="min-w-0 whitespace-normal break-words leading-6 text-foreground">
|
|
{permissionLabel(permission.slug, permission.name, t)}
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 justify-end gap-2 border-t bg-background px-5 py-4">
|
|
<Button type="button" variant="outline" onClick={() => handleRolePermissionDialogOpenChange(false)}>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button type="button" disabled={!selectedRole || roleSaving} onClick={() => void saveRolePermissions()}>
|
|
{roleSaving ? t("saving") : t("actions.save")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={roleDialogOpen} onOpenChange={handleRoleDialogOpenChange}>
|
|
<DialogContent showCloseButton className="max-w-lg gap-4">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{roleMode === "create" ? t("roleDialog.createTitle") : t("roleDialog.editTitle")}
|
|
</DialogTitle>
|
|
<DialogDescription>{t("roleDialog.description")}</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium leading-none">{t("roleDialog.slug")}</div>
|
|
<Input value={roleSlug} onChange={(e) => setRoleSlug(e.target.value)} disabled={roleMode === "edit"} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium leading-none">{t("roleDialog.name")}</div>
|
|
<Input value={roleName} onChange={(e) => setRoleName(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium leading-none">{t("roleDialog.descriptionLabel")}</div>
|
|
<Input value={roleDescription} onChange={(e) => setRoleDescription(e.target.value)} />
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<div className="text-sm font-medium leading-none">{t("roleDialog.status")}</div>
|
|
<select className={selectClassName} value={roleStatus} onChange={(e) => setRoleStatus(Number(e.target.value))}>
|
|
<option value={1}>{t("status.enabled")}</option>
|
|
<option value={0}>{t("status.disabled")}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<Button type="button" variant="outline" onClick={() => handleRoleDialogOpenChange(false)}>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button type="button" disabled={roleFormSaving} onClick={() => void submitRole()}>
|
|
{roleFormSaving ? t("saving") : t("actions.save")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={roleDeleteTarget !== null} onOpenChange={(open) => !open && setRoleDeleteTarget(null)}>
|
|
<DialogContent showCloseButton className="max-w-md gap-4">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("roleDelete.confirmTitle")}</DialogTitle>
|
|
<DialogDescription>
|
|
{roleDeleteTarget ? t("roleDelete.confirmDescription", { name: roleDeleteTarget.name }) : null}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
|
<Button type="button" variant="outline" disabled={roleDeleteBusy} onClick={() => setRoleDeleteTarget(null)}>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button type="button" variant="destructive" disabled={roleDeleteBusy} onClick={() => void confirmRoleDelete()}>
|
|
{roleDeleteBusy ? t("deleting") : t("actions.delete")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|