@ultimat3/admin 22.15.0 → 24.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 +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- 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 +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- 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-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -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 +137 -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/keyset-seek.ts +189 -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 +242 -0
- package/src/list-request.ts +129 -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 +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -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/row-version.ts +20 -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 +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- 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 -16
package/src/routes.ts
CHANGED
|
@@ -1,83 +1,56 @@
|
|
|
1
|
-
// The one
|
|
2
|
-
// is
|
|
3
|
-
//
|
|
4
|
-
// keeps a stale org's rows out of a service worker cache.
|
|
1
|
+
// The one place a route gets its SCREEN, and the matcher a host asks per request. The config a
|
|
2
|
+
// route is served under — `ssr`, gated, `network-only` — is `route-config.ts`'s; this file binds
|
|
3
|
+
// it to what renders there.
|
|
5
4
|
|
|
6
|
-
import {
|
|
7
|
-
import { defineRoute, type RouteGuard } from '@ultimat3/render';
|
|
5
|
+
import { compilePattern, type RouteConfig, type RouteGuard } from '@ultimat3/render';
|
|
8
6
|
import type { AdminApp, AdminRoute } from './admin';
|
|
9
|
-
import { AdminPagePathInvalidError
|
|
10
|
-
import {
|
|
11
|
-
import type {
|
|
7
|
+
import { AdminPagePathInvalidError } from './errors';
|
|
8
|
+
import { adminRouteDefinition } from './route-config';
|
|
9
|
+
import type { AdminRouteRequest, AdminRouteResponse } from './screen-frame';
|
|
10
|
+
import { screenFor } from './screens';
|
|
12
11
|
|
|
13
12
|
export interface AdminRouteConfig {
|
|
14
13
|
readonly path: string;
|
|
15
14
|
readonly view: AdminRoute['view'];
|
|
16
15
|
readonly entity: string | null;
|
|
17
16
|
readonly permissions: readonly string[];
|
|
18
|
-
/** The GUARDED component of a custom page; `null` for a generated view. */
|
|
19
|
-
readonly component: AdminPageComponent | null;
|
|
20
17
|
/**
|
|
21
18
|
* The coarse gate, already composed — the SAME object `config.policy` carries. It is here, and
|
|
22
|
-
* not read back off `config`, because `RouteConfig.policy` is optional
|
|
23
|
-
* admin URL from its own file has to be able to take the gate without proving it exists.
|
|
19
|
+
* not read back off `config`, because `RouteConfig.policy` is optional.
|
|
24
20
|
*/
|
|
25
21
|
readonly policy: RouteGuard;
|
|
26
|
-
readonly config:
|
|
22
|
+
readonly config: RouteConfig;
|
|
23
|
+
/**
|
|
24
|
+
* Answer one request to this route — a GET, or the form the screen posted back at itself — as
|
|
25
|
+
* a status and a framed body, or the redirect a write that worked earns. Never absent: a
|
|
26
|
+
* generated view renders its rows and a custom page is wrapped, so no route of the table is a
|
|
27
|
+
* path with nothing behind it. The screen decides before it renders; a host only serialises.
|
|
28
|
+
*/
|
|
29
|
+
respond(request: AdminRouteRequest): Promise<AdminRouteResponse>;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
/**
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
* there by `permissionsForOperation`/`pagePermissions`); the rest of the pair is decided per
|
|
33
|
-
* request by `decideAll`, in `crud.ts` for a generated view and in `page-guard.tsx` for a page.
|
|
34
|
-
* An empty list is refused here too: `render: 'ssr'` with no policy is a public dashboard.
|
|
35
|
-
*/
|
|
36
|
-
export function adminRouteConfig(route: AdminRoute): AdminRouteConfig {
|
|
37
|
-
const permission = route.permissions[0];
|
|
38
|
-
if (permission === undefined) throw new AdminPageUnguardedError({ path: route.path });
|
|
39
|
-
const policy: RouteGuard = { permission };
|
|
40
|
-
|
|
32
|
+
/** One route of the table, with its gate, its served config and its screen. */
|
|
33
|
+
export function adminRouteConfig(app: AdminApp, route: AdminRoute): AdminRouteConfig {
|
|
34
|
+
const { policy, config } = adminRouteDefinition(route);
|
|
41
35
|
return {
|
|
42
36
|
path: route.path,
|
|
43
37
|
view: route.view,
|
|
44
38
|
entity: route.entity,
|
|
45
39
|
permissions: route.permissions,
|
|
46
|
-
|
|
40
|
+
respond: screenFor(app, route),
|
|
47
41
|
policy,
|
|
48
|
-
config
|
|
49
|
-
// One mode for every admin route, never an author's choice. A generated view was `spa` — a
|
|
50
|
-
// shell the browser was supposed to fill — until `spa` was deleted: nothing ever built the
|
|
51
|
-
// client bundle it preloaded and `renderSpa` never read the route's component, so every
|
|
52
|
-
// generated view served an empty `<div id="x-root">`. `ssr` renders the same rows behind the
|
|
53
|
-
// same guard, once per request, and `hydrate: 'never'` means the screen ships no JS at all:
|
|
54
|
-
// an admin view is a pure function of its props (no `createSignal`, no local state), so
|
|
55
|
-
// there is nothing to hydrate. `never` is a REFUSAL and not just a default — an `island()`
|
|
56
|
-
// rendered on this route throws `X_ISLAND_NOT_HYDRATED` (`@ultimat3/render`'s
|
|
57
|
-
// `island-collector.ts`), and no admin module declares one.
|
|
58
|
-
render: 'ssr',
|
|
59
|
-
offline: 'network-only',
|
|
60
|
-
hydrate: 'never',
|
|
61
|
-
policy,
|
|
62
|
-
meta: () => ({ title: t(route.titleKey) }),
|
|
63
|
-
}),
|
|
42
|
+
config,
|
|
64
43
|
};
|
|
65
44
|
}
|
|
66
45
|
|
|
67
|
-
/**
|
|
46
|
+
/** Every route of the table, each with its screen. Auth stays the host app's: see `AdminApp.auth`. */
|
|
68
47
|
export function adminRoutes(app: AdminApp): readonly AdminRouteConfig[] {
|
|
69
|
-
return app.routes.map(adminRouteConfig);
|
|
48
|
+
return app.routes.map((route) => adminRouteConfig(app, route));
|
|
70
49
|
}
|
|
71
50
|
|
|
72
51
|
/**
|
|
73
|
-
* The one route the admin declares for `path` —
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* It exists because the alternative is what the deployed demo shipped: a page file typing
|
|
77
|
-
* `policy: { permission: 'admin:read' }` beside a route table that separately declared
|
|
78
|
-
* `permissionsForOperation(...)` for the same URL. Two declarations of one URL's authz agree until
|
|
79
|
-
* one of them is edited, and nothing was ever going to notice. Reading the gate from here means
|
|
80
|
-
* there is one declaration and one reader.
|
|
52
|
+
* The one route the admin declares for `path` — a read of the table, for a caller that wants a
|
|
53
|
+
* route's gate or its screen without matching a URL.
|
|
81
54
|
*
|
|
82
55
|
* A path the table does not declare is refused rather than answered with a default: a mount with
|
|
83
56
|
* no route is a screen whose permissions nothing composed, which is exactly the shape `pages:`
|
|
@@ -95,5 +68,33 @@ export function adminRouteFor(app: AdminApp, path: string): AdminRouteConfig {
|
|
|
95
68
|
'or declare this one in `pages:` on defineAdmin()',
|
|
96
69
|
});
|
|
97
70
|
}
|
|
98
|
-
return adminRouteConfig(route);
|
|
71
|
+
return adminRouteConfig(app, route);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AdminRouteMatch {
|
|
75
|
+
readonly route: AdminRouteConfig;
|
|
76
|
+
readonly params: Readonly<Record<string, string>>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The route a REQUEST's pathname names, with its params — what a host mounting the admin under
|
|
81
|
+
* one catch-all asks per request. `null` is "no admin screen lives there", which the host answers
|
|
82
|
+
* 404. The most specific pattern wins, so `/posts/new` is the create form and never the detail
|
|
83
|
+
* of a row called `new`. Matched against the table as it is NOW, so a page added in a save is
|
|
84
|
+
* served without the host re-reading anything.
|
|
85
|
+
*/
|
|
86
|
+
export function adminRouteMatch(app: AdminApp, pathname: string): AdminRouteMatch | null {
|
|
87
|
+
let best: { route: AdminRoute; params: Record<string, string>; specificity: number } | null =
|
|
88
|
+
null;
|
|
89
|
+
for (const route of app.routes) {
|
|
90
|
+
const pattern = compilePattern(route.path);
|
|
91
|
+
const found = pattern.regex.exec(pathname);
|
|
92
|
+
if (found === null || (best !== null && best.specificity >= pattern.specificity)) continue;
|
|
93
|
+
const params: Record<string, string> = {};
|
|
94
|
+
pattern.keys.forEach((key, index) => {
|
|
95
|
+
params[key] = decodeURIComponent(found[index + 1] ?? '');
|
|
96
|
+
});
|
|
97
|
+
best = { route, params, specificity: pattern.specificity };
|
|
98
|
+
}
|
|
99
|
+
return best === null ? null : { route: adminRouteConfig(app, best.route), params: best.params };
|
|
99
100
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// `rows`, applied to a WRITE: the row a create or an update would leave behind must satisfy the
|
|
2
|
+
// acting actor's row scope, or the write is refused before the repo is asked for it. The one place
|
|
3
|
+
// this package evaluates a predicate itself — a row that does not exist yet cannot be asked of the
|
|
4
|
+
// repo, which is how every READ answers the same question (`list-scope.ts`, `findRow`).
|
|
5
|
+
|
|
6
|
+
import type { AdminActor, AdminDecision } from './authz';
|
|
7
|
+
import type { AdminFilter, AdminRow } from './registry';
|
|
8
|
+
import type { AdminResource } from './resource';
|
|
9
|
+
|
|
10
|
+
/** The key a write whose result would sit outside the actor's row scope is refused with. */
|
|
11
|
+
export const ROW_OUT_OF_SCOPE_REASON = 'admin.error.row-out-of-scope';
|
|
12
|
+
|
|
13
|
+
export interface OutsideRowScope {
|
|
14
|
+
readonly field: string;
|
|
15
|
+
readonly op: AdminFilter['op'];
|
|
16
|
+
/**
|
|
17
|
+
* `false` when the predicate could not be decided exactly here — an ordering over text is a
|
|
18
|
+
* collation's to answer — and the write is refused for that reason rather than for a mismatch.
|
|
19
|
+
*/
|
|
20
|
+
readonly decidable: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const absent = (value: unknown): boolean => value === null || value === undefined;
|
|
24
|
+
|
|
25
|
+
/** An instant as milliseconds, whichever of the two shapes it travels in; `undefined` otherwise. */
|
|
26
|
+
const instantOf = (value: unknown): number | undefined => {
|
|
27
|
+
if (value instanceof Date) return value.getTime();
|
|
28
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T/.test(value)) return undefined;
|
|
29
|
+
const at = Date.parse(value);
|
|
30
|
+
return Number.isNaN(at) ? undefined : at;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Equal as the column's value: a `Date` against the ISO text a filter carries is one instant. */
|
|
34
|
+
const equal = (held: unknown, wanted: unknown): boolean => {
|
|
35
|
+
if (held instanceof Date || wanted instanceof Date) {
|
|
36
|
+
const a = instantOf(held);
|
|
37
|
+
return a !== undefined && a === instantOf(wanted);
|
|
38
|
+
}
|
|
39
|
+
return typeof held === 'object' ? false : String(held) === String(wanted);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** `-1 | 0 | 1` for two numbers or two instants; `undefined` for anything a collation orders. */
|
|
43
|
+
const ordered = (held: unknown, wanted: unknown): number | undefined => {
|
|
44
|
+
const pair =
|
|
45
|
+
typeof held === 'number' && typeof wanted === 'number'
|
|
46
|
+
? ([held, wanted] as const)
|
|
47
|
+
: held instanceof Date
|
|
48
|
+
? ([instantOf(held), instantOf(wanted)] as const)
|
|
49
|
+
: undefined;
|
|
50
|
+
const [a, b] = pair ?? [];
|
|
51
|
+
if (a === undefined || b === undefined || Number.isNaN(a) || Number.isNaN(b)) return undefined;
|
|
52
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** `true` in scope, `false` out of it, `undefined` when this file cannot say. */
|
|
56
|
+
function satisfies(row: AdminRow, filter: AdminFilter): boolean | undefined {
|
|
57
|
+
const held = Object.hasOwn(row, filter.field) ? row[filter.field] : undefined;
|
|
58
|
+
if (filter.op === 'is-null') return absent(held) === (filter.value !== false);
|
|
59
|
+
// SQL's three-valued logic: a NULL on either side matches nothing, `neq` included.
|
|
60
|
+
if (absent(held) || absent(filter.value)) return false;
|
|
61
|
+
switch (filter.op) {
|
|
62
|
+
case 'eq':
|
|
63
|
+
return equal(held, filter.value);
|
|
64
|
+
case 'neq':
|
|
65
|
+
return !equal(held, filter.value);
|
|
66
|
+
case 'in':
|
|
67
|
+
return Array.isArray(filter.value) && filter.value.some((one) => equal(held, one));
|
|
68
|
+
case 'contains':
|
|
69
|
+
// `LIKE` is case-sensitive and collation-free for a literal substring.
|
|
70
|
+
return typeof held === 'string' ? held.includes(String(filter.value)) : undefined;
|
|
71
|
+
default: {
|
|
72
|
+
const order = ordered(held, filter.value);
|
|
73
|
+
if (order === undefined) return undefined;
|
|
74
|
+
if (filter.op === 'gt') return order > 0;
|
|
75
|
+
if (filter.op === 'gte') return order >= 0;
|
|
76
|
+
if (filter.op === 'lt') return order < 0;
|
|
77
|
+
return order <= 0;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The first predicate `row` does not satisfy, or `null` when it satisfies every one. */
|
|
83
|
+
export function outsideRowScope(
|
|
84
|
+
row: AdminRow,
|
|
85
|
+
where: readonly AdminFilter[],
|
|
86
|
+
): OutsideRowScope | null {
|
|
87
|
+
for (const filter of where) {
|
|
88
|
+
const verdict = satisfies(row, filter);
|
|
89
|
+
if (verdict === true) continue;
|
|
90
|
+
return { field: filter.field, op: filter.op, decidable: verdict !== undefined };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* What a write would leave, judged against the acting actor's scope. `null` for a resource that
|
|
97
|
+
* declares no `rows` — and for an actor whose scope is the empty conjunction, who sees every row.
|
|
98
|
+
*
|
|
99
|
+
* `touched` narrows an UPDATE to the predicates over the fields it writes: the stored row is
|
|
100
|
+
* already inside the scope (the repo said so when it was loaded), so only a field the patch
|
|
101
|
+
* changes can move it out — and a predicate this file cannot decide is then never asked about a
|
|
102
|
+
* column nobody touched.
|
|
103
|
+
*/
|
|
104
|
+
export function writeOutsideScope(
|
|
105
|
+
resource: AdminResource,
|
|
106
|
+
actor: AdminActor,
|
|
107
|
+
candidate: AdminRow,
|
|
108
|
+
touched?: readonly string[],
|
|
109
|
+
): OutsideRowScope | null {
|
|
110
|
+
if (resource.rowScope === undefined) return null;
|
|
111
|
+
const scope = resource.rowScope(actor);
|
|
112
|
+
return outsideRowScope(
|
|
113
|
+
candidate,
|
|
114
|
+
touched === undefined ? scope : scope.filter((filter) => touched.includes(filter.field)),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The refusal a write outside the scope is answered and audited with. */
|
|
119
|
+
export function outOfScopeDecision(permission: string, outside: OutsideRowScope): AdminDecision {
|
|
120
|
+
return {
|
|
121
|
+
allowed: false,
|
|
122
|
+
permission,
|
|
123
|
+
reason: ROW_OUT_OF_SCOPE_REASON,
|
|
124
|
+
trace: [
|
|
125
|
+
outside.decidable
|
|
126
|
+
? `rows: ${outside.field} ${outside.op} is not satisfied by the row this write would leave`
|
|
127
|
+
: `rows: ${outside.field} ${outside.op} cannot be decided before the write, so it is refused`,
|
|
128
|
+
],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The version an edit form is rendered against: a keyed fingerprint of the row as it was READ,
|
|
2
|
+
// posted back in a hidden field, so `adminUpdate` can tell "the row I was shown" from "the row as
|
|
3
|
+
// it is now" and refuse a write that would silently overwrite a concurrent edit.
|
|
4
|
+
|
|
5
|
+
import { keyedFingerprint } from '@ultimat3/core';
|
|
6
|
+
import type { AdminRow } from './registry';
|
|
7
|
+
|
|
8
|
+
/** The posted name of the version, beside `_operation`: never a column, never an input field. */
|
|
9
|
+
export const VERSION_FIELD = '_version';
|
|
10
|
+
|
|
11
|
+
/** The key a write against a stale version is refused and audited with. */
|
|
12
|
+
export const ROW_CHANGED_REASON = 'admin.error.row-changed';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Every value the row enumerates — a sealed one is non-enumerable and is not in it, so a password
|
|
16
|
+
* change is not a "concurrent edit" and no sealed value is ever folded into page text. Keyed: the
|
|
17
|
+
* page carries it, and an unkeyed hash of a row is an offline oracle for a short value in a column
|
|
18
|
+
* the form does not draw.
|
|
19
|
+
*/
|
|
20
|
+
export const rowVersion = (row: AdminRow): string => keyedFingerprint(row, 'admin.row-version');
|
|
@@ -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
|
+
}
|