portal-mojo 0.2.15 → 0.2.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/DetailView.md +6 -0
- package/docs/admin-identity-users.md +11 -2
- package/docs/admin-members.md +37 -0
- package/docs/admin-modals.md +3 -1
- package/docs/admin-storage.md +18 -1
- package/docs/forms.md +16 -0
- package/package.json +1 -1
- package/src/admin/identity/groups/GroupDetail.tsx +4 -0
- package/src/admin/identity/groups/group-sections/OverviewSection.tsx +5 -5
- package/src/admin/identity/members/GroupMembersPanel.tsx +23 -5
- package/src/admin/identity/members/MemberDetail.tsx +90 -39
- package/src/admin/identity/members/MembersPage.tsx +5 -0
- package/src/admin/identity/members/member-dialog.tsx +31 -0
- package/src/admin/identity/members/member-flows.tsx +81 -45
- package/src/admin/identity/members/member-scope.ts +61 -0
- package/src/admin/identity/members/models.ts +4 -0
- package/src/admin/identity/users/UserDetail.tsx +32 -9
- package/src/admin/identity/users/sections/OverviewSection.tsx +8 -5
- package/src/admin/identity/users/sections/PermissionsSection.tsx +128 -2
- package/src/admin/identity/users/sections/actions.tsx +1 -1
- package/src/admin/security/devices/UserDeviceDetail.tsx +4 -4
- package/src/admin/security/geoip/GeoIpDossier.tsx +4 -4
- package/src/admin/storage/FileManagerDetail.tsx +60 -2
- package/src/admin/storage/models.ts +49 -1
- package/src/client/mock.ts +48 -1
- package/src/styles/components/admin-storage.css +1 -1
- package/src/styles/components/file-upload.css +1 -1
- package/src/styles/components/user-admin.css +17 -0
- package/src/styles/core.css +18 -0
- package/src/ui/DetailView.tsx +28 -4
- package/src/ui/FormView.tsx +9 -1
- package/src/ui/guardrail.tsx +3 -1
- package/src/ui/ui.tsx +11 -2
package/docs/DetailView.md
CHANGED
|
@@ -53,6 +53,11 @@ of the toolkit it must render inside the app's `QueryClientProvider`
|
|
|
53
53
|
- `sections` mixes `{key, label, icon, render, permissions?}` entries with
|
|
54
54
|
`{divider: 'Label'}` side-labels.
|
|
55
55
|
- `initialSection` picks the starting rail entry.
|
|
56
|
+
- `avatarUrl` shows an image in the header avatar (falls back to
|
|
57
|
+
`avatarName` initials / `icon` when absent or the image fails to load);
|
|
58
|
+
pass a pre-vetted URL (`safePreviewUrl`) kept in component state, never a
|
|
59
|
+
Query cache. `onAvatarClick` turns the avatar into a button with a camera
|
|
60
|
+
hover overlay (`avatarLabel` sets its label/tooltip).
|
|
56
61
|
|
|
57
62
|
## Page embedding — `onClose` is optional
|
|
58
63
|
|
|
@@ -172,6 +177,7 @@ permission behavior, not misconfig.
|
|
|
172
177
|
| `Eyebrow` | small uppercase section heading inside a panel |
|
|
173
178
|
| `FlatRow` | label + value row; `action` adds the edit pencil (`actionIcon` to override, e.g. `bi-plus-lg` for empty values) |
|
|
174
179
|
| `SecurityItem` | icon + title + description row with a trailing slot (badge/button) |
|
|
180
|
+
| `MetricCard compact` | dense one-line KPI card for detail-view snapshot rows (value never wraps; `title` for a tooltip such as the full timestamp) |
|
|
175
181
|
|
|
176
182
|
Pencil actions open `formModal` with current values as `initial`. Current-record
|
|
177
183
|
operations live in the header menu or focused dialogs; content rails never
|
|
@@ -25,8 +25,13 @@ reason per batch; rows already in the target state are ineligible.
|
|
|
25
25
|
|
|
26
26
|
The detail preserves all 14 sections: Overview, Profile, Personal, Security,
|
|
27
27
|
OAuth, Groups, Sys Perms, conditional App Perms, API Keys, Devices, Logins,
|
|
28
|
-
Audit, Notifications, and Metadata. Permission sections are labeled **
|
|
29
|
-
|
|
28
|
+
Audit, Notifications, and Metadata. Permission sections are labeled **Sys
|
|
29
|
+
Perms** and **Product permissions** in the rail. Sys Perms opens with every
|
|
30
|
+
granted key (catalog keys neutral, app-registered accent, out-of-catalog
|
|
31
|
+
amber), then **Custom permissions**: out-of-catalog keys with remove (armed)
|
|
32
|
+
and a grant input, then the catalog tabs. Remove sends
|
|
33
|
+
`{permissions: {key: false}}` — django-mojo `User.set_permissions` deletes a
|
|
34
|
+
key on a falsy value (the mock matches). Consuming apps install
|
|
30
35
|
their product tabs with `registerFormTabs(USER_APP_PERMS_TABSET, tabs)`; the
|
|
31
36
|
section stays absent when none are registered. User grants can cover multiple
|
|
32
37
|
groups; use a Member product permission for a single group.
|
|
@@ -77,6 +82,10 @@ per endpoint, so `UserDetail` and the fleet-wide tables share one cache.
|
|
|
77
82
|
|
|
78
83
|
## Avatar uploads
|
|
79
84
|
|
|
85
|
+
The header avatar shows the attached image (thumbnail fetched from
|
|
86
|
+
`/api/fileman/file/<id>` into component state) and, for admins, is a button
|
|
87
|
+
that opens the same manager as the kebab's "Manage avatar…".
|
|
88
|
+
|
|
80
89
|
Manage avatar uses the shared `ImageField`: it uploads into the acting admin's
|
|
81
90
|
personal File scope, then saves that completed positive numeric File id (or
|
|
82
91
|
null to clear) on the target User. This matches django-mojo #1488's
|
package/docs/admin-members.md
CHANGED
|
@@ -116,6 +116,15 @@ new grant semantics: the backend still decides what each key authorizes.
|
|
|
116
116
|
|
|
117
117
|
## Composition
|
|
118
118
|
|
|
119
|
+
`GroupMembersPanel` and `MemberDetail` gate writes on the system-pinned
|
|
120
|
+
constants by default. A tenant composition that renders them for the active
|
|
121
|
+
group passes the group-local gates instead (`MEMBER_GROUP_SAVE_PERMISSIONS`,
|
|
122
|
+
`MEMBER_GROUP_INVITE_PERMISSIONS`) through `savePermissions`,
|
|
123
|
+
`invitePermissions` and `directoryPermissions`; the server still decides. A
|
|
124
|
+
product may also pass `roles` (`AdmissionRole[]`): the invite form then shows a
|
|
125
|
+
role select, posts that role's grants with the invitation, and records the role
|
|
126
|
+
label as `metadata.role`.
|
|
127
|
+
|
|
119
128
|
`MEMBERS_ADMIN_SECTION` is root-relative and dual-mounts as `/members` in the
|
|
120
129
|
standalone Admin and `/system/members` when embedded. It never consumes active
|
|
121
130
|
group context. `GroupMembersPanel` is the reusable Group-detail composition;
|
|
@@ -126,3 +135,31 @@ Member product tabs only render `permissions.<grant>` fields whose grant is
|
|
|
126
135
|
registered through `registerMemberPermissions()` and editable. System/derived
|
|
127
136
|
grants, unregistered names and unrelated fields (such as `group` or `user`)
|
|
128
137
|
are ignored; tabs with no valid fields do not appear.
|
|
138
|
+
|
|
139
|
+
## Operation lifetime
|
|
140
|
+
|
|
141
|
+
Member admission captures the initiating authentication session and active group.
|
|
142
|
+
The selected group remains fixed throughout its form and the optional role-label
|
|
143
|
+
write. An account/group change, even switching away and back, permanently ends
|
|
144
|
+
the operation's client continuations. Ordinary JWT renewal keeps the session.
|
|
145
|
+
The no-oracle success message is still shown for an accepted invitation with no
|
|
146
|
+
member row, and a refused display-label write does not undo its accepted grants.
|
|
147
|
+
|
|
148
|
+
`openMemberAdmissionDialog({ signal })` accepts the initiating panel's lifetime.
|
|
149
|
+
`GroupMembersPanel`, global `MembersPage` and the Group detail admission action
|
|
150
|
+
supply it automatically. Custom hosts should abort their controller when they
|
|
151
|
+
unmount or change the record/authority used to open the dialog. Forms remain open
|
|
152
|
+
through the request and disable repeated submission and Escape/backdrop dismissal
|
|
153
|
+
while pending. Owner disposal always closes them. A request already received by
|
|
154
|
+
the server is not cancelled or rolled back by closing its view.
|
|
155
|
+
|
|
156
|
+
`MemberDetail` also accepts `ownerSignal` for a fixed-group host. Its activation,
|
|
157
|
+
role, resend and permission changes bind the final pre-send check, returned row,
|
|
158
|
+
cache invalidation and notices to the initiating detail scope. Disposed outcomes
|
|
159
|
+
cannot populate a later account's cache. Live successful saves still update the
|
|
160
|
+
shared member row immediately and invalidate the member lists. Errors keep the
|
|
161
|
+
saved row and surface the server refusal; autosaved fields revert normally.
|
|
162
|
+
|
|
163
|
+
The browser fixture sends no real invitations: start
|
|
164
|
+
`node scripts/member-lifecycle-server.mjs`, then run
|
|
165
|
+
`uv run --with playwright python scripts/verify-member-lifecycle.py`.
|
package/docs/admin-modals.md
CHANGED
|
@@ -31,7 +31,9 @@ fails; successful DetailView bodies supply the normal header close.
|
|
|
31
31
|
Already-aborted signals resolve null without adding an entry. Later abort removes
|
|
32
32
|
only that entry immediately and resolves null, even before `ModalHost` mounts;
|
|
33
33
|
normal close removes the listener. Owner disposal bypasses `canDismiss` because
|
|
34
|
-
it ends the data scope. An owner must also abort its controller on unmount.
|
|
34
|
+
it ends the data scope. An owner must also abort its controller on unmount. `confirmGuardrail({ signal })`
|
|
35
|
+
passes the same lifetime to its underlying modal, so an awaiting confirmation
|
|
36
|
+
closes with its record or authority.
|
|
35
37
|
|
|
36
38
|
## Executable inventory
|
|
37
39
|
|
package/docs/admin-storage.md
CHANGED
|
@@ -60,9 +60,26 @@ closure, refetches authoritatively, and compares requested owner FKs.
|
|
|
60
60
|
Creation supports runtime backends `file` and `s3`, explicitly starts private,
|
|
61
61
|
and requires an authorized group/user owner unless the caller is superuser and
|
|
62
62
|
chooses System. Supported payload actions are `test_connection`, `check_cors`,
|
|
63
|
-
`fix_cors`,
|
|
63
|
+
`fix_cors`, `clone`, and the CDN trio `enable_cdn`, `check_cdn`, `disable_cdn`
|
|
64
|
+
(django-mojo #8250); inner `{status:false}` rejects. CORS and CDN are S3-only.
|
|
64
65
|
FileManager DELETE and `check_public_access` are absent.
|
|
65
66
|
|
|
67
|
+
**CDN delivery.** The manager graph carries a read-only `cdn` object
|
|
68
|
+
(`projectCdn` keeps only its named keys; `{enabled: false}` otherwise), shown
|
|
69
|
+
as the Overview's CDN row: not enabled, or the CDN address with "public
|
|
70
|
+
links" or "signed links" and whether the distribution is still deploying.
|
|
71
|
+
The detail menu offers **Check CDN**, **Enable CDN…** and **Disable CDN…**
|
|
72
|
+
to every storage manager (`manage_files` / `files`; django-mojo enforces the
|
|
73
|
+
same global grant on the actions), the two mutating ones behind a confirm
|
|
74
|
+
that says what changes in AWS (enable provisions CloudFront for the whole
|
|
75
|
+
bucket under the deployment's identity and creates nothing twice; disable
|
|
76
|
+
reverts every manager on the bucket to S3 links and leaves AWS alone).
|
|
77
|
+
Results render as a findings list — status badge, code, message, remedy —
|
|
78
|
+
through `cdnFindings`, never raw JSON. The mock serves the field (the
|
|
79
|
+
"Public media" backend starts enabled) and the three actions, with a
|
|
80
|
+
MANUAL KMS finding on the private uploads backend so the remedy rendering
|
|
81
|
+
is exercised.
|
|
82
|
+
|
|
66
83
|
## Files and capability URLs
|
|
67
84
|
|
|
68
85
|
`FileModel` forces the list graph and normal saves to `filename`, `is_public`,
|
package/docs/forms.md
CHANGED
|
@@ -106,6 +106,7 @@ if (!data) return; // cancelled → null
|
|
|
106
106
|
onSaved={({ changes, fields, row }) => …}
|
|
107
107
|
onSaveError={({ changes, fields, error }) => …}
|
|
108
108
|
beforeSave={({ changes, names, row }) => …} // once per batch, BEFORE the POST (below)
|
|
109
|
+
isCurrent={() => ownerScope.current()} // optional owner lifetime fence
|
|
109
110
|
onPendingChange={(uploading) => setDetailDismissLocked(uploading)}
|
|
110
111
|
/>
|
|
111
112
|
```
|
|
@@ -176,6 +177,21 @@ contract is pinned by `npm run verify:form-autosave`.
|
|
|
176
177
|
Server normalization is visible: save `555 010 0142`, the field comes back
|
|
177
178
|
`+15550100142`.
|
|
178
179
|
|
|
180
|
+
### Owner lifetime
|
|
181
|
+
|
|
182
|
+
`isCurrent?: () => boolean` is an optional, synchronous lifetime predicate for
|
|
183
|
+
forms whose account, group or owning dialog can end while a save is pending.
|
|
184
|
+
Returning `false` prevents the batch before and after its asynchronous
|
|
185
|
+
`beforeSave` gate, and suppresses late success/error callbacks and autosave
|
|
186
|
+
feedback. Capture a one-way scope: leaving and returning must not revive the
|
|
187
|
+
old operation. Omitting it preserves the existing autosave behavior.
|
|
188
|
+
|
|
189
|
+
This guard covers the form's continuations, not a model's own mutation effects.
|
|
190
|
+
An owner-scoped model must also check its scope at the transport's final
|
|
191
|
+
`beforeSend` boundary and before writing response data to a shared cache.
|
|
192
|
+
MemberDetail provides that guarded mutation boundary. Already-sent server writes
|
|
193
|
+
are not rolled back when the view closes.
|
|
194
|
+
|
|
179
195
|
### `file` / `image` relations
|
|
180
196
|
|
|
181
197
|
Both registry bindings use the exported controlled `FileField`/`ImageField`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.17",
|
|
4
4
|
"description": "React portal toolkit for django-mojo — typed client + params store, mission-control UI, dependency-free SVG charts, admin section bundles.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
MemberModel, openMemberAdmissionDialog,
|
|
38
38
|
} from '../members';
|
|
39
39
|
import { LogModel } from '../../monitoring';
|
|
40
|
+
import { useMemberScope } from '../members/member-scope';
|
|
40
41
|
import {
|
|
41
42
|
GROUP_DESTRUCTIVE_PERMS, GROUP_MANAGE_PERMS,
|
|
42
43
|
GroupModel, type GroupRow,
|
|
@@ -93,6 +94,8 @@ export function GroupDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
93
94
|
const { can: canCreateMember } = useCan(MEMBER_SAVE_PERMISSIONS);
|
|
94
95
|
const { can: canReadUsers } = useCan(MEMBER_USER_DIRECTORY_PERMISSIONS);
|
|
95
96
|
|
|
97
|
+
const admissionScope = useMemberScope(`${id}:${canInviteMember}:${canCreateMember}:${canReadUsers}`);
|
|
98
|
+
|
|
96
99
|
// Rail count badges (source setBadge wiring, as controlled props). The
|
|
97
100
|
// member/apikey peeks share Overview's keys and dedupe; the gated ones
|
|
98
101
|
// hold off until the caller could actually open the section.
|
|
@@ -148,6 +151,7 @@ export function GroupDetail({ id, onClose }: { id: number; onClose: () => void }
|
|
|
148
151
|
canCreate: canCreateMember,
|
|
149
152
|
canReadUsers,
|
|
150
153
|
queryClient: qc,
|
|
154
|
+
signal: admissionScope.current?.signal,
|
|
151
155
|
});
|
|
152
156
|
},
|
|
153
157
|
},
|
|
@@ -52,11 +52,11 @@ export function OverviewSection({ group, openGroup, canViewCredentials, canViewA
|
|
|
52
52
|
|
|
53
53
|
return (
|
|
54
54
|
<>
|
|
55
|
-
<div className="grid gap-
|
|
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, '—')} />
|
|
55
|
+
<div className="grid gap-2 mb-4 grid-cols-[repeat(auto-fit,minmax(7.5rem,1fr))]">
|
|
56
|
+
<MetricCard compact label="Members" value={memberCount} />
|
|
57
|
+
<MetricCard compact label="Sub-groups" value={subs ? subCount : '—'} />
|
|
58
|
+
<MetricCard compact label="API keys" value={canViewCredentials ? (keyPeek?.count ?? '—') : 'Unavailable'} />
|
|
59
|
+
<MetricCard compact label="Last activity" value={fmt.relative(group.last_activity, '—')} title={group.last_activity ? fmt.datetime(group.last_activity) : undefined} />
|
|
60
60
|
</div>
|
|
61
61
|
|
|
62
62
|
<Eyebrow>This group</Eyebrow>
|
|
@@ -3,7 +3,9 @@ import { useQueryClient } from '@tanstack/react-query';
|
|
|
3
3
|
import { useCan, type Params } from '../../../client/runtime';
|
|
4
4
|
import { Badge, Eyebrow, SecurityItem, fmt, modal } from '../../../ui';
|
|
5
5
|
import { MemberDetail, type MemberNavigationCallbacks } from './MemberDetail';
|
|
6
|
-
import { openMemberAdmissionDialog, type AdmissionGroup, type AdmissionMode } from './member-flows';
|
|
6
|
+
import { openMemberAdmissionDialog, type AdmissionGroup, type AdmissionMode, type AdmissionRole } from './member-flows';
|
|
7
|
+
import { useMemberScope } from './member-scope';
|
|
8
|
+
import type { PermSpec } from '../../../client/runtime';
|
|
7
9
|
import {
|
|
8
10
|
MEMBER_GROUP_READ_PERMISSIONS,
|
|
9
11
|
MEMBER_INVITE_PERMISSIONS,
|
|
@@ -59,28 +61,42 @@ function MemberItem({ member, onOpen }: { member: MemberRow; onOpen: () => void
|
|
|
59
61
|
|
|
60
62
|
export interface GroupMembersPanelProps extends MemberNavigationCallbacks {
|
|
61
63
|
group: AdmissionGroup;
|
|
64
|
+
/** Global Admin gates are system-pinned; a tenant composition passes its group-local gates. */
|
|
65
|
+
invitePermissions?: PermSpec;
|
|
66
|
+
savePermissions?: PermSpec;
|
|
67
|
+
directoryPermissions?: PermSpec;
|
|
68
|
+
/** Optional fixed roles offered on invite; each sets grants and the role label. */
|
|
69
|
+
roles?: AdmissionRole[];
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
export function GroupMembersPanel({
|
|
72
|
+
export function GroupMembersPanel({
|
|
73
|
+
group, onNavigateUser, onNavigateGroup, roles,
|
|
74
|
+
invitePermissions = MEMBER_INVITE_PERMISSIONS,
|
|
75
|
+
savePermissions = MEMBER_SAVE_PERMISSIONS,
|
|
76
|
+
directoryPermissions = MEMBER_USER_DIRECTORY_PERMISSIONS,
|
|
77
|
+
}: GroupMembersPanelProps) {
|
|
65
78
|
const qc = useQueryClient();
|
|
66
79
|
const [search, setSearch] = useState('');
|
|
67
80
|
const [status, setStatus] = useState<StatusFilter>('active');
|
|
68
81
|
const [start, setStart] = useState(0);
|
|
69
82
|
useEffect(() => setStart(0), [search, status, group.id]);
|
|
70
83
|
const query = MemberModel.useList(paramsFor(group.id, search, status, start));
|
|
71
|
-
const { can: canInvite } = useCan(
|
|
72
|
-
const { can: canCreate } = useCan(
|
|
73
|
-
const { can: canReadUsers } = useCan(
|
|
84
|
+
const { can: canInvite } = useCan(invitePermissions);
|
|
85
|
+
const { can: canCreate } = useCan(savePermissions);
|
|
86
|
+
const { can: canReadUsers } = useCan(directoryPermissions);
|
|
87
|
+
const scope = useMemberScope(`${group.id}:${canInvite}:${canCreate}:${canReadUsers}`);
|
|
74
88
|
const members = query.data?.rows ?? [];
|
|
75
89
|
const count = query.data?.count ?? 0;
|
|
76
90
|
|
|
77
91
|
const admit = (initialMode: AdmissionMode) => openMemberAdmissionDialog({
|
|
78
92
|
group,
|
|
79
93
|
initialMode,
|
|
94
|
+
roles,
|
|
80
95
|
canInvite,
|
|
81
96
|
canCreate,
|
|
82
97
|
canReadUsers,
|
|
83
98
|
queryClient: qc,
|
|
99
|
+
signal: scope.current?.signal,
|
|
84
100
|
});
|
|
85
101
|
const openMember = (member: MemberRow) => {
|
|
86
102
|
void MemberModel.fetchOne(qc, member.id).catch(() => {});
|
|
@@ -89,6 +105,8 @@ export function GroupMembersPanel({ group, onNavigateUser, onNavigateGroup }: Gr
|
|
|
89
105
|
id={member.id}
|
|
90
106
|
onClose={() => close(null)}
|
|
91
107
|
readPermissions={MEMBER_GROUP_READ_PERMISSIONS}
|
|
108
|
+
savePermissions={savePermissions}
|
|
109
|
+
ownerSignal={scope.current?.signal}
|
|
92
110
|
onNavigateUser={onNavigateUser}
|
|
93
111
|
onNavigateGroup={onNavigateGroup}
|
|
94
112
|
/>
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { useSyncExternalStore, type ReactNode } from 'react';
|
|
2
|
-
import { useQueryClient } from '@tanstack/react-query';
|
|
1
|
+
import { useEffect, useMemo, useSyncExternalStore, type ReactNode } from 'react';
|
|
2
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
3
3
|
import { useCan, type PermSpec } from '../../../client/runtime';
|
|
4
|
+
import { mojoCall, withFreshAuth, type SaveVars } from '../../../client/runtime';
|
|
5
|
+
import { saveMemberInScope, useMemberScope } from './member-scope';
|
|
6
|
+
import { memberForm } from './member-dialog';
|
|
4
7
|
import {
|
|
5
8
|
Badge, DetailView, Eyebrow, FlatRow, FormView, JsonBlock, MetricCard,
|
|
6
|
-
ModelTable, confirmGuardrail, fmt,
|
|
9
|
+
ModelTable, confirmGuardrail, fmt, toast, getFormTabs, formTabsVersion, subscribeFormTabs,
|
|
7
10
|
type Column, type DetailMenuEntry,
|
|
8
11
|
} from '../../../ui';
|
|
9
12
|
import { LOGS_ADMIN_PERMISSIONS, LogModel, type LogRow } from '../../monitoring';
|
|
@@ -33,6 +36,10 @@ export interface MemberDetailProps extends MemberNavigationCallbacks {
|
|
|
33
36
|
onClose: () => void;
|
|
34
37
|
/** Global Admin is system-pinned; fixed-group compositions pass their read gate. */
|
|
35
38
|
readPermissions?: PermSpec;
|
|
39
|
+
/** Same for writes: a fixed-group composition passes the group-local save gate. */
|
|
40
|
+
savePermissions?: PermSpec;
|
|
41
|
+
/** Fixed-group owner lifetime; closing/changing its panel disposes this detail. */
|
|
42
|
+
ownerSignal?: AbortSignal;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
function AuditTable({ memberId }: { memberId: number }) {
|
|
@@ -74,7 +81,7 @@ const ESCALATING_GRANTS: Record<string, { label: string; why: ReactNode }> = {
|
|
|
74
81
|
};
|
|
75
82
|
|
|
76
83
|
/** beforeSave gate: stops only when an escalating grant flips ON. */
|
|
77
|
-
function confirmGrantEscalation(member: MemberRow, changes: Record<string, unknown
|
|
84
|
+
function confirmGrantEscalation(member: MemberRow, changes: Record<string, unknown>, signal?: AbortSignal): Promise<boolean> | true {
|
|
78
85
|
const perms = changes.permissions;
|
|
79
86
|
if (!perms || typeof perms !== 'object') return true;
|
|
80
87
|
const granting = Object.keys(ESCALATING_GRANTS).filter((key) => (perms as Record<string, unknown>)[key] === true);
|
|
@@ -83,6 +90,7 @@ function confirmGrantEscalation(member: MemberRow, changes: Record<string, unkno
|
|
|
83
90
|
const group = member.group?.name || 'this group';
|
|
84
91
|
const labels = granting.map((key) => ESCALATING_GRANTS[key]!.label).join(' + ');
|
|
85
92
|
return confirmGuardrail({
|
|
93
|
+
signal,
|
|
86
94
|
title: `Grant ${labels} to ${who}?`,
|
|
87
95
|
effect: <><b>{who}</b> holds <b>{granting.join(' and ')}</b> in <b>{group}</b> the moment this saves — there is no save button and no second step.</>,
|
|
88
96
|
why: granting.map((key) => ESCALATING_GRANTS[key]!.why),
|
|
@@ -118,13 +126,46 @@ function GrantSummary({ member }: { member: MemberRow }) {
|
|
|
118
126
|
export function MemberDetail({
|
|
119
127
|
id, onClose, onNavigateUser, onNavigateGroup,
|
|
120
128
|
readPermissions = MEMBER_READ_PERMISSIONS,
|
|
129
|
+
savePermissions = MEMBER_SAVE_PERMISSIONS, ownerSignal,
|
|
121
130
|
}: MemberDetailProps) {
|
|
122
131
|
const qc = useQueryClient();
|
|
123
132
|
const { can: canRead, isLoading: readLoading } = useCan(readPermissions);
|
|
124
133
|
const query = MemberModel.useOne(canRead ? id : null);
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const
|
|
134
|
+
const { can: canSave } = useCan(savePermissions);
|
|
135
|
+
const owner = `${id}:${canRead}:${canSave}`;
|
|
136
|
+
const scope = useMemberScope(owner, onClose);
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (ownerSignal?.aborted) onClose();
|
|
139
|
+
ownerSignal?.addEventListener('abort', onClose, { once: true });
|
|
140
|
+
return () => ownerSignal?.removeEventListener('abort', onClose);
|
|
141
|
+
}, [ownerSignal, onClose]);
|
|
142
|
+
const isCurrent = () => scope.current?.owner === owner && scope.current.current() && !ownerSignal?.aborted;
|
|
143
|
+
// Local mutation boundary: generic model hooks publish unconditionally. A late
|
|
144
|
+
// member response must never write through into the next account's cache.
|
|
145
|
+
const guardedModel = useMemo(() => ({
|
|
146
|
+
useSave() {
|
|
147
|
+
return useMutation({
|
|
148
|
+
gcTime: 0,
|
|
149
|
+
mutationFn: async ({ id: target, changes }: SaveVars) => {
|
|
150
|
+
const started = scope.current;
|
|
151
|
+
const check = () => {
|
|
152
|
+
started?.assert();
|
|
153
|
+
if (!started || scope.current !== started || ownerSignal?.aborted)
|
|
154
|
+
throw new DOMException('Membership context ended', 'AbortError');
|
|
155
|
+
};
|
|
156
|
+
const row = await withFreshAuth(() => {
|
|
157
|
+
check();
|
|
158
|
+
return saveMemberInScope(target, changes, check);
|
|
159
|
+
});
|
|
160
|
+
check();
|
|
161
|
+
qc.setQueryData(MemberModel.keys.one(row.id), row);
|
|
162
|
+
void MemberModel.invalidate(qc);
|
|
163
|
+
return row;
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
}), [qc, scope, ownerSignal]);
|
|
168
|
+
const save = guardedModel.useSave();
|
|
128
169
|
useSyncExternalStore(subscribeMemberPermissions, memberPermissionsVersion, memberPermissionsVersion);
|
|
129
170
|
useSyncExternalStore(subscribeFormTabs, formTabsVersion, formTabsVersion);
|
|
130
171
|
|
|
@@ -160,39 +201,50 @@ export function MemberDetail({
|
|
|
160
201
|
const deploymentOnly = raw.filter((name) => !catalogNames.has(name) && !ignoredMemberGrants(member.permissions).includes(name));
|
|
161
202
|
|
|
162
203
|
const editRole = async () => {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
help: 'Presentation only. This label does not grant authorization.',
|
|
169
|
-
}],
|
|
204
|
+
const started = scope.current;
|
|
205
|
+
if (!canSave || !isCurrent() || !started) return;
|
|
206
|
+
await memberForm(started, {
|
|
207
|
+
title: 'Edit role label', submitText: 'Update label',
|
|
208
|
+
fields: [{ name: 'role', type: 'text', label: 'Role label',
|
|
209
|
+
help: 'Presentation only. This label does not grant authorization.' }],
|
|
170
210
|
initial: { role },
|
|
211
|
+
}, async (values, current) => {
|
|
212
|
+
if (!current() || scope.current !== started) return null;
|
|
213
|
+
const result = await save.mutateAsync({ id: member.id, changes: { metadata: { role: String(values.role ?? '').trim() } } });
|
|
214
|
+
if (current()) toast.success('Role label updated');
|
|
215
|
+
return result;
|
|
171
216
|
});
|
|
172
|
-
if (!values) return;
|
|
173
|
-
try {
|
|
174
|
-
await save.mutateAsync({ id: member.id, changes: { metadata: { role: String(values.role ?? '').trim() } } });
|
|
175
|
-
toast.success('Role label updated');
|
|
176
|
-
} catch (error) {
|
|
177
|
-
toast.error(error instanceof Error ? error.message : 'Role update failed');
|
|
178
|
-
}
|
|
179
217
|
};
|
|
180
218
|
|
|
181
219
|
const setActive = async (next: boolean) => {
|
|
220
|
+
const started = scope.current;
|
|
221
|
+
if (!canSave || !isCurrent()) return;
|
|
182
222
|
try {
|
|
183
223
|
await save.mutateAsync({ id: member.id, changes: { is_active: next } });
|
|
184
|
-
|
|
224
|
+
if (started?.current() && scope.current === started)
|
|
225
|
+
toast.success(next ? 'Membership activated' : 'Membership deactivated');
|
|
185
226
|
} catch (error) {
|
|
186
|
-
|
|
227
|
+
if (started?.current() && scope.current === started)
|
|
228
|
+
toast.error(error instanceof Error ? error.message : 'Membership update failed');
|
|
187
229
|
}
|
|
188
230
|
};
|
|
189
231
|
|
|
190
232
|
const resendInvite = async () => {
|
|
233
|
+
const started = scope.current;
|
|
234
|
+
if (!canSave || !isCurrent() || !started) return;
|
|
191
235
|
try {
|
|
192
|
-
await
|
|
236
|
+
await withFreshAuth(() => mojoCall(`${MemberModel.endpoint}/${member.id}`, {
|
|
237
|
+
method: 'POST', body: { resend_invite: {} }, unscoped: true, beforeSend: () => {
|
|
238
|
+
started.assert();
|
|
239
|
+
if (ownerSignal?.aborted) throw new DOMException('Membership context ended', 'AbortError');
|
|
240
|
+
},
|
|
241
|
+
}));
|
|
242
|
+
if (!started.current() || scope.current !== started) return;
|
|
193
243
|
toast.success('Invitation resent');
|
|
244
|
+
void MemberModel.invalidate(qc);
|
|
194
245
|
} catch (error) {
|
|
195
|
-
|
|
246
|
+
if (started.current() && scope.current === started)
|
|
247
|
+
toast.error(error instanceof Error ? error.message : 'Invitation resend failed');
|
|
196
248
|
}
|
|
197
249
|
};
|
|
198
250
|
|
|
@@ -200,13 +252,13 @@ export function MemberDetail({
|
|
|
200
252
|
...(onNavigateUser && member.user ? [{ label: 'View user', icon: 'bi-person', onSelect: () => onNavigateUser(member.user!.id) }] : []),
|
|
201
253
|
...(onNavigateGroup && member.group ? [{ label: 'View group', icon: 'bi-people', onSelect: () => onNavigateGroup(member.group!.id) }] : []),
|
|
202
254
|
{ divider: true as const },
|
|
203
|
-
{ label: 'Edit role label', icon: 'bi-person-badge', permissions:
|
|
204
|
-
{ label: 'Resend invitation', icon: 'bi-envelope-arrow-up', permissions:
|
|
255
|
+
{ label: 'Edit role label', icon: 'bi-person-badge', permissions: savePermissions, onSelect: () => { void editRole(); } },
|
|
256
|
+
{ label: 'Resend invitation', icon: 'bi-envelope-arrow-up', permissions: savePermissions, onSelect: () => { void resendInvite(); } },
|
|
205
257
|
{
|
|
206
258
|
label: member.is_active ? 'Deactivate membership' : 'Activate membership',
|
|
207
259
|
icon: member.is_active ? 'bi-toggle-off' : 'bi-toggle-on',
|
|
208
260
|
danger: member.is_active,
|
|
209
|
-
permissions:
|
|
261
|
+
permissions: savePermissions,
|
|
210
262
|
onSelect: () => { void setActive(!member.is_active); },
|
|
211
263
|
},
|
|
212
264
|
];
|
|
@@ -222,7 +274,7 @@ export function MemberDetail({
|
|
|
222
274
|
{ icon: 'bi-person-badge', text: role, tone: 'primary' as const },
|
|
223
275
|
{ text: `${effectiveMemberGrants(member.permissions).length} effective grants`, tone: 'muted' as const },
|
|
224
276
|
]}
|
|
225
|
-
active={canSave ? { value: member.is_active, onChange: (next) => { void setActive(next); } } : undefined}
|
|
277
|
+
active={canSave ? { value: member.is_active, disabled: save.isPending, onChange: (next) => { void setActive(next); } } : undefined}
|
|
226
278
|
contextMenu={menu}
|
|
227
279
|
menuContext={member}
|
|
228
280
|
onClose={onClose}
|
|
@@ -231,10 +283,10 @@ export function MemberDetail({
|
|
|
231
283
|
key: 'Overview', label: 'Overview', icon: 'bi-grid-1x2', render: () => (
|
|
232
284
|
<>
|
|
233
285
|
<div className="detail-kpi-grid">
|
|
234
|
-
<MetricCard label="Role label" value={role} />
|
|
235
|
-
<MetricCard label="Status" value={member.is_active ? 'Active' : 'Inactive'} />
|
|
236
|
-
<MetricCard label="Joined" value={fmt.relative(member.created)} />
|
|
237
|
-
<MetricCard label="Effective grants" value={effectiveMemberGrants(member.permissions).length} />
|
|
286
|
+
<MetricCard compact label="Role label" value={role} />
|
|
287
|
+
<MetricCard compact label="Status" value={member.is_active ? 'Active' : 'Inactive'} />
|
|
288
|
+
<MetricCard compact label="Joined" value={fmt.relative(member.created)} />
|
|
289
|
+
<MetricCard compact label="Effective grants" value={effectiveMemberGrants(member.permissions).length} />
|
|
238
290
|
</div>
|
|
239
291
|
<Eyebrow>This membership</Eyebrow>
|
|
240
292
|
<FlatRow label="User" action={member.user && onNavigateUser ? () => onNavigateUser(member.user!.id) : undefined} actionTitle="View user">{userLabel}</FlatRow>
|
|
@@ -256,11 +308,10 @@ export function MemberDetail({
|
|
|
256
308
|
{canSave
|
|
257
309
|
? (
|
|
258
310
|
<FormView
|
|
259
|
-
model={
|
|
311
|
+
model={guardedModel} isCurrent={isCurrent}
|
|
260
312
|
row={member}
|
|
261
313
|
fields={groupFields}
|
|
262
|
-
beforeSave={({ changes }) => confirmGrantEscalation(member, changes)}
|
|
263
|
-
onSaved={() => { void MemberModel.invalidate(qc); }}
|
|
314
|
+
beforeSave={({ changes }) => confirmGrantEscalation(member, changes, scope.current?.signal)}
|
|
264
315
|
/>
|
|
265
316
|
)
|
|
266
317
|
: <GrantSummary member={member} />}
|
|
@@ -276,9 +327,9 @@ export function MemberDetail({
|
|
|
276
327
|
<Eyebrow>Product permissions</Eyebrow>
|
|
277
328
|
<p className="dim">These grants apply only to this membership’s group. Changes save automatically.</p>
|
|
278
329
|
{canSave ? (
|
|
279
|
-
<FormView model={
|
|
280
|
-
beforeSave={({ changes }) => confirmGrantEscalation(member, changes)}
|
|
281
|
-
|
|
330
|
+
<FormView model={guardedModel} isCurrent={isCurrent} row={member} tabs={productTabs}
|
|
331
|
+
beforeSave={({ changes }) => confirmGrantEscalation(member, changes, scope.current?.signal)}
|
|
332
|
+
/>
|
|
282
333
|
) : <GrantSummary member={member} />}
|
|
283
334
|
</>
|
|
284
335
|
),
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '../../../ui';
|
|
7
7
|
import { MemberDetail, type MemberNavigationCallbacks } from './MemberDetail';
|
|
8
8
|
import { openMemberAdmissionDialog } from './member-flows';
|
|
9
|
+
import { useMemberScope } from './member-scope';
|
|
9
10
|
import {
|
|
10
11
|
MEMBER_INVITE_PERMISSIONS,
|
|
11
12
|
MEMBER_SAVE_PERMISSIONS,
|
|
@@ -67,6 +68,8 @@ export function MembersPage({ onNavigateUser, onNavigateGroup }: MemberNavigatio
|
|
|
67
68
|
const { can: canInvite } = useCan(MEMBER_INVITE_PERMISSIONS);
|
|
68
69
|
const { can: canReadUsers } = useCan(MEMBER_USER_DIRECTORY_PERMISSIONS);
|
|
69
70
|
|
|
71
|
+
const admissionScope = useMemberScope(`${canSave}:${canInvite}:${canReadUsers}`);
|
|
72
|
+
|
|
70
73
|
const batchActions: BatchAction<MemberRow>[] = canSave ? [
|
|
71
74
|
{
|
|
72
75
|
key: 'activate', label: 'Activate', icon: 'bi-check-circle',
|
|
@@ -101,6 +104,7 @@ export function MembersPage({ onNavigateUser, onNavigateGroup }: MemberNavigatio
|
|
|
101
104
|
void modal.detail((close) => (
|
|
102
105
|
<MemberDetail
|
|
103
106
|
id={member.id}
|
|
107
|
+
ownerSignal={admissionScope.current?.signal}
|
|
104
108
|
onClose={() => close(null)}
|
|
105
109
|
onNavigateUser={onNavigateUser}
|
|
106
110
|
onNavigateGroup={onNavigateGroup}
|
|
@@ -113,6 +117,7 @@ export function MembersPage({ onNavigateUser, onNavigateGroup }: MemberNavigatio
|
|
|
113
117
|
canCreate: canSave,
|
|
114
118
|
canReadUsers,
|
|
115
119
|
queryClient: qc,
|
|
120
|
+
signal: admissionScope.current?.signal,
|
|
116
121
|
});
|
|
117
122
|
|
|
118
123
|
return (
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { SchemaForm, modal, type Field, type FormData } from '../../../ui';
|
|
2
|
+
import type { MemberScope } from './member-scope';
|
|
3
|
+
|
|
4
|
+
/** Focused form stays mounted through the write; abort/dismiss fences its continuations. */
|
|
5
|
+
export async function memberForm<T = FormData>(scope: MemberScope, opts: {
|
|
6
|
+
title: string; fields: Field[]; initial?: FormData; submitText?: string;
|
|
7
|
+
}, submit?: (data: FormData, current: () => boolean) => Promise<T>): Promise<T | null> {
|
|
8
|
+
let open = true;
|
|
9
|
+
let pending = false;
|
|
10
|
+
const current = () => open && scope.current();
|
|
11
|
+
try {
|
|
12
|
+
return await modal.open<T>((close) => (
|
|
13
|
+
<div className="modal-pad">
|
|
14
|
+
<h2 className="modal-title">{opts.title}</h2>
|
|
15
|
+
<SchemaForm {...opts}
|
|
16
|
+
onCancel={() => { open = false; close(null as T); }}
|
|
17
|
+
onBusyChange={(busy) => { pending = busy; }}
|
|
18
|
+
onSubmit={async (data) => {
|
|
19
|
+
if (!current() || pending) return;
|
|
20
|
+
pending = true;
|
|
21
|
+
try {
|
|
22
|
+
const result = submit ? await submit(data, current) : data as T;
|
|
23
|
+
if (current()) close(result);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (current()) throw error;
|
|
26
|
+
} finally { pending = false; }
|
|
27
|
+
}} />
|
|
28
|
+
</div>
|
|
29
|
+
), { signal: scope.signal, canDismiss: () => !pending });
|
|
30
|
+
} finally { open = false; }
|
|
31
|
+
}
|