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.
- package/docs/admin-media-rendering.md +6 -1
- package/docs/admin-members.md +28 -0
- package/docs/admin-modals.md +3 -1
- package/docs/admin-storage.md +18 -1
- package/docs/forms.md +16 -0
- package/package.json +1 -1
- package/src/admin/identity/groups/GroupDetail.tsx +4 -0
- package/src/admin/identity/members/GroupMembersPanel.tsx +4 -0
- package/src/admin/identity/members/MemberDetail.tsx +80 -32
- package/src/admin/identity/members/MembersPage.tsx +5 -0
- package/src/admin/identity/members/member-dialog.tsx +31 -0
- package/src/admin/identity/members/member-flows.tsx +61 -51
- package/src/admin/identity/members/member-scope.ts +61 -0
- package/src/admin/storage/FileManagerDetail.tsx +60 -2
- package/src/admin/storage/MediaRenderingPage.tsx +15 -8
- package/src/admin/storage/models.ts +49 -1
- package/src/admin/storage/rendition-settings.ts +27 -4
- package/src/client/mock.ts +41 -1
- package/src/styles/components/admin-storage.css +1 -1
- package/src/ui/FormView.tsx +9 -1
- package/src/ui/guardrail.tsx +3 -1
|
@@ -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>`
|
package/docs/admin-members.md
CHANGED
|
@@ -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`.
|
package/docs/admin-modals.md
CHANGED
|
@@ -31,7 +31,9 @@ fails; successful DetailView bodies supply the normal header close.
|
|
|
31
31
|
Already-aborted signals resolve null without adding an entry. Later abort removes
|
|
32
32
|
only that entry immediately and resolves null, even before `ModalHost` mounts;
|
|
33
33
|
normal close removes the listener. Owner disposal bypasses `canDismiss` because
|
|
34
|
-
it ends the data scope. An owner must also abort its controller on unmount.
|
|
34
|
+
it ends the data scope. An owner must also abort its controller on unmount. `confirmGuardrail({ signal })`
|
|
35
|
+
passes the same lifetime to its underlying modal, so an awaiting confirmation
|
|
36
|
+
closes with its record or authority.
|
|
35
37
|
|
|
36
38
|
## Executable inventory
|
|
37
39
|
|
package/docs/admin-storage.md
CHANGED
|
@@ -60,9 +60,26 @@ closure, refetches authoritatively, and compares requested owner FKs.
|
|
|
60
60
|
Creation supports runtime backends `file` and `s3`, explicitly starts private,
|
|
61
61
|
and requires an authorized group/user owner unless the caller is superuser and
|
|
62
62
|
chooses System. Supported payload actions are `test_connection`, `check_cors`,
|
|
63
|
-
`fix_cors`,
|
|
63
|
+
`fix_cors`, `clone`, and the CDN trio `enable_cdn`, `check_cdn`, `disable_cdn`
|
|
64
|
+
(django-mojo #8250); inner `{status:false}` rejects. CORS and CDN are S3-only.
|
|
64
65
|
FileManager DELETE and `check_public_access` are absent.
|
|
65
66
|
|
|
67
|
+
**CDN delivery.** The manager graph carries a read-only `cdn` object
|
|
68
|
+
(`projectCdn` keeps only its named keys; `{enabled: false}` otherwise), shown
|
|
69
|
+
as the Overview's CDN row: not enabled, or the CDN address with "public
|
|
70
|
+
links" or "signed links" and whether the distribution is still deploying.
|
|
71
|
+
The detail menu offers **Check CDN**, **Enable CDN…** and **Disable CDN…**
|
|
72
|
+
to every storage manager (`manage_files` / `files`; django-mojo enforces the
|
|
73
|
+
same global grant on the actions), the two mutating ones behind a confirm
|
|
74
|
+
that says what changes in AWS (enable provisions CloudFront for the whole
|
|
75
|
+
bucket under the deployment's identity and creates nothing twice; disable
|
|
76
|
+
reverts every manager on the bucket to S3 links and leaves AWS alone).
|
|
77
|
+
Results render as a findings list — status badge, code, message, remedy —
|
|
78
|
+
through `cdnFindings`, never raw JSON. The mock serves the field (the
|
|
79
|
+
"Public media" backend starts enabled) and the three actions, with a
|
|
80
|
+
MANUAL KMS finding on the private uploads backend so the remedy rendering
|
|
81
|
+
is exercised.
|
|
82
|
+
|
|
66
83
|
## Files and capability URLs
|
|
67
84
|
|
|
68
85
|
`FileModel` forces the list graph and normal saves to `filename`, `is_public`,
|
package/docs/forms.md
CHANGED
|
@@ -106,6 +106,7 @@ if (!data) return; // cancelled → null
|
|
|
106
106
|
onSaved={({ changes, fields, row }) => …}
|
|
107
107
|
onSaveError={({ changes, fields, error }) => …}
|
|
108
108
|
beforeSave={({ changes, names, row }) => …} // once per batch, BEFORE the POST (below)
|
|
109
|
+
isCurrent={() => ownerScope.current()} // optional owner lifetime fence
|
|
109
110
|
onPendingChange={(uploading) => setDetailDismissLocked(uploading)}
|
|
110
111
|
/>
|
|
111
112
|
```
|
|
@@ -176,6 +177,21 @@ contract is pinned by `npm run verify:form-autosave`.
|
|
|
176
177
|
Server normalization is visible: save `555 010 0142`, the field comes back
|
|
177
178
|
`+15550100142`.
|
|
178
179
|
|
|
180
|
+
### Owner lifetime
|
|
181
|
+
|
|
182
|
+
`isCurrent?: () => boolean` is an optional, synchronous lifetime predicate for
|
|
183
|
+
forms whose account, group or owning dialog can end while a save is pending.
|
|
184
|
+
Returning `false` prevents the batch before and after its asynchronous
|
|
185
|
+
`beforeSave` gate, and suppresses late success/error callbacks and autosave
|
|
186
|
+
feedback. Capture a one-way scope: leaving and returning must not revive the
|
|
187
|
+
old operation. Omitting it preserves the existing autosave behavior.
|
|
188
|
+
|
|
189
|
+
This guard covers the form's continuations, not a model's own mutation effects.
|
|
190
|
+
An owner-scoped model must also check its scope at the transport's final
|
|
191
|
+
`beforeSend` boundary and before writing response data to a shared cache.
|
|
192
|
+
MemberDetail provides that guarded mutation boundary. Already-sent server writes
|
|
193
|
+
are not rolled back when the view closes.
|
|
194
|
+
|
|
179
195
|
### `file` / `image` relations
|
|
180
196
|
|
|
181
197
|
Both registry bindings use the exported controlled `FileField`/`ImageField`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portal-mojo",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.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,
|
|
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
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
224
|
+
if (started?.current() && scope.current === started)
|
|
225
|
+
toast.success(next ? 'Membership activated' : 'Membership deactivated');
|
|
188
226
|
} catch (error) {
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
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={
|
|
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={
|
|
283
|
-
beforeSave={({ changes }) => confirmGrantEscalation(member, changes)}
|
|
284
|
-
|
|
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,
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
|
|
98
|
-
const
|
|
99
|
-
const
|
|
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.
|
|
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
|
|
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">{
|
|
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:
|
|
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
|
-
/**
|
|
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(() =>
|
|
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
|
-
/**
|
|
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, {});
|
package/src/client/mock.ts
CHANGED
|
@@ -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}
|
package/src/ui/FormView.tsx
CHANGED
|
@@ -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);
|
package/src/ui/guardrail.tsx
CHANGED
|
@@ -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
|
|