portal-mojo 0.2.4 → 0.2.6

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 (60) hide show
  1. package/docs/README.md +2 -0
  2. package/docs/admin-fleet-configuration.md +50 -0
  3. package/docs/admin-identity-directories-sources.json +130 -0
  4. package/docs/admin-identity-directories.md +60 -0
  5. package/docs/admin-identity-users.md +6 -1
  6. package/docs/admin-members.md +16 -0
  7. package/docs/admin-network-security.md +1 -1
  8. package/docs/admin-push.md +85 -6
  9. package/docs/admin-settings.md +10 -0
  10. package/docs/admin-signin.md +73 -0
  11. package/docs/menus-and-access.md +6 -2
  12. package/docs/realtime.md +5 -0
  13. package/package.json +1 -1
  14. package/src/admin/core/index.ts +4 -4
  15. package/src/admin/domains/identity.ts +25 -1
  16. package/src/admin/domains/operations.ts +8 -1
  17. package/src/admin/fleet-configuration/FleetConfigurationPage.tsx +171 -0
  18. package/src/admin/fleet-configuration/data.ts +89 -0
  19. package/src/admin/identity/groups/GroupDetail.tsx +287 -0
  20. package/src/admin/identity/groups/GroupsPage.tsx +125 -0
  21. package/src/admin/identity/groups/group-permissions.ts +5 -0
  22. package/src/admin/identity/groups/group-sections/AuditSection.tsx +57 -0
  23. package/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx +677 -0
  24. package/src/admin/identity/groups/group-sections/EventsSection.tsx +53 -0
  25. package/src/admin/identity/groups/group-sections/GeofenceSection.tsx +198 -0
  26. package/src/admin/identity/groups/group-sections/IdentitySection.tsx +211 -0
  27. package/src/admin/identity/groups/group-sections/MembersSection.tsx +25 -0
  28. package/src/admin/identity/groups/group-sections/OverviewSection.tsx +112 -0
  29. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +102 -0
  30. package/src/admin/identity/groups/group-sections/auth-config.ts +285 -0
  31. package/src/admin/identity/groups/group-sections/geofence-data.ts +40 -0
  32. package/src/admin/identity/groups/group-sections/models.ts +110 -0
  33. package/src/admin/identity/groups/group-sections/shared.ts +21 -0
  34. package/src/admin/identity/groups/index.ts +6 -0
  35. package/src/admin/identity/groups/models.ts +70 -0
  36. package/src/admin/identity/index.ts +1 -0
  37. package/src/admin/identity/members/MemberDetail.tsx +32 -3
  38. package/src/admin/identity/members/models.ts +3 -0
  39. package/src/admin/identity/users/PersonalApiKeysPage.tsx +220 -0
  40. package/src/admin/identity/users/UserDetail.tsx +2 -2
  41. package/src/admin/identity/users/index.ts +2 -0
  42. package/src/admin/identity/users/sections/PermissionsSection.tsx +2 -2
  43. package/src/admin/index.ts +3 -0
  44. package/src/admin/messaging/push/PushPage.tsx +61 -13
  45. package/src/admin/messaging/push/PushTestDialog.tsx +59 -0
  46. package/src/admin/messaging/push/api.ts +25 -1
  47. package/src/admin/messaging/push/models.ts +7 -6
  48. package/src/admin/public/identity.ts +1 -0
  49. package/src/admin/public/operations.ts +1 -0
  50. package/src/admin/registry.ts +6 -2
  51. package/src/admin/settings/SettingDetail.tsx +2 -1
  52. package/src/admin/settings/SettingValue.tsx +37 -0
  53. package/src/admin/settings/SettingsPage.tsx +2 -1
  54. package/src/admin/signin/SigninPage.tsx +397 -0
  55. package/src/admin/signin/api.ts +83 -0
  56. package/src/admin/signin/index.ts +4 -0
  57. package/src/client/fleet-mock.ts +61 -0
  58. package/src/client/mock.ts +239 -10
  59. package/src/client/realtime.ts +12 -0
  60. package/src/ui/SidebarNav.tsx +2 -2
@@ -2,6 +2,8 @@ import type { AdminSection } from '../core';
2
2
  import { GLOBAL_CREDENTIAL_PERMS } from '../credentials/models';
3
3
  import { MEMBER_READ_PERMISSIONS } from '../identity/members/models';
4
4
  import { USER_VIEW_PERMISSIONS } from '../identity/users/models';
5
+ import { SIGNIN_ADMIN_PERMISSIONS } from '../signin/api';
6
+ import { GROUP_VIEW_PERMS } from '../identity/groups/group-permissions';
5
7
 
6
8
  export const USERS_ADMIN_SECTION: AdminSection = {
7
9
  id: 'users', basePath: '', title: 'Users', icon: 'bi-people', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
@@ -21,4 +23,26 @@ export const CREDENTIALS_ADMIN_SECTION: AdminSection = {
21
23
  ],
22
24
  };
23
25
 
