feat: add tenant admin role management page and permission service integration
This commit is contained in:
parent
399666e246
commit
04003a2332
@ -69,6 +69,11 @@ const superAdminPlatformMenu: MenuItem[] = [
|
||||
{ label: "Roles & Permissions", path: "/platform-roles" },
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
label: "Tenant Admin Role",
|
||||
path: "/tenant-admin-role",
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
label: "Modules",
|
||||
|
||||
420
src/pages/superadmin/TenantAdminRole.tsx
Normal file
420
src/pages/superadmin/TenantAdminRole.tsx
Normal file
@ -0,0 +1,420 @@
|
||||
import { useState, useEffect, type ReactElement } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import {
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
Modal,
|
||||
FormField,
|
||||
FormSelect,
|
||||
StatusBadge,
|
||||
DataTable,
|
||||
Pagination,
|
||||
DeleteConfirmationModal,
|
||||
type Column,
|
||||
} from "@/components/shared";
|
||||
import { roleService } from "@/services/role-service";
|
||||
import { permissionService } from "@/services/permission-service";
|
||||
import type { Role, Permission } from "@/types/role";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import {
|
||||
Shield,
|
||||
Plus,
|
||||
Trash2,
|
||||
Loader2,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
|
||||
// Zod Schema for custom permissions
|
||||
const customPermissionSchema = z.object({
|
||||
resource: z
|
||||
.string()
|
||||
.min(1, "Resource name is required")
|
||||
.regex(
|
||||
/^[a-z0-9_]+$/,
|
||||
"Resource name must be lowercase alphanumeric and can contain underscores"
|
||||
),
|
||||
action: z.string().min(1, "Action is required"),
|
||||
});
|
||||
|
||||
type CustomPermissionFormData = z.infer<typeof customPermissionSchema>;
|
||||
|
||||
export default function TenantAdminRole(): ReactElement {
|
||||
const [role, setRole] = useState<Role | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// All permissions list for tenant_admin
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
|
||||
// Pagination state for permissions table
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [limit, setLimit] = useState<number>(10);
|
||||
const [totalItems, setTotalItems] = useState<number>(0);
|
||||
const [totalPages, setTotalPages] = useState<number>(1);
|
||||
|
||||
// Add Custom Permission Modal state
|
||||
const [isAddCustomOpen, setIsAddCustomOpen] = useState<boolean>(false);
|
||||
const [isAddingCustom, setIsAddingCustom] = useState<boolean>(false);
|
||||
|
||||
// Delete/Revoke Permission state
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
|
||||
// React Hook Form setup
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<CustomPermissionFormData>({
|
||||
resolver: zodResolver(customPermissionSchema),
|
||||
defaultValues: {
|
||||
resource: "",
|
||||
action: "read",
|
||||
},
|
||||
});
|
||||
const loadPermissions = async (roleId: string, pageNum: number, limitNum: number) => {
|
||||
try {
|
||||
const response = await permissionService.getAll(pageNum, limitNum, { role_id: roleId });
|
||||
if (response.success) {
|
||||
setPermissions(response.data);
|
||||
if (response.pagination) {
|
||||
setTotalItems(response.pagination.total);
|
||||
setTotalPages(response.pagination.totalPages);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error("Failed to load permissions list.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTenantAdminRole = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 1. Fetch roles to find tenant_admin
|
||||
const rolesResponse = await roleService.getAll(1, 100, null, "tenant_admin", null, true);
|
||||
if (!rolesResponse.success || rolesResponse.data.length === 0) {
|
||||
throw new Error("Tenant Admin role not found. Please verify that the system is seeded.");
|
||||
}
|
||||
|
||||
const tenantAdminSummary = rolesResponse.data.find(
|
||||
(r) => r.code === "tenant_admin"
|
||||
);
|
||||
if (!tenantAdminSummary) {
|
||||
throw new Error("Tenant Admin role not found among tenant roles.");
|
||||
}
|
||||
|
||||
// Keep role metadata
|
||||
setRole(tenantAdminSummary);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to load Tenant Admin role data.");
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTenantAdminRole();
|
||||
}, []);
|
||||
|
||||
// When page or limit changes, load updated permissions
|
||||
useEffect(() => {
|
||||
if (role?.id) {
|
||||
loadPermissions(role.id, page, limit);
|
||||
}
|
||||
}, [page, limit, role?.id]);
|
||||
|
||||
// Add custom permission directly via Permission API (Always tenant scoped)
|
||||
const handleAddCustomPermission = async (data: CustomPermissionFormData) => {
|
||||
if (!role) return;
|
||||
try {
|
||||
setIsAddingCustom(true);
|
||||
const payload = {
|
||||
role_id: role.id,
|
||||
resource: data.resource.trim().toLowerCase(),
|
||||
action: data.action.trim().toLowerCase(),
|
||||
scope: "tenant" as const, // Always tenant scoped
|
||||
};
|
||||
|
||||
const response = await permissionService.create(payload);
|
||||
if (response.success) {
|
||||
showToast.success("Permission added successfully.");
|
||||
setIsAddCustomOpen(false);
|
||||
reset();
|
||||
await loadPermissions(role.id, page, limit);
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message ||
|
||||
err?.message ||
|
||||
"Failed to add permission."
|
||||
);
|
||||
} finally {
|
||||
setIsAddingCustom(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Revoke permission directly via Permission API
|
||||
const handleRevokePermission = async () => {
|
||||
if (!deleteTargetId || !role) return;
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const response = await permissionService.delete(deleteTargetId);
|
||||
if (response.success) {
|
||||
showToast.success("Permission revoked successfully.");
|
||||
setDeleteTargetId(null);
|
||||
await loadPermissions(role.id, page, limit);
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.error?.message ||
|
||||
err?.message ||
|
||||
"Failed to revoke permission."
|
||||
);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// DataTable column mapping for permissions
|
||||
const columns: Column<Permission>[] = [
|
||||
{
|
||||
key: "resource",
|
||||
label: "Resource",
|
||||
width: "40%",
|
||||
render: (item) => (
|
||||
<code className="bg-slate-50 px-1.5 py-0.5 rounded font-mono text-xs text-slate-700">
|
||||
{item.resource}
|
||||
</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "action",
|
||||
label: "Action",
|
||||
width: "25%",
|
||||
render: (item) => (
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-700 font-mono rounded text-xs">
|
||||
{item.action}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "scope",
|
||||
label: "Scope",
|
||||
width: "20%",
|
||||
render: (item) => (
|
||||
<StatusBadge variant="info">
|
||||
{item.scope || "tenant"}
|
||||
</StatusBadge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "Action",
|
||||
width: "15%",
|
||||
align: "right",
|
||||
render: (item) => (
|
||||
<button
|
||||
onClick={() => item.id && setDeleteTargetId(item.id)}
|
||||
className="p-1.5 text-slate-400 hover:text-[#ef4444] hover:bg-red-50 rounded-md transition-colors"
|
||||
title="Revoke Permission"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Tenant Admin Role"
|
||||
pageHeader={{
|
||||
title: "Tenant Admin Permissions",
|
||||
description: "Manage permissions assigned to the system tenant admin role.",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="w-10 h-10 text-[#0F3CC9] animate-spin mb-4" />
|
||||
<p className="text-slate-500 text-sm">Loading Tenant Admin configuration...</p>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !role) {
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Tenant Admin Role"
|
||||
pageHeader={{
|
||||
title: "Tenant Admin Permissions",
|
||||
description: "Manage permissions assigned to the system tenant admin role.",
|
||||
}}
|
||||
>
|
||||
<div className="p-6 bg-red-50 border border-red-200 rounded-lg max-w-2xl mx-auto my-8 text-center">
|
||||
<ShieldAlert className="w-12 h-12 text-[#ef4444] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-red-900 mb-2">Configuration Error</h3>
|
||||
<p className="text-red-700 text-sm mb-6">{error || "Role not found."}</p>
|
||||
<SecondaryButton onClick={loadTenantAdminRole} className="px-4 py-2">
|
||||
Retry Loading
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="Tenant Admin Role"
|
||||
pageHeader={{
|
||||
title: "Tenant Admin Permission Control",
|
||||
description: "Configure system-wide administrative permissions inherited by all Tenant Admins.",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Role Meta Information Card */}
|
||||
<div className="p-6 bg-white border border-[#E5E7EB] rounded-xl shadow-sm flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-100 flex items-center justify-center text-[#0F3CC9]">
|
||||
<Shield className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h2 className="text-lg font-bold text-slate-900">{role.name}</h2>
|
||||
<StatusBadge variant="info">Tenant Scoped</StatusBadge>
|
||||
{role.is_system && (
|
||||
<span className="px-2 py-0.5 text-[11px] font-medium bg-slate-100 text-slate-600 rounded">
|
||||
System seeded
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-1">
|
||||
Role Code: <code className="bg-slate-50 px-1.5 py-0.5 rounded font-mono text-xs text-slate-700">{role.code}</code>
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
This is a global system template. Changes here immediately affect the permission policies of all active tenants.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Permissions Table Section */}
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-xl shadow-sm overflow-hidden">
|
||||
<div className="p-5 border-b border-[#E5E7EB] flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-md font-bold text-slate-900">Tenant Admin Permissions</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Manage granular, scope-specific system policies or actions assigned to this role.
|
||||
</p>
|
||||
</div>
|
||||
<SecondaryButton
|
||||
onClick={() => setIsAddCustomOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 text-xs"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Add Custom Permission</span>
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col gap-4">
|
||||
<DataTable
|
||||
data={permissions}
|
||||
columns={columns}
|
||||
keyExtractor={(item) => item.id || `${item.resource}-${item.action}`}
|
||||
emptyMessage="No permissions configured for this role."
|
||||
/>
|
||||
|
||||
{totalItems > 0 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
totalPages={totalPages}
|
||||
totalItems={totalItems}
|
||||
limit={limit}
|
||||
onPageChange={(p: number) => setPage(p)}
|
||||
onLimitChange={(l: number) => {
|
||||
setLimit(l);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Custom Permission Modal */}
|
||||
<Modal
|
||||
isOpen={isAddCustomOpen}
|
||||
onClose={() => {
|
||||
setIsAddCustomOpen(false);
|
||||
reset();
|
||||
}}
|
||||
title="Add Custom Permission"
|
||||
description="Define a granular resource policy mapping directly to the database permission rules."
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton
|
||||
onClick={() => {
|
||||
setIsAddCustomOpen(false);
|
||||
reset();
|
||||
}}
|
||||
disabled={isAddingCustom}
|
||||
className="px-4 py-2 text-sm"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit(handleAddCustomPermission)}
|
||||
disabled={isAddingCustom}
|
||||
className="px-4 py-2 text-sm"
|
||||
>
|
||||
{isAddingCustom ? "Adding..." : "Add Permission"}
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit(handleAddCustomPermission)}>
|
||||
<FormField
|
||||
label="Resource Name"
|
||||
required
|
||||
placeholder="e.g. prompt_templates, audit_filters"
|
||||
{...register("resource")}
|
||||
error={errors.resource?.message}
|
||||
/>
|
||||
|
||||
<FormSelect
|
||||
label="Action"
|
||||
required
|
||||
options={[
|
||||
{ value: "read", label: "Read" },
|
||||
{ value: "create", label: "Create" },
|
||||
{ value: "update", label: "Update" },
|
||||
{ value: "delete", label: "Delete" },
|
||||
{ value: "*", label: "All Access (*)" },
|
||||
]}
|
||||
value={watch("action")}
|
||||
onValueChange={(val) => setValue("action", val, { shouldValidate: true })}
|
||||
error={errors.action?.message}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Revoke confirmation modal */}
|
||||
<DeleteConfirmationModal
|
||||
isOpen={deleteTargetId !== null}
|
||||
onClose={() => setDeleteTargetId(null)}
|
||||
onConfirm={handleRevokePermission}
|
||||
title="Revoke Permission"
|
||||
message="Are you sure you want to revoke this advanced permission? This will take effect immediately across all tenants."
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@ -22,6 +22,7 @@ const FailedEmails = lazy(() => import("@/pages/superadmin/FailedEmails"));
|
||||
const AIFallbackHistory = lazy(() => import("@/pages/superadmin/AIFallbackHistory"));
|
||||
const PlatformUsers = lazy(() => import("@/pages/superadmin/Users"));
|
||||
const PlatformRoles = lazy(() => import("@/pages/superadmin/Roles"));
|
||||
const TenantAdminRole = lazy(() => import("@/pages/superadmin/TenantAdminRole"));
|
||||
const StorageBuckets = lazy(() => import("@/pages/superadmin/StorageBuckets"));
|
||||
|
||||
// Loading fallback component
|
||||
@ -61,6 +62,10 @@ export const superAdminRoutes: RouteConfig[] = [
|
||||
path: "/platform-roles",
|
||||
element: <LazyRoute component={PlatformRoles} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant-admin-role",
|
||||
element: <LazyRoute component={TenantAdminRole} />,
|
||||
},
|
||||
{
|
||||
path: "/tenants",
|
||||
element: <LazyRoute component={Tenants} />,
|
||||
|
||||
103
src/services/permission-service.ts
Normal file
103
src/services/permission-service.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import apiClient from "./api-client";
|
||||
import type { Permission } from "@/types/role";
|
||||
|
||||
export interface PermissionsResponse {
|
||||
success: boolean;
|
||||
data: Permission[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PermissionActionResponse {
|
||||
success: boolean;
|
||||
data: Permission;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DeletePermissionResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const permissionService = {
|
||||
/**
|
||||
* Get all permissions with pagination and filters
|
||||
*/
|
||||
getAll: async (
|
||||
page: number = 1,
|
||||
limit: number = 100,
|
||||
filters: Record<string, any> = {}
|
||||
): Promise<PermissionsResponse> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append("page", String(page));
|
||||
params.append("limit", String(limit));
|
||||
|
||||
// Append filter options
|
||||
Object.entries(filters).forEach(([key, val]) => {
|
||||
if (val !== undefined && val !== null && val !== "") {
|
||||
params.append(key, String(val));
|
||||
}
|
||||
});
|
||||
|
||||
const response = await apiClient.get<PermissionsResponse>(
|
||||
`/permissions?${params.toString()}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get permission details by ID
|
||||
*/
|
||||
getById: async (id: string): Promise<PermissionActionResponse> => {
|
||||
const response = await apiClient.get<PermissionActionResponse>(
|
||||
`/permissions/${id}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new custom permission
|
||||
*/
|
||||
create: async (data: {
|
||||
role_id: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
scope: "global" | "tenant" | "module" | "platform";
|
||||
conditions?: any;
|
||||
}): Promise<PermissionActionResponse> => {
|
||||
const response = await apiClient.post<PermissionActionResponse>(
|
||||
"/permissions",
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing permission
|
||||
*/
|
||||
update: async (
|
||||
id: string,
|
||||
data: Partial<Permission>
|
||||
): Promise<PermissionActionResponse> => {
|
||||
const response = await apiClient.put<PermissionActionResponse>(
|
||||
`/permissions/${id}`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete/revoke a permission
|
||||
*/
|
||||
delete: async (id: string): Promise<DeletePermissionResponse> => {
|
||||
const response = await apiClient.delete<DeletePermissionResponse>(
|
||||
`/permissions/${id}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@ -30,8 +30,11 @@ export interface RolesResponse {
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
id?: string;
|
||||
role_id?: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
scope?: "global" | "tenant" | "module" | "platform";
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user