portal-mojo 0.2.16 → 0.2.18

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.
@@ -53,7 +53,12 @@ with raw `mojoList` — `SettingModel.normalizeListParams` strips `key`, so the
53
53
  model's list hook cannot do this. More than one global row for a key disables
54
54
  Save and Reset with a notice naming the ids: `Setting.Meta.ordering = ["key"]`
55
55
  leaves the server's `.first()` undefined among duplicates, so editing one could
56
- change a row the renderer never reads.
56
+ change a row the renderer never reads. Save and Reset also stay disabled until
57
+ the lookup succeeds (pending or failed reads say so beside the buttons), and a
58
+ save with no known row (`saveCategoryValue(category, null, …)`) re-reads first:
59
+ it refuses without writing when that read fails or finds a row, and creates
60
+ only when the list is empty. `resetCategory(category, null)` resolves `null`,
61
+ meaning nothing was reset.
57
62
 
58
63
  **Write:** the whole category as one object — `POST /api/settings` to create
59
64
  (`{key, is_secret: false, group: null, value}`) or `POST /api/settings/<id>`
@@ -135,3 +135,31 @@ Member product tabs only render `permissions.<grant>` fields whose grant is
135
135
  registered through `registerMemberPermissions()` and editable. System/derived
136
136
  grants, unregistered names and unrelated fields (such as `group` or `user`)
137
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.16",
3
+ "version": "0.2.18",
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
  },
@@ -4,6 +4,7 @@ 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
6
  import { openMemberAdmissionDialog, type AdmissionGroup, type AdmissionMode, type AdmissionRole } from './member-flows';
7
+ import { useMemberScope } from './member-scope';
7
8
  import type { PermSpec } from '../../../client/runtime';
