621 lines
21 KiB
TypeScript
621 lines
21 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { Edit2, Eye, Trash2 } from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
CustomButton,
|
|
CustomColumnFilter,
|
|
CustomConfirmationModal,
|
|
CustomInput,
|
|
CustomModal,
|
|
CustomTable,
|
|
CustomLoader,
|
|
CustomActionMenu,
|
|
CustomActionItem,
|
|
} from "../../../components/custom";
|
|
import { GroupedAccessSelector } from "./GroupedAccessSelector";
|
|
import { GroupedAccessViewer } from "./GroupedAccessViewer";
|
|
import type { ColumnDef } from "../../../components/custom/CustomTable";
|
|
import {
|
|
buildColumnFilterOptions,
|
|
resolveColumnSortState,
|
|
} from "../../../components/custom/CustomColumnFilter.utils";
|
|
import type { ColumnSortDirection } from "../../../components/custom/CustomColumnFilter";
|
|
import { formatDate } from "../../../lib/dateFormat";
|
|
import type {
|
|
Role,
|
|
RoleAccess,
|
|
RoleUpdateRequest,
|
|
RolePaginatedResponse,
|
|
} from "../RolesTypes";
|
|
import { rolesApi } from "../RolesApi";
|
|
import { tenantsApi } from "../../tenants/TenantsApi";
|
|
import type { Tenant } from "../../tenants/TenantsTypes";
|
|
import { ProtectedComponent } from "../../../components/auth/ProtectedComponent";
|
|
import { useAuth } from "../../../context/AuthContext";
|
|
import { useDebounce } from "../../../components/hooks/useDebounce";
|
|
import {
|
|
DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
|
SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
|
resolveStoredTablePageSize,
|
|
} from "../../../lib/tablePageSize";
|
|
|
|
const AllRoles = () => {
|
|
const { t, i18n } = useTranslation(['roles', 'common']);
|
|
const { user: currentUser, hasAccess, isLoading: isAuthLoading } = useAuth();
|
|
const canReadAllTenants = hasAccess("superadmin.tenant.read");
|
|
|
|
const [roles, setRoles] = useState<Role[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [errorMessage, setErrorMessage] = useState("");
|
|
|
|
|
|
const [page, setPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(() =>
|
|
resolveStoredTablePageSize({
|
|
storageKey: SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY,
|
|
pageSizeOptions: DEFAULT_TABLE_PAGE_SIZE_OPTIONS,
|
|
defaultPageSize: 10,
|
|
})
|
|
);
|
|
const [search, setSearch] = useState("");
|
|
const [roleNameFilter, setRoleNameFilter] = useState<string[]>([]);
|
|
const [tenantFilter, setTenantFilter] = useState<string[]>([]);
|
|
const [totalRows, setTotalRows] = useState(0);
|
|
const [, setTotalPages] = useState(0);
|
|
const [activeSort, setActiveSort] = useState<{
|
|
column: "role_name" | "tenant_id" | null;
|
|
direction: ColumnSortDirection;
|
|
}>({ column: null, direction: null });
|
|
|
|
const debouncedSearch = useDebounce(search, 500);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const prevLoadingRef = useRef(isLoading);
|
|
const latestRolesRequestRef = useRef(0);
|
|
|
|
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
|
const [selectedRoleAccesses, setSelectedRoleAccesses] = useState<RoleAccess[] | null>(null);
|
|
const [isViewOpen, setIsViewOpen] = useState(false);
|
|
const [isEditOpen, setIsEditOpen] = useState(false);
|
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
|
const [isDetailLoading, setIsDetailLoading] = useState(false);
|
|
|
|
const [accessOptions, setAccessOptions] = useState<RoleAccess[]>([]);
|
|
const [isAccessLoading, setIsAccessLoading] = useState(false);
|
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
|
const [allRolesForCounts, setAllRolesForCounts] = useState<Role[]>([]);
|
|
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
|
|
|
|
const [editForm, setEditForm] = useState<{ role_name: string; is_default?: boolean }>({ role_name: "" });
|
|
const [editAccessIds, setEditAccessIds] = useState<string[]>([]);
|
|
const [editError, setEditError] = useState("");
|
|
const [deleteError, setDeleteError] = useState("");
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [isDeleting, setIsDeleting] = useState(false);
|
|
|
|
const availableAccessOptions = useMemo(() => {
|
|
if (canReadAllTenants) return accessOptions;
|
|
|
|
if (!currentUser?.role?.accesses) return [];
|
|
|
|
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
|
|
const includedIds = new Set<string>();
|
|
|
|
accessOptions.forEach((option) => {
|
|
if (currentUser.role?.accesses.includes(option.access_code)) {
|
|
let current: RoleAccess | undefined = option;
|
|
while (current) {
|
|
if (includedIds.has(current.id)) break;
|
|
includedIds.add(current.id);
|
|
|
|
if (current.parent_id) {
|
|
current = accessMap.get(current.parent_id);
|
|
} else {
|
|
current = undefined;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
return accessOptions.filter((option) => includedIds.has(option.id));
|
|
}, [accessOptions, currentUser, canReadAllTenants]);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const loadSupportingData = async () => {
|
|
setIsAccessLoading(true);
|
|
try {
|
|
const accesses = await rolesApi.getAccesses();
|
|
if (isMounted) setAccessOptions(accesses);
|
|
|
|
const allRoles = await rolesApi.getAll();
|
|
if (isMounted) setAllRolesForCounts(allRoles);
|
|
|
|
if (canReadAllTenants) {
|
|
const tenantsData = await tenantsApi.getAll();
|
|
if (isMounted) setTenants(tenantsData);
|
|
} else if (currentUser?.tenant_id) {
|
|
const tenant = await tenantsApi.getMine();
|
|
if (isMounted) setCurrentTenant(tenant);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load supporting data:", err);
|
|
} finally {
|
|
if (isMounted) setIsAccessLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadRoles = async () => {
|
|
const requestId = ++latestRolesRequestRef.current;
|
|
setIsLoading(true);
|
|
setErrorMessage("");
|
|
|
|
try {
|
|
const response: RolePaginatedResponse = await rolesApi.getPaginated({
|
|
page,
|
|
page_size: pageSize,
|
|
search: debouncedSearch || undefined,
|
|
filter_role_names: roleNameFilter,
|
|
filter_tenant_ids: tenantFilter,
|
|
sort_by:
|
|
activeSort.column === "role_name"
|
|
? "name"
|
|
: activeSort.column === "tenant_id"
|
|
? "tenant"
|
|
: undefined,
|
|
sort_order: activeSort.direction ?? undefined,
|
|
});
|
|
|
|
if (isMounted && requestId === latestRolesRequestRef.current) {
|
|
setRoles(response.items);
|
|
setTotalRows(response.total);
|
|
setTotalPages(response.total_pages);
|
|
}
|
|
} catch (err) {
|
|
if (isMounted && requestId === latestRolesRequestRef.current) {
|
|
const msg = err instanceof Error ? err.message : t('messages.error');
|
|
setErrorMessage(msg);
|
|
}
|
|
} finally {
|
|
if (isMounted && requestId === latestRolesRequestRef.current) setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
if (!isAuthLoading) {
|
|
loadSupportingData();
|
|
loadRoles();
|
|
}
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [
|
|
isAuthLoading,
|
|
canReadAllTenants,
|
|
currentUser?.tenant_id,
|
|
page,
|
|
pageSize,
|
|
debouncedSearch,
|
|
roleNameFilter,
|
|
tenantFilter,
|
|
activeSort,
|
|
t
|
|
]);
|
|
|
|
useEffect(() => {
|
|
setPage(1);
|
|
}, [debouncedSearch, roleNameFilter, tenantFilter, activeSort]);
|
|
|
|
useEffect(() => {
|
|
if (prevLoadingRef.current && !isLoading && search.trim()) {
|
|
searchInputRef.current?.focus({ preventScroll: true });
|
|
}
|
|
prevLoadingRef.current = isLoading;
|
|
}, [isLoading, search]);
|
|
|
|
const getTenantName = useCallback(
|
|
(tenantId?: string | null) => {
|
|
if (!tenantId) return "";
|
|
const found = tenants.find((t) => t.id === tenantId);
|
|
if (found) return found.tenant_name;
|
|
if (currentTenant?.id === tenantId) return currentTenant.tenant_name;
|
|
if (currentUser?.tenant_id === tenantId && currentUser?.tenant_name) {
|
|
return currentUser.tenant_name;
|
|
}
|
|
return tenantId;
|
|
},
|
|
[tenants, currentTenant, currentUser]
|
|
);
|
|
|
|
const roleNameCounts = useMemo(() => {
|
|
const counts = new Map<string, number>();
|
|
allRolesForCounts.forEach((role) => {
|
|
counts.set(role.role_name, (counts.get(role.role_name) ?? 0) + 1);
|
|
});
|
|
return counts;
|
|
}, [allRolesForCounts]);
|
|
|
|
const tenantCounts = useMemo(() => {
|
|
const counts = new Map<string, number>();
|
|
allRolesForCounts.forEach((role) => {
|
|
if (!role.tenant_id) return;
|
|
counts.set(role.tenant_id, (counts.get(role.tenant_id) ?? 0) + 1);
|
|
});
|
|
return counts;
|
|
}, [allRolesForCounts]);
|
|
|
|
const openView = useCallback(async (role: Role) => {
|
|
setSelectedRole(role);
|
|
setSelectedRoleAccesses(null);
|
|
setIsViewOpen(true);
|
|
setIsDetailLoading(true);
|
|
try {
|
|
const details = await rolesApi.getById(role.id);
|
|
setSelectedRoleAccesses(details.accesses || []);
|
|
} catch {
|
|
setSelectedRoleAccesses([]);
|
|
} finally {
|
|
setIsDetailLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const openEdit = useCallback(async (role: Role) => {
|
|
setSelectedRole(role);
|
|
setEditForm({ role_name: role.role_name, is_default: role.is_default });
|
|
setEditAccessIds([]);
|
|
setEditError("");
|
|
setIsEditOpen(true);
|
|
try {
|
|
const details = await rolesApi.getById(role.id);
|
|
setEditAccessIds(details.accesses?.map((a) => a.id) || []);
|
|
} catch (err) {
|
|
setEditError(err instanceof Error ? err.message : t('messages.error'));
|
|
}
|
|
}, [t]);
|
|
|
|
const openDelete = useCallback((role: Role) => {
|
|
setSelectedRole(role);
|
|
setDeleteError("");
|
|
setIsDeleteOpen(true);
|
|
}, []);
|
|
|
|
const closeView = useCallback(() => {
|
|
setIsViewOpen(false);
|
|
setSelectedRole(null);
|
|
setSelectedRoleAccesses(null);
|
|
}, []);
|
|
|
|
const closeEdit = useCallback(() => {
|
|
setIsEditOpen(false);
|
|
setSelectedRole(null);
|
|
setEditError("");
|
|
}, []);
|
|
|
|
const closeDelete = useCallback(() => {
|
|
setIsDeleteOpen(false);
|
|
setSelectedRole(null);
|
|
setDeleteError("");
|
|
}, []);
|
|
|
|
const handleEditChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const { name, value, type, checked } = e.target;
|
|
setEditForm((prev) => ({
|
|
...prev,
|
|
[name]: type === "checkbox" ? checked : value,
|
|
}));
|
|
},
|
|
[]
|
|
);
|
|
|
|
const handleUpdate = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!selectedRole) return;
|
|
|
|
setIsSaving(true);
|
|
setEditError("");
|
|
|
|
try {
|
|
const payload: RoleUpdateRequest = {
|
|
role_name: editForm.role_name.trim(),
|
|
access_ids: editAccessIds.length ? editAccessIds : null,
|
|
is_default: editForm.is_default,
|
|
};
|
|
|
|
const updated = await rolesApi.update(selectedRole.id, payload);
|
|
setRoles((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
|
|
closeEdit();
|
|
} catch (err) {
|
|
setEditError(err instanceof Error ? err.message : t('messages.error'));
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
if (!selectedRole) return;
|
|
setIsDeleting(true);
|
|
try {
|
|
await rolesApi.remove(selectedRole.id);
|
|
setRoles((prev) => prev.filter((r) => r.id !== selectedRole.id));
|
|
closeDelete();
|
|
} catch (err) {
|
|
setDeleteError(err instanceof Error ? err.message : t('messages.error'));
|
|
} finally {
|
|
setIsDeleting(false);
|
|
}
|
|
};
|
|
|
|
const columns = useMemo<Array<ColumnDef<Role>>>(
|
|
() => [
|
|
{
|
|
key: "role_name",
|
|
visibilityLabel: t('columns.roleName'),
|
|
header: (
|
|
<div className="flex items-center">
|
|
{t('columns.roleName')}
|
|
<CustomColumnFilter
|
|
title={t('columns.roleName')}
|
|
options={buildColumnFilterOptions(roleNameCounts.entries())}
|
|
selectedValues={roleNameFilter}
|
|
sortDirection={activeSort.column === "role_name" ? activeSort.direction : null}
|
|
onApply={(values, direction) => {
|
|
setPage(1);
|
|
setRoleNameFilter(values);
|
|
setActiveSort(resolveColumnSortState(activeSort, "role_name", direction));
|
|
}}
|
|
/>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "tenant_id",
|
|
visibilityLabel: t('columns.tenant'),
|
|
header: (
|
|
<div className="flex items-center">
|
|
{t('columns.tenant')}
|
|
{canReadAllTenants ? (
|
|
<CustomColumnFilter
|
|
title={t('columns.tenant')}
|
|
options={tenants.map((tenant) => ({
|
|
label: `${tenant.tenant_name} (${tenantCounts.get(tenant.id) ?? 0})`,
|
|
value: tenant.id,
|
|
}))}
|
|
selectedValues={tenantFilter}
|
|
sortDirection={activeSort.column === "tenant_id" ? activeSort.direction : null}
|
|
onApply={(values, direction) => {
|
|
setPage(1);
|
|
setTenantFilter(values);
|
|
setActiveSort(resolveColumnSortState(activeSort, "tenant_id", direction));
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
),
|
|
render: (row) => getTenantName(row.tenant_id),
|
|
},
|
|
{
|
|
key: "created_at",
|
|
header: t('columns.created'),
|
|
render: (row) => (
|
|
<div className="text-sm font-medium text-(--text-primary)">
|
|
{formatDate(row.created_at, i18n.language)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "updated_at",
|
|
header: t('columns.updated'),
|
|
render: (row) => (
|
|
<div className="text-sm font-medium text-(--text-primary)">
|
|
{formatDate(row.updated_at, i18n.language)}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
key: "id",
|
|
header: t('columns.actions'),
|
|
searchable: false,
|
|
render: (row) => (
|
|
<CustomActionMenu>
|
|
<ProtectedComponent requiredAccess="admin.role.read">
|
|
<CustomActionItem onClick={() => openView(row)}>
|
|
<Eye size={16} className="mr-2" /> {t('actions.view')}
|
|
</CustomActionItem>
|
|
</ProtectedComponent>
|
|
|
|
<ProtectedComponent requiredAccess="admin.role.update">
|
|
{(!row.is_default || canReadAllTenants) && (
|
|
<CustomActionItem onClick={() => openEdit(row)}>
|
|
<Edit2 size={16} className="mr-2" /> {t('actions.edit')}
|
|
</CustomActionItem>
|
|
)}
|
|
</ProtectedComponent>
|
|
|
|
<ProtectedComponent requiredAccess="admin.role.delete">
|
|
{(!row.is_default || canReadAllTenants) && (
|
|
<CustomActionItem onClick={() => openDelete(row)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
|
|
<Trash2 size={16} className="mr-2" /> {t('actions.delete')}
|
|
</CustomActionItem>
|
|
)}
|
|
</ProtectedComponent>
|
|
</CustomActionMenu>
|
|
),
|
|
},
|
|
],
|
|
[
|
|
activeSort,
|
|
canReadAllTenants,
|
|
getTenantName,
|
|
i18n.language,
|
|
openDelete,
|
|
openEdit,
|
|
openView,
|
|
roleNameCounts,
|
|
roleNameFilter,
|
|
t,
|
|
tenantCounts,
|
|
tenantFilter,
|
|
tenants,
|
|
]
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">{t('title')}</h1>
|
|
</div>
|
|
<ProtectedComponent requiredAccess="admin.role.create">
|
|
<Link to="/roles/add">
|
|
<CustomButton variant="primary">+ {t('add')}</CustomButton>
|
|
</Link>
|
|
</ProtectedComponent>
|
|
</div>
|
|
|
|
|
|
|
|
{isLoading ? (
|
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-6">
|
|
<CustomLoader />
|
|
</div>
|
|
) : errorMessage ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-sm text-red-600">
|
|
{errorMessage}
|
|
</div>
|
|
) : (
|
|
<CustomTable
|
|
data={roles}
|
|
columns={columns}
|
|
columnVisibilityEnabled
|
|
columnVisibilityStorageKey="roles-table-columns"
|
|
pageSizeStorageKey={SHARED_ADMIN_TABLE_PAGE_SIZE_STORAGE_KEY}
|
|
exportEnabled={false}
|
|
getRowId={(row) => row.id}
|
|
manualPagination
|
|
manualFiltering
|
|
totalRows={totalRows}
|
|
page={page}
|
|
pageSize={pageSize}
|
|
search={search}
|
|
onPageChange={setPage}
|
|
onPageSizeChange={setPageSize}
|
|
onSearchChange={setSearch}
|
|
searchInputRef={searchInputRef}
|
|
/>
|
|
)}
|
|
|
|
{/* View Modal */}
|
|
<CustomModal
|
|
isOpen={isViewOpen}
|
|
onClose={closeView}
|
|
title={t('details')}
|
|
size="lg"
|
|
footer={<CustomButton variant="outlined" onClick={closeView}>{t('actions.close')}</CustomButton>}
|
|
>
|
|
{selectedRole ? (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.roleName')}</p>
|
|
<p className="text-sm font-medium text-[var(--text-primary)]">{selectedRole.role_name}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.tenant')}</p>
|
|
<p className="text-sm font-medium text-[var(--text-primary)]">{getTenantName(selectedRole.tenant_id)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.created')}</p>
|
|
<p className="text-sm font-medium text-[var(--text-primary)]">{formatDate(selectedRole.created_at, i18n.language)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)]">{t('columns.updated')}</p>
|
|
<p className="text-sm font-medium text-[var(--text-primary)]">{formatDate(selectedRole.updated_at, i18n.language)}</p>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-wide text-[var(--text-secondary)] mb-2">Accesses</p>
|
|
{isDetailLoading ? (
|
|
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] p-3 text-sm text-[var(--text-secondary)]">
|
|
<CustomLoader />
|
|
</div>
|
|
) : (
|
|
<GroupedAccessViewer accesses={selectedRoleAccesses || []} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-[var(--text-secondary)]">{t('common.noData')}</p>
|
|
)}
|
|
</CustomModal>
|
|
|
|
{/* Edit Modal */}
|
|
<CustomModal
|
|
isOpen={isEditOpen}
|
|
onClose={closeEdit}
|
|
title={t('edit')}
|
|
size="lg"
|
|
footer={
|
|
<>
|
|
<CustomButton variant="outlined" onClick={closeEdit} disabled={isSaving}>{t('actions.cancel')}</CustomButton>
|
|
<CustomButton type="submit" form="edit-role-form" variant="primary" loading={isSaving}>{t('actions.update')}</CustomButton>
|
|
</>
|
|
}
|
|
>
|
|
<form id="edit-role-form" onSubmit={handleUpdate} className="space-y-6">
|
|
<CustomInput
|
|
label={t('columns.roleName')}
|
|
name="role_name"
|
|
value={editForm.role_name}
|
|
onChange={handleEditChange}
|
|
required
|
|
/>
|
|
|
|
{canReadAllTenants && (
|
|
<div className="flex items-center space-x-2">
|
|
<input
|
|
type="checkbox"
|
|
id="edit_is_default"
|
|
name="is_default"
|
|
checked={editForm.is_default || false}
|
|
onChange={handleEditChange}
|
|
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
|
/>
|
|
<label htmlFor="edit_is_default" className="text-sm font-medium text-[var(--text-primary)]">
|
|
Set as Default Role
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
<GroupedAccessSelector
|
|
allAccesses={availableAccessOptions}
|
|
selectedIds={editAccessIds}
|
|
onChange={setEditAccessIds}
|
|
isLoading={isAccessLoading}
|
|
/>
|
|
{editError && (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
|
{editError}
|
|
</div>
|
|
)}
|
|
</form>
|
|
</CustomModal>
|
|
|
|
{/* Delete Modal */}
|
|
<CustomConfirmationModal
|
|
isOpen={isDeleteOpen}
|
|
onClose={closeDelete}
|
|
onConfirm={handleDelete}
|
|
title={t('messages.confirmDelete')}
|
|
description={deleteError || t('messages.confirmDelete')}
|
|
confirmText={t('actions.delete')}
|
|
variant="danger"
|
|
isLoading={isDeleting}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AllRoles;
|