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 (
+
+
+
+
+ {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 {