feat: implement service-based sidebar access control with manual locking and service enablement hooks

This commit is contained in:
SibarchanNayak 2026-07-14 15:20:17 +05:30
parent f7eb25f874
commit b8a4f4825a
10 changed files with 831 additions and 178 deletions

View File

@ -18,11 +18,13 @@ import {
Paperclip, Paperclip,
Bot, Bot,
ShieldCheck, ShieldCheck,
Lock,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useAppSelector } from "@/hooks/redux-hooks"; import { useAppSelector } from "@/hooks/redux-hooks";
import { useAppTheme } from "@/hooks/useAppTheme"; import { useAppTheme } from "@/hooks/useAppTheme";
import { useEnabledServices } from "@/hooks/useEnabledServices";
import { AuthenticatedImage } from "@/components/shared"; import { AuthenticatedImage } from "@/components/shared";
interface MenuItem { interface MenuItem {
@ -30,9 +32,11 @@ interface MenuItem {
label: string; label: string;
path?: string; path?: string;
isGroup?: boolean; isGroup?: boolean;
serviceKey?: string;
children?: Array<{ children?: Array<{
label: string; label: string;
path: string; path: string;
serviceKey?: string;
requiredPermission?: { requiredPermission?: {
resource: string; resource: string;
action?: string; action?: string;
@ -145,6 +149,7 @@ const tenantAdminPlatformMenu: MenuItem[] = [
icon: Users, icon: Users,
label: "Suppliers", label: "Suppliers",
path: "/tenant/suppliers", path: "/tenant/suppliers",
serviceKey: "supplier",
requiredPermission: { resource: "supplier" }, requiredPermission: { resource: "supplier" },
}, },
{ {
@ -160,15 +165,18 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
icon: Paperclip, icon: Paperclip,
label: "File Attachments", label: "File Attachments",
isGroup: true, isGroup: true,
serviceKey: "file_attachment",
children: [ children: [
{ {
label: "Files List", label: "Files List",
path: "/tenant/files", path: "/tenant/files",
serviceKey: "file_attachment",
requiredPermission: { resource: "files" }, requiredPermission: { resource: "files" },
}, },
{ {
label: "Storage Dashboard", label: "Storage Dashboard",
path: "/tenant/files/storage-dashboard", path: "/tenant/files/storage-dashboard",
serviceKey: "file_attachment",
requiredPermission: { resource: "files" }, requiredPermission: { resource: "files" },
}, },
], ],
@ -178,15 +186,18 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
icon: GitBranch, icon: GitBranch,
label: "Workflows", label: "Workflows",
isGroup: true, isGroup: true,
serviceKey: "workflow",
children: [ children: [
{ {
label: "Definitions", label: "Definitions",
path: "/tenant/workflows/definitions", path: "/tenant/workflows/definitions",
serviceKey: "workflow",
requiredPermission: { resource: "workflow" }, requiredPermission: { resource: "workflow" },
}, },
{ {
label: "Tasks", label: "Tasks",
path: "/tenant/workflows/tasks", path: "/tenant/workflows/tasks",
serviceKey: "workflow",
requiredPermission: { resource: "workflow" }, requiredPermission: { resource: "workflow" },
}, },
], ],
@ -196,25 +207,30 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
icon: FileText, icon: FileText,
label: "Documents", label: "Documents",
isGroup: true, isGroup: true,
serviceKey: "document",
children: [ children: [
{ {
label: "Document Lists", label: "Document Lists",
path: "/tenant/documents", path: "/tenant/documents",
serviceKey: "document",
requiredPermission: { resource: "document" }, requiredPermission: { resource: "document" },
}, },
{ {
label: "Create Document", label: "Create Document",
path: "/tenant/documents/create", path: "/tenant/documents/create",
serviceKey: "document",
requiredPermission: { resource: "document", action: "create" }, requiredPermission: { resource: "document", action: "create" },
}, },
{ {
label: "Categories", label: "Categories",
path: "/tenant/documents/categories", path: "/tenant/documents/categories",
serviceKey: "document",
requiredPermission: { resource: "document" }, requiredPermission: { resource: "document" },
}, },
{ {
label: "Due for Review", label: "Due for Review",
path: "/tenant/documents/due-for-review", path: "/tenant/documents/due-for-review",
serviceKey: "document",
requiredPermission: { resource: "document" }, requiredPermission: { resource: "document" },
}, },
], ],
@ -224,37 +240,32 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
icon: Bot, icon: Bot,
label: "AI Services", label: "AI Services",
isGroup: true, isGroup: true,
serviceKey: "ai",
children: [ children: [
{ {
label: "Completion History", label: "Completion History",
path: "/tenant/ai/completions", path: "/tenant/ai/completions",
serviceKey: "ai",
requiredPermission: { resource: "ai" }, requiredPermission: { resource: "ai" },
}, },
{ {
label: "Prompt Management", label: "Prompt Management",
path: "/tenant/ai/prompts", path: "/tenant/ai/prompts",
serviceKey: "ai",
requiredPermission: { resource: "ai" }, requiredPermission: { resource: "ai" },
}, },
{ {
label: "Tenant AI Providers", label: "Tenant AI Providers",
path: "/tenant/ai/providers", path: "/tenant/ai/providers",
serviceKey: "ai",
requiredPermission: { resource: "ai" }, requiredPermission: { resource: "ai" },
}, },
{ {
label: "AI Usage & Cost Dashboard", label: "AI Usage & Cost Dashboard",
path: "/tenant/ai/dashboard", path: "/tenant/ai/dashboard",
serviceKey: "ai",
requiredPermission: { resource: "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" }, requiredPermission: { resource: "ai" },
}, },
@ -277,6 +288,7 @@ const tenantAdminSystemMenu: MenuItem[] = [
icon: ShieldCheck, icon: ShieldCheck,
label: "E-Signatures", label: "E-Signatures",
path: "/tenant/electronic-signatures", path: "/tenant/electronic-signatures",
serviceKey: "electronic_signature",
}, },
{ {
icon: Settings, icon: Settings,
@ -290,6 +302,7 @@ const tenantAdminSystemMenu: MenuItem[] = [
{ {
label: "Security Policy", label: "Security Policy",
path: "/tenant/settings/security-policy", path: "/tenant/settings/security-policy",
serviceKey: "security",
}, },
{ {
label: "Notification Settings", label: "Notification Settings",
@ -310,6 +323,7 @@ const tenantAdminSystemMenu: MenuItem[] = [
{ {
label: "Storage Config", label: "Storage Config",
path: "/tenant/settings/storage", path: "/tenant/settings/storage",
serviceKey: "storage",
}, },
], ],
requiredPermission: { resource: "tenants" }, requiredPermission: { resource: "tenants" },
@ -389,19 +403,24 @@ const GroupMenuItem = ({
}, [isAnyChildActive]); }, [isAnyChildActive]);
const Icon = item.icon; const Icon = item.icon;
const { isServiceEnabled } = useEnabledServices();
const isLocked = item.serviceKey ? !isServiceEnabled(item.serviceKey) : false;
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<button <button
onClick={() => setIsExpanded(!isExpanded)} onClick={isLocked ? undefined : () => setIsExpanded(!isExpanded)}
title={isLocked ? "This service has been disabled by the Super Admin" : undefined}
className={cn( className={cn(
"flex items-center justify-between gap-2.5 px-3 py-2 rounded-md transition-all min-h-[44px]", "flex items-center justify-between gap-2.5 px-3 py-2 rounded-md transition-all min-h-[44px]",
isAnyChildActive isLocked
? "shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]" ? "text-gray-400 cursor-not-allowed opacity-60 bg-white"
: "text-[#0f1724] hover:bg-gray-50", : isAnyChildActive
? "shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]"
: "text-[#0f1724] hover:bg-gray-50",
)} )}
style={ style={
isAnyChildActive !isLocked && isAnyChildActive
? { ? {
backgroundColor: primaryColor, backgroundColor: primaryColor,
color: secondaryColor, color: secondaryColor,
@ -418,17 +437,36 @@ const GroupMenuItem = ({
{item.label} {item.label}
</span> </span>
</div> </div>
{isExpanded ? ( {isLocked ? (
<Lock className="w-3.5 h-3.5 text-gray-400" />
) : isExpanded ? (
<ChevronDown className="w-3.5 h-3.5" /> <ChevronDown className="w-3.5 h-3.5" />
) : ( ) : (
<ChevronRight className="w-3.5 h-3.5" /> <ChevronRight className="w-3.5 h-3.5" />
)} )}
</button> </button>
{isExpanded && ( {isExpanded && !isLocked && (
<div className="flex flex-col mt-1 mb-1 border-l-2 border-[rgba(0,0,0,0.08)] ml-5 py-1 gap-0.5"> <div className="flex flex-col mt-1 mb-1 border-l-2 border-[rgba(0,0,0,0.08)] ml-5 py-1 gap-0.5">
{childrenItems.map((child) => { {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 (
<div
key={child.path}
title="This service has been disabled by the Super Admin"
className="flex items-center justify-between px-4 py-2 rounded-r-md text-[13px] font-medium text-gray-400 cursor-not-allowed opacity-60 bg-white"
>
<span className="truncate mr-2" title={child.label}>
{child.label}
</span>
<Lock className="w-3 h-3 text-gray-400 shrink-0" />
</div>
);
}
return ( return (
<Link <Link
key={child.path} key={child.path}
@ -644,78 +682,105 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
}: { }: {
title: string; title: string;
items: MenuItem[]; items: MenuItem[];
}) => ( }) => {
<div className="w-full"> const { isServiceEnabled } = useEnabledServices();
<div className="flex flex-col gap-1">
<div className="pb-1 px-2 md:px-2 lg:px-3"> return (
<div className="text-[10px] md:text-[10px] lg:text-[11px] font-semibold text-[#6b7280] uppercase tracking-[0.88px]"> <div className="w-full">
{title} <div className="flex flex-col gap-1">
<div className="pb-1 px-2 md:px-2 lg:px-3">
<div className="text-[10px] md:text-[10px] lg:text-[11px] font-semibold text-[#6b7280] uppercase tracking-[0.88px]">
{title}
</div>
</div>
<div className="flex flex-col gap-1 mt-1">
{items.map((item) => {
if (item.isGroup) {
const children =
(item as any)._filteredChildren || item.children || [];
return (
<GroupMenuItem
key={item.label}
item={item}
childrenItems={children}
location={location}
primaryColor={primaryColor}
secondaryColor={secondaryColor}
onClose={onClose}
/>
);
}
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 (
<div
key={item.path}
title="This service has been disabled by the Super Admin"
className="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] text-gray-400 cursor-not-allowed opacity-60 bg-white justify-between"
>
<div className="flex items-center gap-2 md:gap-2 lg:gap-2.5 min-w-0">
<Icon className="w-4 h-4 shrink-0" />
<span
className="text-xs md:text-xs lg:text-[13px] font-medium truncate"
title={item.label}
>
{item.label}
</span>
</div>
<Lock className="w-3.5 h-3.5 text-gray-400 shrink-0" />
</div>
);
}
return (
<Link
key={item.path}
to={item.path || "#"}
onClick={() => {
// 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
}
>
<Icon className="w-4 h-4 shrink-0" />
<span
className="text-xs md:text-xs lg:text-[13px] font-medium truncate"
title={item.label}
>
{item.label}
</span>
</Link>
);
})}
</div> </div>
</div> </div>
<div className="flex flex-col gap-1 mt-1">
{items.map((item) => {
if (item.isGroup) {
const children =
(item as any)._filteredChildren || item.children || [];
return (
<GroupMenuItem
key={item.label}
item={item}
childrenItems={children}
location={location}
primaryColor={primaryColor}
secondaryColor={secondaryColor}
onClose={onClose}
/>
);
}
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 (
<Link
key={item.path}
to={item.path || "#"}
onClick={() => {
// 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
}
>
<Icon className="w-4 h-4 shrink-0" />
<span
className="text-xs md:text-xs lg:text-[13px] font-medium truncate"
title={item.label}
>
{item.label}
</span>
</Link>
);
})}
</div>
</div> </div>
</div> );
); };
return ( return (
<> <>

View File

@ -7,6 +7,7 @@ import {
import { fileService } from "@/services/file-service"; import { fileService } from "@/services/file-service";
import apiClient from "@/services/api-client"; import apiClient from "@/services/api-client";
import { Loader2, ImageIcon } from "lucide-react"; 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) // Global cache to persist blob URLs between component remounts (prevents re-fetching on every page click)
const BLOB_CACHE = new Map<string, string>(); const BLOB_CACHE = new Map<string, string>();
@ -31,6 +32,9 @@ export const AuthenticatedImage = ({
tenantId, tenantId,
...props ...props
}: AuthenticatedImageProps): ReactElement => { }: AuthenticatedImageProps): ReactElement => {
const { isServiceEnabled } = useEnabledServices();
const isFileServiceEnabled = isServiceEnabled("file_attachment");
// Helper to extract fileId from backend preview URL // Helper to extract fileId from backend preview URL
const extractFileIdFromUrl = (url: string | null | undefined): string | null => { const extractFileIdFromUrl = (url: string | null | undefined): string | null => {
if (!url) return 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 // 2. If we have a fileId or a backend URL, fetch it via authenticated request
if (isAuthRequired) { if (isAuthRequired) {
if (!isFileServiceEnabled) {
setError(true);
return;
}
// If we already have the blobUrl for this cacheKey, use it and don't fetch // If we already have the blobUrl for this cacheKey, use it and don't fetch
if (currentCachedUrl) { if (currentCachedUrl) {
setBlobUrl(currentCachedUrl); setBlobUrl(currentCachedUrl);
@ -165,7 +174,7 @@ export const AuthenticatedImage = ({
// For other external URLs, use them directly // For other external URLs, use them directly
setBlobUrl(src); setBlobUrl(src);
} }
}, [fileId, src, cacheKey, isAuthRequired, tenantId]); }, [fileId, src, cacheKey, isAuthRequired, tenantId, isFileServiceEnabled]);
if (isLoading) { if (isLoading) {
return ( return (

View File

@ -3,7 +3,8 @@ import type { ReactElement } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { Loader2 } from "lucide-react"; import { Loader2, Lock } from "lucide-react";
import { useEnabledServices } from "@/hooks/useEnabledServices";
import { import {
Modal, Modal,
FormField, FormField,
@ -64,6 +65,25 @@ const ALL_RESOURCES = [
// All available actions // All available actions
const ALL_ACTIONS = ["create", "read", "update", "delete"]; const ALL_ACTIONS = ["create", "read", "update", "delete"];
// Resource to Platform Service mapping
const RESOURCE_SERVICE_MAPPING: Record<string, string> = {
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 // Validation schema
const editRoleSchema = z.object({ const editRoleSchema = z.object({
name: z.string().min(1, "Role name is required"), name: z.string().min(1, "Role name is required"),
@ -118,6 +138,7 @@ export const EditRoleModal = ({
const [selectedPermissions, setSelectedPermissions] = useState< const [selectedPermissions, setSelectedPermissions] = useState<
Array<{ resource: string; action: string }> Array<{ resource: string; action: string }>
>([]); >([]);
const { isServiceEnabled } = useEnabledServices();
// const [expandedResources, setExpandedResources] = useState<Set<string>>( // const [expandedResources, setExpandedResources] = useState<Set<string>>(
// new Set(), // new Set(),
// ); // );
@ -590,22 +611,32 @@ export const EditRoleModal = ({
{/* Table Body */} {/* Table Body */}
<tbody> <tbody>
{Array.from(availableResourcesAndActions.entries()).map( {Array.from(availableResourcesAndActions.entries()).map(
([resource, actions]) => ( ([resource, actions]) => {
<tr key={resource}> const serviceKey = RESOURCE_SERVICE_MAPPING[resource];
{/* Resource Name */} const isResourceLocked = serviceKey ? !isServiceEnabled(serviceKey) : false;
<td
className=" return (
<tr key={resource}>
{/* Resource Name */}
<td
className="
w-[204px] w-[204px]
h-[53px] h-[53px]
px-3 py-[19px] px-3 py-[19px]
border-b border-[#E5E7EB] border-b border-[#E5E7EB]
text-[14px] text-[14px]
text-[#111827]
bg-white bg-white
" "
> >
{resource.replace(/_/g, " ")} {isResourceLocked ? (
</td> <div className="flex items-center gap-1.5 text-gray-400 opacity-60" title="Service disabled by Super Admin">
<span className="truncate">{resource.replace(/_/g, " ")}</span>
<Lock className="w-3.5 h-3.5 text-gray-400 shrink-0" />
</div>
) : (
<span className="text-[#111827]">{resource.replace(/_/g, " ")}</span>
)}
</td>
{/* Action Columns */} {/* Action Columns */}
{[ {[
@ -635,7 +666,7 @@ export const EditRoleModal = ({
return false; return false;
}); });
const isAvailable = actions.has(action); const isAvailable = actions.has(action) && !isResourceLocked;
return ( return (
<td <td
@ -674,8 +705,8 @@ export const EditRoleModal = ({
); );
})} })}
</tr> </tr>
), );
)} })}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -3,7 +3,8 @@ import type { ReactElement } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
// import { ChevronDown, ChevronRight } from "lucide-react"; import { Lock } from "lucide-react";
import { useEnabledServices } from "@/hooks/useEnabledServices";
import { import {
Modal, Modal,
FormField, FormField,
@ -64,6 +65,25 @@ const ALL_RESOURCES = [
// All available actions // All available actions
const ALL_ACTIONS = ["create", "read", "update", "delete"]; const ALL_ACTIONS = ["create", "read", "update", "delete"];
// Resource to Platform Service mapping
const RESOURCE_SERVICE_MAPPING: Record<string, string> = {
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 // Validation schema
const newRoleSchema = z.object({ const newRoleSchema = z.object({
name: z.string().min(1, "Role name is required"), name: z.string().min(1, "Role name is required"),
@ -110,6 +130,7 @@ export const NewRoleModal = ({
const [selectedPermissions, setSelectedPermissions] = useState< const [selectedPermissions, setSelectedPermissions] = useState<
Array<{ resource: string; action: string }> Array<{ resource: string; action: string }>
>([]); >([]);
const { isServiceEnabled } = useEnabledServices();
// const [expandedResources, setExpandedResources] = useState<Set<string>>( // const [expandedResources, setExpandedResources] = useState<Set<string>>(
// new Set(), // new Set(),
// ); // );
@ -481,61 +502,71 @@ export const NewRoleModal = ({
{/* Table Body */} {/* Table Body */}
<tbody> <tbody>
{Array.from(availableResourcesAndActions.entries()).map( {Array.from(availableResourcesAndActions.entries()).map(
([resource, actions]) => ( ([resource, actions]) => {
<tr key={resource}> const serviceKey = RESOURCE_SERVICE_MAPPING[resource];
{/* Resource Name */} const isResourceLocked = serviceKey ? !isServiceEnabled(serviceKey) : false;
<td
className="
w-[204px]
h-[53px]
px-3 py-[19px]
border-b border-[#E5E7EB]
text-[14px]
text-[#111827]
bg-white
"
>
{resource.replace(/_/g, " ")}
</td>
{/* Action Columns */} return (
{[ <tr key={resource}>
"read", {/* Resource Name */}
"create", <td
"update", className="
"delete", w-[204px]
// "approve", h-[53px]
// "admin", px-3 py-[19px]
].map((action) => { border-b border-[#E5E7EB]
const isChecked = selectedPermissions.some((p) => { text-[14px]
if (p.resource === resource && p.action === action) bg-white
return true; "
>
{isResourceLocked ? (
<div className="flex items-center gap-1.5 text-gray-400 opacity-60" title="Service disabled by Super Admin">
<span className="truncate">{resource.replace(/_/g, " ")}</span>
<Lock className="w-3.5 h-3.5 text-gray-400 shrink-0" />
</div>
) : (
<span className="text-[#111827]">{resource.replace(/_/g, " ")}</span>
)}
</td>
if (p.resource === "*" && p.action === action) {/* Action Columns */}
return true; {[
"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 === "*") if (p.resource === "*" && p.action === action)
return true; return true;
if (p.resource === "*" && p.action === "*") if (p.resource === resource && p.action === "*")
return true; 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;
<td
key={`${resource}-${action}`} return (
className=" <td
w-[120px] key={`${resource}-${action}`}
px-3 py-[10px] className="
text-center w-[120px]
border-b border-[#E5E7EB] px-3 py-[10px]
bg-white text-center
" border-b border-[#E5E7EB]
> bg-white
"
>
<div className="flex justify-center items-center"> <div className="flex justify-center items-center">
<input <input
type="checkbox" type="checkbox"
@ -562,8 +593,8 @@ export const NewRoleModal = ({
); );
})} })}
</tr> </tr>
), );
)} })}
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@ -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<string[]> | null = null;
export const useEnabledServices = () => {
const { tenantId, isAuthenticated, roles } = useAppSelector((state) => state.auth);
const [enabledServices, setEnabledServices] = useState<string[]>(
cachedTenantId === tenantId && cachedServices ? cachedServices : []
);
const [isLoading, setIsLoading] = useState(!cachedServices || cachedTenantId !== tenantId);
const [error, setError] = useState<Error | null>(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,
};
};

View File

@ -16,10 +16,50 @@ import {
import { tenantService } from "@/services/tenant-service"; import { tenantService } from "@/services/tenant-service";
import { moduleService } from "@/services/module-service"; import { moduleService } from "@/services/module-service";
import { fileService } from "@/services/file-service"; import { fileService } from "@/services/file-service";
import { platformServiceApi, type PlatformService } from "@/services/platform-service";
import { showToast } from "@/utils/toast"; 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"; import { generateUUID } from "@/lib/utils";
// Icon mapping helper
const serviceIconMap: Record<string, any> = {
FileText,
GraduationCap,
AlertTriangle,
Factory,
GitBranch,
FolderOpen,
BarChart2,
PenTool,
Brain,
Link2,
Paperclip,
HardDrive,
Shield,
CreditCard,
};
// Step 1: Tenant Details Schema - matches NewTenantModal // Step 1: Tenant Details Schema - matches NewTenantModal
const tenantDetailsSchema = z.object({ const tenantDetailsSchema = z.object({
name: z name: z
@ -186,6 +226,31 @@ const CreateTenantWizard = (): ReactElement => {
Array<{ value: string; label: string }> Array<{ value: string; label: string }>
>([]); >([]);
// Platform Services state
const [platformServices, setPlatformServices] = useState<PlatformService[]>([]);
const [selectedServices, setSelectedServices] = useState<string[]>([]);
// 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 // Form instances for each step
const tenantDetailsForm = useForm<TenantDetailsForm>({ const tenantDetailsForm = useForm<TenantDetailsForm>({
resolver: zodResolver(tenantDetailsSchema), resolver: zodResolver(tenantDetailsSchema),
@ -477,6 +542,7 @@ const CreateTenantWizard = (): ReactElement => {
const tenantData = { const tenantData = {
...restTenantDetails, ...restTenantDetails,
module_ids: selectedModules.length > 0 ? selectedModules : undefined, module_ids: selectedModules.length > 0 ? selectedModules : undefined,
service_keys: selectedServices,
settings: { settings: {
enable_sso, enable_sso,
enable_2fa, enable_2fa,
@ -952,6 +1018,84 @@ const CreateTenantWizard = (): ReactElement => {
initialOptions={initialModuleOptions} initialOptions={initialModuleOptions}
error={tenantDetailsForm.formState.errors.modules?.message} error={tenantDetailsForm.formState.errors.modules?.message}
/> />
{/* Platform Services Section */}
<div className="mt-8 border-t border-[rgba(0,0,0,0.08)] pt-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-2">
<div>
<h3 className="text-md font-semibold text-[#0f1724]">Platform Services</h3>
<p className="text-xs text-[#6b7280]">
Select internal platform services enabled for this tenant. System services (Identity, Audit, Notification) are always enabled.
</p>
</div>
{platformServices.length > 0 && (
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelectedServices(platformServices.map((s) => s.key))}
className="px-3 py-1.5 text-xs font-medium text-[#112868] bg-[#112868]/10 hover:bg-[#112868]/20 rounded-md transition-colors"
>
Enable All
</button>
<button
type="button"
onClick={() => setSelectedServices([])}
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-md transition-colors"
>
Disable All
</button>
</div>
)}
</div>
{platformServices.length === 0 ? (
<div className="text-sm text-gray-500 py-4 flex items-center justify-center">
<Loader2 className="w-4 h-4 mr-2 animate-spin text-[#112868]" /> Loading platform services...
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{platformServices.map((service) => {
const IconComponent = serviceIconMap[service.icon || ""] || Settings;
const isSelected = selectedServices.includes(service.key);
return (
<div
key={service.key}
onClick={() => 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"
}`}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}} // Click handler on parent handles toggle
className="mt-1 h-4 w-4 text-[#112868] border-gray-300 rounded focus:ring-[#112868]"
/>
<div className="ml-3 flex-1">
<div className="flex items-center gap-2">
<div className={`p-1 rounded ${isSelected ? "bg-[#112868]/10 text-[#112868]" : "bg-gray-100 text-gray-500"}`}>
<IconComponent className="w-4 h-4" />
</div>
<span className="text-sm font-semibold text-gray-900">{service.name}</span>
{service.category && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 text-gray-600 capitalize">
{service.category}
</span>
)}
</div>
{service.description && (
<p className="text-xs text-gray-500 mt-2 leading-relaxed">{service.description}</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -16,6 +16,7 @@ import {
import { tenantService } from "@/services/tenant-service"; import { tenantService } from "@/services/tenant-service";
import { moduleService } from "@/services/module-service"; import { moduleService } from "@/services/module-service";
import { fileService } from "@/services/file-service"; import { fileService } from "@/services/file-service";
import { platformServiceApi, type TenantPlatformService } from "@/services/platform-service";
import { showToast } from "@/utils/toast"; import { showToast } from "@/utils/toast";
import { import {
ChevronRight, ChevronRight,
@ -23,9 +24,42 @@ import {
Image as ImageIcon, Image as ImageIcon,
Loader2, Loader2,
X, X,
FileText,
GraduationCap,
AlertTriangle,
Factory,
GitBranch,
FolderOpen,
BarChart2,
PenTool,
Brain,
Link2,
Paperclip,
HardDrive,
Shield,
CreditCard,
Settings,
} from "lucide-react"; } from "lucide-react";
import { generateUUID } from "@/lib/utils"; import { generateUUID } from "@/lib/utils";
// Icon mapping helper
const serviceIconMap: Record<string, any> = {
FileText,
GraduationCap,
AlertTriangle,
Factory,
GitBranch,
FolderOpen,
BarChart2,
PenTool,
Brain,
Link2,
Paperclip,
HardDrive,
Shield,
CreditCard,
};
// Step 1: Tenant Details Schema // Step 1: Tenant Details Schema
const tenantDetailsSchema = z.object({ const tenantDetailsSchema = z.object({
name: z name: z
@ -196,6 +230,16 @@ const EditTenant = (): ReactElement => {
Array<{ value: string; label: string }> Array<{ value: string; label: string }>
>([]); >([]);
// Platform Services state
const [platformServices, setPlatformServices] = useState<TenantPlatformService[]>([]);
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const toggleService = (key: string) => {
setSelectedServices((prev) =>
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
);
};
// File upload state for branding // File upload state for branding
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null); const [faviconFile, setFaviconFile] = useState<File | null>(null);
@ -436,6 +480,15 @@ const EditTenant = (): ReactElement => {
secondary_color: secondaryColor, secondary_color: secondaryColor,
accent_color: accentColor, 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) { } catch (err: any) {
setLoadError( setLoadError(
err?.response?.data?.error?.message || err?.response?.data?.error?.message ||
@ -488,6 +541,11 @@ const EditTenant = (): ReactElement => {
if (isValid) { if (isValid) {
setCurrentStep(3); setCurrentStep(3);
} }
} else if (currentStep === 3) {
const isValid = await settingsForm.trigger();
if (isValid) {
setCurrentStep(4);
}
} }
}; };
@ -611,6 +669,7 @@ const EditTenant = (): ReactElement => {
const tenantData = { const tenantData = {
...restTenantDetails, ...restTenantDetails,
module_ids: selectedModules.length > 0 ? selectedModules : undefined, module_ids: selectedModules.length > 0 ? selectedModules : undefined,
service_keys: selectedServices,
settings: { settings: {
enable_sso, enable_sso,
enable_2fa, enable_2fa,
@ -802,6 +861,13 @@ const EditTenant = (): ReactElement => {
title: "Settings", title: "Settings",
description: "Security & branding", description: "Security & branding",
isActive: currentStep === 3, isActive: currentStep === 3,
isCompleted: currentStep > 3,
},
{
number: 4,
title: "Platform Services",
description: "Up/Down service control",
isActive: currentStep === 4,
isCompleted: false, isCompleted: false,
}, },
]; ];
@ -1715,6 +1781,100 @@ const EditTenant = (): ReactElement => {
</div> </div>
)} )}
{/* Step 4: Platform Services */}
{currentStep === 4 && (
<div className="space-y-6">
<div className="pb-4 border-b border-[rgba(0,0,0,0.08)]">
<h2 className="text-lg font-semibold text-[#0f1724]">
Platform Services
</h2>
<p className="text-sm text-[#6b7280] mt-1">
Configure granular up/down service control for internal platform features.
</p>
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-4 gap-2 bg-gray-50 p-4 rounded-lg border border-[rgba(0,0,0,0.08)]">
<div>
<h3 className="text-sm font-semibold text-[#0f1724]">Service Registry Access</h3>
<p className="text-xs text-[#6b7280]">
Select which services are active for this tenant's users. Identity, Audit Logs, and Notifications are system-critical and always enabled.
</p>
</div>
{platformServices.length > 0 && (
<div className="flex gap-2">
<button
type="button"
onClick={() => setSelectedServices(platformServices.map((s) => s.key))}
className="px-3 py-1.5 text-xs font-medium text-[#112868] bg-[#112868]/10 hover:bg-[#112868]/20 rounded-md transition-colors"
>
Enable All
</button>
<button
type="button"
onClick={() => setSelectedServices([])}
className="px-3 py-1.5 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-md transition-colors"
>
Disable All
</button>
</div>
)}
</div>
{platformServices.length === 0 ? (
<div className="text-sm text-gray-500 py-8 flex items-center justify-center">
<Loader2 className="w-6 h-6 mr-2 animate-spin text-[#112868]" /> Loading tenant platform services...
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{platformServices.map((service) => {
const IconComponent = serviceIconMap[service.icon || ""] || Settings;
const isEnabled = selectedServices.includes(service.key);
return (
<div
key={service.key}
onClick={() => 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"
}`}
>
<input
type="checkbox"
checked={isEnabled}
onChange={() => {}} // click handler on card handles state
className="mt-1 h-4 w-4 text-[#112868] border-gray-300 rounded focus:ring-[#112868]"
/>
<div className="ml-3 flex-1">
<div className="flex items-center gap-2">
<div className={`p-1 rounded ${isEnabled ? "bg-[#112868]/10 text-[#112868]" : "bg-gray-100 text-gray-500"}`}>
<IconComponent className="w-4 h-4" />
</div>
<span className="text-sm font-semibold text-gray-900">{service.name}</span>
{service.category && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 text-gray-600 capitalize">
{service.category}
</span>
)}
</div>
{service.description && (
<p className="text-xs text-gray-500 mt-2 leading-relaxed">{service.description}</p>
)}
{service.changed_at && (
<p className="text-[10px] text-gray-400 mt-3">
Last updated: {new Date(service.changed_at).toLocaleString()}
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
)}
{/* Footer Navigation */} {/* Footer Navigation */}
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[rgba(0,0,0,0.08)]"> <div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[rgba(0,0,0,0.08)]">
{currentStep > 1 && ( {currentStep > 1 && (
@ -1723,7 +1883,7 @@ const EditTenant = (): ReactElement => {
Previous Previous
</SecondaryButton> </SecondaryButton>
)} )}
{currentStep < 3 ? ( {currentStep < 4 ? (
<PrimaryButton onClick={handleNext} disabled={isSubmitting}> <PrimaryButton onClick={handleNext} disabled={isSubmitting}>
Next Next
<ChevronRight className="w-4 h-4 ml-2" /> <ChevronRight className="w-4 h-4 ml-2" />

View File

@ -1,11 +1,12 @@
import { Layout } from "@/components/layout/Layout"; import { Layout } from "@/components/layout/Layout";
import type { ReactElement } from "react"; 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 { QuickActions } from "@/features/dashboard/components/QuickActions";
import { RecentActivity } from "@/features/dashboard/components/RecentActivity"; import { RecentActivity } from "@/features/dashboard/components/RecentActivity";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useAppTheme } from "@/hooks/useAppTheme"; import { useAppTheme } from "@/hooks/useAppTheme";
import { useEnabledServices } from "@/hooks/useEnabledServices";
import { workflowService } from "@/services/workflow-service"; import { workflowService } from "@/services/workflow-service";
import { import {
dashboardService, dashboardService,
@ -99,28 +100,36 @@ const Dashboard = (): ReactElement => {
const [stats, setStats] = useState<TenantDashboardStats | null>(null); const [stats, setStats] = useState<TenantDashboardStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const { isServiceEnabled } = useEnabledServices();
const isWorkflowEnabled = isServiceEnabled("workflow");
useEffect(() => { useEffect(() => {
fetchDashboardData(); fetchDashboardData();
}, []); }, [isWorkflowEnabled]);
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
setLoading(true); setLoading(true);
setTasksLoading(true); setTasksLoading(true);
try { try {
// Fetch tasks independently // Fetch tasks independently only if workflow service is enabled
workflowService if (isWorkflowEnabled) {
.listTasks({ limit: 3 }) workflowService
.then((response) => { .listTasks({ limit: 3 })
if (response.success && Array.isArray(response.data)) { .then((response) => {
setTasks(response.data); if (response.success && Array.isArray(response.data)) {
} setTasks(response.data);
}) }
.catch((error) => { })
console.error("Error fetching tasks:", error); .catch((error) => {
}) console.error("Error fetching tasks:", error);
.finally(() => { })
setTasksLoading(false); .finally(() => {
}); setTasksLoading(false);
});
} else {
setTasks([]);
setTasksLoading(false);
}
// Fetch statistics independently // Fetch statistics independently
dashboardService dashboardService
@ -216,17 +225,31 @@ const Dashboard = (): ReactElement => {
<h2 className="text-[16px] font-semibold text-[#111827] leading-none"> <h2 className="text-[16px] font-semibold text-[#111827] leading-none">
My Tasks My Tasks
</h2> </h2>
<button {isWorkflowEnabled ? (
onClick={() => navigate("/tenant/workflows/tasks")} <button
className="text-[11px] font-bold hover:underline cursor-pointer" onClick={() => navigate("/tenant/workflows/tasks")}
style={{ color: primaryColor }} className="text-[11px] font-bold hover:underline cursor-pointer"
> style={{ color: primaryColor }}
View all >
</button> View all
</button>
) : (
<span className="text-[11px] font-bold text-gray-400 flex items-center gap-1">
<Lock className="w-3 h-3" /> Locked
</span>
)}
</div> </div>
<div className="flex flex-col gap-3 p-4 w-full"> <div className="flex flex-col gap-3 p-4 w-full">
{tasksLoading ? ( {!isWorkflowEnabled ? (
<div className="flex flex-col items-center justify-center py-6 text-gray-400 gap-2">
<Lock className="w-8 h-8 text-gray-300" />
<span className="text-sm font-medium">Service locked</span>
<span className="text-xs text-center text-gray-400">
The Workflow service is disabled for your organization.
</span>
</div>
) : tasksLoading ? (
<div className="text-center py-4 text-gray-400 text-sm"> <div className="text-center py-4 text-gray-400 text-sm">
Loading tasks... Loading tasks...
</div> </div>

View File

@ -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<PlatformService[]> => {
const response = await apiClient.get<ListPlatformServicesResponse>('/platform-services');
return response.data?.data || [];
},
/**
* Get all services with enabled status for a tenant.
* Auth: Super Admin
*/
getTenantServices: async (tenantId: string): Promise<TenantPlatformService[]> => {
const response = await apiClient.get<ListTenantPlatformServicesResponse>(`/platform-services/tenant/${tenantId}`);
return response.data?.data || [];
},
/**
* Get enabled services for the current tenant.
* Auth: Tenant User / Admin
*/
getMyServices: async (): Promise<string[]> => {
const response = await apiClient.get<ListMyPlatformServicesResponse>('/platform-services/my');
const services = response.data?.data || [];
return services
.filter((s) => s.is_enabled)
.map((s) => s.key);
},
};

View File

@ -15,6 +15,7 @@ export interface CreateTenantRequest {
subscription_tier?: string | null; subscription_tier?: string | null;
max_users?: number | null; max_users?: number | null;
max_modules?: number | null; max_modules?: number | null;
service_keys?: string[] | null;
} }
export interface CreateTenantResponse { export interface CreateTenantResponse {
@ -48,6 +49,7 @@ export interface UpdateTenantRequest {
max_users?: number | null; max_users?: number | null;
max_modules?: number | null; max_modules?: number | null;
module_ids?: string[] | null; module_ids?: string[] | null;
service_keys?: string[] | null;
} }
export interface UpdateTenantResponse { export interface UpdateTenantResponse {