24
- export const IDENTITY_ADMIN_SECTIONS = [USERS_ADMIN_SECTION, MEMBERS_ADMIN_SECTION, CREDENTIALS_ADMIN_SECTION] as const;
26
+ export const SIGNIN_ADMIN_SECTION: AdminSection = {
27
+ id: 'signin', basePath: 'system/sign-in', title: 'Sign-in', icon: 'bi-box-arrow-in-right', navigationGroup: 'identity-access', permissions: SIGNIN_ADMIN_PERMISSIONS,
28
+ routes: [{ path: '', loadComponent: () => import('../signin/SigninPage').then(({ SigninPage }) => ({ default: SigninPage })), permissions: SIGNIN_ADMIN_PERMISSIONS }],
29
+ };
30
+
31
+ export const GROUPS_ADMIN_SECTION: AdminSection = {
32
+ id: 'groups', basePath: '', title: 'Groups', icon: 'bi-diagram-3', navigationGroup: 'identity-access', permissions: GROUP_VIEW_PERMS,
33
+ routes: [{ path: 'groups', label: 'Groups', loadComponent: () => import('../identity/groups/GroupsPage').then(({ GroupsPage }) => ({ default: GroupsPage })), permissions: GROUP_VIEW_PERMS }],
34
+ };
35
+
36
+ export const PERSONAL_API_KEYS_ADMIN_SECTION: AdminSection = {
37
+ id: 'personal-api-keys', basePath: '', title: 'Personal API Keys', icon: 'bi-key', navigationGroup: 'identity-access', permissions: USER_VIEW_PERMISSIONS,
38
+ routes: [{ path: 'apikeys', label: 'Personal API Keys', loadComponent: () => import('../identity/users/PersonalApiKeysPage').then(({ PersonalApiKeysPage }) => ({ default: PersonalApiKeysPage })) }],
39
+ };
40
+
41
+ export const IDENTITY_ADMIN_SECTIONS = [
42
+ GROUPS_ADMIN_SECTION,
43
+ PERSONAL_API_KEYS_ADMIN_SECTION,
44
+ USERS_ADMIN_SECTION,
45
+ MEMBERS_ADMIN_SECTION,
46
+ CREDENTIALS_ADMIN_SECTION,
47
+ SIGNIN_ADMIN_SECTION,
48
+ ] as const;
@@ -6,6 +6,13 @@ export const SETTINGS_ADMIN_SECTION: AdminSection = {
6
6
  id: 'settings', title: 'Settings', icon: 'bi-gear', navigationGroup: 'operations', permissions: SETTINGS_PERMISSIONS,
7
7
  routes: [{ path: '', loadComponent: () => import('../settings/SettingsPage').then(({ SettingsPage }) => ({ default: SettingsPage })), permissions: SETTINGS_PERMISSIONS }],
8
8
  };
