portal-mojo 0.2.5 → 0.2.7

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 (43) hide show
  1. package/README.md +2 -2
  2. package/docs/README.md +1 -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/menus-and-access.md +6 -2
  9. package/docs/user-menu.md +131 -0
  10. package/package.json +1 -1
  11. package/src/admin/core/index.ts +4 -4
  12. package/src/admin/domains/identity.ts +19 -1
  13. package/src/admin/identity/groups/GroupDetail.tsx +287 -0
  14. package/src/admin/identity/groups/GroupsPage.tsx +125 -0
  15. package/src/admin/identity/groups/group-permissions.ts +5 -0
  16. package/src/admin/identity/groups/group-sections/AuditSection.tsx +57 -0
  17. package/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx +677 -0
  18. package/src/admin/identity/groups/group-sections/EventsSection.tsx +53 -0
  19. package/src/admin/identity/groups/group-sections/GeofenceSection.tsx +198 -0
  20. package/src/admin/identity/groups/group-sections/IdentitySection.tsx +211 -0
  21. package/src/admin/identity/groups/group-sections/MembersSection.tsx +25 -0
  22. package/src/admin/identity/groups/group-sections/OverviewSection.tsx +112 -0
  23. package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +102 -0
  24. package/src/admin/identity/groups/group-sections/auth-config.ts +285 -0
  25. package/src/admin/identity/groups/group-sections/geofence-data.ts +40 -0
  26. package/src/admin/identity/groups/group-sections/models.ts +110 -0
  27. package/src/admin/identity/groups/group-sections/shared.ts +21 -0
  28. package/src/admin/identity/groups/index.ts +6 -0
  29. package/src/admin/identity/groups/models.ts +70 -0
  30. package/src/admin/identity/index.ts +1 -0
  31. package/src/admin/identity/members/MemberDetail.tsx +32 -3
  32. package/src/admin/identity/members/models.ts +3 -0
  33. package/src/admin/identity/users/PersonalApiKeysPage.tsx +220 -0
  34. package/src/admin/identity/users/UserDetail.tsx +2 -2
  35. package/src/admin/identity/users/index.ts +2 -0
  36. package/src/admin/identity/users/sections/PermissionsSection.tsx +2 -2
  37. package/src/admin/registry.ts +3 -1
  38. package/src/ui/FileField.tsx +1 -10
  39. package/src/ui/SidebarNav.tsx +2 -2
  40. package/src/ui/UserMenu.tsx +401 -0
  41. package/src/ui/index.ts +1 -0
  42. package/src/ui/safe-url.ts +14 -0
  43. package/src/ui/shell.ts +1 -0
