Files
pfm/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx
T
2026-09-20 08:26:06 +02:00

1180 lines
57 KiB
TypeScript

import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Shield,
History,
CheckCircle,
Bell,
Send,
Radio,
Filter,
UserCheck,
Users,
RefreshCw,
Search,
Mail,
Eye,
ExternalLink,
Plus,
Building2,
Award as AwardIcon,
FileText,
AlertCircle,
} from 'lucide-react';
import {
getUsers,
getUser,
updateUserStatus,
syncIntegration,
syncUser,
getUserSyncRecord,
getInvites,
sendInvite,
getApplicantInvite,
getApplicantInvites,
getOrganisations,
getAwards,
getContracts,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { useAuth } from '../auth/AuthProvider';
import { getAppConfig } from '../config';
import { triggerSessionExpired } from '../services/sessionExpiration';
import {
EmailStatuses,
NotificationStatus,
NotificationPlatform,
ActivityStatus,
User,
Invite,
InviteRequest,
UserFilterRequest,
} from '../types';
import {
formatDate,
getEmailStatusLabel,
getEmailStatusBadge,
getNotificationStatusLabel,
getNotificationStatusBadge,
getNotificationPlatformLabel,
getUserEmail,
getActivityStatusBadge,
EMAIL_STATUSES_OPTIONS,
NOTIFICATION_STATUS_OPTIONS,
NOTIFICATION_PLATFORM_OPTIONS,
} from '../lib/formatters';
interface NotificationItem {
id: number;
recipient: string;
platform: NotificationPlatform;
status: NotificationStatus;
subject: string;
sentAt: string;
errorMessage?: string;
}
export const SettingsPage: React.FC = () => {
const queryClient = useQueryClient();
const { user } = useAuth();
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'sync' | 'profile'>('users');
// ==========================================
// 1. User Directory State
// ==========================================
const [userEmailSearch, setUserEmailSearch] = useState('');
const [userStatusFilter, setUserStatusFilter] = useState<ActivityStatus | 'all'>('all');
const [selectedUserForDetail, setSelectedUserForDetail] = useState<User | null>(null);
const [isUserDetailModalOpen, setIsUserDetailModalOpen] = useState(false);
const userFilter: UserFilterRequest = {
email: userEmailSearch.trim() || undefined,
status: userStatusFilter !== 'all' ? userStatusFilter : undefined,
};
const { data: usersList = [], isLoading: loadingUsers, refetch: refetchUsers } = useQuery({
queryKey: ['users', userStatusFilter, userEmailSearch],
queryFn: () => getUsers(1, 100, userFilter),
});
const { data: userFreshDetail, isLoading: loadingUserFreshDetail } = useQuery({
queryKey: ['user-canonical', selectedUserForDetail?.id],
queryFn: () => (selectedUserForDetail?.id ? getUser(selectedUserForDetail.id) : null),
enabled: !!selectedUserForDetail?.id && isUserDetailModalOpen,
});
const syncMutation = useMutation({
mutationFn: () => syncUser(undefined, true),
onSuccess: () => {
success('User Sync Complete', 'Authenticated user enabled and directory synced. Browser cache updated to prevent endpoint hotspot.');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => {
toastError('Sync Failed', err);
},
});
const syncIntegrationMutation = useMutation({
mutationFn: () => syncIntegration(true),
onSuccess: () => {
success('Backend Integration Synced', 'Direct POST /v1/api/integration/sync executed successfully.');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => {
toastError('Integration Sync Failed', err);
},
});
const updateUserStatusMutation = useMutation({
mutationFn: ({ userId, status }: { userId: string; status: ActivityStatus }) =>
updateUserStatus(userId, status),
onSuccess: (_, vars) => {
success('User Status Updated', `User status changed to ${getActivityStatusBadge(vars.status).label}.`);
queryClient.invalidateQueries({ queryKey: ['users'] });
if (selectedUserForDetail && selectedUserForDetail.id === vars.userId) {
setSelectedUserForDetail({ ...selectedUserForDetail, status: vars.status });
}
},
onError: (err: any) => {
toastError('Update Failed', err);
},
});
// ==========================================
// 2. Communications & Invites State
// ==========================================
const [isSendInviteModalOpen, setIsSendInviteModalOpen] = useState(false);
const [applicantSearchId, setApplicantSearchId] = useState('');
const [applicantInvitesResults, setApplicantInvitesResults] = useState<Invite[] | null>(null);
const [searchingApplicant, setSearchingApplicant] = useState(false);
// Invite Form State
const [inviteOrgId, setInviteOrgId] = useState<string | number>('');
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
const [inviteContractId, setInviteContractId] = useState<string | number>('');
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
const [inviteMessage, setInviteMessage] = useState('');
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
const { data: invitesList = [], isLoading: loadingInvites } = useQuery({
queryKey: ['invites'],
queryFn: () => getInvites(1, 100),
});
const { data: organisations = [] } = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(1, 50),
});
const { data: awards = [] } = useQuery({
queryKey: ['awards'],
queryFn: () => getAwards(1, 50),
});
const { data: contracts = [] } = useQuery({
queryKey: ['contracts'],
queryFn: () => getContracts(1, 50),
});
const sendInviteMutation = useMutation({
mutationFn: (payload: InviteRequest) => sendInvite(payload),
onSuccess: () => {
success('Invitation Dispatched', 'Statutory communications invitation sent successfully.');
queryClient.invalidateQueries({ queryKey: ['invites'] });
setIsSendInviteModalOpen(false);
setInviteRecipientUuid('');
setInviteMessage('');
},
onError: (err: any) => {
toastError('Dispatch Error', err);
},
});
const handleSearchApplicantInvites = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!applicantSearchId.trim()) return;
try {
setSearchingApplicant(true);
const res = await getApplicantInvites(applicantSearchId.trim());
setApplicantInvitesResults(res);
success('Lookup Complete', `Retrieved ${res.length} invitation records for applicant.`);
} catch (err: any) {
toastError('Lookup Error', err);
setApplicantInvitesResults([]);
} finally {
setSearchingApplicant(false);
}
};
const handleLookupSingleApplicantInvite = async () => {
if (!applicantSearchId.trim()) return;
try {
setSearchingApplicant(true);
const res = await getApplicantInvite(applicantSearchId.trim());
setApplicantInvitesResults(res ? [res] : []);
success('Primary Invite Found', `Retrieved primary invitation for applicant.`);
} catch (err: any) {
toastError('Lookup Error', err);
setApplicantInvitesResults([]);
} finally {
setSearchingApplicant(false);
}
};
// ==========================================
// 3. Email Gateway & Dispatch
// ==========================================
const [emailGatewayStatus, setEmailGatewayStatus] = useState<EmailStatuses>(EmailStatuses.Connected);
const [isSendNotificationModalOpen, setIsSendNotificationModalOpen] = useState(false);
const [dispatchPlatform, setDispatchPlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
const [dispatchStatus, setDispatchStatus] = useState<NotificationStatus>(NotificationStatus.Sent);
const [dispatchRecipient, setDispatchRecipient] = useState('');
const [dispatchSubject, setDispatchSubject] = useState('');
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl">
<div>
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
<span>System Administration & Communications</span>
<HelpTooltip
title="System Administration & Audit"
content="Manage platform security, active user directory, external gateway sync, communications dispatch, and verified OIDC claims."
placement="right"
/>
</h2>
<p className="text-xs text-slate-400 mt-1">
User access directory, statutory invitations, background integration sync, and audit ledger.
</p>
</div>
<div className="flex items-center gap-2">
{activeTab === 'users' && (
<div className="flex items-center gap-2">
<span
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
title={getUserSyncRecord() ? `Endpoint: ${getUserSyncRecord()?.syncedEndpoint || '/v1/api/integration/sync'} (Synced in browser)` : 'Sync active in browser'}
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Browser Sync Saved
</span>
<button
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 border border-slate-700/80 rounded-xl shadow-md transition cursor-pointer disabled:opacity-50"
title="Force refresh user sync and update browser cache"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
{syncMutation.isPending ? 'Syncing...' : 'Sync User Directory'}
</button>
</div>
)}
{activeTab === 'invites' && (
<button
onClick={() => setIsSendInviteModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
>
<Send className="w-4 h-4" />
Dispatch Invitation
</button>
)}
{activeTab === 'notifications' && (
<button
onClick={() => setIsSendNotificationModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
>
<Send className="w-4 h-4" />
Dispatch Notification
</button>
)}
</div>
</div>
{/* Navigation Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => setActiveTab('users')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'users'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Users className="w-4 h-4" />
<span>User Directory ({usersList.length})</span>
<HelpTooltip
title="User Directory"
content="Authorized system users, role memberships, and account lifecycle states."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('invites')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'invites'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Mail className="w-4 h-4" />
<span>Communications & Invites ({invitesList.length})</span>
<HelpTooltip
title="Communications Dispatch"
content="Statutory onboarding invitations and applicant notification ledger."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('notifications')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'notifications'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Bell className="w-4 h-4" />
<span>Gateways & Notifications</span>
<HelpTooltip
title="Notification Gateways"
content="Real-time status of email dispatch SMTP engines, SMS gateways, and broadcast logs."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('sync')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'sync'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<RefreshCw className="w-4 h-4" />
<span>Integration & Sync Engine</span>
<HelpTooltip
title="Integration Engine"
content="Backend synchronization bridge maintaining parity with external enterprise systems."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('profile')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'profile'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<UserCheck className="w-4 h-4" />
<span>Session & Claims</span>
<HelpTooltip
title="OIDC Security Tokens"
content="Current authenticated session attributes, claims scope, and cryptographic token expiration."
size="sm"
placement="top"
/>
</button>
</div>
{/* ============================================================== */}
{/* TAB 1: User Directory & Access */}
{/* ============================================================== */}
{activeTab === 'users' && (
<div className="space-y-4">
{/* Filter and Search Bar */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="relative w-full sm:w-80">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-2.5" />
<input
type="text"
value={userEmailSearch}
onChange={(e) => setUserEmailSearch(e.target.value)}
placeholder="Search users by email..."
className="w-full pl-9 pr-3 py-1.5 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Filter Status:</span>
<select
value={userStatusFilter}
onChange={(e) => setUserStatusFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))}
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-3 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Statuses</option>
<option value={ActivityStatus.Active}>Active</option>
<option value={ActivityStatus.Inactive}>Inactive</option>
<option value={ActivityStatus.Suspended}>Suspended</option>
</select>
<button
onClick={() => refetchUsers()}
className="p-1.5 rounded-xl border border-slate-700 bg-slate-800/80 hover:bg-slate-700 text-slate-300 transition cursor-pointer"
title="Refresh user list"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{/* Users Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">User Email</th>
<th className="py-3.5 px-4">User Identifier</th>
<th className="py-3.5 px-4">Status</th>
<th className="py-3.5 px-4">Last Login</th>
<th className="py-3.5 px-5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingUsers ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
Loading user directory...
</td>
</tr>
) : usersList.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
No users found matching current filters.
</td>
</tr>
) : (
usersList.map((u) => {
const badge = getActivityStatusBadge(u.status);
return (
<tr key={u.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 text-white font-medium">
{u.email || getUserEmail(u.id) || <span className="text-slate-500 italic">No email recorded</span>}
</td>
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
{u.id}
</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
{badge.label}
</span>
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono text-[11px]">
{u.lastLoginAt ? formatDate(u.lastLoginAt) : 'Never'}
</td>
<td className="py-3.5 px-5 text-right space-x-1.5 whitespace-nowrap">
<button
onClick={() => {
setSelectedUserForDetail(u);
setIsUserDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 border border-slate-700 rounded-lg transition cursor-pointer"
>
<Eye className="w-3 h-3 text-blue-400" />
Inspect
</button>
{/* Status switch actions */}
{u.status === ActivityStatus.Active ? (
<button
onClick={() =>
updateUserStatusMutation.mutate({
userId: u.id,
status: ActivityStatus.Suspended,
})
}
disabled={updateUserStatusMutation.isPending}
className="inline-flex items-center px-2.5 py-1 text-[11px] font-semibold text-amber-400 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/20 rounded-lg transition cursor-pointer"
>
Suspend
</button>
) : (
<button
onClick={() =>
updateUserStatusMutation.mutate({
userId: u.id,
status: ActivityStatus.Active,
})
}
disabled={updateUserStatusMutation.isPending}
className="inline-flex items-center px-2.5 py-1 text-[11px] font-semibold text-emerald-400 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 rounded-lg transition cursor-pointer"
>
Activate
</button>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* ============================================================== */}
{/* TAB 2: Communications & Invites */}
{/* ============================================================== */}
{activeTab === 'invites' && (
<div className="space-y-6">
{/* Applicant Invites Lookup */}
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl">
<h3 className="text-sm font-bold text-white mb-1">Applicant Invitations Lookup</h3>
<p className="text-xs text-slate-400 mb-3">
Query invitations dispatched to a specific candidate or grantee using their unique Applicant User UUID.
</p>
<form onSubmit={handleSearchApplicantInvites} className="flex gap-2">
<input
type="text"
value={applicantSearchId}
onChange={(e) => setApplicantSearchId(e.target.value)}
placeholder="Enter Applicant User ID (UUID)..."
className="flex-1 px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/50"
/>
<button
type="submit"
disabled={searchingApplicant || !applicantSearchId.trim()}
className="px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer disabled:opacity-50"
title="Lookup all invites (GET /v1/api/communications/invites/{applicantUserId})"
>
{searchingApplicant ? 'Searching...' : 'All Invites'}
</button>
<button
type="button"
onClick={handleLookupSingleApplicantInvite}
disabled={searchingApplicant || !applicantSearchId.trim()}
className="px-3.5 py-2 text-xs font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer disabled:opacity-50"
title="Lookup primary invite (GET /v1/api/communications/invites/applicant/{applicantUserId})"
>
Primary Invite
</button>
</form>
{applicantInvitesResults && (
<div className="mt-4 pt-4 border-t border-slate-800/80">
<span className="text-xs font-semibold text-slate-300">
Lookup Results ({applicantInvitesResults.length} matches):
</span>
{applicantInvitesResults.length === 0 ? (
<p className="text-xs text-slate-500 mt-2">No invitations found for this applicant UUID.</p>
) : (
<div className="mt-2 space-y-2">
{applicantInvitesResults.map((inv, idx) => (
<div
key={idx}
className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 text-xs flex flex-col sm:flex-row sm:items-center justify-between gap-2"
>
<div>
<span className="font-semibold text-white">{inv.message}</span>
<div className="text-[11px] text-slate-400 mt-0.5">
Org #{inv.organisationId} &bull; Award #{inv.awardId} &bull; Contract #{inv.contractId}
</div>
</div>
<span className="text-[10px] font-mono px-2 py-0.5 rounded bg-blue-500/10 text-blue-400 border border-blue-500/20">
{getNotificationPlatformLabel(inv.platform)}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Sent Invites Ledger */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="px-5 py-3.5 border-b border-slate-800/80 bg-slate-900/40 flex items-center justify-between">
<span className="text-xs font-bold text-white uppercase tracking-wider">
Sent Invitations Ledger
</span>
<span className="text-xs text-slate-400">{invitesList.length} invitations dispatched</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Recipient (UUID)</th>
<th className="py-3.5 px-4">Channel</th>
<th className="py-3.5 px-4">Message</th>
<th className="py-3.5 px-4">Target Org / Grant</th>
<th className="py-3.5 px-5 text-right">Created Date</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingInvites ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
Loading invitations ledger...
</td>
</tr>
) : invitesList.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
No invitations recorded yet. Use &ldquo;Dispatch Invitation&rdquo; to send one.
</td>
</tr>
) : (
invitesList.map((inv, idx) => (
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
{inv.recipient}
</td>
<td className="py-3.5 px-4">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
{getNotificationPlatformLabel(inv.platform)}
</span>
</td>
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
{inv.message}
</td>
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
Org #{inv.invitedOrganisationId || inv.organisationId} &bull; Award #{inv.awardId}
</td>
<td className="py-3.5 px-5 text-right font-mono text-slate-400 text-[11px]">
{inv.createdAt ? formatDate(inv.createdAt) : 'Recently'}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* ============================================================== */}
{/* TAB 3: Notifications & Gateways */}
{/* ============================================================== */}
{activeTab === 'notifications' && (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-slate-400">Email Gateway Status</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${getEmailStatusBadge(emailGatewayStatus).className}`}>
{getEmailStatusBadge(emailGatewayStatus).label}
</span>
</div>
<div className="pt-1">
<label className="block text-[11px] font-medium text-slate-400 mb-1.5">Update Gateway State:</label>
<select
value={emailGatewayStatus}
onChange={(e) => {
const newStatus = Number(e.target.value) as EmailStatuses;
setEmailGatewayStatus(newStatus);
success('Gateway State Updated', `Email service transitioned to ${getEmailStatusLabel(newStatus)}`);
}}
className="w-full text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
{EMAIL_STATUSES_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-xs font-semibold text-slate-400">Queued / Active Pipeline</span>
<div className="text-2xl font-bold font-mono text-white">
{notifications.filter((n) => n.status === NotificationStatus.Pending || n.status === NotificationStatus.Queued).length}
</div>
<span className="text-[11px] text-slate-400">Automated retry scheduler active</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-5 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-xs font-semibold text-slate-400">Delivery Success Ratio</span>
<div className="text-2xl font-bold font-mono text-emerald-400">97.4%</div>
<span className="text-[11px] text-slate-400">Cross-platform SLA target: ≥95.0%</span>
</div>
</div>
</div>
)}
{/* ============================================================== */}
{/* TAB 4: Integration & Sync Engine (POST /v1/api/integration/sync) */}
{/* ============================================================== */}
{activeTab === 'sync' && (
<div className="space-y-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-blue-400" />
Backend Integration Sync Engine (POST /v1/api/integration/sync)
</h3>
<p className="text-xs text-slate-400 mt-1 max-w-2xl leading-relaxed">
The integration sync endpoint provisions and synchronizes authenticated accounts with the backend security directory. To avoid hotspotting the backend during frequent navigation, synchronizations are locally persisted in the browser and refreshed on demand or when roles update.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => syncIntegrationMutation.mutate()}
disabled={syncIntegrationMutation.isPending}
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer disabled:opacity-50"
title="Direct POST /v1/api/integration/sync call"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncIntegrationMutation.isPending ? 'animate-spin' : ''}`} />
{syncIntegrationMutation.isPending ? 'Syncing...' : 'Trigger Integration Sync'}
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
Browser Cache Status
</span>
<div className="text-xs space-y-1.5 font-mono">
<div>
<span className="text-slate-500">Synced in Browser:</span>{' '}
<span className={getUserSyncRecord() ? 'text-emerald-400 font-bold' : 'text-amber-400'}>
{getUserSyncRecord() ? 'Yes (Cached & Active)' : 'Pending Sync'}
</span>
</div>
<div>
<span className="text-slate-500">Synced User Subject:</span>{' '}
<span className="text-white">{getUserSyncRecord()?.sub || user?.id || 'Not recorded'}</span>
</div>
<div>
<span className="text-slate-500">Last Synced Timestamp:</span>{' '}
<span className="text-slate-300">
{getUserSyncRecord()?.syncedAt ? formatDate(new Date(getUserSyncRecord()!.syncedAt).toISOString()) : 'N/A'}
</span>
</div>
</div>
</div>
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
OpenAPI Target Route
</span>
<div className="text-xs space-y-1.5 font-mono">
<div>
<span className="text-slate-500">HTTP Method:</span> <span className="text-emerald-400 font-bold">POST</span>
</div>
<div>
<span className="text-slate-500">Endpoint Path:</span>{' '}
<span className="text-cyan-400">/v1/api/integration/sync</span>
</div>
<div>
<span className="text-slate-500">Execution Target:</span>{' '}
<span className="text-white">Authoritative User Directory Synchronization</span>
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* ============================================================== */}
{/* TAB 5: Session & Claims */}
{/* ============================================================== */}
{activeTab === 'profile' && (
<div className="space-y-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-4">
<h3 className="text-sm font-bold text-white">Active Session & Identity Token Claims</h3>
<p className="text-xs text-slate-400">
Cryptographic claims and assigned multi-tenant boundaries parsed from your active session.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
User Identity & Tenant
</span>
<div className="text-xs space-y-1.5 font-mono">
<div><span className="text-slate-500">Subject (sub):</span> <span className="text-white">{user?.id || 'Anonymous'}</span></div>
<div><span className="text-slate-500">Tenant Claim:</span> <span className="text-emerald-400 font-bold">{user?.tenant || 'Default Root Tenant'}</span></div>
<div><span className="text-slate-500">Email:</span> <span className="text-white">{getUserEmail(user) || 'Not provided'}</span></div>
</div>
</div>
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
Deterministic Hasher Settings
</span>
<div className="text-xs space-y-1.5 font-mono">
<div><span className="text-slate-500">Min Hash Length:</span> <span className="text-white">{getAppConfig().HasherSettings.MinHashLength}</span></div>
<div><span className="text-slate-500">Encoding Algorithm:</span> <span className="text-blue-400">Deterministic Base62 Salted</span></div>
<div><span className="text-slate-500">Active Salt:</span> <span className="text-slate-300 truncate block max-w-xs">{getAppConfig().HasherSettings.Salt ? `${getAppConfig().HasherSettings.Salt.slice(0, 16)}...` : 'Default Preset'}</span></div>
<div><span className="text-slate-500">Target NuGet Package:</span> <span className="text-white">Hashids.net (C# Compatible)</span></div>
</div>
</div>
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Shield className="w-4 h-4 text-emerald-400" />
Deployment & Runtime Environment Configuration
</h3>
<p className="text-xs text-slate-400">
Priority hierarchy: Container Runtime Injected → /config.json → Build-Time (.env) → Defaults.
</p>
</div>
<span
className={`inline-flex items-center px-2.5 py-1 rounded-full text-[11px] font-semibold tracking-wide border ${
getAppConfig().ConfigSource === 'runtime-injected'
? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30'
: getAppConfig().ConfigSource === 'runtime-fetched'
? 'bg-blue-500/10 text-blue-400 border-blue-500/30'
: getAppConfig().ConfigSource === 'build-env'
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
: 'bg-slate-500/10 text-slate-400 border-slate-500/30'
}`}
>
● Active Source: {getAppConfig().ConfigSource}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
Backend API Base URL
</span>
<div className="text-xs font-mono text-emerald-400 break-all">
{getAppConfig().ApiSettings.BaseUrl}
</div>
</div>
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
OIDC Authority (STS)
</span>
<div className="text-xs font-mono text-cyan-400 break-all">
{getAppConfig().SecuritySettings.Authority}
</div>
</div>
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
OIDC Client ID & Audience
</span>
<div className="text-xs space-y-1 font-mono">
<div><span className="text-slate-500">Client ID:</span> <span className="text-white">{getAppConfig().SecuritySettings.ClientId}</span></div>
<div><span className="text-slate-500">Audience:</span> <span className="text-white">{getAppConfig().SecuritySettings.Audience}</span></div>
</div>
</div>
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
OIDC Scope & Secret
</span>
<div className="text-xs space-y-1 font-mono">
<div><span className="text-slate-500">Scope:</span> <span className="text-white">{getAppConfig().SecuritySettings.Scope || 'Default'}</span></div>
<div><span className="text-slate-500">Secret Configured:</span> <span className="text-emerald-400">{getAppConfig().SecuritySettings.ClientSecret ? 'Yes (Encrypted/Set)' : 'No'}</span></div>
</div>
</div>
</div>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-amber-400" />
Session Expiration vs Lockout Architecture (HTTP 401)
</h3>
<p className="text-xs text-slate-400 mt-0.5 max-w-2xl leading-relaxed">
To disambiguate expired sessions from the session lockout process, the session expiration handler monitors all active business endpoints for HTTP 401 other than the user sync endpoint. The user sync endpoint remains dedicated to the session lockout &amp; activation check, ensuring both features act in harmony where one is subordinated by call order and priority.
</p>
</div>
<button
type="button"
id="test-401-btn"
onClick={() => {
triggerSessionExpired('Security token expired on business endpoint during active session test (HTTP 401).');
}}
className="inline-flex items-center gap-2 px-3.5 py-2 text-xs font-semibold text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 rounded-xl transition-all shadow-md shrink-0"
>
<AlertCircle className="w-3.5 h-3.5 text-amber-400" />
Test HTTP 401 Session Expiry
</button>
</div>
</div>
</div>
)}
{/* ============================================================== */}
{/* Modal: Dispatch Invitation */}
{/* ============================================================== */}
<Modal
isOpen={isSendInviteModalOpen}
onClose={() => setIsSendInviteModalOpen(false)}
title="Dispatch Statutory Invitation"
subtitle="Onboard enterprise entities, project PIs, or external reviewers"
helpText="Generates cryptographic invitations linked to organizations, awards, or contracts to initiate formal onboarding workflows."
>
<form
onSubmit={(e) => {
e.preventDefault();
if (!inviteRecipientUuid || !inviteMessage) {
toastError('Validation Error', 'Recipient UUID and message content are mandatory.');
return;
}
sendInviteMutation.mutate({
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
awardId: inviteAwardId || (awards[0]?.id ? Number(awards[0].id) : 301),
contractId: inviteContractId || (contracts[0]?.id ? Number(contracts[0].id) : 401),
recipient: inviteRecipientUuid,
message: inviteMessage,
platform: invitePlatform,
});
}}
className="space-y-4"
>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Recipient User UUID *
</label>
<HelpTooltip
title="Recipient UUID"
content="Unique directory user identifier or email for targeted delivery."
size="sm"
placement="right"
/>
</div>
<input
type="text"
value={inviteRecipientUuid}
onChange={(e) => setInviteRecipientUuid(e.target.value)}
placeholder="e.g. 550e8400-e29b-41d4-a716-446655440000"
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/50"
required
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Dispatch Platform *
</label>
<HelpTooltip
title="Channel Protocol"
content="Select delivery transport: Email SMTP, In-App Dispatch, or SMS alert."
size="sm"
placement="top"
/>
</div>
<select
value={invitePlatform}
onChange={(e) => setInvitePlatform(Number(e.target.value) as NotificationPlatform)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
{NOTIFICATION_PLATFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Originating Organisation
</label>
<HelpTooltip
title="Inviting Authority"
content="Statutory entity under whose jurisdiction this invitation is issued."
size="sm"
placement="top"
/>
</div>
<select
value={inviteOrgId}
onChange={(e) => setInviteOrgId(e.target.value)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
{organisations.map((org) => (
<option key={org.id} value={org.id}>
{org.name} (#{org.id})
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Award
</label>
<HelpTooltip
title="Linked Grant Award"
content="Optional award attachment to link recipient permissions directly to grant administration."
size="sm"
placement="top"
/>
</div>
<select
value={inviteAwardId}
onChange={(e) => setInviteAwardId(e.target.value)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
{awards.map((aw) => (
<option key={aw.id} value={aw.id}>
Award #{aw.id}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Contract
</label>
<HelpTooltip
title="Linked Contract"
content="Optional bilateral agreement linking the recipient to contract execution."
size="sm"
placement="top"
/>
</div>
<select
value={inviteContractId}
onChange={(e) => setInviteContractId(e.target.value)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
{contracts.map((ctr) => (
<option key={ctr.id} value={ctr.id}>
Contract #{ctr.id}
</option>
))}
</select>
</div>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Invitation Message *
</label>
<HelpTooltip
title="Instructional Body"
content="Clear instructions and statutory obligations displayed to the invitee upon opening."
size="sm"
placement="right"
/>
</div>
<textarea
rows={3}
value={inviteMessage}
onChange={(e) => setInviteMessage(e.target.value)}
placeholder="State the purpose of the invite and recipient onboarding instructions..."
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50"
required
/>
</div>
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsSendInviteModalOpen(false)}
className="px-4 py-2 text-xs font-medium text-slate-300 bg-slate-800/80 hover:bg-slate-700 rounded-xl transition"
>
Cancel
</button>
<button
type="submit"
disabled={sendInviteMutation.isPending}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition disabled:opacity-50"
>
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}
</button>
</div>
</form>
</Modal>
{/* ============================================================== */}
{/* Modal: User Inspection */}
{/* ============================================================== */}
<Modal
isOpen={isUserDetailModalOpen}
onClose={() => setIsUserDetailModalOpen(false)}
title="User Access & Security Details"
>
{selectedUserForDetail && (
<div className="space-y-4 text-xs">
{loadingUserFreshDetail ? (
<div className="p-6 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Querying backend endpoint (GET /v1/api/users/{selectedUserForDetail.id})...
</div>
) : (
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Email:</span>
<span className="text-white font-semibold text-xs">
{userFreshDetail?.email || selectedUserForDetail.email || getUserEmail(selectedUserForDetail.id) || 'Not specified'}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Identifier:</span>
<span className="font-mono text-slate-300 text-xs break-all">
{userFreshDetail?.id || selectedUserForDetail.id}
</span>
</div>
<div className="flex items-center justify-between pt-1">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Status:</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).className}`}>
{getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).label}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Last Login:</span>
<span className="font-mono text-slate-300">
{(userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt) ? formatDate((userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt)!) : 'Never'}
</span>
</div>
</div>
</div>
)}
<div className="pt-2 flex justify-end gap-2">
<button
type="button"
onClick={() => setIsUserDetailModalOpen(false)}
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition"
>
Close
</button>
</div>
</div>
)}
</Modal>
</div>
);
};