@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
@@ -0,0 +1,31 @@
1
+ // A `CrudCtx` for a test: an actor, the grants it holds, and an audit log to read back. A custom
2
+ // page's component is typed to need one (`AdminPageProps.ctx`), and until this existed the only
3
+ // thing that minted one was the app's whole admin — so a generated page shipped with no render
4
+ // test. Server-only, like everything in this barrel: never imported by an island.
5
+
6
+ import { memoryAuditLog } from './audit';
7
+ import { type AdminActor, staticAuthz } from './authz';
8
+ import type { CrudCtx } from './crud';
9
+
10
+ export interface AdminTestCtxInput {
11
+ /** Who is acting. Omitted: an actor with no role and no tenant. */
12
+ readonly actor?: AdminActor;
13
+ /** The permissions the actor holds. Omitted: none — every gate refuses, by name. */
14
+ readonly granted?: readonly string[];
15
+ readonly requestId?: string;
16
+ }
17
+
18
+ /**
19
+ * The handle a page or a CRUD call is given, built from a grant LIST. `staticAuthz` decides it, so
20
+ * the admin's own implications hold (`admin:write` reads) and nothing else does — a test says
21
+ * exactly what its actor may do, and a rule that reads the row or the tenant belongs in a test
22
+ * that builds the app's real authz.
23
+ */
24
+ export function adminTestCtx(input: AdminTestCtxInput = {}): CrudCtx {
25
+ return {
26
+ actor: input.actor ?? { id: 'test-admin' },
27
+ authz: staticAuthz(input.granted ?? []),
28
+ audit: memoryAuditLog(),
29
+ requestId: input.requestId ?? 'test-admin-request',
30
+ };
31
+ }
package/src/validate.ts CHANGED
@@ -4,7 +4,10 @@
4
4
 
5
5
  export interface ValidationIssue {
6
6
  readonly path: string;
7
+ /** The schema's own words, or the literal an agent reads for an issue the admin raised. */
7
8
  readonly message: string;
9
+ /** Set only on an issue the ADMIN raised: the i18n key a screen renders instead of `message`. */
10
+ readonly messageKey?: string;
8
11
  }
9
12
 
