diff --git a/.gitignore b/.gitignore index 7f5c9df601..cda0b00018 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ fly.toml .db *.db dist +audit-logs diff --git a/admin-ui/client/package.json b/admin-ui/client/package.json index 80d3463a48..93e8b0a7c5 100644 --- a/admin-ui/client/package.json +++ b/admin-ui/client/package.json @@ -95,7 +95,12 @@ "./work": { "types": "./dist/modules/work/index.d.ts", "import": "./dist/modules/work/index.js", - "require": "./dist/modules/work/index.js" + "require": "./dist/modules/work/index.js" + }, + "./audit": { + "import": "./dist/modules/audit/index.js", + "require": "./dist/modules/audit/index.js", + "types": "./dist/modules/audit/index.d.ts" } }, "peerDependencies": { diff --git a/admin-ui/src/components/ui/BreadCrumbs.tsx b/admin-ui/src/components/ui/BreadCrumbs.tsx index 707bd7f468..aa00ceef14 100644 --- a/admin-ui/src/components/ui/BreadCrumbs.tsx +++ b/admin-ui/src/components/ui/BreadCrumbs.tsx @@ -32,6 +32,7 @@ const BreadCrumbs = ({ 'warehousing-provider': 'warehousing_provider', system: 'system', account: 'account', + 'audit-log': 'audit_log', new: 'new', edit: 'edit', }; diff --git a/admin-ui/src/gql/types.ts b/admin-ui/src/gql/types.ts index 960d81d4d6..c5a3326d8f 100644 --- a/admin-ui/src/gql/types.ts +++ b/admin-ui/src/gql/types.ts @@ -230,6 +230,76 @@ export type IAssortmentTexts = { title?: Maybe; }; +export type IAuditChainError = { + message: Scalars['String']['output']; + sequenceNumber: Scalars['Int']['output']; +}; + +export type IAuditChainStatus = { + checkedEntries: Scalars['Int']['output']; + errors: Array; + firstEntry?: Maybe; + lastEntry?: Maybe; + totalEntries: Scalars['Int']['output']; + valid: Scalars['Boolean']['output']; +}; + +export type IAuditLogActor = { + session?: Maybe; + user?: Maybe; +}; + +export type IAuditLogApi = { + operation?: Maybe; + request?: Maybe; + response?: Maybe; +}; + +export type IAuditLogApiRequest = { + uid?: Maybe; +}; + +export type IAuditLogApiResponse = { + code?: Maybe; +}; + +export type IAuditLogEndpoint = { + ip?: Maybe; + port?: Maybe; +}; + +export type IAuditLogEntry = { + activityId: Scalars['Int']['output']; + activityName?: Maybe; + actor?: Maybe; + api?: Maybe; + categoryUid: Scalars['Int']['output']; + className: Scalars['String']['output']; + classUid: Scalars['Int']['output']; + hash?: Maybe; + id: Scalars['ID']['output']; + message?: Maybe; + prevHash?: Maybe; + raw?: Maybe; + sequenceNumber?: Maybe; + severityId: Scalars['Int']['output']; + srcEndpoint?: Maybe; + statusDetail?: Maybe; + statusId: Scalars['Int']['output']; + time: Scalars['Float']['output']; + typeUid: Scalars['Int']['output']; +}; + +export type IAuditLogSession = { + uid?: Maybe; +}; + +export type IAuditLogUser = { + emailAddr?: Maybe; + name?: Maybe; + uid?: Maybe; +}; + export type IBookmark = { _id: Scalars['ID']['output']; created?: Maybe; @@ -730,6 +800,7 @@ export type IEventStatistics = { export enum IEventType { AclDenied = 'ACL_DENIED', AclGrantedSensitive = 'ACL_GRANTED_SENSITIVE', + ApiLoginFailed = 'API_LOGIN_FAILED', ApiLoginTokenCreated = 'API_LOGIN_TOKEN_CREATED', ApiLogout = 'API_LOGOUT', AssortmentAddFilter = 'ASSORTMENT_ADD_FILTER', @@ -2793,6 +2864,12 @@ export type IQuery = { assortments: Array; /** Returns total number of assortments that match a given criteria or all if no criteria is given */ assortmentsCount: Scalars['Int']['output']; + /** Verify tamper-evident hash chain integrity of the audit log */ + auditChainStatus: IAuditChainStatus; + /** Get audit log entries */ + auditLogs: Array; + /** Get total count of audit log entries */ + auditLogsCount: Scalars['Int']['output']; /** Get all countries, by default sorted by creation date (ascending) */ countries: Array; /** Returns total number of countries */ @@ -2827,6 +2904,8 @@ export type IQuery = { events: Array; /** Get total count of all emitted events */ eventsCount: Scalars['Int']['output']; + /** Get count of failed login attempts */ + failedLoginAttempts: Scalars['Int']['output']; /** Get a specific filter by ID */ filter?: Maybe; /** Get all filters, by default sorted by creation date (ascending) */ @@ -2964,6 +3043,26 @@ export type IQueryAssortmentsCountArgs = { tags?: InputMaybe>; }; +export type IQueryAuditLogsArgs = { + classUids?: InputMaybe>; + from?: InputMaybe; + limit?: InputMaybe; + offset?: InputMaybe; + queryText?: InputMaybe; + success?: InputMaybe; + to?: InputMaybe; + userId?: InputMaybe; +}; + +export type IQueryAuditLogsCountArgs = { + classUids?: InputMaybe>; + from?: InputMaybe; + queryText?: InputMaybe; + success?: InputMaybe; + to?: InputMaybe; + userId?: InputMaybe; +}; + export type IQueryCountriesArgs = { includeInactive?: InputMaybe; limit?: InputMaybe; @@ -3055,6 +3154,12 @@ export type IQueryEventsCountArgs = { types?: InputMaybe>; }; +export type IQueryFailedLoginAttemptsArgs = { + remoteAddress?: InputMaybe; + since?: InputMaybe; + userId?: InputMaybe; +}; + export type IQueryFilterArgs = { filterId?: InputMaybe; }; @@ -3451,9 +3556,11 @@ export enum IRoleAction { UploadTempFile = 'uploadTempFile', UploadUserAvatar = 'uploadUserAvatar', UseWebAuthn = 'useWebAuthn', + VerifyAuditChain = 'verifyAuditChain', VerifyEmail = 'verifyEmail', ViewAssortment = 'viewAssortment', ViewAssortments = 'viewAssortments', + ViewAuditLog = 'viewAuditLog', ViewCountries = 'viewCountries', ViewCountry = 'viewCountry', ViewCurrencies = 'viewCurrencies', @@ -4060,6 +4167,7 @@ export enum IWorkStatus { } export enum IWorkType { + AuditLogPrune = 'AUDIT_LOG_PRUNE', Budgetsms = 'BUDGETSMS', Bulkgate = 'BULKGATE', BulkExport = 'BULK_EXPORT', @@ -6938,6 +7046,109 @@ export type IUpdateAssortmentTextsMutation = { }>; }; +export type IAuditLogEntryFragment = { + id: string; + time: number; + message?: string | null; + classUid: number; + className: string; + activityId: number; + activityName?: string | null; + typeUid: number; + categoryUid: number; + severityId: number; + statusId: number; + statusDetail?: string | null; + sequenceNumber?: number | null; + prevHash?: string | null; + hash?: string | null; + raw?: any | null; + actor?: { + user?: { + uid?: string | null; + name?: string | null; + emailAddr?: string | null; + } | null; + session?: { uid?: string | null } | null; + } | null; + srcEndpoint?: { ip?: string | null; port?: number | null } | null; + api?: { + operation?: string | null; + request?: { uid?: string | null } | null; + response?: { code?: number | null } | null; + } | null; +}; + +export type IAuditLogEntryFragmentVariables = Exact<{ [key: string]: never }>; + +export type IAuditChainStatusQueryVariables = Exact<{ [key: string]: never }>; + +export type IAuditChainStatusQuery = { + auditChainStatus: { + valid: boolean; + totalEntries: number; + checkedEntries: number; + firstEntry?: number | null; + lastEntry?: number | null; + errors: Array<{ sequenceNumber: number; message: string }>; + }; +}; + +export type IAuditLogsQueryVariables = Exact<{ + limit?: InputMaybe; + offset?: InputMaybe; + classUids?: InputMaybe>; + userId?: InputMaybe; + success?: InputMaybe; + from?: InputMaybe; + to?: InputMaybe; + queryText?: InputMaybe; +}>; + +export type IAuditLogsQuery = { + auditLogsCount: number; + auditLogs: Array<{ + id: string; + time: number; + message?: string | null; + classUid: number; + className: string; + activityId: number; + activityName?: string | null; + typeUid: number; + categoryUid: number; + severityId: number; + statusId: number; + statusDetail?: string | null; + sequenceNumber?: number | null; + prevHash?: string | null; + hash?: string | null; + raw?: any | null; + actor?: { + user?: { + uid?: string | null; + name?: string | null; + emailAddr?: string | null; + } | null; + session?: { uid?: string | null } | null; + } | null; + srcEndpoint?: { ip?: string | null; port?: number | null } | null; + api?: { + operation?: string | null; + request?: { uid?: string | null } | null; + response?: { code?: number | null } | null; + } | null; + }>; +}; + +export type IFailedLoginAttemptsQueryVariables = Exact<{ + userId?: InputMaybe; + remoteAddress?: InputMaybe; + since?: InputMaybe; +}>; + +export type IFailedLoginAttemptsQuery = { failedLoginAttempts: number }; + export type IAddressFragment = { firstName?: string | null; lastName?: string | null; diff --git a/admin-ui/src/i18n/de.json b/admin-ui/src/i18n/de.json index 25619923da..1b32a93e30 100644 --- a/admin-ui/src/i18n/de.json +++ b/admin-ui/src/i18n/de.json @@ -7,6 +7,67 @@ "account_status": "Kontostatus", "actions_column_header": "Aktionen", "activate": "Aktivieren", + "audit_chain_entries": "{checked} von {total} Einträgen verifiziert", + "audit_chain_error": "Audit-Kettenstatus konnte nicht geladen werden", + "audit_chain_invalid": "Audit-Kette manipuliert", + "audit_chain_reverify": "Erneut prüfen", + "audit_chain_valid": "Integrität der Audit-Kette verifiziert", + "audit_activity": "Aktivität", + "audit_activity_access_denied": "Zugriff verweigert", + "audit_activity_attach_policy": "Richtlinie zuweisen", + "audit_activity_auth_ticket": "Authentifizierungs-Ticket", + "audit_activity_checkout": "Bestellung", + "audit_activity_create": "Erstellen", + "audit_activity_delete": "Löschen", + "audit_activity_detach_policy": "Richtlinie entfernen", + "audit_activity_disable": "Deaktivieren", + "audit_activity_enable": "Aktivieren", + "audit_activity_export": "Exportieren", + "audit_activity_import": "Importieren", + "audit_activity_lock": "Sperren", + "audit_activity_logoff": "Abmeldung", + "audit_activity_logon": "Anmeldung", + "audit_activity_mfa_disable": "MFA deaktivieren", + "audit_activity_mfa_enable": "MFA aktivieren", + "audit_activity_password_change": "Passwort ändern", + "audit_activity_password_reset": "Passwort zurücksetzen", + "audit_activity_payment": "Zahlung", + "audit_activity_pre_auth": "Vorautorisierung", + "audit_activity_read": "Lesen", + "audit_activity_refund": "Erstattung", + "audit_activity_service_ticket_renew": "Service-Ticket erneuern", + "audit_activity_service_ticket_request": "Service-Ticket anfordern", + "audit_activity_update": "Aktualisieren", + "audit_class": "Klasse", + "audit_class_account_change": "Kontoänderung", + "audit_class_api_activity": "API-Aktivität", + "audit_class_authentication": "Authentifizierung", + "audit_detail_actor": "Akteur", + "audit_detail_api": "API", + "audit_detail_chain": "Hash-Kette", + "audit_detail_changed_data": "Geänderte Daten", + "audit_detail_event": "Ereignis", + "audit_detail_raw": "OCSF-Rohereignis", + "audit_detail_source": "Quell-Endpunkt", + "audit_entry_detail": "Audit-Eintrag Details", + "audit_filter_all_classes": "Alle Klassen", + "audit_filter_all_statuses": "Alle Status", + "audit_filter_user_id": "Nach Benutzer-ID filtern...", + "audit_log_browser": "Audit-Log ({count})", + "audit_message": "Nachricht", + "audit_status": "Status", + "audit_status_failure": "Fehlgeschlagen", + "audit_severity_critical": "Kritisch", + "audit_severity_fatal": "Fatal", + "audit_severity_high": "Hoch", + "audit_severity_informational": "Informativ", + "audit_severity_low": "Niedrig", + "audit_severity_medium": "Mittel", + "audit_severity_other": "Sonstige", + "audit_severity_unknown": "Unbekannt", + "audit_status_success": "Erfolgreich", + "audit_time": "Zeit", + "audit_user": "Benutzer", "activate_assortment_description": "Wird bei der Suche sichtbar sein", "activate_button": "Aktivieren", "activate_header_conformation": "Sind Sie sicher, dass Sie dieses Abonnement abschliessen möchten?", @@ -114,6 +175,7 @@ "change": "Ändern", "change_language": "Sprache ändern", "change_password": "Passwort ändern", + "class": "Klasse", "chat_disabled_message": "Copilot-Chat ist derzeit in diesem Shop deaktiviert. Bitte überprüfen Sie Ihre Engine-Konfiguration", "chat_disabled_title": "Chat ist deaktiviert", "chat_error_retry_message": "Ein Fehler ist beim Verarbeiten Ihrer Anfrage aufgetreten. Bitte laden Sie die Unterhaltung neu und senden Sie etwas Kürzeres.", @@ -363,13 +425,18 @@ "extensions": "Erweiterungen", "fail_reorder": "Neuordnung fehlgeschlagen, versuchen Sie es erneut!", "fail_upload": "Medien-Upload fehlgeschlagen", + "failed_login_attempts": "Fehlgeschlagene Anmeldeversuche", + "failed_login_error": "Anmeldedaten konnten nicht geladen werden", "failed_tool_response": "Tool-Ergebnis konnte nicht geparst werden.", "female": "Weiblich", "filer_options": "Filter-Optionen", "filter": "Filter", "filter_activated": "Filter aktiviert!", "filter_by_creation_date": "Nach Erstellungsdatum filtern", + "filter_by_date": "Nach Datum filtern", + "filter_by_message": "Nachrichten durchsuchen", "filter_by_type": "Nach Typ filtern", + "filter_by_user": "Nach Benutzer-ID filtern", "filter_count_header": "{count} Filter", "filter_created": "Filter erfolgreich erstellt!", "filter_deactivated": "Filter deaktiviert!", @@ -458,6 +525,8 @@ "language_page_title": "{count, plural, one {# Sprache} other {# Sprachen}}", "language_updated": "Sprache erfolgreich aktualisiert!", "languages": "Sprachen", + "last_24_hours": "Letzte 24 Stunden", + "last_7_days": "Letzte 7 Tage", "last_Name": "Nachname", "last_login": "Letzter Login:", "last_updated": "Zuletzt aktualisiert:", @@ -708,6 +777,11 @@ "scaffold_product": "Gerüst", "scheduled": "Geplant", "search": "Suchen", + "security": "Sicherheit", + "audit_log_page_header": "Audit-Log", + "audit_log_page_title": "Audit-Log", + "settings": "Einstellungen", + "search_messages_placeholder": "Nach Nachricht oder Operation suchen...", "search_product": "Search product", "select_language": "Sprache auswählen", "select_option": "Option auswählen", @@ -979,6 +1053,7 @@ "events": "Veranstaltungen", "items": "Artikel", "no_active_types": "Keine aktiven Arbeitertypen gefunden", + "no_audit_entries": "Keine Audit-Log-Einträge gefunden", "worker.dateRange": "Datumsbereich", "worker.deleteCount": "Gelöscht", "worker.errorCount": "Fehler", diff --git a/admin-ui/src/i18n/en.json b/admin-ui/src/i18n/en.json index fc13ebf649..29416b63b6 100644 --- a/admin-ui/src/i18n/en.json +++ b/admin-ui/src/i18n/en.json @@ -7,6 +7,67 @@ "action_edit": "Edit", "actions_column_header": "Actions", "activate": "Activate", + "audit_chain_entries": "{checked} of {total} entries verified", + "audit_chain_error": "Failed to load audit chain status", + "audit_chain_invalid": "Audit chain integrity compromised", + "audit_chain_reverify": "Re-verify", + "audit_chain_valid": "Audit chain integrity verified", + "audit_activity": "Activity", + "audit_activity_access_denied": "Access Denied", + "audit_activity_attach_policy": "Attach Policy", + "audit_activity_auth_ticket": "Authentication Ticket", + "audit_activity_checkout": "Checkout", + "audit_activity_create": "Create", + "audit_activity_delete": "Delete", + "audit_activity_detach_policy": "Detach Policy", + "audit_activity_disable": "Disable", + "audit_activity_enable": "Enable", + "audit_activity_export": "Export", + "audit_activity_import": "Import", + "audit_activity_lock": "Lock", + "audit_activity_logoff": "Logoff", + "audit_activity_logon": "Logon", + "audit_activity_mfa_disable": "MFA Disable", + "audit_activity_mfa_enable": "MFA Enable", + "audit_activity_password_change": "Password Change", + "audit_activity_password_reset": "Password Reset", + "audit_activity_payment": "Payment", + "audit_activity_pre_auth": "Pre-Auth", + "audit_activity_read": "Read", + "audit_activity_refund": "Refund", + "audit_activity_service_ticket_renew": "Service Ticket Renew", + "audit_activity_service_ticket_request": "Service Ticket Request", + "audit_activity_update": "Update", + "audit_class": "Class", + "audit_class_account_change": "Account Change", + "audit_class_api_activity": "API Activity", + "audit_class_authentication": "Authentication", + "audit_detail_actor": "Actor", + "audit_detail_api": "API", + "audit_detail_chain": "Hash Chain", + "audit_detail_changed_data": "Changed Data", + "audit_detail_event": "Event", + "audit_detail_raw": "Raw OCSF Event", + "audit_detail_source": "Source Endpoint", + "audit_entry_detail": "Audit Entry Detail", + "audit_filter_all_classes": "All classes", + "audit_filter_all_statuses": "All statuses", + "audit_filter_user_id": "Filter by user ID...", + "audit_log_browser": "Audit Log ({count})", + "audit_message": "Message", + "audit_status": "Status", + "audit_status_failure": "Failure", + "audit_severity_critical": "Critical", + "audit_severity_fatal": "Fatal", + "audit_severity_high": "High", + "audit_severity_informational": "Informational", + "audit_severity_low": "Low", + "audit_severity_medium": "Medium", + "audit_severity_other": "Other", + "audit_severity_unknown": "Unknown", + "audit_status_success": "Success", + "audit_time": "Time", + "audit_user": "User", "activate_assortment_description": "Will be visible on search", "activate_button": "Activate", "activate_header_conformation": "Are you sure you want to this subscription?", @@ -135,6 +196,7 @@ "change": "Change", "change_language": "Change language", "change_password": "Change password", + "class": "Class", "chat.error.auth": "🔐 **Authentication Error** Your session has expired or the authentication token is invalid. *Please refresh or log in again.*", "chat.error.connection": "🚫 **Connection Error** Cannot connect to the chat API server at {chatUrl}. **To enable chat:** 1. Start the backend 2. Ensure chat/MCP endpoint is configured 3. Check NEXT_PUBLIC_CHAT_URL **Current configuration:** - Chat URL: {chatUrl} *Please start the backend.*", "chat.error.stream": "⚠️ **Stream Processing Error** There was an error processing the AI response stream. **Possible causes:** 1. Backend not running 2. MCP server not responding 3. Missing/invalid API keys 4. Network issues **Error details:** {details}", @@ -413,6 +475,8 @@ "extensions": "Extensions", "fail_reorder": "Reordering failed, Try again!", "fail_upload": "Media upload failed", + "failed_login_attempts": "Failed login attempts", + "failed_login_error": "Failed to load login data", "failed_tool_response": "Failed to parse tool result.", "female": "Female", "file": "File", @@ -423,7 +487,10 @@ "filter.sortKey": "Sort", "filter_activated": "Filter activated!", "filter_by_creation_date": "Filter by creation date", + "filter_by_date": "Filter by date", + "filter_by_message": "Search messages", "filter_by_type": "Filter by type", + "filter_by_user": "Filter by user ID", "filter_count_header": "{count} Filters", "filter_created": "Filter created successfully!", "filter_csv_invalid_type_set": "invalid filter type {type}", @@ -523,6 +590,8 @@ "language_page_title": "{count, plural, one {# Language} other {# Languages}}", "language_updated": "Language updated successfully!", "languages": "Languages", + "last_24_hours": "Last 24 hours", + "last_7_days": "Last 7 days", "last_Name": "Last name", "last_login": "Last log in", "last_order_date": "Last order", @@ -589,6 +658,7 @@ "next": "Next", "no_active_exports_found": "No active files found or all links have expired.", "no_active_types": "No active worker types found", + "no_audit_entries": "No audit log entries found", "no_assortments_found": "No assortments found matching your criteria", "no_customers": "No top customers found.", "no_data_message": "

No {message} available

", @@ -829,6 +899,11 @@ "scaffold_product": "Scaffold", "scheduled": "Scheduled", "search": "Search", + "security": "Security", + "audit_log_page_header": "Audit Log", + "audit_log_page_title": "Audit Log", + "settings": "Settings", + "search_messages_placeholder": "Search by message or operation...", "search_product": "Search product", "select_language": "Select Language", "select_options_toggle": "Change {type} status", diff --git a/admin-ui/src/modules/Auth/permissionConfig.ts b/admin-ui/src/modules/Auth/permissionConfig.ts index b8e859fde8..1c42ac60bd 100644 --- a/admin-ui/src/modules/Auth/permissionConfig.ts +++ b/admin-ui/src/modules/Auth/permissionConfig.ts @@ -34,6 +34,7 @@ const ROUTE_ROLES: Record = { '/tokens': 'viewTokens', '/copilot': 'viewProducts', '/exports': 'viewWorkQueue', + '/activities/audit-log': 'viewAuditLog', }; const UNRESTRICTED_PAGES = [ diff --git a/admin-ui/src/modules/apollo/utils/typepolicies.ts b/admin-ui/src/modules/apollo/utils/typepolicies.ts index a7e224ee47..1f8ac744f8 100644 --- a/admin-ui/src/modules/apollo/utils/typepolicies.ts +++ b/admin-ui/src/modules/apollo/utils/typepolicies.ts @@ -112,6 +112,16 @@ const keyMappings: any = { 'paymentProviderIds', ]), }, + auditLogs: { + ...offsetLimitPagination([ + 'classUids', + 'userId', + 'success', + 'from', + 'to', + 'queryText', + ]), + }, }, }, }; diff --git a/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx b/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx new file mode 100644 index 0000000000..d6e2ad7107 --- /dev/null +++ b/admin-ui/src/modules/audit/components/AuditEntryDetail.tsx @@ -0,0 +1,174 @@ +import { useIntl } from 'react-intl'; +import { useOcsfLabels } from './ocsf-labels'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { IAuditLogEntryFragment } from '@/gql/types'; + +const AuditEntryDetail = ({ + entry, + onClose, +}: { + entry: IAuditLogEntryFragment | null; + onClose: () => void; +}) => { + const { formatMessage, formatDate, formatTime } = useIntl(); + const { CLASS_LABELS, SEVERITY_LABELS, STATUS_LABELS, getActivityName } = + useOcsfLabels(); + + if (!entry) return null; + + const changedData = entry.raw?.unmapped?.data; + + const sections = [ + { + label: formatMessage({ + id: 'audit_detail_event', + defaultMessage: 'Event', + }), + rows: [ + ['Class', CLASS_LABELS[entry.className] || entry.className], + ['Activity', getActivityName(entry.classUid, entry.activityId)], + ['Type UID', entry.typeUid], + ['Severity', SEVERITY_LABELS[entry.severityId] || entry.severityId], + ['Status', STATUS_LABELS[entry.statusId] || entry.statusId], + entry.statusDetail && ['Status Detail', entry.statusDetail], + ['Message', entry.message], + [ + 'Time', + `${formatDate(entry.time, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} ${formatTime(entry.time, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })}`, + ], + ].filter(Boolean), + }, + entry.actor && { + label: formatMessage({ + id: 'audit_detail_actor', + defaultMessage: 'Actor', + }), + rows: [ + entry.actor.user?.uid && ['User ID', entry.actor.user.uid], + entry.actor.user?.name && ['Name', entry.actor.user.name], + entry.actor.user?.emailAddr && ['Email', entry.actor.user.emailAddr], + entry.actor.session?.uid && ['Session', entry.actor.session.uid], + ].filter(Boolean), + }, + entry.srcEndpoint && { + label: formatMessage({ + id: 'audit_detail_source', + defaultMessage: 'Source Endpoint', + }), + rows: [ + entry.srcEndpoint.ip && ['IP', entry.srcEndpoint.ip], + entry.srcEndpoint.port && ['Port', entry.srcEndpoint.port], + ].filter(Boolean), + }, + entry.api && { + label: formatMessage({ id: 'audit_detail_api', defaultMessage: 'API' }), + rows: [ + entry.api.operation && ['Operation', entry.api.operation], + entry.api.request?.uid && ['Request ID', entry.api.request.uid], + entry.api.response?.code && ['Response Code', entry.api.response.code], + ].filter(Boolean), + }, + { + label: formatMessage({ + id: 'audit_detail_chain', + defaultMessage: 'Hash Chain', + }), + rows: [ + ['Sequence #', entry.sequenceNumber], + entry.hash && ['Hash', entry.hash], + entry.prevHash && ['Previous Hash', entry.prevHash], + ].filter(Boolean), + }, + ].filter(Boolean); + + return ( +
+
+

+ {formatMessage({ + id: 'audit_entry_detail', + defaultMessage: 'Audit Entry Detail', + })} +

+ +
+
+ {sections.map((section: any, i) => ( +
+

+ {section.label} +

+
+ {section.rows.map(([key, value]: [string, any], j: number) => ( +
+
+ {key} +
+
+ {String(value ?? '—')} +
+
+ ))} +
+
+ ))} + + {changedData && ( +
+

+ {formatMessage({ + id: 'audit_detail_changed_data', + defaultMessage: 'Changed Data', + })} +

+
+ {Object.entries(changedData).map(([field, value]) => ( +
+

+ {field} +

+
+                    {typeof value === 'object'
+                      ? JSON.stringify(value, null, 2)
+                      : String(value)}
+                  
+
+ ))} +
+
+ )} + +
+

+ {formatMessage({ + id: 'audit_detail_raw', + defaultMessage: 'Raw OCSF Event', + })} +

+
+            {JSON.stringify(entry.raw, null, 2)}
+          
+
+
+
+ ); +}; + +export default AuditEntryDetail; diff --git a/admin-ui/src/modules/audit/components/AuditLogFilters.tsx b/admin-ui/src/modules/audit/components/AuditLogFilters.tsx new file mode 100644 index 0000000000..8bcd5deca6 --- /dev/null +++ b/admin-ui/src/modules/audit/components/AuditLogFilters.tsx @@ -0,0 +1,292 @@ +import { useRef } from 'react'; +import { useIntl } from 'react-intl'; +import { useRouter } from 'next/router'; +import StatusFilter from '../../common/components/StatusFilter'; +import DateInputField from '@/components/ui/DateInput'; +import useFormatDateTime from '../../common/utils/useFormatDateTime'; +import { normalizeQuery } from '../../common/utils/utils'; +const AuditLogFilters = () => { + const { formatMessage } = useIntl(); + + const CLASS_OPTIONS = [ + { + value: '3002', + label: formatMessage({ + id: 'audit_class_authentication', + defaultMessage: 'Authentication', + }), + }, + { + value: '3001', + label: formatMessage({ + id: 'audit_class_account_change', + defaultMessage: 'Account Change', + }), + }, + { + value: '6003', + label: formatMessage({ + id: 'audit_class_api_activity', + defaultMessage: 'API Activity', + }), + }, + ]; + + const STATUS_OPTIONS = [ + formatMessage({ id: 'audit_status_success', defaultMessage: 'Success' }), + formatMessage({ id: 'audit_status_failure', defaultMessage: 'Failure' }), + ]; + const { parseDate } = useFormatDateTime(); + const router = useRouter(); + const { query, push } = router; + const searchTimerRef = useRef>(null); + const userIdTimerRef = useRef>(null); + const routerRef = useRef(router); + routerRef.current = router; + + const selectedStatuses = (() => { + if (query.success === 'true') return ['Success']; + if (query.success === 'false') return ['Failure']; + return []; + })(); + + const onStatusChange = (statuses: string[]) => { + const { success, ...rest } = query; + if (statuses.length === 0 || statuses.length === 2) { + push({ query: rest }, undefined, { shallow: true }); + } else if (statuses.includes('Success')) { + push({ query: { ...rest, success: 'true' } }, undefined, { + shallow: true, + }); + } else { + push({ query: { ...rest, success: 'false' } }, undefined, { + shallow: true, + }); + } + }; + + const appliedClassLabels = ((query.classUids as string) || '') + .split(',') + .filter(Boolean) + .map((uid) => CLASS_OPTIONS.find((o) => o.value === uid)?.label) + .filter(Boolean) as string[]; + + const onClassChange = (labels: string[]) => { + const { classUids, ...rest } = query; + if (labels.length === 0) { + push({ query: rest }, undefined, { shallow: true }); + } else { + const uids = labels + .map((l) => CLASS_OPTIONS.find((o) => o.label === l)?.value) + .filter(Boolean) + .join(','); + push({ query: { ...rest, classUids: uids } }, undefined, { + shallow: true, + }); + } + }; + + return ( +
+
+
+ {formatMessage({ + id: 'filter_by_date', + defaultMessage: 'Filter by date', + })} +
+
+ + { + if (value) { + push({ + query: normalizeQuery( + query, + new Date(value).toISOString(), + 'from', + ), + }); + } else { + const { from, ...rest } = query; + push({ query: { ...rest } }); + } + }} + placeholder={formatMessage({ + id: 'start_date', + defaultMessage: 'Start date', + })} + value={query?.from ? parseDate(query?.from) : null} + containerClassName="w-full" + /> + + { + if (value) { + push({ + query: normalizeQuery( + query, + new Date(value).toISOString(), + 'to', + ), + }); + } else { + const { to, ...rest } = query; + push({ query: { ...rest } }); + } + }} + placeholder={formatMessage({ + id: 'end_date', + defaultMessage: 'End Date', + })} + value={query?.to ? parseDate(query?.to) : parseDate(new Date())} + containerClassName="w-full" + /> +
+
+ +
+
+
+ {formatMessage({ id: 'class', defaultMessage: 'Class' })} +
+
+ o.label)} + /> +
+
+ +
+
+ {formatMessage({ id: 'status', defaultMessage: 'Status' })} +
+
+ +
+
+
+ +
+
+ +
+
+ +
+ { + const value = e.target.value; + if (searchTimerRef.current) + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + const { query: currentQuery, push: currentPush } = + routerRef.current; + const { queryText, ...rest } = currentQuery; + if (value) { + currentPush( + { query: { ...rest, queryText: value } }, + undefined, + { + shallow: true, + }, + ); + } else { + currentPush({ query: rest }, undefined, { shallow: true }); + } + }, 300); + }} + /> +
+
+ +
+ +
+ { + const value = e.target.value; + if (userIdTimerRef.current) + clearTimeout(userIdTimerRef.current); + userIdTimerRef.current = setTimeout(() => { + const { query: currentQuery, push: currentPush } = + routerRef.current; + const { userId, ...rest } = currentQuery; + if (value) { + currentPush( + { query: { ...rest, userId: value } }, + undefined, + { + shallow: true, + }, + ); + } else { + currentPush({ query: rest }, undefined, { shallow: true }); + } + }, 300); + }} + /> +
+
+
+
+ ); +}; + +export default AuditLogFilters; diff --git a/admin-ui/src/modules/audit/components/AuditLogTable.tsx b/admin-ui/src/modules/audit/components/AuditLogTable.tsx new file mode 100644 index 0000000000..71bacc2580 --- /dev/null +++ b/admin-ui/src/modules/audit/components/AuditLogTable.tsx @@ -0,0 +1,141 @@ +import Link from 'next/link'; +import { useIntl } from 'react-intl'; +import type { IAuditLogEntryFragment } from '@/gql/types'; +import { CLASS_COLORS, useOcsfLabels } from './ocsf-labels'; + +const AuditLogTable = ({ + entries, + onSelectEntry, +}: { + entries: IAuditLogEntryFragment[]; + onSelectEntry: (entry: IAuditLogEntryFragment) => void; +}) => { + const { formatMessage, formatDate, formatTime } = useIntl(); + const { CLASS_LABELS, STATUS_LABELS, getActivityName } = useOcsfLabels(); + + if (!entries?.length) { + return ( +

+ {formatMessage({ + id: 'no_audit_entries', + defaultMessage: 'No audit log entries found', + })} +

+ ); + } + + return ( +
+ + + + + + + + + + + + + {entries.map((entry) => { + const userName = + entry.actor?.user?.name || + entry.actor?.user?.emailAddr || + entry.actor?.user?.uid || + '—'; + const statusLabel = STATUS_LABELS[entry.statusId] || 'Unknown'; + const isFailure = entry.statusId === 2; + const classColor = + CLASS_COLORS[entry.className] || + 'bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-200'; + + return ( + onSelectEntry(entry)} + > + + + + + + + + ); + })} + +
+ {formatMessage({ id: 'audit_time', defaultMessage: 'Time' })} + + {formatMessage({ id: 'audit_class', defaultMessage: 'Class' })} + + {formatMessage({ + id: 'audit_activity', + defaultMessage: 'Activity', + })} + + {formatMessage({ + id: 'audit_message', + defaultMessage: 'Message', + })} + + {formatMessage({ id: 'audit_user', defaultMessage: 'User' })} + + {formatMessage({ + id: 'audit_status', + defaultMessage: 'Status', + })} +
+ {formatDate(entry.time, { + month: 'short', + day: 'numeric', + })}{' '} + {formatTime(entry.time, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + + {CLASS_LABELS[entry.className] || entry.className} + + + {getActivityName(entry.classUid, entry.activityId)} + + {entry.message || '—'} + + {entry.actor?.user?.uid ? ( + e.stopPropagation()} + > + {userName} + + ) : ( + userName + )} + + + {statusLabel} + +
+
+ ); +}; + +export default AuditLogTable; diff --git a/admin-ui/src/modules/audit/components/ChainStatusBanner.tsx b/admin-ui/src/modules/audit/components/ChainStatusBanner.tsx new file mode 100644 index 0000000000..4f1b01664f --- /dev/null +++ b/admin-ui/src/modules/audit/components/ChainStatusBanner.tsx @@ -0,0 +1,103 @@ +import { useIntl } from 'react-intl'; +import useAuditChainStatus from '../hooks/useAuditChainStatus'; +import { + CheckCircleIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline'; + +const ChainStatusBanner = () => { + const { formatMessage } = useIntl(); + const { chainStatus, loading, error, refetch } = useAuditChainStatus(); + + if (error) { + return ( +
+
+ +

+ {formatMessage({ + id: 'audit_chain_error', + defaultMessage: 'Failed to load audit chain status', + })} +

+
+
+ ); + } + + if (loading || !chainStatus) return null; + + const isValid = chainStatus.valid; + + return ( +
+
+
+ {isValid ? ( + + ) : ( + + )} +
+

+ {isValid + ? formatMessage({ + id: 'audit_chain_valid', + defaultMessage: 'Audit chain integrity verified', + }) + : formatMessage({ + id: 'audit_chain_invalid', + defaultMessage: 'Audit chain integrity compromised', + })} +

+

+ {formatMessage( + { + id: 'audit_chain_entries', + defaultMessage: '{checked} of {total} entries verified', + }, + { + checked: chainStatus.checkedEntries, + total: chainStatus.totalEntries, + }, + )} +

+
+
+ +
+ {chainStatus.errors?.length > 0 && ( +
+ {chainStatus.errors.map((err, i) => ( +

+ #{err.sequenceNumber}: {err.message} +

+ ))} +
+ )} +
+ ); +}; + +export default ChainStatusBanner; diff --git a/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx b/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx new file mode 100644 index 0000000000..ddcdb60e1e --- /dev/null +++ b/admin-ui/src/modules/audit/components/FailedLoginsWidget.tsx @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; +import { useIntl } from 'react-intl'; +import useFailedLoginAttempts from '../hooks/useFailedLoginAttempts'; +import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; + +const FailedLoginsWidget = () => { + const { formatMessage } = useIntl(); + const { oneDayAgo, oneWeekAgo } = useMemo(() => { + const now = Date.now(); + return { + oneDayAgo: now - 24 * 60 * 60 * 1000, + oneWeekAgo: now - 7 * 24 * 60 * 60 * 1000, + }; + }, []); + + const { + failedLoginAttempts: last24h, + loading: loading24h, + error: error24h, + } = useFailedLoginAttempts({ since: oneDayAgo }); + const { + failedLoginAttempts: last7d, + loading: loading7d, + error: error7d, + } = useFailedLoginAttempts({ since: oneWeekAgo }); + + const loading = loading24h || loading7d; + const error = error24h || error7d; + + return ( +
+
+ +

+ {formatMessage({ + id: 'failed_login_attempts', + defaultMessage: 'Failed login attempts', + })} +

+
+ {error ? ( +

+ {formatMessage({ + id: 'failed_login_error', + defaultMessage: 'Failed to load login data', + })} +

+ ) : loading ? ( +
+ ) : ( +
+
+

+ {last24h} +

+

+ {formatMessage({ + id: 'last_24_hours', + defaultMessage: 'Last 24 hours', + })} +

+
+
+

+ {last7d} +

+

+ {formatMessage({ + id: 'last_7_days', + defaultMessage: 'Last 7 days', + })} +

+
+
+ )} +
+ ); +}; + +export default FailedLoginsWidget; diff --git a/admin-ui/src/modules/audit/components/ocsf-labels.ts b/admin-ui/src/modules/audit/components/ocsf-labels.ts new file mode 100644 index 0000000000..7f98303a22 --- /dev/null +++ b/admin-ui/src/modules/audit/components/ocsf-labels.ts @@ -0,0 +1,173 @@ +import { useIntl } from 'react-intl'; + +export const CLASS_COLORS: Record = { + AUTHENTICATION: + 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + ACCOUNT_CHANGE: + 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200', + API_ACTIVITY: + 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200', +}; + +export function useOcsfLabels() { + const { formatMessage } = useIntl(); + + const CLASS_LABELS: Record = { + ACCOUNT_CHANGE: formatMessage({ + id: 'audit_class_account_change', + defaultMessage: 'Account Change', + }), + AUTHENTICATION: formatMessage({ + id: 'audit_class_authentication', + defaultMessage: 'Authentication', + }), + API_ACTIVITY: formatMessage({ + id: 'audit_class_api_activity', + defaultMessage: 'API Activity', + }), + }; + + const SEVERITY_LABELS: Record = { + 0: formatMessage({ + id: 'audit_severity_unknown', + defaultMessage: 'Unknown', + }), + 1: formatMessage({ + id: 'audit_severity_informational', + defaultMessage: 'Informational', + }), + 2: formatMessage({ id: 'audit_severity_low', defaultMessage: 'Low' }), + 3: formatMessage({ id: 'audit_severity_medium', defaultMessage: 'Medium' }), + 4: formatMessage({ id: 'audit_severity_high', defaultMessage: 'High' }), + 5: formatMessage({ + id: 'audit_severity_critical', + defaultMessage: 'Critical', + }), + 6: formatMessage({ id: 'audit_severity_fatal', defaultMessage: 'Fatal' }), + 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), + }; + + const STATUS_LABELS: Record = { + 0: formatMessage({ + id: 'audit_severity_unknown', + defaultMessage: 'Unknown', + }), + 1: formatMessage({ id: 'audit_status_success', defaultMessage: 'Success' }), + 2: formatMessage({ id: 'audit_status_failure', defaultMessage: 'Failure' }), + 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), + }; + + const AUTH_ACTIVITIES: Record = { + 0: formatMessage({ + id: 'audit_severity_unknown', + defaultMessage: 'Unknown', + }), + 1: formatMessage({ id: 'audit_activity_logon', defaultMessage: 'Logon' }), + 2: formatMessage({ id: 'audit_activity_logoff', defaultMessage: 'Logoff' }), + 3: formatMessage({ + id: 'audit_activity_auth_ticket', + defaultMessage: 'Authentication Ticket', + }), + 4: formatMessage({ + id: 'audit_activity_service_ticket_request', + defaultMessage: 'Service Ticket Request', + }), + 5: formatMessage({ + id: 'audit_activity_service_ticket_renew', + defaultMessage: 'Service Ticket Renew', + }), + 6: formatMessage({ + id: 'audit_activity_pre_auth', + defaultMessage: 'Pre-Auth', + }), + 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), + }; + + const ACCOUNT_ACTIVITIES: Record = { + 0: formatMessage({ + id: 'audit_severity_unknown', + defaultMessage: 'Unknown', + }), + 1: formatMessage({ id: 'audit_activity_create', defaultMessage: 'Create' }), + 2: formatMessage({ id: 'audit_activity_enable', defaultMessage: 'Enable' }), + 3: formatMessage({ + id: 'audit_activity_password_change', + defaultMessage: 'Password Change', + }), + 4: formatMessage({ + id: 'audit_activity_password_reset', + defaultMessage: 'Password Reset', + }), + 5: formatMessage({ + id: 'audit_activity_disable', + defaultMessage: 'Disable', + }), + 6: formatMessage({ id: 'audit_activity_delete', defaultMessage: 'Delete' }), + 7: formatMessage({ + id: 'audit_activity_attach_policy', + defaultMessage: 'Attach Policy', + }), + 8: formatMessage({ + id: 'audit_activity_detach_policy', + defaultMessage: 'Detach Policy', + }), + 9: formatMessage({ id: 'audit_activity_lock', defaultMessage: 'Lock' }), + 10: formatMessage({ + id: 'audit_activity_mfa_enable', + defaultMessage: 'MFA Enable', + }), + 11: formatMessage({ + id: 'audit_activity_mfa_disable', + defaultMessage: 'MFA Disable', + }), + 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), + }; + + const API_ACTIVITIES: Record = { + 0: formatMessage({ + id: 'audit_severity_unknown', + defaultMessage: 'Unknown', + }), + 1: formatMessage({ id: 'audit_activity_create', defaultMessage: 'Create' }), + 2: formatMessage({ id: 'audit_activity_read', defaultMessage: 'Read' }), + 3: formatMessage({ id: 'audit_activity_update', defaultMessage: 'Update' }), + 4: formatMessage({ id: 'audit_activity_delete', defaultMessage: 'Delete' }), + 90: formatMessage({ + id: 'audit_activity_checkout', + defaultMessage: 'Checkout', + }), + 91: formatMessage({ + id: 'audit_activity_payment', + defaultMessage: 'Payment', + }), + 92: formatMessage({ + id: 'audit_activity_refund', + defaultMessage: 'Refund', + }), + 93: formatMessage({ + id: 'audit_activity_export', + defaultMessage: 'Export', + }), + 94: formatMessage({ + id: 'audit_activity_import', + defaultMessage: 'Import', + }), + 95: formatMessage({ + id: 'audit_activity_access_denied', + defaultMessage: 'Access Denied', + }), + 99: formatMessage({ id: 'audit_severity_other', defaultMessage: 'Other' }), + }; + + const ACTIVITY_MAP: Record> = { + 3002: AUTH_ACTIVITIES, + 3001: ACCOUNT_ACTIVITIES, + 6003: API_ACTIVITIES, + }; + + const getActivityName = (classUid: number, activityId: number): string => { + return ACTIVITY_MAP[classUid]?.[activityId] || `Activity ${activityId}`; + }; + + return { CLASS_LABELS, SEVERITY_LABELS, STATUS_LABELS, getActivityName }; +} diff --git a/admin-ui/src/modules/audit/fragments/AuditLogEntryFragment.ts b/admin-ui/src/modules/audit/fragments/AuditLogEntryFragment.ts new file mode 100644 index 0000000000..50a1e3a8ca --- /dev/null +++ b/admin-ui/src/modules/audit/fragments/AuditLogEntryFragment.ts @@ -0,0 +1,47 @@ +import { gql } from '@apollo/client'; + +const AuditLogEntryFragment = gql` + fragment AuditLogEntryFragment on AuditLogEntry { + id + time + message + classUid + className + activityId + activityName + typeUid + categoryUid + severityId + statusId + statusDetail + actor { + user { + uid + name + emailAddr + } + session { + uid + } + } + srcEndpoint { + ip + port + } + api { + operation + request { + uid + } + response { + code + } + } + sequenceNumber + prevHash + hash + raw + } +`; + +export default AuditLogEntryFragment; diff --git a/admin-ui/src/modules/audit/hooks/useAuditChainStatus.ts b/admin-ui/src/modules/audit/hooks/useAuditChainStatus.ts new file mode 100644 index 0000000000..44f3487f22 --- /dev/null +++ b/admin-ui/src/modules/audit/hooks/useAuditChainStatus.ts @@ -0,0 +1,38 @@ +import { + IAuditChainStatusQuery, + IAuditChainStatusQueryVariables, +} from '@/gql/types'; +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; + +const AuditChainStatusQuery = gql` + query AuditChainStatus { + auditChainStatus { + valid + totalEntries + checkedEntries + firstEntry + lastEntry + errors { + sequenceNumber + message + } + } + } +`; + +const useAuditChainStatus = () => { + const { data, loading, error, refetch } = useQuery< + IAuditChainStatusQuery, + IAuditChainStatusQueryVariables + >(AuditChainStatusQuery); + + return { + chainStatus: data?.auditChainStatus, + loading, + error, + refetch, + }; +}; + +export default useAuditChainStatus; diff --git a/admin-ui/src/modules/audit/hooks/useAuditLogs.ts b/admin-ui/src/modules/audit/hooks/useAuditLogs.ts new file mode 100644 index 0000000000..1044159611 --- /dev/null +++ b/admin-ui/src/modules/audit/hooks/useAuditLogs.ts @@ -0,0 +1,91 @@ +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; +import AuditLogEntryFragment from '../fragments/AuditLogEntryFragment'; +import { IAuditLogsQuery, IAuditLogsQueryVariables } from '@/gql/types'; + +const AuditLogsQuery = gql` + query AuditLogs( + $limit: Int + $offset: Int + $classUids: [Int!] + $userId: String + $success: Boolean + $from: Timestamp + $to: Timestamp + $queryText: String + ) { + auditLogs( + limit: $limit + offset: $offset + classUids: $classUids + userId: $userId + success: $success + from: $from + to: $to + queryText: $queryText + ) { + ...AuditLogEntryFragment + } + auditLogsCount( + classUids: $classUids + userId: $userId + success: $success + from: $from + to: $to + queryText: $queryText + ) + } + ${AuditLogEntryFragment} +`; + +const useAuditLogs = ({ + limit = 50, + offset = 0, + classUids = null, + userId = null, + success = null, + from = null, + to = null, + queryText = null, +}: IAuditLogsQueryVariables = {}) => { + const { data, loading, error, fetchMore, previousData, networkStatus } = + useQuery(AuditLogsQuery, { + variables: { + limit, + offset, + classUids, + userId, + success, + from, + to, + queryText, + }, + notifyOnNetworkStatusChange: true, + }); + + const isFetchingMore = networkStatus === 3; + const auditLogs = + data?.auditLogs || (isFetchingMore ? previousData?.auditLogs : null) || []; + const auditLogsCount = + data?.auditLogsCount ?? + (isFetchingMore ? previousData?.auditLogsCount : null) ?? + 0; + const hasMore = auditLogs.length > 0 && auditLogs.length < auditLogsCount; + + const loadMore = () => { + if (loading || isFetchingMore) return; + fetchMore({ variables: { offset: auditLogs.length } }); + }; + + return { + auditLogs, + auditLogsCount, + hasMore, + loadMore, + loading: loading && !isFetchingMore, + loadingMore: isFetchingMore, + error, + }; +}; + +export default useAuditLogs; diff --git a/admin-ui/src/modules/audit/hooks/useFailedLoginAttempts.ts b/admin-ui/src/modules/audit/hooks/useFailedLoginAttempts.ts new file mode 100644 index 0000000000..e635a839e2 --- /dev/null +++ b/admin-ui/src/modules/audit/hooks/useFailedLoginAttempts.ts @@ -0,0 +1,42 @@ +import { + IFailedLoginAttemptsQuery, + IFailedLoginAttemptsQueryVariables, +} from '@/gql/types'; +import { gql } from '@apollo/client'; +import { useQuery } from '@apollo/client/react'; + +const FailedLoginAttemptsQuery = gql` + query FailedLoginAttempts( + $userId: String + $remoteAddress: String + $since: Timestamp + ) { + failedLoginAttempts( + userId: $userId + remoteAddress: $remoteAddress + since: $since + ) + } +`; + +const useFailedLoginAttempts = ({ + userId = null, + remoteAddress = null, + since = null, +}: IFailedLoginAttemptsQueryVariables = {}) => { + const { data, loading, error, refetch } = useQuery< + IFailedLoginAttemptsQuery, + IFailedLoginAttemptsQueryVariables + >(FailedLoginAttemptsQuery, { + variables: { userId, remoteAddress, since }, + }); + + return { + failedLoginAttempts: data?.failedLoginAttempts ?? 0, + loading, + error, + refetch, + }; +}; + +export default useFailedLoginAttempts; diff --git a/admin-ui/src/modules/audit/index.ts b/admin-ui/src/modules/audit/index.ts new file mode 100644 index 0000000000..9c8adab9b8 --- /dev/null +++ b/admin-ui/src/modules/audit/index.ts @@ -0,0 +1,3 @@ +export { default as useAuditLogs } from './hooks/useAuditLogs'; +export { default as useAuditChainStatus } from './hooks/useAuditChainStatus'; +export { default as useFailedLoginAttempts } from './hooks/useFailedLoginAttempts'; diff --git a/admin-ui/src/modules/common/components/InfiniteScroll.tsx b/admin-ui/src/modules/common/components/InfiniteScroll.tsx index dac34f3071..9d0faa4636 100644 --- a/admin-ui/src/modules/common/components/InfiniteScroll.tsx +++ b/admin-ui/src/modules/common/components/InfiniteScroll.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useCallback } from 'react'; +import { useEffect, useRef } from 'react'; import Loading from '@/components/ui/Loading'; interface InfiniteScrollProps { @@ -17,36 +17,35 @@ const InfiniteScroll = ({ threshold = 200, }: InfiniteScrollProps) => { const sentinelRef = useRef(null); + const loadingRef = useRef(loading); + const hasMoreRef = useRef(hasMore); + const onLoadMoreRef = useRef(onLoadMore); - const handleIntersect = useCallback( - (entries: IntersectionObserverEntry[]) => { - const [entry] = entries; - if (entry.isIntersecting && hasMore && !loading) { - onLoadMore(); - } - }, - [hasMore, loading, onLoadMore], - ); + loadingRef.current = loading; + hasMoreRef.current = hasMore; + onLoadMoreRef.current = onLoadMore; useEffect(() => { const sentinel = sentinelRef.current; if (!sentinel) return; - const observer = new IntersectionObserver(handleIntersect, { - rootMargin: `${threshold}px`, - }); + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && hasMoreRef.current && !loadingRef.current) { + onLoadMoreRef.current(); + } + }, + { rootMargin: `${threshold}px` }, + ); observer.observe(sentinel); - - return () => { - observer.disconnect(); - }; - }, [handleIntersect, threshold]); + return () => observer.disconnect(); + }, [threshold]); return ( <> {children} -
+ {hasMore &&
} {loading && (
diff --git a/admin-ui/src/modules/common/components/Layout.tsx b/admin-ui/src/modules/common/components/Layout.tsx index 5b68d604b0..cad2181654 100644 --- a/admin-ui/src/modules/common/components/Layout.tsx +++ b/admin-ui/src/modules/common/components/Layout.tsx @@ -316,7 +316,15 @@ const Layout = ({ href: '/events', requiredRole: 'viewEvents', }, - ], + isSystemReady && { + name: formatMessage({ + id: 'audit_log', + defaultMessage: 'Audit Log', + }), + requiredRole: 'viewAuditLog', + href: '/activities/audit-log', + }, + ].filter(Boolean), }, shopInfo?.adminUiConfig?.externalLinks?.length && { _sortOrder: 130, diff --git a/admin-ui/src/pages/activities/audit-log.tsx b/admin-ui/src/pages/activities/audit-log.tsx new file mode 100644 index 0000000000..9fdcb6f9dc --- /dev/null +++ b/admin-ui/src/pages/activities/audit-log.tsx @@ -0,0 +1,159 @@ +import { useState, useCallback } from 'react'; +import { useIntl } from 'react-intl'; +import { useRouter } from 'next/router'; +import BreadCrumbs from '@/components/ui/BreadCrumbs'; +import PageHeader from '@/components/ui/PageHeader'; +import Loading from '@/components/ui/Loading'; +import Button from '@/components/ui/Button'; +import InfiniteScroll from '../../modules/common/components/InfiniteScroll'; +import ChainStatusBanner from '../../modules/audit/components/ChainStatusBanner'; +import FailedLoginsWidget from '../../modules/audit/components/FailedLoginsWidget'; +import AuditLogFilters from '../../modules/audit/components/AuditLogFilters'; +import AuditLogTable from '../../modules/audit/components/AuditLogTable'; +import AuditEntryDetail from '../../modules/audit/components/AuditEntryDetail'; +import useAuditLogs from '../../modules/audit/hooks/useAuditLogs'; +import { useCSVExport } from '../../modules/common/hooks/useCSVExport'; + +const AuditLogPage = () => { + const { formatMessage } = useIntl(); + const { query } = useRouter(); + const [selectedEntry, setSelectedEntry] = useState(null); + + const classUids = query.classUids + ? (query.classUids as string).split(',').map((s) => parseInt(s, 10)) + : null; + const success = + query.success === 'true' ? true : query.success === 'false' ? false : null; + const userId = (query.userId as string) || null; + const queryText = (query.queryText as string) || null; + const from = query.from ? new Date(query.from as string).getTime() : null; + const to = query.to + ? new Date(query.to as string).getTime() + 86400000 + : null; + + const { auditLogs, auditLogsCount, loading, loadingMore, hasMore, loadMore } = + useAuditLogs({ + limit: 50, + classUids, + success, + userId, + queryText, + from, + to, + }); + + const { exportCSV, isExporting } = useCSVExport(); + + const exportAuditLogs = useCallback( + async (format: 'csv' | 'jsonl') => { + await exportCSV({ + type: 'AUDIT_LOGS', + exportCSV: format === 'csv', + exportJSONL: format === 'jsonl', + ...(classUids ? { classUids } : {}), + ...(userId ? { userId } : {}), + ...(success !== null ? { success } : {}), + ...(from ? { from } : {}), + ...(to ? { to } : {}), + ...(queryText ? { queryText } : {}), + }); + }, + [exportCSV, classUids, userId, success, from, to, queryText], + ); + + return ( + <> + + +