@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/actions.tsx CHANGED
@@ -1,34 +1,71 @@
1
- // Action buttons. The component renders `actionButtons()` and nothing else: if the gate did
2
- // not return a button, no markup exists for it — not hidden with CSS, not disabled. The same
3
- // gate then decides the call, so there is one authz system and the UI cannot lie about it.
1
+ // Action buttons, as native form submits. The component renders `actionButtons()` and nothing
2
+ // else: if the gate did not return a button, no markup exists for it — not hidden with CSS, not
3
+ // disabled. The same gate then decides the POST, so there is one authz system and the UI cannot
4
+ // lie about it.
4
5
  //
5
- // Fully controlled (no local signals): the route owns "which destructive action is pending",
6
- // which keeps this file a pure function of props and keeps the confirmation state somewhere
7
- // the server round-trip can also see.
6
+ // A `<form method="post">` and a `type="submit"`, because an admin screen is `hydrate: 'never'`:
7
+ // the browser's own form handling is the client. These were `type="button"` with an `onClick`
8
+ // until the screens were mounted — markup that reads as a control and can never act.
8
9
 
9
10
  import { t } from '@ultimat3/i18n';
10
- import { Dialog } from '@ultimat3/ui';
11
+ import { Button, Field, Input, Link } from '@ultimat3/ui';
11
12
  import type { JSX } from 'solid-js';
12
- import { type AdminActionButton, actionButtons } from './action-gate';
13
+ import { actionButtons } from './action-gate';
14
+ import styles from './admin.module.scss';
13
15
  import type { AdminActor, AdminAuthz, AdminSubject } from './authz';
14
16
  import { confirmationToken } from './permissions';
15
17
  import type { AdminAction } from './registry';
16
18
 
19
+ /** The posted field that names which write a form is: an action here, a delete on the detail. */
20
+ export const OPERATION_FIELD = '_operation';
21
+ export const ACTION_OPERATION = 'action';
22
+ export const DELETE_OPERATION = 'delete';
23
+ /** A batch: posted at the LIST's URL, whose query string is the filter "all matching" means. */
24
+ export const BATCH_OPERATION = 'batch';
25
+
26
+ /** The query parameter a row's URL opens an action's own form with: `<row>?action=<name>`. */
27
+ export const ACTION_PARAM = 'action';
28
+
29
+ /** Where an action's form lives: the row's own URL, asked for that action. */
30
+ export const actionFormHref = (rowHref: string, name: string): string =>
31
+ `${rowHref}?${ACTION_PARAM}=${encodeURIComponent(name)}`;
32
+
33
+ /**
34
+ * The echo a destructive write must carry. Typed, not pre-filled: the token is on screen so it
35
+ * can be copied, and copying it is the deliberate act.
36
+ */
37
+ export function ConfirmationField(props: { readonly token: string }): JSX.Element {
38
+ return (
39
+ <Field label={t('admin.actions.confirm.body', { token: props.token })} required>
40
+ {(control) => (
41
+ <Input
42
+ id={control.id}
43
+ aria-describedby={control['aria-describedby']}
44
+ name="confirmation"
45
+ autocomplete="off"
46
+ size="sm"
47
+ required
48
+ />
49
+ )}
50
+ </Field>
51
+ );
52
+ }
53
+
17
54
  export interface AdminActionsProps {
18
55
  readonly actions: readonly AdminAction[];
19
56
  readonly actor: AdminActor;
20
57
  readonly authz: AdminAuthz;
21
- // `| undefined` is explicit on every optional prop: under exactOptionalPropertyTypes a
22
- // parent that forwards its own optional handler would otherwise not typecheck.
58
+ // `| undefined` is explicit: under exactOptionalPropertyTypes a parent that forwards its own
59
+ // optional subject would otherwise not typecheck.
23
60
  readonly subject?: AdminSubject | undefined;
24
- /** A non-destructive press, or a confirmed destructive one. */
25
- readonly onRun?: ((button: AdminActionButton, confirmation: string) => void) | undefined;
26
- /** A destructive press: the route sets `pending` and re-renders. */
27
- readonly onRequestConfirm?: ((button: AdminActionButton) => void) | undefined;
28
- readonly pending?: AdminActionButton | null | undefined;
29
- readonly confirmation?: string | undefined;
30
- readonly onConfirmationInput?: ((value: string) => void) | undefined;
31
- readonly onCancel?: (() => void) | undefined;
61
+ /**
62
+ * Where the forms post: the URL of the row the action is about (`<base>/<resource>/<id>`), or
63
+ * the admin's base path for an app-wide action. The subject is the URL's, never a hidden field
64
+ * a stale page could have got wrong.
65
+ */
66
+ readonly href: string;
67
+ /** The resource's sealed column names: a `when` never reads one. */
68
+ readonly sealed?: readonly string[] | undefined;
32
69
  }
