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.
- package/docs/DetailView.md +19 -15
- package/docs/README.md +1 -0
- package/docs/admin-assistant.md +3 -1
- package/docs/admin-credentials.md +7 -0
- package/docs/admin-dashboard.md +1 -1
- package/docs/admin-dns.md +8 -2
- package/docs/admin-incidents.md +26 -3
- package/docs/admin-jobs.md +7 -4
- package/docs/admin-messaging.md +6 -5
- package/docs/admin-modals.md +122 -0
- package/docs/admin-network-security.md +4 -4
- package/docs/admin-phonehub.md +13 -5
- package/docs/admin-push.md +7 -0
- package/docs/admin-rules.md +8 -0
- package/docs/admin-shortlinks.md +2 -1
- package/docs/admin-storage.md +2 -1
- package/docs/auth.md +28 -0
- package/docs/client.md +12 -0
- package/docs/feedback.md +10 -0
- package/docs/forms.md +10 -0
- package/docs/popover.md +15 -0
- package/package.json +1 -1
- package/src/admin/assistant/api.ts +1 -1
- package/src/admin/assistant/pages.tsx +71 -17
- package/src/admin/assistant/skill-lifecycle.ts +9 -0
- package/src/admin/core/index.ts +7 -2
- package/src/admin/credentials/group-api-keys.tsx +47 -43
- package/src/admin/credentials/webhook-subscriptions.tsx +28 -40
- package/src/admin/dashboard/AdminDashboardPage.tsx +1 -1
- package/src/admin/dns/CertificateLifecyclePoller.tsx +15 -3
- package/src/admin/dns/ProviderCredentialsPage.tsx +33 -49
- package/src/admin/dns/setup.ts +1 -1
- package/src/admin/domains/observability.ts +1 -1
- package/src/admin/identity/users/UserDetail.tsx +36 -20
- package/src/admin/identity/users/sections/OAuthSection.tsx +13 -6
- package/src/admin/identity/users/sections/actions.tsx +20 -9
- package/src/admin/incidents/EventDetail.tsx +147 -5
- package/src/admin/incidents/IncidentDetail.tsx +181 -8
- package/src/admin/incidents/models.ts +1 -1
- package/src/admin/jobs/RunnerDetail.tsx +5 -8
- package/src/admin/jobs/ScheduledTaskDetail.tsx +5 -71
- package/src/admin/jobs/index.ts +1 -1
- package/src/admin/jobs/purge-preview.ts +25 -0
- package/src/admin/jobs/sections/JobOperationsSection.tsx +46 -35
- package/src/admin/messaging/EmailDomainsPage.tsx +3 -3
- package/src/admin/messaging/EmailTemplatesPage.tsx +2 -4
- package/src/admin/messaging/MailboxesPage.tsx +3 -3
- package/src/admin/messaging/PublicMessagesPage.tsx +3 -5
- package/src/admin/messaging/push/PushPage.tsx +20 -8
- package/src/admin/monitoring/LogInspector.tsx +3 -19
- package/src/admin/network/IPSetDetail.tsx +32 -63
- package/src/admin/phonehub/PhoneHubPage.tsx +274 -13
- package/src/admin/phonehub/SmsComposer.tsx +97 -0
- package/src/admin/phonehub/api.ts +6 -0
- package/src/admin/rules/RuleSetDetailPage.tsx +31 -7
- package/src/admin/rules/condition-removal.ts +7 -0
- package/src/admin/security/geoip/models.ts +5 -0
- package/src/admin/security/tickets.tsx +40 -17
- package/src/admin/shortlinks/ShortlinkDetail.tsx +18 -12
- package/src/admin/storage/BucketDetail.tsx +28 -9
- package/src/admin/storage/FileManagerDetail.tsx +23 -5
- package/src/admin/storage/FileView.tsx +28 -16
- package/src/admin/storage/FilesPage.tsx +4 -5
- package/src/client/auth.ts +10 -1
- package/src/client/client.ts +14 -6
- package/src/client/mock.ts +89 -5
- package/src/client/upload.ts +1 -0
- package/src/ui/DetailView.tsx +22 -5
- package/src/ui/Popover.tsx +13 -12
- 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: '
|
|
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,
|
|
27
|
-
|
|
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,
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|
|
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>
|
|
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
|
-
{
|
|
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 {
|
|
13
|
+
import { useEffect, useRef, useState } from 'react';
|
|
14
|
+
import { mojoCall, useCan, useMe, withFreshAuth, type Field } from '../../../../client/runtime';
|
|
15
15
|
import {
|
|
16
|
-
Badge,
|
|
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: '
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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
|
|
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}
|