1180 lines
57 KiB
TypeScript
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} • Award #{inv.awardId} • 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 “Dispatch Invitation” 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} • 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 & 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>
|
|
);
|
|
};
|