33
70
 
34
71
  export function AdminActions(props: AdminActionsProps): JSX.Element {
@@ -37,58 +74,44 @@ export function AdminActions(props: AdminActionsProps): JSX.Element {
37
74
  actor: props.actor,
38
75
  authz: props.authz,
39
76
  ...(props.subject === undefined ? {} : { subject: props.subject }),
77
+ ...(props.sealed === undefined ? {} : { sealed: props.sealed }),
40
78
  });
41
79
 
42
- const expected = confirmationToken(props.subject?.entity ?? 'admin', props.subject?.id ?? '');
43
- const pending = props.pending ?? null;
44
- const typed = props.confirmation ?? '';
45
-
46
- const press = (button: AdminActionButton): void => {
47
- if (button.destructive) props.onRequestConfirm?.(button);
48
- else props.onRun?.(button, '');
49
- };
50
-
51
80
  if (buttons.length === 0) return <span class="x-admin-actions-empty" />;
52
81
 
53
- return (
54
- <fieldset class="x-admin-actions" aria-label={t('admin.actions.label')}>
55
- {buttons.map((button) => (
56
- <button
57
- type="button"
58
- data-destructive={button.destructive ? 'true' : undefined}
59
- onClick={() => press(button)}
60
- >
61
- {t(button.labelKey)}
62
- </button>
63
- ))}
82
+ const expected = confirmationToken(props.subject?.entity ?? 'admin', props.subject?.id ?? '');
64
83
 
65
- <Dialog
66
- open={pending !== null}
67
- title={t('admin.actions.confirm.title')}
68
- onClose={() => props.onCancel?.()}
69
- >
70
- <p>{t('admin.actions.confirm.body', { token: expected })}</p>
71
- <label for="x-admin-confirm">{t('admin.actions.confirm.label')}</label>
72
- <input
73
- id="x-admin-confirm"
74
- name="confirmation"
75
- autocomplete="off"
76
- value={typed}
77
- onInput={(event: InputEvent) => {
78
- const target = event.currentTarget;
79
- props.onConfirmationInput?.(target instanceof HTMLInputElement ? target.value : '');
80
- }}
81
- />
82
- <button
83
- type="button"
84
- disabled={typed !== expected}
85
- onClick={() => {
86
- if (pending !== null) props.onRun?.(pending, typed);
87
- }}
88
- >
89
- {t('admin.actions.confirm.submit')}
90
- </button>
91
- </Dialog>
92
- </fieldset>
84
+ return (
85
+ <span class={styles['actions']}>
86
+ {buttons.map((button) =>
87
+ // An action that takes input is a LINK to its own form — a page the server renders, at
88
+ // the row's URL — and never a button that would post an input nobody typed.
89
+ button.form ? (
90
+ <Link
91
+ appearance="button"
92
+ size="sm"
93
+ variant="secondary"
94
+ tone={button.destructive ? 'danger' : 'neutral'}
95
+ href={actionFormHref(props.href, button.name)}
96
+ >
97
+ {t(button.labelKey)}
98
+ </Link>
99
+ ) : (
100
+ <form class={styles['actionForm']} method="post" action={props.href}>
101
+ <input type="hidden" name={OPERATION_FIELD} value={ACTION_OPERATION} />
102
+ <input type="hidden" name="name" value={button.name} />
103
+ {button.destructive ? <ConfirmationField token={expected} /> : null}
104
+ <Button
105
+ type="submit"
106
+ size="sm"
107
+ variant="secondary"
108
+ tone={button.destructive ? 'danger' : 'neutral'}
109
+ >
110
+ {t(button.labelKey)}
111
+ </Button>
112
+ </form>
113
+ ),
114
+ )}
115
+ </span>
93
116
  );
94
117
  }
