Added tenant management

Applied basic access control
This commit is contained in:
2026-10-04 09:13:55 +02:00
parent 8100518549
commit 8fed46961a
7 changed files with 1349 additions and 22 deletions
@@ -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>
);
};