@ultimat3/admin 22.14.0 → 23.0.0
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/CLAUDE.md +73 -80
- package/README.md +276 -87
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +127 -28
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +92 -0
- package/src/batch.ts +431 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +166 -0
- package/src/crud.ts +129 -177
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +24 -6
- package/src/dev/facts.ts +27 -0
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel.ts +6 -0
- package/src/dev/server.ts +15 -7
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +83 -0
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +91 -0
- package/src/form.tsx +78 -45
- package/src/index.ts +198 -9
- package/src/jobs/fleet-repo.ts +143 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +103 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +217 -0
- package/src/list-request.ts +127 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +108 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/policy-bridge.ts +110 -15
- package/src/registry.ts +138 -8
- package/src/related.ts +105 -0
- package/src/relations.ts +224 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +204 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +185 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +393 -0
- package/src/screen-system.tsx +69 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +13 -9
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -12
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// The two writes an action makes from a screen: ONE row, posted at the row's URL (its form, when
|
|
2
|
+
// it takes input, is that URL asked for the action), and a BATCH, posted at the list's URL whose
|
|
3
|
+
// query string is the filter "all matching" means. Both decide through the gate first and render
|
|
4
|
+
// what it answered — a 422 form with the schema's issues, a 409 for a row the action's `when`
|
|
5
|
+
// excludes, a 403 for a refusal, and an honest count of what a batch did.
|
|
6
|
+
|
|
7
|
+
import { t } from '@ultimat3/i18n';
|
|
8
|
+
import { Button, Card, ErrorState, Link } from '@ultimat3/ui';
|
|
9
|
+
import { AdminActionForm } from './action-form';
|
|
10
|
+
import { actionButtons } from './action-gate';
|
|
11
|
+
import {
|
|
12
|
+
ACTION_INPUT_PREFIX,
|
|
13
|
+
actionInputFields,
|
|
14
|
+
decodeActionInput,
|
|
15
|
+
postedActionInput,
|
|
16
|
+
} from './action-input';
|
|
17
|
+
import { actionLabelKey } from './action-label';
|
|
18
|
+
import { invokeRowAction } from './action-row';
|
|
19
|
+
import { BATCH_OPERATION, OPERATION_FIELD } from './actions';
|
|
20
|
+
import type { AdminApp } from './admin';
|
|
21
|
+
import styles from './admin.module.scss';
|
|
22
|
+
import { type AdminBatchResult, type BatchSelection, batchPlan, runAdminBatch } from './batch';
|
|
23
|
+
import { BATCH_IDS_FIELD, BATCH_SELECTION_FIELD } from './batch-bar';
|
|
24
|
+
import { batchEnqueue } from './batch-job';
|
|
25
|
+
import { posted } from './form-decode';
|
|
26
|
+
import { listHref, pageRequestOf } from './list-request';
|
|
27
|
+
import { findRow } from './list-scope';
|
|
28
|
+
import { confirmationToken } from './permissions';
|
|
29
|
+
import type { AdminAction } from './registry';
|
|
30
|
+
import type { AdminResource } from './resource';
|
|
31
|
+
import { type AdminRouteRequest, type AdminRouteResponse, framed, refused } from './screen-frame';
|
|
32
|
+
import type { ValidationIssue } from './validate';
|
|
33
|
+
|
|
34
|
+
/** Posted by a form that already showed its fields: run now, do not ask again. */
|
|
35
|
+
export const STEP_FIELD = '_step';
|
|
36
|
+
const RUN_STEP = 'run';
|
|
37
|
+
/** Where an "all matching" batch continues from: the last row the previous request reached. */
|
|
38
|
+
export const AFTER_FIELD = 'after';
|
|
39
|
+
|
|
40
|
+
const rowHref = (app: AdminApp, resource: AdminResource, id: string): string =>
|
|
41
|
+
`${app.basePath}${resource.path}/${id}`;
|
|
42
|
+
|
|
43
|
+
/** A posted name repeated once per value — the checked rows. */
|
|
44
|
+
const postedList = (form: Readonly<Record<string, unknown>>, name: string): readonly string[] => {
|
|
45
|
+
if (!Object.hasOwn(form, name)) return [];
|
|
46
|
+
const value = form[name];
|
|
47
|
+
const list: readonly unknown[] = Array.isArray(value) ? value : [value];
|
|
48
|
+
return list.filter((one): one is string => typeof one === 'string' && one !== '');
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The 409 a row the action does not apply to is answered with: the error, and the way back. */
|
|
52
|
+
function notApplicable(
|
|
53
|
+
app: AdminApp,
|
|
54
|
+
request: AdminRouteRequest,
|
|
55
|
+
resource: AdminResource,
|
|
56
|
+
error: Error,
|
|
57
|
+
back: string,
|
|
58
|
+
): AdminRouteResponse {
|
|
59
|
+
return framed(
|
|
60
|
+
app,
|
|
61
|
+
request,
|
|
62
|
+
resource.titleKey,
|
|
63
|
+
<>
|
|
64
|
+
<ErrorState error={error} />
|
|
65
|
+
<Link href={back}>{t('admin.actions.back')}</Link>
|
|
66
|
+
</>,
|
|
67
|
+
409,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** One row's action form: `GET <row>?action=<name>`, and its refused post. */
|
|
72
|
+
export async function rowActionForm(
|
|
73
|
+
app: AdminApp,
|
|
74
|
+
request: AdminRouteRequest,
|
|
75
|
+
resource: AdminResource,
|
|
76
|
+
action: AdminAction,
|
|
77
|
+
values: Readonly<Record<string, string>>,
|
|
78
|
+
issues: readonly ValidationIssue[],
|
|
79
|
+
status = 200,
|
|
80
|
+
): Promise<AdminRouteResponse> {
|
|
81
|
+
const id = request.params['id'] ?? '';
|
|
82
|
+
const href = rowHref(app, resource, id);
|
|
83
|
+
return framed(
|
|
84
|
+
app,
|
|
85
|
+
request,
|
|
86
|
+
resource.titleKey,
|
|
87
|
+
<AdminActionForm
|
|
88
|
+
action={action}
|
|
89
|
+
href={href}
|
|
90
|
+
operation="action"
|
|
91
|
+
values={values}
|
|
92
|
+
issues={issues}
|
|
93
|
+
confirmation={action.destructive === true ? confirmationToken(resource.name, id) : null}
|
|
94
|
+
subject={t('admin.actions.subject.row', { id })}
|
|
95
|
+
cancelHref={href}
|
|
96
|
+
/>,
|
|
97
|
+
status,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The form of `?action=<name>` on a row — only for an action the actor may run on THIS row, so a
|
|
103
|
+
* link to a form is never a way round a hidden button. Anything else is the row's 404.
|
|
104
|
+
*/
|
|
105
|
+
export async function rowActionFormScreen(
|
|
106
|
+
app: AdminApp,
|
|
107
|
+
request: AdminRouteRequest,
|
|
108
|
+
resource: AdminResource,
|
|
109
|
+
name: string,
|
|
110
|
+
missing: () => AdminRouteResponse,
|
|
111
|
+
): Promise<AdminRouteResponse> {
|
|
112
|
+
const action = resource.actions.find((declared) => declared.name === name);
|
|
113
|
+
if (action === undefined) return missing();
|
|
114
|
+
const id = request.params['id'] ?? '';
|
|
115
|
+
const row = await findRow(resource, request.ctx.actor, id);
|
|
116
|
+
if (row === null) return missing();
|
|
117
|
+
// The button's own decision: a form for an action this row would not show is no form at all.
|
|
118
|
+
const offered = actionButtons({
|
|
119
|
+
actions: [action],
|
|
120
|
+
actor: request.ctx.actor,
|
|
121
|
+
authz: request.ctx.authz,
|
|
122
|
+
subject: { entity: resource.name, id, row },
|
|
123
|
+
sealed: resource.secretFields.map((field) => field.name),
|
|
124
|
+
});
|
|
125
|
+
if (offered.length === 0) return missing();
|
|
126
|
+
return rowActionForm(app, request, resource, action, {}, []);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A posted action on one row: decided by the same gate that decided whether its button rendered. */
|
|
130
|
+
export async function rowActionWrite(
|
|
131
|
+
app: AdminApp,
|
|
132
|
+
request: AdminRouteRequest,
|
|
133
|
+
resource: AdminResource,
|
|
134
|
+
form: Readonly<Record<string, unknown>>,
|
|
135
|
+
missing: () => AdminRouteResponse,
|
|
136
|
+
): Promise<AdminRouteResponse> {
|
|
137
|
+
const id = request.params['id'] ?? '';
|
|
138
|
+
// The posted name, resolved against the actions `defineAdmin()` attached to THIS resource —
|
|
139
|
+
// "which operation is this?" is answered before "may this actor run it?".
|
|
140
|
+
const action = resource.actions.find((declared) => declared.name === posted(form, 'name'));
|
|
141
|
+
if (action === undefined) return missing();
|
|
142
|
+
const result = await invokeRowAction({
|
|
143
|
+
resource,
|
|
144
|
+
action,
|
|
145
|
+
id,
|
|
146
|
+
ctx: request.ctx,
|
|
147
|
+
input: decodeActionInput(action, form),
|
|
148
|
+
confirmation: posted(form, 'confirmation'),
|
|
149
|
+
});
|
|
150
|
+
const href = rowHref(app, resource, id);
|
|
151
|
+
if (result.ok) return { kind: 'redirect', location: href };
|
|
152
|
+
if (result.kind === 'not-applicable') {
|
|
153
|
+
return notApplicable(app, request, resource, result.error, href);
|
|
154
|
+
}
|
|
155
|
+
if (result.kind === 'invalid') {
|
|
156
|
+
return rowActionForm(
|
|
157
|
+
app,
|
|
158
|
+
request,
|
|
159
|
+
resource,
|
|
160
|
+
action,
|
|
161
|
+
postedActionInput(action, form),
|
|
162
|
+
result.issues,
|
|
163
|
+
422,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
// A destructive action with a form, posted with the wrong token: the form again, not a 403 —
|
|
167
|
+
// the operator holds the grant and mistyped.
|
|
168
|
+
if (result.confirmationRequired && actionInputFields(action).length > 0) {
|
|
169
|
+
return rowActionForm(app, request, resource, action, postedActionInput(action, form), [], 422);
|
|
170
|
+
}
|
|
171
|
+
return refused(app, request, resource.titleKey, result.decision);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** What a batch result page says, outcome by outcome. */
|
|
175
|
+
function batchResult(
|
|
176
|
+
app: AdminApp,
|
|
177
|
+
request: AdminRouteRequest,
|
|
178
|
+
resource: AdminResource,
|
|
179
|
+
action: AdminAction,
|
|
180
|
+
result: AdminBatchResult,
|
|
181
|
+
again: readonly (readonly [string, string])[],
|
|
182
|
+
): AdminRouteResponse {
|
|
183
|
+
const list = listHref(app.basePath, resource);
|
|
184
|
+
const terms: readonly (readonly [string, number])[] = [
|
|
185
|
+
['admin.batch.done', result.done],
|
|
186
|
+
['admin.batch.refused', result.refused],
|
|
187
|
+
['admin.batch.failed', result.failed],
|
|
188
|
+
['admin.batch.queued', result.queued],
|
|
189
|
+
['admin.batch.remaining', result.remaining],
|
|
190
|
+
];
|
|
191
|
+
return framed(
|
|
192
|
+
app,
|
|
193
|
+
request,
|
|
194
|
+
resource.titleKey,
|
|
195
|
+
<Card header={<h2>{t(actionLabelKey(action))}</h2>}>
|
|
196
|
+
<dl class={styles['counts']}>
|
|
197
|
+
{terms.map(([key, count]) => (
|
|
198
|
+
<>
|
|
199
|
+
<dt>{t(key)}</dt>
|
|
200
|
+
<dd>{String(count)}</dd>
|
|
201
|
+
</>
|
|
202
|
+
))}
|
|
203
|
+
</dl>
|
|
204
|
+
{result.rows.length === 0 ? null : (
|
|
205
|
+
<ul class={styles['list']}>
|
|
206
|
+
{result.rows.map((row) => (
|
|
207
|
+
<li>
|
|
208
|
+
<a href={rowHref(app, resource, row.id)}>{row.id}</a>{' '}
|
|
209
|
+
<span data-outcome={row.outcome}>{t(row.reason)}</span>
|
|
210
|
+
</li>
|
|
211
|
+
))}
|
|
212
|
+
</ul>
|
|
213
|
+
)}
|
|
214
|
+
{result.remaining === 0 || (result.after === null && action.matching === undefined) ? null : (
|
|
215
|
+
// The rest of an "all matching" batch: the same post, from where this one stopped — or,
|
|
216
|
+
// for a set-based one, the same post again: the set it reads has already shrunk.
|
|
217
|
+
<form
|
|
218
|
+
class={styles['actionForm']}
|
|
219
|
+
method="post"
|
|
220
|
+
action={new URL(request.url).pathname + new URL(request.url).search}
|
|
221
|
+
>
|
|
222
|
+
<input type="hidden" name={OPERATION_FIELD} value={BATCH_OPERATION} />
|
|
223
|
+
<input type="hidden" name="name" value={action.name} />
|
|
224
|
+
<input type="hidden" name={BATCH_SELECTION_FIELD} value="all" />
|
|
225
|
+
{result.after === null ? null : (
|
|
226
|
+
<input type="hidden" name={AFTER_FIELD} value={result.after} />
|
|
227
|
+
)}
|
|
228
|
+
{again.map(([name, value]) => (
|
|
229
|
+
<input type="hidden" name={name} value={value} />
|
|
230
|
+
))}
|
|
231
|
+
<Button type="submit" size="sm" variant="secondary">
|
|
232
|
+
{t('admin.batch.continue', { count: result.remaining })}
|
|
233
|
+
</Button>
|
|
234
|
+
</form>
|
|
235
|
+
)}
|
|
236
|
+
<Link href={list}>{t('admin.batch.back')}</Link>
|
|
237
|
+
</Card>,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A batch posted from the list's bar. The first post of an action that takes input, or that is
|
|
243
|
+
* destructive, answers with its form — the confirmation is a server round trip — and the second,
|
|
244
|
+
* `_step=run`, runs it.
|
|
245
|
+
*/
|
|
246
|
+
export async function batchWrite(
|
|
247
|
+
app: AdminApp,
|
|
248
|
+
request: AdminRouteRequest,
|
|
249
|
+
resource: AdminResource,
|
|
250
|
+
form: Readonly<Record<string, unknown>>,
|
|
251
|
+
missing: () => AdminRouteResponse,
|
|
252
|
+
): Promise<AdminRouteResponse> {
|
|
253
|
+
const action = resource.actions.find(
|
|
254
|
+
(declared) => declared.name === posted(form, 'name') && declared.batch !== undefined,
|
|
255
|
+
);
|
|
256
|
+
if (action === undefined) return missing();
|
|
257
|
+
const url = new URL(request.url);
|
|
258
|
+
const all = posted(form, BATCH_SELECTION_FIELD) === 'all';
|
|
259
|
+
const after = posted(form, AFTER_FIELD);
|
|
260
|
+
const ids = postedList(form, BATCH_IDS_FIELD);
|
|
261
|
+
const selection: BatchSelection = all
|
|
262
|
+
? {
|
|
263
|
+
kind: 'all',
|
|
264
|
+
// The list's own URL grammar: a parameter it does not derive is refused there, by name.
|
|
265
|
+
request: pageRequestOf(resource, url),
|
|
266
|
+
...(after === undefined ? {} : { after }),
|
|
267
|
+
}
|
|
268
|
+
: { kind: 'ids', ids };
|
|
269
|
+
// What the form posts back: the selection, as it was.
|
|
270
|
+
const hidden: readonly (readonly [string, string])[] = [
|
|
271
|
+
[BATCH_SELECTION_FIELD, all ? 'all' : 'checked'],
|
|
272
|
+
...(all ? [] : ids.map((id) => [BATCH_IDS_FIELD, id] as const)),
|
|
273
|
+
...(after === undefined ? [] : [[AFTER_FIELD, after] as const]),
|
|
274
|
+
[STEP_FIELD, RUN_STEP],
|
|
275
|
+
];
|
|
276
|
+
const href = url.pathname + url.search;
|
|
277
|
+
const values = postedActionInput(action, form);
|
|
278
|
+
const formOf = (
|
|
279
|
+
token: string | null,
|
|
280
|
+
count: number | null,
|
|
281
|
+
issues: readonly ValidationIssue[],
|
|
282
|
+
status: number,
|
|
283
|
+
): AdminRouteResponse =>
|
|
284
|
+
framed(
|
|
285
|
+
app,
|
|
286
|
+
request,
|
|
287
|
+
resource.titleKey,
|
|
288
|
+
<AdminActionForm
|
|
289
|
+
action={action}
|
|
290
|
+
href={href}
|
|
291
|
+
operation={BATCH_OPERATION}
|
|
292
|
+
values={values}
|
|
293
|
+
issues={issues}
|
|
294
|
+
confirmation={token}
|
|
295
|
+
subject={
|
|
296
|
+
count === null
|
|
297
|
+
? t(all ? 'admin.batch.subject.all' : 'admin.batch.subject.checked', {
|
|
298
|
+
count: ids.length,
|
|
299
|
+
})
|
|
300
|
+
: t('admin.batch.subject.count', { count })
|
|
301
|
+
}
|
|
302
|
+
hidden={hidden}
|
|
303
|
+
cancelHref={href}
|
|
304
|
+
/>,
|
|
305
|
+
status,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const run = posted(form, STEP_FIELD) === RUN_STEP;
|
|
309
|
+
const asks = actionInputFields(action).length > 0;
|
|
310
|
+
if (!run && asks && action.destructive !== true) return formOf(null, null, [], 200);
|
|
311
|
+
|
|
312
|
+
const result = await runAdminBatch({
|
|
313
|
+
resource,
|
|
314
|
+
action,
|
|
315
|
+
ctx: request.ctx,
|
|
316
|
+
selection,
|
|
317
|
+
input: decodeActionInput(action, form),
|
|
318
|
+
confirmation: posted(form, 'confirmation'),
|
|
319
|
+
...(batchPlan(action) === null ? {} : { enqueue: batchEnqueue(app.basePath) }),
|
|
320
|
+
});
|
|
321
|
+
if (result.ok) {
|
|
322
|
+
return batchResult(app, request, resource, action, result, [
|
|
323
|
+
...Object.entries(values).map(
|
|
324
|
+
([name, value]) => [`${ACTION_INPUT_PREFIX}${name}`, value] as const,
|
|
325
|
+
),
|
|
326
|
+
]);
|
|
327
|
+
}
|
|
328
|
+
if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
|
|
329
|
+
if (result.kind === 'confirm') {
|
|
330
|
+
return formOf(result.token, result.count, [], run ? 422 : 200);
|
|
331
|
+
}
|
|
332
|
+
return formOf(null, null, result.issues, 422);
|
|
333
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// What every mounted screen shares: the shape of its request and its answer, the shell it is
|
|
2
|
+
// framed in, and the refusal it renders. A screen DECIDES first — through `crud.ts`,
|
|
3
|
+
// `action-gate.ts` or `page-guard.tsx` — and only then builds a body, so the frame is the one
|
|
4
|
+
// place a status meets a document.
|
|
5
|
+
|
|
6
|
+
import { localeConfig, t } from '@ultimat3/i18n';
|
|
7
|
+
import { ErrorState } from '@ultimat3/ui';
|
|
8
|
+
import type { JSX } from 'solid-js';
|
|
9
|
+
import type { AdminApp, AdminRoute } from './admin';
|
|
10
|
+
import { type AdminDecision, decideAll } from './authz';
|
|
11
|
+
import { type CrudCtx, canOperate } from './crud';
|
|
12
|
+
import { AdminFilterInvalidError } from './errors';
|
|
13
|
+
import { AdminLayout } from './layout';
|
|
14
|
+
import { AdminPageDenied, auditRefusal } from './page-guard';
|
|
15
|
+
import type { AdminPageProps } from './pages';
|
|
16
|
+
import type { RelationData } from './relations';
|
|
17
|
+
import type { AdminResource } from './resource';
|
|
18
|
+
import { LOOKUP_SEGMENT } from './resource-list';
|
|
19
|
+
import type { WidgetContext } from './widget-value';
|
|
20
|
+
|
|
21
|
+
/** One request to one admin route: a GET, or the form a screen posted back at itself. */
|
|
22
|
+
export interface AdminRouteRequest extends AdminPageProps {
|
|
23
|
+
readonly method: 'GET' | 'POST';
|
|
24
|
+
/** The parsed form. `null` on a GET; a POST with no fields is `{}`. */
|
|
25
|
+
readonly form: Readonly<Record<string, unknown>> | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AdminRouteResponse =
|
|
29
|
+
| {
|
|
30
|
+
readonly kind: 'document';
|
|
31
|
+
readonly status: number;
|
|
32
|
+
/** The framed screen: shell, nav for this actor, and the body. */
|
|
33
|
+
readonly body: JSX.Element;
|
|
34
|
+
}
|
|
35
|
+
/** A write that worked: the browser is sent to the row or the list it changed. Always a 303. */
|
|
36
|
+
| { readonly kind: 'redirect'; readonly location: string };
|
|
37
|
+
|
|
38
|
+
/** A mounted screen. `routes.ts` is the only thing that hands one to a host. */
|
|
39
|
+
export type AdminScreen = (request: AdminRouteRequest) => Promise<AdminRouteResponse>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The zone and locale every formatter on a screen reads — the ACTOR's, never the server's — and
|
|
43
|
+
* what the page read of the resources it references. `relations` is the whole of it: a widget
|
|
44
|
+
* shows a label or an option list the page already holds, and never reads a row of its own.
|
|
45
|
+
*/
|
|
46
|
+
export function widgetContextOf(
|
|
47
|
+
app: AdminApp,
|
|
48
|
+
ctx: CrudCtx,
|
|
49
|
+
relations: ReadonlyMap<string, RelationData> = new Map(),
|
|
50
|
+
): WidgetContext {
|
|
51
|
+
const target = (entity: string): AdminResource | undefined =>
|
|
52
|
+
app.resources.find((resource) => resource.name === entity);
|
|
53
|
+
return {
|
|
54
|
+
timeZone: ctx.actor.timeZone ?? 'UTC',
|
|
55
|
+
// The app's declared default, read back off the framework — never a second literal.
|
|
56
|
+
locale: ctx.actor.locale ?? localeConfig().fallback,
|
|
57
|
+
// The route table is the app's; a widget three layers down gets a link or no link.
|
|
58
|
+
hrefFor: (entity, id) => {
|
|
59
|
+
const found = target(entity);
|
|
60
|
+
return found === undefined ? null : `${app.basePath}${found.path}/${id}`;
|
|
61
|
+
},
|
|
62
|
+
labelFor: (entity, id) => relations.get(entity)?.labels.get(id),
|
|
63
|
+
optionsFor: (entity) => relations.get(entity)?.options ?? null,
|
|
64
|
+
lookupHref: (entity) => {
|
|
65
|
+
const found = target(entity);
|
|
66
|
+
// Only a resource whose list this actor may read has a lookup to send them to.
|
|
67
|
+
return found === undefined || !canOperate(found, 'list', ctx)
|
|
68
|
+
? null
|
|
69
|
+
: lookupHref(app.basePath, found);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where a resource's lookup screen lives. One spelling, read by the route table and every link. */
|
|
75
|
+
export const lookupHref = (basePath: string, resource: Pick<AdminResource, 'path'>): string =>
|
|
76
|
+
`${basePath}${resource.path}/${LOOKUP_SEGMENT}`;
|
|
77
|
+
|
|
78
|
+
export function framed(
|
|
79
|
+
app: AdminApp,
|
|
80
|
+
request: AdminRouteRequest,
|
|
81
|
+
/** The screen's `<h1>`. `null` when the body carries its own — a refusal does. */
|
|
82
|
+
titleKey: string | null,
|
|
83
|
+
body: JSX.Element,
|
|
84
|
+
status = 200,
|
|
85
|
+
): AdminRouteResponse {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'document',
|
|
88
|
+
status,
|
|
89
|
+
body: (
|
|
90
|
+
<AdminLayout
|
|
91
|
+
app={app}
|
|
92
|
+
nav={app.navFor(request.ctx)}
|
|
93
|
+
currentPath={new URL(request.url).pathname}
|
|
94
|
+
actor={request.ctx.actor}
|
|
95
|
+
{...(titleKey === null ? {} : { title: t(titleKey) })}
|
|
96
|
+
>
|
|
97
|
+
{body}
|
|
98
|
+
</AdminLayout>
|
|
99
|
+
),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A refusal the gate already audited: the page names the permission, and the status is 403. */
|
|
104
|
+
export function refused(
|
|
105
|
+
app: AdminApp,
|
|
106
|
+
request: AdminRouteRequest,
|
|
107
|
+
titleKey: string,
|
|
108
|
+
decision: AdminDecision,
|
|
109
|
+
): AdminRouteResponse {
|
|
110
|
+
return framed(
|
|
111
|
+
app,
|
|
112
|
+
request,
|
|
113
|
+
null,
|
|
114
|
+
<AdminPageDenied titleKey={titleKey} decision={decision} />,
|
|
115
|
+
403,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A refusal decided HERE rather than inside a crud call — a form's GET, a page — so the audit
|
|
121
|
+
* entry is written here too. The page is the subject: its path is what the row names.
|
|
122
|
+
*/
|
|
123
|
+
export async function refusedPage(
|
|
124
|
+
app: AdminApp,
|
|
125
|
+
request: AdminRouteRequest,
|
|
126
|
+
route: AdminRoute,
|
|
127
|
+
decision: AdminDecision,
|
|
128
|
+
): Promise<AdminRouteResponse> {
|
|
129
|
+
await auditRefusal(request.ctx, route.path, decision);
|
|
130
|
+
return refused(app, request, route.titleKey, decision);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A URL the admin serves and a thing it does not have: a row that is gone, an unknown action. */
|
|
134
|
+
export function missing(
|
|
135
|
+
app: AdminApp,
|
|
136
|
+
request: AdminRouteRequest,
|
|
137
|
+
titleKey: string,
|
|
138
|
+
body: JSX.Element,
|
|
139
|
+
): AdminRouteResponse {
|
|
140
|
+
return framed(app, request, titleKey, body, 404);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The wrapper that makes a screen's authz unskippable: a custom page, the dashboard, jobs, audit
|
|
145
|
+
* and search are all built through it, so "the screen that forgot its policy line" has nowhere to
|
|
146
|
+
* exist. It asks the SAME `decideAll` every CRUD call and every nav item asks, audits the
|
|
147
|
+
* refusal, answers it 403, and only then calls the body. A resource screen is the one thing not
|
|
148
|
+
* built here — `crud.ts` decides those, because only it holds the row a row-level rule reads.
|
|
149
|
+
*/
|
|
150
|
+
export function guardedScreen(
|
|
151
|
+
app: AdminApp,
|
|
152
|
+
route: AdminRoute,
|
|
153
|
+
body: (request: AdminRouteRequest) => JSX.Element | Promise<JSX.Element>,
|
|
154
|
+
): AdminScreen {
|
|
155
|
+
return async (request) => {
|
|
156
|
+
const decision = decideAll(request.ctx.authz, route.permissions, request.ctx.actor);
|
|
157
|
+
if (!decision.allowed) return refusedPage(app, request, route, decision);
|
|
158
|
+
try {
|
|
159
|
+
return framed(app, request, route.titleKey, await body(request));
|
|
160
|
+
} catch (error) {
|
|
161
|
+
// A body that refuses its URL — a parameter it does not answer — is the list's 400: the
|
|
162
|
+
// error, naming what the screen does answer. Anything else is not this frame's to render.
|
|
163
|
+
if (!(error instanceof AdminFilterInvalidError)) throw error;
|
|
164
|
+
return framed(app, request, route.titleKey, <ErrorState error={error} />, 400);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// `/admin` — the dashboard: every resource this actor may open, the decision behind each of its
|
|
2
|
+
// operations, and the app-wide actions. No row is read here: a dashboard that listed five rows of
|
|
3
|
+
// every resource would be one query per entity on every visit to the front page.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/i18n';
|
|
6
|
+
import { Card } from '@ultimat3/ui';
|
|
7
|
+
import type { JSX } from 'solid-js';
|
|
8
|
+
import { AdminActionForm } from './action-form';
|
|
9
|
+
import { actionButtons, invokeAdminAction } from './action-gate';
|
|
10
|
+
import { decodeActionInput, postedActionInput } from './action-input';
|
|
11
|
+
import { ACTION_OPERATION, ACTION_PARAM, AdminActions, OPERATION_FIELD } from './actions';
|
|
12
|
+
import type { AdminApp, AdminRoute } from './admin';
|
|
13
|
+
import styles from './admin.module.scss';
|
|
14
|
+
import { decideAll } from './authz';
|
|
15
|
+
import type { CrudCtx } from './crud';
|
|
16
|
+
import { canOperate, decideOperation, permissionsForOperation } from './crud';
|
|
17
|
+
import { posted } from './form-decode';
|
|
18
|
+
import { ADMIN_OPERATIONS, type AdminOperation, confirmationToken } from './permissions';
|
|
19
|
+
import type { AdminAction } from './registry';
|
|
20
|
+
import type { AdminResource } from './resource';
|
|
21
|
+
import {
|
|
22
|
+
type AdminRouteRequest,
|
|
23
|
+
type AdminRouteResponse,
|
|
24
|
+
type AdminScreen,
|
|
25
|
+
framed,
|
|
26
|
+
refused,
|
|
27
|
+
refusedPage,
|
|
28
|
+
} from './screen-frame';
|
|
29
|
+
import type { ValidationIssue } from './validate';
|
|
30
|
+
|
|
31
|
+
/** One row of an operation matrix: the decision the dashboard renders AND the call obeys. */
|
|
32
|
+
export interface OperationDecision {
|
|
33
|
+
readonly operation: AdminOperation;
|
|
34
|
+
readonly allowed: boolean;
|
|
35
|
+
readonly permissions: readonly string[];
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every operation the resource OFFERS, with its decision. This IS the view-only proof, rendered
|
|
41
|
+
* rather than asserted. One it does not offer has no row: "denied" would read as a missing grant
|
|
42
|
+
* the operator should ask for, when no grant opens it.
|
|
43
|
+
*/
|
|
44
|
+
export const operationMatrix = (
|
|
45
|
+
resource: AdminResource,
|
|
46
|
+
ctx: CrudCtx,
|
|
47
|
+
): readonly OperationDecision[] =>
|
|
48
|
+
ADMIN_OPERATIONS.filter((operation) => resource.operations.includes(operation)).map(
|
|
49
|
+
(operation) => {
|
|
50
|
+
const decision = decideOperation(resource, operation, ctx);
|
|
51
|
+
return {
|
|
52
|
+
operation,
|
|
53
|
+
allowed: decision.allowed,
|
|
54
|
+
permissions: permissionsForOperation(resource.permission, operation),
|
|
55
|
+
reason: decision.reason,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The permission matrix, rendered. A reader can see that `create`, `update` and `delete` are
|
|
62
|
+
* refused and WHICH permission refused them — the same decision the call obeys, not a note about
|
|
63
|
+
* a button that was left out.
|
|
64
|
+
*/
|
|
65
|
+
export function OperationMatrix(props: {
|
|
66
|
+
readonly rows: readonly OperationDecision[];
|
|
67
|
+
}): JSX.Element {
|
|
68
|
+
return (
|
|
69
|
+
<table class={styles['matrix']}>
|
|
70
|
+
<thead>
|
|
71
|
+
<tr>
|
|
72
|
+
<th>{t('admin.matrix.operation')}</th>
|
|
73
|
+
<th>{t('admin.matrix.permissions')}</th>
|
|
74
|
+
<th>{t('admin.matrix.verdict')}</th>
|
|
75
|
+
</tr>
|
|
76
|
+
</thead>
|
|
77
|
+
<tbody>
|
|
78
|
+
{props.rows.map((row) => (
|
|
79
|
+
<tr class={row.allowed ? styles['allowed'] : styles['denied']}>
|
|
80
|
+
<td>{t(`admin.operation.${row.operation}`)}</td>
|
|
81
|
+
<td class={styles['mono']}>{row.permissions.join(' + ')}</td>
|
|
82
|
+
<td>
|
|
83
|
+
{row.allowed
|
|
84
|
+
? t('admin.matrix.allowed')
|
|
85
|
+
: `${t('admin.matrix.deniedBy')} ${row.reason}`}
|
|
86
|
+
</td>
|
|
87
|
+
</tr>
|
|
88
|
+
))}
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An app-wide action's POST: the same gate, with no row for a subject. */
|
|
95
|
+
async function globalWrite(
|
|
96
|
+
app: AdminApp,
|
|
97
|
+
route: AdminRoute,
|
|
98
|
+
request: AdminRouteRequest,
|
|
99
|
+
form: Readonly<Record<string, unknown>>,
|
|
100
|
+
): Promise<AdminRouteResponse> {
|
|
101
|
+
const name = posted(form, 'name') ?? '';
|
|
102
|
+
const action =
|
|
103
|
+
posted(form, OPERATION_FIELD) === ACTION_OPERATION
|
|
104
|
+
? app.globalActions.find((declared) => declared.name === name)
|
|
105
|
+
: undefined;
|
|
106
|
+
if (action === undefined) {
|
|
107
|
+
return framed(app, request, route.titleKey, <p>{t('admin.actions.unknown')}</p>, 404);
|
|
108
|
+
}
|
|
109
|
+
const confirmation = posted(form, 'confirmation');
|
|
110
|
+
const result = await invokeAdminAction({
|
|
111
|
+
action,
|
|
112
|
+
input: decodeActionInput(action, form),
|
|
113
|
+
actor: request.ctx.actor,
|
|
114
|
+
authz: request.ctx.authz,
|
|
115
|
+
audit: request.ctx.audit,
|
|
116
|
+
requestId: request.ctx.requestId,
|
|
117
|
+
...(confirmation === undefined ? {} : { confirmation }),
|
|
118
|
+
});
|
|
119
|
+
if (result.ok) return { kind: 'redirect', location: app.basePath };
|
|
120
|
+
if (result.kind === 'invalid') {
|
|
121
|
+
return globalForm(app, route, request, action, postedActionInput(action, form), result.issues);
|
|
122
|
+
}
|
|
123
|
+
// A global action has no row, so its `when` is never asked: only a refusal is left.
|
|
124
|
+
return refused(app, request, route.titleKey, result.decision);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** An app-wide action's own form: `GET /admin?action=<name>`, and its refused post. */
|
|
128
|
+
function globalForm(
|
|
129
|
+
app: AdminApp,
|
|
130
|
+
route: AdminRoute,
|
|
131
|
+
request: AdminRouteRequest,
|
|
132
|
+
action: AdminAction,
|
|
133
|
+
values: Readonly<Record<string, string>>,
|
|
134
|
+
issues: readonly ValidationIssue[],
|
|
135
|
+
): AdminRouteResponse {
|
|
136
|
+
return framed(
|
|
137
|
+
app,
|
|
138
|
+
request,
|
|
139
|
+
route.titleKey,
|
|
140
|
+
<AdminActionForm
|
|
141
|
+
action={action}
|
|
142
|
+
href={app.basePath}
|
|
143
|
+
operation={ACTION_OPERATION}
|
|
144
|
+
values={values}
|
|
145
|
+
issues={issues}
|
|
146
|
+
confirmation={action.destructive === true ? confirmationToken('admin', '') : null}
|
|
147
|
+
subject={t('admin.actions.subject.app')}
|
|
148
|
+
cancelHref={app.basePath}
|
|
149
|
+
/>,
|
|
150
|
+
issues.length === 0 ? 200 : 422,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function homeScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
155
|
+
return async (request) => {
|
|
156
|
+
const decision = decideAll(request.ctx.authz, route.permissions, request.ctx.actor);
|
|
157
|
+
if (!decision.allowed) return refusedPage(app, request, route, decision);
|
|
158
|
+
if (request.method === 'POST') return globalWrite(app, route, request, request.form ?? {});
|
|
159
|
+
const asked = new URL(request.url).searchParams.get(ACTION_PARAM);
|
|
160
|
+
if (asked !== null) {
|
|
161
|
+
// Only an action this actor may run: a link to a form is never a way round a missing button.
|
|
162
|
+
const offered = actionButtons({
|
|
163
|
+
actions: app.globalActions,
|
|
164
|
+
actor: request.ctx.actor,
|
|
165
|
+
authz: request.ctx.authz,
|
|
166
|
+
}).some((button) => button.name === asked);
|
|
167
|
+
const action = app.globalActions.find((declared) => declared.name === asked);
|
|
168
|
+
if (!offered || action === undefined) {
|
|
169
|
+
return framed(app, request, route.titleKey, <p>{t('admin.actions.unknown')}</p>, 404);
|
|
170
|
+
}
|
|
171
|
+
return globalForm(app, route, request, action, {}, []);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const open = app.resources.filter((resource) => canOperate(resource, 'list', request.ctx));
|
|
175
|
+
return framed(
|
|
176
|
+
app,
|
|
177
|
+
request,
|
|
178
|
+
route.titleKey,
|
|
179
|
+
<div class={styles['panel']}>
|
|
180
|
+
<AdminActions
|
|
181
|
+
actions={app.globalActions}
|
|
182
|
+
actor={request.ctx.actor}
|
|
183
|
+
authz={request.ctx.authz}
|
|
184
|
+
href={app.basePath}
|
|
185
|
+
/>
|
|
186
|
+
{open.length === 0 ? <p class={styles['note']}>{t('admin.actions.none')}</p> : null}
|
|
187
|
+
{open.map((resource) => (
|
|
188
|
+
<Card
|
|
189
|
+
header={
|
|
190
|
+
<h2>
|
|
191
|
+
<a href={`${app.basePath}${resource.path}`}>{t(resource.titleKey)}</a>
|
|
192
|
+
</h2>
|
|
193
|
+
}
|
|
194
|
+
>
|
|
195
|
+
<OperationMatrix rows={operationMatrix(resource, request.ctx)} />
|
|
196
|
+
</Card>
|
|
197
|
+
))}
|
|
198
|
+
</div>,
|
|
199
|
+
);
|
|
200
|
+
};
|
|
201
|
+
}
|