@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.
Files changed (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. package/src/widgets.tsx +84 -16
package/src/routes.ts CHANGED
@@ -1,83 +1,56 @@
1
- // The one bridge from the admin's route table to @ultimat3/render, and the one place a policy
2
- // is composed onto an admin page. Every admin route is `ssr`: it is behind auth, so its guard runs
3
- // on the server, there is nothing to prerender and nothing a CDN may hold — and `network-only`
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 { t } from '@ultimat3/i18n';
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, AdminPageUnguardedError } from './errors';
10
- import { guardedPage } from './page-guard';
11
- import type { AdminPageComponent } from './pages';
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: a host that serves an
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: ReturnType<typeof defineRoute>;
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
- * The author never writes this `defineRoute` call, so the author cannot omit its `policy` —
31
- * which is the whole mechanism. The coarse gate is `permissions[0]` (always `admin:read`, put
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
- component: route.component === undefined ? null : guardedPage(route, route.component),
40
+ respond: screenFor(app, route),
47
41
  policy,
48
- config: defineRoute({
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
- /** Hand these to the router. Auth stays the host app's: see `AdminApp.auth`. */
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` — the lookup a host performs when it serves an admin
74
- * URL from its own file rather than from `adminRoutes()`.
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
+ }