portal-mojo 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/docs/DetailView.md +19 -15
  2. package/docs/README.md +1 -0
  3. package/docs/admin-assistant.md +3 -1
  4. package/docs/admin-credentials.md +7 -0
  5. package/docs/admin-dashboard.md +1 -1
  6. package/docs/admin-dns.md +8 -2
  7. package/docs/admin-incidents.md +26 -3
  8. package/docs/admin-jobs.md +7 -4
  9. package/docs/admin-messaging.md +6 -5
  10. package/docs/admin-modals.md +122 -0
  11. package/docs/admin-network-security.md +4 -4
  12. package/docs/admin-phonehub.md +13 -5
  13. package/docs/admin-push.md +7 -0
  14. package/docs/admin-rules.md +8 -0
  15. package/docs/admin-shortlinks.md +2 -1
  16. package/docs/admin-storage.md +2 -1
  17. package/docs/auth.md +28 -0
  18. package/docs/client.md +12 -0
  19. package/docs/feedback.md +10 -0
  20. package/docs/forms.md +10 -0
  21. package/docs/popover.md +15 -0
  22. package/package.json +1 -1
  23. package/src/admin/assistant/api.ts +1 -1
  24. package/src/admin/assistant/pages.tsx +71 -17
  25. package/src/admin/assistant/skill-lifecycle.ts +9 -0
  26. package/src/admin/core/index.ts +7 -2
  27. package/src/admin/credentials/group-api-keys.tsx +47 -43
  28. package/src/admin/credentials/webhook-subscriptions.tsx +28 -40
  29. package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
  30. package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
  31. package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
  32. package/src/admin/dns/setup.ts +1 -1
  33. package/src/admin/domains/observability.ts +1 -1
  34. package/src/admin/identity/users/UserDetail.tsx +36 -20
  35. package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
  36. package/src/admin/identity/users/sections/actions.tsx +20 -9
  37. package/src/admin/incidents/EventDetail.tsx +147 -5
  38. package/src/admin/incidents/IncidentDetail.tsx +181 -8
  39. package/src/admin/incidents/models.ts +1 -1
  40. package/src/admin/jobs/RunnerDetail.tsx +5 -8
  41. package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
  42. package/src/admin/jobs/index.ts +1 -1
  43. package/src/admin/jobs/purge-preview.ts +25 -0
  44. package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
  45. package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
  46. package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
  47. package/src/admin/messaging/MailboxesPage.tsx +3 -3
  48. package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
  49. package/src/admin/messaging/push/PushPage.tsx +20 -8
  50. package/src/admin/monitoring/LogInspector.tsx +3 -19
  51. package/src/admin/network/IPSetDetail.tsx +32 -63
  52. package/src/admin/phonehub/PhoneHubPage.tsx +274 -13
  53. package/src/admin/phonehub/SmsComposer.tsx +97 -0
  54. package/src/admin/phonehub/api.ts +6 -0
  55. package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
  56. package/src/admin/rules/condition-removal.ts +7 -0
  57. package/src/admin/security/geoip/models.ts +5 -0
  58. package/src/admin/security/tickets.tsx +40 -17
  59. package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
  60. package/src/admin/storage/BucketDetail.tsx +28 -9
  61. package/src/admin/storage/FileManagerDetail.tsx +23 -5
  62. package/src/admin/storage/FileView.tsx +28 -16
  63. package/src/admin/storage/FilesPage.tsx +4 -5
  64. package/src/client/auth.ts +10 -1
  65. package/src/client/client.ts +14 -6
  66. package/src/client/mock.ts +89 -5
  67. package/src/client/upload.ts +1 -0
  68. package/src/ui/DetailView.tsx +22 -5
  69. package/src/ui/Popover.tsx +13 -12
  70. package/src/ui/modal.tsx +8 -4
@@ -9,7 +9,7 @@ import { SECURITY_VIEW_PERMS } from '../security-permissions';
9
9
 
10
10
  export const ADMIN_DASHBOARD_PERMISSIONS = [...new Set([...DASHBOARD_METRIC_PERMISSIONS, ...SECURITY_VIEW_PERMS, ...JOBS_VIEW_PERMS, ...EMAIL_ADMIN_PERMISSIONS, ...LOGIN_SUMMARY_PERMS])];