@@ -0,0 +1,53 @@
1
+ // group-sections/EventsSection.tsx — the incident Events table port
2
+ // (GroupView.js:1271-1282): events recorded ABOUT this group
3
+ // (model_name=account.Group, model_id=<id>) with the source's columns —
4
+ // Date / Category badge / Title.
5
+ import { Badge, Eyebrow, fmt } from 'portal-mojo/ui';
6
+ import type { GroupRow } from '../models';
7
+ import { IncidentEventModel } from './models';
8
+
9
+ export function EventsSection({ group }: { group: GroupRow }) {
10
+ const { data, isPending, isError, error } = IncidentEventModel.useList({
11
+ model_name: 'account.Group',
12
+ model_id: group.id,
13
+ size: 25,
14
+ sort: '-created',
15
+ });
16
+ const rows = data?.rows ?? [];
17
+
18
+ return (
19
+ <>
20
+ <Eyebrow>Events</Eyebrow>
21
+ {isError && (
22
+ <p className="dim-italic">
23
+ Events unavailable — {error instanceof Error ? error.message : 'the incident API rejected the request'}.
24
+ </p>
25
+ )}
26
+ {!isPending && !isError && rows.length === 0 && (
27
+ <p className="dim-italic">No incident events recorded for this group.</p>
28
+ )}
29
+ {rows.length > 0 && (
30
+ <div className="tbl-scroll">
31
+ <table className="tbl">
32
+ <thead>
33
+ <tr>
34
+ <th style={{ width: 170 }}>Date</th>
35
+ <th>Category</th>
36
+ <th>Title</th>
37
+ </tr>
38
+ </thead>
39
+ <tbody>
40
+ {rows.map((ev) => (
41
+ <tr key={ev.id}>
42
+ <td className="dim">{fmt.datetime(ev.created)}</td>
43
+ <td><Badge tone="info">{ev.category}</Badge></td>
44
+ <td>{ev.title ?? <span className="dim">—</span>}</td>
45
+ </tr>
46
+ ))}
47
+ </tbody>
48
+ </table>
49
+ </div>
50
+ )}
51
+ </>
52
+ );
53
+ }
@@ -0,0 +1,198 @@
1
+ // group-sections/GeofenceSection.tsx — GroupGeofenceSection port
2
+ // (admin/account/groups/GroupGeofenceSection.js, read in full 2026-08-05):
3
+ // the GROUP-scoped geofence panel, three layers so an operator always sees
4
+ // the effective policy, never just the fragment they can edit:
5
+ // 1. the platform floor (read-only, GET /api/geo/rules?group_uuid=),
6
+ // 2. this group's additional rules (editable — they can only tighten),
7
+ // 3. the effective policy (floor + group, each clause tagged by source).
8
+ //
9
+ // FAIL-CLOSED floor: portal_test measures a 403 on /api/geo/rules — correct
10
+ // gating (the endpoint honors only GLOBAL geofence grants). The floor card
11
+ // then reads "Platform rules unavailable (requires the global geofence
12
+ // permission)" and the group's own rule (from group.metadata.geofence) still
13
+ // renders + edits — exactly the source's degradation.
14
+ //
15
+ // Saves go through the standard group REST: django deep-merges the metadata
16
+ // JSONField (null deletes a key; nested __replace is NOT supported), so the
17
+ // payload comes from buildGroupRulePayload — the new rule plus explicit
18
+ // nulls for anything removed. The server validates the merged rule and 400s
19
+ // with a human-readable message, surfaced inline + toast.
20
+ import { useMemo, useState } from 'react';
21
+ import { useQuery } from '@tanstack/react-query';
22
+ import { Eyebrow, toast } from 'portal-mojo/ui';
23
+ import { mojoCall, useCan } from 'portal-mojo/client/runtime';
24
+ // ONE editor, ONE projection, two scopes (#1287): the platform page and this
25
+ // group panel now render the same `GeofenceRuleEditor` — including its
26
+ // guided↔JSON toggle, which this file's private FriendlyEditor never had.
27
+ import {
28
+ GeofenceRuleEditor, makeRuleEditorValue, ruleFromEditorValue,
29
+ type RuleEditorValue,
30
+ } from 'portal-mojo/admin/security';
31
+ import { GroupModel, type GroupRow } from '../models';
32
+ import {
33
+ GROUP_GEOFENCE_EDIT_PERMS, buildGroupRulePayload, describeRule, isAdvancedRule,
34
+ type GeoRulesConfig, type GeofenceRule, type RuleClause,
35
+ } from './geofence-data';
36
+
37
+ const CLAUSE_ICONS: Record<RuleClause['tone'], string> = {
38
+ block: 'bi-slash-circle',
39
+ warn: 'bi-exclamation-triangle',
40
+ ok: 'bi-check-circle',
41
+ };
42
+
43
+ function ClauseRow({ clause, source }: { clause: RuleClause; source?: 'platform' | 'group' }) {
44
+ return (
45
+ <div className={`ga-clause ga-clause-${clause.tone}`}>
46
+ <i className={`bi ${CLAUSE_ICONS[clause.tone]}`} />
47
+ <span>{clause.text}</span>
48
+ {source && (
49
+ <span className={`chip ${source === 'platform' ? 'chip-muted' : 'chip-primary'}`}>
50
+ {source === 'platform' ? 'Platform' : 'This group'}
51
+ </span>
52
+ )}
53
+ </div>
54
+ );
55
+ }
56
+
57
+ export function GeofenceSection({ group }: { group: GroupRow }) {
58
+ const { can: canEdit } = useCan(GROUP_GEOFENCE_EDIT_PERMS);
59
+ const save = GroupModel.useSave();
60
+
61
+ // The platform floor + the server's view of this group's rule. 4xx
62
+ // (MojoError) never retries under mojoQueryDefaults; a 403 lands in
63
+ // `error` and the card degrades — fail-closed, never a spinner forever.
64
+ const floorQuery = useQuery({
65
+ queryKey: ['geo-rules', group.uuid ?? group.id],
66
+ queryFn: async (): Promise<GeoRulesConfig> => {
67
+ const body = await mojoCall('/api/geo/rules', {
68
+ params: group.uuid ? { group_uuid: group.uuid } : {},
69
+ });
70
+ return (body.data ?? {}) as GeoRulesConfig;
71
+ },
72
+ });
73
+
74
+ const floorRule: GeofenceRule = floorQuery.data?.system?.rule ?? {};
75
+ // The group rule: prefer the server's view (the /api/geo/rules payload
76
+ // echoes it), else the metadata blob on the row.
77
+ const groupRuleStored: GeofenceRule = useMemo(() => {
78
+ const fromServer = floorQuery.data?.group?.rule;
79
+ if (fromServer !== undefined && fromServer !== null) return fromServer;
80
+ const meta = group.metadata?.geofence;
81
+ return meta && typeof meta === 'object' && !Array.isArray(meta) ? meta as GeofenceRule : {};
82
+ }, [floorQuery.data, group.metadata]);
83
+
84
+ const advancedForced = isAdvancedRule(groupRuleStored);
85
+
86
+ // Editor state, re-baselined whenever the STORED rule changes identity
87
+ // (post-save refetch). A live form must not be rebuilt under the user —
88
+ // the baseline key ties state to the stored snapshot (source
89
+ // _onModelChange no-op semantics).
90
+ const baselineKey = JSON.stringify(groupRuleStored);
91
+ const [editorState, setEditorState] = useState<{ key: string; value: RuleEditorValue }>(() => ({
92
+ key: baselineKey,
93
+ value: makeRuleEditorValue(groupRuleStored),
94
+ }));
95
+ if (editorState.key !== baselineKey) {
96
+ setEditorState({ key: baselineKey, value: makeRuleEditorValue(groupRuleStored) });
97
+ }
98
+ const [status, setStatus] = useState<{ text: string; tone: 'muted' | 'ok' | 'bad' }>({ text: '', tone: 'muted' });
99
+
100
+ const floorClauses = describeRule(floorRule);
101
+ const groupClauses = describeRule(groupRuleStored);
102
+ const floorEmptyText = floorQuery.isPending
103
+ ? 'Loading platform rules…'
104
+ : floorQuery.isError
105
+ ? 'Platform rules unavailable (requires the global geofence permission).'
106
+ : 'No platform rules — the floor allows all locations.';
107
+
108
+ const saveRules = async () => {
109
+ const newRule: GeofenceRule | null = ruleFromEditorValue(editorState.value);
110
+ if (newRule === null) {
111
+ setStatus({ text: 'Not saved — the rule must be a valid JSON object.', tone: 'bad' });
112
+ toast.error('Not saved — the rule must be a valid JSON object.');
113
+ return;
114
+ }
115
+
116
+ const payload = buildGroupRulePayload(groupRuleStored, newRule);
117
+ if (payload === null) {
118
+ setStatus({ text: 'No changes to save.', tone: 'muted' });
119
+ return;
120
+ }
121
+
122
+ setStatus({ text: 'Saving…', tone: 'muted' });
123
+ try {
124
+ await save.mutateAsync({ id: group.id, changes: { metadata: { geofence: payload } } });
125
+ await floorQuery.refetch();
126
+ setStatus({ text: 'All changes saved.', tone: 'ok' });
127
+ toast.success('Group geofence rules saved');
128
+ } catch (err) {
129
+ const msg = err instanceof Error ? err.message : 'Failed to save the group rules';
130
+ setStatus({ text: msg, tone: 'bad' });
131
+ toast.error(msg);
132
+ }
133
+ };
134
+
135
+ return (
136
+ <>
137
+ <Eyebrow>Geofencing</Eyebrow>
138
+ <p className="dim" style={{ margin: '0 0 12px' }}>
139
+ Rules here apply only to this group and can only <b>tighten</b> the
140
+ platform rules — they can't loosen the platform floor.
141
+ </p>
142
+
143
+ <div className="ga-geo-card">
144
+ <div className="ga-geo-card-head"><i className="bi bi-lock" /> Platform floor — read only</div>
145
+ <div className="ga-geo-card-body">
146
+ {floorClauses.length > 0
147
+ ? floorClauses.map((c, i) => <ClauseRow key={i} clause={c} />)
148
+ : <div className="dim">{floorEmptyText}</div>}
149
+ </div>
150
+ </div>
151
+
152
+ {canEdit ? (
153
+ <div className="ga-geo-card">
154
+ <div className="ga-geo-card-head">This group's additional rules</div>
155
+ <div className="ga-geo-card-body">
156
+ <GeofenceRuleEditor
157
+ value={editorState.value}
158
+ onChange={(value) => setEditorState({ key: baselineKey, value })}
159
+ advancedForced={advancedForced}
160
+ disabled={save.isPending}
161
+ />
162
+ <div className="ga-geo-save-row">
163
+ <span className={`ga-geo-status ${status.tone === 'bad' ? 'ga-status-bad' : status.tone === 'ok' ? 'ga-status-ok' : 'dim'}`}>
164
+ {status.text}
165
+ </span>
166
+ <button className="btn btn-primary btn-compact" disabled={save.isPending} onClick={() => void saveRules()}>
167
+ <i className="bi bi-check-lg" /> Save group rules
168
+ </button>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ ) : (
173
+ <div className="ga-geo-card">
174
+ <div className="ga-geo-card-body dim">
175
+ <i className="bi bi-eye" /> You have read-only access to this group's geofence rules.
176
+ </div>
177
+ </div>
178
+ )}
179
+
180
+ <div className="ga-geo-card">
181
+ <div className="ga-geo-card-head">
182
+ Effective policy for this group
183
+ <span className="ga-geo-eyebrow">what a visitor experiences</span>
184
+ </div>
185
+ <div className="ga-geo-card-body">
186
+ {floorClauses.length === 0 && groupClauses.length === 0 ? (
187
+ <div className="dim">No rules apply — all locations are allowed.</div>
188
+ ) : (
189
+ <>
190
+ {floorClauses.map((c, i) => <ClauseRow key={`f${i}`} clause={c} source="platform" />)}
191
+ {groupClauses.map((c, i) => <ClauseRow key={`g${i}`} clause={c} source="group" />)}
192
+ </>
193
+ )}
194
+ </div>
195
+ </div>
196
+ </>
197
+ );
198
+ }
@@ -0,0 +1,211 @@
1
+ // group-sections/IdentitySection.tsx — GroupIdentitySection port
2
+ // (GroupView.js:354-731) at source fidelity, in the house autosave idiom:
3
+ // web-mojo's pencil-prompt-per-row grid collapses into ONE FormView autosave
4
+ // surface carrying the safely editable fields — name, kind (combo over
5
+ // the full known-kind catalog + free entry), uuid, and the metadata.*
6
+ // settings (timezone / short_name / domain /
7
+ // auth_domain / portal / email_template). Dotted names → partial dicts →
8
+ // the backend's JSONField dict-merge, so editing one key never clobbers the
9
+ // rest of metadata.
10
+ //
11
+ // Parent assignment stays read-only until the backend can reject hierarchy
12
+ // cycles. Existing parents may only be cleared to the top level. EOD hour
13
+ // lives outside FormView because the source saved a NUMBER (0–23, or null
14
+ // to clear) and
15
+ // select values ride the form pipeline as strings.
16
+ //
17
+ // UUID extras (source onActionGenerateUuid): when unset, a Generate button
18
+ // mints backend-shaped `uuid4().hex` (32 lowercase hex chars, no hyphens)
19
+ // after a confirm.
20
+ import {
21
+ Badge, Eyebrow, FlatRow, FormView,
22
+ fmt, modal, toast, type Field,
23
+ } from 'portal-mojo/ui';
24
+ import { GroupModel, type GroupRow } from '../models';
25
+ import { EOD_HOUR_OPTIONS, GROUP_KIND_COMBO_OPTIONS, kindLabel } from './models';
26
+
27
+ const IDENTITY_FIELDS: Field[] = [
28
+ { name: 'name', type: 'text', label: 'Name', columns: 6 },
29
+ {
30
+ name: 'kind', type: 'combo', label: 'Kind', columns: 6,
31
+ options: GROUP_KIND_COMBO_OPTIONS, placeholder: 'Type or pick a kind…',
32
+ help: 'Drives sidebar context; free-typed kinds are allowed.',
33
+ },
34
+ {
35
+ name: 'uuid', type: 'text', label: 'UUID', columns: 6,
36
+ placeholder: '32-character hex string',
37
+ },
38
+ ];
39
+
40
+ const SETTINGS_FIELDS: Field[] = [
41
+ { name: 'metadata.timezone', type: 'timezone', label: 'Timezone', columns: 6 },
42
+ { name: 'metadata.short_name', type: 'text', label: 'Short name', columns: 6 },
43
+ { name: 'metadata.domain', type: 'text', label: 'Domain', columns: 6, placeholder: 'example.com' },
44
+ {
45
+ name: 'metadata.auth_domain', type: 'text', label: 'Auth domain', columns: 6,
46
+ placeholder: 'auth.example.com', help: 'Used for white-label login pages.',
47
+ },
48
+ { name: 'metadata.portal', type: 'text', label: 'Portal URL', columns: 6, placeholder: 'https://…' },
49
+ {
50
+ name: 'metadata.email_template', type: 'text', label: 'Email template', columns: 6,
51
+ placeholder: 'Template prefix',
52
+ },
53
+ ];
54
+
55
+ /** Backend-shaped uuid4().hex: 32 lowercase hex chars, no hyphens. */
56
+ function generateUuidHex(): string | null {
57
+ try {
58
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
59
+ return crypto.randomUUID().replace(/-/g, '');
60
+ }
61
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
62
+ const bytes = new Uint8Array(16);
63
+ crypto.getRandomValues(bytes);
64
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
65
+ }
66
+ } catch {
67
+ // fall through
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function shown(value: unknown): string {
73
+ return value == null || value === '' ? 'Not set' : String(value);
74
+ }
75
+
76
+ export function IdentitySection({ group, editable }: { group: GroupRow; editable: boolean }) {
77
+ const save = GroupModel.useSave();
78
+
79
+ const eodRaw = group.metadata?.eod_hour;
80
+ const eodHour = typeof eodRaw === 'number' ? eodRaw
81
+ : typeof eodRaw === 'string' && eodRaw !== '' ? Number(eodRaw)
82
+ : null;
83
+ const eodValid = eodHour !== null && Number.isFinite(eodHour) && eodHour >= 0 && eodHour <= 23;
84
+ if (eodHour !== null && !eodValid) {
85
+ console.warn(`IdentitySection: metadata.eod_hour ${JSON.stringify(eodRaw)} is not 0–23 — showing "Not set"`);
86
+ }
87
+
88
+ const saveEod = async (raw: string) => {
89
+ // '' clears (null deletes the key under the JSONField merge); numbers
90
+ // ride the wire as real numbers (source parity — never "17" strings).
91
+ const next = raw === '' ? null : Math.max(0, Math.min(23, Number(raw)));
92
+ try {
93
+ await save.mutateAsync({ id: group.id, changes: { metadata: { eod_hour: next } } });
94
+ toast.success('EOD hour updated');
95
+ } catch (err) {
96
+ toast.error(err instanceof Error ? err.message : 'Failed to update EOD hour');
97
+ }
98
+ };
99
+
100
+ const clearParent = async () => {
101
+ try {
102
+ await save.mutateAsync({ id: group.id, changes: { parent: null } });
103
+ toast.success('Parent cleared — now a top-level group');
104
+ } catch (err) {
105
+ toast.error(err instanceof Error ? err.message : 'Failed to update parent');
106
+ }
107
+ };
108
+
109
+ const generateUuid = async () => {
110
+ const generated = generateUuidHex();
111
+ if (!generated) {
112
+ toast.error('UUID generation not supported in this browser');
113
+ return;
114
+ }
115
+ const ok = await modal.confirm({
116
+ title: 'Generate UUID',
117
+ message: <>Generate a new UUID for this group?<br /><code>{generated}</code></>,
118
+ confirmText: 'Generate',
119
+ });
120
+ if (!ok) return;
121
+ try {
122
+ await save.mutateAsync({ id: group.id, changes: { uuid: generated } });
123
+ toast.success('UUID updated');
124
+ } catch (err) {
125
+ toast.error(err instanceof Error ? err.message : 'Failed to update UUID');
126
+ }
127
+ };
128
+
129
+ return (
130
+ <>
131
+ <Eyebrow>Profile</Eyebrow>
132
+ {editable ? (
133
+ <>
134
+ <p className="dim" style={{ margin: '0 0 12px' }}>Edits save on commit — no save button.</p>
135
+ <FormView model={GroupModel} row={group} fields={IDENTITY_FIELDS} />
136
+ </>
137
+ ) : (
138
+ <>
139
+ <FlatRow label="Name">{group.name}</FlatRow>
140
+ <FlatRow label="Kind">{shown(group.kind)}</FlatRow>
141
+ <FlatRow label="UUID"><code>{shown(group.uuid)}</code></FlatRow>
142
+ </>
143
+ )}
144
+ {editable && !group.uuid && (
145
+ <button className="btn btn-compact" style={{ marginTop: 4 }} onClick={() => void generateUuid()}>
146
+ <i className="bi bi-shuffle" /> Generate UUID
147
+ </button>
148
+ )}
149
+ <div className="ga-parent-row">
150
+ <FlatRow label="Parent group">
151
+ <span>{group.parent?.name ?? 'None — top-level group'}</span>
152
+ {editable && group.parent?.id && (
153
+ <button className="btn btn-compact" style={{ marginLeft: 10 }} onClick={() => void clearParent()}>
154
+ <i className="bi bi-x-circle" /> Clear to top-level
155
+ </button>
156
+ )}
157
+ </FlatRow>
158
+ {editable && group.parent?.id && (
159
+ <p className="field-help">Assigning or changing a parent is unavailable until the server validates hierarchy cycles.</p>
160
+ )}
161
+ </div>
162
+
163
+ <Eyebrow>Settings</Eyebrow>
164
+ {editable ? (
165
+ <FormView model={GroupModel} row={group} fields={SETTINGS_FIELDS} />
166
+ ) : (
167
+ <>
168
+ <FlatRow label="Timezone">{shown(group.metadata?.timezone)}</FlatRow>
169
+ <FlatRow label="Short name">{shown(group.metadata?.short_name)}</FlatRow>
170
+ <FlatRow label="Domain">{shown(group.metadata?.domain)}</FlatRow>
171
+ <FlatRow label="Auth domain">{shown(group.metadata?.auth_domain)}</FlatRow>
172
+ <FlatRow label="Portal URL">{shown(group.metadata?.portal)}</FlatRow>
173
+ <FlatRow label="Email template">{shown(group.metadata?.email_template)}</FlatRow>
174
+ </>
175
+ )}
176
+ <div className="ga-eod-row">
177
+ {editable ? (
178
+ <label className="field">
179
+ <span className="field-label">EOD hour</span>
180
+ <select
181
+ className="input"
182
+ value={eodValid ? String(eodHour) : ''}
183
+ onChange={(e) => void saveEod(e.target.value)}
184
+ >
185
+ <option value="">Not set</option>
186
+ {EOD_HOUR_OPTIONS.map((o) => (
187
+ <option key={o.value} value={String(o.value)}>
188
+ {String(o.value).padStart(2, '0')}:00 — {o.label}
189
+ </option>
190
+ ))}
191
+ </select>
192
+ <span className="field-help">Hour of the day (24h) when this group rolls over.</span>
193
+ </label>
194
+ ) : (
195
+ <FlatRow label="EOD hour">{eodValid ? `${String(eodHour).padStart(2, '0')}:00` : 'Not set'}</FlatRow>
196
+ )}
197
+ </div>
198
+
199
+ <Eyebrow>Record</Eyebrow>
200
+ <FlatRow label="ID"><code>{group.id}</code></FlatRow>
201
+ <FlatRow label="Status">
202
+ <Badge tone={group.is_active ? 'success' : 'muted'}>{group.is_active ? 'Active' : 'Inactive'}</Badge>
203
+ </FlatRow>
204
+ <FlatRow label="Kind">
205
+ {group.kind ? <Badge tone="primary">{kindLabel(group.kind)}</Badge> : <span className="dim-italic">Not set</span>}
206
+ </FlatRow>
207
+ <FlatRow label="Created"><code>{fmt.datetime(group.created)}</code></FlatRow>
208
+ <FlatRow label="Modified"><code>{fmt.datetime(group.modified)}</code></FlatRow>
209
+ </>
210
+ );
211
+ }
@@ -0,0 +1,25 @@
1
+ // Thin app adapter: the package owns member listing, admission, and detail.
2
+ // Cross-domain navigation stays in the app so the package never imports the
3
+ // app-owned GroupDetail implementation.
4
+ import { GroupMembersPanel } from '../../members';
5
+ import { UserDetail } from '../../users/UserDetail';
6
+ import type { GroupRow } from '../models';
7
+ import { modal } from 'portal-mojo/ui';
8
+
9
+ export function MembersSection({ group, openGroup }: { group: GroupRow; openGroup: (id: number) => void }) {
10
+ return (
11
+ <GroupMembersPanel
12
+ group={group}
13
+ onNavigateGroup={openGroup}
14
+ onNavigateUser={(id) => {
15
+ void modal.detail((close) => (
16
+ <UserDetail
17
+ id={id}
18
+ onClose={() => close(null)}
19
+ onOpenGroup={openGroup}
20
+ />
21
+ ));
22
+ }}
23
+ />
24
+ );
25
+ }
@@ -0,0 +1,112 @@
1
+ // group-sections/OverviewSection.tsx — GroupOverviewSection port
2
+ // (GroupView.js:98-349): four KPI cards (Members / Sub-Groups / API Keys /
3
+ // Last activity), the "This group" flat rows, the monospace hierarchy
4
+ // mini-tree with cross-record navigation, and the recent-activity timeline
5
+ // fed by the group's OBJECT logs (model_name=account.Group — the source's
6
+ // shared audit collection, not the gid context feed).
7
+ import {
8
+ Badge, Eyebrow, FlatRow, MetricCard, Timeline,
9
+ fmt, type TimelineItem,
10
+ } from 'portal-mojo/ui';
11
+ import { GroupApiKeyModel, type GroupApiKeyRow } from '../../../credentials';
12
+ import { LogModel, type LogRow } from 'portal-mojo/admin/observability';
13
+ import { useModelList } from 'portal-mojo/client/runtime';
14
+ import { GroupModel, MemberModel, type GroupRow } from '../models';
15
+ import { kindLabel } from './models';
16
+ import { LOG_TONE, groupAuditParams } from './shared';
17
+
18
+ export function OverviewSection({ group, openGroup, canViewCredentials, canViewAudit }: {
19
+ group: GroupRow;
20
+ openGroup: (id: number) => void;
21
+ canViewCredentials: boolean;
22
+ canViewAudit: boolean;
23
+ }) {
24
+ const { data: subs } = GroupModel.useList({ parent: group.id, size: 25, sort: 'name' });
25
+ const { data: memberPeek } = MemberModel.useList({ group: group.id, size: 1, is_active: true });
26
+ const { data: keyPeek } = useModelList<GroupApiKeyRow>(
27
+ GroupApiKeyModel.endpoint,
28
+ { group: group.id, size: 1, graph: 'default' },
29
+ { enabled: canViewCredentials },
30
+ );
31
+ const { data: logs } = useModelList<LogRow>(
32
+ LogModel.endpoint,
33
+ { ...groupAuditParams(group.id), size: 5 },
34
+ { enabled: canViewAudit },
35
+ );
36
+
37
+ const subGroups = subs?.rows ?? [];
38
+ const subCount = subs?.count ?? subGroups.length;
39
+ const memberCount = memberPeek?.count ?? group.member_count;
40
+ const items: TimelineItem[] = (logs?.rows ?? []).map((l) => ({
41
+ tone: LOG_TONE[l.level] ?? 'muted',
42
+ title: l.kind ?? l.level,
43
+ body: l.log ? <span className="dim">{fmt.truncate(l.log, 90)}</span> : undefined,
44
+ meta: fmt.relative(l.created),
45
+ }));
46
+
47
+ const parentLink = group.parent?.id ? (
48
+ <a href="#" onClick={(e) => { e.preventDefault(); openGroup(group.parent!.id); }}>
49
+ {group.parent.name ?? `#${group.parent.id}`}
50
+ </a>
51
+ ) : null;
52
+
53
+ return (
54
+ <>
55
+ <div className="grid gap-3 grid-cols-2 xl:grid-cols-4" style={{ marginBottom: 14 }}>
56
+ <MetricCard label="Members" value={memberCount} />
57
+ <MetricCard label="Sub-groups" value={subs ? subCount : '—'} />
58
+ <MetricCard label="API keys" value={canViewCredentials ? (keyPeek?.count ?? '—') : 'Unavailable'} />
59
+ <MetricCard label="Last activity" value={fmt.relative(group.last_activity, '—')} />
60
+ </div>
61
+
62
+ <Eyebrow>This group</Eyebrow>
63
+ <FlatRow label="Name">{group.name}</FlatRow>
64
+ <FlatRow label="Kind">
65
+ {group.kind ? <Badge tone="primary">{kindLabel(group.kind)}</Badge> : <span className="dim-italic">Not set</span>}
66
+ </FlatRow>
67
+ <FlatRow label="Status">
68
+ <Badge tone={group.is_active ? 'success' : 'muted'}>{group.is_active ? 'Active' : 'Inactive'}</Badge>
69
+ </FlatRow>
70
+ <FlatRow label="Parent">
71
+ {parentLink ?? <span className="dim-italic">None — top-level group</span>}
72
+ </FlatRow>
73
+
74
+ <Eyebrow>Hierarchy</Eyebrow>
75
+ {/* GroupHierarchyTree port — the └─/├─ rules are decorative; the
76
+ links are the cross-record nav (parent + sub-group open a
77
+ STACKED GroupDetail via native <dialog> stacking). */}
78
+ <div className="ga-tree">
79
+ {group.parent?.id ? (
80
+ <a href="#" className="dim" onClick={(e) => { e.preventDefault(); openGroup(group.parent!.id); }}>
81
+ {group.parent.name ?? `#${group.parent.id}`}
82
+ </a>
83
+ ) : (
84
+ <span className="dim">Top-level group</span>
85
+ )}
86
+ <div>
87
+ └─ <b>{group.name}</b>
88
+ <span className="dim">
89
+ {' '}· {memberCount} member{memberCount === 1 ? '' : 's'} · {subCount} sub-group{subCount === 1 ? '' : 's'}
90
+ </span>
91
+ </div>
92
+ {subGroups.length > 0 && (
93
+ <div className="ga-tree-children">
94
+ {subGroups.map((sg, i) => (
95
+ <div key={sg.id}>
96
+ {i === subGroups.length - 1 ? '└─' : '├─'}{' '}
97
+ <a href="#" onClick={(e) => { e.preventDefault(); openGroup(sg.id); }}>{sg.name}</a>
98
+ </div>
99
+ ))}
100
+ </div>
101
+ )}
102
+ </div>
103
+
104
+ <Eyebrow>Recent activity</Eyebrow>
105
+ {canViewAudit ? (
106
+ <Timeline items={items} emptyText="No recorded activity for this group yet." />
107
+ ) : (
108
+ <p className="dim-italic">Recent activity requires global log or security access.</p>
109
+ )}
110
+ </>
111
+ );
112
+ }