@ultimat3/admin 22.14.0 → 23.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CLAUDE.md +73 -80
  2. package/README.md +276 -87
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +127 -28
  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 +92 -0
  20. package/src/batch.ts +431 -0
  21. package/src/catalog-keys.ts +55 -0
  22. package/src/crud-input.ts +104 -0
  23. package/src/crud-outcome.ts +166 -0
  24. package/src/crud.ts +129 -177
  25. package/src/describe.ts +131 -0
  26. package/src/detail.tsx +115 -43
  27. package/src/dev/data.ts +24 -6
  28. package/src/dev/facts.ts +27 -0
  29. package/src/dev/panel-db.ts +37 -5
  30. package/src/dev/panel-jobs.ts +8 -0
  31. package/src/dev/panel-routes.ts +10 -0
  32. package/src/dev/panel.ts +6 -0
  33. package/src/dev/server.ts +15 -7
  34. package/src/entity-columns.ts +24 -5
  35. package/src/errors.ts +83 -0
  36. package/src/fields.ts +63 -4
  37. package/src/form-decode.ts +91 -0
  38. package/src/form.tsx +78 -45
  39. package/src/index.ts +198 -9
  40. package/src/jobs/fleet-repo.ts +143 -0
  41. package/src/jobs/job-actions.ts +172 -0
  42. package/src/jobs/job-entities.ts +118 -0
  43. package/src/jobs/job-labels.ts +108 -0
  44. package/src/jobs/job-repo.ts +103 -0
  45. package/src/jobs/job-resources.ts +211 -0
  46. package/src/jobs/job-where.ts +170 -0
  47. package/src/jobs/operator.ts +34 -0
  48. package/src/jobs/overview-data.ts +129 -0
  49. package/src/jobs/overview-tab.tsx +61 -0
  50. package/src/jobs/overview.tsx +189 -0
  51. package/src/json-text.ts +52 -0
  52. package/src/layout.tsx +55 -43
  53. package/src/list-columns.tsx +53 -0
  54. package/src/list-filter-bar.tsx +207 -0
  55. package/src/list-filters.ts +217 -0
  56. package/src/list-request.ts +127 -0
  57. package/src/list-scope-tabs.tsx +60 -0
  58. package/src/list-scope.ts +113 -0
  59. package/src/list.tsx +111 -72
  60. package/src/mcp-tools.ts +57 -13
  61. package/src/mcp.ts +108 -38
  62. package/src/mounts.ts +44 -0
  63. package/src/page-guard.tsx +26 -29
  64. package/src/policy-bridge.ts +110 -15
  65. package/src/registry.ts +138 -8
  66. package/src/related.ts +105 -0
  67. package/src/relations.ts +224 -0
  68. package/src/renderers.tsx +89 -0
  69. package/src/repo-entity.ts +204 -0
  70. package/src/resource-fields.ts +116 -0
  71. package/src/resource-layout.ts +89 -0
  72. package/src/resource-list.ts +185 -0
  73. package/src/resource.ts +129 -79
  74. package/src/route-config.ts +71 -0
  75. package/src/routes.ts +55 -54
  76. package/src/row-scope-write.ts +130 -0
  77. package/src/screen-action.tsx +333 -0
  78. package/src/screen-frame.tsx +167 -0
  79. package/src/screen-home.tsx +201 -0
  80. package/src/screen-lookup.tsx +157 -0
  81. package/src/screen-resource.tsx +393 -0
  82. package/src/screen-system.tsx +69 -0
  83. package/src/screens.ts +56 -0
  84. package/src/search.ts +13 -9
  85. package/src/stylesheet-scope.ts +8 -0
  86. package/src/test-ctx.ts +31 -0
  87. package/src/validate.ts +3 -0
  88. package/src/widget-value.ts +36 -8
  89. package/src/widgets.tsx +84 -12
package/src/search.ts CHANGED
@@ -5,11 +5,14 @@
5
5
  import { finiteCount } from '@ultimat3/core';
6
6
  import { expectedQueryLoop } from '@ultimat3/db';
7
7
  import { type AuditEntry, deniedDraft } from './audit';
8
+ import type { AdminActor } from './authz';
8
9
  import { denied } from './authz';
9
10
  import type { CrudCtx } from './crud';
10
11
  import { decideOperation } from './crud';
11
- import type { AdminFilter, AdminRow } from './registry';
12
+ import { rowWhere } from './list-scope';
13
+ import type { AdminFilter } from './registry';
12
14
  import { rowId } from './registry';
15
+ import { labelOf } from './relations';
13
16
  import { type AdminResource, repoOf } from './resource';
14
17
 
15
18
  /** Per resource, and per field within it. Search is a jump box, not a report. */
