feat: implement service-based sidebar access control with manual locking and service enablement hooks
This commit is contained in:
parent
f7eb25f874
commit
b8a4f4825a
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
111
src/hooks/useEnabledServices.ts
Normal file
111
src/hooks/useEnabledServices.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
77
src/services/platform-service.ts
Normal file
77
src/services/platform-service.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user