11
11
  export const DASHBOARD_ADMIN_SECTION: AdminSection = {
12
- id: 'dashboard', basePath: '', title: 'Overview', icon: 'bi-grid-1x2', navigationGroup: 'overview', permissions: ADMIN_DASHBOARD_PERMISSIONS,
12
+ id: 'dashboard', basePath: '', title: 'Dashboard', icon: 'bi-grid-1x2', navigationGroup: null, permissions: ADMIN_DASHBOARD_PERMISSIONS,
13
13
  routes: [{ path: '', label: 'Dashboard', loadComponent: () => import('../dashboard/AdminDashboardPage').then(({ AdminDashboardPage }) => ({ default: AdminDashboardPage })), permissions: ADMIN_DASHBOARD_PERMISSIONS, fallbackToFirstVisible: true }],
14
14
  };
15
15
  export const MONITORING_ADMIN_SECTION: AdminSection = {
@@ -23,34 +23,49 @@
23
23
  import { useSyncExternalStore } from 'react';
24
24
  import { useCan, useMe } from '../../../client/runtime';
25
25
  import {
26
- DetailView, getFormTabs, subscribeFormTabs, fmt, modal,
27
- type Chip, type DetailMenuEntry,
26
+ DetailView,
27
+ fmt,
28
+ getFormTabs,
29
+ modal,
30
+ subscribeFormTabs,
31
+ type Chip,
32
+ type DetailMenuEntry,
28
33
  } from '../../../ui';
34
+ import { MEMBER_READ_PERMISSIONS } from '../members';
29
35
  import {
30
- USER_DEVICE_PERMISSIONS, USER_EVENT_PERMISSIONS, USER_LOGIN_PERMISSIONS,
31
- USER_LOG_PERMISSIONS, USER_MANAGE_PERMISSIONS, USER_PUSH_DEVICE_PERMISSIONS,
32
- UserModel, type UserRow,
36
+ USER_DEVICE_PERMISSIONS,
37
+ USER_EVENT_PERMISSIONS,
38
+ USER_LOGIN_PERMISSIONS,
39
+ USER_LOG_PERMISSIONS,
40
+ USER_MANAGE_PERMISSIONS,
41
+ USER_PUSH_DEVICE_PERMISSIONS,
42
+ UserModel,
43
+ type UserRow,
33
44
  } from './models';
34
- import { MEMBER_READ_PERMISSIONS } from '../members';
35
45
  import { useUserAdminActions } from './sections/actions';
36
- import { useSharedUserQueries } from './sections/queries';
37
- import {
38
- accountType, inactivityWarning, isAnonymized, isOnline, statusBadge, useAdminCaller,
39
- } from './sections/shared';
40
- import { USER_APP_PERMS_TABSET } from './sections/permission-catalog';
41
- import { OverviewSection } from './sections/OverviewSection';
42
- import { ProfileSection } from './sections/ProfileSection';
43
- import { PersonalSection } from './sections/PersonalSection';
44
- import { SecuritySection } from './sections/SecuritySection';
45
- import { OAuthSection } from './sections/OAuthSection';
46
- import { GroupsSection } from './sections/GroupsSection';
47
- import { AppPermsSection, SysPermsSection } from './sections/PermissionsSection';
48
46
  import { ApiKeysSection } from './sections/ApiKeysSection';
47
+ import { AuditSection } from './sections/AuditSection';
49
48
  import { DevicesSection } from './sections/DevicesSection';
49
+ import { GroupsSection } from './sections/GroupsSection';
50
50
  import { LoginsSection } from './sections/LoginsSection';
51
- import { AuditSection } from './sections/AuditSection';
52
- import { NotificationsSection } from './sections/NotificationsSection';
53
51
  import { UserMetadataSection } from './sections/MetadataPanel';
52
+ import { NotificationsSection } from './sections/NotificationsSection';
53
+ import { OAuthSection } from './sections/OAuthSection';
54
+ import { OverviewSection } from './sections/OverviewSection';
55
+ import { USER_APP_PERMS_TABSET } from './sections/permission-catalog';
56
+ import { AppPermsSection, SysPermsSection } from './sections/PermissionsSection';
57
+ import { PersonalSection } from './sections/PersonalSection';
58
+ import { ProfileSection } from './sections/ProfileSection';
59
+ import { useSharedUserQueries } from './sections/queries';
60
+ import { SecuritySection } from './sections/SecuritySection';
61
+ import {
62
+ accountType,
63
+ inactivityWarning,
64
+ isAnonymized,
65
+ isOnline,
66
+ statusBadge,
67
+ useAdminCaller,
68
+ } from './sections/shared';
54
69
 
55
70
  export interface UserDetailProps {
56
71
  id: number;
@@ -144,6 +159,7 @@ function UserDetailLoaded({ user, isAdmin, hasAppPerms, onClose, onOpenGroup }:
144
159
  // ── Kebab (UserView contextItems at full scope) ───────────────────
145
160
  const ADMIN = USER_MANAGE_PERMISSIONS;
146
161
  const MENU: DetailMenuEntry<UserRow>[] = [
162
+ { label: user.is_active ? 'Deactivate' : 'Reactivate', icon: 'bi-power', permissions: ADMIN, when: u => Boolean(u && !isAnonymized(u)), onSelect: () => void (user.is_active ? actions.disableUser() : actions.reactivateUser()) },
147
163
  { label: 'Edit user', icon: 'bi-pencil', permissions: ADMIN, onSelect: () => void actions.editUser() },
148
164
  {
149
165
  label: 'Manage avatar…', icon: 'bi-image', permissions: ADMIN,
@@ -1,3 +1,5 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useCan, useMe } from '../../../../client/runtime';
1
3
  // OAuth section — AdminConnectedSection port (read in full 2026-08-05):
2
4
  // the user's linked OAuth providers with admin unlink. The same list powers
3
5
  // the Profile card's "manage linked accounts" modal (source Phase 3 shared
@@ -6,23 +8,28 @@
6
8
  // Wire: GET /api/account/oauth_connection?user=<id> → rows
7
9
  // {id, provider, email, is_active, created} (default graph, measured in
8
10
  // django-mojo oauth.py); DELETE /api/account/oauth_connection/<id> unlinks.
9
- import { modal, toast, fmt, Eyebrow } from '../../../../ui';
10
- import { OAuthConnectionModel, type UserRow } from '../models';
11
+ import { Eyebrow, fmt, modal, toast } from '../../../../ui';
12
+ import { OAuthConnectionModel, USER_MANAGE_PERMISSIONS, type UserRow } from '../models';
11
13
  import { providerIcon } from './shared';
12
14
 
13
15
  export function OAuthConnectionList({ userId, canManage = true }: { userId: number; canManage?: boolean }) {
14
16
  const { data, isPending } = OAuthConnectionModel.useList({ user: userId, size: 25, sort: '-created' });
15
17
  const del = OAuthConnectionModel.useDelete();
18
+ const admin = useCan(USER_MANAGE_PERMISSIONS).can;
19
+ const me = useMe().data;
20
+ const allowed = (me?.id === userId || admin) && canManage;
21
+ const permission = useRef(allowed); permission.current = allowed;
22
+ useEffect(() => { permission.current = allowed; return () => { permission.current = false; }; }, [allowed]);
16
23
  const rows = data?.rows ?? [];
17
24
 
18
25
  const unlink = async (id: number, provider: string) => {
19
26
  const ok = await modal.confirm({
20
27
  title: 'Unlink account',
21
- message: <>Unlink <b>{provider}</b> for this user?</>,
28
+ message: <>Unlink <b>{provider}</b> from user #{userId}? This removes the association only; reconnect through provider authorization. The provider account is retained.</>,
22
29
  confirmText: 'Unlink',
23
30
  danger: true,
24
31
  });
25
- if (!ok) return;
32
+ if (!ok || !permission.current) return;
26
33
  try {
27
34
  await del.mutateAsync({ id });
28
35
  toast.success(`${provider} account unlinked`);
@@ -51,8 +58,8 @@ export function OAuthConnectionList({ userId, canManage = true }: { userId: numb
51
58
  {c.email ?? <span className="dim-italic">no email</span>} · Connected {fmt.relative(c.created)}
52
59
  </div>
53
60
  </div>
54
- {canManage && <div className="us-row-actions">
55
- <button className="btn btn-compact us-danger" onClick={() => void unlink(c.id, c.provider)}>
61
+ {allowed && <div className="us-row-actions">
62
+ <button className="btn btn-compact us-danger" disabled={del.isPending} onClick={() => void unlink(c.id, c.provider)}>
56
63
  <i className="bi bi-x-lg" /> Unlink
57
64
  </button>
58
65
  </div>}
@@ -9,16 +9,22 @@
9
9
  // surfaces via the error toast like any failure. auth/email/verify/send is
10
10
  // the public, admin-targetable verification sender (NOT the JWT-scoped
11
11
  // auth/verify/email/send).
12
- import { useEffect, useState } from 'react';
13
12
  import { useQueryClient } from '@tanstack/react-query';
14
- import { mojoCall, withFreshAuth, type Field } from '../../../../client/runtime';
13
+ import { useEffect, useRef, useState } from 'react';
14
+ import { mojoCall, useCan, useMe, withFreshAuth, type Field } from '../../../../client/runtime';
15
15
  import {
16
- Badge, ImageField, PasswordStrengthMeter, fmt, formModal, modal, toast,
16
+ Badge,
17
+ ImageField,
18
+ PasswordStrengthMeter,
19
+ fmt,
20
+ formModal,
21
+ modal,
22
+ toast,
17
23
  type FileFieldOwnerResult,
18
24
  } from '../../../../ui';
19
- import { PasskeyModel, UserModel, type UserRow } from '../models';
20
- import { openGroupDetail, useAdminCaller } from './shared';
25
+ import { PasskeyModel, USER_MANAGE_PERMISSIONS, UserModel, type UserRow } from '../models';
21
26
  import { OAuthConnectionList } from './OAuthSection';
27
+ import { openGroupDetail, useAdminCaller } from './shared';
22
28
 
23
29
  /** One-field prompt modal (the source's Modal.prompt pencils). */
24
30
  async function promptField(title: string, field: Field, initial: string): Promise<string | null> {
@@ -663,6 +669,10 @@ function AvatarModal({ user, onClose, onPendingChange }: { user: UserRow; onClos
663
669
  * friendly_name and is_enabled; delete is real (CAN_DELETE).
664
670
  */
665
671
  function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => void }) {
672
+ const admin = useCan(USER_MANAGE_PERMISSIONS).can;
673
+ const allowed = useMe().data?.id === userId || admin;
674
+ const permission = useRef(allowed); permission.current = allowed;
675
+ useEffect(() => { permission.current = allowed; return () => { permission.current = false; }; }, [allowed]);
666
676
  const { data, isPending } = PasskeyModel.useList({ user: userId, size: 25, sort: '-created' });
667
677
  const save = PasskeyModel.useSave();
668
678
  const del = PasskeyModel.useDelete();
@@ -679,7 +689,7 @@ function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => voi
679
689
  initial: { friendly_name: current ?? '' },
680
690
  });
681
691
  const name = data?.friendly_name;
682
- if (typeof name !== 'string' || !name.trim()) return;
692
+ if (!permission.current || typeof name !== 'string' || !name.trim()) return;
683
693
  try {
684
694
  await save.mutateAsync({ id, changes: { friendly_name: name.trim() } });
685
695
  toast.success('Passkey updated');
@@ -689,6 +699,7 @@ function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => voi
689
699
  };
690
700
 
691
701
  const toggleEnabled = async (id: number, next: boolean) => {
702
+ if (!permission.current) return;
692
703
  try {
693
704
  await save.mutateAsync({ id, changes: { is_enabled: next } });
694
705
  toast.success(next ? 'Passkey enabled' : 'Passkey disabled');
@@ -700,11 +711,11 @@ function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => voi
700
711
  const remove = async (id: number) => {
701
712
  const ok = await modal.confirm({
702
713
  title: 'Delete passkey',
703
- message: 'Delete this passkey?',
714
+ message: `Remove ${rows.find(row => row.id === id)?.friendly_name || 'Unnamed passkey'} for ${rows.find(row => row.id === id)?.rp_id || 'unknown relying party'}, user #${userId}? Reenrollment is required to restore it. The physical device is not erased.`,
704
715
  confirmText: 'Delete',
705
716
  danger: true,
706
717
  });
707
- if (!ok) return;
718
+ if (!ok || !permission.current) return;
708
719
  try {
709
720
  await del.mutateAsync({ id });
710
721
  toast.success('Passkey deleted');
@@ -735,7 +746,7 @@ function PasskeysModal({ userId, onClose }: { userId: number; onClose: () => voi
735
746
  Created {fmt.date(p.created)} · Last used {fmt.relative(p.last_used, 'never')} · {p.sign_count} uses
736
747
  </div>
737
748
  </div>
738
- <div className="us-row-actions">
749
+ <div className="us-row-actions" inert={!allowed || save.isPending || del.isPending}>
739
750
  <button className="btn-icon btn-icon-sm" title="Rename" aria-label={`Rename ${p.friendly_name ?? 'passkey'}`} onClick={() => void rename(p.id, p.friendly_name)}>
740
751
  <i className="bi bi-pencil" />
741
752
  </button>
@@ -1,18 +1,157 @@
1
- import { DetailView, Eyebrow, FlatRow, KnownFieldsCard, StatusPanel, fmt } from '../../ui';
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { mojoCall } from '../../client/runtime';
3
+ import { DetailView, Eyebrow, FlatRow, JsonBlock, KnownFieldsCard, StatusPanel, fmt, type KnownField } from '../../ui';
2
4
  import { EvidenceCard, INCIDENT_METADATA_FIELDS, RequestResponseForensics, TraceForensics, first, metadataOf } from './forensics';
3
- import { showIncidentDetail, useEventDetail } from './models';
5
+ import { sanitizeEventRow } from './sanitize';
6
+ import { EventModel, showIncidentDetail, type EventRow } from './models';
7
+
8
+ const MOJOSEC_SUMMARY_FIELDS: KnownField[] = [
9
+ { key: 'kind', label: 'Detection kind', hideEmpty: true },
10
+ { key: 'count', label: 'Occurrences', hideEmpty: true },
11
+ { key: 'event_id', label: 'Sensor event ID', hideEmpty: true },
12
+ { key: 'sensor_id', label: 'Sensor', hideEmpty: true },
13
+ { key: 'sensor_severity', label: 'Sensor severity', hideEmpty: true },
14
+ { key: 'effective_level', label: 'Effective level', hideEmpty: true },
15
+ { key: 'recommendation', label: 'Recommendation', hideEmpty: true },
16
+ { key: 'first_seen', label: 'First seen', format: 'datetime', hideEmpty: true },
17
+ { key: 'last_seen', label: 'Last seen', format: 'datetime', hideEmpty: true },
18
+ { key: 'protocol_version', label: 'Protocol version', hideEmpty: true },
19
+ { key: 'installation_key_id', label: 'Installation key ID', hideEmpty: true },
20
+ { key: 'sensor_policy_revision_sha256', label: 'Sensor policy revision', hideEmpty: true },
21
+ { key: 'expected_change', label: 'Expected change', hideEmpty: true },
22
+ ];
23
+
24
+ const MOJOSEC_EVIDENCE_FIELDS: KnownField[] = [
25
+ { key: 'actor', label: 'Actor', hideEmpty: true },
26
+ { key: 'target_user', label: 'Target user', hideEmpty: true },
27
+ { key: 'user', label: 'User', hideEmpty: true },
28
+ { key: 'auth_method', label: 'Authentication method', hideEmpty: true },
29
+ { key: 'service', label: 'Service', hideEmpty: true },
30
+ { key: 'attribution', label: 'Attribution', hideEmpty: true },
31
+ { key: 'command', label: 'Command', hideEmpty: true },
32
+ { key: 'command_path', label: 'Command path', hideEmpty: true },
33
+ { key: 'command_family', label: 'Command family', hideEmpty: true },
34
+ { key: 'command_truncated', label: 'Command truncated', hideEmpty: true },
35
+ { key: 'command_path_truncated', label: 'Command path truncated', hideEmpty: true },
36
+ { key: 'cwd', label: 'Working directory', hideEmpty: true },
37
+ { key: 'cwd_truncated', label: 'Working directory truncated', hideEmpty: true },
38
+ { key: 'proof_status', label: 'Proof status', hideEmpty: true },
39
+ { key: 'producer_comm', label: 'Producer process', hideEmpty: true },
40
+ { key: 'producer_exe', label: 'Producer executable', hideEmpty: true },
41
+ { key: 'producer_pid', label: 'Producer PID', hideEmpty: true },
42
+ { key: 'producer_uid', label: 'Producer UID', hideEmpty: true },
43
+ { key: 'target_uid', label: 'Target UID', hideEmpty: true },
44
+ { key: 'opener_uid', label: 'Opener UID', hideEmpty: true },
45
+ { key: 'audit_session', label: 'Audit session', hideEmpty: true },
46
+ { key: 'audit_loginuid', label: 'Audit login UID', hideEmpty: true },
47
+ { key: 'tty', label: 'TTY', hideEmpty: true },
48
+ { key: 'boot_id', label: 'Boot ID', hideEmpty: true },
49
+ { key: 'monotonic', label: 'Monotonic timestamp', hideEmpty: true },
50
+ { key: 'systemd_unit', label: 'systemd unit', hideEmpty: true },
51
+ { key: 'cgroup', label: 'Control group', hideEmpty: true },
52
+ { key: 'selinux', label: 'SELinux context', hideEmpty: true },
53
+ { key: 'job_function', label: 'Job function', hideEmpty: true },
54
+ { key: 'origin_kind', label: 'Origin kind', hideEmpty: true },
55
+ { key: 'operation_id', label: 'Operation ID', hideEmpty: true },
56
+ { key: 'execution_id', label: 'Execution ID', hideEmpty: true },
57
+ { key: 'job_id', label: 'Job ID', hideEmpty: true },
58
+ { key: 'lineage_sha256', label: 'Lineage digest', hideEmpty: true },
59
+ { key: 'receipt_semantics', label: 'Receipt semantics', hideEmpty: true },
60
+ { key: 'ancestors', label: 'Process ancestry', hideEmpty: true },
61
+ { key: 'peer_ip', label: 'Peer IP', hideEmpty: true },
62
+ { key: 'method', label: 'HTTP method', hideEmpty: true },
63
+ { key: 'host', label: 'HTTP host', hideEmpty: true },
64
+ { key: 'status', label: 'HTTP status', hideEmpty: true },
65
+ { key: 'response_class', label: 'Response class', hideEmpty: true },
66
+ { key: 'resource_id', label: 'Resource ID', hideEmpty: true },
67
+ { key: 'edge_policy_version', label: 'Edge policy version', hideEmpty: true },
68
+ { key: 'path', label: 'Request path', hideEmpty: true },
69
+ { key: 'upstream_status', label: 'Upstream status', hideEmpty: true },
70
+ { key: 'request_id', label: 'Request ID', hideEmpty: true },
71
+ { key: 'scheme', label: 'Scheme', hideEmpty: true },
72
+ { key: 'protocol', label: 'Protocol', hideEmpty: true },
73
+ { key: 'tls_protocol', label: 'TLS protocol', hideEmpty: true },
74
+ { key: 'tls_cipher', label: 'TLS cipher', hideEmpty: true },
75
+ { key: 'remote_port', label: 'Remote port', hideEmpty: true },
76
+ { key: 'peer_port', label: 'Peer port', hideEmpty: true },
77
+ { key: 'server_port', label: 'Server port', hideEmpty: true },
78
+ { key: 'request_length', label: 'Request bytes', hideEmpty: true },
79
+ { key: 'response_bytes', label: 'Response bytes', hideEmpty: true },
80
+ { key: 'response_body_bytes', label: 'Response body bytes', hideEmpty: true },
81
+ { key: 'request_time_ms', label: 'Request time (ms)', hideEmpty: true },
82
+ { key: 'upstream_connect_time_ms', label: 'Upstream connect time (ms)', hideEmpty: true },
83
+ { key: 'upstream_header_time_ms', label: 'Upstream header time (ms)', hideEmpty: true },
84
+ { key: 'upstream_response_time_ms', label: 'Upstream response time (ms)', hideEmpty: true },
85
+ { key: 'upstream_response_length', label: 'Upstream response bytes', hideEmpty: true },
86
+ { key: 'upstream_bytes_received', label: 'Upstream bytes received', hideEmpty: true },
87
+ { key: 'upstream_bytes_sent', label: 'Upstream bytes sent', hideEmpty: true },
88
+ { key: 'referrer_origin', label: 'Referrer origin', hideEmpty: true },
89
+ { key: 'referrer', label: 'Referrer', hideEmpty: true },
90
+ { key: 'user_agent', label: 'User agent', hideEmpty: true },
91
+ { key: 'last_occurrence_sample', label: 'Last occurrence sample', hideEmpty: true },
92
+ { key: 'unit', label: 'Unit', hideEmpty: true },
93
+ { key: 'failure_kind', label: 'Failure kind', hideEmpty: true },
94
+ ];
95
+
96
+ function record(value: unknown): Record<string, unknown> | null {
97
+ return value != null && typeof value === 'object' && !Array.isArray(value)
98
+ ? value as Record<string, unknown>
99
+ : null;
100
+ }
101
+
102
+ export function extractMojosecDetails(metadata: Record<string, unknown>): {
103
+ summary: Record<string, unknown>;
104
+ evidence: Record<string, unknown>;
105
+ } | null {
106
+ const mojosec = record(metadata.mojosec);
107
+ if (!mojosec) return null;
108
+ const { evidence: rawEvidence, ...summary } = mojosec;
109
+ return { summary, evidence: record(rawEvidence) ?? {} };
110
+ }
111
+
112
+ export function MojosecEventSection({ details }: {
113
+ details: NonNullable<ReturnType<typeof extractMojosecDetails>>;
114
+ }) {
115
+ return <>
116
+ <Eyebrow>Detection and sensor</Eyebrow>
117
+ <KnownFieldsCard data={details.summary} known={MOJOSEC_SUMMARY_FIELDS} showRaw={false} emptyText="No MojoSec detection summary was recorded." />
118
+ <Eyebrow>Validated evidence</Eyebrow>
119
+ <KnownFieldsCard data={details.evidence} known={MOJOSEC_EVIDENCE_FIELDS} showRaw={false} emptyText="No projected evidence was recorded." />
120
+ </>;
121
+ }
122
+
123
+ function displayValue(value: unknown): string {
124
+ if (value == null || value === '') return '—';
125
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
126
+ try { return JSON.stringify(value); } catch { return String(value); }
127
+ }
4
128
 
5
129
  function incidentId(value: unknown): number | null {
6
130
  if (typeof value === 'number') return value;
7
131
  if (value && typeof value === 'object' && typeof (value as { id?: unknown }).id === 'number') return (value as { id: number }).id;
8
132
  return null;
9
133
  }
134
+
135
+ function prepareEventDetailRecord(rawEvent: EventRow): { rawEvent: EventRow; event: EventRow } {
136
+ return { rawEvent, event: sanitizeEventRow(rawEvent) };
137
+ }
138
+
139
+ /** Keep the authorized raw record isolated from every sanitized event cache. */
140
+ function useRawEventDetail(id: number | null) {
141
+ return useQuery({
142
+ queryKey: [EventModel.endpoint, 'raw-one', id, { graph: 'detailed' }],
143
+ queryFn: async () => (await mojoCall(`${EventModel.endpoint}/${id!}`, { params: { graph: 'detailed' } })).data as EventRow,
144
+ enabled: id != null,
145
+ });
146
+ }
147
+
10
148
  export function EventDetail({ id, onClose }: { id: number; onClose: () => void }) {
11
- const query = useEventDetail(id);
149
+ const query = useRawEventDetail(id);
12
150
  if (query.isPending) return <div className="modal-pad dim">Loading event…</div>;
13
151
  if (!query.data || query.error) return <div className="modal-pad text-bad">{query.error?.message ?? 'Event not found'}</div>;
14
- const event = query.data;
152
+ const { rawEvent, event } = prepareEventDetailRecord(query.data);
15
153
  const metadata = metadataOf(event);
154
+ const mojosec = extractMojosecDetails(metadata);
16
155
  const linkedIncident = incidentId(event.incident);
17
156
  const trace = first(metadata, 'stack_trace', 'traceback');
18
157
  const hasOssec = event.category.startsWith('ossec') || metadata.alert_id != null || metadata.logfile != null;
@@ -29,13 +168,16 @@ export function EventDetail({ id, onClose }: { id: number; onClose: () => void }
29
168
  <FlatRow label="Model">{event.model_name ? `${event.model_name} #${event.model_id ?? '—'}` : '—'}</FlatRow>
30
169
  <FlatRow label="Group ID">{event.group_id ?? 'Global'}</FlatRow>
31
170
  </> },
32
- { key: 'source', label: 'Source', icon: 'bi-globe2', render: () => <><Eyebrow>Source and network</Eyebrow><FlatRow label="Source IP"><code>{event.source_ip || '—'}</code></FlatRow><FlatRow label="Hostname"><code>{event.hostname || '—'}</code></FlatRow><FlatRow label="Country">{String(event.country_code || metadata.country_code || '—')}</FlatRow><FlatRow label="Server">{String(metadata.server ?? '—')}</FlatRow></> },
171
+ { key: 'source', label: 'Source', icon: 'bi-globe2', render: () => <><Eyebrow>Source and network</Eyebrow><FlatRow label="Source IP"><code>{event.source_ip || '—'}</code></FlatRow><FlatRow label="Hostname"><code>{event.hostname || '—'}</code></FlatRow><FlatRow label="Country">{String(event.country_code || metadata.country_code || '—')}</FlatRow>{event.geo_ip != null && <FlatRow label="GeoIP">{displayValue(event.geo_ip)}</FlatRow>}<FlatRow label="Server">{String(metadata.server ?? '—')}</FlatRow></> },
33
172
  { key: 'request', label: 'Request', icon: 'bi-funnel', render: () => <RequestResponseForensics metadata={metadata} /> },
34
173
  ...(trace ? [{ key: 'trace', label: 'Stack trace', icon: 'bi-code-square', render: () => <TraceForensics metadata={metadata} /> }] : []),
35
174
  ...(hasOssec ? [{ key: 'ossec', label: 'OSSEC', icon: 'bi-shield-exclamation', render: () => <EvidenceCard metadata={metadata} /> }] : []),
175
+ ...(mojosec ? [{ key: 'mojosec', label: 'MojoSec', icon: 'bi-shield-lock', render: () => <MojosecEventSection details={mojosec} /> }] : []),
36
176
  ...(hasBouncer ? [{ key: 'bouncer', label: 'Bouncer', icon: 'bi-shield-shaded', render: () => <KnownFieldsCard data={metadata} showRaw={false} known={[{ key: 'decision', hideEmpty: true }, { key: 'risk_score', hideEmpty: true }, { key: 'page', hideEmpty: true }, { key: 'page_type', hideEmpty: true }, { key: 'muid', hideEmpty: true }, { key: 'duid', hideEmpty: true }, { key: 'triggered_signals', hideEmpty: true }]} /> }] : []),
37
177
  ...(hasPermissions ? [{ key: 'permissions', label: 'Auth evidence', icon: 'bi-key', render: () => <KnownFieldsCard data={metadata} showRaw={false} known={[{ key: 'permission_keys', hideEmpty: true }, { key: 'perms', hideEmpty: true }, { key: 'auth_result', hideEmpty: true }, { key: 'user_email', hideEmpty: true }]} /> }] : []),
38
178
  { key: 'metadata', label: 'Known fields', icon: 'bi-braces', render: () => <KnownFieldsCard data={metadata} known={[...INCIDENT_METADATA_FIELDS, { key: 'alert_id', hideEmpty: true }, { key: 'logfile', hideEmpty: true }, { key: 'error_code', hideEmpty: true }, { key: 'error_message', hideEmpty: true }]} showRaw={false} /> },
179
+ { divider: 'Raw' },
180
+ { key: 'raw', label: 'Raw data', icon: 'bi-filetype-json', render: () => <><Eyebrow>Complete API response</Eyebrow><p className="dim">The complete event record returned by Django, including all metadata.</p><JsonBlock value={rawEvent} label="Event API record" collapsible={false} /></> },
39
181
  ]}
40
182
  initialSection="overview"
41
183
  onClose={onClose}