@@ -61,10 +64,14 @@ export interface AdminSearchInput {
61
64
  */
62
65
  async function searchResource(
63
66
  resource: AdminResource,
67
+ actor: AdminActor,
64
68
  term: string,
65
69
  limit: number,
66
70
  ): Promise<readonly AdminSearchHit[]> {
67
71
  const repo = repoOf(resource);
72
+ // The resource's row scope rides every lookup: a hit is a row, and a row this actor's `rows`
73
+ // leaves out is not one they may be told exists.
74
+ const visible = rowWhere(resource, actor);
68
75
  const fields = resource.searchFields.slice(0, MAX_FIELDS_PER_RESOURCE);
69
76
  const seen = new Set<string>();
70
77
  const hits: AdminSearchHit[] = [];
@@ -73,7 +80,10 @@ async function searchResource(
73
80
  'admin search runs one indexed lookup per text field, which beats one unindexed OR',
74
81
  async () => {
75
82
  for (const field of fields) {
76
- const where: readonly AdminFilter[] = [{ field: field.name, op: 'contains', value: term }];
83
+ const where: readonly AdminFilter[] = [
84
+ ...visible,
85
+ { field: field.name, op: 'contains', value: term },
86
+ ];
77
87
  const rows = await repo.list({ where, sort: resource.defaultSort, limit });
78
88
  for (const row of rows) {
79
89
  const id = rowId(row, resource.idField);
@@ -94,12 +104,6 @@ async function searchResource(
94
104
  );
95
105
  }
96
106
 
97
- function labelOf(row: AdminRow, resource: AdminResource): string {
98
- const value = row[resource.labelField];
99
- if (typeof value === 'string' && value !== '') return value;
100
- return rowId(row, resource.idField);
101
- }
102
-
103
107
  const SKIPPED_FORBIDDEN = 'admin.search.skipped.forbidden';
104
108
 
105
109
  export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchResult> {
@@ -172,7 +176,7 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
172
176
  reason: decision.reason,
173
177
  }),
174
178
  );
175
- hits.push(...(await searchResource(resource, term, limit)));
179
+ hits.push(...(await searchResource(resource, ctx.actor, term, limit)));
176
180
  }
177
181
 
178
182
  return { term, hits, searched, skipped, audit };
@@ -0,0 +1,8 @@
1
+ // Every stylesheet of this package belongs to the `app/` surface. The admin's screens are served
2
+ // there and nowhere else, and a package sheet nobody claims rides BOTH surface bundles — so an
3
+ // app that declared an admin shipped the dashboard's CSS in every static `site/` document
4
+ // (axiom 6: the static path never pays for the app path). Imported for this one side effect.
5
+
6
+ import { claimStylesheets } from '@ultimat3/render/server';
7
+
8
+ claimStylesheets(import.meta.dir, 'app');
@@ -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 } from './form-decode';
19
21
  import { type WidgetContext, type WidgetProps, widgetProps } from './widget-value';
20
22
 
21
23
  export interface WidgetInput {
@@ -72,6 +74,17 @@ export const formatCalendarDate: DateTimeFormatter = (at, options) =>
72
74
  const inputValueFor = (iso: string, precision: 'date' | 'instant'): string =>
73
75
  iso.slice(0, precision === 'date' ? 10 : 16);
74
76
 
77
+ /**
78
+ * An enum value's label: its translation when the catalog has one, the value itself when it does
79
+ * not. The value is an identifier the ENTITY declares (`public`, `draft`), so showing it is true;
80
+ * a `⟦admin.posts.field.audience.option.public⟧` in every cell of a derived list is not, and the
81
+ * key is built at runtime, so no i18n gate could have asked the app for it.
82
+ */
83
+ export const optionLabel = (field: AdminField, value: string): string => {
84
+ const label = t(`${field.labelKey}.option.${value}`);
85
+ return isMiss(label) ? value : label;
86
+ };
87
+
75
88
  /** Read-mode rendering of already-guarded props. Never formats; the widgets do that. */
76
89
  function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JSX.Element {
77
90
  switch (props.widget) {
@@ -79,7 +92,9 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
79
92
  return props.value === null ? (
80
93
  <span>{t('admin.value.empty')}</span>
81
94
  ) : (
82
- <Money value={props.value} />
95
+ // The ACTOR's locale, handed over: ui's own default is the document's, and an operator
96
+ // whose dashboard is otherwise in their language read every amount and date in another.
97
+ <Money value={props.value} locale={ctx.locale} />
83
98
  );
84
99
  case 'datetime':
85
100
  return props.value === null ? (
@@ -87,6 +102,7 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
87
102
  ) : (
88
103
  <DateTime
89
104
  value={props.value}
105
+ locale={ctx.locale}
90
106
  timeZone={props.timeZone}
91
107
  format={props.precision === 'date' ? formatCalendarDate : undefined}
92
108
  />
@@ -94,21 +110,25 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
94
110
  case 'checkbox':
95
111
  return <span>{t(props.value ? 'admin.value.true' : 'admin.value.false')}</span>;
96
112
  case 'select':
97
- return (
98
- <span>
99
- {props.value === null
100
- ? t('admin.value.empty')
101
- : t(`${field.labelKey}.option.${props.value}`)}
102
- </span>
113
+ // A value from a closed set is a state, and a state reads as a badge. Only an enum: a
114
+ // locale or a zone is an identifier, and stays text.
115
+ if (props.value === null) return <span>{t('admin.value.empty')}</span>;
116
+ return field.type === 'enum' ? (
117
+ <Badge>{optionLabel(field, props.value)}</Badge>
118
+ ) : (
119
+ <span>{optionLabel(field, props.value)}</span>
103
120
  );
104
121
  case 'json-editor':
105
122
  return <pre class="x-admin-json">{props.value}</pre>;
106
123
  case 'reference': {
107
124
  if (props.value === null) return <span>{t('admin.value.empty')}</span>;
125
+ // The target's label when the page read it, the id when it did not — a row this actor may
126
+ // not see keeps its id and gains no name.
127
+ const shown = props.label ?? props.value;
108
128
  // `ctx.hrefFor` or nothing. The route table lives on `AdminApp`; this file only ever knew
109
129
  // the target entity's NAME, and turning that into a URL by appending an `s` is a guess.
110
130
  const href = ctx.hrefFor?.(props.entity, props.value) ?? null;
111
- return href === null ? <span>{props.value}</span> : <a href={href}>{props.value}</a>;
131
+ return href === null ? <span>{shown}</span> : <a href={href}>{shown}</a>;
112
132
  }
113
133
  case 'upload':
114
134
  return props.value === null ? (
@@ -116,6 +136,9 @@ function readView(props: WidgetProps, field: AdminField, ctx: WidgetContext): JS
116
136
  ) : (
117
137
  <a href={safeUrl(props.value.url, 'href') ?? undefined}>{props.value.name}</a>
118
138
  );
139
+ case 'secret-input':
140
+ // Nothing to show, by type: `WidgetProps` carries no value for a sealed column.
141
+ return <span>{t('admin.value.sealed')}</span>;
119
142
  default:
120
143
  return <span>{String(props.value ?? '')}</span>;
121
144
  }
@@ -155,7 +178,9 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
155
178
  // Minor units, not a decimal: turning "12,34" into cents is currency- and locale-aware
156
179
  // work owned by @ultimat3/money, and the design system has no money input to host it.
157
180
  const currency = props.value?.currency ?? input.field.currency ?? '';
158
- return (
181
+ // The other half of the value, posted beside the amount: a known currency rides hidden, an
182
+ // unknown one is asked for — money is always both.
183
+ return [
159
184
  <Input
160
185
  {...shared}
161
186
  name={props.field}
@@ -167,8 +192,18 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
167
192
  const next = event.currentTarget.value;
168
193
  emit(input, next === '' ? null : { minor: Number(next), currency });
169
194
  }}
170
- />
171
- );
195
+ />,
196
+ currency === '' ? (
197
+ <Input
198
+ name={currencyFieldOf(props.field)}
199
+ maxlength={3}
200
+ placeholder={t('admin.value.currency')}
201
+ disabled={disabled}
202
+ />
203
+ ) : (
204
+ <input type="hidden" name={currencyFieldOf(props.field)} value={currency} />
205
+ ),
206
+ ];
172
207
  }
173
208
  case 'checkbox':
174
209
  return (
@@ -242,6 +277,43 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
242
277
  onInput={(event) => emit(input, event.currentTarget.value)}
243
278
  />
244
279
  );
280
+ case 'reference':
281
+ // A small target is a `<select>` of its rows, by label. A large one is the id in a text
282
+ // box, with its label and the target's lookup beside it — both work with scripting off.
283
+ if (props.options !== null) {
284
+ return (
285
+ <Select
286
+ {...shared}
287
+ name={props.field}
288
+ value={props.value ?? ''}
289
+ disabled={disabled}
290
+ options={[
291
+ { value: '', label: t('admin.value.empty') },
292
+ ...props.options.map((option) => ({ value: option.id, label: option.label })),
293
+ ]}
294
+ onChange={(event) => emit(input, event.currentTarget.value)}
295
+ />
296
+ );
297
+ }
298
+ return [
299
+ <Input
300
+ {...shared}
301
+ name={props.field}
302
+ value={props.value ?? ''}
303
+ disabled={disabled}
304
+ suffix={props.label ?? undefined}
305
+ onInput={(event) => emit(input, event.currentTarget.value)}
306
+ />,
307
+ props.lookupHref === null ? null : (
308
+ <a href={props.lookupHref} target="_blank" rel="noopener">
309
+ {t('admin.filter.find')}
310
+ </a>
311
+ ),
312
+ ];
313
+ case 'secret-input':
314
+ // Write-only: never a `value`, and `new-password` so a browser offers no saved credential
315
+ // of the operator's own. Empty on an edit means "unchanged" (`form-decode.ts`).
316
+ return <Input {...shared} name={props.field} type="password" autocomplete="new-password" />;
245
317
  default:
246
318
  return (
247
319
  <Input