10
13
  interface StandardResult {
@@ -7,6 +7,7 @@ import type { Money } from '@ultimat3/money';
7
7
  import { isCurrencyCode } from '@ultimat3/schema';
8
8
  import { AdminFieldUnsupportedError } from './errors';
9
9
  import type { AdminField } from './fields';
10
+ import type { AdminOption } from './relations';
10
11
 
11
12
  export interface WidgetContext {
12
13
  /** IANA zone. Required — there is no "server local time" in Ultimate. */
@@ -25,6 +26,15 @@ export interface WidgetContext {
25
26
  * caller that builds this context can. A wrong link is worse than no link.
26
27
  */
27
28
  readonly hrefFor?: (entity: string, id: string) => string | null;
29
+ /**
30
+ * The three things a reference widget shows that only the PAGE can know, because each is a read
31
+ * of another resource: a referenced row's label, a small target's whole option list, and where
32
+ * the target's lookup lives. Absent, a reference renders its id and edits as a text box — the
33
+ * widget never reads a row itself, so a cell cannot become a query.
34
+ */
35
+ readonly labelFor?: (entity: string, id: string) => string | undefined;
36
+ readonly optionsFor?: (entity: string) => readonly AdminOption[] | null;
37
+ readonly lookupHref?: (entity: string) => string | null;
28
38
  }
29
39
 
30
40
  export interface SelectOption {
@@ -60,15 +70,25 @@ export type WidgetProps =
60
70
  readonly field: string;
61
71
  readonly value: string | null;
62
72
  readonly entity: string;
63
- readonly labelField: string;
73
+ /** The target row's label, when the page read it. `null` renders the id itself. */
74
+ readonly label: string | null;
75
+ /** Every row of a small target — a `<select>`. `null`: the picker is the lookup screen. */
76
+ readonly options: readonly AdminOption[] | null;
77
+ /** Where an operator finds a row of the target, or `null` when it is not an admin resource. */
78
+ readonly lookupHref: string | null;
64
79
  }
65
80
  | {
66
81
  readonly widget: 'upload';
67
82
  readonly field: string;
68
83
  readonly value: { readonly url: string; readonly name: string } | null;
69
- };
84
+ }
85
+ /** No `value`, by type: a sealed column is written, never rendered and never prefilled. */
86
+ | { readonly widget: 'secret-input'; readonly field: string };
70
87
 
71
- const fail = (field: AdminField, cause: string, fix: string): never => {
88
+ /** What a value guard names in its refusal — a field, or a computed column standing in for one. */
89
+ export type GuardedField = Pick<AdminField, 'entity' | 'name'> & { readonly currency?: string };
90
+
91
+ const fail = (field: GuardedField, cause: string, fix: string): never => {
72
92
  throw new AdminFieldUnsupportedError({ entity: field.entity, field: field.name, cause, fix });
73
93
  };
74
94
 
@@ -86,7 +106,7 @@ const minorUnits = (value: unknown): number | null => {
86
106
  };
87
107
 
88
108
  /** `Money = { minor: number; currency: string }` or nothing. A float is a bug, not a value. */
89
- export function assertMoney(field: AdminField, value: unknown): Money | null {
109
+ export function assertMoney(field: GuardedField, value: unknown): Money | null {
90
110
  if (value === null || value === undefined) return null;
91
111
  if (typeof value === 'number') {
92
112
  return fail(
@@ -207,16 +227,24 @@ export function widgetProps(field: AdminField, value: unknown, ctx: WidgetContex
207
227
  field: field.name,
208
228
  value: value === undefined ? '' : JSON.stringify(value, null, 2),
209
229
  };
210
- case 'reference':
230
+ case 'reference': {
231
+ const entity = field.relation?.entity ?? field.name;
232
+ const id = value === null || value === undefined || value === '' ? null : String(value);
211
233
  return {
212
234
  widget: 'reference',
213
235
  field: field.name,
214
- value: value === null || value === undefined ? null : String(value),
215
- entity: field.relation?.entity ?? field.name,
216
- labelField: field.relation?.labelField ?? 'id',
236
+ value: id,
237
+ entity,
238
+ label: id === null ? null : (ctx.labelFor?.(entity, id) ?? null),
239
+ options: ctx.optionsFor?.(entity) ?? null,
240
+ lookupHref: ctx.lookupHref?.(entity) ?? null,
217
241
  };
242
+ }
218
243
  case 'upload':
219
244
  return { widget: 'upload', field: field.name, value: uploadValue(field, value) };
245
+ case 'secret-input':
246
+ // The row value is not read at all — not even to test it for null.
247
+ return { widget: 'secret-input', field: field.name };
220
248
  }
221
249
  }
222
250
 
package/src/widgets.tsx CHANGED
@@ -3,8 +3,9 @@
3
3
  // every one of those places — there is no second place to get it wrong.
4
4
 
5
5
  import { safeUrl } from '@ultimat3/core';
6
- import { registeredLocales, t } from '@ultimat3/i18n';
6
+ import { isMiss, registeredLocales, t } from '@ultimat3/i18n';
7
7
  import {
8
+ Badge,
8
9
  Checkbox,
9
10
  DateTime,
10
11
  type DateTimeFormatter,
@@ -16,6 +17,7 @@ import {
16
17
  } from '@ultimat3/ui';
17
18
  import type { JSX } from 'solid-js';
18
19
  import type { AdminField } from './fields';
20
+ import { currencyFieldOf, datetimeInputValue } from './form-decode';
19
21
  import { type WidgetContext, type WidgetProps, widgetProps } from './widget-value';
20
22
 
21
23
  export interface WidgetInput {
@@ -68,9 +70,16 @@ export const formatCalendarDate: DateTimeFormatter = (at, options) =>
68
70
  dateStyle: 'medium',
69
71
  }).format(at);
70
72
 
71
- /** `<input type="date">` wants `YYYY-MM-DD`; `datetime-local` wants `YYYY-MM-DDTHH:mm`. */
72
- const inputValueFor = (iso: string, precision: 'date' | 'instant'): string =>
73
- iso.slice(0, precision === 'date' ? 10 : 16);
73
+ /**
74
+ * An enum value's label: its translation when the catalog has one, the value itself when it does
75
+ * not. The value is an identifier the ENTITY declares (`public`, `draft`), so showing it is true;
76
+ * a `⟦admin.posts.field.audience.option.public⟧` in every cell of a derived list is not, and the
77
+ * key is built at runtime, so no i18n gate could have asked the app for it.
78
+ */
79
+ export const optionLabel = (field: AdminField, value: string): string => {
80
+ const label = t(`${field.labelKey}.option.${value}`);
81
+ return isMiss(label) ? value : label;
82
+ };
74
83
 
75
84
  /** Read-mode rendering of already-guarded props. Never formats; the widgets do that. */
76
85
  function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JSX.Element {
@@ -79,7 +88,9 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
79
88
  return props.value === null ? (
80
89
  <span>{t('admin.value.empty')}</span>
81
90
  ) : (
82
- <Money value={props.value} />
91
+ // The ACTOR's locale, handed over: ui's own default is the document's, and an operator
92
+ // whose dashboard is otherwise in their language read every amount and date in another.
93
+ <Money value={props.value} locale={ctx.locale} />
83
94
  );
84
95
  case 'datetime':
85
96
  return props.value === null ? (
@@ -87,6 +98,7 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
87
98
  ) : (
88
99
  <DateTime
89
100
  value={props.value}
101
+ locale={ctx.locale}
90
102
  timeZone={props.timeZone}
91
103
  format={props.precision === 'date' ? formatCalendarDate : undefined}
92
104
  />
@@ -94,21 +106,25 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
94
106
  case 'checkbox':
95
107
  return <span>{t(props.value ? 'admin.value.true' : 'admin.value.false')}</span>;
96
108
  case 'select':
97
- return (
98
- <span>
99
- {props.value === null
100
- ? t('admin.value.empty')
101
- : t(`${field.labelKey}.option.${props.value}`)}
102
- </span>
109
+ // A value from a closed set is a state, and a state reads as a badge. Only an enum: a
110
+ // locale or a zone is an identifier, and stays text.
111
+ if (props.value === null) return <span>{t('admin.value.empty')}</span>;
112
+ return field.type === 'enum' ? (
113
+ <Badge>{optionLabel(field, props.value)}</Badge>
114
+ ) : (
115
+ <span>{optionLabel(field, props.value)}</span>
103
116
  );
104
117
  case 'json-editor':
105
118
  return <pre class="x-admin-json">{props.value}</pre>;
106
119
  case 'reference': {
107
120
  if (props.value === null) return <span>{t('admin.value.empty')}</span>;
121
+ // The target's label when the page read it, the id when it did not — a row this actor may
122
+ // not see keeps its id and gains no name.
123
+ const shown = props.label ?? props.value;
108
124
  // `ctx.hrefFor` or nothing. The route table lives on `AdminApp`; this file only ever knew
109
125
  // the target entity's NAME, and turning that into a URL by appending an `s` is a guess.
110
126
  const href = ctx.hrefFor?.(props.entity, props.value) ?? null;
111
- return href === null ? <span>{props.value}</span> : <a href={href}>{props.value}</a>;
127
+ return href === null ? <span>{shown}</span> : <a href={href}>{shown}</a>;
112
128
  }
113
129
  case 'upload':
114
130
  return props.value === null ? (
@@ -116,6 +132,9 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
116
132
  ) : (
117
133
  <a href={safeUrl(props.value.url, 'href') ?? undefined}>{props.value.name}</a>
118
134
  );
135
+ case 'secret-input':
136
+ // Nothing to show, by type: `WidgetProps` carries no value for a sealed column.
137
+ return <span>{t('admin.value.sealed')}</span>;
119
138
  default:
120
139
  return <span>{String(props.value ?? '')}</span>;
121
140
  }
@@ -155,7 +174,9 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
155
174
  // Minor units, not a decimal: turning "12,34" into cents is currency- and locale-aware
156
175
  // work owned by @ultimat3/money, and the design system has no money input to host it.
157
176
  const currency = props.value?.currency ?? input.field.currency ?? '';
158
- return (
177
+ // The other half of the value, posted beside the amount: a known currency rides hidden, an
178
+ // unknown one is asked for — money is always both.
179
+ return [
159
180
  <Input
160
181
  {...shared}
161
182
  name={props.field}
@@ -167,8 +188,18 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
167
188
  const next = event.currentTarget.value;
168
189
  emit(input, next === '' ? null : { minor: Number(next), currency });
169
190
  }}
170
- />
171
- );
191
+ />,
192
+ currency === '' ? (
193
+ <Input
194
+ name={currencyFieldOf(props.field)}
195
+ maxlength={3}
196
+ placeholder={t('admin.value.currency')}
197
+ disabled={disabled}
198
+ />
199
+ ) : (
200
+ <input type="hidden" name={currencyFieldOf(props.field)} value={currency} />
201
+ ),
202
+ ];
172
203
  }
173
204
  case 'checkbox':
174
205
  return (
@@ -203,7 +234,7 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
203
234
  {...shared}
204
235
  name={props.field}
205
236
  type={props.precision === 'date' ? 'date' : 'datetime-local'}
206
- value={props.value === null ? '' : inputValueFor(props.value, props.precision)}
237
+ value={props.value === null ? '' : datetimeInputValue(props.value, props.precision)}
207
238
  suffix={props.precision === 'date' ? undefined : 'UTC'}
208
239
  disabled={disabled}
209
240
  onInput={(event) => emit(input, event.currentTarget.value)}
@@ -242,6 +273,43 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
242
273
  onInput={(event) => emit(input, event.currentTarget.value)}
243
274
  />
244
275
  );
276
+ case 'reference':
277
+ // A small target is a `<select>` of its rows, by label. A large one is the id in a text
278
+ // box, with its label and the target's lookup beside it — both work with scripting off.
279
+ if (props.options !== null) {
280
+ return (
281
+ <Select
282
+ {...shared}
283
+ name={props.field}
284
+ value={props.value ?? ''}
285
+ disabled={disabled}
286
+ options={[
287
+ { value: '', label: t('admin.value.empty') },
288
+ ...props.options.map((option) => ({ value: option.id, label: option.label })),
289
+ ]}
290
+ onChange={(event) => emit(input, event.currentTarget.value)}
291
+ />
292
+ );
293
+ }
294
+ return [
295
+ <Input
296
+ {...shared}
297
+ name={props.field}
298
+ value={props.value ?? ''}
299
+ disabled={disabled}
300
+ suffix={props.label ?? undefined}
301
+ onInput={(event) => emit(input, event.currentTarget.value)}
302
+ />,
303
+ props.lookupHref === null ? null : (
304
+ <a href={props.lookupHref} target="_blank" rel="noopener">
305
+ {t('admin.filter.find')}
306
+ </a>
307
+ ),
308
+ ];
309
+ case 'secret-input':
310
+ // Write-only: never a `value`, and `new-password` so a browser offers no saved credential
311
+ // of the operator's own. Empty on an edit means "unchanged" (`form-decode.ts`).
312
+ return <Input {...shared} name={props.field} type="password" autocomplete="new-password" />;
245
313
  default:
246
314
  return (
247
315
  <Input