package/src/actor.ts ADDED
@@ -0,0 +1,53 @@
1
+ // Who the dashboard is acting as, by default: the actor the HTTP pipeline already resolved for
2
+ // this request, read off the framework's own request context — so the admin cannot grow a session
3
+ // lookup of its own beside the app's.
4
+
5
+ import { type Actor, isAnonymous, tryUseContext } from '@ultimat3/core';
6
+ import type { AdminActor } from './authz';
7
+
8
+ /**
9
+ * `null` for anonymous, and that is a real answer rather than a missing one: every operation an
10
+ * unauthenticated caller asks about is refused by the same `decideAll()` that refuses a signed-in
11
+ * actor who lacks the grant. The tenant rides along — a role-only rule would otherwise allow a row
12
+ * from another org.
13
+ */
14
+ export const adminActorFrom = (
15
+ actor: Actor,
16
+ locale: string,
17
+ timeZone: string,
18
+ ): AdminActor | null =>
19
+ isAnonymous(actor)
20
+ ? null
21
+ : {
22
+ id: actor.id,
23
+ roles: actor.roles,
24
+ locale,
25
+ timeZone,
26
+ ...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
27
+ };
28
+
29
+ export interface AdminRequestActor {
30
+ readonly actor: AdminActor | null;
31
+ /** Every audit row is keyed by it, so a denial is traceable to the request that caused it. */
32
+ readonly requestId: string;
33
+ }
34
+
35
+ /** The id an audit row carries when no request is in flight — a prerender, a script, a test. */
36
+ export const NO_REQUEST_ID = 'no-request';
37
+
38
+ /**
39
+ * The actor for the in-flight request. `tryUseContext()` and not `useContext()`: a test calls a
40
+ * screen with no request in flight, and "no context" is anonymous, not a crash.
41
+ */
42
+ export const requestActor = (): AdminRequestActor => {
43
+ const ctx = tryUseContext();
44
+ if (ctx === undefined) return { actor: null, requestId: NO_REQUEST_ID };
45
+ return { actor: adminActorFrom(ctx.actor, ctx.locale, ctx.tz), requestId: ctx.requestId };
46
+ };
47
+
48
+ /**
49
+ * Anonymous is a real actor id on a decision — `decideAll()` refuses it exactly as it refuses a
50
+ * signed-in actor missing the grant, and an audit row that says `anonymous` is more useful than
51
+ * one that says nothing.
52
+ */
53
+ export const ANONYMOUS_ADMIN_ACTOR: AdminActor = Object.freeze({ id: 'anonymous', roles: [] });
@@ -0,0 +1,298 @@
1
+ // The admin's own layer, on @ultimat3/ui's tokens directly. Semantic roles and token rungs only:
2
+ // a raw colour here would paint one theme correctly and the other one wrong, and a raw length is
3
+ // a second scale beside the one every other screen is drawn on.
4
+
5
+ @use '@ultimat3/ui/tokens' as t;
6
+
7
+ .shell {
8
+ display: grid;
9
+ grid-template-columns: minmax(t.rem(192), t.rem(256)) 1fr;
10
+ gap: t.space(6);
11
+ min-block-size: 100dvh;
12
+ background: t.role('bg');
13
+ color: t.role('fg');
14
+
15
+ // One column under the `md` rung: the sidebar becomes a block above the screen it navigates.
16
+ @include t.respond-down('md') {
17
+ grid-template-columns: 1fr;
18
+ }
19
+ }
20
+
21
+ .skip {
22
+ @include t.visually-hidden;
23
+
24
+ &:focus-visible {
25
+ @include t.focus-ring;
26
+ }
27
+ }
28
+
29
+ .side {
30
+ @include t.surface('surface', 'sm');
31
+ @include t.padding-block(t.space(6));
32
+ @include t.padding-inline(t.space(4));
33
+ @include t.column(t.space(4));
34
+
35
+ border-inline-end: t.stroke(hairline) solid t.role('line');
36
+ }
37
+
38
+ .brand {
39
+ @include t.row(t.space(2));
40
+
41
+ font-size: t.text('lg');
42
+ font-weight: t.weight('semibold');
43
+ color: t.role('fg-strong');
44
+ text-decoration: none;
45
+ }
46
+
47
+ .actor {
48
+ margin: 0;
49
+ font-size: t.text('sm');
50
+ color: t.role('fg-muted');
51
+ }
52
+
53
+ .search {
54
+ display: grid;
55
+ }
56
+
57
+ .hidden {
58
+ @include t.visually-hidden;
59
+ }
60
+
61
+ .group {
62
+ display: grid;
63
+ gap: t.space(2);
64
+ }
65
+
66
+ .groupTitle {
67
+ @include t.label-caps;
68
+
69
+ margin: 0;
70
+ color: t.role('fg-muted');
71
+ }
72
+
73
+ .navList {
74
+ display: grid;
75
+ gap: t.space(1);
76
+ padding: 0;
77
+ margin: 0;
78
+ list-style: none;
79
+ }
80
+
81
+ .navLink {
82
+ @include t.transition(background-color, color);
83
+
84
+ display: block;
85
+ padding: t.space(2) t.space(3);
86
+ border-radius: t.radius('md');
87
+ color: t.role('fg');
88
+ text-decoration: none;
89
+
90
+ &:hover,
91
+ &[aria-current='page'] {
92
+ background: t.role('bg-soft');
93
+ color: t.role('fg-strong');
94
+ }
95
+
96
+ &:focus-visible {
97
+ @include t.focus-ring;
98
+ }
99
+ }
100
+
101
+ .main {
102
+ @include t.padding-block(t.space(8));
103
+ @include t.padding-inline(t.space(6));
104
+ @include t.column(t.space(6));
105
+
106
+ min-inline-size: 0;
107
+ }
108
+
109
+ .title {
110
+ margin: 0;
111
+ font-size: t.text('2xl');
112
+ color: t.role('fg-strong');
113
+ }
114
+
115
+ .toolbar {
116
+ @include t.row(t.space(3), center, space-between);
117
+
118
+ flex-wrap: wrap;
119
+ }
120
+
121
+ .panel {
122
+ display: grid;
123
+ gap: t.space(4);
124
+ }
125
+
126
+ .matrix {
127
+ inline-size: 100%;
128
+ border-collapse: collapse;
129
+ font-size: t.text('sm');
130
+
131
+ th,
132
+ td {
133
+ padding: t.space(2) t.space(3);
134
+ text-align: start;
135
+ border-block-end: t.stroke(hairline) solid t.role('line');
136
+ vertical-align: top;
137
+ }
138
+
139
+ th {
140
+ color: t.role('fg-muted');
141
+ font-weight: t.weight('semibold');
142
+ }
143
+ }
144
+
145
+ // The two verdict rows carry a tone, not a colour: `success` and `danger` both resolve in the
146
+ // light and the dark scale.
147
+ .allowed td:last-child {
148
+ color: t.role('success');
149
+ }
150
+
151
+ .denied td:last-child {
152
+ color: t.role('danger');
153
+ }
154
+
155
+ .mono {
156
+ @include t.data-text;
157
+ }
158
+
159
+ .fields {
160
+ display: grid;
161
+ grid-template-columns: minmax(t.rem(128), max-content) 1fr;
162
+ gap: t.space(2) t.space(4);
163
+ margin: 0;
164
+
165
+ dt {
166
+ color: t.role('fg-muted');
167
+ font-size: t.text('sm');
168
+ }
169
+
170
+ dd {
171
+ margin: 0;
172
+ min-inline-size: 0;
173
+ overflow-wrap: anywhere;
174
+ }
175
+ }
176
+
177
+ .form {
178
+ @include t.column(t.space(4));
179
+ }
180
+
181
+ // One titled group of a detail page or a form. A `<section>` on the detail, a `<fieldset>` on the
182
+ // form — the same spacing, so the two read as one layout.
183
+ .section {
184
+ @include t.column(t.space(3));
185
+
186
+ padding: 0;
187
+ margin: 0;
188
+ border: 0;
189
+
190
+ h3,
191
+ legend {
192
+ margin: 0;
193
+ padding: 0;
194
+ font-size: t.text('sm');
195
+ font-weight: t.weight('semibold');
196
+ color: t.role('fg-muted');
197
+ }
198
+ }
199
+
200
+ // The list's batch bar: what to run, over which rows, and the submit — one native form whose
201
+ // row checkboxes sit in the table and join it through their `form` attribute.
202
+ .batch {
203
+ @include t.row(t.space(3), end);
204
+
205
+ flex-wrap: wrap;
206
+ margin: 0;
207
+ }
208
+
209
+ // The counts a batch answers with, one term per outcome.
210
+ .counts {
211
+ display: grid;
212
+ grid-template-columns: max-content max-content;
213
+ gap: t.space(1) t.space(4);
214
+ margin: 0;
215
+
216
+ dt {
217
+ color: t.role('fg-muted');
218
+ }
219
+
220
+ dd {
221
+ @include t.data-text;
222
+
223
+ margin: 0;
224
+ }
225
+ }
226
+
227
+ .actions {
228
+ @include t.row(t.space(2));
229
+
230
+ flex-wrap: wrap;
231
+ }
232
+
233
+ // One form per button — a native POST is the whole client on a page that never hydrates, and a
234
+ // single form around several submits could not say which one was pressed.
235
+ .actionForm {
236
+ @include t.row(t.space(2));
237
+
238
+ display: inline-flex;
239
+ margin: 0;
240
+ }
241
+
242
+
243
+ .note,
244
+ .refusal {
245
+ margin: 0;
246
+ font-size: t.text('sm');
247
+ color: t.role('fg-muted');
248
+ }
249
+
250
+ .refusal {
251
+ @include t.surface('surface-raised', 'sm');
252
+ @include t.padding-block(t.space(4));
253
+ @include t.padding-inline(t.space(4));
254
+
255
+ border-inline-start: t.stroke(heavy) solid t.role('danger');
256
+ border-radius: t.radius('md');
257
+ color: t.role('fg');
258
+ }
259
+
260
+ .list {
261
+ display: grid;
262
+ gap: t.space(2);
263
+ padding: 0;
264
+ margin: 0;
265
+ list-style: none;
266
+ }
267
+
268
+ // The filter bar: controls flow and wrap, each as wide as its label needs, with the submit last.
269
+ .filters {
270
+ @include t.row(t.space(3), end);
271
+
272
+ flex-wrap: wrap;
273
+ margin: 0;
274
+ }
275
+
276
+ // Two bounds of one filter, or a control and the link beside it.
277
+ .range {
278
+ @include t.row(t.space(2), center);
279
+ }
280
+
281
+ .scopes {
282
+ @include t.row(t.space(1));
283
+
284
+ flex-wrap: wrap;
285
+ padding: 0;
286
+ margin: 0;
287
+ list-style: none;
288
+ }
289
+
290
+ // The jobs overview: a row of figure tiles that wraps, each a link to the list it counts.
291
+ .tiles {
292
+ display: grid;
293
+ grid-template-columns: repeat(auto-fill, minmax(t.rem(160), 1fr));
294
+ gap: t.space(3);
295
+ padding: 0;
296
+ margin: 0;
297
+ list-style: none;
298
+ }