Removed settings page mock data and replaced with api calls

This commit is contained in:
2026-09-27 10:58:08 +02:00
parent 6ab01830f3
commit fb27cc8c92
10 changed files with 1067 additions and 557 deletions
@@ -33,6 +33,9 @@ import {
Server,
Database,
Hash,
ChevronDown,
ChevronRight,
User as UserIcon,
} from 'lucide-react';
import {
getUsers,
@@ -77,6 +80,7 @@ import {
getNotificationStatusBadge,
getNotificationPlatformLabel,
getUserEmail,
registerUserEmails,
getActivityStatusBadge,
EMAIL_STATUSES_OPTIONS,
NOTIFICATION_STATUS_OPTIONS,
@@ -103,11 +107,29 @@ export const SettingsPage: React.FC = () => {
status: userStatusFilter !== 'all' ? userStatusFilter : undefined,
};
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const { data: usersList = [], isLoading: loadingUsers, refetch: refetchUsers } = useQuery({
queryKey: ['users', userStatusFilter, userEmailSearch],
queryFn: () => getUsers(1, 100, userFilter),
});
React.useEffect(() => {
if (usersDirectory && usersDirectory.length > 0) {
registerUserEmails(usersDirectory);
}
}, [usersDirectory]);
React.useEffect(() => {
if (usersList && usersList.length > 0) {
registerUserEmails(usersList);
}
}, [usersList]);
const { data: userFreshDetail, isLoading: loadingUserFreshDetail } = useQuery({
queryKey: ['user-canonical', selectedUserForDetail?.id],
queryFn: () => (selectedUserForDetail?.id ? getUser(selectedUserForDetail.id) : null),
@@ -238,21 +260,25 @@ export const SettingsPage: React.FC = () => {
// ==========================================
const [emailGatewayStatus, setEmailGatewayStatus] = useState<EmailStatuses>(EmailStatuses.Connected);
const [notificationSearchTerm, setNotificationSearchTerm] = useState('');
const [notificationOrgFilter, setNotificationOrgFilter] = useState<string>('all');
const [notificationOrgFilter, setNotificationOrgFilter] = useState<string>('');
const [notificationPlatformFilter, setNotificationPlatformFilter] = useState<string>('all');
const [notificationStatusFilter, setNotificationStatusFilter] = useState<string>('all');
const [selectedNotificationForDetail, setSelectedNotificationForDetail] = useState<Notification | null>(null);
const [isNotificationDetailModalOpen, setIsNotificationDetailModalOpen] = useState(false);
const [copiedNotificationPayload, setCopiedNotificationPayload] = useState(false);
const effectiveNotificationOrgId =
notificationOrgFilter || (organisations[0]?.id ? String(organisations[0].id) : '1');
const {
data: fetchedNotifications = [],
isLoading: loadingNotifications,
refetch: refetchNotifications,
isFetching: isFetchingNotifications,
} = useQuery({
queryKey: ['notifications', notificationOrgFilter],
queryFn: () => getNotifications(notificationOrgFilter === 'all' ? undefined : notificationOrgFilter),
queryKey: ['notifications', effectiveNotificationOrgId],
queryFn: () => getNotifications(effectiveNotificationOrgId),
enabled: !!effectiveNotificationOrgId,
});
const notificationExportColumns: ExportColumn<Notification>[] = [
@@ -270,9 +296,6 @@ export const SettingsPage: React.FC = () => {
];
const filteredNotifications = fetchedNotifications.filter((item) => {
if (notificationOrgFilter !== 'all' && String(item.organisationId) !== String(notificationOrgFilter)) {
return false;
}
if (notificationPlatformFilter !== 'all' && String(item.platform) !== String(notificationPlatformFilter)) {
return false;
}
@@ -305,6 +328,28 @@ export const SettingsPage: React.FC = () => {
const [auditActionFilter, setAuditActionFilter] = useState<string>('all');
const [selectedAuditLog, setSelectedAuditLog] = useState<AuditLog | null>(null);
const [isAuditDetailModalOpen, setIsAuditDetailModalOpen] = useState(false);
const [copiedAuditPayload, setCopiedAuditPayload] = useState(false);
const [expandedAuditLogIds, setExpandedAuditLogIds] = useState<Record<string | number, boolean>>({});
const [copiedUserId, setCopiedUserId] = useState<string | number | null>(null);
const toggleExpandAuditLog = (id: string | number) => {
setExpandedAuditLogIds((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const handleCopyUserId = (userId: string | number, e?: React.MouseEvent) => {
if (e) e.stopPropagation();
if (!userId) return;
const strId = String(userId);
navigator.clipboard.writeText(strId);
setCopiedUserId(userId);
success('User ID Copied', `Copied ${strId} to clipboard.`);
setTimeout(() => {
setCopiedUserId((curr) => (curr === userId ? null : curr));
}, 2000);
};
const {
data: auditLogs = [],
@@ -313,62 +358,83 @@ export const SettingsPage: React.FC = () => {
isFetching: isFetchingAuditLogs,
} = useQuery({
queryKey: ['audit-logs'],
queryFn: () => getAuditLogs(1, 200),
queryFn: () => getAuditLogs(1, 100),
});
const auditExportColumns: ExportColumn<AuditLog>[] = [
{ header: 'Audit Event ID', key: 'id' },
{ header: 'Timestamp', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Action Type', key: 'action' },
{ header: 'Entity Type', key: 'entityType' },
{ header: 'Entity Identifier', key: 'entityId' },
{ header: 'Actor User / System', key: 'userEmail', format: (val) => getUserEmail(val) },
{ header: 'Audit Details & Payload', key: 'details', format: (val) => val || '—' },
{ header: 'Event ID', key: 'id' },
{ header: 'Timestamp', key: 'timestamp', format: (val) => formatDate(val) },
{ header: 'Action', key: 'action', format: (val) => val || '—' },
{ header: 'Entity Name', key: 'entityName', format: (val) => val || '—' },
{ header: 'Entity ID', key: 'entityId', format: (val) => val || '—' },
{
header: 'Performed By (Email)',
key: 'performedBy',
format: (val) => {
const email = getUserEmail(val);
return email && email !== '—' ? `${email} (${val})` : String(val || 'System');
},
},
{ header: 'Tenant ID', key: 'tenantId', format: (val) => (val ? `Tenant #${val}` : '—') },
{
header: 'Changes',
key: 'changes',
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
},
];
const uniqueAuditEntities = Array.from(
new Set(auditLogs.map((l) => l.entityName).filter((e): e is string => Boolean(e)))
);
const uniqueAuditActions = Array.from(
new Set(auditLogs.map((l) => l.action).filter((a): a is string => Boolean(a)))
);
const filteredAuditLogs = auditLogs.filter((log) => {
if (auditEntityFilter !== 'all' && log.entityType.toLowerCase() !== auditEntityFilter.toLowerCase()) {
if (auditEntityFilter !== 'all' && (log.entityName || '').toLowerCase() !== auditEntityFilter.toLowerCase()) {
return false;
}
if (auditActionFilter !== 'all' && log.action.toLowerCase() !== auditActionFilter.toLowerCase()) {
if (auditActionFilter !== 'all' && (log.action || '').toLowerCase() !== auditActionFilter.toLowerCase()) {
return false;
}
if (!auditSearchTerm.trim()) return true;
const q = auditSearchTerm.toLowerCase();
const id = (log.id || '').toLowerCase();
const id = String(log.id || '').toLowerCase();
const action = (log.action || '').toLowerCase();
const entityType = (log.entityType || '').toLowerCase();
const entityName = (log.entityName || '').toLowerCase();
const entityId = String(log.entityId || '').toLowerCase();
const email = (log.userEmail || '').toLowerCase();
const details = (log.details || '').toLowerCase();
const performedBy = (log.performedBy || '').toLowerCase();
const tenant = String(log.tenantId || '').toLowerCase();
const changes = Array.isArray(log.changes) ? log.changes.join(' ').toLowerCase() : '';
return (
id.includes(q) ||
action.includes(q) ||
entityType.includes(q) ||
entityName.includes(q) ||
entityId.includes(q) ||
email.includes(q) ||
details.includes(q)
performedBy.includes(q) ||
tenant.includes(q) ||
changes.includes(q)
);
});
const getActionBadge = (action: string) => {
const act = (action || '').toUpperCase();
const getActionBadge = (action?: string | null) => {
const act = (action || 'GENERAL_EVENT').toUpperCase();
if (act.includes('APPROV') || act.includes('RELEASE') || act.includes('AUTH') || act.includes('VERIF')) {
return { label: action, className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' };
return { label: action || 'APPROVED', className: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' };
}
if (act.includes('CREATE') || act.includes('REGISTER') || act.includes('ADD') || act.includes('SCHEDULE')) {
return { label: action, className: 'bg-blue-500/10 text-blue-400 border-blue-500/30' };
if (act.includes('CREATE') || act.includes('REGISTER') || act.includes('ADD') || act.includes('POST') || act.includes('INSERT')) {
return { label: action || 'CREATED', className: 'bg-blue-500/10 text-blue-400 border-blue-500/30' };
}
if (act.includes('UPDATE') || act.includes('SYNC') || act.includes('EDIT')) {
return { label: action, className: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30' };
if (act.includes('UPDATE') || act.includes('SYNC') || act.includes('EDIT') || act.includes('PUT') || act.includes('PATCH')) {
return { label: action || 'UPDATED', className: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/30' };
}
if (act.includes('DISPATCH') || act.includes('INVIT') || act.includes('SEND') || act.includes('NOTIF')) {
return { label: action, className: 'bg-purple-500/10 text-purple-400 border-purple-500/30' };
return { label: action || 'DISPATCHED', className: 'bg-purple-500/10 text-purple-400 border-purple-500/30' };
}
if (act.includes('DELETE') || act.includes('SUSPEND') || act.includes('REJECT') || act.includes('LOCK')) {
return { label: action, className: 'bg-rose-500/10 text-rose-400 border-rose-500/30' };
return { label: action || 'REJECTED', className: 'bg-rose-500/10 text-rose-400 border-rose-500/30' };
}
return { label: action, className: 'bg-slate-800 text-slate-300 border-slate-700' };
return { label: action || 'AUDIT_EVENT', className: 'bg-slate-800 text-slate-300 border-slate-700' };
};
return (
@@ -914,18 +980,21 @@ export const SettingsPage: React.FC = () => {
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<Building2 className="w-3.5 h-3.5 text-slate-400" />
<span className="text-xs font-semibold text-slate-400 whitespace-nowrap">Target Org:</span>
<Building2 className="w-3.5 h-3.5 text-blue-400" />
<select
value={notificationOrgFilter}
onChange={(e) => setNotificationOrgFilter(e.target.value)}
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-2 text-slate-300 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white"
className="text-xs bg-[#090d16] border border-blue-500/40 rounded-xl px-2.5 py-2 text-white font-medium focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white"
>
<option value="all">All Organisations</option>
{organisations.map((org) => (
<option key={org.id} value={org.id}>
{org.name} (#{org.id})
{org.name || `Organisation #${org.id}`} (#{org.id})
</option>
))}
{organisations.length === 0 && (
<option value="1">Default Organisation (Org #1)</option>
)}
</select>
</div>
@@ -962,13 +1031,11 @@ export const SettingsPage: React.FC = () => {
</div>
{(notificationSearchTerm ||
notificationOrgFilter !== 'all' ||
notificationPlatformFilter !== 'all' ||
notificationStatusFilter !== 'all') && (
<button
onClick={() => {
setNotificationSearchTerm('');
setNotificationOrgFilter('all');
setNotificationPlatformFilter('all');
setNotificationStatusFilter('all');
}}
@@ -978,6 +1045,28 @@ export const SettingsPage: React.FC = () => {
Reset
</button>
)}
<button
type="button"
onClick={() => {
refetchNotifications();
success('Refreshed', `Transmissions updated for Organisation #${effectiveNotificationOrgId}.`);
}}
disabled={isFetchingNotifications}
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Force refresh notifications from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingNotifications ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
<ExportButton
title="Communications &amp; Notifications Ledger"
subtitle={`Transmission telemetry report for Organisation #${effectiveNotificationOrgId}`}
filename={`pfm-notifications-org-${effectiveNotificationOrgId}-${new Date().toISOString().split('T')[0]}`}
columns={notificationExportColumns}
data={filteredNotifications}
/>
</div>
</div>
@@ -997,7 +1086,7 @@ export const SettingsPage: React.FC = () => {
/>
</div>
<span className="text-[11px] text-slate-400 font-mono">
GET /v1/api/communications/norifications/{notificationOrgFilter === 'all' ? '{organisationId}' : notificationOrgFilter}
GET /v1/api/communications/norifications/{effectiveNotificationOrgId}
</span>
</div>
@@ -1020,14 +1109,14 @@ export const SettingsPage: React.FC = () => {
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
Querying communication microservice endpoints...
Querying communication microservice endpoints for Organisation #{effectiveNotificationOrgId}...
</td>
</tr>
) : filteredNotifications.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center text-slate-500">
<Bell className="w-8 h-8 text-slate-600 mx-auto mb-2" />
No notification records returned matching filter criteria.
No notification records returned for Organisation #{effectiveNotificationOrgId}.
</td>
</tr>
) : (
@@ -1106,37 +1195,37 @@ export const SettingsPage: React.FC = () => {
Total Audit Events
</span>
<div className="text-xl font-bold font-mono text-white">{auditLogs.length}</div>
<span className="text-[10px] text-slate-500">Immutable ledger records</span>
<span className="text-[10px] text-slate-500">Live API ledger records</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-[11px] font-semibold text-emerald-400 block uppercase tracking-wider">
Financial Operations
Audited Entities
</span>
<div className="text-xl font-bold font-mono text-emerald-300">
{auditLogs.filter((l) => ['Award', 'Budget', 'Disbursement'].includes(l.entityType)).length}
{uniqueAuditEntities.length}
</div>
<span className="text-[10px] text-slate-500">Awards, budgets &amp; disbursements</span>
<span className="text-[10px] text-slate-500">Distinct system resources</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-[11px] font-semibold text-blue-400 block uppercase tracking-wider">
Security &amp; Auth
Active Actors
</span>
<div className="text-xl font-bold font-mono text-blue-300">
{auditLogs.filter((l) => ['User', 'Security', 'Invite'].includes(l.entityType)).length}
{new Set(auditLogs.map((l) => l.performedBy).filter(Boolean)).size}
</div>
<span className="text-[10px] text-slate-500">Logins, roles &amp; access syncs</span>
<span className="text-[10px] text-slate-500">Distinct caller UUIDs</span>
</div>
<div className="bg-[#0f1523]/80 backdrop-blur-md p-4 rounded-2xl border border-slate-800/80 shadow-xl space-y-1">
<span className="text-[11px] font-semibold text-purple-400 block uppercase tracking-wider">
Audited Actors
Action Categories
</span>
<div className="text-xl font-bold font-mono text-purple-300">
{new Set(auditLogs.map((l) => l.userEmail)).size}
{uniqueAuditActions.length}
</div>
<span className="text-[10px] text-slate-500">Unique authorized principals</span>
<span className="text-[10px] text-slate-500">Registered action types</span>
</div>
</div>
@@ -1148,7 +1237,7 @@ export const SettingsPage: React.FC = () => {
type="text"
value={auditSearchTerm}
onChange={(e) => setAuditSearchTerm(e.target.value)}
placeholder="Search audit trail by action, email, entity ID, or details..."
placeholder="Search audit trail by action, entity name, ID, performed by UUID..."
className="w-full pl-9 pr-3 py-2 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
@@ -1162,14 +1251,20 @@ export const SettingsPage: React.FC = () => {
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
>
<option value="all">All Entities</option>
<option value="User">User</option>
<option value="Award">Award</option>
<option value="Budget">Budget</option>
<option value="Disbursement">Disbursement</option>
<option value="Risk">Risk</option>
<option value="Evidence">Evidence</option>
<option value="Invite">Invite</option>
<option value="Security">Security</option>
{uniqueAuditEntities.map((ent) => (
<option key={ent} value={ent}>
{ent}
</option>
))}
{uniqueAuditEntities.length === 0 && (
<>
<option value="User">User</option>
<option value="Award">Award</option>
<option value="Budget">Budget</option>
<option value="Disbursement">Disbursement</option>
<option value="Risk">Risk</option>
</>
)}
</select>
</div>
@@ -1181,15 +1276,11 @@ export const SettingsPage: React.FC = () => {
className="text-xs bg-[#090d16] border border-slate-700/80 rounded-xl px-2.5 py-1.5 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
>
<option value="all">All Actions</option>
<option value="USER_AUTHENTICATED">USER_AUTHENTICATED</option>
<option value="DISBURSEMENT_APPROVED">DISBURSEMENT_APPROVED</option>
<option value="DISBURSEMENT_SCHEDULED">DISBURSEMENT_SCHEDULED</option>
<option value="BUDGET_LINE_CREATED">BUDGET_LINE_CREATED</option>
<option value="AWARD_REGISTERED">AWARD_REGISTERED</option>
<option value="RISK_EVALUATED">RISK_EVALUATED</option>
<option value="EVIDENCE_VERIFIED">EVIDENCE_VERIFIED</option>
<option value="INVITATION_DISPATCHED">INVITATION_DISPATCHED</option>
<option value="DIRECTORY_SYNCED">DIRECTORY_SYNCED</option>
{uniqueAuditActions.map((act) => (
<option key={act} value={act}>
{act}
</option>
))}
</select>
</div>
@@ -1211,19 +1302,19 @@ export const SettingsPage: React.FC = () => {
type="button"
onClick={() => {
refetchAuditLogs();
success('Audit Ledger Refreshed', 'Latest audit log entries loaded.');
success('Refreshed', 'Latest audit log entries loaded from API.');
}}
disabled={isFetchingAuditLogs}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh audit trail"
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
title="Refresh audit trail from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingAuditLogs ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">Refresh</span>
<span>Refresh</span>
</button>
<ExportButton
title="System Audit & Security Log"
subtitle="Complete audit trail of user access, statutory approvals, and financial transactions"
title="System Audit &amp; Security Log"
subtitle="Complete audit trail retrieved directly from /v1/api/governance/auditlog"
filename={`pfm-audit-trail-${new Date().toISOString().split('T')[0]}`}
columns={auditExportColumns}
data={filteredAuditLogs}
@@ -1233,74 +1324,235 @@ export const SettingsPage: React.FC = () => {
{/* Audit Logs Table */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="p-4 border-b border-slate-800/80 flex items-center justify-between">
<div className="flex items-center gap-2">
<History className="w-4 h-4 text-blue-400" />
<h3 className="text-sm font-semibold text-white">
Audit Log Ledger ({filteredAuditLogs.length} of {auditLogs.length})
</h3>
<HelpTooltip
title="Audit Trail"
content="Authoritative record of state changes retrieved via POST /v1/api/governance/auditlog."
size="sm"
placement="right"
/>
</div>
<span className="text-[11px] text-slate-400 font-mono">
POST /v1/api/governance/auditlog
</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">Timestamp</th>
<th className="py-3.5 px-3 w-10 text-center"></th>
<th className="py-3.5 px-4">Timestamp</th>
<th className="py-3.5 px-4">Event ID</th>
<th className="py-3.5 px-4">Action</th>
<th className="py-3.5 px-4">Entity</th>
<th className="py-3.5 px-4">Actor</th>
<th className="py-3.5 px-4">Details &amp; Telemetry</th>
<th className="py-3.5 px-4">Performed By</th>
<th className="py-3.5 px-4">Changes</th>
<th className="py-3.5 px-5 text-right">Inspect</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingAuditLogs ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
Loading system audit ledger...
<td colSpan={8} className="py-12 text-center text-slate-500">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2 text-blue-400" />
Querying governance audit log endpoints...
</td>
</tr>
) : filteredAuditLogs.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
<td colSpan={8} className="py-12 text-center text-slate-500">
<History className="w-8 h-8 text-slate-600 mx-auto mb-2" />
{auditSearchTerm || auditEntityFilter !== 'all' || auditActionFilter !== 'all'
? 'No audit log entries match your filter criteria.'
: 'No audit records logged yet.'}
: 'No audit records returned from endpoint.'}
</td>
</tr>
) : (
filteredAuditLogs.map((log) => {
const badge = getActionBadge(log.action);
const isExpanded = !!expandedAuditLogIds[log.id];
const changesCount = Array.isArray(log.changes) ? log.changes.length : 0;
const performerEmail = getUserEmail(log.performedBy);
const hasResolvedEmail = performerEmail && performerEmail !== '—' && !performerEmail.includes('—');
const displayEmail = hasResolvedEmail ? performerEmail : (log.performedBy || 'System');
return (
<tr key={log.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-mono text-slate-400 whitespace-nowrap text-[11px]">
{formatDate(log.createdAt)}
</td>
<td className="py-3.5 px-4 font-mono font-bold text-white whitespace-nowrap">
{log.id}
</td>
<td className="py-3.5 px-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
{badge.label}
</span>
</td>
<td className="py-3.5 px-4 whitespace-nowrap">
<span className="font-semibold text-slate-200 block">{log.entityType}</span>
<span className="text-[10px] text-slate-500 font-mono">#{log.entityId}</span>
</td>
<td className="py-3.5 px-4 text-slate-300 font-mono text-[11px]">
{getUserEmail(log.userEmail)}
</td>
<td className="py-3.5 px-4 text-slate-400 max-w-md truncate" title={log.details}>
{log.details || '—'}
</td>
<td className="py-3.5 px-5 text-right whitespace-nowrap">
<button
type="button"
onClick={() => {
setSelectedAuditLog(log);
setIsAuditDetailModalOpen(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"
<React.Fragment key={log.id}>
<tr
onClick={() => toggleExpandAuditLog(log.id)}
className={`hover:bg-slate-800/40 transition-colors cursor-pointer ${
isExpanded ? 'bg-slate-800/30' : ''
}`}
>
<td className="py-3.5 px-3 text-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpandAuditLog(log.id);
}}
className="p-1 rounded-md hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer"
title={isExpanded ? 'Collapse changes shelf' : 'Expand changes shelf'}
>
<ChevronRight
className={`w-3.5 h-3.5 transition-transform duration-200 ${
isExpanded ? 'rotate-90 text-blue-400' : 'text-slate-500'
}`}
/>
</button>
</td>
<td className="py-3.5 px-4 font-mono text-slate-400 whitespace-nowrap text-[11px]">
{formatDate(log.timestamp)}
</td>
<td className="py-3.5 px-4 font-mono font-bold text-white whitespace-nowrap">
#{log.id}
</td>
<td className="py-3.5 px-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${badge.className}`}>
{badge.label}
</span>
</td>
<td className="py-3.5 px-4 whitespace-nowrap">
<span className="font-semibold text-slate-200 block">{log.entityName || 'General'}</span>
{log.entityId && (
<span className="text-[10px] text-slate-500 font-mono">#{log.entityId}</span>
)}
</td>
<td
className="py-3.5 px-4"
onClick={(e) => e.stopPropagation()}
>
<Eye className="w-3 h-3 text-blue-400" />
Inspect
</button>
</td>
</tr>
<div className="flex items-center gap-1.5 min-w-0 max-w-[220px]">
<span
className="text-slate-200 font-mono text-[11px] truncate block"
title={`${displayEmail}${log.performedBy ? ` (User ID: ${log.performedBy})` : ''}`}
>
{displayEmail}
</span>
{log.performedBy && (
<button
type="button"
onClick={(e) => handleCopyUserId(log.performedBy, e)}
className="p-1 rounded-md hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer shrink-0"
title={`Click to copy User ID (${log.performedBy})`}
>
{copiedUserId === log.performedBy ? (
<Check className="w-3.5 h-3.5 text-emerald-400" />
) : (
<Copy className="w-3.5 h-3.5 hover:text-blue-400" />
)}
</button>
)}
</div>
</td>
<td className="py-3.5 px-4">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpandAuditLog(log.id);
}}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded-lg bg-slate-800/90 hover:bg-slate-700 border border-slate-700/80 text-[11px] text-slate-300 hover:text-white transition cursor-pointer"
title="Click to toggle changes shelf"
>
<Layers className="w-3 h-3 text-blue-400" />
<span>{changesCount} {changesCount === 1 ? 'change' : 'changes'}</span>
<ChevronDown
className={`w-3 h-3 text-slate-400 transition-transform duration-200 ${
isExpanded ? 'rotate-180 text-blue-400' : ''
}`}
/>
</button>
</td>
<td className="py-3.5 px-5 text-right whitespace-nowrap">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedAuditLog(log);
setIsAuditDetailModalOpen(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>
</td>
</tr>
{/* Expanding Shelf for Changes Content */}
{isExpanded && (
<tr className="bg-[#080c14] border-b border-slate-800/80">
<td colSpan={8} className="p-4 sm:p-5 bg-gradient-to-r from-[#0d121f] via-[#090d16] to-[#0d121f]">
<div className="rounded-xl border border-slate-800/90 bg-[#070a12]/90 p-4 space-y-3 shadow-inner">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80 pb-3">
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-blue-400" />
<h4 className="text-xs font-bold text-white uppercase tracking-wider">
Recorded Changes Content (Event #{log.id})
</h4>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-semibold">
{changesCount} {changesCount === 1 ? 'Entry' : 'Entries'}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-400">
<span>
Entity: <strong className="text-white">{log.entityName || 'General'}</strong> {log.entityId ? `(#${log.entityId})` : ''}
</span>
<span>•</span>
<span className="flex items-center gap-1">
Performer: <strong className="text-slate-200 font-mono text-[11px]">{displayEmail}</strong>
{log.performedBy && (
<button
type="button"
onClick={(e) => handleCopyUserId(log.performedBy, e)}
className="p-1 rounded hover:bg-slate-800 text-slate-400 hover:text-white transition cursor-pointer"
title={`Copy User ID: ${log.performedBy}`}
>
{copiedUserId === log.performedBy ? (
<Check className="w-3 h-3 text-emerald-400" />
) : (
<Copy className="w-3 h-3 text-slate-400 hover:text-blue-400" />
)}
</button>
)}
</span>
</div>
</div>
{/* Changes List */}
{Array.isArray(log.changes) && log.changes.length > 0 ? (
<div className="space-y-2">
{log.changes.map((changeItem, idx) => (
<div
key={idx}
className="flex items-start gap-3 p-3 bg-slate-900/90 rounded-xl border border-slate-800/80 text-xs font-mono shadow-xs hover:border-slate-700 transition"
>
<span className="px-2 py-0.5 rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20 text-[10px] font-bold shrink-0 mt-0.5">
#{idx + 1}
</span>
<div className="flex-1 text-emerald-400 leading-relaxed break-all">
{changeItem}
</div>
</div>
))}
</div>
) : (
<div className="p-4 rounded-xl bg-slate-900/50 border border-slate-800/80 text-center text-slate-400 text-xs font-mono">
No itemized field changes content recorded for this event.
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})
)}
@@ -1319,9 +1571,10 @@ export const SettingsPage: React.FC = () => {
onClose={() => {
setIsAuditDetailModalOpen(false);
setSelectedAuditLog(null);
setCopiedAuditPayload(false);
}}
title={`Audit Event: ${selectedAuditLog?.id || ''}`}
subtitle="Cryptographic verification and authoritative event metadata"
title={`Audit Event: #${selectedAuditLog?.id || ''}`}
subtitle="Authoritative event payload from /v1/api/governance/auditlog"
>
{selectedAuditLog && (
<div className="space-y-4 text-xs">
@@ -1329,37 +1582,112 @@ export const SettingsPage: React.FC = () => {
<div className="flex justify-between items-center border-b border-slate-800/80 pb-2">
<span className="text-slate-400 font-semibold uppercase text-[11px]">Action Type:</span>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold border ${getActionBadge(selectedAuditLog.action).className}`}>
{selectedAuditLog.action}
{selectedAuditLog.action || 'EVENT'}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Type:</span>
<span className="text-white font-semibold">{selectedAuditLog.entityType}</span>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Name:</span>
<span className="text-white font-semibold">{selectedAuditLog.entityName || '—'}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity ID:</span>
<span className="font-mono text-slate-300">{selectedAuditLog.entityId}</span>
<span className="font-mono text-slate-300">{selectedAuditLog.entityId || '—'}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 border-t border-slate-800/80 pt-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Actor / Principal:</span>
<span className="font-mono text-slate-200 break-all">{selectedAuditLog.userEmail}</span>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Performed By:</span>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="font-mono text-slate-200 text-[11px] truncate">
{getUserEmail(selectedAuditLog.performedBy) !== '—'
? getUserEmail(selectedAuditLog.performedBy)
: (selectedAuditLog.performedBy || 'System')}
</span>
{selectedAuditLog.performedBy && (
<button
type="button"
onClick={(e) => handleCopyUserId(selectedAuditLog.performedBy, e)}
className="p-1 rounded hover:bg-slate-700/80 text-slate-400 hover:text-white transition cursor-pointer shrink-0"
title={`Copy User ID: ${selectedAuditLog.performedBy}`}
>
{copiedUserId === selectedAuditLog.performedBy ? (
<Check className="w-3.5 h-3.5 text-emerald-400" />
) : (
<Copy className="w-3.5 h-3.5 hover:text-blue-400" />
)}
</button>
)}
</div>
{selectedAuditLog.performedBy && (
<span className="text-[10px] text-slate-500 font-mono block mt-0.5 truncate">
ID: {selectedAuditLog.performedBy}
</span>
)}
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Logged Timestamp:</span>
<span className="font-mono text-slate-300">{formatDate(selectedAuditLog.createdAt)}</span>
<span className="font-mono text-slate-300 block mt-0.5">{formatDate(selectedAuditLog.timestamp)}</span>
</div>
</div>
<div className="border-t border-slate-800/80 pt-2">
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">Details &amp; Audit Trail:</span>
<p className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-slate-300 leading-relaxed font-mono text-[11px]">
{selectedAuditLog.details || 'No additional payload notes recorded for this transaction.'}
</p>
{selectedAuditLog.tenantId && (
<div className="border-t border-slate-800/80 pt-2">
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Tenant Scope:</span>
<span className="font-mono text-slate-300">Tenant #{selectedAuditLog.tenantId}</span>
</div>
)}
<div className="border-t border-slate-800/80 pt-2 space-y-1">
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">State Changes / Delta:</span>
{Array.isArray(selectedAuditLog.changes) && selectedAuditLog.changes.length > 0 ? (
<ul className="space-y-1 p-2 rounded-lg bg-slate-900 border border-slate-800">
{selectedAuditLog.changes.map((ch, idx) => (
<li key={idx} className="font-mono text-emerald-400/90 text-[11px] flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
{ch}
</li>
))}
</ul>
) : (
<p className="p-3 rounded-lg bg-slate-900 border border-slate-800 text-slate-400 leading-relaxed font-mono text-[11px]">
No granular delta changes specified in audit payload.
</p>
)}
</div>
{/* Raw JSON Data Viewer */}
<div className="border-t border-slate-800/80 pt-2.5 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-slate-400 font-semibold text-[11px] uppercase">
Raw Audit Record (JSON):
</span>
<button
onClick={() => {
navigator.clipboard.writeText(JSON.stringify(selectedAuditLog, null, 2));
setCopiedAuditPayload(true);
setTimeout(() => setCopiedAuditPayload(false), 2000);
}}
className="inline-flex items-center gap-1 text-[10px] font-medium text-slate-400 hover:text-white bg-slate-800 px-2 py-1 rounded transition cursor-pointer"
>
{copiedAuditPayload ? (
<>
<Check className="w-3 h-3 text-emerald-400" />
<span className="text-emerald-400">Copied</span>
</>
) : (
<>
<Copy className="w-3 h-3" />
<span>Copy JSON</span>
</>
)}
</button>
</div>
<pre className="p-3 rounded-lg bg-[#070a12] border border-slate-800/80 text-emerald-300/90 font-mono text-[10.5px] overflow-x-auto max-h-48">
{JSON.stringify(selectedAuditLog, null, 2)}
</pre>
</div>
</div>
@@ -1369,6 +1697,7 @@ export const SettingsPage: React.FC = () => {
onClick={() => {
setIsAuditDetailModalOpen(false);
setSelectedAuditLog(null);
setCopiedAuditPayload(false);
}}
className="px-4 py-2 text-xs font-semibold text-white bg-slate-800 hover:bg-slate-700 rounded-xl transition cursor-pointer"
>