8
9
  import {
9
10
  MEMBER_GROUP_READ_PERMISSIONS,
@@ -83,6 +84,7 @@ export function GroupMembersPanel({
83
84
  const { can: canInvite } = useCan(invitePermissions);
84
85
  const { can: canCreate } = useCan(savePermissions);
85
86
  const { can: canReadUsers } = useCan(directoryPermissions);
87
+ const scope = useMemberScope(`${group.id}:${canInvite}:${canCreate}:${canReadUsers}`);
86
88
  const members = query.data?.rows ?? [];
87
89
  const count = query.data?.count ?? 0;
88
90
 
@@ -94,6 +96,7 @@ export function GroupMembersPanel({
94
96
  canCreate,
95
97
  canReadUsers,
96
98
  queryClient: qc,
99
+ signal: scope.current?.signal,
97
100
  });
98
101
  const openMember = (member: MemberRow) => {
99
102
  void MemberModel.fetchOne(qc, member.id).catch(() => {});
@@ -103,6 +106,7 @@ export function GroupMembersPanel({
103
106
  onClose={() => close(null)}
104
107
  readPermissions={MEMBER_GROUP_READ_PERMISSIONS}
105
108
  savePermissions={savePermissions}
109
+ ownerSignal={scope.current?.signal}
106
110
  onNavigateUser={onNavigateUser}
107
111
  onNavigateGroup={onNavigateGroup}
108
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';
@@ -35,6 +38,8 @@ export interface MemberDetailProps extends MemberNavigationCallbacks {
35
38
  readPermissions?: PermSpec;
36
39
  /** Same for writes: a fixed-group composition passes the group-local save gate. */
37
40
  savePermissions?: PermSpec;
41
+ /** Fixed-group owner lifetime; closing/changing its panel disposes this detail. */
42
+ ownerSignal?: AbortSignal;
38
43
  }
39
44
 
40
45
  function AuditTable({ memberId }: { memberId: number }) {
@@ -76,7 +81,7 @@ const ESCALATING_GRANTS: Record<string, { label: string; why: ReactNode }> = {
76
81
  };
77
82
 
78
83
  /** beforeSave gate: stops only when an escalating grant flips ON. */
79
- 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 {
80
85
  const perms = changes.permissions;
81
86
  if (!perms || typeof perms !== 'object') return true;
82
87
  const granting = Object.keys(ESCALATING_GRANTS).filter((key) => (perms as Record<string, unknown>)[key] === true);
@@ -85,6 +90,7 @@ function confirmGrantEscalation(member: MemberRow, changes: Record<string, unkno
85
90
  const group = member.group?.name || 'this group';
86
91
  const labels = granting.map((key) => ESCALATING_GRANTS[key]!.label).join(' + ');
87
92
  return confirmGuardrail({
93
+ signal,
88
94
  title: `Grant ${labels} to ${who}?`,
89
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.</>,
90
96
  why: granting.map((key) => ESCALATING_GRANTS[key]!.why),
@@ -120,14 +126,46 @@ function GrantSummary({ member }: { member: MemberRow }) {
120
126
  export function MemberDetail({
121
127
  id, onClose, onNavigateUser, onNavigateGroup,
122
128
  readPermissions = MEMBER_READ_PERMISSIONS,
123
- savePermissions = MEMBER_SAVE_PERMISSIONS,
129
+ savePermissions = MEMBER_SAVE_PERMISSIONS, ownerSignal,
124
130
  }: MemberDetailProps) {
125
131
  const qc = useQueryClient();
126
132
  const { can: canRead, isLoading: readLoading } = useCan(readPermissions);
127
133
  const query = MemberModel.useOne(canRead ? id : null);
128
- const save = MemberModel.useSave();
129
- const resend = MemberModel.useAction('resend_invite');
130
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();
131
169
  useSyncExternalStore(subscribeMemberPermissions, memberPermissionsVersion, memberPermissionsVersion);
132
170
  useSyncExternalStore(subscribeFormTabs, formTabsVersion, formTabsVersion);
133
171
 
@@ -163,39 +201,50 @@ export function MemberDetail({
163
201
  const deploymentOnly = raw.filter((name) => !catalogNames.has(name) && !ignoredMemberGrants(member.permissions).includes(name));
164
202
 
165
203
  const editRole = async () => {
166
- const values = await formModal({
167
- title: 'Edit role label',
168
- submitText: 'Update label',
169
- fields: [{
170
- name: 'role', type: 'text', label: 'Role label',
171
- help: 'Presentation only. This label does not grant authorization.',
172
- }],
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.' }],
173
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;
174
216
  });
175
- if (!values) return;
176
- try {
177
- await save.mutateAsync({ id: member.id, changes: { metadata: { role: String(values.role ?? '').trim() } } });
178
- toast.success('Role label updated');
179
- } catch (error) {
180
- toast.error(error instanceof Error ? error.message : 'Role update failed');
181
- }
182
217
  };
183
218
 
184
219
  const setActive = async (next: boolean) => {
220
+ const started = scope.current;
221
+ if (!canSave || !isCurrent()) return;
185
222
  try {
186
223
  await save.mutateAsync({ id: member.id, changes: { is_active: next } });
187
- toast.success(next ? 'Membership activated' : 'Membership deactivated');
224
+ if (started?.current() && scope.current === started)
225
+ toast.success(next ? 'Membership activated' : 'Membership deactivated');
188
226
  } catch (error) {
189
- 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');
190
229
  }
191
230
  };
192
231
 
193
232
  const resendInvite = async () => {
233
+ const started = scope.current;
234
+ if (!canSave || !isCurrent() || !started) return;
194
235
  try {
195
- 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;
196
243
  toast.success('Invitation resent');
244
+ void MemberModel.invalidate(qc);
197
245
  } catch (error) {
198
- 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');
199
248
  }
200
249
  };
201
250
 
@@ -225,7 +274,7 @@ export function MemberDetail({
225
274
  { icon: 'bi-person-badge', text: role, tone: 'primary' as const },
226
275
  { text: `${effectiveMemberGrants(member.permissions).length} effective grants`, tone: 'muted' as const },
227
276
  ]}
228
- 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}
229
278
  contextMenu={menu}
230
279
  menuContext={member}
231
280
  onClose={onClose}
@@ -259,11 +308,10 @@ export function MemberDetail({
259
308
  {canSave
260
309
  ? (
261
310
  <FormView
262
- model={MemberModel}
311
+ model={guardedModel} isCurrent={isCurrent}
263
312
  row={member}
264
313
  fields={groupFields}
265
- beforeSave={({ changes }) => confirmGrantEscalation(member, changes)}
266
- onSaved={() => { void MemberModel.invalidate(qc); }}
314
+ beforeSave={({ changes }) => confirmGrantEscalation(member, changes, scope.current?.signal)}
267
315
  />
268
316
  )
269
317
  : <GrantSummary member={member} />}
@@ -279,9 +327,9 @@ export function MemberDetail({
279
327
  <Eyebrow>Product permissions</Eyebrow>
280
328
  <p className="dim">These grants apply only to this membership’s group. Changes save automatically.</p>
281
329
  {canSave ? (
282
- <FormView model={MemberModel} row={member} tabs={productTabs}
283
- beforeSave={({ changes }) => confirmGrantEscalation(member, changes)}
284
- 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
+ />
285
333
  ) : <GrantSummary member={member} />}
286
334
  </>
287
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
+ }
@@ -1,6 +1,8 @@
1
1
  import type { QueryClient } from '@tanstack/react-query';
2
- import { mojoCall, mojoSave, type Group } from '../../../client/runtime';
3
- import { formModal, modal, toast } from '../../../ui';
2
+ import { mojoCall, type Group } from '../../../client/runtime';
3
+ import { modal, toast } from '../../../ui';
4
+ import { memberForm } from './member-dialog';
5
+ import { captureMemberScope, saveMemberInScope, type MemberScope } from './member-scope';
4
6
  import { MemberModel, type MemberRow } from './models';
5
7
 
6
8
  export interface AdmissionGroup extends Pick<Group, 'id' | 'name' | 'kind'> {}
@@ -14,9 +16,10 @@ export interface AdmissionRole {
14
16
  }
15
17
 
16
18
  /** Email-only, with no user-directory lookup or existence-dependent branch. */
17
- export async function inviteMemberByEmail(groupId: number, email: string, permissions?: string[]): Promise<MemberRow | null> {
19
+ export async function inviteMemberByEmail(groupId: number, email: string, permissions?: string[], beforeSend?: () => void): Promise<MemberRow | null> {
18
20
  const response = await mojoCall('/api/group/member/invite', {
19
21
  method: 'POST',
22
+ beforeSend,
20
23
  body: {
21
24
  group: groupId, email: email.trim(),
22
25
  ...(permissions?.length ? { permissions: Object.fromEntries(permissions.map((name) => [name, true])) } : {}),
@@ -27,7 +30,7 @@ export async function inviteMemberByEmail(groupId: number, email: string, permis
27
30
 
28
31
  export type AdmissionMode = 'invite' | 'add';
29
32
 
30
- function chooseAdmissionMode(group: AdmissionGroup, showInvite: boolean, showAdd: boolean): Promise<AdmissionMode | null> {
33
+ function chooseAdmissionMode(group: AdmissionGroup, showInvite: boolean, showAdd: boolean, signal: AbortSignal): Promise<AdmissionMode | null> {
31
34
  return modal.open<AdmissionMode>((close) => (
32
35
  <div className="modal-pad">
33
36
  <h2 className="modal-title">Add a member to {group.name}</h2>
@@ -50,11 +53,11 @@ function chooseAdmissionMode(group: AdmissionGroup, showInvite: boolean, showAdd
50
53
  <button type="button" className="btn" onClick={() => close(null as unknown as AdmissionMode)}>Cancel</button>
51
54
  </div>
52
55
  </div>
53
- ), { size: 'md' });
56
+ ), { size: 'md', signal });
54
57
  }
55
58
 
56
- async function pickGroup(): Promise<AdmissionGroup | null> {
57
- const data = await formModal({
59
+ async function pickGroup(scope: MemberScope): Promise<AdmissionGroup | null> {
60
+ const data = await memberForm(scope, {
58
61
  title: 'Choose a group',
59
62
  submitText: 'Continue',
60
63
  fields: [{
@@ -83,30 +86,26 @@ export interface OpenMemberAdmissionOptions {
83
86
  onCreated?: (member: MemberRow) => void | Promise<void>;
84
87
  /** Fixed roles shown as a select on the invite form; the first is the default. */
85
88
  roles?: AdmissionRole[];
89
+ /** Abort when the initiating panel unmounts or its group/authority changes. */
90
+ signal?: AbortSignal;
86
91
  }
87
92
 
88
93
  /** Shared explicit Invite/Add chooser used by global Members and GroupDetail. */
89
94
  export async function openMemberAdmissionDialog(options: OpenMemberAdmissionOptions): Promise<MemberRow | null> {
90
- const group = options.group ?? await pickGroup();
91
- if (!group) return null;
92
- const canAddExisting = options.canCreate && options.canReadUsers;
93
- if (!options.canInvite && !canAddExisting) return null;
94
-
95
- const allowedInitial = options.initialMode === 'invite'
96
- ? options.canInvite
97
- : options.initialMode === 'add'
98
- ? canAddExisting
99
- : false;
100
- const mode = allowedInitial
101
- ? options.initialMode!
102
- : await chooseAdmissionMode(group, options.canInvite, canAddExisting);
103
- if (!mode) return null;
104
-
95
+ const scope = captureMemberScope(options.signal);
105
96
  try {
106
- let member: MemberRow | null;
107
- if (mode === 'invite') {
108
- const roles = options.roles ?? [];
109
- const data = await formModal({
97
+ const group = options.group ?? await pickGroup(scope);
98
+ if (!scope.current() || !group) return null;
99
+ const canAddExisting = options.canCreate && options.canReadUsers;
100
+ if (!options.canInvite && !canAddExisting) return null;
101
+ const allowedInitial = options.initialMode === 'invite' ? options.canInvite
102
+ : options.initialMode === 'add' ? canAddExisting : false;
103
+ const mode = allowedInitial ? options.initialMode!
104
+ : await chooseAdmissionMode(group, options.canInvite, canAddExisting, scope.signal);
105
+ if (!scope.current() || !mode) return null;
106
+ const roles = options.roles ?? [];
107
+ const result = mode === 'invite'
108
+ ? await memberForm<{ member: MemberRow | null } | null>(scope, {
110
109
  title: `Invite a member to ${group.name}`,
111
110
  submitText: 'Send invitation',
112
111
  fields: [{
@@ -118,21 +117,28 @@ export async function openMemberAdmissionDialog(options: OpenMemberAdmissionOpti
118
117
  help: roles.map((role) => role.help ? `${role.label}: ${role.help}` : null).filter(Boolean).join(' '),
119
118
  }] : [])],
120
119
  ...(roles.length ? { initial: { role: roles[0].value } } : {}),
121
- });
122
- if (!data?.email) return null;
123
- const role = roles.find((candidate) => candidate.value === data.role) ?? roles[0];
124
- member = await inviteMemberByEmail(group.id, String(data.email), role?.permissions);
125
- // The role label is display-only; the grants above are the authority. A failed
126
- // label write leaves a correctly permissioned member, so it is not fatal.
127
- if (member && role) {
128
- try { member = await mojoSave<MemberRow>(MemberModel.endpoint, member.id, { metadata: { role: role.label } }); }
129
- catch { /* label stays "Member"; grants already applied */ }
130
- }
131
- toast.success('Invitation request accepted');
132
- } else {
133
- const data = await formModal({
134
- title: `Add an existing user to ${group.name}`,
135
- submitText: 'Add to group',
120
+ }, async (data, current) => {
121
+ const role = roles.find((candidate) => candidate.value === data.role) ?? roles[0];
122
+ let result = await inviteMemberByEmail(group.id, String(data.email), role?.permissions, () => {
123
+ scope.assert();
124
+ if (!current()) throw new DOMException('Member dialog closed', 'AbortError');
125
+ });
126
+ if (!current()) return null;
127
+ // The grants are authoritative. A refused display-label write is non-fatal.
128
+ if (result && role) {
129
+ try {
130
+ result = await saveMemberInScope(result.id, { metadata: { role: role.label } }, () => {
131
+ scope.assert();
132
+ if (!current()) throw new DOMException('Member dialog closed', 'AbortError');
133
+ });
134
+ } catch { /* invitation already accepted; keep its authoritative row */ }
135
+ }
136
+ if (!current()) return null;
137
+ toast.success('Invitation request accepted');
138
+ return { member: result };
139
+ })
140
+ : await memberForm<{ member: MemberRow }>(scope, {
141
+ title: `Add an existing user to ${group.name}`, submitText: 'Add to group',
136
142
  fields: [{
137
143
  name: 'user', type: 'collection', label: 'User', required: true,
138
144
  endpoint: '/api/user', labelField: 'email', valueField: 'id',
@@ -140,19 +146,23 @@ export async function openMemberAdmissionDialog(options: OpenMemberAdmissionOpti
140
146
  placeholder: 'Search users by email…',
141
147
  help: 'This directory-backed option is available only to authorized user-directory viewers.',
142
148
  }],
149
+ }, async (data, current) => {
150
+ const result = await saveMemberInScope(null,
151
+ { group: group.id, user: Number(data.user) }, () => {
152
+ scope.assert();
153
+ if (!current()) throw new DOMException('Member dialog closed', 'AbortError');
154
+ });
155
+ if (current()) toast.success('Existing user added to the group');
156
+ return { member: result };
143
157
  });
144
- if (data?.user == null || data.user === '') return null;
145
- member = await mojoSave<MemberRow>(MemberModel.endpoint, null, {
146
- group: group.id,
147
- user: Number(data.user),
148
- });
149
- toast.success('Existing user added to the group');
150
- }
158
+ if (!scope.current() || !result) return null;
159
+ const member = result.member;
151
160
  if (member) await options.onCreated?.(member);
161
+ if (!scope.current()) return null;
152
162
  if (options.queryClient) await MemberModel.invalidate(options.queryClient);
153
- return member;
163
+ return scope.current() ? member : null;
154
164
  } catch (error) {
155
- toast.error(error instanceof Error ? error.message : 'Member admission failed');
165
+ if (scope.current()) toast.error(error instanceof Error ? error.message : 'Member admission failed');
156
166
  return null;
157
- }
167
+ } finally { scope.dispose(); }
158
168
  }
@@ -0,0 +1,61 @@
1
+ import { useLayoutEffect, useMemo, useRef } from 'react';
2
+ import { getAuthSessionRevision, getAuthSnapshot, subscribeAuth } from '../../../client/auth';
3
+ import { mojoCall } from '../../../client/client';
4
+ import { MemberModel, type MemberRow } from './models';
5
+ import { getActiveGroupId, subscribeActiveGroup } from '../../../client/active-group';
6
+
7
+ /** A one-way fence: switching away and back never revives an old operation. */
8
+ export function captureMemberScope(owner?: AbortSignal) {
9
+ const controller = new AbortController();
10
+ const actor = getAuthSnapshot();
11
+ const revision = getAuthSessionRevision();
12
+ const group = getActiveGroupId();
13
+ const abort = () => controller.abort();
14
+ const current = () => !controller.signal.aborted
15
+ && getAuthSessionRevision() === revision
16
+ && getAuthSnapshot().uid === actor.uid
17
+ && getAuthSnapshot().authenticated === actor.authenticated
18
+ && getActiveGroupId() === group;
19
+ const observe = () => { if (!current()) abort(); };
20
+ const offAuth = subscribeAuth(observe);
21
+ const offGroup = subscribeActiveGroup(observe);
22
+ owner?.addEventListener('abort', abort, { once: true });
23
+ if (owner?.aborted) abort();
24
+ return {
25
+ signal: controller.signal,
26
+ current,
27
+ assert() { if (!current()) throw new DOMException('Membership context ended', 'AbortError'); },
28
+ dispose() { offAuth(); offGroup(); owner?.removeEventListener('abort', abort); abort(); },
29
+ };
30
+ }
31
+
32
+ export type MemberScope = ReturnType<typeof captureMemberScope>;
33
+
34
+ /** Mounted owner lifetime, also replaced when its record or authority changes. */
35
+ export function useMemberScope(owner: string, onInvalidated?: () => void) {
36
+ // A new reference per owner keeps old async callbacks attached to the disposed
37
+ // scope even if authority changes away and back to the same values.
38
+ const scope = useMemo<{ current: (MemberScope & { owner: string }) | null }>(
39
+ () => ({ current: null }), [owner]);
40
+ const callback = useRef(onInvalidated);
41
+ callback.current = onInvalidated;
42
+ useLayoutEffect(() => {
43
+ const next = { ...captureMemberScope(), owner };
44
+ scope.current = next;
45
+ const invalidated = () => callback.current?.();
46
+ next.signal.addEventListener('abort', invalidated, { once: true });
47
+ return () => {
48
+ next.signal.removeEventListener('abort', invalidated);
49
+ next.dispose();
50
+ };
51
+ }, [owner, scope]);
52
+ return scope;
53
+ }
54
+
55
+ /** Keep the final pre-send check after the transport's asynchronous auth refresh. */
56
+ export async function saveMemberInScope(id: number | string | null, changes: Record<string, unknown>, beforeSend: () => void): Promise<MemberRow> {
57
+ const response = await mojoCall(id == null ? MemberModel.endpoint : `${MemberModel.endpoint}/${id}`, {
58
+ method: 'POST', body: changes, unscoped: id != null, beforeSend,
59
+ });
60
+ return response.data as MemberRow;
61
+ }
@@ -3,7 +3,7 @@ import { useState } from 'react';
3
3
  import { useCan } from '../../client/runtime';
4
4
  import { Badge, DetailView, FlatRow, fmt, modal, toast } from '../../ui';
5
5
  import { FileView } from './FileView';
6
- import { FileManagerModel, FileModel, GROUP_DIRECTORY_PERMS, STORAGE_MANAGE_PERMS, USER_DIRECTORY_PERMS, runFileManagerAction, saveFileManagerAtomic, storageRelationId } from './models';
6
+ import { FileManagerModel, FileModel, GROUP_DIRECTORY_PERMS, STORAGE_MANAGE_PERMS, USER_DIRECTORY_PERMS, cdnFindings, runFileManagerAction, saveFileManagerAtomic, storageRelationId, type CdnFinding, type FileManagerCdn } from './models';
7
7
  import { openFileManagerEditor } from './storage-dialogs';
8
8
 
9
9
  function NestedFiles({ managerId }: { managerId: number }) {
@@ -14,6 +14,30 @@ function NestedFiles({ managerId }: { managerId: number }) {
14
14
  return <div className="storage-child-files">{rows.length === 0 ? <p className="dim">No files. Upload is unavailable in this release.</p> : rows.map((file) => <button key={file.id} className="storage-child-file" onClick={() => void modal.detail((close) => <FileView id={file.id} onClose={() => close(null)} />)}><span><b>{file.filename}</b><small>{file.content_type} · {fmt.filesize(file.file_size ?? 0)}</small></span><i className="bi bi-chevron-right" /></button>)}</div>;
15
15
  }
16
16
 
17
+ const CDN_TONES: Record<string, 'success' | 'warning' | 'danger' | 'info' | 'muted'> = {
18
+ PASS: 'success', PENDING: 'info', DRIFT: 'warning', MISSING: 'warning', MANUAL: 'danger', BLOCKED: 'danger', BLIND: 'danger',
19
+ };
20
+
21
+ /** The findings of a CDN enable/check/disable report: status, code, message, remedy. Never raw JSON. */
22
+ function CdnReport({ title, findings, summary }: { title: string; findings: CdnFinding[]; summary: string }) {
23
+ return <div className="modal-pad storage-cdn-report">
24
+ <h2 className="modal-title">{title}</h2>
25
+ <p className="modal-message">{summary}</p>
26
+ {findings.length === 0 ? <p className="dim">No findings.</p> : <div className="storage-cdn-findings">{findings.map((item, index) => <div className="storage-cdn-finding" key={`${item.code}-${index}`}>
27
+ <div><Badge tone={CDN_TONES[item.status] ?? 'muted'}>{item.status}</Badge> <code>{item.step}/{item.code}</code></div>
28
+ <p>{item.message}</p>
29
+ {item.remedy && <pre className="storage-result-json">{item.remedy}</pre>}
30
+ </div>)}</div>}
31
+ </div>;
32
+ }
33
+
34
+ function cdnSummary(cdn: FileManagerCdn | undefined): string {
35
+ if (!cdn?.enabled) return 'Not enabled';
36
+ const delivery = cdn.signed ? 'signed links' : 'public links';
37
+ const state = cdn.status === 'deployed' ? 'ready' : cdn.status === 'pending' ? 'deploying' : cdn.status ?? 'unknown';
38
+ return `${cdn.url ?? '—'} · ${delivery} · ${state}${cdn.source === 'byo' ? ' · brought by the operator' : ''}`;
39
+ }
40
+
17
41
  export function FileManagerDetail({ id, onClose }: { id: number; onClose: () => void }) {
18
42
  const queryClient = useQueryClient();
19
43
  const [pending, setPending] = useState(false);
@@ -28,6 +52,36 @@ export function FileManagerDetail({ id, onClose }: { id: number; onClose: () =>
28
52
  const row = query.data;
29
53
  const edit = async (mode: 'general' | 'credentials' | 'owner') => { const saved = await openFileManagerEditor(mode, row); if (saved) { toast.success(`${saved.name} updated`); await query.refetch(); } };
30
54
  const action = async (name: 'test_connection' | 'check_cors' | 'fix_cors') => { try { const result = await runFileManagerAction(id, name); toast.success(name === 'test_connection' ? 'Connection succeeded' : `${name.replace('_', ' ')} completed`); await modal.open((close) => <div className="modal-pad"><h2 className="modal-title">{name.replaceAll('_', ' ')}</h2><pre className="storage-result-json">{JSON.stringify(result, null, 2)}</pre><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></div>); } catch (error) { toast.error(error instanceof Error ? error.message : `${name} failed`); } };
55
+ const cdnAction = async (name: 'enable_cdn' | 'check_cdn' | 'disable_cdn', value: unknown = true) => {
56
+ try {
57
+ const result = await runFileManagerAction(id, name, value);
58
+ const report = (result.result ?? {}) as Record<string, unknown>;
59
+ const findings = cdnFindings(report);
60
+ const summary = name === 'disable_cdn'
61
+ ? (report.disabled ? `Every backend on this bucket now hands out S3 links again${report.retired ? '; the distribution is disabled' : ''}. Nothing was deleted.` : 'No CDN was configured for this bucket.')
62
+ : report.converged
63
+ ? (name === 'enable_cdn' ? 'CDN delivery is set up. Links are issued now and start working once CloudFront reports Deployed.' : 'The CDN setup is complete.')
64
+ : 'Something still needs attention. Each finding below says what, and how to fix it.';
65
+ await query.refetch();
66
+ await modal.open((close) => <><CdnReport title={name === 'enable_cdn' ? 'Enable CDN' : name === 'check_cdn' ? 'Check CDN' : 'Disable CDN'} findings={findings} summary={summary} /><div className="modal-actions"><button className="btn" onClick={() => close(null)}>Close</button></div></>, { size: 'lg' });
67
+ } catch (error) { toast.error(error instanceof Error ? error.message : `${name} failed`); }
68
+ };
69
+ const enableCdn = async () => {
70
+ const ok = await modal.confirm({
71
+ title: 'Enable CDN delivery?',
72
+ message: <>django-mojo will set up CloudFront for the whole bucket behind <b>{row.name}</b> under the deployment's AWS identity: the distribution, its private access to the bucket, the signing key, and the bucket and encryption-key policies. Running it again creates nothing. Public backends on the bucket get public links; private ones get signed links.</>,
73
+ confirmText: 'Enable CDN',
74
+ });
75
+ if (ok) await cdnAction('enable_cdn', { harden: false });
76
+ };
77
+ const disableCdn = async () => {
78
+ const ok = await modal.confirm({
79
+ title: 'Disable CDN delivery?',
80
+ message: <>Every backend on the bucket behind <b>{row.name}</b> goes back to S3 links on its next request. Nothing in AWS is changed or deleted; the distribution stays and can be enabled again.</>,
81
+ confirmText: 'Disable CDN', danger: true,
82
+ });
83
+ if (ok) await cdnAction('disable_cdn', { retire: false });
84
+ };
31
85
  const clone = async () => { const ok = await modal.confirm({ title: 'Clone backend?', message: <>Create a private clone of <b>{row.name}</b> under the same explicit owner scope?</>, confirmText: 'Clone' }); if (!ok) return; try { await runFileManagerAction(id, 'clone'); await FileManagerModel.invalidate(queryClient); toast.success(`${row.name} cloned`); } catch (error) { toast.error(error instanceof Error ? error.message : 'Clone failed'); } };
32
86
  const toggle = async () => { const ok = await modal.confirm({ title: row.is_public ? 'Make backend private?' : 'Make backend public?', message: 'This changes prefix policy on the storage provider. Continue?', confirmText: row.is_public ? 'Make private' : 'Make public', danger: !row.is_public }); if (!ok) return; try { await saveFileManagerAtomic({ queryClient, id, changes: { is_public: !row.is_public } }); await query.refetch(); } catch (error) { toast.error(error instanceof Error ? error.message : 'Access change failed'); } };
33
87
  const setActive = async (next: boolean) => {
@@ -45,9 +99,13 @@ export function FileManagerDetail({ id, onClose }: { id: number; onClose: () =>
45
99
  { label: row.is_public ? 'Make private…' : 'Make public…', onSelect: () => void toggle() },
46
100
  { label: 'Test connection', onSelect: () => void action('test_connection') },
47
101
  ...(row.backend_type === 's3' ? [{ label: 'Check CORS', onSelect: () => void action('check_cors') }, { label: 'Fix CORS', onSelect: () => void action('fix_cors') }] : []),
102
+ ...(row.backend_type === 's3' ? [
103
+ { label: 'Check CDN', onSelect: () => void cdnAction('check_cdn') },
104
+ ...(row.cdn?.enabled ? [{ label: 'Disable CDN…', danger: true, onSelect: () => void disableCdn() }] : [{ label: 'Enable CDN…', onSelect: () => void enableCdn() }]),
105
+ ] : []),
48
106
  { label: 'Clone…', onSelect: () => void clone() },
49
107
  ] : []} title={row.name} subtitle={`${row.backend_type} · ${row.backend_url}`} icon="bi-hdd-stack" chips={[{ text: row.is_active ? 'Active' : 'Inactive', tone: row.is_active ? 'success' : 'muted' }, { text: row.is_public ? 'Public' : 'Private', tone: row.is_public ? 'warning' : 'success' }]} sections={[
50
- { key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"><FlatRow label="Backend"><Badge tone="info">{row.backend_type}</Badge></FlatRow><FlatRow label="Use">{row.use || '—'}</FlatRow><FlatRow label="URL"><code>{row.backend_url}</code></FlatRow><FlatRow label="Owner">{storageRelationId(row.group) != null ? `Group #${storageRelationId(row.group)}` : storageRelationId(row.user) != null ? `User #${storageRelationId(row.user)}` : 'System'}</FlatRow><FlatRow label="Default">{row.is_default ? 'Yes' : 'No'}</FlatRow><FlatRow label="Region">{row.aws_region || '—'}</FlatRow><FlatRow label="Access key">{row.aws_key_masked || 'Not configured'}</FlatRow><FlatRow label="Secret key">{row.aws_secret_masked || 'Not configured'}</FlatRow><FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> },
108
+ { key: 'overview', label: 'Overview', icon: 'bi-info-circle', render: () => <div className="detail-section"><FlatRow label="Backend"><Badge tone="info">{row.backend_type}</Badge></FlatRow><FlatRow label="Use">{row.use || '—'}</FlatRow><FlatRow label="URL"><code>{row.backend_url}</code></FlatRow><FlatRow label="Owner">{storageRelationId(row.group) != null ? `Group #${storageRelationId(row.group)}` : storageRelationId(row.user) != null ? `User #${storageRelationId(row.user)}` : 'System'}</FlatRow><FlatRow label="Default">{row.is_default ? 'Yes' : 'No'}</FlatRow><FlatRow label="Region">{row.aws_region || '—'}</FlatRow><FlatRow label="Access key">{row.aws_key_masked || 'Not configured'}</FlatRow><FlatRow label="Secret key">{row.aws_secret_masked || 'Not configured'}</FlatRow>{row.backend_type === 's3' && <FlatRow label="CDN">{cdnSummary(row.cdn)}</FlatRow>}<FlatRow label="Created">{fmt.datetime(row.created)}</FlatRow></div> },
51
109
  { key: 'files', label: 'Files', icon: 'bi-files', render: () => <div className="detail-section"><h3>Recent files</h3><p className="dim">Newest 10 for this backend. This bounded child inventory does not own URL table state.</p><NestedFiles managerId={id} /></div> },
52
110
 
53
111
  ]} initialSection="overview" onClose={onClose} />;
@@ -94,9 +94,10 @@ function RoleField({ category, draft, role, kind, name, error, disabled, onChang
94
94
  function CategoryPanel({ category, categoryKey }: { category: RenditionCategory; categoryKey: string }) {
95
95
  const queryClient = useQueryClient();
96
96
  const rowsQuery = useQuery({ queryKey: rowQueryKey(category.key), queryFn: () => findCategoryRows(category.key), retry: false, refetchOnWindowFocus: false });
97
- const rows = rowsQuery.data ?? [];
98
- const row: SettingRow | null = rows[0] ?? null;
99
- const duplicates = rows.length > 1;
97
+ // Rows only from a successful read: a failed or pending read is "unknown", never "no row".
98
+ const rows = rowsQuery.isSuccess ? rowsQuery.data : null;
99
+ const row: SettingRow | null = rows?.[0] ?? null;
100
+ const duplicates = (rows?.length ?? 0) > 1;
100
101
  const [draft, setDraft] = useState<CategoryDraft>(() => valueToDraft(category, category.override));
101
102
  const [errors, setErrors] = useState<Record<string, string>>({});
102
103
  const [formError, setFormError] = useState('');
@@ -134,6 +135,7 @@ function CategoryPanel({ category, categoryKey }: { category: RenditionCategory;
134
135
  setErrors((current) => ({ ...current, [key]: routed.message }));
135
136
  };
136
137
  const save = async () => {
138
+ if (!rowsQuery.isSuccess || duplicates) return;
137
139
  setBusy(true); setFormError(''); setErrors({});
138
140
  try {
139
141
  await saveCategoryValue(category, row, value);
@@ -146,6 +148,7 @@ function CategoryPanel({ category, categoryKey }: { category: RenditionCategory;
146
148
  }
147
149
  };
148
150
  const reset = async () => {
151
+ if (!rowsQuery.isSuccess || duplicates) return;
149
152
  const ok = await modal.confirm({
150
153
  title: `Reset ${renditionCategoryLabel(categoryKey).toLowerCase()} rendering to defaults?`,
151
154
  message: 'Every role goes back to the framework defaults and the default set of roles runs on upload. Existing files are not touched.',
@@ -155,8 +158,9 @@ function CategoryPanel({ category, categoryKey }: { category: RenditionCategory;
155
158
  if (!ok) return;
156
159
  setBusy(true); setFormError(''); setErrors({});
157
160
  try {
158
- await resetCategory(category, row);
159
- toast.success(`${renditionCategoryLabel(categoryKey)} rendering reset to defaults`);
161
+ const resetRow = await resetCategory(category, row);
162
+ if (resetRow) toast.success(`${renditionCategoryLabel(categoryKey)} rendering reset to defaults`);
163
+ else toast.info('There was no stored override to reset.');
160
164
  await refresh();
161
165
  } catch (reason) {
162
166
  fail(reason);
@@ -165,14 +169,17 @@ function CategoryPanel({ category, categoryKey }: { category: RenditionCategory;
165
169
  }
166
170
  };
167
171
 
168
- const disabled = busy || duplicates || rowsQuery.isPending;
172
+ const disabled = busy || duplicates || !rowsQuery.isSuccess;
173
+ const status = rowsQuery.isPending ? 'Reading the current override…'
174
+ : rowsQuery.isError ? 'The current override could not be read — Save is disabled until it loads.'
175
+ : row ? `Override row #${row.id}` : 'No override row yet — Save creates one.';
169
176
  return <div className="media-rendering-panel">
170
177
  {hasH265 && <p className="media-rendering-note">
171
178
  <b>H.265 (HEVC)</b> encodes take several times longer than H.264 and do not play in every browser. Keep <code>video_mp4</code> as the H.264 compatibility rendition and enable <code>video_hevc</code> on upload only when the extra encode time is worth it.
172
179
  </p>}
173
180
  {rowsQuery.error && <div className="form-alert" role="alert">The current override could not be read: {rowsQuery.error.message} <button className="btn btn-compact" onClick={() => void rowsQuery.refetch()}>Retry</button></div>}
174
181
  {duplicates && <div className="form-alert" role="alert">
175
- More than one global row exists for <code>{category.key}</code> (ids {rows.map((entry) => entry.id).join(', ')}). The server reads an undefined one, so this page will not edit them; remove the duplicates on the server first.
182
+ More than one global row exists for <code>{category.key}</code> (ids {rows?.map((entry) => entry.id).join(', ')}). The server reads an undefined one, so this page will not edit them; remove the duplicates on the server first.
176
183
  </div>}
177
184
  {formError && <div className="form-alert" role="alert">{formError}</div>}
178
185
  <div className="media-rendering-roles">
@@ -210,7 +217,7 @@ function CategoryPanel({ category, categoryKey }: { category: RenditionCategory;
210
217
  <div className="media-rendering-actions">
211
218
  <button className="btn btn-primary" disabled={disabled || !dirty} onClick={() => void save()}>{busy ? 'Saving…' : 'Save'}</button>
212
219
  <button className="btn" disabled={disabled || !row || category.override == null} onClick={() => void reset()}>Reset to defaults…</button>
213
- <span className="dim">{row ? `Override row #${row.id}` : 'No override row yet — Save creates one.'}</span>
220
+ <span className="dim">{status}</span>
214
221
  </div>
215
222
  </div>;
216
223
  }
@@ -13,6 +13,23 @@ export const USER_DIRECTORY_PERMS = USER_VIEW_PERMISSIONS;
13
13
 
14
14
  export interface RelationRow { id: number; name?: string | null; display_name?: string | null; email?: string | null }
15
15
 
16
+ /** The manager graph's read-only `cdn` object (django-mojo #8250). */
17
+ export interface FileManagerCdn {
18
+ enabled: boolean;
19
+ url?: string;
20
+ bucket?: string;
21
+ distribution_id?: string;
22
+ source?: string;
23
+ status?: string;
24
+ signed?: boolean;
25
+ signing_key_id?: string | null;
26
+ }
27
+
28
+ export type FileManagerAction = 'test_connection' | 'check_cors' | 'fix_cors' | 'clone' | 'enable_cdn' | 'check_cdn' | 'disable_cdn';
29
+
30
+ /** One finding of a CDN enable/check/disable report, aws-check shaped. */
31
+ export interface CdnFinding { step: string; status: string; code: string; message: string; remedy: string | null }
32
+
16
33
  export interface FileManagerRow {
17
34
  id: number;
18
35
  created: number;
@@ -29,6 +46,7 @@ export interface FileManagerRow {
29
46
  allowed_origins?: string[];
30
47
  assume_role_arn?: string | null;
31
48
  has_external_id?: boolean;
49
+ cdn?: FileManagerCdn;
32
50
  group?: RelationRow | number | null;
33
51
  user?: RelationRow | number | null;
34
52
  }
@@ -181,11 +199,41 @@ export function sanitizeFileManagerRow(input: FileManagerRow): FileManagerRow {
181
199
  allowed_origins: Array.isArray(raw.allowed_origins) ? raw.allowed_origins.map(String) : [],
182
200
  assume_role_arn: raw.assume_role_arn == null ? null : String(raw.assume_role_arn),
183
201
  has_external_id: Boolean(raw.has_external_id),
202
+ cdn: projectCdn(raw.cdn),
184
203
  group: relation(raw.group),
185
204
  user: relation(raw.user),
186
205
  };
187
206
  }
188
207
 
208
+ /** Positive projection of the `cdn` object: named keys only, never key material. */
209
+ export function projectCdn(value: unknown): FileManagerCdn {
210
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) return { enabled: false };
211
+ const raw = value as Record<string, unknown>;
212
+ if (raw.enabled !== true) return { enabled: false };
213
+ const text = (key: string) => (typeof raw[key] === 'string' ? raw[key] as string : undefined);
214
+ return {
215
+ enabled: true,
216
+ url: text('url'), bucket: text('bucket'), distribution_id: text('distribution_id'),
217
+ source: text('source'), status: text('status'), signed: raw.signed === true,
218
+ signing_key_id: typeof raw.signing_key_id === 'string' ? raw.signing_key_id : null,
219
+ };
220
+ }
221
+
222
+ /** The findings list out of an enable/check/disable action result. */
223
+ export function cdnFindings(result: unknown): CdnFinding[] {
224
+ if (result == null || typeof result !== 'object') return [];
225
+ const items = (result as Record<string, unknown>).items;
226
+ if (!Array.isArray(items)) return [];
227
+ return items.flatMap((item) => {
228
+ if (item == null || typeof item !== 'object') return [];
229
+ const raw = item as Record<string, unknown>;
230
+ return [{
231
+ step: String(raw.step ?? ''), status: String(raw.status ?? ''), code: String(raw.code ?? ''),
232
+ message: String(raw.message ?? ''), remedy: typeof raw.remedy === 'string' ? raw.remedy : null,
233
+ }];
234
+ });
235
+ }
236
+
189
237
  export function sanitizeFileManagerUploadPolicyRow(input: FileManagerUploadPolicyRow): FileManagerUploadPolicyRow {
190
238
  const raw = input as unknown as Record<string, unknown>;
191
239
  const id = Number(raw.id);
@@ -367,7 +415,7 @@ export function storageRefreshFailure(error: unknown): unknown {
367
415
  // hand-normalize the wrapped `{data:{status:false, error}}` shape themselves
368
416
  // and answer a caller-shaped `{status, id, result}` evidence dict — migrating
369
417
  // would change both the thrown type and the resolved shape for no gain.
370
- export async function runFileManagerAction(id: number, action: 'test_connection' | 'check_cors' | 'fix_cors' | 'clone', value: unknown = true): Promise<Record<string, unknown>> {
418
+ export async function runFileManagerAction(id: number, action: FileManagerAction, value: unknown = true): Promise<Record<string, unknown>> {
371
419
  const body = await mojoCall(`${FileManagerModel.endpoint}/${id}`, { method: 'POST', body: { [action]: value } });
372
420
  const data = body.data;
373
421
  if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error(`${action} returned no result`);
@@ -6,7 +6,7 @@
6
6
  // Everything about roles, fields, choices and limits comes from the
7
7
  // descriptor. The ONE rule this module hardcodes is the mp4-only trio
8
8
  // (`codec`/`crf`/`preset`), which the server refuses on a non-mp4 role.
9
- import { mojoCall, mojoList, mojoSave, withFreshAuth } from '../../client/runtime';
9
+ import { isReauthRequired, mojoCall, mojoList, mojoSave, withFreshAuth } from '../../client/runtime';
10
10
  import type { SettingRow } from '../settings/model';
11
11
 
12
12
  export type RenditionCategoryKey = 'image' | 'video' | 'document';
@@ -251,13 +251,36 @@ export function routeRenditionError(message: string, knownRoles: readonly string
251
251
  return { role: role!, field: field ?? null, message: rest! };
252
252
  }
253
253
 
254
- /** Create or update the category's global row. Imperative; a failure rejects. */
254
+ /**
255
+ * Create or update the category's global row. Imperative; a failure rejects.
256
+ * With `row === null` the handler proves the row is absent before creating:
257
+ * it re-reads the global rows and refuses (no write) when that read fails or
258
+ * finds a row, so no caller creates one blind. The read and the create are
259
+ * separate requests: two admins saving at the same moment can still both
260
+ * create (#8200 Challenge row 2); server-side uniqueness is #8201.
261
+ */
255
262
  export async function saveCategoryValue(category: RenditionCategory, row: SettingRow | null, value: Record<string, unknown>): Promise<SettingRow> {
256
263
  const changes: Record<string, unknown> = row ? { value } : { key: category.key, is_secret: false, group: null, value };
257
- return withFreshAuth(() => mojoSave<SettingRow>('/api/settings', row?.id ?? null, changes));
264
+ return withFreshAuth(async () => {
265
+ if (!row) {
266
+ let existing: SettingRow[];
267
+ try {
268
+ existing = await findCategoryRows(category.key);
269
+ } catch (error) {
270
+ // A step-up refusal goes back to withFreshAuth, which re-runs the read after re-login.
271
+ if (isReauthRequired(error)) throw error;
272
+ throw new Error('The current override could not be read, so nothing was saved. Retry the read first.');
273
+ }
274
+ if (existing.length) throw new Error(`A global row for ${category.key} already exists (id ${existing.map((entry) => entry.id).join(', ')}); reload before saving.`);
275
+ }
276
+ return mojoSave<SettingRow>('/api/settings', row?.id ?? null, changes);
277
+ });
258
278
  }
259
279
 
260
- /** Reset = write `{}`. The live Setting model refuses DELETE, and `{}` is reported as `override: null`. */
280
+ /**
281
+ * Reset = write `{}`. The live Setting model refuses DELETE, and `{}` is reported as `override: null`.
282
+ * Resolves `null` when `row` is null: nothing was reset and nothing was sent.
283
+ */
261
284
  export async function resetCategory(category: RenditionCategory, row: SettingRow | null): Promise<SettingRow | null> {
262
285
  if (!row) return null;
263
286
  return saveCategoryValue(category, row, {});
@@ -4149,6 +4149,7 @@ interface MockFileManager {
4149
4149
  aws_key_masked: string | null; aws_secret_masked: string | null; allowed_origins: string[];
4150
4150
  assume_role_arn: string | null; has_external_id: boolean; group: number | null; user: number | null;
4151
4151
  max_file_size: number; allowed_extensions: string[]; allowed_mime_types: string[]; supports_direct_upload: boolean;
4152
+ cdn?: Record<string, unknown>;
4152
4153
  }
4153
4154
 
4154
4155
  interface MockStorageFile {
@@ -4267,7 +4268,7 @@ function buildFileManagers(): MockFileManager[] {
4267
4268
  const now = Math.floor(Date.now() / 1000);
4268
4269
  return [
4269
4270
  { id: 4101, created: now - 410 * 86400, name: 'Private uploads', use: 'uploads', backend_type: 's3', backend_url: 's3://mojo-private-assets/uploads', is_active: true, is_default: true, is_public: false, aws_region: 'us-west-2', aws_key_masked: 'AKIA••••7D2Q', aws_secret_masked: '••••••••••m9p', allowed_origins: ['https://admin.example.test'], assume_role_arn: null, has_external_id: false, group: 1, user: null, max_file_size: 25_000_000, allowed_extensions: ['jpg', 'png', 'pdf', 'txt'], allowed_mime_types: ['image/*', 'application/pdf', 'text/plain'], supports_direct_upload: true },
4270
- { id: 4102, created: now - 320 * 86400, name: 'Public media', use: 'media', backend_type: 's3', backend_url: 's3://mojo-public-media/media', is_active: true, is_default: false, is_public: true, aws_region: 'us-west-2', aws_key_masked: 'AKIA••••3K8M', aws_secret_masked: '••••••••••r2x', allowed_origins: ['https://example.test'], assume_role_arn: null, has_external_id: false, group: null, user: 14, max_file_size: 50_000_000, allowed_extensions: [], allowed_mime_types: ['image/*', 'video/*'], supports_direct_upload: true },
4271
+ { cdn: { enabled: true, url: 'https://d2mockmedia.cloudfront.net', bucket: 'mojo-public-media', distribution_id: 'E2MOCKMEDIA', source: 'provisioned', status: 'deployed', signed: false, signing_key_id: 'K2MOCKKEY' }, id: 4102, created: now - 320 * 86400, name: 'Public media', use: 'media', backend_type: 's3', backend_url: 's3://mojo-public-media/media', is_active: true, is_default: false, is_public: true, aws_region: 'us-west-2', aws_key_masked: 'AKIA••••3K8M', aws_secret_masked: '••••••••••r2x', allowed_origins: ['https://example.test'], assume_role_arn: null, has_external_id: false, group: null, user: 14, max_file_size: 50_000_000, allowed_extensions: [], allowed_mime_types: ['image/*', 'video/*'], supports_direct_upload: true },
4271
4272
  { id: 4103, created: now - 70 * 86400, name: 'Local archive', use: 'archive', backend_type: 'file', backend_url: '/srv/mojo/archive', is_active: false, is_default: false, is_public: false, aws_region: null, aws_key_masked: null, aws_secret_masked: null, allowed_origins: [], assume_role_arn: null, has_external_id: false, group: 2, user: null, max_file_size: 0, allowed_extensions: [], allowed_mime_types: [], supports_direct_upload: false },
4272
4273
  { id: 4104, created: now - 30 * 86400, name: 'System uploads', use: 'uploads', backend_type: 'file', backend_url: '/srv/mojo/uploads', is_active: true, is_default: false, is_public: false, aws_region: null, aws_key_masked: null, aws_secret_masked: null, allowed_origins: [], assume_role_arn: null, has_external_id: false, group: null, user: null, max_file_size: 0, allowed_extensions: [], allowed_mime_types: [], supports_direct_upload: false },
4273
4274
  { id: 4105, created: now - 20 * 86400, name: 'Ian uploads', use: 'uploads', backend_type: 'file', backend_url: '/srv/mojo/users/1', is_active: true, is_default: false, is_public: false, aws_region: null, aws_key_masked: null, aws_secret_masked: null, allowed_origins: [], assume_role_arn: null, has_external_id: false, group: null, user: 1, max_file_size: 0, allowed_extensions: [], allowed_mime_types: [], supports_direct_upload: false },
@@ -6631,10 +6632,33 @@ function serializeStorageManager(row: MockFileManager): Record<string, unknown>
6631
6632
  aws_region: row.aws_region, aws_key_masked: row.aws_key_masked,
6632
6633
  aws_secret_masked: row.aws_secret_masked, allowed_origins: [...row.allowed_origins],
6633
6634
  assume_role_arn: row.assume_role_arn, has_external_id: row.has_external_id,
6635
+ cdn: row.cdn ? { ...row.cdn } : { enabled: false },
6634
6636
  group: storageRelation('group', row.group), user: storageRelation('user', row.user),
6635
6637
  };
6636
6638
  }
6637
6639
 
6640
+ /** django-mojo #8250: the aws-check-shaped report the three CDN actions return. */
6641
+ function storageCdnReport(manager: MockFileManager, mode: 'enable' | 'check' | 'disable'): Record<string, unknown> {
6642
+ const bucket = manager.backend_url.replace(/^s3:\/\//, '').split('/')[0];
6643
+ const items = mode === 'disable'
6644
+ ? []
6645
+ : [
6646
+ { step: 'preflight', status: 'PASS', code: 'identity.ok', message: 'running as arn:aws:iam::123456789012:role/django-mojo-setup', remedy: null },
6647
+ { step: 'bucket', status: 'PASS', code: 'public_access_block.ok', message: 'Block Public Access is on for every setting', remedy: null },
6648
+ { step: 'signing_key', status: 'PASS', code: 'signing_key.ok', message: 'signing key K2MOCKKEY is active', remedy: null },
6649
+ { step: 'oac', status: 'PASS', code: 'oac.ok', message: `origin access control mojo-fm-oac-${bucket.slice(0, 6)} exists`, remedy: null },
6650
+ { step: 'distribution', status: mode === 'enable' ? 'PENDING' : 'PASS', code: mode === 'enable' ? 'distribution.deploying' : 'distribution.ok', message: mode === 'enable' ? 'distribution EMOCK1 is still deploying to the edge' : `distribution EMOCK1 serves ${bucket} through OAC with the expected behaviors`, remedy: mode === 'enable' ? 'links are issued now and start working when CloudFront reports Deployed' : null },
6651
+ { step: 'bucket_policy', status: 'PASS', code: 'bucket_policy.ok', message: 'the bucket policy lets this distribution read objects', remedy: null },
6652
+ { step: 'kms', status: 'MANUAL', code: 'kms.aws_managed', message: 'objects are encrypted with the AWS-managed aws/s3 key, whose policy cannot be changed, so CloudFront can never decrypt them', remedy: 'create a customer-managed KMS key dedicated to media, set it as the manager\'s kms_key_id and the bucket\'s default encryption, re-encrypt existing objects, then re-run' },
6653
+ ].filter((item) => !(item.step === 'kms' && manager.id !== 4101));
6654
+ const worst = items.some((i) => i.status === 'MANUAL') ? 'MANUAL' : items.some((i) => i.status === 'PENDING') ? 'PENDING' : 'PASS';
6655
+ return {
6656
+ version: 1, mode, bucket, manager: manager.id, harden: false, items, actions: [],
6657
+ worst, blocking: false, converged: worst !== 'MANUAL',
6658
+ distribution: { distribution_id: 'EMOCK1', domain_name: 'emock1.cloudfront.net', status: mode === 'enable' ? 'pending' : 'deployed' },
6659
+ };
6660
+ }
6661
+
6638
6662
  function serializeUploadPolicy(row: MockFileManager): Record<string, unknown> {
6639
6663
  return {
6640
6664
  id: row.id, name: row.name, use: row.use, is_active: row.is_active,
@@ -6946,6 +6970,22 @@ function storageFetch(path: string, opts: MockFetchOpts): Record<string, unknown
6946
6970
  }
6947
6971
  if (method !== 'POST' || !hasGlobalPermission(caller, STORAGE_MANAGE_GRANTS)) return permissionDenied();
6948
6972
  const body = opts.body ?? {};
6973
+ for (const action of ['enable_cdn', 'check_cdn', 'disable_cdn'] as const) {
6974
+ if (!(action in body)) continue;
6975
+ if (!manager) return { status: false, error: 'FileManager not found', error_code: 404 };
6976
+ if (manager.backend_type !== 's3') return { status: true, data: { status: false, error: 'CDN delivery is only supported for S3 backends.' } };
6977
+ const bucket = manager.backend_url.replace(/^s3:\/\//, '').split('/')[0];
6978
+ const report = storageCdnReport(manager, action === 'enable_cdn' ? 'enable' : action === 'check_cdn' ? 'check' : 'disable');
6979
+ const siblings = db.fileManagers.filter((row) => row.backend_type === 's3' && row.backend_url.replace(/^s3:\/\//, '').split('/')[0] === bucket);
6980
+ if (action === 'enable_cdn' && report.converged) {
6981
+ for (const row of siblings) row.cdn = { enabled: true, url: 'https://emock1.cloudfront.net', bucket, distribution_id: 'EMOCK1', source: 'provisioned', status: 'pending', signed: !row.is_public, signing_key_id: 'K2MOCKKEY' };
6982
+ }
6983
+ if (action === 'disable_cdn') {
6984
+ for (const row of siblings) row.cdn = { enabled: false };
6985
+ return { status: true, data: { status: true, result: { status: true, disabled: true, distribution_id: 'EMOCK1', retired: Boolean((body[action] as Record<string, unknown> | undefined)?.retire) } } };
6986
+ }
6987
+ return { status: true, data: { status: true, result: report } };
6988
+ }
6949
6989
  for (const action of ['test_connection', 'check_cors', 'fix_cors', 'clone'] as const) {
6950
6990
  if (!(action in body)) continue;
6951
6991
  if (!manager) return { status: false, error: 'FileManager not found', error_code: 404 };
@@ -1,4 +1,4 @@
1
1
  .storage-bucket-tools,.storage-bucket-create,.storage-action-row,.storage-counts,.storage-section-head{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap}.storage-bucket-tools{padding:14px 16px;border-bottom:1px solid var(--line)}.storage-bucket-tools>.field{min-width:220px;flex:1}.storage-bucket-create{flex:2}.storage-bucket-create .field{min-width:260px;flex:1}.storage-footnote{padding:10px 16px;margin:0;color:var(--mute);font-size:12px}.storage-error{color:var(--bad)}.storage-danger{border:1px solid var(--bad);background:var(--bad-soft);border-radius:10px;padding:16px}.storage-operation-result,.storage-operation-evidence{margin-top:14px;padding:12px;border-radius:8px;background:var(--surface2);border:1px solid var(--line)}.storage-operation-result.is-error{border-color:var(--bad)}.storage-operation-evidence dl{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px}.storage-operation-evidence dl>div{padding:8px;background:var(--surface);border-radius:6px}.storage-operation-evidence dt{font-size:11px;color:var(--mute);text-transform:uppercase}.storage-operation-evidence dd{margin:2px 0 0;font-weight:700}.storage-counts span{padding:6px 9px;border-radius:999px;background:var(--surface2)}.storage-manager-editor form,.storage-manager-editor .form-grid{display:grid;gap:12px}.storage-manager-editor .form-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.storage-span{grid-column:1/-1}.storage-owner-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:12px}.storage-child-files,.storage-renditions,.storage-shares{display:grid;gap:8px}.storage-child-file,.storage-rendition,.storage-shares>div{display:flex;justify-content:space-between;align-items:center;gap:12px;width:100%;padding:10px 12px;text-align:left;color:var(--ink);background:var(--surface2);border:1px solid var(--line);border-radius:8px}.storage-child-file span,.storage-rendition>div:first-child,.storage-shares span{display:grid;gap:2px}.storage-child-file small,.storage-rendition small,.storage-shares small{color:var(--mute)}.storage-result-json{max-height:360px;overflow:auto;padding:12px;background:var(--surface2);border-radius:8px}.storage-preview{display:grid;gap:12px;justify-items:start}.storage-preview img{max-width:100%;max-height:480px;object-fit:contain;border-radius:8px;background:var(--surface2)}.storage-media{width:min(100%,760px);max-height:480px}.storage-audio{min-height:54px}.storage-generic-preview{display:grid;gap:10px;justify-items:center;padding:34px;border:1px dashed var(--line);border-radius:10px;background:var(--surface2)}.storage-generic-preview>i{font-size:40px;color:var(--mute)}.storage-unsafe{color:var(--bad);padding:10px;background:var(--bad-soft);border-radius:8px}.storage-capability{display:block;overflow-wrap:anywhere;padding:12px;background:var(--surface2);border:1px solid var(--line);border-radius:8px}.storage-section-head{justify-content:space-between;align-items:start}.storage-section-head h3{margin-top:0}.storage-rendition .storage-action-row{flex-wrap:nowrap}
2
2
  @media(max-width:720px){.storage-manager-editor .form-grid,.storage-owner-grid{grid-template-columns:1fr}.storage-bucket-create{flex-basis:100%}.storage-child-file,.storage-rendition,.storage-shares>div{align-items:flex-start;flex-direction:column}.storage-rendition .storage-action-row{align-self:flex-end}}
3
3
  .storage-file-upload-surface{position:relative}.storage-page-drop-overlay{position:absolute;inset:0;z-index:20;display:grid;place-content:center;justify-items:center;gap:10px;pointer-events:none;border:2px dashed var(--accent);border-radius:12px;background:color-mix(in srgb,var(--surface) 88%,transparent);color:var(--accent);font-size:16px}.storage-page-drop-overlay i{font-size:42px}.storage-upload-dialog{display:grid;gap:14px}.storage-upload-scope{justify-self:start}.storage-upload-policy{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0}.storage-upload-policy>div{padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--surface2)}.storage-upload-policy dt{font-size:11px;text-transform:uppercase;color:var(--mute)}.storage-upload-policy dd{margin:4px 0 0;overflow-wrap:anywhere}.storage-upload-selection{margin:0;padding:10px;border-radius:8px;background:var(--surface2)}.storage-upload-rejections{margin:0;color:var(--bad)}.storage-upload-queue{margin-top:14px}.storage-upload-refresh{margin:10px 0 0;padding:10px 12px;border:1px solid var(--line);border-radius:8px;color:var(--mute)}.storage-upload-refresh.is-error{border-color:var(--bad);color:var(--bad);background:var(--bad-soft)}
4
- @media(max-width:720px){.storage-upload-policy{grid-template-columns:1fr}}
4
+ @media(max-width:720px){.storage-upload-policy{grid-template-columns:1fr}}.storage-cdn-findings{display:grid;gap:10px;margin-top:12px}.storage-cdn-finding{padding:10px 12px;background:var(--surface2);border:1px solid var(--line);border-radius:8px}.storage-cdn-finding>div{display:flex;gap:8px;align-items:center}.storage-cdn-finding p{margin:6px 0 0}.storage-cdn-finding pre{margin-top:8px;white-space:pre-wrap;overflow-wrap:anywhere}
@@ -111,6 +111,8 @@ export interface FormViewProps<T extends { id: number | string }> {
111
111
  * proceeds. Put a `confirmGuardrail` here for grants/identity fields.
112
112
  */
113
113
  beforeSave?: AutosaveBeforeSave<T>;
114
+ /** Owner scope fence for delayed writes and feedback after account/group changes. */
115
+ isCurrent?: () => boolean;
114
116
  /** True while any FileField transfer is active. Owners can block dismissal. */
115
117
  onPendingChange?: (pending: boolean) => void;
116
118
  className?: string;
@@ -186,12 +188,18 @@ function OwnedFormView<T extends { id: number | string }>(props: FormViewProps<T
186
188
  save: (changes) => save.mutateAsync({ id: row.id, changes } satisfies SaveVars),
187
189
  debounceMs,
188
190
  savedFlashMs,
189
- beforeSave,
191
+ beforeSave: props.isCurrent ? async (info) => {
192
+ if (props.isCurrent?.() === false) return false;
193
+ const result = await beforeSave?.(info);
194
+ return props.isCurrent?.() === false ? false : result;
195
+ } : beforeSave,
190
196
  onSaved: (info) => {
197
+ if (props.isCurrent?.() === false) return;
191
198
  publishOwnerResult(info.fields, 'success', info.changes, info.row);
192
199
  onSaved?.(info);
193
200
  },
194
201
  onSaveError: (info) => {
202
+ if (props.isCurrent?.() === false) return;
195
203
  publishOwnerResult(info.fields, 'failed', info.changes);
196
204
  // Unmissable failure (workspec): pinned field error + toast.
197
205
  toast.error(info.error.message);
@@ -41,6 +41,8 @@ export interface Guardrail {
41
41
  typeToConfirm?: string;
42
42
  /** false = primary button + warn tint for a stop that is not destructive. Default true. */
43
43
  danger?: boolean;
44
+ /** End this confirmation when its initiating record or authority is disposed. */
45
+ signal?: AbortSignal;
44
46
  }
45
47
 
46
48
  function GuardrailBody({ g, close }: { g: Guardrail; close: (v: boolean) => void }) {
@@ -102,7 +104,7 @@ function GuardrailBody({ g, close }: { g: Guardrail; close: (v: boolean) => void
102
104
 
103
105
  /** Open the guardrail and resolve true only when the operator confirms. */
104
106
  export function confirmGuardrail(g: Guardrail): Promise<boolean> {
105
- return modal.open<boolean>((close) => <GuardrailBody g={g} close={close} />, { size: 'sm' })
107
+ return modal.open<boolean>((close) => <GuardrailBody g={g} close={close} />, { size: 'sm', signal: g.signal })
106
108
  .then((v) => v === true);
107
109
  }
108
110