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.
Files changed (33) hide show
  1. package/docs/DetailView.md +6 -0
  2. package/docs/admin-identity-users.md +11 -2
  3. package/docs/admin-members.md +37 -0
  4. package/docs/admin-modals.md +3 -1
  5. package/docs/admin-storage.md +18 -1
  6. package/docs/forms.md +16 -0
  7. package/package.json +1 -1
  8. package/src/admin/identity/groups/GroupDetail.tsx +4 -0
  9. package/src/admin/identity/groups/group-sections/OverviewSection.tsx +5 -5
  10. package/src/admin/identity/members/GroupMembersPanel.tsx +23 -5
  11. package/src/admin/identity/members/MemberDetail.tsx +90 -39
  12. package/src/admin/identity/members/MembersPage.tsx +5 -0
  13. package/src/admin/identity/members/member-dialog.tsx +31 -0
  14. package/src/admin/identity/members/member-flows.tsx +81 -45
  15. package/src/admin/identity/members/member-scope.ts +61 -0
  16. package/src/admin/identity/members/models.ts +4 -0
  17. package/src/admin/identity/users/UserDetail.tsx +32 -9
  18. package/src/admin/identity/users/sections/OverviewSection.tsx +8 -5
  19. package/src/admin/identity/users/sections/PermissionsSection.tsx +128 -2
  20. package/src/admin/identity/users/sections/actions.tsx +1 -1
  21. package/src/admin/security/devices/UserDeviceDetail.tsx +4 -4
  22. package/src/admin/security/geoip/GeoIpDossier.tsx +4 -4
  23. package/src/admin/storage/FileManagerDetail.tsx +60 -2
  24. package/src/admin/storage/models.ts +49 -1
  25. package/src/client/mock.ts +48 -1
  26. package/src/styles/components/admin-storage.css +1 -1
  27. package/src/styles/components/file-upload.css +1 -1
  28. package/src/styles/components/user-admin.css +17 -0
  29. package/src/styles/core.css +18 -0
  30. package/src/ui/DetailView.tsx +28 -4
  31. package/src/ui/FormView.tsx +9 -1
  32. package/src/ui/guardrail.tsx +3 -1
  33. package/src/ui/ui.tsx +11 -2
@@ -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 **System
29
- permissions** and **Product permissions** in the UI. Consuming apps install
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
@@ -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`.
@@ -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
 
@@ -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`, and `clone`; inner `{status:false}` rejects. CORS is S3-only.
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.15",
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-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, '—')} />
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({ group, onNavigateUser, onNavigateGroup }: GroupMembersPanelProps) {
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(MEMBER_INVITE_PERMISSIONS);
72
- const { can: canCreate } = useCan(MEMBER_SAVE_PERMISSIONS);
73
- const { can: canReadUsers } = useCan(MEMBER_USER_DIRECTORY_PERMISSIONS);
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, formModal, toast, getFormTabs, formTabsVersion, subscribeFormTabs,
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>): Promise<boolean> | true {
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 save = MemberModel.useSave();
126
- const resend = MemberModel.useAction('resend_invite');
127
- const { can: canSave } = useCan(MEMBER_SAVE_PERMISSIONS);
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 values = await formModal({
164
- title: 'Edit role label',
165
- submitText: 'Update label',
166
- fields: [{
167
- name: 'role', type: 'text', label: 'Role label',
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
- toast.success(next ? 'Membership activated' : 'Membership deactivated');
224
+ if (started?.current() && scope.current === started)
225
+ toast.success(next ? 'Membership activated' : 'Membership deactivated');
185
226
  } catch (error) {
186
- toast.error(error instanceof Error ? error.message : 'Membership update failed');
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 resend.mutateAsync({ id: member.id });
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
- toast.error(error instanceof Error ? error.message : 'Invitation resend failed');
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: MEMBER_SAVE_PERMISSIONS, onSelect: () => { void editRole(); } },
204
- { label: 'Resend invitation', icon: 'bi-envelope-arrow-up', permissions: MEMBER_SAVE_PERMISSIONS, onSelect: () => { void resendInvite(); } },
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: MEMBER_SAVE_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={MemberModel}
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={MemberModel} row={member} tabs={productTabs}
280
- beforeSave={({ changes }) => confirmGrantEscalation(member, changes)}
281
- onSaved={() => { void MemberModel.invalidate(qc); }} />
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
+ }