Added tenant management
Applied basic access control
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user