feat: Implement module-based access control system with integrated authentication, authorization, and security utilities.
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
||||
# Development environment
|
||||
VITE_API_BASE_URL=https://dev-api.yourdomain.com
|
||||
VITE_API_BASE_URL=https://saas-dev.maskantech.in
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
# Production environment
|
||||
VITE_API_BASE_URL=https://api.yourdomain.com
|
||||
VITE_API_BASE_URL=https://api.yourdomain.com
|
||||
+5
-1
@@ -6,8 +6,12 @@
|
||||
"scripts": {
|
||||
"local": "vite --mode localhost",
|
||||
"dev": "vite --mode development",
|
||||
"test": "vite --mode test",
|
||||
"prod": "vite --mode production",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:dev": "tsc -b && vite build --mode development",
|
||||
"build:test": "tsc -b && vite build --mode test",
|
||||
"build:prod": "tsc -b && vite build --mode production",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
@@ -43,4 +47,4 @@
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExternalLink, Box } from 'lucide-react';
|
||||
import { moduleApi, type Module } from '../module/ModuleApi';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import axios from 'axios';
|
||||
import { ExternalLink, Box, Search, Rocket } from 'lucide-react';
|
||||
import { moduleApi, type Module } from '../modules/user/UserModuleApi';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const Dashboard = () => {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { user } = useAuth();
|
||||
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchModules = async () => {
|
||||
@@ -26,87 +29,162 @@ const Dashboard = () => {
|
||||
const handleModuleClick = async (moduleId: string) => {
|
||||
try {
|
||||
const response = await moduleApi.launchModule(moduleId);
|
||||
|
||||
await axios.post(response.target_url, response.payload, {
|
||||
headers: response.headers,
|
||||
withCredentials: true
|
||||
});
|
||||
|
||||
if (response.redirect_url) {
|
||||
window.location.href = response.redirect_url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to launch module', error);
|
||||
alert("Failed to launch module. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const groupedModules = useMemo(() => {
|
||||
const filtered = modules.filter(m =>
|
||||
m.module_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(m.description && m.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
|
||||
const groups: Record<string, Module[]> = {};
|
||||
|
||||
if (filtered.length === 0) return {};
|
||||
|
||||
filtered.forEach(module => {
|
||||
const category = module.category || 'All Modules';
|
||||
if (!groups[category]) {
|
||||
groups[category] = [];
|
||||
}
|
||||
groups[category].push(module);
|
||||
});
|
||||
|
||||
return Object.keys(groups).sort().reduce(
|
||||
(obj, key) => {
|
||||
obj[key] = groups[key];
|
||||
return obj;
|
||||
},
|
||||
{} as Record<string, Module[]>
|
||||
);
|
||||
}, [modules, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-(--text-primary) mb-2">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="text-(--text-secondary)">
|
||||
{t('message')}
|
||||
</p>
|
||||
<div className="p-8 space-y-10 min-h-screen bg-gray-50/50">
|
||||
{/* Hero Section */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6 animate-fade-in-up">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-(--text-primary) tracking-tight mb-2">
|
||||
Welcome back, {user?.first_name || 'Admin'}! 👋
|
||||
</h1>
|
||||
<p className="text-lg text-(--text-secondary)">
|
||||
Manage your SaaS ecosystem from one central hub.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full md:w-auto">
|
||||
<div className="relative group w-full md:w-64">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400 group-focus-within:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search available modules..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2.5 bg-white border border-gray-200 rounded-xl leading-5 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm hover:shadow-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modules Section */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-(--text-primary) mb-4">
|
||||
Available Modules
|
||||
</h2>
|
||||
|
||||
{/* Modules Grid */}
|
||||
<div className="space-y-12">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6 animate-pulse"
|
||||
>
|
||||
<div className="h-12 w-12 bg-gray-300 rounded-lg mb-4"></div>
|
||||
<div className="h-6 bg-gray-300 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-300 rounded w-full"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-white border border-gray-100 rounded-2xl p-6 h-48 animate-pulse shadow-sm">
|
||||
<div className="h-12 w-12 bg-gray-200 rounded-xl mb-4"></div>
|
||||
<div className="h-6 bg-gray-200 rounded w-3/4 mb-3"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : modules.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{modules.map((module) => (
|
||||
<button
|
||||
key={module.module_id}
|
||||
onClick={() => handleModuleClick(module.module_id)}
|
||||
className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6 hover:shadow-lg transition-all duration-200 text-left group hover:border-blue-500"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{module.icon_url ? (
|
||||
<img
|
||||
src={module.icon_url}
|
||||
alt={module.module_name}
|
||||
className="w-12 h-12 rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<Box className="w-6 h-6 text-blue-600" />
|
||||
) : Object.keys(groupedModules).length > 0 ? (
|
||||
Object.entries(groupedModules).map(([category, categoryModules]) => (
|
||||
<section key={category} className="animate-fade-in">
|
||||
<h2 className="text-xl font-bold text-(--text-primary) mb-6 flex items-center gap-2">
|
||||
<span className="w-1.5 h-6 bg-blue-500 rounded-full inline-block"></span>
|
||||
{category}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{categoryModules.map((module) => (
|
||||
<div
|
||||
key={module.module_id}
|
||||
className="group bg-white border border-gray-100 rounded-2xl p-6 hover:shadow-xl hover:border-blue-100 transition-all duration-300 relative overflow-hidden flex flex-col h-full"
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-6 opacity-0 group-hover:opacity-100 transition-opacity transform translate-x-2 group-hover:translate-x-0 duration-300">
|
||||
<ExternalLink className="text-blue-500" size={20} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="mb-6 relative">
|
||||
<div className="absolute inset-0 bg-blue-500/5 rounded-xl blur-xl opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
{module.icon_url ? (
|
||||
<img
|
||||
src={module.icon_url}
|
||||
alt={module.module_name}
|
||||
className="w-14 h-14 rounded-xl object-contain relative z-10 bg-white p-1 shadow-sm border border-gray-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-xl bg-linear-to-br from-blue-50 to-indigo-50 border border-blue-100 flex items-center justify-center relative z-10 text-blue-600 shadow-sm">
|
||||
<Box className="w-7 h-7" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors">
|
||||
{module.module_name}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-500 leading-relaxed line-clamp-2 mb-4">
|
||||
{module.description || "Access powerful tools designed for efficient management and scalability."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-50 mt-auto">
|
||||
<button
|
||||
onClick={() => handleModuleClick(module.module_id)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-gray-50 hover:bg-blue-600 text-gray-700 hover:text-white rounded-xl font-medium transition-all duration-200 group-hover:shadow-md"
|
||||
>
|
||||
<Rocket size={18} className="transition-transform group-hover:-translate-y-0.5 group-hover:translate-x-0.5" />
|
||||
Launch Module
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="w-5 h-5 text-(--text-secondary) group-hover:text-blue-500 transition-colors" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-(--text-primary) mb-2">
|
||||
{module.module_name}
|
||||
</h3>
|
||||
|
||||
{module.description && (
|
||||
<p className="text-sm text-(--text-secondary) line-clamp-2">
|
||||
{module.description}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-8 text-center">
|
||||
<Box className="w-12 h-12 text-(--text-secondary) mx-auto mb-4" />
|
||||
<p className="text-(--text-secondary)">
|
||||
No modules available
|
||||
<div className="flex flex-col items-center justify-center p-12 bg-white border border-gray-100 rounded-2xl text-center shadow-sm">
|
||||
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-4">
|
||||
<Box className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">No modules found</h3>
|
||||
<p className="text-gray-500 max-w-sm mx-auto">
|
||||
{searchTerm ? `No results found for "${searchTerm}". Try a different keyword.` : "Your dashboard is currently empty. Contact your administrator to assign modules."}
|
||||
</p>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="mt-4 text-blue-600 font-medium hover:underline"
|
||||
>
|
||||
Clear Search
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import axios from 'axios';
|
||||
import { API_BASE_URL } from '../../../constant';
|
||||
import type {
|
||||
Module,
|
||||
ModuleCreate,
|
||||
ModuleUpdate,
|
||||
ModuleEnvironment,
|
||||
EnvironmentCreate,
|
||||
EnvironmentUpdate,
|
||||
ModulePermission,
|
||||
TenantModule,
|
||||
TenantModuleCreate,
|
||||
TenantModuleUpdate
|
||||
} from './AdminModuleTypes';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
export const adminModuleApi = {
|
||||
listModules: async (): Promise<Module[]> => {
|
||||
const response = await api.get('/api/admin/modules/');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createModule: async (data: ModuleCreate): Promise<Module> => {
|
||||
const response = await api.post('/api/admin/modules/', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getModule: async (moduleId: string): Promise<Module> => {
|
||||
const response = await api.get(`/api/admin/modules/${moduleId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateModule: async (moduleId: string, data: ModuleUpdate): Promise<Module> => {
|
||||
const response = await api.put(`/api/admin/modules/${moduleId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteModule: async (moduleId: string): Promise<void> => {
|
||||
await api.delete(`/api/admin/modules/${moduleId}`);
|
||||
},
|
||||
|
||||
listEnvironments: async (moduleId: string): Promise<ModuleEnvironment[]> => {
|
||||
const response = await api.get(`/api/admin/modules/${moduleId}/environments`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createEnvironment: async (moduleId: string, data: EnvironmentCreate): Promise<ModuleEnvironment> => {
|
||||
const response = await api.post(`/api/admin/modules/${moduleId}/environments`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateEnvironment: async (moduleId: string, envId: string, data: EnvironmentUpdate): Promise<ModuleEnvironment> => {
|
||||
const response = await api.put(`/api/admin/modules/${moduleId}/environments/${envId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
setDefaultEnvironment: async (moduleId: string, envId: string): Promise<void> => {
|
||||
await api.patch(`/api/admin/modules/${moduleId}/environments/${envId}/default`);
|
||||
},
|
||||
|
||||
deleteEnvironment: async (moduleId: string, envId: string): Promise<void> => {
|
||||
await api.delete(`/api/admin/modules/${moduleId}/environments/${envId}`);
|
||||
},
|
||||
|
||||
getModulePermissions: async (moduleId: string): Promise<ModulePermission[]> => {
|
||||
const response = await api.get(`/api/admin/modules/${moduleId}/permissions`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
syncModulePermissions: async (moduleId: string): Promise<{ message: string; synced_count: number }> => {
|
||||
const response = await api.post(`/api/admin/modules/${moduleId}/permissions/sync`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
listTenantModules: async (tenantId: string): Promise<TenantModule[]> => {
|
||||
const response = await api.get(`/api/admin/tenants/${tenantId}/modules`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
assignModuleToTenant: async (tenantId: string, data: TenantModuleCreate): Promise<TenantModule> => {
|
||||
const response = await api.post(`/api/admin/tenants/${tenantId}/modules`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTenantModule: async (tenantId: string, tenantModuleId: string, data: TenantModuleUpdate): Promise<TenantModule> => {
|
||||
const response = await api.put(`/api/admin/tenants/${tenantId}/modules/${tenantModuleId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
removeTenantModule: async (tenantId: string, tenantModuleId: string): Promise<void> => {
|
||||
await api.delete(`/api/admin/tenants/${tenantId}/modules/${tenantModuleId}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
export type ModuleStatus = 'active' | 'inactive';
|
||||
export type EnvironmentTrustType = 'internal' | 'full' | 'none';
|
||||
|
||||
export interface Module {
|
||||
id: string;
|
||||
module_id: string;
|
||||
module_name: string;
|
||||
description?: string;
|
||||
icon_url?: string;
|
||||
status: ModuleStatus;
|
||||
display_order: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ModuleCreate {
|
||||
module_id: string;
|
||||
module_name: string;
|
||||
description?: string;
|
||||
icon_url?: string;
|
||||
status?: ModuleStatus;
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
export interface ModuleUpdate {
|
||||
module_name?: string;
|
||||
description?: string;
|
||||
icon_url?: string;
|
||||
status?: ModuleStatus;
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
export interface ModuleEnvironment {
|
||||
id: string;
|
||||
module_id: string;
|
||||
slug: string;
|
||||
frontend_base_url: string;
|
||||
backend_base_url: string;
|
||||
sso_entry_path: string;
|
||||
permission_sync_endpoint: string;
|
||||
provisioning_endpoint: string;
|
||||
trust_type: EnvironmentTrustType;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface EnvironmentCreate {
|
||||
slug: string;
|
||||
frontend_base_url: string;
|
||||
backend_base_url: string;
|
||||
sso_entry_path?: string;
|
||||
permission_sync_endpoint?: string;
|
||||
provisioning_endpoint?: string;
|
||||
trust_type?: EnvironmentTrustType;
|
||||
trust_credentials?: Record<string, any>;
|
||||
is_default?: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface EnvironmentUpdate {
|
||||
slug?: string;
|
||||
frontend_base_url?: string;
|
||||
backend_base_url?: string;
|
||||
sso_entry_path?: string;
|
||||
permission_sync_endpoint?: string;
|
||||
provisioning_endpoint?: string;
|
||||
trust_type?: EnvironmentTrustType;
|
||||
trust_credentials?: Record<string, any>;
|
||||
is_default?: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface ModulePermission {
|
||||
id: string;
|
||||
access_code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
parent_id?: string;
|
||||
scope: string;
|
||||
module_id: string;
|
||||
}
|
||||
|
||||
export interface TenantModule {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
module_id: string;
|
||||
module_name: string;
|
||||
module_icon_url?: string;
|
||||
assigned_environment_slug: string;
|
||||
is_active: boolean;
|
||||
module_config?: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TenantModuleCreate {
|
||||
module_id: string;
|
||||
assigned_environment_slug?: string;
|
||||
is_active?: boolean;
|
||||
module_config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface TenantModuleUpdate {
|
||||
assigned_environment_slug?: string;
|
||||
is_active?: boolean;
|
||||
module_config?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { adminModuleApi } from '../AdminModuleApi';
|
||||
import type { ModuleEnvironment, EnvironmentCreate, EnvironmentUpdate } from '../AdminModuleTypes';
|
||||
|
||||
interface EnvironmentFormProps {
|
||||
moduleId: string;
|
||||
environment: ModuleEnvironment | null;
|
||||
onClose: (saved: boolean) => void;
|
||||
}
|
||||
|
||||
const EnvironmentForm = ({ moduleId, environment, onClose }: EnvironmentFormProps) => {
|
||||
const [formData, setFormData] = useState({
|
||||
slug: '',
|
||||
frontend_base_url: '',
|
||||
backend_base_url: '',
|
||||
sso_entry_path: '/sso/callback',
|
||||
permission_sync_endpoint: '/internal/permissions',
|
||||
provisioning_endpoint: '/internal/tenants/provision',
|
||||
trust_type: 'hmac',
|
||||
hmac_secret: '',
|
||||
is_default: false,
|
||||
is_active: true,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (environment) {
|
||||
setFormData({
|
||||
slug: environment.slug,
|
||||
frontend_base_url: environment.frontend_base_url,
|
||||
backend_base_url: environment.backend_base_url,
|
||||
sso_entry_path: environment.sso_entry_path,
|
||||
permission_sync_endpoint: environment.permission_sync_endpoint,
|
||||
provisioning_endpoint: environment.provisioning_endpoint,
|
||||
trust_type: environment.trust_type,
|
||||
hmac_secret: '',
|
||||
is_default: environment.is_default,
|
||||
is_active: environment.is_active,
|
||||
});
|
||||
}
|
||||
}, [environment]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const trust_credentials = formData.trust_type === 'hmac'
|
||||
? { hmac_secret: formData.hmac_secret }
|
||||
: { secret_key: formData.hmac_secret };
|
||||
|
||||
try {
|
||||
if (environment) {
|
||||
const updateData: EnvironmentUpdate = {
|
||||
slug: formData.slug,
|
||||
frontend_base_url: formData.frontend_base_url,
|
||||
backend_base_url: formData.backend_base_url,
|
||||
sso_entry_path: formData.sso_entry_path,
|
||||
permission_sync_endpoint: formData.permission_sync_endpoint,
|
||||
provisioning_endpoint: formData.provisioning_endpoint,
|
||||
trust_type: formData.trust_type as any,
|
||||
...(formData.hmac_secret && { trust_credentials }),
|
||||
is_default: formData.is_default,
|
||||
is_active: formData.is_active,
|
||||
};
|
||||
await adminModuleApi.updateEnvironment(moduleId, environment.id, updateData);
|
||||
} else {
|
||||
const createData: EnvironmentCreate = {
|
||||
slug: formData.slug,
|
||||
frontend_base_url: formData.frontend_base_url,
|
||||
backend_base_url: formData.backend_base_url,
|
||||
sso_entry_path: formData.sso_entry_path,
|
||||
permission_sync_endpoint: formData.permission_sync_endpoint,
|
||||
provisioning_endpoint: formData.provisioning_endpoint,
|
||||
trust_type: formData.trust_type as any,
|
||||
trust_credentials,
|
||||
is_default: formData.is_default,
|
||||
is_active: formData.is_active,
|
||||
};
|
||||
await adminModuleApi.createEnvironment(moduleId, createData);
|
||||
}
|
||||
onClose(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to save environment');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">{environment ? 'Edit' : 'Create'} Environment</h2>
|
||||
<button onClick={() => onClose(false)} className="p-2 hover:bg-gray-100 rounded-lg">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">{error}</div>}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Environment Slug *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="prod, staging, eu-prod"
|
||||
required
|
||||
disabled={!!environment}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Trust Type *</label>
|
||||
<select
|
||||
value={formData.trust_type}
|
||||
onChange={(e) => setFormData({ ...formData, trust_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="hmac">HMAC-SHA256</option>
|
||||
<option value="static_key">Static Key</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Frontend Base URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.frontend_base_url}
|
||||
onChange={(e) => setFormData({ ...formData, frontend_base_url: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="https://module.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Backend Base URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
value={formData.backend_base_url}
|
||||
onChange={(e) => setFormData({ ...formData, backend_base_url: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="https://api.module.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">SSO Entry Path *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.sso_entry_path}
|
||||
onChange={(e) => setFormData({ ...formData, sso_entry_path: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Permission Sync Endpoint *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.permission_sync_endpoint}
|
||||
onChange={(e) => setFormData({ ...formData, permission_sync_endpoint: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Provisioning Endpoint *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.provisioning_endpoint}
|
||||
onChange={(e) => setFormData({ ...formData, provisioning_endpoint: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
{formData.trust_type === 'hmac' ? 'HMAC Secret' : 'Secret Key'} {!environment && '*'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.hmac_secret}
|
||||
onChange={(e) => setFormData({ ...formData, hmac_secret: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={environment ? "Leave blank to keep existing" : "Enter secret"}
|
||||
required={!environment}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">Stored securely on backend</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_default}
|
||||
onChange={(e) => setFormData({ ...formData, is_default: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">Set as default environment</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">Active</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Saving...' : (environment ? 'Update' : 'Create')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose(false)}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentForm;
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Plus, Edit, Trash2, Check, X } from 'lucide-react';
|
||||
import { adminModuleApi } from '../AdminModuleApi';
|
||||
import type { ModuleEnvironment, Module } from '../AdminModuleTypes';
|
||||
import EnvironmentForm from './EnvironmentForm';
|
||||
|
||||
const ModuleEnvironments = () => {
|
||||
const { moduleId } = useParams<{ moduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [module, setModule] = useState<Module | null>(null);
|
||||
const [environments, setEnvironments] = useState<ModuleEnvironment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedEnv, setSelectedEnv] = useState<ModuleEnvironment | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!moduleId) return;
|
||||
|
||||
try {
|
||||
const [moduleData, envsData] = await Promise.all([
|
||||
adminModuleApi.getModule(moduleId),
|
||||
adminModuleApi.listEnvironments(moduleId)
|
||||
]);
|
||||
setModule(moduleData);
|
||||
setEnvironments(envsData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [moduleId]);
|
||||
|
||||
const handleSetDefault = async (envId: string) => {
|
||||
if (!moduleId) return;
|
||||
|
||||
try {
|
||||
await adminModuleApi.setDefaultEnvironment(moduleId, envId);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Failed to set default', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (env: ModuleEnvironment) => {
|
||||
if (!moduleId) return;
|
||||
if (!confirm(`Delete environment "${env.slug}"?`)) return;
|
||||
|
||||
try {
|
||||
await adminModuleApi.deleteEnvironment(moduleId, env.id);
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to delete environment');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-6">Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/admin/modules')}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-(--text-primary)">
|
||||
{module?.module_name} - Environments
|
||||
</h1>
|
||||
<p className="text-(--text-secondary) mt-1">Configure environment-specific settings</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setSelectedEnv(null); setShowForm(true); }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Add Environment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{environments.map((env) => (
|
||||
<div key={env.id} className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">{env.slug}</h3>
|
||||
{env.is_default && (
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs font-medium rounded">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
{env.is_active ? (
|
||||
<Check className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Frontend:</span>
|
||||
<p className="text-(--text-primary) font-mono text-xs break-all">{env.frontend_base_url}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Backend:</span>
|
||||
<p className="text-(--text-primary) font-mono text-xs break-all">{env.backend_base_url}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="text-gray-500">Trust:</span>
|
||||
<p className="text-(--text-primary)">{env.trust_type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">SSO Path:</span>
|
||||
<p className="text-(--text-primary) font-mono text-xs">{env.sso_entry_path}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2 border-t">
|
||||
{!env.is_default && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(env.id)}
|
||||
className="flex-1 px-3 py-2 bg-green-50 text-green-600 rounded-lg hover:bg-green-100 text-sm"
|
||||
>
|
||||
Set Default
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setSelectedEnv(env); setShowForm(true); }}
|
||||
className="flex-1 px-3 py-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<Edit size={16} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(env)}
|
||||
className="flex-1 px-3 py-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100 text-sm flex items-center justify-center gap-2"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{environments.length === 0 && (
|
||||
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-12 text-center">
|
||||
<p className="text-(--text-secondary) mb-4">No environments configured</p>
|
||||
<button
|
||||
onClick={() => { setSelectedEnv(null); setShowForm(true); }}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Add First Environment
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && moduleId && (
|
||||
<EnvironmentForm
|
||||
moduleId={moduleId}
|
||||
environment={selectedEnv}
|
||||
onClose={(saved) => {
|
||||
setShowForm(false);
|
||||
setSelectedEnv(null);
|
||||
if (saved) fetchData();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModuleEnvironments;
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { X } from 'lucide-react';
|
||||
import { useModuleApi } from '../hooks/useModuleApi';
|
||||
import type { Module, ModuleCreate, ModuleUpdate, ModuleStatus } from '../AdminModuleTypes';
|
||||
import CustomInput from '../../../../components/custom/CustomInput';
|
||||
import CustomButton from '../../../../components/custom/CustomButton';
|
||||
|
||||
interface ModuleFormProps {
|
||||
module: Module | null;
|
||||
onClose: (saved: boolean) => void;
|
||||
}
|
||||
|
||||
interface ModuleFormData {
|
||||
module_id: string;
|
||||
module_name: string;
|
||||
description: string;
|
||||
icon_url: string;
|
||||
status: ModuleStatus;
|
||||
display_order: number;
|
||||
}
|
||||
|
||||
const ModuleForm = ({ module, onClose }: ModuleFormProps) => {
|
||||
const { createModule, updateModule, loading } = useModuleApi();
|
||||
|
||||
const { register, handleSubmit, formState: { errors }, reset } = useForm<ModuleFormData>({
|
||||
defaultValues: {
|
||||
module_id: '',
|
||||
module_name: '',
|
||||
description: '',
|
||||
icon_url: '',
|
||||
status: 'active',
|
||||
display_order: 0,
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (module) {
|
||||
reset({
|
||||
module_id: module.module_id,
|
||||
module_name: module.module_name,
|
||||
description: module.description || '',
|
||||
icon_url: module.icon_url || '',
|
||||
status: module.status,
|
||||
display_order: module.display_order,
|
||||
});
|
||||
}
|
||||
}, [module, reset]);
|
||||
|
||||
const onSubmit = async (data: ModuleFormData) => {
|
||||
let success;
|
||||
if (module) {
|
||||
const updateData: ModuleUpdate = {
|
||||
module_name: data.module_name,
|
||||
description: data.description || undefined,
|
||||
icon_url: data.icon_url || undefined,
|
||||
status: data.status,
|
||||
display_order: data.display_order,
|
||||
};
|
||||
success = await updateModule(module.id, updateData);
|
||||
} else {
|
||||
const createData: ModuleCreate = {
|
||||
module_id: data.module_id,
|
||||
module_name: data.module_name,
|
||||
description: data.description || undefined,
|
||||
icon_url: data.icon_url || undefined,
|
||||
status: data.status,
|
||||
display_order: data.display_order,
|
||||
};
|
||||
success = await createModule(createData);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
onClose(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-(--card-bg) rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-(--card-border)">
|
||||
<div className="sticky top-0 bg-(--card-bg) border-b border-(--card-border) px-6 py-4 flex items-center justify-between z-10">
|
||||
<h2 className="text-xl font-semibold text-(--text-primary)">
|
||||
{module ? 'Edit Module' : 'Create Module'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => onClose(false)}
|
||||
className="p-2 hover:bg-(--background) rounded-lg transition-colors text-(--text-secondary) hover:text-(--text-primary)"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-6 space-y-4">
|
||||
<CustomInput
|
||||
label="Module ID *"
|
||||
placeholder="e.g., inventory"
|
||||
{...register('module_id', {
|
||||
required: 'Module ID is required',
|
||||
pattern: {
|
||||
value: /^[a-z0-9-]+$/,
|
||||
message: 'Only lowercase alphanumeric and hyphens allowed'
|
||||
}
|
||||
})}
|
||||
error={errors.module_id?.message}
|
||||
disabled={!!module}
|
||||
/>
|
||||
<p className="text-xs text-(--text-secondary) -mt-3 ml-1">Unique identifier (lowercase, alphanumeric, hyphens)</p>
|
||||
|
||||
<CustomInput
|
||||
label="Module Name *"
|
||||
placeholder="e.g., Inventory Management"
|
||||
{...register('module_name', { required: 'Module Name is required' })}
|
||||
error={errors.module_name?.message}
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-(--text-primary)">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
{...register('description')}
|
||||
className="w-full px-3 py-2 border border-(--card-border) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-(--background) text-(--text-primary) placeholder-(--text-secondary)/50"
|
||||
placeholder="Brief description of the module"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CustomInput
|
||||
label="Icon URL"
|
||||
placeholder="https://example.com/icon.png"
|
||||
type="url"
|
||||
{...register('icon_url')}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-(--text-primary)">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
{...register('status')}
|
||||
className="w-full px-3 py-2 border border-(--card-border) rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-(--background) text-(--text-primary)"
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<CustomInput
|
||||
label="Display Order"
|
||||
type="number"
|
||||
{...register('display_order', { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-4">
|
||||
<CustomButton
|
||||
type="submit"
|
||||
loading={loading}
|
||||
className="flex-1"
|
||||
>
|
||||
{module ? 'Update Module' : 'Create Module'}
|
||||
</CustomButton>
|
||||
<CustomButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onClose(false)}
|
||||
className="flex-1"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModuleForm;
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Edit, Trash2, Settings, Shield, Eye, EyeOff } from 'lucide-react';
|
||||
import { useModuleApi } from '../hooks/useModuleApi';
|
||||
import type { Module } from '../AdminModuleTypes';
|
||||
import ModuleForm from './ModuleForm';
|
||||
import CustomConfirmationModal from '../../../../components/custom/CustomConfirmationModal';
|
||||
import CustomButton from '../../../../components/custom/CustomButton';
|
||||
|
||||
const ModuleList = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('modules');
|
||||
const { listModules, deleteModule, loading } = useModuleApi();
|
||||
const [modules, setModules] = useState<Module[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [selectedModule, setSelectedModule] = useState<Module | null>(null);
|
||||
const [moduleToDelete, setModuleToDelete] = useState<Module | null>(null);
|
||||
|
||||
const fetchModules = async () => {
|
||||
const data = await listModules();
|
||||
if (data) {
|
||||
setModules(data);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchModules();
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
setSelectedModule(null);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleEdit = (module: Module) => {
|
||||
setSelectedModule(module);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const confirmDelete = (module: Module) => {
|
||||
setModuleToDelete(module);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!moduleToDelete) return;
|
||||
|
||||
const success = await deleteModule(moduleToDelete.id);
|
||||
if (success) {
|
||||
fetchModules();
|
||||
}
|
||||
setModuleToDelete(null);
|
||||
};
|
||||
|
||||
const handleFormClose = (saved: boolean) => {
|
||||
setShowForm(false);
|
||||
setSelectedModule(null);
|
||||
if (saved) {
|
||||
fetchModules();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && modules.length === 0) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 bg-gray-300 rounded w-1/4"></div>
|
||||
<div className="h-64 bg-gray-300 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-(--text-primary)">{t('registry.title')}</h1>
|
||||
<p className="text-(--text-secondary) mt-1">{t('registry.subtitle')}</p>
|
||||
</div>
|
||||
<CustomButton
|
||||
onClick={handleCreate}
|
||||
leftIcon={<Plus size={20} />}
|
||||
>
|
||||
{t('registry.create_button')}
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{modules.map((module) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6 space-y-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{module.icon_url ? (
|
||||
<img src={module.icon_url} alt={module.module_name} className="w-12 h-12 rounded-lg" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-blue-100 flex items-center justify-center">
|
||||
<Settings className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-(--text-primary)">{module.module_name}</h3>
|
||||
<p className="text-sm text-(--text-secondary)">{module.module_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{module.status === 'active' ? (
|
||||
<Eye className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<EyeOff className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{module.description && (
|
||||
<p className="text-sm text-(--text-secondary) line-clamp-2">{module.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-4 border-t border-(--card-border)">
|
||||
<button
|
||||
onClick={() => navigate(`/admin/modules/${module.id}/environments`)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm text-(--text-primary)"
|
||||
>
|
||||
<Settings size={16} />
|
||||
Environments
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/modules/${module.id}/permissions`)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors text-sm text-(--text-primary)"
|
||||
>
|
||||
<Shield size={16} />
|
||||
Permissions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(module)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-blue-50 hover:bg-blue-100 text-blue-600 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
<Edit size={16} />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => confirmDelete(module)}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-red-50 hover:bg-red-100 text-red-600 rounded-lg transition-colors text-sm"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{modules.length === 0 && !loading && (
|
||||
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-12 text-center">
|
||||
<Settings className="w-16 h-16 text-(--text-secondary) mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-(--text-primary) mb-2">{t('registry.empty_state.title')}</h3>
|
||||
<p className="text-(--text-secondary) mb-4">{t('registry.empty_state.subtitle')}</p>
|
||||
<CustomButton onClick={handleCreate}>
|
||||
{t('registry.create_button')}
|
||||
</CustomButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<ModuleForm
|
||||
module={selectedModule}
|
||||
onClose={handleFormClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomConfirmationModal
|
||||
isOpen={!!moduleToDelete}
|
||||
onClose={() => setModuleToDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title={t('delete_modal.title')}
|
||||
description={t('delete_modal.description', { name: moduleToDelete?.module_name })}
|
||||
confirmText={t('delete_modal.confirm')}
|
||||
cancelText={t('delete_modal.cancel')}
|
||||
variant="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModuleList;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, RefreshCw, Shield } from 'lucide-react';
|
||||
import { useModuleApi } from '../hooks/useModuleApi';
|
||||
import type { ModulePermission, Module } from '../AdminModuleTypes';
|
||||
import CustomButton from '../../../../components/custom/CustomButton';
|
||||
import { NodeGroupAccessViewer } from '../../../roles/components/NodeGroupAccessViewer';
|
||||
import type { RoleAccess } from '../../../roles/RolesTypes';
|
||||
|
||||
const ModulePermissions = () => {
|
||||
const { moduleId } = useParams<{ moduleId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { getModule, getPermissions, syncPermissions, loading } = useModuleApi();
|
||||
|
||||
const [module, setModule] = useState<Module | null>(null);
|
||||
const [permissions, setPermissions] = useState<ModulePermission[]>([]);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!moduleId) return;
|
||||
|
||||
const [moduleData, permsData] = await Promise.all([
|
||||
getModule(moduleId),
|
||||
getPermissions(moduleId)
|
||||
]);
|
||||
|
||||
if (moduleData) setModule(moduleData);
|
||||
if (permsData) setPermissions(permsData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [moduleId]);
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!moduleId) return;
|
||||
|
||||
setSyncing(true);
|
||||
const result = await syncPermissions(moduleId);
|
||||
if (result) {
|
||||
fetchData();
|
||||
}
|
||||
setSyncing(false);
|
||||
};
|
||||
|
||||
if (loading && !module) {
|
||||
return <div className="p-6">Loading...</div>;
|
||||
}
|
||||
|
||||
const viewerAccesses: RoleAccess[] = permissions.map(p => ({
|
||||
id: p.id,
|
||||
access_code: p.access_code,
|
||||
name: p.name,
|
||||
category: p.category || 'General',
|
||||
parent_id: p.parent_id
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/admin/modules')}
|
||||
className="p-2 hover:bg-(--background) rounded-lg transition-colors text-(--text-secondary) hover:text-(--text-primary)"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-(--text-primary)">
|
||||
{module?.module_name} - Permissions
|
||||
</h1>
|
||||
<p className="text-(--text-secondary) mt-1">View and sync permissions from module</p>
|
||||
</div>
|
||||
<CustomButton
|
||||
onClick={handleSync}
|
||||
loading={syncing}
|
||||
leftIcon={!syncing && <RefreshCw size={20} />}
|
||||
>
|
||||
{syncing ? 'Syncing...' : 'Sync Permissions'}
|
||||
</CustomButton>
|
||||
</div>
|
||||
|
||||
{permissions.length > 0 ? (
|
||||
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6">
|
||||
<NodeGroupAccessViewer accesses={viewerAccesses} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-(--card-bg) border border-(--card-border) rounded-lg p-12 text-center">
|
||||
<Shield className="w-16 h-16 text-(--text-secondary) mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-(--text-primary) mb-2">No Permissions Synced</h3>
|
||||
<p className="text-(--text-secondary) mb-4">Click "Sync Permissions" to fetch from the module</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModulePermissions;
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { adminModuleApi } from '../AdminModuleApi';
|
||||
import type { TenantModule, Module } from '../AdminModuleTypes';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
const TenantModuleAssignment = () => {
|
||||
const { tenantId } = useParams<{ tenantId: string }>();
|
||||
const [assignedModules, setAssignedModules] = useState<TenantModule[]>([]);
|
||||
const [availableModules, setAvailableModules] = useState<Module[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAssignDialog, setShowAssignDialog] = useState(false);
|
||||
const [selectedModule, setSelectedModule] = useState('');
|
||||
const [selectedEnv, setSelectedEnv] = useState('prod');
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
const [assigned, all] = await Promise.all([
|
||||
adminModuleApi.listTenantModules(tenantId),
|
||||
adminModuleApi.listModules()
|
||||
]);
|
||||
setAssignedModules(assigned);
|
||||
const assignedIds = assigned.map(a => a.module_id);
|
||||
setAvailableModules(all.filter(m => !assignedIds.includes(m.id)));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [tenantId]);
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!tenantId || !selectedModule) return;
|
||||
|
||||
try {
|
||||
await adminModuleApi.assignModuleToTenant(tenantId, {
|
||||
module_id: selectedModule,
|
||||
assigned_environment_slug: selectedEnv,
|
||||
is_active: true
|
||||
});
|
||||
setShowAssignDialog(false);
|
||||
setSelectedModule('');
|
||||
fetchData();
|
||||
} catch (error: any) {
|
||||
alert(error.response?.data?.detail || 'Failed to assign module');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (tm: TenantModule) => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
await adminModuleApi.updateTenantModule(tenantId, tm.id, { is_active: !tm.is_active });
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeEnv = async (tm: TenantModule, newSlug: string) => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
await adminModuleApi.updateTenantModule(tenantId, tm.id, { assigned_environment_slug: newSlug });
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Failed to update', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (tm: TenantModule) => {
|
||||
if (!tenantId || !confirm(`Remove ${tm.module_name}?`)) return;
|
||||
|
||||
try {
|
||||
await adminModuleApi.removeTenantModule(tenantId, tm.id);
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-6">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-(--text-primary)">Tenant Modules</h1>
|
||||
<button
|
||||
onClick={() => setShowAssignDialog(true)}
|
||||
disabled={availableModules.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
<Plus size={20} />
|
||||
Assign Module
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{assignedModules.map((tm) => (
|
||||
<div key={tm.id} className="bg-(--card-bg) border border-(--card-border) rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{tm.module_icon_url && (
|
||||
<img src={tm.module_icon_url} alt={tm.module_name} className="w-10 h-10 rounded" />
|
||||
)}
|
||||
<div>
|
||||
<h3 className="font-semibold text-(--text-primary)">{tm.module_name}</h3>
|
||||
<p className="text-xs text-(--text-secondary)">Environment: {tm.assigned_environment_slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={tm.assigned_environment_slug}
|
||||
onChange={(e) => handleChangeEnv(tm, e.target.value)}
|
||||
className="flex-1 px-3 py-2 border rounded-lg text-sm"
|
||||
>
|
||||
<option value="prod">Production</option>
|
||||
<option value="staging">Staging</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleToggleActive(tm)}
|
||||
className={`px-3 py-2 rounded-lg text-sm ${tm.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}
|
||||
>
|
||||
{tm.is_active ? 'Active' : 'Inactive'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemove(tm)}
|
||||
className="p-2 bg-red-50 text-red-600 rounded-lg hover:bg-red-100"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showAssignDialog && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full">
|
||||
<h2 className="text-xl font-semibold mb-4">Assign Module</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Module</label>
|
||||
<select
|
||||
value={selectedModule}
|
||||
onChange={(e) => setSelectedModule(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="">Select Module</option>
|
||||
{availableModules.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.module_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Environment</label>
|
||||
<select
|
||||
value={selectedEnv}
|
||||
onChange={(e) => setSelectedEnv(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg"
|
||||
>
|
||||
<option value="prod">Production</option>
|
||||
<option value="staging">Staging</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={!selectedModule}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Assign
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAssignDialog(false)}
|
||||
className="flex-1 px-4 py-2 bg-gray-200 rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantModuleAssignment;
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { adminModuleApi } from '../AdminModuleApi';
|
||||
import type {
|
||||
Module,
|
||||
ModuleCreate,
|
||||
ModuleUpdate,
|
||||
ModuleEnvironment,
|
||||
EnvironmentCreate,
|
||||
EnvironmentUpdate,
|
||||
ModulePermission,
|
||||
TenantModule,
|
||||
TenantModuleCreate,
|
||||
TenantModuleUpdate
|
||||
} from '../AdminModuleTypes';
|
||||
|
||||
interface UseModuleApiResult {
|
||||
loading: boolean;
|
||||
listModules: () => Promise<Module[] | undefined>;
|
||||
getModule: (id: string) => Promise<Module | undefined>;
|
||||
createModule: (data: ModuleCreate) => Promise<Module | undefined>;
|
||||
updateModule: (id: string, data: ModuleUpdate) => Promise<Module | undefined>;
|
||||
deleteModule: (id: string) => Promise<boolean>;
|
||||
|
||||
listEnvironments: (moduleId: string) => Promise<ModuleEnvironment[] | undefined>;
|
||||
createEnvironment: (moduleId: string, data: EnvironmentCreate) => Promise<ModuleEnvironment | undefined>;
|
||||
updateEnvironment: (moduleId: string, envId: string, data: EnvironmentUpdate) => Promise<ModuleEnvironment | undefined>;
|
||||
setDefaultEnvironment: (moduleId: string, envId: string) => Promise<boolean>;
|
||||
deleteEnvironment: (moduleId: string, envId: string) => Promise<boolean>;
|
||||
|
||||
getPermissions: (moduleId: string) => Promise<ModulePermission[] | undefined>;
|
||||
syncPermissions: (moduleId: string) => Promise<{ message: string; synced_count: number } | undefined>;
|
||||
|
||||
listTenantModules: (tenantId: string) => Promise<TenantModule[] | undefined>;
|
||||
assignTenantModule: (tenantId: string, data: TenantModuleCreate) => Promise<TenantModule | undefined>;
|
||||
updateTenantModule: (tenantId: string, assignmentId: string, data: TenantModuleUpdate) => Promise<TenantModule | undefined>;
|
||||
removeTenantModule: (tenantId: string, assignmentId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export const useModuleApi = (): UseModuleApiResult => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleError = (error: any, action: string) => {
|
||||
console.error(`Failed to ${action}`, error);
|
||||
const message = error.response?.data?.detail || `Failed to ${action}`;
|
||||
toast.error(message);
|
||||
};
|
||||
|
||||
const wrapRequest = useCallback(async <T>(
|
||||
request: () => Promise<T>,
|
||||
actionName: string,
|
||||
successMessage?: string
|
||||
): Promise<T | undefined> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await request();
|
||||
if (successMessage) {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
handleError(error, actionName);
|
||||
return undefined;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const listModules = useCallback(() =>
|
||||
wrapRequest(() => adminModuleApi.listModules(), 'fetch modules'),
|
||||
[]);
|
||||
|
||||
const getModule = useCallback((id: string) =>
|
||||
wrapRequest(() => adminModuleApi.getModule(id), 'fetch module details'),
|
||||
[]);
|
||||
|
||||
const createModule = useCallback((data: ModuleCreate) =>
|
||||
wrapRequest(() => adminModuleApi.createModule(data), 'create module', 'Module created successfully'),
|
||||
[]);
|
||||
|
||||
const updateModule = useCallback((id: string, data: ModuleUpdate) =>
|
||||
wrapRequest(() => adminModuleApi.updateModule(id, data), 'update module', 'Module updated successfully'),
|
||||
[]);
|
||||
|
||||
const deleteModule = useCallback(async (id: string) => {
|
||||
const result = await wrapRequest(() => adminModuleApi.deleteModule(id), 'delete module', 'Module deleted successfully');
|
||||
return result !== undefined;
|
||||
}, []);
|
||||
|
||||
const listEnvironments = useCallback((moduleId: string) =>
|
||||
wrapRequest(() => adminModuleApi.listEnvironments(moduleId), 'fetch environments'),
|
||||
[]);
|
||||
|
||||
const createEnvironment = useCallback((moduleId: string, data: EnvironmentCreate) =>
|
||||
wrapRequest(() => adminModuleApi.createEnvironment(moduleId, data), 'create environment', 'Environment created'),
|
||||
[]);
|
||||
|
||||
const updateEnvironment = useCallback((moduleId: string, envId: string, data: EnvironmentUpdate) =>
|
||||
wrapRequest(() => adminModuleApi.updateEnvironment(moduleId, envId, data), 'update environment', 'Environment updated'),
|
||||
[]);
|
||||
|
||||
const setDefaultEnvironment = useCallback(async (moduleId: string, envId: string) => {
|
||||
const result = await wrapRequest(() => adminModuleApi.setDefaultEnvironment(moduleId, envId), 'set default environment', 'Default environment updated');
|
||||
return result !== undefined;
|
||||
}, []);
|
||||
|
||||
const deleteEnvironment = useCallback(async (moduleId: string, envId: string) => {
|
||||
const result = await wrapRequest(() => adminModuleApi.deleteEnvironment(moduleId, envId), 'delete environment', 'Environment deleted');
|
||||
return result !== undefined;
|
||||
}, []);
|
||||
|
||||
const getPermissions = useCallback((moduleId: string) =>
|
||||
wrapRequest(() => adminModuleApi.getModulePermissions(moduleId), 'fetch permissions'),
|
||||
[]);
|
||||
|
||||
const syncPermissions = useCallback((moduleId: string) =>
|
||||
wrapRequest(() => adminModuleApi.syncModulePermissions(moduleId), 'sync permissions'),
|
||||
[]);
|
||||
|
||||
const listTenantModules = useCallback((tenantId: string) =>
|
||||
wrapRequest(() => adminModuleApi.listTenantModules(tenantId), 'fetch tenant modules'),
|
||||
[]);
|
||||
|
||||
const assignTenantModule = useCallback((tenantId: string, data: TenantModuleCreate) =>
|
||||
wrapRequest(() => adminModuleApi.assignModuleToTenant(tenantId, data), 'assign module', 'Module assigned to tenant'),
|
||||
[]);
|
||||
|
||||
const updateTenantModule = useCallback((tenantId: string, assignmentId: string, data: TenantModuleUpdate) =>
|
||||
wrapRequest(() => adminModuleApi.updateTenantModule(tenantId, assignmentId, data), 'update assignment', 'Assignment updated'),
|
||||
[]);
|
||||
|
||||
const removeTenantModule = useCallback(async (tenantId: string, assignmentId: string) => {
|
||||
const result = await wrapRequest(() => adminModuleApi.removeTenantModule(tenantId, assignmentId), 'remove assignment', 'Module unassigned from tenant');
|
||||
return result !== undefined;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loading,
|
||||
listModules,
|
||||
getModule,
|
||||
createModule,
|
||||
updateModule,
|
||||
deleteModule,
|
||||
listEnvironments,
|
||||
createEnvironment,
|
||||
updateEnvironment,
|
||||
setDefaultEnvironment,
|
||||
deleteEnvironment,
|
||||
getPermissions,
|
||||
syncPermissions,
|
||||
listTenantModules,
|
||||
assignTenantModule,
|
||||
updateTenantModule,
|
||||
removeTenantModule
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import ModuleList from "./components/ModuleList";
|
||||
import ModuleEnvironments from "./components/ModuleEnvironments";
|
||||
import ModulePermissions from "./components/ModulePermissions";
|
||||
|
||||
const Modules: React.FC = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<ModuleList />} />
|
||||
<Route path=":moduleId/environments" element={<ModuleEnvironments />} />
|
||||
<Route path=":moduleId/permissions" element={<ModulePermissions />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modules;
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios from "axios";
|
||||
import { API_BASE_URL } from "../../constant";
|
||||
import { API_BASE_URL } from "../../../constant";
|
||||
|
||||
export interface Module {
|
||||
module_id: string;
|
||||
@@ -7,12 +7,15 @@ export interface Module {
|
||||
description: string | null;
|
||||
icon_url: string | null;
|
||||
display_order: number;
|
||||
category: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface LaunchResponse {
|
||||
target_url: string;
|
||||
payload: Record<string, any>;
|
||||
headers: Record<string, string>;
|
||||
redirect_url: string;
|
||||
grant_code: string;
|
||||
}
|
||||
|
||||
export const moduleApi = {
|
||||
@@ -31,4 +34,4 @@ export const moduleApi = {
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -14,6 +14,8 @@ export type RoleAccess = {
|
||||
category: string;
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
module_id?: string;
|
||||
module_name?: string;
|
||||
};
|
||||
|
||||
export type RoleCreateRequest = {
|
||||
|
||||
@@ -140,14 +140,32 @@ const AddRoles = () => {
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Filter accessOptions to only show what the current user has access to
|
||||
const availableAccessOptions = useMemo(() => {
|
||||
if (canReadAllTenants) return accessOptions;
|
||||
|
||||
if (!user?.role?.accesses) return [];
|
||||
// If the user has access codes in their token, filter the list
|
||||
return accessOptions.filter((option) =>
|
||||
user.role?.accesses.includes(option.access_code)
|
||||
);
|
||||
}, [accessOptions, user]);
|
||||
|
||||
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
|
||||
const includedIds = new Set<string>();
|
||||
|
||||
accessOptions.forEach((option) => {
|
||||
if (user.role?.accesses.includes(option.access_code)) {
|
||||
let current: RoleAccess | undefined = option;
|
||||
while (current) {
|
||||
if (includedIds.has(current.id)) break;
|
||||
includedIds.add(current.id);
|
||||
|
||||
if (current.parent_id) {
|
||||
current = accessMap.get(current.parent_id);
|
||||
} else {
|
||||
current = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return accessOptions.filter((option) => includedIds.has(option.id));
|
||||
}, [accessOptions, user, canReadAllTenants]);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -277,4 +295,4 @@ const AddRoles = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AddRoles;
|
||||
export default AddRoles;
|
||||
@@ -92,21 +92,32 @@ const AllRoles = () => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Filter access options based on current user's permissions
|
||||
const availableAccessOptions = useMemo(() => {
|
||||
if (
|
||||
!currentUser ||
|
||||
!currentUser.role ||
|
||||
!currentUser.role.accesses ||
|
||||
!Array.isArray(currentUser.role.accesses)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (canReadAllTenants) return accessOptions;
|
||||
|
||||
return accessOptions.filter((opt) =>
|
||||
currentUser.role!.accesses.includes(opt.access_code)
|
||||
);
|
||||
}, [accessOptions, currentUser]);
|
||||
if (!currentUser?.role?.accesses) return [];
|
||||
|
||||
const accessMap = new Map(accessOptions.map((a) => [a.id, a]));
|
||||
const includedIds = new Set<string>();
|
||||
|
||||
accessOptions.forEach((option) => {
|
||||
if (currentUser.role?.accesses.includes(option.access_code)) {
|
||||
let current: RoleAccess | undefined = option;
|
||||
while (current) {
|
||||
if (includedIds.has(current.id)) break;
|
||||
includedIds.add(current.id);
|
||||
|
||||
if (current.parent_id) {
|
||||
current = accessMap.get(current.parent_id);
|
||||
} else {
|
||||
current = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return accessOptions.filter((option) => includedIds.has(option.id));
|
||||
}, [accessOptions, currentUser, canReadAllTenants]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -175,12 +186,10 @@ const AllRoles = () => {
|
||||
t
|
||||
]);
|
||||
|
||||
// Reset page when search changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
// Restore search focus after loading
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !isLoading && search.trim()) {
|
||||
searchInputRef.current?.focus({ preventScroll: true });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { RoleAccess } from "../RolesTypes";
|
||||
import { CustomLoader } from "../../../components/custom";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
interface GroupedAccessSelectorProps {
|
||||
allAccesses: RoleAccess[];
|
||||
@@ -9,174 +10,286 @@ interface GroupedAccessSelectorProps {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface CategoryGroup {
|
||||
category: string;
|
||||
accesses: RoleAccess[];
|
||||
children: Record<string, CategoryGroup>;
|
||||
}
|
||||
|
||||
export const GroupedAccessSelector = ({
|
||||
allAccesses,
|
||||
selectedIds = [],
|
||||
onChange,
|
||||
isLoading = false,
|
||||
}: GroupedAccessSelectorProps) => {
|
||||
const hierarchicalGroups = useMemo(() => {
|
||||
const parents: RoleAccess[] = [];
|
||||
const children: Record<string, RoleAccess[]> = {};
|
||||
|
||||
const { moduleGroups, childMap, allIds } = useMemo(() => {
|
||||
const ids = new Set(allAccesses.map((a) => a.id));
|
||||
const cMap: Record<string, RoleAccess[]> = {};
|
||||
const roots: RoleAccess[] = [];
|
||||
|
||||
allAccesses.forEach(access => {
|
||||
if (!access.parent_id) {
|
||||
parents.push(access);
|
||||
allAccesses.forEach((access) => {
|
||||
const hasParent = access.parent_id && ids.has(access.parent_id);
|
||||
|
||||
if (hasParent) {
|
||||
if (!cMap[access.parent_id!]) {
|
||||
cMap[access.parent_id!] = [];
|
||||
}
|
||||
cMap[access.parent_id!].push(access);
|
||||
} else {
|
||||
if (!children[access.parent_id]) {
|
||||
children[access.parent_id] = [];
|
||||
}
|
||||
children[access.parent_id].push(access);
|
||||
roots.push(access);
|
||||
}
|
||||
});
|
||||
|
||||
const modGroups: Record<string, Record<string, RoleAccess[]>> = {};
|
||||
|
||||
const categoryGroups: Record<string, CategoryGroup> = {};
|
||||
|
||||
parents.forEach(parent => {
|
||||
if (!categoryGroups[parent.category]) {
|
||||
categoryGroups[parent.category] = {
|
||||
category: parent.category,
|
||||
accesses: [],
|
||||
children: {}
|
||||
};
|
||||
roots.forEach((root) => {
|
||||
const moduleName = root.module_name || "SaaS (Internal)";
|
||||
const cat = root.category || "General";
|
||||
|
||||
if (!modGroups[moduleName]) {
|
||||
modGroups[moduleName] = {};
|
||||
}
|
||||
|
||||
categoryGroups[parent.category].accesses.push(parent);
|
||||
|
||||
const parentChildren = children[parent.id] || [];
|
||||
parentChildren.forEach(child => {
|
||||
if (!categoryGroups[parent.category].children[child.category]) {
|
||||
categoryGroups[parent.category].children[child.category] = {
|
||||
category: child.category,
|
||||
accesses: [],
|
||||
children: {}
|
||||
};
|
||||
}
|
||||
categoryGroups[parent.category].children[child.category].accesses.push(child);
|
||||
});
|
||||
if (!modGroups[moduleName][cat]) {
|
||||
modGroups[moduleName][cat] = [];
|
||||
}
|
||||
modGroups[moduleName][cat].push(root);
|
||||
});
|
||||
|
||||
return categoryGroups;
|
||||
|
||||
const sortedModuleNames = Object.keys(modGroups).sort((a, b) => {
|
||||
if (a === "SaaS (Internal)") return -1;
|
||||
if (b === "SaaS (Internal)") return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const orderedGroups = sortedModuleNames.map(name => ({
|
||||
name,
|
||||
categories: modGroups[name]
|
||||
}));
|
||||
|
||||
return { moduleGroups: orderedGroups, childMap: cMap, allIds: ids };
|
||||
}, [allAccesses]);
|
||||
|
||||
const getCategoryAccesses = (group: CategoryGroup): RoleAccess[] => {
|
||||
const accesses = [...group.accesses];
|
||||
Object.values(group.children).forEach(child => {
|
||||
accesses.push(...child.accesses);
|
||||
const [expandedModules, setExpandedModules] = useState<Record<string, boolean>>({});
|
||||
|
||||
const toggleModuleExpansion = (moduleName: string) => {
|
||||
setExpandedModules(prev => ({
|
||||
...prev,
|
||||
[moduleName]: !prev[moduleName]
|
||||
}));
|
||||
};
|
||||
|
||||
const getBranchIds = (id: string): string[] => {
|
||||
const ids = [id];
|
||||
const children = childMap[id] || [];
|
||||
children.forEach((c) => {
|
||||
ids.push(...getBranchIds(c.id));
|
||||
});
|
||||
return accesses;
|
||||
return ids;
|
||||
};
|
||||
|
||||
const isCategorySelected = (group: CategoryGroup) => {
|
||||
const categoryAccesses = getCategoryAccesses(group);
|
||||
return (
|
||||
categoryAccesses.length > 0 &&
|
||||
categoryAccesses.every((access) => selectedIds.includes(access.id))
|
||||
);
|
||||
const isBranchSelected = (id: string) => {
|
||||
const ids = getBranchIds(id);
|
||||
return ids.every((i) => selectedIds.includes(i));
|
||||
};
|
||||
|
||||
const isCategoryIndeterminate = (group: CategoryGroup) => {
|
||||
const categoryAccesses = getCategoryAccesses(group);
|
||||
const selectedCount = categoryAccesses.filter((access) =>
|
||||
selectedIds.includes(access.id)
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < categoryAccesses.length;
|
||||
const isBranchIndeterminate = (id: string) => {
|
||||
const ids = getBranchIds(id);
|
||||
const selectedCount = ids.filter((i) => selectedIds.includes(i)).length;
|
||||
return selectedCount > 0 && selectedCount < ids.length;
|
||||
};
|
||||
|
||||
const toggleCategory = (group: CategoryGroup) => {
|
||||
const categoryAccesses = getCategoryAccesses(group);
|
||||
const allSelected = isCategorySelected(group);
|
||||
const categoryIds = categoryAccesses.map((a) => a.id);
|
||||
const toggleBranch = (id: string) => {
|
||||
const ids = getBranchIds(id);
|
||||
const allSelected = ids.every((i) => selectedIds.includes(i));
|
||||
|
||||
let newIds: string[];
|
||||
if (allSelected) {
|
||||
newIds = selectedIds.filter((id) => !categoryIds.includes(id));
|
||||
newIds = selectedIds.filter((i) => !ids.includes(i));
|
||||
} else {
|
||||
const uniqueIds = new Set([...selectedIds, ...categoryIds]);
|
||||
newIds = Array.from(uniqueIds);
|
||||
const unique = new Set([...selectedIds, ...ids]);
|
||||
newIds = Array.from(unique);
|
||||
}
|
||||
onChange(newIds);
|
||||
};
|
||||
|
||||
// --- Subcategory Helpers ---
|
||||
const isSubcategorySelected = (accesses: RoleAccess[]) => {
|
||||
return (
|
||||
accesses.length > 0 &&
|
||||
accesses.every((access) => selectedIds.includes(access.id))
|
||||
);
|
||||
};
|
||||
|
||||
const isSubcategoryIndeterminate = (accesses: RoleAccess[]) => {
|
||||
const selectedCount = accesses.filter((access) =>
|
||||
selectedIds.includes(access.id)
|
||||
).length;
|
||||
return selectedCount > 0 && selectedCount < accesses.length;
|
||||
};
|
||||
|
||||
const toggleSubcategory = (accesses: RoleAccess[]) => {
|
||||
const allSelected = isSubcategorySelected(accesses);
|
||||
const accessIds = accesses.map((a) => a.id);
|
||||
|
||||
let newIds: string[];
|
||||
if (allSelected) {
|
||||
newIds = selectedIds.filter((id) => !accessIds.includes(id));
|
||||
const toggleSingle = (id: string) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onChange(selectedIds.filter((sid) => sid !== id));
|
||||
} else {
|
||||
const uniqueIds = new Set([...selectedIds, ...accessIds]);
|
||||
newIds = Array.from(uniqueIds);
|
||||
onChange([...selectedIds, id]);
|
||||
}
|
||||
onChange(newIds);
|
||||
};
|
||||
|
||||
// --- Global Helpers ---
|
||||
const isAllSelected = () => {
|
||||
return (
|
||||
allAccesses.length > 0 &&
|
||||
allAccesses.every((access) => selectedIds.includes(access.id))
|
||||
);
|
||||
const getCategoryIds = (roots: RoleAccess[]) => {
|
||||
const ids: string[] = [];
|
||||
roots.forEach((root) => {
|
||||
ids.push(...getBranchIds(root.id));
|
||||
});
|
||||
return ids;
|
||||
};
|
||||
|
||||
const isAllIndeterminate = () => {
|
||||
const selectedCount = selectedIds.length;
|
||||
return selectedCount > 0 && selectedCount < allAccesses.length;
|
||||
const isCategorySelected = (roots: RoleAccess[]) => {
|
||||
const ids = getCategoryIds(roots);
|
||||
return ids.length > 0 && ids.every((id) => selectedIds.includes(id));
|
||||
};
|
||||
|
||||
const isCategoryIndeterminate = (roots: RoleAccess[]) => {
|
||||
const ids = getCategoryIds(roots);
|
||||
const count = ids.filter((id) => selectedIds.includes(id)).length;
|
||||
return count > 0 && count < ids.length;
|
||||
};
|
||||
|
||||
const toggleCategory = (roots: RoleAccess[]) => {
|
||||
const ids = getCategoryIds(roots);
|
||||
const allSelected = ids.every((id) => selectedIds.includes(id));
|
||||
if (allSelected) {
|
||||
onChange(selectedIds.filter((id) => !ids.includes(id)));
|
||||
} else {
|
||||
const unique = new Set([...selectedIds, ...ids]);
|
||||
onChange(Array.from(unique));
|
||||
}
|
||||
};
|
||||
|
||||
const getModuleIds = (categories: Record<string, RoleAccess[]>) => {
|
||||
return Object.values(categories).flatMap(roots => getCategoryIds(roots));
|
||||
};
|
||||
|
||||
const isModuleSelected = (categories: Record<string, RoleAccess[]>) => {
|
||||
const ids = getModuleIds(categories);
|
||||
return ids.length > 0 && ids.every(id => selectedIds.includes(id));
|
||||
};
|
||||
|
||||
const isModuleIndeterminate = (categories: Record<string, RoleAccess[]>) => {
|
||||
const ids = getModuleIds(categories);
|
||||
const count = ids.filter(id => selectedIds.includes(id)).length;
|
||||
return count > 0 && count < ids.length;
|
||||
};
|
||||
|
||||
const toggleModule = (categories: Record<string, RoleAccess[]>) => {
|
||||
const ids = getModuleIds(categories);
|
||||
const allSelected = ids.every(id => selectedIds.includes(id));
|
||||
if (allSelected) {
|
||||
onChange(selectedIds.filter(id => !ids.includes(id)));
|
||||
} else {
|
||||
const unique = new Set([...selectedIds, ...ids]);
|
||||
onChange(Array.from(unique));
|
||||
}
|
||||
};
|
||||
|
||||
const isAllSelected = () =>
|
||||
allIds.size > 0 && Array.from(allIds).every((id) => selectedIds.includes(id));
|
||||
const isAllIndeterminate = () =>
|
||||
selectedIds.length > 0 && selectedIds.length < allIds.size;
|
||||
const toggleAll = () => {
|
||||
if (isAllSelected()) {
|
||||
onChange([]);
|
||||
} else {
|
||||
const allIds = allAccesses.map((a) => a.id);
|
||||
onChange(allIds);
|
||||
onChange(Array.from(allIds));
|
||||
}
|
||||
};
|
||||
|
||||
// --- Individual Helper ---
|
||||
const toggleAccess = (accessId: string) => {
|
||||
const newIds = selectedIds.includes(accessId)
|
||||
? selectedIds.filter((id) => id !== accessId)
|
||||
: [...selectedIds, accessId];
|
||||
onChange(newIds);
|
||||
const renderNode = (node: RoleAccess, depth: number = 0) => {
|
||||
const children = childMap[node.id];
|
||||
const hasChildren = children && children.length > 0;
|
||||
const isRoot = depth === 0;
|
||||
|
||||
const renderChildrenList = (items: RoleAccess[]) => {
|
||||
if (items.some(c => childMap[c.id])) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map(child => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{items.map(child => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (hasChildren) {
|
||||
if (isRoot) {
|
||||
const childGroups: Record<string, RoleAccess[]> = {};
|
||||
let hasMultipleGroups = false;
|
||||
children.forEach(c => {
|
||||
const cat = c.category || 'General';
|
||||
if (!childGroups[cat]) childGroups[cat] = [];
|
||||
childGroups[cat].push(c);
|
||||
});
|
||||
hasMultipleGroups = Object.keys(childGroups).length > 1;
|
||||
|
||||
return (
|
||||
<div key={node.id} className="rounded-md border border-gray-200 bg-white p-3 mb-3 break-inside-avoid shadow-xs">
|
||||
<label className="flex items-center gap-2 mb-3 cursor-pointer group pb-2 border-b border-gray-50/50 select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isBranchSelected(node.id)}
|
||||
ref={(el) => { if (el) el.indeterminate = isBranchIndeterminate(node.id); }}
|
||||
onChange={() => toggleBranch(node.id)}
|
||||
/>
|
||||
<span className="font-semibold text-gray-800">{node.name}</span>
|
||||
</label>
|
||||
|
||||
<div className="pl-2">
|
||||
{hasMultipleGroups ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(childGroups).map(([groupName, groupItems]) => (
|
||||
<div key={groupName} className="bg-gray-50/50 rounded-md border border-gray-200/50 p-3">
|
||||
<h5 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2 border-b border-gray-100 pb-1">
|
||||
{groupName}
|
||||
</h5>
|
||||
{renderChildrenList(groupItems)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
renderChildrenList(children)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={node.id} className="rounded-md border border-gray-100 bg-white p-3">
|
||||
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-gray-50 select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isBranchSelected(node.id)}
|
||||
ref={(el) => { if (el) el.indeterminate = isBranchIndeterminate(node.id); }}
|
||||
onChange={() => toggleBranch(node.id)}
|
||||
/>
|
||||
<span className="font-medium text-sm text-gray-700">{node.name}</span>
|
||||
</div>
|
||||
{renderChildrenList(children)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<label
|
||||
key={node.id}
|
||||
className="flex items-start gap-2 cursor-pointer p-1.5 hover:bg-gray-50/50 rounded-md transition-colors select-none"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={selectedIds.includes(node.id)}
|
||||
onChange={() => toggleSingle(node.id)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">{node.name}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <CustomLoader />;
|
||||
}
|
||||
|
||||
if (allAccesses.length === 0) {
|
||||
if (isLoading) return <CustomLoader />;
|
||||
if (allAccesses.length === 0)
|
||||
return <p className="text-sm text-gray-500">No permissions available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-medium text-gray-900">Permissions</h3>
|
||||
|
||||
{/* Global Select All */}
|
||||
{/* Global Header */}
|
||||
<div className="flex items-center justify-between border-b pb-4 mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Role Permissions</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -188,108 +301,92 @@ export const GroupedAccessSelector = ({
|
||||
}}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
<label
|
||||
htmlFor="select-all"
|
||||
<label
|
||||
htmlFor="select-all"
|
||||
className="text-sm font-medium text-gray-700 cursor-pointer select-none"
|
||||
>
|
||||
Select All Permissions
|
||||
Select All
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-6">
|
||||
{Object.entries(hierarchicalGroups).map(([category, group]) => (
|
||||
<div
|
||||
key={category}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50/50 p-4"
|
||||
>
|
||||
{/* Category Header */}
|
||||
<div className="mb-4 flex items-center gap-3 border-b border-gray-200 pb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isCategorySelected(group)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isCategoryIndeterminate(group);
|
||||
}}
|
||||
onChange={() => toggleCategory(group)}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-gray-800 uppercase tracking-wide">
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Parent Accesses */}
|
||||
{group.accesses.length > 0 && (
|
||||
<div className="mb-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{group.accesses.map((access) => (
|
||||
<label
|
||||
key={access.id}
|
||||
className="flex items-start gap-3 cursor-pointer group"
|
||||
{moduleGroups.map((moduleGroup) => {
|
||||
const isExpanded = expandedModules[moduleGroup.name];
|
||||
return (
|
||||
<div key={moduleGroup.name} className="border border-gray-200 rounded-lg bg-white shadow-sm overflow-hidden">
|
||||
{/* Module Header (Collapsible) */}
|
||||
<div
|
||||
className="flex items-center justify-between p-4 bg-gray-50 cursor-pointer hover:bg-gray-100 transition-colors"
|
||||
onClick={() => toggleModuleExpansion(moduleGroup.name)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer group-hover:border-blue-400"
|
||||
checked={selectedIds.includes(access.id)}
|
||||
onChange={() => toggleAccess(access.id)}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 group-hover:text-gray-900">
|
||||
{access.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Child Categories */}
|
||||
{Object.keys(group.children).length > 0 && (
|
||||
<div className="space-y-4 mt-4">
|
||||
{Object.entries(group.children).map(([childCategory, childGroup]) => (
|
||||
<div
|
||||
key={childCategory}
|
||||
className="rounded-md border border-gray-300 bg-white p-3 ml-4"
|
||||
>
|
||||
{/* Subcategory Header */}
|
||||
<div className="mb-3 flex items-center gap-2 border-b border-gray-200 pb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isSubcategorySelected(childGroup.accesses)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isSubcategoryIndeterminate(childGroup.accesses);
|
||||
}}
|
||||
onChange={() => toggleSubcategory(childGroup.accesses)}
|
||||
/>
|
||||
<span className="text-xs font-semibold text-gray-700 uppercase tracking-wide">
|
||||
{childCategory}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subcategory Accesses */}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{childGroup.accesses.map((access) => (
|
||||
<label
|
||||
key={access.id}
|
||||
className="flex items-start gap-2 cursor-pointer group"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 size-3.5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer group-hover:border-blue-400"
|
||||
checked={selectedIds.includes(access.id)}
|
||||
onChange={() => toggleAccess(access.id)}
|
||||
/>
|
||||
<span className="text-xs text-gray-600 group-hover:text-gray-900">
|
||||
{access.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isExpanded ? <ChevronDown size={20} className="text-gray-500" /> : <ChevronRight size={20} className="text-gray-500" />}
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent collapse when clicking checkbox
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isModuleSelected(moduleGroup.categories)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isModuleIndeterminate(moduleGroup.categories);
|
||||
}}
|
||||
onChange={() => toggleModule(moduleGroup.categories)}
|
||||
/>
|
||||
<h2 className="text-base font-bold text-gray-900 select-none">{moduleGroup.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Module Content (Collapsible) */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 border-t border-gray-200 animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<div className="space-y-8">
|
||||
{Object.entries(moduleGroup.categories).map(([category, roots]) => (
|
||||
<div
|
||||
key={category}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50/10 p-4"
|
||||
>
|
||||
{/* Category Header with Select All Category */}
|
||||
<div className="mb-4 flex items-center gap-3 border-b border-gray-200 pb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer"
|
||||
checked={isCategorySelected(roots)}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = isCategoryIndeterminate(roots);
|
||||
}}
|
||||
onChange={() => toggleCategory(roots)}
|
||||
/>
|
||||
<span className="text-sm font-bold text-gray-700 uppercase tracking-widest select-none">
|
||||
{category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 1. Root Branches (Cards) */}
|
||||
{roots
|
||||
.filter((r) => childMap[r.id]?.length > 0)
|
||||
.map((root) => renderNode(root, 0))}
|
||||
|
||||
{/* 2. Root Leaves (Grid) */}
|
||||
{roots.some((r) => !childMap[r.id]?.length) && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 mt-4 ml-1">
|
||||
{roots
|
||||
.filter((r) => !childMap[r.id]?.length)
|
||||
.map((root) => renderNode(root, 0))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useMemo } from "react";
|
||||
import type { RoleAccess } from "../RolesTypes";
|
||||
|
||||
interface NodeGroupAccessViewerProps {
|
||||
accesses: RoleAccess[];
|
||||
}
|
||||
|
||||
export const NodeGroupAccessViewer = ({ accesses }: NodeGroupAccessViewerProps) => {
|
||||
const { categoryGroups, childMap } = useMemo(() => {
|
||||
const childMap: Record<string, RoleAccess[]> = {};
|
||||
const categoryGroups: Record<string, RoleAccess[]> = {};
|
||||
|
||||
const idMap = new Set(accesses.map(a => a.id));
|
||||
|
||||
accesses.forEach(node => {
|
||||
if (!node.parent_id || !idMap.has(node.parent_id)) {
|
||||
const category = node.category || 'General';
|
||||
if (!categoryGroups[category]) {
|
||||
categoryGroups[category] = [];
|
||||
}
|
||||
categoryGroups[category].push(node);
|
||||
} else {
|
||||
if (!childMap[node.parent_id]) {
|
||||
childMap[node.parent_id] = [];
|
||||
}
|
||||
childMap[node.parent_id].push(node);
|
||||
}
|
||||
});
|
||||
|
||||
return { categoryGroups, childMap };
|
||||
}, [accesses]);
|
||||
|
||||
const renderNode = (node: RoleAccess, depth: number = 0) => {
|
||||
const children = childMap[node.id];
|
||||
const hasChildren = children && children.length > 0;
|
||||
|
||||
const renderChildrenList = (items: RoleAccess[]) => {
|
||||
if (items.some(c => childMap[c.id])) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map(child => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{items.map(child => renderNode(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (depth === 0) {
|
||||
if (hasChildren) {
|
||||
const childGroups: Record<string, RoleAccess[]> = {};
|
||||
let hasMultipleGroups = false;
|
||||
|
||||
children.forEach(c => {
|
||||
const cat = c.category || 'General';
|
||||
if (!childGroups[cat]) childGroups[cat] = [];
|
||||
childGroups[cat].push(c);
|
||||
});
|
||||
|
||||
hasMultipleGroups = Object.keys(childGroups).length > 1;
|
||||
|
||||
return (
|
||||
<div key={node.id} className="col-span-1 sm:col-span-2 rounded-lg border border-gray-200 bg-gray-50/50 p-4 break-inside-avoid">
|
||||
<div className="flex items-center gap-2 mb-3 border-b border-gray-200 pb-2">
|
||||
<span className="size-2 rounded-full bg-blue-600 shrink-0" />
|
||||
<span className="text-base font-semibold text-gray-800 tracking-wide">
|
||||
{node.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pl-2">
|
||||
{hasMultipleGroups ? (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(childGroups).map(([groupName, groupItems]) => (
|
||||
<div key={groupName} className="bg-white/50 rounded-md border border-gray-200/50 p-3">
|
||||
<h5 className="text-xs font-bold text-gray-500 uppercase tracking-wider mb-2 border-b border-gray-100 pb-1">
|
||||
{groupName}
|
||||
</h5>
|
||||
{renderChildrenList(groupItems)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
renderChildrenList(children)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={node.id} className="flex items-center gap-2 p-3 bg-white border border-gray-200 rounded-lg shadow-sm">
|
||||
<span className="size-2 rounded-full bg-blue-500 shrink-0" />
|
||||
<span className="font-medium text-gray-700">{node.name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div key={node.id} className="rounded-md border border-gray-100 bg-white p-3">
|
||||
<div className="mb-2 pb-2 border-b border-gray-50">
|
||||
<span className="font-medium text-sm text-gray-700 block">{node.name}</span>
|
||||
</div>
|
||||
{renderChildrenList(children)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={node.id} className="flex items-center gap-2 text-sm text-gray-600 bg-white p-2 rounded-md border border-transparent hover:border-gray-100 shadow-sm hover:shadow-md transition-all">
|
||||
<span className="size-1.5 rounded-full bg-green-500 shrink-0" />
|
||||
<span className="font-medium leading-tight">{node.name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!accesses || accesses.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center bg-gray-50 rounded-lg border border-dashed border-gray-300">
|
||||
<p className="text-gray-500">No permissions found in this module.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{Object.entries(categoryGroups).map(([category, roots]) => (
|
||||
<div key={category} className="bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* Category Header */}
|
||||
<div className="bg-gray-50/80 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="font-bold text-gray-800 text-lg">{category}</h3>
|
||||
</div>
|
||||
|
||||
{/* Roots in this Category - Grid Layout */}
|
||||
<div className="p-5 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{roots.map(root => renderNode(root, 0))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,11 +8,17 @@ export type Tenant = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type TenantModuleCreate = {
|
||||
module_id: string;
|
||||
environment_slug: string;
|
||||
};
|
||||
|
||||
export type TenantCreateRequest = {
|
||||
tenant_name: string;
|
||||
tenant_domain: string;
|
||||
tenant_logo_url?: string;
|
||||
is_active?: boolean;
|
||||
modules?: TenantModuleCreate[];
|
||||
};
|
||||
|
||||
export type TenantUpdateRequest = {
|
||||
@@ -20,6 +26,7 @@ export type TenantUpdateRequest = {
|
||||
tenant_domain?: string;
|
||||
tenant_logo_url?: string | null;
|
||||
is_active?: boolean;
|
||||
modules?: TenantModuleCreate[];
|
||||
};
|
||||
|
||||
export type TenantPaginatedResponse = {
|
||||
@@ -32,4 +39,4 @@ export type TenantPaginatedResponse = {
|
||||
|
||||
export type ApiMessage = {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
@@ -1,21 +1,32 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CustomButton, CustomInput } from "../../../components/custom";
|
||||
import { CustomButton, CustomInput, CustomDropdown } from "../../../components/custom";
|
||||
import CustomBackButton from "../../../components/custom/CustomBackButton";
|
||||
import { CustomLoader } from "../../../components/custom";
|
||||
import { tenantsApi } from "../TenantsApi";
|
||||
import type { Tenant, TenantCreateRequest } from "../TenantsTypes";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { useModuleApi } from "../../modules/admin/hooks/useModuleApi";
|
||||
import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
const AddTenants = () => {
|
||||
const { hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||
const canCreateTenant = hasAccess("superadmin.tenant.create");
|
||||
const navigate = useNavigate();
|
||||
const { listModules, listEnvironments, loading: isModulesLoading } = useModuleApi();
|
||||
|
||||
const [formData, setFormData] = useState<TenantCreateRequest>({
|
||||
tenant_name: "",
|
||||
tenant_domain: "",
|
||||
tenant_logo_url: "",
|
||||
modules: []
|
||||
});
|
||||
|
||||
const [availableModules, setAvailableModules] = useState<Module[]>([]);
|
||||
const [moduleEnvironments, setModuleEnvironments] = useState<Record<string, ModuleEnvironment[]>>({});
|
||||
const [loadingEnvironments, setLoadingEnvironments] = useState<Record<string, boolean>>({});
|
||||
|
||||
const [currentTenant, setCurrentTenant] = useState<Tenant | null>(null);
|
||||
const [isTenantLoading, setIsTenantLoading] = useState(true);
|
||||
const [tenantError, setTenantError] = useState("");
|
||||
@@ -59,11 +70,81 @@ const AddTenants = () => {
|
||||
};
|
||||
}, [canCreateTenant, isAuthLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canCreateTenant) {
|
||||
listModules().then((data) => {
|
||||
if (data) setAvailableModules(data);
|
||||
});
|
||||
}
|
||||
}, [canCreateTenant]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = event.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const fetchModuleEnvironments = async (moduleId: string) => {
|
||||
// Return cached if available
|
||||
if (moduleEnvironments[moduleId]) return moduleEnvironments[moduleId];
|
||||
|
||||
setLoadingEnvironments(prev => ({ ...prev, [moduleId]: true }));
|
||||
try {
|
||||
const envs = await listEnvironments(moduleId);
|
||||
if (envs) {
|
||||
setModuleEnvironments(prev => ({ ...prev, [moduleId]: envs }));
|
||||
return envs;
|
||||
}
|
||||
} finally {
|
||||
setLoadingEnvironments(prev => ({ ...prev, [moduleId]: false }));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const handleModuleToggle = async (moduleId: string) => {
|
||||
setFormData(prev => {
|
||||
const currentModules = prev.modules || [];
|
||||
const exists = currentModules.find(m => m.module_id === moduleId);
|
||||
|
||||
if (exists) {
|
||||
return {
|
||||
...prev,
|
||||
modules: currentModules.filter(m => m.module_id !== moduleId)
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
modules: [...currentModules, { module_id: moduleId, environment_slug: '' }]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const currentModules = formData.modules || [];
|
||||
const isAdding = !currentModules.find(m => m.module_id === moduleId);
|
||||
|
||||
if (isAdding) {
|
||||
const envs = await fetchModuleEnvironments(moduleId);
|
||||
const defaultEnv = envs.find(e => e.is_default)?.slug || envs[0]?.slug || '';
|
||||
|
||||
if (defaultEnv) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
modules: (prev.modules || []).map(m =>
|
||||
m.module_id === moduleId ? { ...m, environment_slug: defaultEnv } : m
|
||||
)
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnvironmentChange = (moduleId: string, slug: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
modules: (prev.modules || []).map(m =>
|
||||
m.module_id === moduleId ? { ...m, environment_slug: slug } : m
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage("");
|
||||
@@ -74,6 +155,7 @@ const AddTenants = () => {
|
||||
tenant_name: formData.tenant_name.trim(),
|
||||
tenant_domain: formData.tenant_domain.trim(),
|
||||
tenant_logo_url: formData.tenant_logo_url?.trim() || undefined,
|
||||
modules: formData.modules
|
||||
};
|
||||
|
||||
await tenantsApi.create(payload);
|
||||
@@ -173,6 +255,66 @@ const AddTenants = () => {
|
||||
value={formData.tenant_logo_url}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Module Provisioning</h3>
|
||||
{isModulesLoading ? (
|
||||
<div className="text-sm text-gray-500">Loading modules...</div>
|
||||
) : availableModules.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<AlertCircle size={16} />
|
||||
No modules available for provisioning.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{availableModules.map(module => {
|
||||
const isSelected = formData.modules?.some(m => m.module_id === module.id);
|
||||
const selectedConfig = formData.modules?.find(m => m.module_id === module.id);
|
||||
const moduleEnvs = moduleEnvironments[module.id] || [];
|
||||
const isLoadingEnvs = loadingEnvironments[module.id];
|
||||
|
||||
return (
|
||||
<div key={module.id} className={`p-4 rounded-lg border ${isSelected ? 'border-primary-200 bg-primary-50' : 'border-gray-200 bg-gray-50'}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`module-${module.id}`}
|
||||
checked={isSelected}
|
||||
onChange={() => handleModuleToggle(module.id)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor={`module-${module.id}`} className="cursor-pointer">
|
||||
<div className="font-medium text-gray-900">{module.module_name}</div>
|
||||
<div className="text-xs text-gray-500">ID: {module.module_id}</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className="w-48">
|
||||
<CustomDropdown
|
||||
label=""
|
||||
value={selectedConfig?.environment_slug || ''}
|
||||
onChange={(e) => handleEnvironmentChange(module.id, e.target.value)}
|
||||
options={moduleEnvs.map(env => ({
|
||||
label: `${env.slug} (${env.trust_type})`,
|
||||
value: env.slug
|
||||
}))}
|
||||
placeholder={isLoadingEnvs ? "Loading..." : "Select Environment"}
|
||||
disabled={isLoadingEnvs || moduleEnvs.length === 0}
|
||||
/>
|
||||
{moduleEnvs.length === 0 && !isLoadingEnvs && (
|
||||
<div className="text-xs text-red-500 mt-1">No environments found</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
@@ -180,9 +322,9 @@ const AddTenants = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<div className="flex justify-end pt-4">
|
||||
<CustomButton type="submit" variant="primary" disabled={isLoading} loading={isLoading}>
|
||||
Create Tenant
|
||||
Create & Provision Tenant
|
||||
</CustomButton>
|
||||
</div>
|
||||
</form>
|
||||
@@ -191,4 +333,4 @@ const AddTenants = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AddTenants;
|
||||
export default AddTenants;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Edit2, Eye, Trash2 } from "lucide-react";
|
||||
import { Edit2, Eye, Trash2, AlertCircle } from "lucide-react";
|
||||
import {
|
||||
CustomButton,
|
||||
CustomCheckBox,
|
||||
@@ -12,13 +12,15 @@ import {
|
||||
CustomLoader,
|
||||
CustomActionMenu,
|
||||
CustomActionItem,
|
||||
CustomDropdown
|
||||
} from "../../../components/custom";
|
||||
import type { ColumnDef } from "../../../components/custom/CustomTable";
|
||||
import type { Tenant, TenantUpdateRequest } from "../TenantsTypes";
|
||||
import type { Tenant, TenantUpdateRequest, TenantModuleCreate } from "../TenantsTypes";
|
||||
import { tenantsApi } from "../TenantsApi";
|
||||
import { useAuth } from "../../../context/AuthContext";
|
||||
import { useModuleApi } from "../../modules/admin/hooks/useModuleApi";
|
||||
import type { Module, ModuleEnvironment } from "../../modules/admin/AdminModuleTypes";
|
||||
|
||||
// Local implementation of useDebounce
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
@@ -32,7 +34,6 @@ function useDebounce<T>(value: T, delay: number): T {
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// Local implementation of ProtectedComponent
|
||||
const ProtectedComponent: React.FC<{
|
||||
requiredAccess: string;
|
||||
children: React.ReactNode;
|
||||
@@ -50,7 +51,6 @@ const formatDate = (dateString?: string | null) => {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) return dateString;
|
||||
|
||||
// Check if the input likely contains specific time (ISO with T or explicit time chars)
|
||||
const hasTime = dateString.includes("T") || dateString.includes(":");
|
||||
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
@@ -71,6 +71,14 @@ const formatDate = (dateString?: string | null) => {
|
||||
}
|
||||
};
|
||||
|
||||
interface EditFormState {
|
||||
tenant_name: string;
|
||||
tenant_domain: string;
|
||||
tenant_logo_url: string;
|
||||
is_active: boolean;
|
||||
modules: TenantModuleCreate[];
|
||||
}
|
||||
|
||||
const AllTenants = () => {
|
||||
const { hasAccess, isLoading: isAuthLoading } = useAuth();
|
||||
const canReadAll = hasAccess("superadmin.tenant.read");
|
||||
@@ -81,34 +89,39 @@ const AllTenants = () => {
|
||||
const [isViewOpen, setIsViewOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState({
|
||||
|
||||
const [editForm, setEditForm] = useState<EditFormState>({
|
||||
tenant_name: "",
|
||||
tenant_domain: "",
|
||||
tenant_logo_url: "",
|
||||
is_active: true,
|
||||
modules: []
|
||||
});
|
||||
|
||||
// Module Management State
|
||||
const { listModules, listEnvironments, listTenantModules } = useModuleApi();
|
||||
const [availableModules, setAvailableModules] = useState<Module[]>([]);
|
||||
const [moduleEnvironments, setModuleEnvironments] = useState<Record<string, ModuleEnvironment[]>>({});
|
||||
const [loadingEnvironments, setLoadingEnvironments] = useState<Record<string, boolean>>({});
|
||||
const [isLoadingModules, setIsLoadingModules] = useState(false);
|
||||
|
||||
const [editError, setEditError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Pagination state
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [search, setSearch] = useState("");
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [, setTotalPages] = useState(0);
|
||||
|
||||
// Status filter state
|
||||
const [statusFilter, setStatusFilter] = useState<boolean | null>(null);
|
||||
|
||||
// Ref for the search input to restore focus
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Track previous loading state to detect transition from loading → idle
|
||||
const prevLoadingRef = useRef(isLoading);
|
||||
|
||||
// Debounce search to avoid excessive API calls
|
||||
const debouncedSearch = useDebounce(search, 500);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -124,7 +137,6 @@ const AllTenants = () => {
|
||||
|
||||
try {
|
||||
if (canReadAll) {
|
||||
// Use paginated API for server-side pagination
|
||||
const response = await tenantsApi.getPaginated({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
@@ -138,7 +150,6 @@ const AllTenants = () => {
|
||||
setTotalPages(response.total_pages);
|
||||
}
|
||||
} else {
|
||||
// For non-superadmin, show only their tenant
|
||||
const data = await tenantsApi.getMine();
|
||||
if (isMounted) {
|
||||
setTenants([data]);
|
||||
@@ -168,7 +179,6 @@ const AllTenants = () => {
|
||||
};
|
||||
}, [canReadAll, isAuthLoading, page, pageSize, debouncedSearch, statusFilter]);
|
||||
|
||||
// Restore focus to search input after loading completes (only if user was searching)
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !isLoading && search.trim() !== "") {
|
||||
searchInputRef.current?.focus({ preventScroll: true });
|
||||
@@ -176,27 +186,75 @@ const AllTenants = () => {
|
||||
prevLoadingRef.current = isLoading;
|
||||
}, [isLoading, search]);
|
||||
|
||||
// Reset to page 1 when search or status filter changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearch, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAccess("superadmin.tenant.update")) {
|
||||
listModules().then((data) => {
|
||||
if (data) setAvailableModules(data);
|
||||
});
|
||||
}
|
||||
}, [hasAccess]);
|
||||
|
||||
const openView = useCallback((tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
setIsViewOpen(true);
|
||||
}, []);
|
||||
|
||||
const openEdit = useCallback((tenant: Tenant) => {
|
||||
const fetchModuleEnvironments = async (moduleId: string) => {
|
||||
if (moduleEnvironments[moduleId]) return moduleEnvironments[moduleId];
|
||||
setLoadingEnvironments(prev => ({ ...prev, [moduleId]: true }));
|
||||
try {
|
||||
const envs = await listEnvironments(moduleId);
|
||||
if (envs) {
|
||||
setModuleEnvironments(prev => ({ ...prev, [moduleId]: envs }));
|
||||
return envs;
|
||||
}
|
||||
} finally {
|
||||
setLoadingEnvironments(prev => ({ ...prev, [moduleId]: false }));
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const openEdit = useCallback(async (tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
setEditForm({
|
||||
setIsEditOpen(true);
|
||||
setEditError("");
|
||||
setIsLoadingModules(true);
|
||||
|
||||
const initialForm: EditFormState = {
|
||||
tenant_name: tenant.tenant_name,
|
||||
tenant_domain: tenant.tenant_domain,
|
||||
tenant_logo_url: tenant.tenant_logo_url ?? "",
|
||||
is_active: tenant.is_active,
|
||||
});
|
||||
setEditError("");
|
||||
setIsEditOpen(true);
|
||||
}, []);
|
||||
modules: []
|
||||
};
|
||||
setEditForm(initialForm);
|
||||
|
||||
try {
|
||||
const assigned = await listTenantModules(tenant.id);
|
||||
if (assigned) {
|
||||
const activeModules: TenantModuleCreate[] = assigned
|
||||
.filter(tm => tm.is_active)
|
||||
.map(tm => ({
|
||||
module_id: tm.module_id,
|
||||
environment_slug: tm.assigned_environment_slug
|
||||
}));
|
||||
|
||||
setEditForm(prev => ({ ...prev, modules: activeModules }));
|
||||
|
||||
assigned.forEach(tm => {
|
||||
fetchModuleEnvironments(tm.module_id);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load assigned modules", e);
|
||||
} finally {
|
||||
setIsLoadingModules(false);
|
||||
}
|
||||
}, [listTenantModules]);
|
||||
|
||||
const openDelete = useCallback((tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
@@ -236,11 +294,53 @@ const AllTenants = () => {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleModuleToggle = async (moduleId: string) => {
|
||||
setEditForm(prev => {
|
||||
const currentModules = prev.modules;
|
||||
const exists = currentModules.find(m => m.module_id === moduleId);
|
||||
|
||||
if (exists) {
|
||||
return {
|
||||
...prev,
|
||||
modules: currentModules.filter(m => m.module_id !== moduleId)
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...prev,
|
||||
modules: [...currentModules, { module_id: moduleId, environment_slug: '' }]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const isAdding = !editForm.modules.find(m => m.module_id === moduleId);
|
||||
if (isAdding) {
|
||||
const envs = await fetchModuleEnvironments(moduleId);
|
||||
const defaultEnv = envs.find(e => e.is_default)?.slug || envs[0]?.slug || '';
|
||||
|
||||
if (defaultEnv) {
|
||||
setEditForm(prev => ({
|
||||
...prev,
|
||||
modules: prev.modules.map(m =>
|
||||
m.module_id === moduleId ? { ...m, environment_slug: defaultEnv } : m
|
||||
)
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnvironmentChange = (moduleId: string, slug: string) => {
|
||||
setEditForm(prev => ({
|
||||
...prev,
|
||||
modules: prev.modules.map(m =>
|
||||
m.module_id === moduleId ? { ...m, environment_slug: slug } : m
|
||||
)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleUpdate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedTenant) return;
|
||||
|
||||
// Capture the ID to use in the async context
|
||||
const tenantId = selectedTenant.id;
|
||||
|
||||
setEditError("");
|
||||
@@ -252,11 +352,11 @@ const AllTenants = () => {
|
||||
tenant_domain: editForm.tenant_domain.trim(),
|
||||
tenant_logo_url: editForm.tenant_logo_url.trim() || null,
|
||||
is_active: editForm.is_active,
|
||||
modules: editForm.modules
|
||||
};
|
||||
|
||||
const updatedTenant = await tenantsApi.update(tenantId, payload);
|
||||
|
||||
// Safeguard: Ensure we actually got a tenant back
|
||||
if (!updatedTenant || !updatedTenant.id) {
|
||||
throw new Error("Invalid response from server");
|
||||
}
|
||||
@@ -402,9 +502,6 @@ const AllTenants = () => {
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-[var(--text-primary)]">Tenants</h1>
|
||||
{/* <p className="text-sm text-[var(--text-secondary)]">
|
||||
{totalRows} Tenant{totalRows === 1 ? "" : "s"} in total
|
||||
</p> */}
|
||||
</div>
|
||||
<ProtectedComponent requiredAccess="superadmin.tenant.create">
|
||||
<Link to="/tenants/add">
|
||||
@@ -572,12 +669,6 @@ const AllTenants = () => {
|
||||
/>
|
||||
|
||||
<div className="flex mt-6 items-center">
|
||||
{/* Checkbox component not found in standard custom, using explicit input or different component if needed,
|
||||
but keeping CustomSwitch or similar logic is safer. User had CustomCheckBox.
|
||||
I'll assume I need to replace it with simple input or use CustomSwitch if I saw it.
|
||||
I saw CustomSwitch in my files but not necessarily in the user's previous code unless I missed it.
|
||||
Actually, CustomCheckBox was imported from "Custom". I have "CustomSwitch".
|
||||
Let's use CustomSwitch for "is_active" as it's cleaner. */}
|
||||
<CustomCheckBox
|
||||
label="Active"
|
||||
name="is_active"
|
||||
@@ -585,6 +676,66 @@ const AllTenants = () => {
|
||||
onChange={handleStatusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Module Provisioning</h3>
|
||||
{isLoadingModules ? (
|
||||
<div className="text-sm text-gray-500">Loading modules...</div>
|
||||
) : availableModules.length === 0 ? (
|
||||
<div className="text-sm text-gray-500 flex items-center gap-2">
|
||||
<AlertCircle size={16} />
|
||||
No modules available for provisioning.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{availableModules.map(module => {
|
||||
const isSelected = editForm.modules?.some(m => m.module_id === module.id);
|
||||
const selectedConfig = editForm.modules?.find(m => m.module_id === module.id);
|
||||
const moduleEnvs = moduleEnvironments[module.id] || [];
|
||||
const isLoadingEnvs = loadingEnvironments[module.id];
|
||||
|
||||
return (
|
||||
<div key={module.id} className={`p-4 rounded-lg border ${isSelected ? 'border-primary-200 bg-primary-50' : 'border-gray-200 bg-gray-50'}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`edit-module-${module.id}`}
|
||||
checked={isSelected}
|
||||
onChange={() => handleModuleToggle(module.id)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500 cursor-pointer"
|
||||
/>
|
||||
<label htmlFor={`edit-module-${module.id}`} className="cursor-pointer">
|
||||
<div className="font-medium text-gray-900">{module.module_name}</div>
|
||||
<div className="text-xs text-gray-500">ID: {module.module_id}</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className="w-48">
|
||||
<CustomDropdown
|
||||
label=""
|
||||
value={selectedConfig?.environment_slug || ''}
|
||||
onChange={(e) => handleEnvironmentChange(module.id, e.target.value)}
|
||||
options={moduleEnvs.map(env => ({
|
||||
label: `${env.slug} (${env.trust_type})`,
|
||||
value: env.slug
|
||||
}))}
|
||||
placeholder={isLoadingEnvs ? "Loading..." : "Select Environment"}
|
||||
disabled={isLoadingEnvs || moduleEnvs.length === 0}
|
||||
/>
|
||||
{moduleEnvs.length === 0 && !isLoadingEnvs && (
|
||||
<div className="text-xs text-red-500 mt-1">No environments found</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, forwardRef } from "react";
|
||||
import { Phone, Eye, EyeOff } from "lucide-react";
|
||||
|
||||
type InputType = "text" | "password" | "number" | "email" | "tel" | "date";
|
||||
type InputType = "text" | "password" | "number" | "email" | "tel" | "date" | "url";
|
||||
|
||||
interface CustomInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
@@ -129,4 +129,4 @@ const CustomInput = forwardRef<HTMLInputElement, CustomInputProps>(
|
||||
}
|
||||
);
|
||||
|
||||
export default CustomInput;
|
||||
export default CustomInput;
|
||||
@@ -63,7 +63,7 @@ const AppHeader: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<Link to="/" className="lg:hidden flex items-center gap-2">
|
||||
<span className="font-bold text-[var(--header-text)] text-lg">F&L</span>
|
||||
<span className="font-bold text-[var(--header-text)] text-lg">SaaS</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -69,6 +69,12 @@ const navItems: NavItem[] = [
|
||||
path: "/theme",
|
||||
access: "superadmin.palette.read",
|
||||
},
|
||||
{
|
||||
icon: <Package size={22} />,
|
||||
name: "Modules",
|
||||
path: "/admin/modules",
|
||||
access: "modules.view",
|
||||
},
|
||||
{
|
||||
icon: <Settings size={22} />,
|
||||
name: "Settings",
|
||||
@@ -104,6 +110,7 @@ const AppSidebar: React.FC = () => {
|
||||
'Packaging': 'packaging',
|
||||
'Dropdowns': 'dropdowns',
|
||||
'Settings': 'settings',
|
||||
'Modules': 'modules',
|
||||
};
|
||||
|
||||
const key = keyMap[name];
|
||||
@@ -231,7 +238,7 @@ const AppSidebar: React.FC = () => {
|
||||
<Package size={32} />
|
||||
</div>
|
||||
<span className="text-xl font-bold text-(--sidebar-active-text) whitespace-nowrap">
|
||||
F&L
|
||||
SaaS
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -391,4 +398,4 @@ const AppSidebar: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AppSidebar;
|
||||
export default AppSidebar;
|
||||
@@ -19,22 +19,9 @@ export default function AuthLayout({
|
||||
>
|
||||
<div className="absolute inset-0 " />
|
||||
<div className="relative flex items-center justify-center z-10">
|
||||
{/* <div className="flex flex-col items-center max-w-xs">
|
||||
<Link to="/" className="block mb-4">
|
||||
<img
|
||||
width={231}
|
||||
height={48}
|
||||
src="/images/logo/auth-logo.svg"
|
||||
alt="Logo"
|
||||
/>
|
||||
</Link>
|
||||
<p className="text-center text-gray-200 mt-2 text-sm font-medium">
|
||||
Streamline your global fulfillment and logistics operations.
|
||||
</p>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -12,6 +12,8 @@ import enProfile from './locales/en/profile.json';
|
||||
import arProfile from './locales/ar/profile.json';
|
||||
import enDashboard from './locales/en/dashboard.json';
|
||||
import arDashboard from './locales/ar/dashboard.json';
|
||||
import enModules from './locales/en/modules.json';
|
||||
import arModules from './locales/ar/modules.json';
|
||||
|
||||
export const languages = {
|
||||
en: { name: 'English', dir: 'ltr' },
|
||||
@@ -27,6 +29,7 @@ const resources = {
|
||||
roles: enRoles,
|
||||
profile: enProfile,
|
||||
dashboard: enDashboard,
|
||||
modules: enModules,
|
||||
},
|
||||
ar: {
|
||||
common: arCommon,
|
||||
@@ -34,6 +37,7 @@ const resources = {
|
||||
roles: arRoles,
|
||||
profile: arProfile,
|
||||
dashboard: arDashboard,
|
||||
modules: arModules,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -73,4 +77,4 @@ if (i18next.language) {
|
||||
updateDirection(i18next.language);
|
||||
}
|
||||
|
||||
export default i18next;
|
||||
export default i18next;
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"registry": {
|
||||
"title": "Module Registry",
|
||||
"subtitle": "Manage platform modules and their configurations",
|
||||
"create_button": "Create Module",
|
||||
"empty_state": {
|
||||
"title": "No Modules Yet",
|
||||
"subtitle": "Create your first module to get started"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Create Module",
|
||||
"edit_title": "Edit Module",
|
||||
"fields": {
|
||||
"module_id": {
|
||||
"label": "Module ID",
|
||||
"placeholder": "e.g., inventory",
|
||||
"hint": "Unique identifier (lowercase, alphanumeric, hyphens)",
|
||||
"error": {
|
||||
"required": "Module ID is required",
|
||||
"pattern": "Only lowercase alphanumeric and hyphens allowed"
|
||||
}
|
||||
},
|
||||
"module_name": {
|
||||
"label": "Module Name",
|
||||
"placeholder": "e.g., Inventory Management",
|
||||
"error": {
|
||||
"required": "Module Name is required"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"label": "Description",
|
||||
"placeholder": "Brief description of the module"
|
||||
},
|
||||
"icon_url": {
|
||||
"label": "Icon URL",
|
||||
"placeholder": "https://example.com/icon.png"
|
||||
},
|
||||
"status": {
|
||||
"label": "Status",
|
||||
"options": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
}
|
||||
},
|
||||
"display_order": {
|
||||
"label": "Display Order"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"create": "Create Module",
|
||||
"update": "Update Module",
|
||||
"cancel": "Cancel",
|
||||
"saving": "Saving..."
|
||||
}
|
||||
},
|
||||
"delete_modal": {
|
||||
"title": "Delete Module",
|
||||
"description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone and will remove all associated environments and tenant assignments.",
|
||||
"confirm": "Delete Module",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,8 @@
|
||||
"profile": "Profile",
|
||||
"settings": "Settings",
|
||||
"configuration": "Configuration",
|
||||
"dropdowns": "Dropdowns"
|
||||
"dropdowns": "Dropdowns",
|
||||
"modules": "Modules"
|
||||
},
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
@@ -107,4 +108,4 @@
|
||||
"error": "An error occurred. Please try again.",
|
||||
"confirmDelete": "Are you sure you want to delete this item?"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
{
|
||||
"title": "Dashboard in Progress",
|
||||
"message": "We're crafting a powerful dashboard experience to give you better insights. Please check back soon!"
|
||||
"title": "Dashboard"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"registry": {
|
||||
"title": "Module Registry",
|
||||
"subtitle": "Manage platform modules and their configurations",
|
||||
"create_button": "Create Module",
|
||||
"empty_state": {
|
||||
"title": "No Modules Yet",
|
||||
"subtitle": "Create your first module to get started"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"create_title": "Create Module",
|
||||
"edit_title": "Edit Module",
|
||||
"fields": {
|
||||
"module_id": {
|
||||
"label": "Module ID",
|
||||
"placeholder": "e.g., inventory",
|
||||
"hint": "Unique identifier (lowercase, alphanumeric, hyphens)",
|
||||
"error": {
|
||||
"required": "Module ID is required",
|
||||
"pattern": "Only lowercase alphanumeric and hyphens allowed"
|
||||
}
|
||||
},
|
||||
"module_name": {
|
||||
"label": "Module Name",
|
||||
"placeholder": "e.g., Inventory Management",
|
||||
"error": {
|
||||
"required": "Module Name is required"
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"label": "Description",
|
||||
"placeholder": "Brief description of the module"
|
||||
},
|
||||
"icon_url": {
|
||||
"label": "Icon URL",
|
||||
"placeholder": "https://example.com/icon.png"
|
||||
},
|
||||
"status": {
|
||||
"label": "Status",
|
||||
"options": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
}
|
||||
},
|
||||
"display_order": {
|
||||
"label": "Display Order"
|
||||
}
|
||||
},
|
||||
"buttons": {
|
||||
"create": "Create Module",
|
||||
"update": "Update Module",
|
||||
"cancel": "Cancel",
|
||||
"saving": "Saving..."
|
||||
}
|
||||
},
|
||||
"delete_modal": {
|
||||
"title": "Delete Module",
|
||||
"description": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone and will remove all associated environments and tenant assignments.",
|
||||
"confirm": "Delete Module",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import ProtectedRoutes from "./ProtectedRoutes";
|
||||
import Theme from "../application/theme";
|
||||
import SettingsPage from "../application/settings/SettingsPage";
|
||||
import Users from "../application/users";
|
||||
import Modules from "../application/modules/admin";
|
||||
import TenantModuleAssignment from "../application/modules/admin/components/TenantModuleAssignment";
|
||||
|
||||
const AppRoutes = () => {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -28,6 +31,11 @@ const AppRoutes = () => {
|
||||
<Route path="/theme/*" element={<Theme />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/users/*" element={<Users />} />
|
||||
|
||||
{/* Admin Module Routes */}
|
||||
<Route path="/admin/modules/*" element={<Modules />} />
|
||||
<Route path="/admin/tenants/:tenantId/modules" element={<TenantModuleAssignment />} />
|
||||
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
|
||||
@@ -36,4 +44,4 @@ const AppRoutes = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AppRoutes;
|
||||
export default AppRoutes;
|
||||
Reference in New Issue
Block a user