9
+ // Empty any-of permissions admit only the existing literal-superuser bypass.
10
+ // A global admin permission alone must not expose this configuration surface.
11
+ export const FLEET_CONFIGURATION_ADMIN_SECTION: AdminSection = {
12
+ id: 'fleet-configuration', title: 'Fleet Configuration', icon: 'bi-hdd-network',
13
+ navigationGroup: 'operations', permissions: [],
14
+ routes: [{ path: '', loadComponent: () => import('../fleet-configuration/FleetConfigurationPage').then(({ FleetConfigurationPage }) => ({ default: FleetConfigurationPage })) }],
15
+ };
9
16
  export const JOBS_ADMIN_SECTION: AdminSection = {
10
17
  id: 'jobs', title: 'Jobs', icon: 'bi-cpu', navigationGroup: 'operations', permissions: [...new Set([...JOBS_VIEW_PERMS, ...SCHEDULED_TASK_VIEW_PERMS])],
11
18
  routes: [
@@ -15,4 +22,4 @@ export const JOBS_ADMIN_SECTION: AdminSection = {
15
22
  { path: 'scheduled-tasks', label: 'Scheduled Tasks', loadComponent: () => import('../jobs/ScheduledTasksPage').then(({ ScheduledTasksPage }) => ({ default: ScheduledTasksPage })), permissions: SCHEDULED_TASK_VIEW_PERMS },
16
23
  ],
17
24
  };
18
- export const OPERATIONS_ADMIN_SECTIONS = [SETTINGS_ADMIN_SECTION, JOBS_ADMIN_SECTION] as const;
25
+ export const OPERATIONS_ADMIN_SECTIONS = [SETTINGS_ADMIN_SECTION, FLEET_CONFIGURATION_ADMIN_SECTION, JOBS_ADMIN_SECTION] as const;
@@ -0,0 +1,171 @@
1
+ import { useRef, useState } from 'react';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { useMe } from '../../client/runtime';
4
+ import { modal } from '../../ui';
5
+ import {
6
+ FLEET_ENDPOINT, FLEET_HEALTH_SCOPE, displayFleetValue, fleetChange, fleetIsHealthy, operationFinished,
7
+ readFleet, readFleetHistory, readFleetOperation, writeFleet,
8
+ type FleetChanges, type FleetEntry, type FleetOperation, type FleetPublication, type FleetReport, type FleetState,
9
+ } from './data';
10
+
11
+ function SettingInput({ entry, disabled }: { entry: FleetEntry; disabled: boolean }) {
12
+ const [mode, setMode] = useState('keep');
13
+ const current = entry.sensitive ? (entry.configured ? 'Configured (hidden)' : 'Not configured') : displayFleetValue(entry.current);
14
+ const initial = entry.sensitive ? '' : entry.value_type === 'string' ? String(entry.current ?? '')
15
+ : entry.value_type === 'boolean' ? String(entry.current === true)
16
+ : entry.current == null ? '' : JSON.stringify(entry.current);
17
+ const control = { name: `${entry.key}:value`, className: 'input', disabled, defaultValue: initial };
18
+ return <div className="panel panel-pad" style={{ minWidth: 0 }}>
19
+ <h3>{entry.label}</h3><p className="dim">{entry.description}</p>
20
+ <p style={{ overflowWrap: 'anywhere' }}><b>Current:</b> {current}</p>
21
+ {!entry.sensitive && <p className="dim">Default: {displayFleetValue(entry.default)}</p>}
22
+ <p><span className="badge">{entry.restart_required ? 'Restart required' : 'No restart required'}</span></p>
23
+ <label className="field"><span className="field-label">Proposed change for {entry.label}</span>
24
+ <select className="input" name={`${entry.key}:mode`} value={mode} disabled={disabled} onChange={(event) => setMode(event.target.value)}>
25
+ <option value="keep">Keep current value</option><option value="set">{entry.sensitive ? 'Replace secret' : 'Set value'}</option>
26
+ {entry.overridden && <option value="clear">Remove published override</option>}
27
+ </select>
28
+ </label>
29
+ {mode === 'set' && <label className="field"><span className="field-label">{entry.sensitive ? 'Secret replacement' : 'Proposed value'}</span>
30
+ {entry.value_type === 'boolean' ? <select {...control}><option value="true">True</option><option value="false">False</option></select>
31
+ : entry.value_type === 'list' || entry.value_type === 'object' ? <textarea {...control} rows={4} autoComplete="off" spellCheck={false} />
32
+ : <input {...control} type={entry.sensitive ? 'password' : entry.value_type === 'integer' ? 'number' : 'text'}
33
+ autoComplete="off" spellCheck={false} min={entry.min_value ?? undefined} max={entry.max_value ?? undefined} maxLength={entry.max_length} step={1} />}
34
+ <span className="field-help">{entry.sensitive ? 'Leave blank to preserve the existing secret. Secrets are never shown in history.'
35
+ : entry.value_type === 'list' || entry.value_type === 'object' ? `Enter a JSON ${entry.value_type}; at most ${entry.max_items ?? 100} entries.` : 'Validated again by the server before publication.'}</span>
36
+ </label>}
37
+ {mode === 'clear' && <p className="text-warn">Removes this published override; the node’s base configuration or application default will apply.</p>}
38
+ </div>;
39
+ }
40
+
41
+ function FleetEditor({ state, busy, onPublish }: { state: FleetState; busy: boolean; onPublish: (changes: FleetChanges) => Promise<void> }) {
42
+ const form = useRef<HTMLFormElement>(null);
43
+ const [error, setError] = useState('');
44
+ const sections = [...new Set(state.entries.map((entry) => entry.section))];
45
+ const submit = async (event: React.FormEvent) => {
46
+ event.preventDefault(); setError('');
47
+ try {
48
+ const fields = new FormData(form.current!);
49
+ const changes: FleetChanges = {};
50
+ for (const entry of state.entries) {
51
+ const change = fleetChange(entry, String(fields.get(`${entry.key}:mode`) ?? 'keep'), String(fields.get(`${entry.key}:value`) ?? ''));
52
+ if (change) changes[entry.key] = change;
53
+ }
54
+ if (!Object.keys(changes).length) { setError('Choose at least one change before publishing.'); return; }
55
+ await onPublish(changes);
56
+ } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not publish configuration'); }
57
+ };
58
+ return <form ref={form} onSubmit={submit}>
59
+ <fieldset disabled={busy} style={{ border: 0, padding: 0, margin: 0, minWidth: 0 }}>
60
+ {sections.map((section) => <section key={section} style={{ marginBottom: 20 }}><h2>{section}</h2>
61
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 12 }}>
62
+ {state.entries.filter((entry) => entry.section === section).map((entry) => <SettingInput key={entry.key} entry={entry} disabled={busy} />)}
63
+ </div></section>)}
64
+ {!state.entries.length && <p>No settings have been registered and delegated for this application.</p>}
65
+ {error && <p className="form-alert" role="alert">{error}</p>}
66
+ <button className="btn btn-primary" type="submit" disabled={busy || !state.publish_configured || !state.entries.length}>Publish</button>
67
+ </fieldset>
68
+ </form>;
69
+ }
70
+
71
+ export function FleetNodeStatus({ report, revision }: { report: FleetReport; revision: string | null }) {
72
+ const healthy = fleetIsHealthy(report);
73
+ const jobsCovered = report.health_scope === FLEET_HEALTH_SCOPE;
74
+ return <section className="panel panel-pad" aria-label="Fleet convergence" style={{ marginTop: 20 }}>
75
+ <h2>Fleet convergence</h2><p role="status" className={healthy ? 'text-ok' : 'text-warn'}>
76
+ {healthy ? 'Applied and healthy on every expected node' : `Not confirmed healthy everywhere — ${report.status}`}</p>
77
+ <p className="dim" style={{ overflowWrap: 'anywhere' }}>Target revision: {revision ?? 'Not published'}{report.observed_at ? ` · Observed ${report.observed_at}` : ''}</p>
78
+ {report.error_code && <p className="form-alert">{report.error_code}</p>}
79
+ {!report.nodes.length ? <p>No node results are available. Publication alone does not establish fleet health.</p>
80
+ : <div className="tbl-scroll"><table className="tbl"><thead><tr><th>Node</th><th>Published</th><th>Downloaded / installed</th><th>Restart</th><th>Healthy</th><th>Result</th></tr></thead>
81
+ <tbody>{report.nodes.map((node) => <tr key={node.hostname}><td>{node.hostname}</td><td>{node.published ? 'Yes' : 'Unconfirmed'}</td>
82
+ <td>{node.installed ? 'Yes' : 'Unconfirmed'}</td><td>{jobsCovered && node.restarted ? 'Confirmed' : node.restart_requested ? 'Requested' : 'Unconfirmed'}</td>
83
+ <td>{jobsCovered && node.healthy && node.installed && node.restarted && !node.error_code ? 'Yes' : 'Unconfirmed'}</td><td>{node.error_code ?? (!jobsCovered && node.status === 'healthy' ? 'Scope unverified' : node.status)}</td></tr>)}</tbody></table></div>}
84
+ {!jobsCovered && <p className="text-warn">This report does not verify job engine and scheduler activation. Refresh after upgrading the backend to confirm fleet health.</p>}
85
+ <p className="dim">Health verifies the request service where enabled, plus the job engine and scheduler. Worker-only nodes do not require a request service. Offline or missing nodes remain unconfirmed.</p>
86
+ </section>;
87
+ }
88
+
89
+ function FleetContent({ userId }: { userId: number }) {
90
+ const queryClient = useQueryClient();
91
+ const [busy, setBusy] = useState(false);
92
+ const [error, setError] = useState('');
93
+ const [notice, setNotice] = useState('');
94
+ const [editorEpoch, setEditorEpoch] = useState(0);
95
+ const [operationId, setOperationId] = useState<string | null>(null);
96
+ const queryKey = [FLEET_ENDPOINT, userId];
97
+ const stateQuery = useQuery({ queryKey, queryFn: readFleet, refetchOnWindowFocus: false, retry: false });
98
+ const history = useQuery({ queryKey: [...queryKey, 'history'], queryFn: readFleetHistory, retry: false });
99
+ const operation = useQuery({ queryKey: [...queryKey, 'operation', operationId], enabled: operationId != null,
100
+ queryFn: () => readFleetOperation(operationId!), retry: false,
101
+ refetchInterval: (query) => query.state.error || (query.state.data && operationFinished(query.state.data)) ? false : 3000 });
102
+ const state = stateQuery.data;
103
+ const refresh = async () => { await Promise.all([stateQuery.refetch(), history.refetch(), ...(operationId ? [operation.refetch()] : [])]); };
104
+ const run = async (task: () => Promise<void>) => {
105
+ setBusy(true); setError(''); setNotice('');
106
+ try { await task(); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Fleet operation failed'); }
107
+ finally { setBusy(false); }
108
+ };
109
+ const publish = async (changes: FleetChanges) => {
110
+ if (!state || !await modal.confirm({ title: 'Publish fleet configuration?',
111
+ message: `Publish changes to ${Object.keys(changes).join(', ')}? Config-sync timers will download the new configuration and may restart services. Publication does not confirm that nodes are healthy.`, confirmText: 'Publish' })) return;
112
+ await run(async () => {
113
+ const result = await writeFleet<FleetPublication>({ action: 'publish', expected_revision: state.revision, changes });
114
+ if (result.published !== true) throw new Error('Publication was not confirmed. Reload to check the current revision.');
115
+ setOperationId(null); setEditorEpoch((value) => value + 1); setNotice('Published to S3. Node application and health are still pending.'); await refresh();
116
+ });
117
+ };
118
+ const apply = async () => {
119
+ if (!state || !await modal.confirm({ title: 'Apply configuration now?', message: 'Ask every expected node to run its configured sync service now. Services may restart. Results will appear below.', confirmText: 'Apply now' })) return;
120
+ await run(async () => {
121
+ const result = await writeFleet<FleetOperation>({ action: 'apply', expected_revision: state.revision });
122
+ if (!result.operation_id) throw new Error('No apply operation was returned. Refresh node status before retrying.');
123
+ queryClient.setQueryData([...queryKey, 'operation', result.operation_id], result);
124
+ setOperationId(result.operation_id); setNotice('Apply requested. Waiting for each node to confirm its result.');
125
+ });
126
+ };
127
+ const restore = async (versionId: string) => {
128
+ if (!state || !await modal.confirm({ title: 'Restore previous version?', message: 'Publish this previous configuration as a new revision, including its stored secret values. Config-sync timers may restart services. Node health must be checked separately.', confirmText: 'Restore previous version' })) return;
129
+ await run(async () => {
130
+ const result = await writeFleet<FleetPublication>({ action: 'restore', expected_revision: state.revision, version_id: versionId });
131
+ if (result.published !== true) throw new Error('Restore was not confirmed. Reload to check the current revision.');
132
+ setOperationId(null); setEditorEpoch((value) => value + 1); setNotice('Previous version published as a new revision. Node application and health are still pending.'); await refresh();
133
+ });
134
+ };
135
+ if (!state) return <div className="panel panel-pad"><h1>Fleet Configuration</h1><p role="alert">{stateQuery.error?.message ?? 'Loading configuration…'}</p>
136
+ {stateQuery.error && <button className="btn" onClick={() => void refresh()}>Retry</button>}</div>;
137
+ const tracked = operationId && operation.data?.revision === state.revision ? operation.data : null;
138
+ const evidence = tracked && (!operationFinished(tracked) || Date.parse(tracked.observed_at ?? '') > Date.parse(state.fleet.observed_at ?? '')) ? tracked : state.fleet;
139
+ const report = stateQuery.error || operation.error ? { ...evidence, status: 'observation_unavailable', healthy_everywhere: false,
140
+ nodes: evidence.nodes.map((node) => ({ ...node, healthy: false })) } : evidence;
141
+ return <div className="page" style={{ minWidth: 0, overflowWrap: 'anywhere' }}><header className="page-header"><div><h1>Fleet Configuration</h1>
142
+ <p className="dim">Publish registered application settings and track their application across your nodes.</p></div></header>
143
+ <div className="panel panel-pad" style={{ marginBottom: 20 }}><p style={{ overflowWrap: 'anywhere' }}><b>Published to S3:</b> {state.published ? state.revision : 'Not yet published'}</p>
144
+ <p className="dim">This request’s node loaded revision: {state.loaded_revision ?? 'Not reported'}</p>
145
+ {!state.publish_configured && <p className="form-alert">Publishing is unavailable until the fleet configuration location, encryption and delegated settings are configured.</p>}
146
+ <button className="btn" disabled={busy || stateQuery.isFetching} onClick={() => void refresh()}>Refresh status</button>{' '}
147
+ <button className="btn" disabled={busy || !state.revision || (operation.data != null && !operationFinished(operation.data))} onClick={() => void apply()}>Apply now</button>
148
+ {notice && <p role="status">{notice}</p>}{(error || stateQuery.error) && <p className="form-alert" role="alert">{error || stateQuery.error?.message}</p>}
149
+ </div>
150
+ <FleetEditor key={`${state.revision ?? 'unpublished'}:${editorEpoch}`} state={state} busy={busy} onPublish={publish} />
151
+ {operation.error && <p className="form-alert" role="alert">Apply status could not be refreshed: {operation.error.message}. Refresh status to check nodes; prior results are not current confirmation.</p>}
152
+ {tracked && <p role="status">Apply operation: {tracked.status}</p>}
153
+ <FleetNodeStatus report={report} revision={state.revision} />
154
+ <section className="panel panel-pad" style={{ marginTop: 20 }}><h2>Version history</h2>
155
+ {history.error && <p className="form-alert">{history.error.message}</p>}
156
+ {history.isPending && <p>Loading versions…</p>}
157
+ {history.data?.versions.map((version) => <div key={version.version_id} style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 12, alignItems: 'center' }}>
158
+ <span style={{ overflowWrap: 'anywhere', minWidth: 0 }}>{version.published_at ?? 'Time unavailable'} · {version.version_id}{version.current ? ' (current)' : ''}</span>
159
+ {!version.current && <button className="btn" disabled={busy || !state.publish_configured} onClick={() => void restore(version.version_id)}>Restore previous version</button>}
160
+ </div>)}
161
+ {history.data?.versions.length === 0 && <p>No previous versions are available.</p>}
162
+ {history.data?.truncated && <p className="dim">Only the most recent available versions are shown.</p>}
163
+ </section></div>;
164
+ }
165
+
166
+ export function FleetConfigurationPage() {
167
+ const { data: me, isLoading } = useMe();
168
+ if (isLoading) return <p>Checking access…</p>;
169
+ if (me?.is_superuser !== true) return <div className="panel panel-pad">Fleet Configuration requires a superuser account.</div>;
170
+ return <FleetContent key={me.id} userId={me.id} />;
171
+ }
@@ -0,0 +1,89 @@
1
+ import { mojoCall, withFreshAuth } from '../../client/runtime';
2
+
3
+ export const FLEET_HEALTH_SCOPE = 'request_service_jobs_and_dependencies';
4
+ export const FLEET_ENDPOINT = '/api/account/admin/fleet';
5
+ export interface FleetEntry {
6
+ key: string; label: string; section: string; description: string;
7
+ value_type: 'boolean' | 'integer' | 'string' | 'list' | 'object';
8
+ sensitive: boolean; restart_required: boolean; overridden: boolean;
9
+ current?: unknown; default?: unknown; configured?: boolean;
10
+ min_value?: number | null; max_value?: number | null; max_length?: number; max_items?: number;
11
+ }
12
+ export interface FleetNode {
13
+ hostname: string; revision: string; published: boolean; installed: boolean;
14
+ restart_requested: boolean; restarted: boolean; healthy: boolean;
15
+ status: string; error_code?: string | null;
16
+ }
17
+ export interface FleetReport {
18
+ status: string; nodes: FleetNode[]; healthy_everywhere: boolean;
19
+ error_code?: string; observed_at?: string; health_scope?: string;
20
+ }
21
+ export interface FleetState {
22
+ schema_version: number; revision: string | null; version_id: string | null;
23
+ entries: FleetEntry[]; published: boolean; loaded_revision: string | null;
24
+ pending_restart: boolean; publish_configured: boolean; fleet: FleetReport;
25
+ }
26
+ export interface FleetHistory { versions: { version_id: string; current: boolean; published_at: string | null }[]; truncated: boolean }
27
+ export interface FleetOperation extends FleetReport { operation_id: string; revision: string; job_status?: string }
28
+ export type FleetChanges = Record<string, { action: 'clear' } | { action: 'set'; value: unknown }>;
29
+ export interface FleetPublication { published: boolean; revision: string; applied: boolean; pending_restart: boolean }
30
+
31
+ /** Project read responses before Query cache sees them. Never trust a secret value/default. */
32
+ export function sanitizeFleetState(state: FleetState): FleetState {
33
+ if (state?.schema_version !== 1 || !Array.isArray(state.entries) || !state.fleet || !Array.isArray(state.fleet.nodes)) {
34
+ throw new Error('Fleet configuration returned an unsupported response');
35
+ }
36
+ return { ...state, entries: state.entries.map((entry) => {
37
+ const { current, default: fallback, ...safe } = entry;
38
+ return entry.sensitive ? safe : { ...safe, current, default: fallback };
39
+ }) };
40
+ }
41
+ export function displayFleetValue(value: unknown): string {
42
+ if (value == null) return 'Not set';
43
+ return typeof value === 'string' ? value : JSON.stringify(value);
44
+ }
45
+ export function fleetChange(entry: FleetEntry, mode: string, input: string): FleetChanges[string] | null {
46
+ if (mode === 'keep') return null;
47
+ if (mode === 'clear') return { action: 'clear' };
48
+ if (mode !== 'set') throw new Error('Choose a supported change');
49
+ if (entry.sensitive && input === '') return null;
50
+ let value: unknown = input;
51
+ if (entry.value_type === 'boolean') {
52
+ if (!['true', 'false'].includes(input)) throw new Error(`${entry.label}: choose true or false`);
53
+ value = input === 'true';
54
+ } else if (entry.value_type === 'integer') {
55
+ if (!/^-?\d+$/.test(input)) throw new Error(`${entry.label}: enter a whole number`);
56
+ value = Number(input);
57
+ if (!Number.isSafeInteger(value) || (entry.min_value != null && Number(value) < entry.min_value)
58
+ || (entry.max_value != null && Number(value) > entry.max_value)) throw new Error(`${entry.label}: number is outside the allowed range`);
59
+ } else if (entry.value_type === 'list' || entry.value_type === 'object') {
60
+ try { value = JSON.parse(input); } catch { throw new Error(`${entry.label}: enter valid JSON`); }
61
+ if (entry.value_type === 'list' ? !Array.isArray(value) : value == null || typeof value !== 'object' || Array.isArray(value)) {
62
+ throw new Error(`${entry.label}: enter a JSON ${entry.value_type}`);
63
+ }
64
+ if (entry.max_items != null && Object.keys(value as object).length > entry.max_items) throw new Error(`${entry.label}: too many entries`);
65
+ } else if (entry.max_length != null && input.length > entry.max_length) throw new Error(`${entry.label}: value is too long`);
66
+ return { action: 'set', value };
67
+ }
68
+ export function fleetIsHealthy(report: FleetReport | undefined): boolean {
69
+ return report?.health_scope === FLEET_HEALTH_SCOPE && report.healthy_everywhere === true && report.nodes.length > 0
70
+ && report.nodes.every((node) => node.installed && node.restarted && node.healthy && !node.error_code);
71
+ }
72
+ export function operationFinished(operation: FleetOperation): boolean {
73
+ // A completed job only dispatched sync; operation reads observe actual activation.
74
+ return ['healthy', 'failed', 'expired', 'canceled', 'superseded', 'timed_out'].includes(operation.status)
75
+ || ['failed', 'expired', 'canceled'].includes(operation.job_status ?? '');
76
+ }
77
+ export async function readFleet(): Promise<FleetState> {
78
+ return sanitizeFleetState((await mojoCall(FLEET_ENDPOINT)).data as FleetState);
79
+ }
80
+ export async function readFleetHistory(): Promise<FleetHistory> {
81
+ return (await mojoCall(`${FLEET_ENDPOINT}/history`)).data as FleetHistory;
82
+ }
83
+ export async function readFleetOperation(id: string): Promise<FleetOperation> {
84
+ return (await mojoCall(`${FLEET_ENDPOINT}/operation/${encodeURIComponent(id)}`)).data as FleetOperation;
85
+ }
86
+ /** Imperative writes deliberately avoid TanStack MutationCache (submitted secrets persist there). */
87
+ export async function writeFleet<T>(body: Record<string, unknown>): Promise<T> {
88
+ return withFreshAuth(async () => (await mojoCall(FLEET_ENDPOINT, { method: 'POST', body })).data as T);
89
+ }
@@ -0,0 +1,287 @@
1
+ // GroupDetail — the FULL GroupView.js port (web-mojo admin/account/groups/
2
+ // GroupView.js, all 2243 lines read 2026-08-05; parity pass over the earlier
3
+ // subset build): DetailView modal with the complete section map —
4
+ // Overview · Identity · ─Membership─ Members · Sub-Groups · ─Access─
5
+ // API Keys · Webhooks(manage_group) · Geofencing(geofence perms) ·
6
+ // ─Activity─ Events · Audit(view_logs…) · ─Detail─ Metadata
7
+ // — kind-aware header (icon/noun/chips), rail count badges, active toggle
8
+ // gated to manage_groups (the source hid it below that tier), kebab context
9
+ // menu (Invite Member · Add Sub-{Noun} · Configure Auth · View Parent ·
10
+ // Deactivate/Activate), and cross-record navigation (parent/sub-group links
11
+ // open a STACKED GroupDetail — native <dialog> stacking).
12
+ //
13
+ // Deviations from source, all deliberate and documented in-section:
14
+ // · "Edit {Noun}" (the GroupForms.detailed modal) is not carried — the
15
+ // Identity section IS that form, as an autosave surface covering every
16
+ // field it had except avatar (no upload pipeline in portal-mojo yet).
17
+ // · The header aux meta line ("Last activity 50m ago" under the toggle)
18
+ // folds into the chips row — DetailView has no right-gutter meta slot;
19
+ // Overview's KPI carries the same fact.
20
+ // · Member rows use the packaged MemberDetail; optional callbacks keep the
21
+ // package decoupled while this app still opens UserDetail/GroupDetail.
22
+ // · Deactivate collects the REQUIRED reason (admin | abuse | archived —
23
+ // django-mojo services/disable.py GROUP_REST_REASONS); web-mojo's bare
24
+ // confirm predates that backend rule.
25
+ import { useQueryClient, useQuery } from '@tanstack/react-query';
26
+ import {
27
+ DetailView, Eyebrow, MetadataSection, formModal, modal, toast,
28
+ type DetailMenuEntry,
29
+ } from 'portal-mojo/ui';
30
+ import { mojoList, useCan, type Params } from 'portal-mojo/client/runtime';
31
+ import {
32
+ GLOBAL_CREDENTIAL_PERMS, GroupApiKeyModel, GroupApiKeysSection,
33
+ WebhookSubscriptionModel, WebhookSubscriptionsSection,
34
+ } from '../../credentials';
35
+ import {
36
+ MEMBER_INVITE_PERMISSIONS, MEMBER_SAVE_PERMISSIONS, MEMBER_USER_DIRECTORY_PERMISSIONS,
37
+ MemberModel, openMemberAdmissionDialog,
38
+ } from '../members';
39
+ import { LogModel } from '../../monitoring';
40
+ import {
41
+ GROUP_DESTRUCTIVE_PERMS, GROUP_MANAGE_PERMS,
42
+ GroupModel, type GroupRow,
43
+ } from './models';
44
+ import {
45
+ iconForKind, kindLabel,
46
+ } from './group-sections/models';
47
+ import { GEOFENCE_VIEW_PERMS } from './group-sections/geofence-data';
48
+ import { groupAuditParams } from './group-sections/shared';
49
+ import { openAuthConfigDialog } from './group-sections/AuthConfigDialog';
50
+ import { OverviewSection } from './group-sections/OverviewSection';
51
+ import { IdentitySection } from './group-sections/IdentitySection';
52
+ import { MembersSection } from './group-sections/MembersSection';
53
+ import { SubGroupsSection, runAddSubGroupFlow } from './group-sections/SubGroupsSection';
54
+ import { GeofenceSection } from './group-sections/GeofenceSection';
55
+ import { EventsSection } from './group-sections/EventsSection';
56
+ import { AuditSection } from './group-sections/AuditSection';
57
+
58
+ export { iconForKind } from './group-sections/models';
59
+
60
+ const GLOBAL_SECURITY_PERMS = ['sys.view_security', 'sys.security'];
61
+ const GLOBAL_LOG_PERMS = ['sys.view_logs', 'sys.manage_logs', 'sys.security'];
62
+
63
+ /** Open another group's detail as a stacked modal (source _openGroupById). */
64
+ function openGroupById(id: number) {
65
+ void modal.detail((close) => <GroupDetail id={id} onClose={() => close(null)} />);
66
+ }
67
+
68
+ /**
69
+ * count-only peek sharing useModelList's exact cache key ([endpoint,
70
+ * params]), so a section mounting the same list dedupes with the badge.
71
+ * `enabled:false` for gated surfaces (a non-manager's webhook badge must
72
+ * not fire a guaranteed-403 request per open).
73
+ */
74
+ function useCountPeek(endpoint: string, params: Params, enabled = true): number | null {
75
+ const q = useQuery({
76
+ queryKey: [endpoint, params],
77
+ queryFn: () => mojoList(endpoint, params),
78
+ enabled,
79
+ });
80
+ return q.data?.count ?? null;
81
+ }
82
+
83
+ export function GroupDetail({ id, onClose }: { id: number; onClose: () => void }) {
84
+ const qc = useQueryClient();
85
+ const { data: group, isPending } = GroupModel.useOne(id);
86
+ const groupSave = GroupModel.useSave();
87
+ const disable = GroupModel.useAction('disable');
88
+ const reactivate = GroupModel.useAction('reactivate');
89
+ const { can: canManage } = useCan(GROUP_MANAGE_PERMS);
90
+ const { can: canAccessCredentials } = useCan(GLOBAL_CREDENTIAL_PERMS);
91
+ const { can: canViewAudit } = useCan(GLOBAL_LOG_PERMS);
92
+ const { can: canInviteMember } = useCan(MEMBER_INVITE_PERMISSIONS);
93
+ const { can: canCreateMember } = useCan(MEMBER_SAVE_PERMISSIONS);
94
+ const { can: canReadUsers } = useCan(MEMBER_USER_DIRECTORY_PERMISSIONS);
95
+
96
+ // Rail count badges (source setBadge wiring, as controlled props). The
97
+ // member/apikey peeks share Overview's keys and dedupe; the gated ones
98
+ // hold off until the caller could actually open the section.
99
+ const memberCount = useCountPeek(MemberModel.endpoint, { group: id, size: 1, is_active: true });
100
+ const subCount = useCountPeek(GroupModel.endpoint, { parent: id, size: 1 });
101
+ const keyCount = useCountPeek(GroupApiKeyModel.endpoint, { group: id, size: 1 }, canAccessCredentials);
102
+ const hookCount = useCountPeek(WebhookSubscriptionModel.endpoint, { group: id, size: 1 }, canAccessCredentials);
103
+ const auditCount = useCountPeek(LogModel.endpoint, { ...groupAuditParams(id), size: 1 }, canViewAudit);
104
+
105
+ if (isPending || !group) {
106
+ return <div className="detail-loading"><span className="skel skel-block" /></div>;
107
+ }
108
+
109
+ const noun = kindLabel(group.kind) || 'Group';
110
+
111
+ /** Deactivate collects the REQUIRED reason (admin | abuse | archived). */
112
+ const deactivate = async () => {
113
+ const data = await formModal({
114
+ ...GroupModel.forms.disable!,
115
+ title: `Deactivate ${noun.toLowerCase()}`,
116
+ intro: <>Deactivate <b>{group.name}</b>? Members keep their accounts; the group stops resolving.</>,
117
+ });
118
+ if (!data) return;
119
+ const payload: Record<string, unknown> = { reason: data.reason };
120
+ if (data.note) payload.note = data.note;
121
+ try {
122
+ await disable.mutateAsync({ id: group.id, payload });
123
+ toast.success(`${noun} deactivated`);
124
+ } catch (err) {
125
+ toast.error(err instanceof Error ? err.message : 'Deactivate failed');
126
+ }
127
+ };
128
+
129
+ const activate = async () => {
130
+ try {
131
+ await reactivate.mutateAsync({ id: group.id });
132
+ toast.success(`${noun} reactivated`);
133
+ } catch (err) {
134
+ toast.error(err instanceof Error ? err.message : 'Reactivate failed');
135
+ }
136
+ };
137
+
138
+ // Kebab — the source contextItems (GroupView.js:1361-1373) minus "Edit
139
+ // {Noun}" (Identity autosave replaces it — see the header comment).
140
+ const MENU: DetailMenuEntry<GroupRow>[] = [
141
+ {
142
+ label: 'Add or Invite Member', icon: 'bi-person-plus',
143
+ permissions: MEMBER_INVITE_PERMISSIONS,
144
+ onSelect: () => {
145
+ void openMemberAdmissionDialog({
146
+ group,
147
+ canInvite: canInviteMember,
148
+ canCreate: canCreateMember,
149
+ canReadUsers,
150
+ queryClient: qc,
151
+ });
152
+ },
153
+ },
154
+ {
155
+ label: `Add Sub-${noun}`, icon: 'bi-diagram-3',
156
+ permissions: GROUP_MANAGE_PERMS,
157
+ onSelect: () => { void runAddSubGroupFlow(group, groupSave.mutateAsync); },
158
+ },
159
+ {
160
+ label: 'Configure Auth', icon: 'bi-box-arrow-in-right',
161
+ permissions: GROUP_MANAGE_PERMS,
162
+ onSelect: () => { openAuthConfigDialog(group); },
163
+ },
164
+ {
165
+ label: 'View Parent', icon: 'bi-arrow-up-right-square',
166
+ when: (g) => Boolean(g?.parent?.id),
167
+ onSelect: () => { if (group.parent?.id) openGroupById(group.parent.id); },
168
+ },
169
+ { divider: true },
170
+ {
171
+ label: group.is_active ? `Deactivate ${noun}` : `Activate ${noun}`,
172
+ icon: group.is_active ? 'bi-toggle-off' : 'bi-toggle-on',
173
+ danger: group.is_active,
174
+ permissions: GROUP_DESTRUCTIVE_PERMS,
175
+ onSelect: () => { void (group.is_active ? deactivate() : activate()); },
176
+ },
177
+ ];
178
+
179
+ const shownMembers = memberCount ?? group.member_count;
180
+ const timezone = typeof group.metadata?.timezone === 'string' ? group.metadata.timezone : null;
181
+ const eodRaw = group.metadata?.eod_hour;
182
+ const eodChip = eodRaw !== undefined && eodRaw !== null && eodRaw !== ''
183
+ ? `EOD ${String(eodRaw).padStart(2, '0')}:00` : null;
184
+
185
+ return (
186
+ <DetailView<GroupRow>
187
+ icon={iconForKind(group.kind)}
188
+ title={group.name}
189
+ subtitle={group.parent?.name ?? undefined}
190
+ chips={[
191
+ ...(group.kind ? [{ text: kindLabel(group.kind), tone: 'primary' as const }] : []),
192
+ ...(shownMembers > 0 ? [{ icon: 'bi-people', text: `${shownMembers} member${shownMembers === 1 ? '' : 's'}`, tone: 'muted' as const }] : []),
193
+ ...(subCount != null && subCount > 0 ? [{ icon: 'bi-diagram-3', text: `${subCount} sub-group${subCount === 1 ? '' : 's'}`, tone: 'muted' as const }] : []),
194
+ ...(timezone ? [{ text: timezone, tone: 'muted' as const }] : []),
195
+ ...(eodChip ? [{ text: eodChip, tone: 'muted' as const }] : []),
196
+ ...(group.metadata?.portal ? [{ icon: 'bi-globe', text: 'Has portal', tone: 'muted' as const }] : []),
197
+ ]}
198
+ // Toggle hidden below manage_groups (source _buildHeaderAux gate).
199
+ // Off → deactivate flow (cancel leaves it on); on → reactivate.
200
+ active={canManage
201
+ ? { value: group.is_active, onChange: (next) => { void (next ? activate() : deactivate()); } }
202
+ : undefined}
203
+ contextMenu={MENU}
204
+ menuContext={group}
205
+ badges={{
206
+ Members: shownMembers > 0 ? shownMembers : null,
207
+ SubGroups: subCount != null && subCount > 0 ? subCount : null,
208
+ ApiKeys: keyCount != null && keyCount > 0 ? keyCount : null,
209
+ Webhooks: hookCount != null && hookCount > 0 ? hookCount : null,
210
+ Audit: auditCount != null && auditCount > 0 ? auditCount : null,
211
+ }}
212
+ onClose={onClose}
213
+ sections={[
214
+ {
215
+ key: 'Overview', label: 'Overview', icon: 'bi-grid-1x2',
216
+ render: () => (
217
+ <OverviewSection
218
+ group={group}
219
+ openGroup={openGroupById}
220
+ canViewCredentials={canAccessCredentials}
221
+ canViewAudit={canViewAudit}
222
+ />
223
+ ),
224
+ },
225
+ { key: 'Identity', label: 'Identity', icon: 'bi-card-text', render: () => <IdentitySection group={group} editable={canManage} /> },
226
+ { divider: 'Membership' },
227
+ { key: 'Members', label: 'Members', icon: 'bi-people', render: () => <MembersSection group={group} openGroup={openGroupById} /> },
228
+ { key: 'SubGroups', label: 'Sub-Groups', icon: 'bi-diagram-3', render: () => <SubGroupsSection group={group} openGroup={openGroupById} /> },
229
+ { divider: 'Access' },
230
+ {
231
+ key: 'ApiKeys', label: 'API Keys', icon: 'bi-key',
232
+ permissions: GLOBAL_CREDENTIAL_PERMS,
233
+ render: () => <GroupApiKeysSection group={group} permission={GLOBAL_CREDENTIAL_PERMS} />,
234
+ },
235
+ {
236
+ key: 'Webhooks', label: 'Webhooks', icon: 'bi-broadcast',
237
+ permissions: GLOBAL_CREDENTIAL_PERMS,
238
+ render: () => <WebhookSubscriptionsSection group={group} permission={GLOBAL_CREDENTIAL_PERMS} />,
239
+ },
240
+ {
241
+ key: 'Geofencing', label: 'Geofencing', icon: 'bi-globe-americas',
242
+ permissions: GEOFENCE_VIEW_PERMS,
243
+ render: () => <GeofenceSection group={group} />,
244
+ },
245
+ { divider: 'Activity' },
246
+ {
247
+ key: 'Events', label: 'Events', icon: 'bi-calendar-event',
248
+ permissions: GLOBAL_SECURITY_PERMS,
249
+ render: () => <EventsSection group={group} />,
250
+ },
251
+ {
252
+ key: 'Audit', label: 'Audit', icon: 'bi-clock-history',
253
+ // Wider than the source's bare 'view_logs': the fetch
254
+ // itself needs logit's VIEW_PERMS, so the gate matches
255
+ // what can actually load (fail-closed alignment).
256
+ permissions: GLOBAL_LOG_PERMS,
257
+ render: () => <AuditSection group={group} />,
258
+ },
259
+ { divider: 'Detail' },
260
+ {
261
+ key: 'Metadata', label: 'Metadata', icon: 'bi-braces',
262
+ render: () => (
263
+ <>
264
+ {canManage && (
265
+ <MetadataSection
266
+ endpoint={GroupModel.endpoint}
267
+ id={group.id}
268
+ metadata={group.metadata ?? {}}
269
+ onSaved={(next) => {
270
+ // Owner write-through + refetch (the primitive is
271
+ // controlled — it never mirrors server state).
272
+ qc.setQueryData(GroupModel.keys.one(group.id), { ...group, metadata: next });
273
+ void GroupModel.invalidate(qc);
274
+ }}
275
+ />
276
+ )}
277
+ <div style={{ marginTop: 16 }}>
278
+ <Eyebrow>Raw metadata (JSON)</Eyebrow>
279
+ <pre className="dim" style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}><code>{JSON.stringify(group.metadata ?? {}, null, 2)}</code></pre>
280
+ </div>
281
+ </>
282
+ ),
283
+ },
284
+ ]}
285
+ />
286
+ );
287
+ }