portal-mojo 0.2.5 → 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.
- 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/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/SidebarNav.tsx +2 -2
|
@@ -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
|
+
}
|