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,
|
||||
Bot,
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { useEnabledServices } from "@/hooks/useEnabledServices";
|
||||
import { AuthenticatedImage } from "@/components/shared";
|
||||
|
||||
interface MenuItem {
|
||||
@ -30,9 +32,11 @@ interface MenuItem {
|
||||
label: string;
|
||||
path?: string;
|
||||
isGroup?: boolean;
|
||||
serviceKey?: string;
|
||||
children?: Array<{
|
||||
label: string;
|
||||
path: string;
|
||||
serviceKey?: string;
|
||||
requiredPermission?: {
|
||||
resource: string;
|
||||
action?: string;
|
||||
@ -145,6 +149,7 @@ const tenantAdminPlatformMenu: MenuItem[] = [
|
||||
icon: Users,
|
||||
label: "Suppliers",
|
||||
path: "/tenant/suppliers",
|
||||
serviceKey: "supplier",
|
||||
requiredPermission: { resource: "supplier" },
|
||||
},
|
||||
{
|
||||
@ -160,15 +165,18 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
|
||||
icon: Paperclip,
|
||||
label: "File Attachments",
|
||||
isGroup: true,
|
||||
serviceKey: "file_attachment",
|
||||
children: [
|
||||
{
|
||||
label: "Files List",
|
||||
path: "/tenant/files",
|
||||
serviceKey: "file_attachment",
|
||||
requiredPermission: { resource: "files" },
|
||||
},
|
||||
{
|
||||
label: "Storage Dashboard",
|
||||
path: "/tenant/files/storage-dashboard",
|
||||
serviceKey: "file_attachment",
|
||||
requiredPermission: { resource: "files" },
|
||||
},
|
||||
],
|
||||
@ -178,15 +186,18 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
|
||||
icon: GitBranch,
|
||||
label: "Workflows",
|
||||
isGroup: true,
|
||||
serviceKey: "workflow",
|
||||
children: [
|
||||
{
|
||||
label: "Definitions",
|
||||
path: "/tenant/workflows/definitions",
|
||||
serviceKey: "workflow",
|
||||
requiredPermission: { resource: "workflow" },
|
||||
},
|
||||
{
|
||||
label: "Tasks",
|
||||
path: "/tenant/workflows/tasks",
|
||||
serviceKey: "workflow",
|
||||
requiredPermission: { resource: "workflow" },
|
||||
},
|
||||
],
|
||||
@ -196,25 +207,30 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
|
||||
icon: FileText,
|
||||
label: "Documents",
|
||||
isGroup: true,
|
||||
serviceKey: "document",
|
||||
children: [
|
||||
{
|
||||
label: "Document Lists",
|
||||
path: "/tenant/documents",
|
||||
serviceKey: "document",
|
||||
requiredPermission: { resource: "document" },
|
||||
},
|
||||
{
|
||||
label: "Create Document",
|
||||
path: "/tenant/documents/create",
|
||||
serviceKey: "document",
|
||||
requiredPermission: { resource: "document", action: "create" },
|
||||
},
|
||||
{
|
||||
label: "Categories",
|
||||
path: "/tenant/documents/categories",
|
||||
serviceKey: "document",
|
||||
requiredPermission: { resource: "document" },
|
||||
},
|
||||
{
|
||||
label: "Due for Review",
|
||||
path: "/tenant/documents/due-for-review",
|
||||
serviceKey: "document",
|
||||
requiredPermission: { resource: "document" },
|
||||
},
|
||||
],
|
||||
@ -224,37 +240,32 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
|
||||
icon: Bot,
|
||||
label: "AI Services",
|
||||
isGroup: true,
|
||||
serviceKey: "ai",
|
||||
children: [
|
||||
{
|
||||
label: "Completion History",
|
||||
path: "/tenant/ai/completions",
|
||||
serviceKey: "ai",
|
||||
requiredPermission: { resource: "ai" },
|
||||
},
|
||||
{
|
||||
label: "Prompt Management",
|
||||
path: "/tenant/ai/prompts",
|
||||
serviceKey: "ai",
|
||||
requiredPermission: { resource: "ai" },
|
||||
},
|
||||
{
|
||||
label: "Tenant AI Providers",
|
||||
path: "/tenant/ai/providers",
|
||||
serviceKey: "ai",
|
||||
requiredPermission: { resource: "ai" },
|
||||
},
|
||||
{
|
||||
label: "AI Usage & Cost Dashboard",
|
||||
path: "/tenant/ai/dashboard",
|
||||
serviceKey: "ai",
|
||||
requiredPermission: { resource: "ai" },
|
||||
},
|
||||
// {
|
||||
// label: "Tenant Config",
|
||||
// path: "/tenant/ai/config",
|
||||
// requiredPermission: { resource: "ai" },
|
||||
// },
|
||||
// {
|
||||
// label: "Knowledge (RAG)",
|
||||
// path: "/tenant/ai/knowledge",
|
||||
// requiredPermission: { resource: "ai" },
|
||||
// },
|
||||
],
|
||||
requiredPermission: { resource: "ai" },
|
||||
},
|
||||
@ -277,6 +288,7 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
||||
icon: ShieldCheck,
|
||||
label: "E-Signatures",
|
||||
path: "/tenant/electronic-signatures",
|
||||
serviceKey: "electronic_signature",
|
||||
},
|
||||
{
|
||||
icon: Settings,
|
||||
@ -290,6 +302,7 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
||||
{
|
||||
label: "Security Policy",
|
||||
path: "/tenant/settings/security-policy",
|
||||
serviceKey: "security",
|
||||
},
|
||||
{
|
||||
label: "Notification Settings",
|
||||
@ -310,6 +323,7 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
||||
{
|
||||
label: "Storage Config",
|
||||
path: "/tenant/settings/storage",
|
||||
serviceKey: "storage",
|
||||
},
|
||||
],
|
||||
requiredPermission: { resource: "tenants" },
|
||||
@ -389,19 +403,24 @@ const GroupMenuItem = ({
|
||||
}, [isAnyChildActive]);
|
||||
|
||||
const Icon = item.icon;
|
||||
const { isServiceEnabled } = useEnabledServices();
|
||||
const isLocked = item.serviceKey ? !isServiceEnabled(item.serviceKey) : false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onClick={isLocked ? undefined : () => setIsExpanded(!isExpanded)}
|
||||
title={isLocked ? "This service has been disabled by the Super Admin" : undefined}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2.5 px-3 py-2 rounded-md transition-all min-h-[44px]",
|
||||
isAnyChildActive
|
||||
? "shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]"
|
||||
: "text-[#0f1724] hover:bg-gray-50",
|
||||
isLocked
|
||||
? "text-gray-400 cursor-not-allowed opacity-60 bg-white"
|
||||
: isAnyChildActive
|
||||
? "shadow-[0px_2px_8px_0px_rgba(15,23,42,0.15)]"
|
||||
: "text-[#0f1724] hover:bg-gray-50",
|
||||
)}
|
||||
style={
|
||||
isAnyChildActive
|
||||
!isLocked && isAnyChildActive
|
||||
? {
|
||||
backgroundColor: primaryColor,
|
||||
color: secondaryColor,
|
||||
@ -418,17 +437,36 @@ const GroupMenuItem = ({
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
{isLocked ? (
|
||||
<Lock className="w-3.5 h-3.5 text-gray-400" />
|
||||
) : isExpanded ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</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">
|
||||
{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 (
|
||||
<Link
|
||||
key={child.path}
|
||||
@ -644,78 +682,105 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
|
||||
}: {
|
||||
title: string;
|
||||
items: MenuItem[];
|
||||
}) => (
|
||||
<div className="w-full">
|
||||
<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}
|
||||
}) => {
|
||||
const { isServiceEnabled } = useEnabledServices();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<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 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>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
import { fileService } from "@/services/file-service";
|
||||
import apiClient from "@/services/api-client";
|
||||
import { Loader2, ImageIcon } from "lucide-react";
|
||||
import { useEnabledServices } from "@/hooks/useEnabledServices";
|
||||
|
||||
// Global cache to persist blob URLs between component remounts (prevents re-fetching on every page click)
|
||||
const BLOB_CACHE = new Map<string, string>();
|
||||
@ -31,6 +32,9 @@ export const AuthenticatedImage = ({
|
||||
tenantId,
|
||||
...props
|
||||
}: AuthenticatedImageProps): ReactElement => {
|
||||
const { isServiceEnabled } = useEnabledServices();
|
||||
const isFileServiceEnabled = isServiceEnabled("file_attachment");
|
||||
|
||||
// Helper to extract fileId from backend preview URL
|
||||
const extractFileIdFromUrl = (url: string | null | undefined): string | null => {
|
||||
if (!url) return null;
|
||||
@ -93,6 +97,11 @@ export const AuthenticatedImage = ({
|
||||
|
||||
// 2. If we have a fileId or a backend URL, fetch it via authenticated request
|
||||
if (isAuthRequired) {
|
||||
if (!isFileServiceEnabled) {
|
||||
setError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already have the blobUrl for this cacheKey, use it and don't fetch
|
||||
if (currentCachedUrl) {
|
||||
setBlobUrl(currentCachedUrl);
|
||||
@ -165,7 +174,7 @@ export const AuthenticatedImage = ({
|
||||
// For other external URLs, use them directly
|
||||
setBlobUrl(src);
|
||||
}
|
||||
}, [fileId, src, cacheKey, isAuthRequired, tenantId]);
|
||||
}, [fileId, src, cacheKey, isAuthRequired, tenantId, isFileServiceEnabled]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@ -3,7 +3,8 @@ import type { ReactElement } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, Lock } from "lucide-react";
|
||||
import { useEnabledServices } from "@/hooks/useEnabledServices";
|
||||
import {
|
||||
Modal,
|
||||
FormField,
|
||||
@ -64,6 +65,25 @@ const ALL_RESOURCES = [
|
||||
// All available actions
|
||||
const ALL_ACTIONS = ["create", "read", "update", "delete"];
|
||||
|
||||
// Resource to Platform Service mapping
|
||||
const RESOURCE_SERVICE_MAPPING: Record<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
|
||||
const editRoleSchema = z.object({
|
||||
name: z.string().min(1, "Role name is required"),
|
||||
@ -118,6 +138,7 @@ export const EditRoleModal = ({
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||
Array<{ resource: string; action: string }>
|
||||
>([]);
|
||||
const { isServiceEnabled } = useEnabledServices();
|
||||
// const [expandedResources, setExpandedResources] = useState<Set<string>>(
|
||||
// new Set(),
|
||||
// );
|
||||
@ -590,22 +611,32 @@ export const EditRoleModal = ({
|
||||
{/* Table Body */}
|
||||
<tbody>
|
||||
{Array.from(availableResourcesAndActions.entries()).map(
|
||||
([resource, actions]) => (
|
||||
<tr key={resource}>
|
||||
{/* Resource Name */}
|
||||
<td
|
||||
className="
|
||||
([resource, actions]) => {
|
||||
const serviceKey = RESOURCE_SERVICE_MAPPING[resource];
|
||||
const isResourceLocked = serviceKey ? !isServiceEnabled(serviceKey) : false;
|
||||
|
||||
return (
|
||||
<tr key={resource}>
|
||||
{/* Resource Name */}
|
||||
<td
|
||||
className="
|
||||
w-[204px]
|
||||
h-[53px]
|
||||
px-3 py-[19px]
|
||||
border-b border-[#E5E7EB]
|
||||
text-[14px]
|
||||
text-[#111827]
|
||||
bg-white
|
||||
"
|
||||
>
|
||||
{resource.replace(/_/g, " ")}
|
||||
</td>
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* Action Columns */}
|
||||
{[
|
||||
@ -635,7 +666,7 @@ export const EditRoleModal = ({
|
||||
return false;
|
||||
});
|
||||
|
||||
const isAvailable = actions.has(action);
|
||||
const isAvailable = actions.has(action) && !isResourceLocked;
|
||||
|
||||
return (
|
||||
<td
|
||||
@ -674,8 +705,8 @@ export const EditRoleModal = ({
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@ -3,7 +3,8 @@ import type { ReactElement } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
// import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Lock } from "lucide-react";
|
||||
import { useEnabledServices } from "@/hooks/useEnabledServices";
|
||||
import {
|
||||
Modal,
|
||||
FormField,
|
||||
@ -64,6 +65,25 @@ const ALL_RESOURCES = [
|
||||
// All available actions
|
||||
const ALL_ACTIONS = ["create", "read", "update", "delete"];
|
||||
|
||||
// Resource to Platform Service mapping
|
||||
const RESOURCE_SERVICE_MAPPING: Record<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
|
||||
const newRoleSchema = z.object({
|
||||
name: z.string().min(1, "Role name is required"),
|
||||
@ -110,6 +130,7 @@ export const NewRoleModal = ({
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||
Array<{ resource: string; action: string }>
|
||||
>([]);
|
||||
const { isServiceEnabled } = useEnabledServices();
|
||||
// const [expandedResources, setExpandedResources] = useState<Set<string>>(
|
||||
// new Set(),
|
||||
// );
|
||||
@ -481,61 +502,71 @@ export const NewRoleModal = ({
|
||||
{/* Table Body */}
|
||||
<tbody>
|
||||
{Array.from(availableResourcesAndActions.entries()).map(
|
||||
([resource, actions]) => (
|
||||
<tr key={resource}>
|
||||
{/* Resource Name */}
|
||||
<td
|
||||
className="
|
||||
w-[204px]
|
||||
h-[53px]
|
||||
px-3 py-[19px]
|
||||
border-b border-[#E5E7EB]
|
||||
text-[14px]
|
||||
text-[#111827]
|
||||
bg-white
|
||||
"
|
||||
>
|
||||
{resource.replace(/_/g, " ")}
|
||||
</td>
|
||||
([resource, actions]) => {
|
||||
const serviceKey = RESOURCE_SERVICE_MAPPING[resource];
|
||||
const isResourceLocked = serviceKey ? !isServiceEnabled(serviceKey) : false;
|
||||
|
||||
{/* Action Columns */}
|
||||
{[
|
||||
"read",
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
// "approve",
|
||||
// "admin",
|
||||
].map((action) => {
|
||||
const isChecked = selectedPermissions.some((p) => {
|
||||
if (p.resource === resource && p.action === action)
|
||||
return true;
|
||||
return (
|
||||
<tr key={resource}>
|
||||
{/* Resource Name */}
|
||||
<td
|
||||
className="
|
||||
w-[204px]
|
||||
h-[53px]
|
||||
px-3 py-[19px]
|
||||
border-b border-[#E5E7EB]
|
||||
text-[14px]
|
||||
bg-white
|
||||
"
|
||||
>
|
||||
{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)
|
||||
return true;
|
||||
{/* Action Columns */}
|
||||
{[
|
||||
"read",
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
// "approve",
|
||||
// "admin",
|
||||
].map((action) => {
|
||||
const isChecked = selectedPermissions.some((p) => {
|
||||
if (p.resource === resource && p.action === action)
|
||||
return true;
|
||||
|
||||
if (p.resource === resource && p.action === "*")
|
||||
return true;
|
||||
if (p.resource === "*" && p.action === action)
|
||||
return true;
|
||||
|
||||
if (p.resource === "*" && p.action === "*")
|
||||
return true;
|
||||
if (p.resource === resource && p.action === "*")
|
||||
return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
if (p.resource === "*" && p.action === "*")
|
||||
return true;
|
||||
|
||||
const isAvailable = actions.has(action);
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<td
|
||||
key={`${resource}-${action}`}
|
||||
className="
|
||||
w-[120px]
|
||||
px-3 py-[10px]
|
||||
text-center
|
||||
border-b border-[#E5E7EB]
|
||||
bg-white
|
||||
"
|
||||
>
|
||||
const isAvailable = actions.has(action) && !isResourceLocked;
|
||||
|
||||
return (
|
||||
<td
|
||||
key={`${resource}-${action}`}
|
||||
className="
|
||||
w-[120px]
|
||||
px-3 py-[10px]
|
||||
text-center
|
||||
border-b border-[#E5E7EB]
|
||||
bg-white
|
||||
"
|
||||
>
|
||||
<div className="flex justify-center items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -562,8 +593,8 @@ export const NewRoleModal = ({
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
),
|
||||
)}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</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 { moduleService } from "@/services/module-service";
|
||||
import { fileService } from "@/services/file-service";
|
||||
import { platformServiceApi, type PlatformService } from "@/services/platform-service";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { ChevronRight, ChevronLeft, Image as ImageIcon, X } from "lucide-react";
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
Image as ImageIcon,
|
||||
X,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
AlertTriangle,
|
||||
Factory,
|
||||
GitBranch,
|
||||
FolderOpen,
|
||||
BarChart2,
|
||||
PenTool,
|
||||
Brain,
|
||||
Link2,
|
||||
Paperclip,
|
||||
HardDrive,
|
||||
Shield,
|
||||
CreditCard,
|
||||
Settings,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
// Icon mapping helper
|
||||
const serviceIconMap: Record<string, any> = {
|
||||
FileText,
|
||||
GraduationCap,
|
||||
AlertTriangle,
|
||||
Factory,
|
||||
GitBranch,
|
||||
FolderOpen,
|
||||
BarChart2,
|
||||
PenTool,
|
||||
Brain,
|
||||
Link2,
|
||||
Paperclip,
|
||||
HardDrive,
|
||||
Shield,
|
||||
CreditCard,
|
||||
};
|
||||
|
||||
// Step 1: Tenant Details Schema - matches NewTenantModal
|
||||
const tenantDetailsSchema = z.object({
|
||||
name: z
|
||||
@ -186,6 +226,31 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
Array<{ value: string; label: string }>
|
||||
>([]);
|
||||
|
||||
// Platform Services state
|
||||
const [platformServices, setPlatformServices] = useState<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
|
||||
const tenantDetailsForm = useForm<TenantDetailsForm>({
|
||||
resolver: zodResolver(tenantDetailsSchema),
|
||||
@ -477,6 +542,7 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
const tenantData = {
|
||||
...restTenantDetails,
|
||||
module_ids: selectedModules.length > 0 ? selectedModules : undefined,
|
||||
service_keys: selectedServices,
|
||||
settings: {
|
||||
enable_sso,
|
||||
enable_2fa,
|
||||
@ -952,6 +1018,84 @@ const CreateTenantWizard = (): ReactElement => {
|
||||
initialOptions={initialModuleOptions}
|
||||
error={tenantDetailsForm.formState.errors.modules?.message}
|
||||
/>
|
||||
|
||||
{/* Platform Services Section */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
import { tenantService } from "@/services/tenant-service";
|
||||
import { moduleService } from "@/services/module-service";
|
||||
import { fileService } from "@/services/file-service";
|
||||
import { platformServiceApi, type TenantPlatformService } from "@/services/platform-service";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import {
|
||||
ChevronRight,
|
||||
@ -23,9 +24,42 @@ import {
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
X,
|
||||
FileText,
|
||||
GraduationCap,
|
||||
AlertTriangle,
|
||||
Factory,
|
||||
GitBranch,
|
||||
FolderOpen,
|
||||
BarChart2,
|
||||
PenTool,
|
||||
Brain,
|
||||
Link2,
|
||||
Paperclip,
|
||||
HardDrive,
|
||||
Shield,
|
||||
CreditCard,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
// Icon mapping helper
|
||||
const serviceIconMap: Record<string, any> = {
|
||||
FileText,
|
||||
GraduationCap,
|
||||
AlertTriangle,
|
||||
Factory,
|
||||
GitBranch,
|
||||
FolderOpen,
|
||||
BarChart2,
|
||||
PenTool,
|
||||
Brain,
|
||||
Link2,
|
||||
Paperclip,
|
||||
HardDrive,
|
||||
Shield,
|
||||
CreditCard,
|
||||
};
|
||||
|
||||
// Step 1: Tenant Details Schema
|
||||
const tenantDetailsSchema = z.object({
|
||||
name: z
|
||||
@ -196,6 +230,16 @@ const EditTenant = (): ReactElement => {
|
||||
Array<{ value: string; label: string }>
|
||||
>([]);
|
||||
|
||||
// Platform Services state
|
||||
const [platformServices, setPlatformServices] = useState<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
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [faviconFile, setFaviconFile] = useState<File | null>(null);
|
||||
@ -436,6 +480,15 @@ const EditTenant = (): ReactElement => {
|
||||
secondary_color: secondaryColor,
|
||||
accent_color: accentColor,
|
||||
});
|
||||
|
||||
// Load Platform Services for the tenant
|
||||
try {
|
||||
const tenantServices = await platformServiceApi.getTenantServices(id);
|
||||
setPlatformServices(tenantServices);
|
||||
setSelectedServices(tenantServices.filter((s) => s.is_enabled).map((s) => s.key));
|
||||
} catch (svcErr) {
|
||||
console.error("Failed to load tenant platform services:", svcErr);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setLoadError(
|
||||
err?.response?.data?.error?.message ||
|
||||
@ -488,6 +541,11 @@ const EditTenant = (): ReactElement => {
|
||||
if (isValid) {
|
||||
setCurrentStep(3);
|
||||
}
|
||||
} else if (currentStep === 3) {
|
||||
const isValid = await settingsForm.trigger();
|
||||
if (isValid) {
|
||||
setCurrentStep(4);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -611,6 +669,7 @@ const EditTenant = (): ReactElement => {
|
||||
const tenantData = {
|
||||
...restTenantDetails,
|
||||
module_ids: selectedModules.length > 0 ? selectedModules : undefined,
|
||||
service_keys: selectedServices,
|
||||
settings: {
|
||||
enable_sso,
|
||||
enable_2fa,
|
||||
@ -802,6 +861,13 @@ const EditTenant = (): ReactElement => {
|
||||
title: "Settings",
|
||||
description: "Security & branding",
|
||||
isActive: currentStep === 3,
|
||||
isCompleted: currentStep > 3,
|
||||
},
|
||||
{
|
||||
number: 4,
|
||||
title: "Platform Services",
|
||||
description: "Up/Down service control",
|
||||
isActive: currentStep === 4,
|
||||
isCompleted: false,
|
||||
},
|
||||
];
|
||||
@ -1715,6 +1781,100 @@ const EditTenant = (): ReactElement => {
|
||||
</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 */}
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[rgba(0,0,0,0.08)]">
|
||||
{currentStep > 1 && (
|
||||
@ -1723,7 +1883,7 @@ const EditTenant = (): ReactElement => {
|
||||
Previous
|
||||
</SecondaryButton>
|
||||
)}
|
||||
{currentStep < 3 ? (
|
||||
{currentStep < 4 ? (
|
||||
<PrimaryButton onClick={handleNext} disabled={isSubmitting}>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import type { ReactElement } from "react";
|
||||
import { FileCheck, Briefcase, FileText, Users, Bell } from "lucide-react";
|
||||
import { FileCheck, Briefcase, FileText, Users, Bell, Lock } from "lucide-react";
|
||||
import { QuickActions } from "@/features/dashboard/components/QuickActions";
|
||||
import { RecentActivity } from "@/features/dashboard/components/RecentActivity";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAppTheme } from "@/hooks/useAppTheme";
|
||||
import { useEnabledServices } from "@/hooks/useEnabledServices";
|
||||
import { workflowService } from "@/services/workflow-service";
|
||||
import {
|
||||
dashboardService,
|
||||
@ -99,28 +100,36 @@ const Dashboard = (): ReactElement => {
|
||||
const [stats, setStats] = useState<TenantDashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { isServiceEnabled } = useEnabledServices();
|
||||
const isWorkflowEnabled = isServiceEnabled("workflow");
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
}, [isWorkflowEnabled]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
setLoading(true);
|
||||
setTasksLoading(true);
|
||||
try {
|
||||
// Fetch tasks independently
|
||||
workflowService
|
||||
.listTasks({ limit: 3 })
|
||||
.then((response) => {
|
||||
if (response.success && Array.isArray(response.data)) {
|
||||
setTasks(response.data);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching tasks:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setTasksLoading(false);
|
||||
});
|
||||
// Fetch tasks independently only if workflow service is enabled
|
||||
if (isWorkflowEnabled) {
|
||||
workflowService
|
||||
.listTasks({ limit: 3 })
|
||||
.then((response) => {
|
||||
if (response.success && Array.isArray(response.data)) {
|
||||
setTasks(response.data);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error fetching tasks:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setTasksLoading(false);
|
||||
});
|
||||
} else {
|
||||
setTasks([]);
|
||||
setTasksLoading(false);
|
||||
}
|
||||
|
||||
// Fetch statistics independently
|
||||
dashboardService
|
||||
@ -216,17 +225,31 @@ const Dashboard = (): ReactElement => {
|
||||
<h2 className="text-[16px] font-semibold text-[#111827] leading-none">
|
||||
My Tasks
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => navigate("/tenant/workflows/tasks")}
|
||||
className="text-[11px] font-bold hover:underline cursor-pointer"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
{isWorkflowEnabled ? (
|
||||
<button
|
||||
onClick={() => navigate("/tenant/workflows/tasks")}
|
||||
className="text-[11px] font-bold hover:underline cursor-pointer"
|
||||
style={{ color: primaryColor }}
|
||||
>
|
||||
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 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">
|
||||
Loading tasks...
|
||||
</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;
|
||||
max_users?: number | null;
|
||||
max_modules?: number | null;
|
||||
service_keys?: string[] | null;
|
||||
}
|
||||
|
||||
export interface CreateTenantResponse {
|
||||
@ -48,6 +49,7 @@ export interface UpdateTenantRequest {
|
||||
max_users?: number | null;
|
||||
max_modules?: number | null;
|
||||
module_ids?: string[] | null;
|
||||
service_keys?: string[] | null;
|
||||
}
|
||||
|
||||
export interface UpdateTenantResponse {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user