From b8a4f4825afa239ff2baabf924d7b85b63c032ac Mon Sep 17 00:00:00 2001 From: SibarchanNayak Date: Tue, 14 Jul 2026 15:20:17 +0530 Subject: [PATCH] feat: implement service-based sidebar access control with manual locking and service enablement hooks --- src/components/layout/Sidebar.tsx | 239 ++++++++++++------- src/components/shared/AuthenticatedImage.tsx | 11 +- src/components/shared/EditRoleModal.tsx | 57 ++++- src/components/shared/NewRoleModal.tsx | 133 +++++++---- src/hooks/useEnabledServices.ts | 111 +++++++++ src/pages/superadmin/CreateTenantWizard.tsx | 146 ++++++++++- src/pages/superadmin/EditTenant.tsx | 162 ++++++++++++- src/pages/tenant/Dashboard.tsx | 71 ++++-- src/services/platform-service.ts | 77 ++++++ src/services/tenant-service.ts | 2 + 10 files changed, 831 insertions(+), 178 deletions(-) create mode 100644 src/hooks/useEnabledServices.ts create mode 100644 src/services/platform-service.ts diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 32a6e84..2576655 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -18,11 +18,13 @@ import { Paperclip, Bot, ShieldCheck, + Lock, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAppSelector } from "@/hooks/redux-hooks"; import { useAppTheme } from "@/hooks/useAppTheme"; +import { useEnabledServices } from "@/hooks/useEnabledServices"; import { AuthenticatedImage } from "@/components/shared"; interface MenuItem { @@ -30,9 +32,11 @@ interface MenuItem { label: string; path?: string; isGroup?: boolean; + serviceKey?: string; children?: Array<{ label: string; path: string; + serviceKey?: string; requiredPermission?: { resource: string; action?: string; @@ -145,6 +149,7 @@ const tenantAdminPlatformMenu: MenuItem[] = [ icon: Users, label: "Suppliers", path: "/tenant/suppliers", + serviceKey: "supplier", requiredPermission: { resource: "supplier" }, }, { @@ -160,15 +165,18 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [ icon: Paperclip, label: "File Attachments", isGroup: true, + serviceKey: "file_attachment", children: [ { label: "Files List", path: "/tenant/files", + serviceKey: "file_attachment", requiredPermission: { resource: "files" }, }, { label: "Storage Dashboard", path: "/tenant/files/storage-dashboard", + serviceKey: "file_attachment", requiredPermission: { resource: "files" }, }, ], @@ -178,15 +186,18 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [ icon: GitBranch, label: "Workflows", isGroup: true, + serviceKey: "workflow", children: [ { label: "Definitions", path: "/tenant/workflows/definitions", + serviceKey: "workflow", requiredPermission: { resource: "workflow" }, }, { label: "Tasks", path: "/tenant/workflows/tasks", + serviceKey: "workflow", requiredPermission: { resource: "workflow" }, }, ], @@ -196,25 +207,30 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [ icon: FileText, label: "Documents", isGroup: true, + serviceKey: "document", children: [ { label: "Document Lists", path: "/tenant/documents", + serviceKey: "document", requiredPermission: { resource: "document" }, }, { label: "Create Document", path: "/tenant/documents/create", + serviceKey: "document", requiredPermission: { resource: "document", action: "create" }, }, { label: "Categories", path: "/tenant/documents/categories", + serviceKey: "document", requiredPermission: { resource: "document" }, }, { label: "Due for Review", path: "/tenant/documents/due-for-review", + serviceKey: "document", requiredPermission: { resource: "document" }, }, ], @@ -224,37 +240,32 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [ icon: Bot, label: "AI Services", isGroup: true, + serviceKey: "ai", children: [ { label: "Completion History", path: "/tenant/ai/completions", + serviceKey: "ai", requiredPermission: { resource: "ai" }, }, { label: "Prompt Management", path: "/tenant/ai/prompts", + serviceKey: "ai", requiredPermission: { resource: "ai" }, }, { label: "Tenant AI Providers", path: "/tenant/ai/providers", + serviceKey: "ai", requiredPermission: { resource: "ai" }, }, { label: "AI Usage & Cost Dashboard", path: "/tenant/ai/dashboard", + serviceKey: "ai", requiredPermission: { resource: "ai" }, }, - // { - // label: "Tenant Config", - // path: "/tenant/ai/config", - // requiredPermission: { resource: "ai" }, - // }, - // { - // label: "Knowledge (RAG)", - // path: "/tenant/ai/knowledge", - // requiredPermission: { resource: "ai" }, - // }, ], requiredPermission: { resource: "ai" }, }, @@ -277,6 +288,7 @@ const tenantAdminSystemMenu: MenuItem[] = [ icon: ShieldCheck, label: "E-Signatures", path: "/tenant/electronic-signatures", + serviceKey: "electronic_signature", }, { icon: Settings, @@ -290,6 +302,7 @@ const tenantAdminSystemMenu: MenuItem[] = [ { label: "Security Policy", path: "/tenant/settings/security-policy", + serviceKey: "security", }, { label: "Notification Settings", @@ -310,6 +323,7 @@ const tenantAdminSystemMenu: MenuItem[] = [ { label: "Storage Config", path: "/tenant/settings/storage", + serviceKey: "storage", }, ], requiredPermission: { resource: "tenants" }, @@ -389,19 +403,24 @@ const GroupMenuItem = ({ }, [isAnyChildActive]); const Icon = item.icon; + const { isServiceEnabled } = useEnabledServices(); + const isLocked = item.serviceKey ? !isServiceEnabled(item.serviceKey) : false; return (
- {isExpanded ? ( + {isLocked ? ( + + ) : isExpanded ? ( ) : ( )} - {isExpanded && ( + {isExpanded && !isLocked && (
{childrenItems.map((child) => { - const isActive = isChildActive(child.path); + const isChildLocked = child.serviceKey ? !isServiceEnabled(child.serviceKey) : false; + const isActive = !isChildLocked && isChildActive(child.path); + + if (isChildLocked) { + return ( +
+ + {child.label} + + +
+ ); + } + return ( { }: { title: string; items: MenuItem[]; - }) => ( -
-
-
-
- {title} + }) => { + const { isServiceEnabled } = useEnabledServices(); + + return ( +
+
+
+
+ {title} +
+
+
+ {items.map((item) => { + if (item.isGroup) { + const children = + (item as any)._filteredChildren || item.children || []; + return ( + + ); + } + + const Icon = item.icon; + const isLocked = item.serviceKey ? !isServiceEnabled(item.serviceKey) : false; + const isTenantDashboardPath = item.path === "/tenant"; + const isActive = !isLocked && (isTenantDashboardPath + ? location.pathname === "/tenant" + : item.path && + (location.pathname === item.path || + location.pathname.startsWith(`${item.path}/`))); + + if (isLocked) { + return ( +
+
+ + + {item.label} + +
+ +
+ ); + } + + return ( + { + // Close sidebar on mobile when navigating + if (window.innerWidth < 768) { + onClose(); + } + }} + className={cn( + "flex items-center gap-2 md:gap-2 lg:gap-2.5 px-2 md:px-2 lg:px-3 py-2 rounded-md transition-colors min-h-[44px]", + isActive + ? "shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]" + : "text-[#0f1724] hover:bg-gray-50", + )} + style={ + isActive + ? { + backgroundColor: primaryColor, + color: secondaryColor, + } + : undefined + } + > + + + {item.label} + + + ); + })}
-
- {items.map((item) => { - if (item.isGroup) { - const children = - (item as any)._filteredChildren || item.children || []; - return ( - - ); - } - - const Icon = item.icon; - const isTenantDashboardPath = item.path === "/tenant"; - const isActive = isTenantDashboardPath - ? location.pathname === "/tenant" - : item.path && - (location.pathname === item.path || - location.pathname.startsWith(`${item.path}/`)); - return ( - { - // Close sidebar on mobile when navigating - if (window.innerWidth < 768) { - onClose(); - } - }} - className={cn( - "flex items-center gap-2 md:gap-2 lg:gap-2.5 px-2 md:px-2 lg:px-3 py-2 rounded-md transition-colors min-h-[44px]", - isActive - ? "shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]" - : "text-[#0f1724] hover:bg-gray-50", - )} - style={ - isActive - ? { - backgroundColor: primaryColor, - color: secondaryColor, - } - : undefined - } - > - - - {item.label} - - - ); - })} -
-
- ); + ); + }; return ( <> diff --git a/src/components/shared/AuthenticatedImage.tsx b/src/components/shared/AuthenticatedImage.tsx index 4095d60..e89fb18 100644 --- a/src/components/shared/AuthenticatedImage.tsx +++ b/src/components/shared/AuthenticatedImage.tsx @@ -7,6 +7,7 @@ import { import { fileService } from "@/services/file-service"; import apiClient from "@/services/api-client"; import { Loader2, ImageIcon } from "lucide-react"; +import { useEnabledServices } from "@/hooks/useEnabledServices"; // Global cache to persist blob URLs between component remounts (prevents re-fetching on every page click) const BLOB_CACHE = new Map(); @@ -31,6 +32,9 @@ export const AuthenticatedImage = ({ tenantId, ...props }: AuthenticatedImageProps): ReactElement => { + const { isServiceEnabled } = useEnabledServices(); + const isFileServiceEnabled = isServiceEnabled("file_attachment"); + // Helper to extract fileId from backend preview URL const extractFileIdFromUrl = (url: string | null | undefined): string | null => { if (!url) return null; @@ -93,6 +97,11 @@ export const AuthenticatedImage = ({ // 2. If we have a fileId or a backend URL, fetch it via authenticated request if (isAuthRequired) { + if (!isFileServiceEnabled) { + setError(true); + return; + } + // If we already have the blobUrl for this cacheKey, use it and don't fetch if (currentCachedUrl) { setBlobUrl(currentCachedUrl); @@ -165,7 +174,7 @@ export const AuthenticatedImage = ({ // For other external URLs, use them directly setBlobUrl(src); } - }, [fileId, src, cacheKey, isAuthRequired, tenantId]); + }, [fileId, src, cacheKey, isAuthRequired, tenantId, isFileServiceEnabled]); if (isLoading) { return ( diff --git a/src/components/shared/EditRoleModal.tsx b/src/components/shared/EditRoleModal.tsx index b27a37d..f7751bd 100644 --- a/src/components/shared/EditRoleModal.tsx +++ b/src/components/shared/EditRoleModal.tsx @@ -3,7 +3,8 @@ import type { ReactElement } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -import { Loader2 } from "lucide-react"; +import { Loader2, Lock } from "lucide-react"; +import { useEnabledServices } from "@/hooks/useEnabledServices"; import { Modal, FormField, @@ -64,6 +65,25 @@ const ALL_RESOURCES = [ // All available actions const ALL_ACTIONS = ["create", "read", "update", "delete"]; +// Resource to Platform Service mapping +const RESOURCE_SERVICE_MAPPING: Record = { + projects: "project", + document: "document", + security: "security", + workflow: "workflow", + training: "training", + capa: "capa", + supplier: "supplier", + reports: "reporting", + files: "file_attachment", + qms_connections: "qms", + qms_sync_jobs: "qms", + qms_sync_conflicts: "qms", + qms_entity_mappings: "qms", + ai: "ai", + qms: "qms", +}; + // Validation schema const editRoleSchema = z.object({ name: z.string().min(1, "Role name is required"), @@ -118,6 +138,7 @@ export const EditRoleModal = ({ const [selectedPermissions, setSelectedPermissions] = useState< Array<{ resource: string; action: string }> >([]); + const { isServiceEnabled } = useEnabledServices(); // const [expandedResources, setExpandedResources] = useState>( // new Set(), // ); @@ -590,22 +611,32 @@ export const EditRoleModal = ({ {/* Table Body */} {Array.from(availableResourcesAndActions.entries()).map( - ([resource, actions]) => ( - - {/* Resource Name */} - - {resource.replace(/_/g, " ")} - + > + {isResourceLocked ? ( +
+ {resource.replace(/_/g, " ")} + +
+ ) : ( + {resource.replace(/_/g, " ")} + )} + {/* Action Columns */} {[ @@ -635,7 +666,7 @@ export const EditRoleModal = ({ return false; }); - const isAvailable = actions.has(action); + const isAvailable = actions.has(action) && !isResourceLocked; return ( - ), - )} + ); + })}
diff --git a/src/components/shared/NewRoleModal.tsx b/src/components/shared/NewRoleModal.tsx index 9afe913..d774428 100644 --- a/src/components/shared/NewRoleModal.tsx +++ b/src/components/shared/NewRoleModal.tsx @@ -3,7 +3,8 @@ import type { ReactElement } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; -// import { ChevronDown, ChevronRight } from "lucide-react"; +import { Lock } from "lucide-react"; +import { useEnabledServices } from "@/hooks/useEnabledServices"; import { Modal, FormField, @@ -64,6 +65,25 @@ const ALL_RESOURCES = [ // All available actions const ALL_ACTIONS = ["create", "read", "update", "delete"]; +// Resource to Platform Service mapping +const RESOURCE_SERVICE_MAPPING: Record = { + projects: "project", + document: "document", + security: "security", + workflow: "workflow", + training: "training", + capa: "capa", + supplier: "supplier", + reports: "reporting", + files: "file_attachment", + qms_connections: "qms", + qms_sync_jobs: "qms", + qms_sync_conflicts: "qms", + qms_entity_mappings: "qms", + ai: "ai", + qms: "qms", +}; + // Validation schema const newRoleSchema = z.object({ name: z.string().min(1, "Role name is required"), @@ -110,6 +130,7 @@ export const NewRoleModal = ({ const [selectedPermissions, setSelectedPermissions] = useState< Array<{ resource: string; action: string }> >([]); + const { isServiceEnabled } = useEnabledServices(); // const [expandedResources, setExpandedResources] = useState>( // new Set(), // ); @@ -481,61 +502,71 @@ export const NewRoleModal = ({ {/* Table Body */} {Array.from(availableResourcesAndActions.entries()).map( - ([resource, actions]) => ( - - {/* Resource Name */} - - {resource.replace(/_/g, " ")} - + ([resource, actions]) => { + const serviceKey = RESOURCE_SERVICE_MAPPING[resource]; + const isResourceLocked = serviceKey ? !isServiceEnabled(serviceKey) : false; - {/* Action Columns */} - {[ - "read", - "create", - "update", - "delete", - // "approve", - // "admin", - ].map((action) => { - const isChecked = selectedPermissions.some((p) => { - if (p.resource === resource && p.action === action) - return true; + return ( + + {/* Resource Name */} + + {isResourceLocked ? ( +
+ {resource.replace(/_/g, " ")} + +
+ ) : ( + {resource.replace(/_/g, " ")} + )} + - if (p.resource === "*" && p.action === action) - return true; + {/* Action Columns */} + {[ + "read", + "create", + "update", + "delete", + // "approve", + // "admin", + ].map((action) => { + const isChecked = selectedPermissions.some((p) => { + if (p.resource === resource && p.action === action) + return true; - if (p.resource === resource && p.action === "*") - return true; + if (p.resource === "*" && p.action === action) + return true; - if (p.resource === "*" && p.action === "*") - return true; + if (p.resource === resource && p.action === "*") + return true; - return false; - }); + if (p.resource === "*" && p.action === "*") + return true; - const isAvailable = actions.has(action); + return false; + }); - return ( - + const isAvailable = actions.has(action) && !isResourceLocked; + + return ( +
- ), - )} + ); + })}
diff --git a/src/hooks/useEnabledServices.ts b/src/hooks/useEnabledServices.ts new file mode 100644 index 0000000..be1ab27 --- /dev/null +++ b/src/hooks/useEnabledServices.ts @@ -0,0 +1,111 @@ +import { useState, useEffect, useMemo } from 'react'; +import { platformServiceApi } from '@/services/platform-service'; +import { useAppSelector } from './redux-hooks'; + +// Simple module-level cache for the tenant's enabled services +let cachedTenantId: string | null = null; +let cachedServices: string[] | null = null; +let activePromise: Promise | null = null; + +export const useEnabledServices = () => { + const { tenantId, isAuthenticated, roles } = useAppSelector((state) => state.auth); + const [enabledServices, setEnabledServices] = useState( + cachedTenantId === tenantId && cachedServices ? cachedServices : [] + ); + const [isLoading, setIsLoading] = useState(!cachedServices || cachedTenantId !== tenantId); + const [error, setError] = useState(null); + + const parsedRoles = useMemo((): string[] => { + if (Array.isArray(roles)) { + return roles; + } + if (typeof roles === 'string') { + try { + const parsed = JSON.parse(roles); + if (Array.isArray(parsed)) { + return parsed; + } + return [roles]; + } catch { + return [roles]; + } + } + return []; + }, [roles]); + + const isSuperAdmin = useMemo(() => { + return parsedRoles.includes('super_admin'); + }, [parsedRoles]); + + useEffect(() => { + if (!isAuthenticated || !tenantId) { + setEnabledServices([]); + setIsLoading(false); + return; + } + + // If already cached for this tenant, use it + if (cachedTenantId === tenantId && cachedServices) { + setEnabledServices(cachedServices); + setIsLoading(false); + return; + } + + // Reset cache if tenant changed + if (cachedTenantId !== tenantId) { + cachedTenantId = tenantId; + cachedServices = null; + activePromise = null; + } + + setIsLoading(true); + + // De-duplicate concurrent calls using the same activePromise + if (!activePromise) { + activePromise = platformServiceApi.getMyServices() + .then((services) => { + cachedServices = services; + return services; + }) + .catch((err) => { + activePromise = null; + throw err; + }); + } + + activePromise + .then((services) => { + setEnabledServices(services); + setError(null); + }) + .catch((err) => { + setError(err); + }) + .finally(() => { + setIsLoading(false); + }); + }, [tenantId, isAuthenticated]); + + /** + * Checks whether a given service key is enabled. + * System-critical services are always enabled. + * Super Admins bypass all service restrictions. + */ + const isServiceEnabled = (key: string): boolean => { + if (isSuperAdmin) { + return true; + } + const normalizedKey = key.toLowerCase().trim(); + if (['identity', 'audit_logs', 'notification', 'audit_log', 'identity_service', 'notifications'].includes(normalizedKey)) { + return true; + } + return enabledServices.includes(normalizedKey); + }; + + return { + enabledServices, + isServiceEnabled, + isLoading, + error, + }; +}; diff --git a/src/pages/superadmin/CreateTenantWizard.tsx b/src/pages/superadmin/CreateTenantWizard.tsx index 0ef78fe..62f8b7a 100644 --- a/src/pages/superadmin/CreateTenantWizard.tsx +++ b/src/pages/superadmin/CreateTenantWizard.tsx @@ -16,10 +16,50 @@ import { import { tenantService } from "@/services/tenant-service"; import { moduleService } from "@/services/module-service"; import { fileService } from "@/services/file-service"; +import { platformServiceApi, type PlatformService } from "@/services/platform-service"; import { showToast } from "@/utils/toast"; -import { ChevronRight, ChevronLeft, Image as ImageIcon, X } from "lucide-react"; +import { + ChevronRight, + ChevronLeft, + Image as ImageIcon, + X, + FileText, + GraduationCap, + AlertTriangle, + Factory, + GitBranch, + FolderOpen, + BarChart2, + PenTool, + Brain, + Link2, + Paperclip, + HardDrive, + Shield, + CreditCard, + Settings, + Loader2, +} from "lucide-react"; import { generateUUID } from "@/lib/utils"; +// Icon mapping helper +const serviceIconMap: Record = { + FileText, + GraduationCap, + AlertTriangle, + Factory, + GitBranch, + FolderOpen, + BarChart2, + PenTool, + Brain, + Link2, + Paperclip, + HardDrive, + Shield, + CreditCard, +}; + // Step 1: Tenant Details Schema - matches NewTenantModal const tenantDetailsSchema = z.object({ name: z @@ -186,6 +226,31 @@ const CreateTenantWizard = (): ReactElement => { Array<{ value: string; label: string }> >([]); + // Platform Services state + const [platformServices, setPlatformServices] = useState([]); + const [selectedServices, setSelectedServices] = useState([]); + + // Load platform services on mount + useEffect(() => { + const fetchServices = async () => { + try { + const services = await platformServiceApi.getAll(); + setPlatformServices(services); + // By default, enable all services for the new tenant + setSelectedServices(services.map((s) => s.key)); + } catch (err) { + console.error("Failed to load platform services:", err); + } + }; + fetchServices(); + }, []); + + const toggleService = (key: string) => { + setSelectedServices((prev) => + prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key] + ); + }; + // Form instances for each step const tenantDetailsForm = useForm({ resolver: zodResolver(tenantDetailsSchema), @@ -477,6 +542,7 @@ const CreateTenantWizard = (): ReactElement => { const tenantData = { ...restTenantDetails, module_ids: selectedModules.length > 0 ? selectedModules : undefined, + service_keys: selectedServices, settings: { enable_sso, enable_2fa, @@ -952,6 +1018,84 @@ const CreateTenantWizard = (): ReactElement => { initialOptions={initialModuleOptions} error={tenantDetailsForm.formState.errors.modules?.message} /> + + {/* Platform Services Section */} +
+
+
+

Platform Services

+

+ Select internal platform services enabled for this tenant. System services (Identity, Audit, Notification) are always enabled. +

+
+ {platformServices.length > 0 && ( +
+ + +
+ )} +
+ + {platformServices.length === 0 ? ( +
+ Loading platform services... +
+ ) : ( +
+ {platformServices.map((service) => { + const IconComponent = serviceIconMap[service.icon || ""] || Settings; + const isSelected = selectedServices.includes(service.key); + + return ( +
toggleService(service.key)} + className={`flex items-start p-4 border rounded-lg cursor-pointer transition-all select-none ${ + isSelected + ? "border-[#112868] bg-[#112868]/5 shadow-sm" + : "border-[rgba(0,0,0,0.08)] hover:border-gray-300 bg-white" + }`} + > + {}} // Click handler on parent handles toggle + className="mt-1 h-4 w-4 text-[#112868] border-gray-300 rounded focus:ring-[#112868]" + /> +
+
+
+ +
+ {service.name} + {service.category && ( + + {service.category} + + )} +
+ {service.description && ( +

{service.description}

+ )} +
+
+ ); + })} +
+ )} +
)} diff --git a/src/pages/superadmin/EditTenant.tsx b/src/pages/superadmin/EditTenant.tsx index ed02760..f8cad8d 100644 --- a/src/pages/superadmin/EditTenant.tsx +++ b/src/pages/superadmin/EditTenant.tsx @@ -16,6 +16,7 @@ import { import { tenantService } from "@/services/tenant-service"; import { moduleService } from "@/services/module-service"; import { fileService } from "@/services/file-service"; +import { platformServiceApi, type TenantPlatformService } from "@/services/platform-service"; import { showToast } from "@/utils/toast"; import { ChevronRight, @@ -23,9 +24,42 @@ import { Image as ImageIcon, Loader2, X, + FileText, + GraduationCap, + AlertTriangle, + Factory, + GitBranch, + FolderOpen, + BarChart2, + PenTool, + Brain, + Link2, + Paperclip, + HardDrive, + Shield, + CreditCard, + Settings, } from "lucide-react"; import { generateUUID } from "@/lib/utils"; +// Icon mapping helper +const serviceIconMap: Record = { + FileText, + GraduationCap, + AlertTriangle, + Factory, + GitBranch, + FolderOpen, + BarChart2, + PenTool, + Brain, + Link2, + Paperclip, + HardDrive, + Shield, + CreditCard, +}; + // Step 1: Tenant Details Schema const tenantDetailsSchema = z.object({ name: z @@ -196,6 +230,16 @@ const EditTenant = (): ReactElement => { Array<{ value: string; label: string }> >([]); + // Platform Services state + const [platformServices, setPlatformServices] = useState([]); + const [selectedServices, setSelectedServices] = useState([]); + + const toggleService = (key: string) => { + setSelectedServices((prev) => + prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key] + ); + }; + // File upload state for branding const [logoFile, setLogoFile] = useState(null); const [faviconFile, setFaviconFile] = useState(null); @@ -436,6 +480,15 @@ const EditTenant = (): ReactElement => { secondary_color: secondaryColor, accent_color: accentColor, }); + + // Load Platform Services for the tenant + try { + const tenantServices = await platformServiceApi.getTenantServices(id); + setPlatformServices(tenantServices); + setSelectedServices(tenantServices.filter((s) => s.is_enabled).map((s) => s.key)); + } catch (svcErr) { + console.error("Failed to load tenant platform services:", svcErr); + } } catch (err: any) { setLoadError( err?.response?.data?.error?.message || @@ -488,6 +541,11 @@ const EditTenant = (): ReactElement => { if (isValid) { setCurrentStep(3); } + } else if (currentStep === 3) { + const isValid = await settingsForm.trigger(); + if (isValid) { + setCurrentStep(4); + } } }; @@ -611,6 +669,7 @@ const EditTenant = (): ReactElement => { const tenantData = { ...restTenantDetails, module_ids: selectedModules.length > 0 ? selectedModules : undefined, + service_keys: selectedServices, settings: { enable_sso, enable_2fa, @@ -802,6 +861,13 @@ const EditTenant = (): ReactElement => { title: "Settings", description: "Security & branding", isActive: currentStep === 3, + isCompleted: currentStep > 3, + }, + { + number: 4, + title: "Platform Services", + description: "Up/Down service control", + isActive: currentStep === 4, isCompleted: false, }, ]; @@ -1715,6 +1781,100 @@ const EditTenant = (): ReactElement => {
)} + {/* Step 4: Platform Services */} + {currentStep === 4 && ( +
+
+

+ Platform Services +

+

+ Configure granular up/down service control for internal platform features. +

+
+ +
+
+

Service Registry Access

+

+ Select which services are active for this tenant's users. Identity, Audit Logs, and Notifications are system-critical and always enabled. +

+
+ {platformServices.length > 0 && ( +
+ + +
+ )} +
+ + {platformServices.length === 0 ? ( +
+ Loading tenant platform services... +
+ ) : ( +
+ {platformServices.map((service) => { + const IconComponent = serviceIconMap[service.icon || ""] || Settings; + const isEnabled = selectedServices.includes(service.key); + + return ( +
toggleService(service.key)} + className={`flex items-start p-4 border rounded-lg cursor-pointer transition-all select-none ${ + isEnabled + ? "border-[#112868] bg-[#112868]/5 shadow-sm" + : "border-[rgba(0,0,0,0.08)] hover:border-gray-300 bg-white" + }`} + > + {}} // click handler on card handles state + className="mt-1 h-4 w-4 text-[#112868] border-gray-300 rounded focus:ring-[#112868]" + /> +
+
+
+ +
+ {service.name} + {service.category && ( + + {service.category} + + )} +
+ {service.description && ( +

{service.description}

+ )} + {service.changed_at && ( +

+ Last updated: {new Date(service.changed_at).toLocaleString()} +

+ )} +
+
+ ); + })} +
+ )} +
+ )} + {/* Footer Navigation */}
{currentStep > 1 && ( @@ -1723,7 +1883,7 @@ const EditTenant = (): ReactElement => { Previous )} - {currentStep < 3 ? ( + {currentStep < 4 ? ( Next diff --git a/src/pages/tenant/Dashboard.tsx b/src/pages/tenant/Dashboard.tsx index c3a4843..addde9f 100644 --- a/src/pages/tenant/Dashboard.tsx +++ b/src/pages/tenant/Dashboard.tsx @@ -1,11 +1,12 @@ import { Layout } from "@/components/layout/Layout"; import type { ReactElement } from "react"; -import { FileCheck, Briefcase, FileText, Users, Bell } from "lucide-react"; +import { FileCheck, Briefcase, FileText, Users, Bell, Lock } from "lucide-react"; import { QuickActions } from "@/features/dashboard/components/QuickActions"; import { RecentActivity } from "@/features/dashboard/components/RecentActivity"; import { cn } from "@/lib/utils"; import { useState, useEffect } from "react"; import { useAppTheme } from "@/hooks/useAppTheme"; +import { useEnabledServices } from "@/hooks/useEnabledServices"; import { workflowService } from "@/services/workflow-service"; import { dashboardService, @@ -99,28 +100,36 @@ const Dashboard = (): ReactElement => { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); + const { isServiceEnabled } = useEnabledServices(); + const isWorkflowEnabled = isServiceEnabled("workflow"); + useEffect(() => { fetchDashboardData(); - }, []); + }, [isWorkflowEnabled]); const fetchDashboardData = async () => { setLoading(true); setTasksLoading(true); try { - // Fetch tasks independently - workflowService - .listTasks({ limit: 3 }) - .then((response) => { - if (response.success && Array.isArray(response.data)) { - setTasks(response.data); - } - }) - .catch((error) => { - console.error("Error fetching tasks:", error); - }) - .finally(() => { - setTasksLoading(false); - }); + // Fetch tasks independently only if workflow service is enabled + if (isWorkflowEnabled) { + workflowService + .listTasks({ limit: 3 }) + .then((response) => { + if (response.success && Array.isArray(response.data)) { + setTasks(response.data); + } + }) + .catch((error) => { + console.error("Error fetching tasks:", error); + }) + .finally(() => { + setTasksLoading(false); + }); + } else { + setTasks([]); + setTasksLoading(false); + } // Fetch statistics independently dashboardService @@ -216,17 +225,31 @@ const Dashboard = (): ReactElement => {

My Tasks

- + {isWorkflowEnabled ? ( + + ) : ( + + Locked + + )}
- {tasksLoading ? ( + {!isWorkflowEnabled ? ( +
+ + Service locked + + The Workflow service is disabled for your organization. + +
+ ) : tasksLoading ? (
Loading tasks...
diff --git a/src/services/platform-service.ts b/src/services/platform-service.ts new file mode 100644 index 0000000..f0bc432 --- /dev/null +++ b/src/services/platform-service.ts @@ -0,0 +1,77 @@ +import apiClient from './api-client'; + +export interface PlatformService { + id: string; + key: string; + name: string; + description: string | null; + icon: string | null; + category: string | null; + sort_order: number; +} + +export interface TenantPlatformService extends PlatformService { + service_id: string; + is_enabled: boolean; + changed_by: string | null; + changed_at: string | null; + notes: string | null; +} + +export interface ListPlatformServicesResponse { + success: boolean; + data: PlatformService[]; +} + +export interface ListTenantPlatformServicesResponse { + success: boolean; + data: TenantPlatformService[]; + tenant: { + id: string; + name: string; + }; +} + +export interface ListMyPlatformServicesResponse { + success: boolean; + data: Array<{ + key: string; + name: string; + icon: string | null; + category: string | null; + sort_order: number; + is_enabled: boolean; + }>; +} + +export const platformServiceApi = { + /** + * Get all registered platform services. + * Auth: Super Admin + */ + getAll: async (): Promise => { + const response = await apiClient.get('/platform-services'); + return response.data?.data || []; + }, + + /** + * Get all services with enabled status for a tenant. + * Auth: Super Admin + */ + getTenantServices: async (tenantId: string): Promise => { + const response = await apiClient.get(`/platform-services/tenant/${tenantId}`); + return response.data?.data || []; + }, + + /** + * Get enabled services for the current tenant. + * Auth: Tenant User / Admin + */ + getMyServices: async (): Promise => { + const response = await apiClient.get('/platform-services/my'); + const services = response.data?.data || []; + return services + .filter((s) => s.is_enabled) + .map((s) => s.key); + }, +}; diff --git a/src/services/tenant-service.ts b/src/services/tenant-service.ts index b07b73e..035b79c 100644 --- a/src/services/tenant-service.ts +++ b/src/services/tenant-service.ts @@ -15,6 +15,7 @@ export interface CreateTenantRequest { subscription_tier?: string | null; max_users?: number | null; max_modules?: number | null; + service_keys?: string[] | null; } export interface CreateTenantResponse { @@ -48,6 +49,7 @@ export interface UpdateTenantRequest { max_users?: number | null; max_modules?: number | null; module_ids?: string[] | null; + service_keys?: string[] | null; } export interface UpdateTenantResponse {