Added tenant management
Applied basic access control
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { Suspense, lazy } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { queryClient } from './lib/queryClient';
|
||||
import { AuthProvider, ProtectedRoute } from './auth/AuthProvider';
|
||||
import { AuthProvider, ProtectedRoute, TenantAdminProtectedRoute } from './auth/AuthProvider';
|
||||
import { ToastProvider } from './components/ui/Toast';
|
||||
import { AppLayout } from './components/layout/AppLayout';
|
||||
|
||||
@@ -15,6 +15,7 @@ const ChangeManagementPage = lazy(() => import('./pages/ChangeManagementPage').t
|
||||
const SiteVisitsPage = lazy(() => import('./pages/SiteVisitsPage').then(m => ({ default: m.SiteVisitsPage })));
|
||||
const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: m.GrantsPage })));
|
||||
const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage })));
|
||||
const TenantManagementPage = lazy(() => import('./pages/TenantManagementPage').then(m => ({ default: m.TenantManagementPage })));
|
||||
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
|
||||
|
||||
const RouteLoading = () => (
|
||||
@@ -57,6 +58,22 @@ export default function App() {
|
||||
<Route path="/site-visits" element={<SiteVisitsPage />} />
|
||||
<Route path="/grants" element={<GrantsPage />} />
|
||||
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||
<Route
|
||||
path="/tenants"
|
||||
element={
|
||||
<TenantAdminProtectedRoute>
|
||||
<TenantManagementPage />
|
||||
</TenantAdminProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/governance/tenants"
|
||||
element={
|
||||
<TenantAdminProtectedRoute>
|
||||
<TenantManagementPage />
|
||||
</TenantAdminProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
UserSyncResult,
|
||||
} from '../services/userSyncService';
|
||||
import { AccountActivationModal } from '../components/auth/AccountActivationModal';
|
||||
import { PartnerAccessDeniedModal } from '../components/auth/PartnerAccessDeniedModal';
|
||||
import { SessionExpiredModal } from '../components/auth/SessionExpiredModal';
|
||||
import { encodeHashIdSync } from '../services/toolsService';
|
||||
import { verifyTenantLockout } from '../services/tenantLockoutService';
|
||||
@@ -44,7 +45,11 @@ interface AuthContextType {
|
||||
claims: UserClaims;
|
||||
tenantId: number | null;
|
||||
hashedTenantId: string | null;
|
||||
tenantName: string | null;
|
||||
accessToken: string | null;
|
||||
roles: string[];
|
||||
isPartner: boolean;
|
||||
canManageTenants: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isSynced: boolean;
|
||||
@@ -65,6 +70,73 @@ export const LOCAL_STORAGE_USER_KEY = 'pfm_auth_user';
|
||||
export const LOCAL_STORAGE_SUB_KEY = 'pfm_user_sub';
|
||||
export const LOCAL_STORAGE_CLAIMS_KEY = 'pfm_user_claims';
|
||||
|
||||
export function extractUserRoles(
|
||||
claims?: UserClaims | null,
|
||||
user?: AuthUserProfile | null,
|
||||
token?: string | null
|
||||
): string[] {
|
||||
const rolesSet = new Set<string>();
|
||||
|
||||
const processRoles = (raw: any) => {
|
||||
if (!raw) return;
|
||||
if (Array.isArray(raw)) {
|
||||
raw.forEach((r) => {
|
||||
if (typeof r === 'string' && r.trim()) rolesSet.add(r.trim());
|
||||
});
|
||||
} else if (typeof raw === 'string' && raw.trim()) {
|
||||
if (raw.includes(',')) {
|
||||
raw.split(',').forEach((r) => {
|
||||
if (r.trim()) rolesSet.add(r.trim());
|
||||
});
|
||||
} else {
|
||||
rolesSet.add(raw.trim());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (claims) {
|
||||
processRoles(claims.roles);
|
||||
processRoles(claims.role);
|
||||
processRoles(claims['roles']);
|
||||
processRoles(claims['role']);
|
||||
processRoles(claims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']);
|
||||
processRoles(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role']);
|
||||
processRoles(claims['realm_access']?.roles);
|
||||
processRoles(claims['resource_access']?.roles);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const tokenClaims = parseJwtClaims(token);
|
||||
processRoles(tokenClaims.roles);
|
||||
processRoles(tokenClaims.role);
|
||||
processRoles(tokenClaims['roles']);
|
||||
processRoles(tokenClaims['role']);
|
||||
processRoles(tokenClaims['http://schemas.microsoft.com/ws/2008/06/identity/claims/role']);
|
||||
processRoles(tokenClaims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role']);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (user?.role && typeof user.role === 'string') {
|
||||
processRoles(user.role);
|
||||
}
|
||||
if ((user as any)?.roles) {
|
||||
processRoles((user as any).roles);
|
||||
}
|
||||
|
||||
return Array.from(rolesSet);
|
||||
}
|
||||
|
||||
export function hasPartnerRole(roles: string[]): boolean {
|
||||
return roles.some((r) => r.toLowerCase() === 'partner');
|
||||
}
|
||||
|
||||
export function canManageTenants(tenantId?: number | string | null, roles?: string[]): boolean {
|
||||
const isTenantOne = Number(tenantId) === 1;
|
||||
const hasAdmin = Array.isArray(roles) && roles.some((r) => r.toLowerCase() === 'admin');
|
||||
return isTenantOne && hasAdmin;
|
||||
}
|
||||
|
||||
export function extractTenantId(claims?: UserClaims | null, token?: string | null): number | null {
|
||||
if (claims) {
|
||||
const raw =
|
||||
@@ -272,6 +344,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null
|
||||
);
|
||||
});
|
||||
const [tenantName, setTenantName] = useState<string | null>(() => {
|
||||
const claims = getCurrentUserClaims();
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem(LOCAL_STORAGE_TOKEN_KEY) : null;
|
||||
const { tenant } = extractTenantClaimValues(claims, token);
|
||||
return tenant;
|
||||
});
|
||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isSynced, setIsSynced] = useState<boolean>(() => isUserSyncedInBrowser());
|
||||
@@ -393,6 +471,14 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
if (tenantLockoutResult.tenant?.name) {
|
||||
profile.tenant = tenantLockoutResult.tenant.name;
|
||||
setTenantName(tenantLockoutResult.tenant.name);
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(profile));
|
||||
} else if (tenantClaim) {
|
||||
setTenantName(tenantClaim);
|
||||
}
|
||||
|
||||
// Synchronize authenticated user profile with backend directory
|
||||
try {
|
||||
const syncResult = await syncUserAccount(userSub, token, profile, true);
|
||||
@@ -488,6 +574,17 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
if (tenantLockoutResult.tenant?.name) {
|
||||
if (resolvedUser) {
|
||||
resolvedUser.tenant = tenantLockoutResult.tenant.name;
|
||||
setUser({ ...resolvedUser });
|
||||
localStorage.setItem(LOCAL_STORAGE_USER_KEY, JSON.stringify(resolvedUser));
|
||||
}
|
||||
setTenantName(tenantLockoutResult.tenant.name);
|
||||
} else if (storedTenantClaim) {
|
||||
setTenantName(storedTenantClaim);
|
||||
}
|
||||
|
||||
// Check if session is already marked pending activation or needs sync check
|
||||
const wasPending = isActivationPending();
|
||||
setIsPendingActivation(wasPending);
|
||||
@@ -633,6 +730,18 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
}
|
||||
};
|
||||
|
||||
const roles = React.useMemo(() => {
|
||||
return extractUserRoles(claims, user, accessToken);
|
||||
}, [claims, user, accessToken]);
|
||||
|
||||
const isPartner = React.useMemo(() => {
|
||||
return hasPartnerRole(roles);
|
||||
}, [roles]);
|
||||
|
||||
const canManageTenantsAccess = React.useMemo(() => {
|
||||
return canManageTenants(tenantId, roles);
|
||||
}, [tenantId, roles]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
@@ -641,7 +750,11 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||
claims,
|
||||
tenantId,
|
||||
hashedTenantId,
|
||||
tenantName,
|
||||
accessToken,
|
||||
roles,
|
||||
isPartner,
|
||||
canManageTenants: canManageTenantsAccess,
|
||||
isAuthenticated: !!accessToken,
|
||||
isLoading,
|
||||
isSynced,
|
||||
@@ -680,6 +793,8 @@ export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children })
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
isPendingActivation,
|
||||
isPartner,
|
||||
roles,
|
||||
user,
|
||||
claims,
|
||||
sub,
|
||||
@@ -707,6 +822,18 @@ export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children })
|
||||
return <Navigate to="/" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
// Security Access Control: Users assigned the Partner role are denied entry to the console
|
||||
if (isPartner || hasPartnerRole(roles)) {
|
||||
return (
|
||||
<PartnerAccessDeniedModal
|
||||
user={user}
|
||||
claims={claims}
|
||||
sub={sub}
|
||||
onLogout={logout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// If user is authenticated but account has not yet been enabled by an administrator:
|
||||
// Render the non-dismissable activation modal, blocking all downstream layout and queries.
|
||||
if (isPendingActivation) {
|
||||
@@ -724,3 +851,18 @@ export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children })
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Guard for Tenant Management (tenant_id == 1 and role list has Admin)
|
||||
export const TenantAdminProtectedRoute: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const { canManageTenants: allowed, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ShieldAlert,
|
||||
LogOut,
|
||||
Ban,
|
||||
Mail,
|
||||
Building2,
|
||||
Fingerprint,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { BrandLogo } from '../ui/BrandLogo';
|
||||
import { AuthUserProfile, UserClaims } from '../../auth/AuthProvider';
|
||||
import { cancelSessionExpiration, dismissSubordinateModals } from '../../services/sessionExpiration';
|
||||
|
||||
interface PartnerAccessDeniedModalProps {
|
||||
user: AuthUserProfile | null;
|
||||
claims: UserClaims;
|
||||
sub: string;
|
||||
onLogout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const PartnerAccessDeniedModal: React.FC<PartnerAccessDeniedModalProps> = ({
|
||||
user,
|
||||
claims,
|
||||
sub,
|
||||
onLogout,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Clear any existing session prompt and close background dialogs
|
||||
useEffect(() => {
|
||||
cancelSessionExpiration();
|
||||
dismissSubordinateModals();
|
||||
}, []);
|
||||
|
||||
const displayName =
|
||||
user?.name ||
|
||||
(claims.name as string) ||
|
||||
(claims.preferred_username as string) ||
|
||||
'Authenticated User';
|
||||
|
||||
const displayEmail =
|
||||
user?.email ||
|
||||
(claims.email as string) ||
|
||||
(claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
|
||||
'';
|
||||
|
||||
const tenant = user?.tenant || (claims.tenant as string) || (claims.org as string) || 'Statutory Enterprise';
|
||||
const role = user?.role || (claims.role as string) || 'Partner';
|
||||
|
||||
const handleLogoutClick = async () => {
|
||||
await onLogout();
|
||||
navigate('/', { replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="partner-denied-title"
|
||||
aria-describedby="partner-denied-description"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/85 backdrop-blur-md animate-in fade-in duration-200"
|
||||
>
|
||||
<div className="relative w-full max-w-lg overflow-hidden rounded-2xl bg-[#0e1424] border border-rose-900/60 shadow-2xl shadow-rose-950/50">
|
||||
{/* Glow Header Accent */}
|
||||
<div className="h-2 w-full bg-linear-to-r from-rose-600 via-red-500 to-amber-500" />
|
||||
|
||||
<div className="p-6 sm:p-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-rose-500/15 border border-rose-500/30 text-rose-400">
|
||||
<Ban className="h-6 w-6 text-rose-400" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-rose-400 bg-rose-950/60 px-2 py-0.5 rounded-md border border-rose-800/60">
|
||||
<Lock className="w-3 h-3" />
|
||||
Restricted Role
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">Security Policy</span>
|
||||
</div>
|
||||
<h2 id="partner-denied-title" className="text-lg font-bold text-white tracking-tight">
|
||||
Access Denied: Partner Role
|
||||
</h2>
|
||||
<p id="partner-denied-description" className="text-xs text-slate-400 leading-relaxed">
|
||||
Users assigned the <strong className="text-rose-300">Partner</strong> role by security are not permitted to access the PFM Backoffice Console.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User & Identity Summary Card */}
|
||||
<div className="rounded-xl bg-slate-900/80 border border-slate-800 p-4 space-y-3 text-xs">
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium">Account Name</span>
|
||||
<span className="text-white font-semibold">{displayName}</span>
|
||||
</div>
|
||||
|
||||
{displayEmail && (
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
||||
Email
|
||||
</span>
|
||||
<span className="text-slate-200 font-mono">{displayEmail}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5 text-slate-500" />
|
||||
Assigned Role
|
||||
</span>
|
||||
<span className="text-rose-400 font-semibold bg-rose-950/60 px-2 py-0.5 rounded border border-rose-800/50">
|
||||
{role}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tenant && (
|
||||
<div className="flex items-center justify-between pb-2 border-b border-slate-800/80">
|
||||
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||
<Building2 className="w-3.5 h-3.5 text-slate-500" />
|
||||
Tenant Context
|
||||
</span>
|
||||
<span className="text-slate-200 font-medium truncate max-w-[220px]">{tenant}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub && (
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Fingerprint className="w-3 h-3" />
|
||||
Subject Key
|
||||
</span>
|
||||
<span className="font-mono text-slate-400 truncate max-w-[200px]">{sub}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Policy Information */}
|
||||
<div className="p-4 rounded-xl bg-rose-950/20 border border-rose-900/40 text-xs text-rose-200 leading-relaxed space-y-1.5">
|
||||
<p className="font-semibold text-rose-300 flex items-center gap-1.5">
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400" />
|
||||
Administrative Boundary Notice
|
||||
</p>
|
||||
<p className="text-[11px] text-rose-300/90">
|
||||
The PFM Console is reserved for statutory administrators, sponsors, programme managers, and governance officers. Partner credentials cannot access this environment. If you require backoffice administrative access, please request an updated role assignment from your system administrator.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="w-full flex items-center justify-center gap-2 py-2.5 px-4 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 hover:text-white text-xs font-semibold border border-slate-700 transition cursor-pointer shadow-sm"
|
||||
>
|
||||
<LogOut className="w-4 h-4 text-rose-400" />
|
||||
<span>Sign Out & Return to Home</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Brand watermark */}
|
||||
<div className="px-6 py-3 bg-[#0a0f1d] border-t border-slate-800/80 flex items-center justify-between text-[11px] text-slate-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<BrandLogo size={16} showGlow={false} animateOnHover={false} />
|
||||
<span>Post Fund Management (PFM)</span>
|
||||
</div>
|
||||
<span>National Security Gateway</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -25,7 +25,7 @@ import { BrandLogo } from '../ui/BrandLogo';
|
||||
import { HelpTooltip } from '../ui/HelpTooltip';
|
||||
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getUsers } from '../../services/api';
|
||||
import { getUsers, getTenant } from '../../services/api';
|
||||
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
|
||||
|
||||
interface SubNavItem {
|
||||
@@ -94,6 +94,12 @@ const navItems: NavItem[] = [
|
||||
icon: ShieldAlert,
|
||||
help: 'Monitoring & Evaluation (M&E) framework, indicator tracking, ISO 31000 risk registers, mitigation plans, and compliance audits.',
|
||||
},
|
||||
{
|
||||
name: 'Tenant Management',
|
||||
path: '/tenants',
|
||||
icon: Database,
|
||||
help: 'Multi-tenant partition directory, organizational onboarding, status lifecycle controls, and system-wide isolation boundaries.',
|
||||
},
|
||||
{
|
||||
name: 'Settings & Communications',
|
||||
path: '/settings',
|
||||
@@ -103,7 +109,7 @@ const navItems: NavItem[] = [
|
||||
];
|
||||
|
||||
export const AppLayout: React.FC = () => {
|
||||
const { user, claims, sub, tenantId, hashedTenantId, logout } = useAuth();
|
||||
const { user, claims, sub, tenantId, hashedTenantId, tenantName: authTenantName, canManageTenants, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
@@ -112,6 +118,14 @@ export const AppLayout: React.FC = () => {
|
||||
await logout();
|
||||
};
|
||||
|
||||
// Only show Tenant Management if user's tenant_id == 1 and their role list has Admin in it
|
||||
const visibleNavItems = navItems.filter((item) => {
|
||||
if (item.path === '/tenants') {
|
||||
return canManageTenants;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Pre-populate user directory cache so all components and modals resolve user emails instantly
|
||||
useQuery({
|
||||
queryKey: ['users-directory'],
|
||||
@@ -119,6 +133,14 @@ export const AppLayout: React.FC = () => {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
|
||||
const { data: remoteTenant } = useQuery({
|
||||
queryKey: ['active-tenant-header', tenantId],
|
||||
queryFn: () => getTenant(tenantId!),
|
||||
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Ensure current logged-in user is registered in the email cache
|
||||
const effectiveEmail = ((user?.email || claims?.email || claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || claims?.upn) as string);
|
||||
if (sub && effectiveEmail) {
|
||||
@@ -143,14 +165,16 @@ export const AppLayout: React.FC = () => {
|
||||
(claims.upn as string) ||
|
||||
(sub ? getUserEmail(sub) : '');
|
||||
|
||||
// Extract tenant name directly from the user claim named 'tenant'
|
||||
// Extract tenant name from user claim 'tenant', auth context, remote tenant endpoint, or organization name
|
||||
const tenantName =
|
||||
(claims?.tenant as string) ||
|
||||
user?.tenant ||
|
||||
(claims?.['tenant'] as string) ||
|
||||
authTenantName ||
|
||||
user?.tenant ||
|
||||
remoteTenant?.name ||
|
||||
user?.organisationName ||
|
||||
(claims?.organisation as string) ||
|
||||
'Department of Science, Innovation & Technology (DSI)';
|
||||
(tenantId ? `Tenant #${tenantId}` : 'Department of Science, Innovation & Technology (DSI)');
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-[#090d16] font-sans text-slate-100 selection:bg-blue-600 selection:text-white">
|
||||
@@ -189,7 +213,7 @@ export const AppLayout: React.FC = () => {
|
||||
|
||||
{/* Navigation Items */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4 space-y-1.5">
|
||||
{navItems.map((item) => {
|
||||
{visibleNavItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const hasSub = item.subItems && item.subItems.length > 0;
|
||||
|
||||
@@ -289,21 +313,40 @@ export const AppLayout: React.FC = () => {
|
||||
|
||||
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
|
||||
<span className="text-slate-400">Tenant:</span>
|
||||
<span
|
||||
className="bg-slate-900/80 text-slate-200 px-2.5 py-1 rounded-lg border border-slate-800 flex items-center gap-1.5 font-medium shadow-xs max-w-[320px] truncate"
|
||||
title={`${tenantName}${hashedTenantId ? ` (Partition: ${hashedTenantId})` : ''}`}
|
||||
>
|
||||
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
<span className="truncate">{tenantName}</span>
|
||||
{hashedTenantId && (
|
||||
<span
|
||||
className="text-[10px] font-mono text-blue-400 bg-blue-950/60 px-1.5 py-0.5 rounded border border-blue-800/60 shrink-0"
|
||||
title={`Tenant Partition HashID: ${hashedTenantId}`}
|
||||
>
|
||||
{hashedTenantId}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{canManageTenants ? (
|
||||
<Link
|
||||
to="/tenants"
|
||||
className="bg-slate-900/80 hover:bg-slate-800/90 text-slate-200 hover:text-white px-2.5 py-1 rounded-lg border border-slate-800 hover:border-slate-700 flex items-center gap-1.5 font-medium shadow-xs max-w-[320px] truncate transition group cursor-pointer"
|
||||
title={`Manage Tenants • Current: ${tenantName}${hashedTenantId ? ` (Partition: ${hashedTenantId})` : ''}`}
|
||||
>
|
||||
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0 group-hover:text-blue-300 transition" />
|
||||
<span className="truncate">{tenantName}</span>
|
||||
{hashedTenantId && (
|
||||
<span
|
||||
className="text-[10px] font-mono text-blue-400 bg-blue-950/60 px-1.5 py-0.5 rounded border border-blue-800/60 shrink-0"
|
||||
title={`Tenant Partition HashID: ${hashedTenantId}`}
|
||||
>
|
||||
{hashedTenantId}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="bg-slate-900/80 text-slate-200 px-2.5 py-1 rounded-lg border border-slate-800 flex items-center gap-1.5 font-medium shadow-xs max-w-[320px] truncate"
|
||||
title={`${tenantName}${hashedTenantId ? ` (Partition: ${hashedTenantId})` : ''}`}
|
||||
>
|
||||
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||
<span className="truncate">{tenantName}</span>
|
||||
{hashedTenantId && (
|
||||
<span
|
||||
className="text-[10px] font-mono text-blue-400 bg-blue-950/60 px-1.5 py-0.5 rounded border border-blue-800/60 shrink-0"
|
||||
title={`Tenant Partition HashID: ${hashedTenantId}`}
|
||||
>
|
||||
{hashedTenantId}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<HelpTooltip
|
||||
title="Tenant Context"
|
||||
content={`Identifies the multi-tenant organization partition and database boundary within the national PFM environment.${
|
||||
|
||||
@@ -0,0 +1,949 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Database,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Edit2,
|
||||
Building2,
|
||||
Layers,
|
||||
Info,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
AlertTriangle,
|
||||
Lock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Mail,
|
||||
Shield,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getTenants,
|
||||
createTenant,
|
||||
updateTenant,
|
||||
getOrganisations,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
||||
import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { ActivityStatusBadge } from '../components/ui/StatusBadge';
|
||||
import { Tenant, Organisation, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
|
||||
import { formatDate, getOrganisationTypeLabel } from '../lib/formatters';
|
||||
import { encodeHashIdSync } from '../services/toolsService';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
const createTenantSchema = z.object({
|
||||
name: z.string().min(2, 'Tenant name must be at least 2 characters').max(100, 'Tenant name too long'),
|
||||
});
|
||||
|
||||
const updateTenantSchema = z.object({
|
||||
name: z.string().min(2, 'Tenant name must be at least 2 characters').max(100, 'Tenant name too long'),
|
||||
status: z.coerce.number().min(1).max(3),
|
||||
});
|
||||
|
||||
type CreateTenantFormValues = z.infer<typeof createTenantSchema>;
|
||||
type UpdateTenantFormValues = z.infer<typeof updateTenantSchema>;
|
||||
|
||||
export const TenantManagementPage: React.FC = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { tenantId: currentTenantId, tenantName: currentTenantName, hashedTenantId: currentHashedTenantId } = useAuth();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [expandedTenants, setExpandedTenants] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Modals
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null);
|
||||
|
||||
// Query Tenants
|
||||
const {
|
||||
data: tenants = [],
|
||||
isLoading: loadingTenants,
|
||||
isFetching: fetchingTenants,
|
||||
refetch: refetchTenants,
|
||||
} = useQuery<Tenant[]>({
|
||||
queryKey: ['tenants-list'],
|
||||
queryFn: () => getTenants(),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
// Query Organisations to populate tenant sub-lists
|
||||
const {
|
||||
data: organisations = [],
|
||||
isLoading: loadingOrgs,
|
||||
isFetching: fetchingOrgs,
|
||||
refetch: refetchOrgs,
|
||||
} = useQuery<Organisation[]>({
|
||||
queryKey: ['organisations'],
|
||||
queryFn: () => getOrganisations(),
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
// Forms
|
||||
const createForm = useForm<CreateTenantFormValues>({
|
||||
resolver: zodResolver(createTenantSchema),
|
||||
defaultValues: { name: '' },
|
||||
});
|
||||
|
||||
const updateForm = useForm<UpdateTenantFormValues>({
|
||||
resolver: zodResolver(updateTenantSchema),
|
||||
defaultValues: { name: '', status: ActivityStatus.Active },
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: CreateTenantRequest) => createTenant(payload),
|
||||
onSuccess: () => {
|
||||
success('Tenant Created', 'New multi-tenant partition has been provisioned successfully.');
|
||||
queryClient.invalidateQueries({ queryKey: ['tenants-list'] });
|
||||
setIsCreateModalOpen(false);
|
||||
createForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError(err, 'Failed to Create Tenant');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: UpdateTenantRequest) => updateTenant(payload),
|
||||
onSuccess: () => {
|
||||
success('Tenant Updated', 'Tenant configuration and operational status updated.');
|
||||
queryClient.invalidateQueries({ queryKey: ['tenants-list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active-tenant-header'] });
|
||||
setIsUpdateModalOpen(false);
|
||||
setSelectedTenant(null);
|
||||
updateForm.reset();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toastError(err, 'Failed to Update Tenant');
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
createForm.reset({ name: '' });
|
||||
setIsCreateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenUpdate = (tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
updateForm.reset({
|
||||
name: tenant.name || '',
|
||||
status: Number(tenant.status ?? ActivityStatus.Active),
|
||||
});
|
||||
setIsUpdateModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenDetail = (tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
setIsDetailModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCopyHashId = (text: string, idKey: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(idKey);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const toggleTenantExpand = (id: string | number) => {
|
||||
const key = String(id);
|
||||
setExpandedTenants((prev) => ({
|
||||
...prev,
|
||||
[key]: !prev[key],
|
||||
}));
|
||||
};
|
||||
|
||||
const getStatusString = (status?: ActivityStatus | number): string => {
|
||||
const s = Number(status);
|
||||
if (s === 1 || s === ActivityStatus.Active) return 'Active';
|
||||
if (s === 2 || s === ActivityStatus.Inactive) return 'Inactive';
|
||||
if (s === 3 || s === ActivityStatus.Disabled) return 'Disabled';
|
||||
return 'Active';
|
||||
};
|
||||
|
||||
// Filter & Search
|
||||
const filteredTenants = useMemo(() => {
|
||||
return tenants.filter((t) => {
|
||||
const s = Number(t.status ?? ActivityStatus.Active);
|
||||
const matchesSearch =
|
||||
!searchTerm.trim() ||
|
||||
(t.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
String(t.id).includes(searchTerm) ||
|
||||
encodeHashIdSync(t.id).toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(t.createdBy || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus =
|
||||
statusFilter === 'all' ||
|
||||
(statusFilter === 'active' && s === 1) ||
|
||||
(statusFilter === 'inactive' && s === 2) ||
|
||||
(statusFilter === 'disabled' && s === 3);
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
}, [tenants, searchTerm, statusFilter]);
|
||||
|
||||
// Sorting
|
||||
const { sortedData, sortColumn, sortDirection, handleSort } = useTableSort(filteredTenants, 'id', 'asc');
|
||||
|
||||
// Metrics
|
||||
const totalCount = tenants.length;
|
||||
const activeCount = tenants.filter((t) => Number(t.status ?? ActivityStatus.Active) === 1).length;
|
||||
const inactiveCount = tenants.filter((t) => Number(t.status) === 2).length;
|
||||
const disabledCount = tenants.filter((t) => Number(t.status) === 3).length;
|
||||
|
||||
// Export columns
|
||||
const exportColumns: ExportColumn<Tenant>[] = [
|
||||
{ header: 'Tenant ID', key: 'id' },
|
||||
{ header: 'Partition HashID', key: 'id', format: (val) => encodeHashIdSync(val) },
|
||||
{ header: 'Tenant Name', key: 'name', format: (val) => val || '—' },
|
||||
{ header: 'Operational Status', key: 'status', format: (val) => getStatusString(val) },
|
||||
{ header: 'Created By', key: 'createdBy', format: (val) => val || '—' },
|
||||
{ header: 'Created Date', key: 'createdAt', format: (val) => formatDate(val) },
|
||||
{ header: 'Last Updated', key: 'updatedAt', format: (val) => formatDate(val) },
|
||||
];
|
||||
|
||||
const handleRefreshAll = () => {
|
||||
refetchTenants();
|
||||
refetchOrgs();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-12">
|
||||
{/* Top Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800/80 pb-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
<div className="p-2 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400">
|
||||
<Database className="w-5 h-5" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-white tracking-tight">Tenant Management</h1>
|
||||
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/20">
|
||||
Multi-Tenancy
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 max-w-2xl">
|
||||
Control tenant database partition boundaries, onboard statutory authorities, and govern tenant lifecycle status.
|
||||
Expand any tenant row to inspect the scoped organisations governed under that partition.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5 self-start sm:self-auto">
|
||||
<button
|
||||
onClick={handleRefreshAll}
|
||||
disabled={fetchingTenants || fetchingOrgs}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-slate-900/80 hover:bg-slate-800 text-slate-300 text-xs font-medium rounded-lg border border-slate-800 transition shadow-xs disabled:opacity-50 cursor-pointer"
|
||||
title="Refresh tenants directory and organisations"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${fetchingTenants || fetchingOrgs ? 'animate-spin text-blue-400' : ''}`} />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
title="PFM Multi-Tenant Directory Register"
|
||||
data={sortedData}
|
||||
columns={exportColumns}
|
||||
filename="PFM_Tenants_Directory"
|
||||
label="Export Tenants"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-lg shadow-sm shadow-blue-600/30 transition cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Tenant</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Total Tenants */}
|
||||
<div className="p-4 rounded-xl bg-slate-900/60 border border-slate-800 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400">Total Partitions</p>
|
||||
<p className="text-2xl font-bold text-white mt-1">{loadingTenants ? '—' : totalCount}</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">Configured system partitions</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-slate-800/60 text-slate-300 border border-slate-700/50">
|
||||
<Layers className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Tenants */}
|
||||
<div className="p-4 rounded-xl bg-slate-900/60 border border-slate-800 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400">Active Tenants</p>
|
||||
<p className="text-2xl font-bold text-emerald-400 mt-1">{loadingTenants ? '—' : activeCount}</p>
|
||||
<p className="text-[11px] text-emerald-500/80 mt-0.5">Users permitted access</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-emerald-950/40 text-emerald-400 border border-emerald-800/30">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inactive / Disabled Tenants */}
|
||||
<div className="p-4 rounded-xl bg-slate-900/60 border border-slate-800 shadow-sm flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400">Locked / Inactive</p>
|
||||
<p className="text-2xl font-bold text-rose-400 mt-1">{loadingTenants ? '—' : inactiveCount + disabledCount}</p>
|
||||
<p className="text-[11px] text-rose-500/80 mt-0.5">Users locked out by policy</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-rose-950/40 text-rose-400 border border-rose-800/30">
|
||||
<Lock className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Active Tenant */}
|
||||
<div className="p-4 rounded-xl bg-blue-950/20 border border-blue-800/40 shadow-sm flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-blue-300 flex items-center gap-1.5">
|
||||
<span>Your Active Partition</span>
|
||||
<HelpTooltip
|
||||
title="Current Session Partition"
|
||||
content="The tenant partition your active login token is scoped to."
|
||||
size="sm"
|
||||
/>
|
||||
</p>
|
||||
<p className="text-sm font-bold text-white mt-1 truncate" title={currentTenantName || 'Default Tenant'}>
|
||||
{currentTenantName || `Tenant #${currentTenantId || 1}`}
|
||||
</p>
|
||||
<p className="text-[11px] font-mono text-blue-400 mt-0.5 truncate">
|
||||
HashID: {currentHashedTenantId || '—'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-3 rounded-xl bg-blue-900/40 text-blue-400 border border-blue-700/50 shrink-0">
|
||||
<Building2 className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search & Filter Bar */}
|
||||
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/80 p-3 rounded-xl border border-slate-800/80">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Tenant Name, ID, HashID, or Creator..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 bg-slate-950/80 border border-slate-800 rounded-lg text-xs text-slate-200 placeholder-slate-500 focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="flex items-center bg-slate-950/80 border border-slate-800 rounded-lg p-1 text-xs">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`px-3 py-1 rounded-md transition font-medium cursor-pointer ${
|
||||
statusFilter === 'all'
|
||||
? 'bg-blue-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
All ({totalCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('active')}
|
||||
className={`px-3 py-1 rounded-md transition font-medium cursor-pointer ${
|
||||
statusFilter === 'active'
|
||||
? 'bg-emerald-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Active ({activeCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('inactive')}
|
||||
className={`px-3 py-1 rounded-md transition font-medium cursor-pointer ${
|
||||
statusFilter === 'inactive'
|
||||
? 'bg-amber-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Inactive ({inactiveCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('disabled')}
|
||||
className={`px-3 py-1 rounded-md transition font-medium cursor-pointer ${
|
||||
statusFilter === 'disabled'
|
||||
? 'bg-rose-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
Disabled ({disabledCount})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tenants Table */}
|
||||
<div className="bg-slate-900/60 rounded-xl border border-slate-800 overflow-hidden shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-950/80 border-b border-slate-800 text-slate-400 font-semibold uppercase tracking-wider">
|
||||
<th className="py-3.5 px-3 w-10 text-center">
|
||||
<span className="sr-only">Expand</span>
|
||||
</th>
|
||||
<SortableTh column="id" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4">
|
||||
Tenant ID & HashID
|
||||
</SortableTh>
|
||||
<SortableTh column="name" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4">
|
||||
Tenant Organization Name
|
||||
</SortableTh>
|
||||
<SortableTh column="status" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4">
|
||||
Operational Status
|
||||
</SortableTh>
|
||||
<SortableTh column="createdBy" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4">
|
||||
Created By
|
||||
</SortableTh>
|
||||
<SortableTh column="createdAt" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4">
|
||||
Creation Date
|
||||
</SortableTh>
|
||||
<th className="py-3.5 px-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/60">
|
||||
{loadingTenants ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-12 text-center text-slate-400">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-blue-400" />
|
||||
<span>Loading multi-tenant partition registry...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="py-12 text-center text-slate-400">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Database className="w-7 h-7 text-slate-600" />
|
||||
<span className="font-medium text-slate-300">No tenant partitions found</span>
|
||||
<p className="text-[11px] text-slate-500">
|
||||
{searchTerm ? 'Try adjusting your search criteria or status filter.' : 'Provision your first tenant partition using the button above.'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedData.map((tenant) => {
|
||||
const hashId = encodeHashIdSync(tenant.id);
|
||||
const isCurrent = String(tenant.id) === String(currentTenantId);
|
||||
const s = Number(tenant.status ?? ActivityStatus.Active);
|
||||
const isActive = s === 1 || s === ActivityStatus.Active;
|
||||
const isExpanded = !!expandedTenants[String(tenant.id)];
|
||||
|
||||
// Filter organisations matching this tenant
|
||||
const tenantOrganisations = organisations.filter(
|
||||
(org) =>
|
||||
(org.tenantId !== undefined && org.tenantId !== null && String(org.tenantId) === String(tenant.id)) ||
|
||||
((org as any).tenant_id !== undefined && (org as any).tenant_id !== null && String((org as any).tenant_id) === String(tenant.id)) ||
|
||||
String(org.id) === String(tenant.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<React.Fragment key={tenant.id}>
|
||||
<tr
|
||||
className={`hover:bg-slate-800/40 transition group ${
|
||||
isCurrent ? 'bg-blue-950/15' : ''
|
||||
} ${isExpanded ? 'bg-slate-800/25 border-b-0' : ''}`}
|
||||
>
|
||||
{/* Expand / Collapse Button */}
|
||||
<td className="py-3 px-3 text-center">
|
||||
<button
|
||||
onClick={() => toggleTenantExpand(tenant.id)}
|
||||
className="p-1 rounded-md text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||
title={isExpanded ? 'Collapse organisations sub-list' : 'Expand organisations sub-list'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-blue-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* ID & HashID */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-white">#{tenant.id}</span>
|
||||
<span className="text-[10px] font-mono text-blue-400 bg-blue-950/60 px-1.5 py-0.5 rounded border border-blue-800/50">
|
||||
{hashId}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopyHashId(hashId, String(tenant.id))}
|
||||
className="text-slate-500 hover:text-slate-300 transition cursor-pointer"
|
||||
title="Copy HashID"
|
||||
>
|
||||
{copiedId === String(tenant.id) ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Tenant Name & Sub-Organisations Count */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
<span className="font-medium text-slate-100">{tenant.name || 'Unnamed Tenant'}</span>
|
||||
{isCurrent && (
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/20 px-2 py-0.5 rounded-full border border-blue-500/30">
|
||||
Active Context
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => toggleTenantExpand(tenant.id)}
|
||||
className="text-[10px] font-medium text-slate-400 hover:text-blue-300 bg-slate-800/80 hover:bg-slate-700/80 px-2 py-0.5 rounded-full border border-slate-700/60 transition cursor-pointer flex items-center gap-1"
|
||||
title="Click to view scoped organisations"
|
||||
>
|
||||
<span>{tenantOrganisations.length} Org{tenantOrganisations.length === 1 ? '' : 's'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ActivityStatusBadge status={tenant.status} />
|
||||
{!isActive && (
|
||||
<span
|
||||
className="text-[10px] text-amber-400 flex items-center gap-1 font-medium bg-amber-950/40 px-2 py-0.5 rounded-full border border-amber-800/40"
|
||||
title="Users associated with this tenant will be locked out"
|
||||
>
|
||||
<Lock className="w-3 h-3" />
|
||||
Locked Out
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Created By */}
|
||||
<td className="py-3 px-4 text-slate-400 font-mono text-[11px]">
|
||||
{tenant.createdBy || 'System Administrator'}
|
||||
</td>
|
||||
|
||||
{/* Created Date */}
|
||||
<td className="py-3 px-4 text-slate-400">
|
||||
{formatDate(tenant.createdAt)}
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<button
|
||||
onClick={() => handleOpenDetail(tenant)}
|
||||
className="p-1.5 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 transition cursor-pointer"
|
||||
title="Inspect Tenant Partition"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleOpenUpdate(tenant)}
|
||||
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-blue-900/30 hover:bg-blue-800/50 text-blue-300 border border-blue-800/40 text-xs font-medium transition cursor-pointer"
|
||||
title="Edit Tenant Status"
|
||||
>
|
||||
<Edit2 className="w-3 h-3" />
|
||||
<span>Edit Status</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded Sub-List of Organisations */}
|
||||
{isExpanded && (
|
||||
<tr className="bg-slate-950/70 border-b border-slate-800/80">
|
||||
<td colSpan={7} className="p-0">
|
||||
<div className="py-4 px-6 border-l-4 border-blue-500 bg-linear-to-r from-blue-950/20 via-slate-900/40 to-slate-950/60 space-y-3">
|
||||
{/* Sub-list Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
<Building2 className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-xs font-bold text-white tracking-tight">
|
||||
Scoped Organisations for {tenant.name || `Tenant #${tenant.id}`}
|
||||
</h4>
|
||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/20 px-2 py-0.5 rounded-full border border-blue-500/30">
|
||||
{tenantOrganisations.length} Legal {tenantOrganisations.length === 1 ? 'Entity' : 'Entities'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Legal entities and holding structures mapped under Tenant ID #{tenant.id} partition boundary (Read-Only).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-[11px] text-slate-500 italic">
|
||||
Organisation management is handled in Governance & Structure
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organisations Sub-Table */}
|
||||
{loadingOrgs ? (
|
||||
<div className="py-6 flex items-center justify-center gap-2 text-slate-400 text-xs">
|
||||
<RefreshCw className="w-4 h-4 animate-spin text-blue-400" />
|
||||
<span>Loading organisations...</span>
|
||||
</div>
|
||||
) : tenantOrganisations.length === 0 ? (
|
||||
<div className="py-6 px-4 rounded-lg bg-slate-900/80 border border-slate-800 text-center">
|
||||
<Building2 className="w-5 h-5 text-slate-600 mx-auto mb-1.5" />
|
||||
<p className="text-xs font-medium text-slate-300">No organisations assigned to this tenant</p>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||
New organisations provisioned with Tenant ID #{tenant.id} will automatically appear in this partition view.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-800/80 overflow-hidden bg-slate-900/90 shadow-xs">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-950/90 border-b border-slate-800/80 text-slate-400 font-medium">
|
||||
<th className="py-2.5 px-3 font-semibold">Org ID & HashID</th>
|
||||
<th className="py-2.5 px-3 font-semibold">Organisation Name</th>
|
||||
<th className="py-2.5 px-3 font-semibold">Registration No.</th>
|
||||
<th className="py-2.5 px-3 font-semibold">Entity Type</th>
|
||||
<th className="py-2.5 px-3 font-semibold">Official Contact</th>
|
||||
<th className="py-2.5 px-3 font-semibold">Status</th>
|
||||
<th className="py-2.5 px-3 font-semibold text-right">Created Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{tenantOrganisations.map((org) => {
|
||||
const orgHashId = encodeHashIdSync(org.id);
|
||||
return (
|
||||
<tr key={org.id} className="hover:bg-slate-800/30 transition">
|
||||
{/* ID & HashID */}
|
||||
<td className="py-2.5 px-3 font-mono">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-slate-300 font-semibold">#{org.id}</span>
|
||||
<span className="text-[10px] text-blue-400 bg-blue-950/50 px-1.5 py-0.5 rounded border border-blue-800/40">
|
||||
{orgHashId}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Name */}
|
||||
<td className="py-2.5 px-3">
|
||||
<span className="font-medium text-white">{org.name || 'Unnamed Organisation'}</span>
|
||||
</td>
|
||||
|
||||
{/* Registration No */}
|
||||
<td className="py-2.5 px-3 text-slate-400 font-mono text-[11px]">
|
||||
{org.registrationNo || '—'}
|
||||
</td>
|
||||
|
||||
{/* Type */}
|
||||
<td className="py-2.5 px-3 text-slate-300">
|
||||
<span className="bg-slate-800/80 text-slate-300 px-2 py-0.5 rounded border border-slate-700/60 text-[11px]">
|
||||
{getOrganisationTypeLabel(org.type)}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Email */}
|
||||
<td className="py-2.5 px-3 text-slate-300">
|
||||
{org.email ? (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-slate-300">
|
||||
<Mail className="w-3 h-3 text-slate-500 shrink-0" />
|
||||
<span className="truncate max-w-[180px]">{org.email}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Status */}
|
||||
<td className="py-2.5 px-3">
|
||||
<ActivityStatusBadge status={org.status} />
|
||||
</td>
|
||||
|
||||
{/* Created Date */}
|
||||
<td className="py-2.5 px-3 text-slate-400 text-right">
|
||||
{formatDate(org.createdAt)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Tenant Modal */}
|
||||
<Modal
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={() => setIsCreateModalOpen(false)}
|
||||
title="Provision New Tenant Partition"
|
||||
size="md"
|
||||
>
|
||||
<form
|
||||
onSubmit={createForm.handleSubmit((data) =>
|
||||
createMutation.mutate({ name: data.name })
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="p-3.5 rounded-xl bg-blue-950/20 border border-blue-800/30 text-xs text-blue-200 flex items-start gap-2.5">
|
||||
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1 leading-relaxed">
|
||||
<p className="font-semibold text-white">Multi-Tenant Partition Boundary</p>
|
||||
<p>
|
||||
Creating a new tenant defines an isolated partition boundary for grants, portfolios, projects, and users.
|
||||
New tenants are automatically initialized in the Active state.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Tenant Organization Name <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Technology Innovation Agency (TIA)"
|
||||
{...createForm.register('name')}
|
||||
className="w-full px-3.5 py-2.5 bg-slate-950/80 border border-slate-800 rounded-lg text-xs text-white placeholder-slate-500 focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
{createForm.formState.errors.name && (
|
||||
<p className="text-[11px] text-rose-400 mt-1">{createForm.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateModalOpen(false)}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-lg shadow-sm transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
{/* Update Tenant Modal */}
|
||||
<Modal
|
||||
isOpen={isUpdateModalOpen}
|
||||
onClose={() => setIsUpdateModalOpen(false)}
|
||||
title={`Update Tenant: ${selectedTenant?.name || ''}`}
|
||||
size="md"
|
||||
>
|
||||
{selectedTenant && (
|
||||
<form
|
||||
onSubmit={updateForm.handleSubmit((data) =>
|
||||
updateMutation.mutate({
|
||||
tenantId: selectedTenant.id,
|
||||
name: data.name,
|
||||
status: Number(data.status),
|
||||
})
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Tenant Organization Name <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
{...updateForm.register('name')}
|
||||
className="w-full px-3.5 py-2.5 bg-slate-950/80 border border-slate-800 rounded-lg text-xs text-white placeholder-slate-500 focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
{updateForm.formState.errors.name && (
|
||||
<p className="text-[11px] text-rose-400 mt-1">{updateForm.formState.errors.name.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Operational Status <span className="text-rose-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
{...updateForm.register('status')}
|
||||
className="w-full px-3.5 py-2.5 bg-slate-950/80 border border-slate-800 rounded-lg text-xs text-white focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value={ActivityStatus.Active}>Active (Users Permitted)</option>
|
||||
<option value={ActivityStatus.Inactive}>Inactive (Tenant Lockout)</option>
|
||||
<option value={ActivityStatus.Disabled}>Disabled (Suspended Partition)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Warning when selecting Inactive or Disabled */}
|
||||
{Number(updateForm.watch('status')) !== ActivityStatus.Active && (
|
||||
<div className="p-3 rounded-xl bg-amber-950/30 border border-amber-800/40 text-xs text-amber-200 flex items-start gap-2.5">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-white">Multitenancy Lockout Warning</p>
|
||||
<p className="text-[11px] leading-relaxed text-amber-300/90">
|
||||
Changing this tenant's status to Inactive or Disabled immediately locks out all users assigned to this partition.
|
||||
The backend API will deny requests until the tenant is restored to Active status.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsUpdateModalOpen(false)}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-lg shadow-sm transition disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||
<span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Tenant Detail Modal */}
|
||||
<Modal
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={() => setIsDetailModalOpen(false)}
|
||||
title={`Tenant Details: ${selectedTenant?.name || ''}`}
|
||||
size="lg"
|
||||
>
|
||||
{selectedTenant && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-slate-950/60 p-4 rounded-xl border border-slate-800">
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Numeric ID</p>
|
||||
<p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Partition HashID</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="font-mono text-xs text-blue-400 bg-blue-950/60 px-2 py-0.5 rounded border border-blue-800/50">
|
||||
{encodeHashIdSync(selectedTenant.id)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopyHashId(encodeHashIdSync(selectedTenant.id), `detail-${selectedTenant.id}`)}
|
||||
className="text-slate-400 hover:text-white transition cursor-pointer"
|
||||
title="Copy HashID"
|
||||
>
|
||||
{copiedId === `detail-${selectedTenant.id}` ? (
|
||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Name</p>
|
||||
<p className="text-sm font-semibold text-slate-200 mt-0.5">{selectedTenant.name || '—'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Operational Status</p>
|
||||
<div className="mt-1">
|
||||
<ActivityStatusBadge status={selectedTenant.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created Date</p>
|
||||
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.createdAt)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created By</p>
|
||||
<p className="text-xs font-mono text-slate-300 mt-0.5">{selectedTenant.createdBy || 'System Admin'}</p>
|
||||
</div>
|
||||
|
||||
{selectedTenant.updatedAt && (
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Last Updated</p>
|
||||
<p className="text-xs text-slate-300 mt-0.5">{formatDate(selectedTenant.updatedAt)}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTenant.updatedBy && (
|
||||
<div>
|
||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Updated By</p>
|
||||
<p className="text-xs font-mono text-slate-300 mt-0.5">{selectedTenant.updatedBy}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs text-slate-300 space-y-2">
|
||||
<p className="font-semibold text-white flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-4 h-4 text-blue-400" />
|
||||
Partition Scope & Authentication
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
When users authenticate, their security token contains claims identifying their tenant partition.
|
||||
The system automatically scopes data queries to this tenant and enforces operational lockout if the status is not Active.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2.5 pt-3 border-t border-slate-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsDetailModalOpen(false);
|
||||
handleOpenUpdate(selectedTenant);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-lg transition cursor-pointer"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
<span>Edit Tenant Status</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDetailModalOpen(false)}
|
||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -176,6 +176,7 @@ export const normalizeOrganisation = (raw: any): Organisation => {
|
||||
return {
|
||||
...raw,
|
||||
id: raw.id ?? raw.Id ?? raw.organisationId ?? raw.OrganisationId,
|
||||
tenantId: raw.tenantId ?? raw.TenantId ?? raw.tenant_id ?? null,
|
||||
name: raw.name ?? raw.Name ?? null,
|
||||
registrationNo: raw.registrationNo ?? raw.RegistrationNo ?? raw.regNo ?? raw.RegNo ?? null,
|
||||
email: raw.email ?? raw.Email ?? null,
|
||||
|
||||
@@ -298,6 +298,7 @@ export interface UserFilterRequest {
|
||||
|
||||
export interface Organisation {
|
||||
id: number | string;
|
||||
tenantId?: number | string;
|
||||
name: string | null;
|
||||
registrationNo: string | null;
|
||||
email: string | null;
|
||||
|
||||
Reference in New Issue
Block a user