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.
- package/README.md +2 -2
- package/docs/README.md +1 -0
- package/docs/admin-identity-directories-sources.json +130 -0
- package/docs/admin-identity-directories.md +60 -0
- package/docs/admin-identity-users.md +6 -1
- package/docs/admin-members.md +16 -0
- package/docs/admin-network-security.md +1 -1
- package/docs/menus-and-access.md +6 -2
- package/docs/user-menu.md +131 -0
- package/package.json +1 -1
- package/src/admin/core/index.ts +4 -4
- package/src/admin/domains/identity.ts +19 -1
- package/src/admin/identity/groups/GroupDetail.tsx +287 -0
- package/src/admin/identity/groups/GroupsPage.tsx +125 -0
- package/src/admin/identity/groups/group-permissions.ts +5 -0
- package/src/admin/identity/groups/group-sections/AuditSection.tsx +57 -0
- package/src/admin/identity/groups/group-sections/AuthConfigDialog.tsx +677 -0
- package/src/admin/identity/groups/group-sections/EventsSection.tsx +53 -0
- package/src/admin/identity/groups/group-sections/GeofenceSection.tsx +198 -0
- package/src/admin/identity/groups/group-sections/IdentitySection.tsx +211 -0
- package/src/admin/identity/groups/group-sections/MembersSection.tsx +25 -0
- package/src/admin/identity/groups/group-sections/OverviewSection.tsx +112 -0
- package/src/admin/identity/groups/group-sections/SubGroupsSection.tsx +102 -0
- package/src/admin/identity/groups/group-sections/auth-config.ts +285 -0
- package/src/admin/identity/groups/group-sections/geofence-data.ts +40 -0
- package/src/admin/identity/groups/group-sections/models.ts +110 -0
- package/src/admin/identity/groups/group-sections/shared.ts +21 -0
- package/src/admin/identity/groups/index.ts +6 -0
- package/src/admin/identity/groups/models.ts +70 -0
- package/src/admin/identity/index.ts +1 -0
- package/src/admin/identity/members/MemberDetail.tsx +32 -3
- package/src/admin/identity/members/models.ts +3 -0
- package/src/admin/identity/users/PersonalApiKeysPage.tsx +220 -0
- package/src/admin/identity/users/UserDetail.tsx +2 -2
- package/src/admin/identity/users/index.ts +2 -0
- package/src/admin/identity/users/sections/PermissionsSection.tsx +2 -2
- package/src/admin/registry.ts +3 -1
- package/src/ui/FileField.tsx +1 -10
- package/src/ui/SidebarNav.tsx +2 -2
- package/src/ui/UserMenu.tsx +401 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/safe-url.ts +14 -0
- package/src/ui/shell.ts +1 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Groups — the second full-UX ModelTable screen (board #1281): chooser,
|
|
2
|
+
// filters incl. daterange, search, export, persistState, all against the
|
|
3
|
+
// live /api/group default graph (measured 2026-08-05: rows carry uuid /
|
|
4
|
+
// modified / last_activity / is_active / member_count / parent-basic).
|
|
5
|
+
// Row click opens the GroupDetail port of web-mojo's GroupView.
|
|
6
|
+
import { useQueryClient } from '@tanstack/react-query';
|
|
7
|
+
import {
|
|
8
|
+
Badge, fmt, formModal, modal, toast, ModelTable,
|
|
9
|
+
type BatchAction, type Column, type FilterDef,
|
|
10
|
+
} from 'portal-mojo/ui';
|
|
11
|
+
import { useCan } from 'portal-mojo/client/runtime';
|
|
12
|
+
import {
|
|
13
|
+
GROUP_KIND_OPTIONS, GROUP_MANAGE_PERMS, GroupModel, type GroupRow,
|
|
14
|
+
} from './models';
|
|
15
|
+
import { GroupDetail, iconForKind } from './GroupDetail';
|
|
16
|
+
|
|
17
|
+
const COLUMNS: Column<GroupRow>[] = [
|
|
18
|
+
{ key: 'id', label: 'ID', sortable: true, render: (g) => <span className="dim">{g.id}</span> },
|
|
19
|
+
{
|
|
20
|
+
key: 'name', label: 'Group', sortable: true, hideable: false, render: (g) => (
|
|
21
|
+
<div className="cell-user">
|
|
22
|
+
<span className="cell-avatar" style={{ fontSize: 13 }}><i className={`bi ${iconForKind(g.kind)}`} /></span>
|
|
23
|
+
<span>
|
|
24
|
+
<span className="cell-name">{g.name}</span>
|
|
25
|
+
<span className="cell-sub">{g.parent?.name ?? 'Top-level'}</span>
|
|
26
|
+
</span>
|
|
27
|
+
</div>
|
|
28
|
+
),
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
key: 'kind', label: 'Kind', sortable: true, render: (g) =>
|
|
32
|
+
g.kind ? <Badge tone="primary">{g.kind}</Badge> : <span className="dim">—</span>,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
key: 'member_count', label: 'Members', align: 'center', render: (g) =>
|
|
36
|
+
g.member_count > 0 ? String(g.member_count) : <span className="dim">—</span>,
|
|
37
|
+
},
|
|
38
|
+
{ key: 'is_active', label: 'Status', render: (g) => <Badge>{g.is_active ? 'Active' : 'Inactive'}</Badge> },
|
|
39
|
+
{ key: 'parent', label: 'Parent', render: (g) => g.parent?.name ?? <span className="dim">—</span> },
|
|
40
|
+
{ key: 'uuid', label: 'UUID', render: (g) => g.uuid ? <code>{g.uuid}</code> : <span className="dim">—</span> },
|
|
41
|
+
{ key: 'created', label: 'Created', sortable: true, render: (g) => fmt.date(g.created) },
|
|
42
|
+
{ key: 'last_activity', label: 'Last Activity', sortable: true, render: (g) => fmt.relative(g.last_activity, '—') },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const FILTERS: FilterDef[] = [
|
|
46
|
+
{ key: 'name', label: 'Name', type: 'text', placeholder: 'Contains…' },
|
|
47
|
+
{ key: 'kind', label: 'Kind', type: 'multiselect', options: GROUP_KIND_OPTIONS },
|
|
48
|
+
{ key: 'is_active', label: 'Status', type: 'boolean', trueLabel: 'Active', falseLabel: 'Inactive' },
|
|
49
|
+
// The boolean dialog writes the key verbatim — carrying the lookup IN
|
|
50
|
+
// the key gives it Django __isnull semantics without a new filter type.
|
|
51
|
+
{ key: 'parent__isnull', label: 'Top-level', type: 'boolean', trueLabel: 'Top-level only', falseLabel: 'Has a parent' },
|
|
52
|
+
{ key: 'created', label: 'Created', type: 'daterange' },
|
|
53
|
+
{ key: 'last_activity', label: 'Last activity', type: 'daterange' },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export function GroupsPage() {
|
|
57
|
+
const qc = useQueryClient();
|
|
58
|
+
const save = GroupModel.useSave();
|
|
59
|
+
const disable = GroupModel.useAction('disable');
|
|
60
|
+
const reactivate = GroupModel.useAction('reactivate');
|
|
61
|
+
const { can: canManage } = useCan(GROUP_MANAGE_PERMS);
|
|
62
|
+
|
|
63
|
+
const addGroup = async () => {
|
|
64
|
+
const form = GroupModel.forms.create!;
|
|
65
|
+
const data = await formModal(form);
|
|
66
|
+
if (!data) return;
|
|
67
|
+
const changes = Object.fromEntries(Object.entries(data).filter(([, v]) => v !== '' && v != null));
|
|
68
|
+
try {
|
|
69
|
+
const created = await save.mutateAsync({ id: null, changes });
|
|
70
|
+
toast.success(`${created.name} created`);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
toast.error(err instanceof Error ? err.message : 'Create failed');
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const openGroup = (g: GroupRow) => {
|
|
77
|
+
// Warm the one-record cache so the modal's useOne attaches to this
|
|
78
|
+
// in-flight request instead of issuing its own.
|
|
79
|
+
void GroupModel.fetchOne(qc, g.id).catch(() => {});
|
|
80
|
+
void modal.detail((close) => <GroupDetail id={g.id} onClose={() => close(null)} />);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const batchActions: BatchAction<GroupRow>[] = canManage ? [
|
|
84
|
+
{
|
|
85
|
+
key: 'deactivate', label: 'Deactivate', icon: 'bi-x-circle', danger: true, confirm: false,
|
|
86
|
+
prepare: async () => {
|
|
87
|
+
const data = await formModal(GroupModel.forms.disable!);
|
|
88
|
+
if (!data) return null;
|
|
89
|
+
const payload: Record<string, unknown> = { reason: data.reason };
|
|
90
|
+
if (data.note) payload.note = data.note;
|
|
91
|
+
return payload;
|
|
92
|
+
},
|
|
93
|
+
run: (row, payload) => disable.mutateAsync({ id: row.id, payload }),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
key: 'reactivate', label: 'Reactivate', icon: 'bi-arrow-counterclockwise',
|
|
97
|
+
run: (row) => reactivate.mutateAsync({ id: row.id }),
|
|
98
|
+
},
|
|
99
|
+
] : [];
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<ModelTable<GroupRow>
|
|
103
|
+
model={GroupModel}
|
|
104
|
+
eyebrow="Account"
|
|
105
|
+
title="Groups"
|
|
106
|
+
searchPlaceholder="Search group names…"
|
|
107
|
+
columns={COLUMNS}
|
|
108
|
+
filters={FILTERS}
|
|
109
|
+
presets={[
|
|
110
|
+
{ key: 'all', label: 'All', params: {} },
|
|
111
|
+
{ key: 'active', label: 'Active', params: { is_active: 'true' } },
|
|
112
|
+
{ key: 'top', label: 'Top-level', params: { parent__isnull: 'true' } },
|
|
113
|
+
{ key: 'orgs', label: 'Orgs', params: { kind: 'org' } },
|
|
114
|
+
]}
|
|
115
|
+
defaultSort="-created"
|
|
116
|
+
selectable={canManage}
|
|
117
|
+
batchActions={batchActions}
|
|
118
|
+
columnChooser
|
|
119
|
+
persistState
|
|
120
|
+
exportFormats={['csv', 'json']}
|
|
121
|
+
onRowClick={openGroup}
|
|
122
|
+
{...(canManage ? { addLabel: 'Add Group', onAdd: addGroup } : {})}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Group directory permissions are pinned to the caller's system grants. */
|
|
2
|
+
export const GROUP_VIEW_PERMS = ['sys.groups', 'sys.view_groups'];
|
|
3
|
+
export const GROUP_MANAGE_PERMS = ['sys.groups', 'sys.manage_groups'];
|
|
4
|
+
export const GROUP_DESTRUCTIVE_PERMS = ['sys.groups', 'sys.manage_groups'];
|
|
5
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// group-sections/AuditSection.tsx — GroupAuditTimelineSection port
|
|
2
|
+
// (GroupView.js:1064-1112): the group's OBJECT logs
|
|
3
|
+
// (model_name=account.Group, model_id=<id> — the source's shared LogList),
|
|
4
|
+
// rendered as a DAY-GROUPED timeline: one date heading per day, a Timeline
|
|
5
|
+
// under each, tones mapped from the log level (LOG_LEVEL_TONE).
|
|
6
|
+
import { Eyebrow, Timeline, fmt, type TimelineItem } from 'portal-mojo/ui';
|
|
7
|
+
import { LogModel, type LogRow } from 'portal-mojo/admin/observability';
|
|
8
|
+
import { type GroupRow } from '../models';
|
|
9
|
+
import { LOG_TONE, groupAuditParams } from './shared';
|
|
10
|
+
|
|
11
|
+
/** Bucket rows by local calendar day, newest day first (rows arrive -created). */
|
|
12
|
+
function groupByDay(rows: LogRow[]): { day: string; items: TimelineItem[] }[] {
|
|
13
|
+
const buckets: { day: string; items: TimelineItem[] }[] = [];
|
|
14
|
+
for (const l of rows) {
|
|
15
|
+
const day = fmt.date(l.created);
|
|
16
|
+
const item: TimelineItem = {
|
|
17
|
+
tone: LOG_TONE[l.level] ?? 'muted',
|
|
18
|
+
title: l.kind ?? l.level,
|
|
19
|
+
body: (
|
|
20
|
+
<span className="dim">
|
|
21
|
+
{l.log ? fmt.truncate(l.log, 110) : ''}
|
|
22
|
+
{l.username && <> · <code>{l.username}</code></>}
|
|
23
|
+
</span>
|
|
24
|
+
),
|
|
25
|
+
meta: fmt.relative(l.created),
|
|
26
|
+
};
|
|
27
|
+
const last = buckets[buckets.length - 1];
|
|
28
|
+
if (last && last.day === day) last.items.push(item);
|
|
29
|
+
else buckets.push({ day, items: [item] });
|
|
30
|
+
}
|
|
31
|
+
return buckets;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function AuditSection({ group }: { group: GroupRow }) {
|
|
35
|
+
const { data, isPending } = LogModel.useList({ ...groupAuditParams(group.id), size: 25 });
|
|
36
|
+
const buckets = groupByDay(data?.rows ?? []);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
<Eyebrow>Audit</Eyebrow>
|
|
41
|
+
{!isPending && buckets.length === 0 && (
|
|
42
|
+
<Timeline items={[]} emptyText="No audit entries recorded for this group yet." />
|
|
43
|
+
)}
|
|
44
|
+
{buckets.map((b) => (
|
|
45
|
+
<div key={b.day} className="ga-audit-day">
|
|
46
|
+
<div className="ga-audit-day-label">{b.day}</div>
|
|
47
|
+
<Timeline items={b.items} />
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
{(data?.count ?? 0) > (data?.rows.length ?? 0) && (
|
|
51
|
+
<p className="dim" style={{ marginTop: 8 }}>
|
|
52
|
+
{data!.count - data!.rows.length} older entries — see the Logs page for the full history.
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
</>
|
|
56
|
+
);
|
|
57
|
+
}
|