From 04003a2332b49f41b3b646b812b8752b853403ba Mon Sep 17 00:00:00 2001 From: Yashwin Date: Thu, 9 Jul 2026 13:11:47 +0530 Subject: [PATCH] feat: add tenant admin role management page and permission service integration --- src/components/layout/Sidebar.tsx | 5 + src/pages/superadmin/TenantAdminRole.tsx | 420 +++++++++++++++++++++++ src/routes/super-admin-routes.tsx | 5 + src/services/permission-service.ts | 103 ++++++ src/types/role.ts | 3 + 5 files changed, 536 insertions(+) create mode 100644 src/pages/superadmin/TenantAdminRole.tsx create mode 100644 src/services/permission-service.ts diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 824d48a..32a6e84 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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", diff --git a/src/pages/superadmin/TenantAdminRole.tsx b/src/pages/superadmin/TenantAdminRole.tsx new file mode 100644 index 0000000..a568160 --- /dev/null +++ b/src/pages/superadmin/TenantAdminRole.tsx @@ -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; + +export default function TenantAdminRole(): ReactElement { + const [role, setRole] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // All permissions list for tenant_admin + const [permissions, setPermissions] = useState([]); + + // Pagination state for permissions table + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(10); + const [totalItems, setTotalItems] = useState(0); + const [totalPages, setTotalPages] = useState(1); + + // Add Custom Permission Modal state + const [isAddCustomOpen, setIsAddCustomOpen] = useState(false); + const [isAddingCustom, setIsAddingCustom] = useState(false); + + // Delete/Revoke Permission state + const [deleteTargetId, setDeleteTargetId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + // React Hook Form setup + const { + register, + handleSubmit, + setValue, + watch, + reset, + formState: { errors }, + } = useForm({ + 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[] = [ + { + key: "resource", + label: "Resource", + width: "40%", + render: (item) => ( + + {item.resource} + + ), + }, + { + key: "action", + label: "Action", + width: "25%", + render: (item) => ( + + {item.action} + + ), + }, + { + key: "scope", + label: "Scope", + width: "20%", + render: (item) => ( + + {item.scope || "tenant"} + + ), + }, + { + key: "actions", + label: "Action", + width: "15%", + align: "right", + render: (item) => ( + + ), + }, + ]; + + if (isLoading) { + return ( + +
+ +

Loading Tenant Admin configuration...

+
+
+ ); + } + + if (error || !role) { + return ( + +
+ +

Configuration Error

+

{error || "Role not found."}

+ + Retry Loading + +
+
+ ); + } + + return ( + +
+ {/* Role Meta Information Card */} +
+
+
+ +
+
+
+

{role.name}

+ Tenant Scoped + {role.is_system && ( + + System seeded + + )} +
+

+ Role Code: {role.code} +

+

+ This is a global system template. Changes here immediately affect the permission policies of all active tenants. +

+
+
+
+ + {/* Permissions Table Section */} +
+
+
+

Tenant Admin Permissions

+

+ Manage granular, scope-specific system policies or actions assigned to this role. +

+
+ setIsAddCustomOpen(true)} + className="flex items-center gap-1.5 px-3 py-2 text-xs" + > + + Add Custom Permission + +
+ +
+ item.id || `${item.resource}-${item.action}`} + emptyMessage="No permissions configured for this role." + /> + + {totalItems > 0 && ( + setPage(p)} + onLimitChange={(l: number) => { + setLimit(l); + setPage(1); + }} + /> + )} +
+
+
+ + {/* Add Custom Permission Modal */} + { + setIsAddCustomOpen(false); + reset(); + }} + title="Add Custom Permission" + description="Define a granular resource policy mapping directly to the database permission rules." + footer={ + <> + { + setIsAddCustomOpen(false); + reset(); + }} + disabled={isAddingCustom} + className="px-4 py-2 text-sm" + > + Cancel + + + {isAddingCustom ? "Adding..." : "Add Permission"} + + + } + > +
+ + + setValue("action", val, { shouldValidate: true })} + error={errors.action?.message} + /> + +
+ + {/* Revoke confirmation modal */} + 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} + /> +
+ ); +} diff --git a/src/routes/super-admin-routes.tsx b/src/routes/super-admin-routes.tsx index b312132..9568dac 100644 --- a/src/routes/super-admin-routes.tsx +++ b/src/routes/super-admin-routes.tsx @@ -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: , }, + { + path: "/tenant-admin-role", + element: , + }, { path: "/tenants", element: , diff --git a/src/services/permission-service.ts b/src/services/permission-service.ts new file mode 100644 index 0000000..a23a2b4 --- /dev/null +++ b/src/services/permission-service.ts @@ -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 = {} + ): Promise => { + 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( + `/permissions?${params.toString()}` + ); + return response.data; + }, + + /** + * Get permission details by ID + */ + getById: async (id: string): Promise => { + const response = await apiClient.get( + `/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 => { + const response = await apiClient.post( + "/permissions", + data + ); + return response.data; + }, + + /** + * Update an existing permission + */ + update: async ( + id: string, + data: Partial + ): Promise => { + const response = await apiClient.put( + `/permissions/${id}`, + data + ); + return response.data; + }, + + /** + * Delete/revoke a permission + */ + delete: async (id: string): Promise => { + const response = await apiClient.delete( + `/permissions/${id}` + ); + return response.data; + }, +}; diff --git a/src/types/role.ts b/src/types/role.ts index dde2ccc..d1090b0 100644 --- a/src/types/role.ts +++ b/src/types/role.ts @@ -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 {