@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,441 @@
1
+ // The four generated screens of one resource — list, detail, create, edit — and the writes each
2
+ // one's forms post back at it. Every one asks `crud.ts` (or the action gate) FIRST and renders
3
+ // what it answered: a row-level rule sees the row, a refusal is audited by the gate that made it,
4
+ // and no screen holds a decision of its own.
5
+
6
+ import { t } from '@ultimat3/i18n';
7
+ import { ErrorState, Link } from '@ultimat3/ui';
8
+ import type { JSX } from 'solid-js';
9
+ import {
10
+ ACTION_OPERATION,
11
+ ACTION_PARAM,
12
+ BATCH_OPERATION,
13
+ DELETE_OPERATION,
14
+ OPERATION_FIELD,
15
+ } from './actions';
16
+ import type { AdminApp, AdminRoute } from './admin';
17
+ import styles from './admin.module.scss';
18
+ import { auditCursorOf } from './audit';
19
+ import {
20
+ adminCreate,
21
+ adminDestroy,
22
+ adminDetail,
23
+ adminList,
24
+ adminUpdate,
25
+ canOperate,
26
+ decideOperation,
27
+ } from './crud';
28
+ import { AdminDetail, HISTORY_PARAM, historyCursorOf, historyCursorText } from './detail';
29
+ import { AdminFilterInvalidError } from './errors';
30
+ import { AdminForm } from './form';
31
+ import { decodeForm, posted } from './form-decode';
32
+ import { AdminList } from './list';
33
+ import { type ListLocation, listHref, pageRequestOf } from './list-request';
34
+ import { type AdminListRequest, findRow, scopeCounts } from './list-scope';
35
+ import type { AdminRow } from './registry';
36
+ import { relatedLists, relatedOf } from './related';
37
+ import { type RelationNeed, relationNeeds, relationsFor } from './relations';
38
+ import type { AdminResource } from './resource';
39
+ import { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
40
+ import { batchWrite, rowActionFormScreen, rowActionWrite } from './screen-action';
41
+ import {
42
+ type AdminRouteRequest,
43
+ type AdminRouteResponse,
44
+ type AdminScreen,
45
+ framed,
46
+ missing,
47
+ refused,
48
+ refusedPage,
49
+ widgetContextOf,
50
+ } from './screen-frame';
51
+ import type { ValidationIssue } from './validate';
52
+ import type { WidgetContext } from './widget-value';
53
+
54
+ const rowHref = (app: AdminApp, resource: AdminResource, id: string): string =>
55
+ `${app.basePath}${resource.path}/${id}`;
56
+
57
+ const idOf = (request: AdminRouteRequest): string => request.params['id'] ?? '';
58
+
59
+ const notFound = (
60
+ app: AdminApp,
61
+ resource: AdminResource,
62
+ request: AdminRouteRequest,
63
+ ): AdminRouteResponse =>
64
+ missing(
65
+ app,
66
+ request,
67
+ resource.titleKey,
68
+ <AdminDetail
69
+ resource={resource}
70
+ row={null}
71
+ error={null}
72
+ ctx={widgetContextOf(app, request.ctx)}
73
+ actor={request.ctx.actor}
74
+ authz={request.ctx.authz}
75
+ audit={[]}
76
+ basePath={app.basePath}
77
+ />,
78
+ );
79
+
80
+ export function listScreen(app: AdminApp, resource: AdminResource): AdminScreen {
81
+ const hrefFor = (location: ListLocation): string => listHref(app.basePath, resource, location);
82
+ return async (request) => {
83
+ // A batch is posted at the list's own URL: its query string is what "all matching" means.
84
+ if (request.method === 'POST') {
85
+ const form = request.form ?? {};
86
+ if (posted(form, OPERATION_FIELD) !== BATCH_OPERATION)
87
+ return notFound(app, resource, request);
88
+ try {
89
+ return await batchWrite(app, request, resource, form, () =>
90
+ notFound(app, resource, request),
91
+ );
92
+ } catch (error) {
93
+ if (!(error instanceof AdminFilterInvalidError)) throw error;
94
+ return framed(app, request, resource.titleKey, <ErrorState error={error} />, 400);
95
+ }
96
+ }
97
+ // Refused BEFORE the URL is read: an actor who may not list the table learns nothing about
98
+ // its filters or scopes from the answer. `adminList` makes and audits the refusal.
99
+ if (!canOperate(resource, 'list', request.ctx)) {
100
+ const denied = await adminList<AdminRow>(resource, request.ctx);
101
+ if (!denied.ok) return refused(app, request, resource.titleKey, denied.decision);
102
+ }
103
+ let asked: AdminListRequest;
104
+ let result: Awaited<ReturnType<typeof adminList<AdminRow>>>;
105
+ try {
106
+ asked = pageRequestOf(resource, new URL(request.url));
107
+ // Inside the same refusal: a repo that cannot answer a predicate the URL grammar allows (a
108
+ // store with one order, a filter it has no column for) refuses it by name too.
109
+ result = await adminList<AdminRow>(resource, request.ctx, asked);
110
+ } catch (error) {
111
+ if (!(error instanceof AdminFilterInvalidError)) throw error;
112
+ // 400, naming what the list does answer — and the way back to it.
113
+ return framed(
114
+ app,
115
+ request,
116
+ resource.titleKey,
117
+ <>
118
+ <ErrorState error={error} />
119
+ <Link href={hrefFor({})}>{t('admin.filter.clear')}</Link>
120
+ </>,
121
+ 400,
122
+ );
123
+ }
124
+ if (!result.ok) return refused(app, request, resource.titleKey, result.decision);
125
+
126
+ // Everything else the page shows, read beside each other: one count per scope that asked for
127
+ // one, and one read per resource the page references — never one per row.
128
+ const [counts, relations] = await Promise.all([
129
+ scopeCounts(resource, request.ctx.actor),
130
+ relationsFor(
131
+ app.resources,
132
+ request.ctx,
133
+ relationNeeds(result.page.rows, resource.listFields, resource.filters),
134
+ ),
135
+ ]);
136
+
137
+ return framed(
138
+ app,
139
+ request,
140
+ resource.titleKey,
141
+ <>
142
+ {canOperate(resource, 'create', request.ctx) ? (
143
+ <div class={styles['toolbar']}>
144
+ <Link appearance="button" href={`${hrefFor({})}/new`}>
145
+ {t('admin.form.create', { entity: t(resource.titleKey) })}
146
+ </Link>
147
+ </div>
148
+ ) : null}
149
+ <AdminList
150
+ resource={resource}
151
+ page={result.page}
152
+ error={null}
153
+ ctx={widgetContextOf(app, request.ctx, relations)}
154
+ actor={request.ctx.actor}
155
+ authz={request.ctx.authz}
156
+ basePath={app.basePath}
157
+ request={asked}
158
+ scope={result.scope}
159
+ counts={counts}
160
+ hrefFor={hrefFor}
161
+ />
162
+ </>,
163
+ );
164
+ };
165
+ }
166
+
167
+ /** The labels, option lists and lookups one row's reference fields need, for a detail or a form. */
168
+ const relationsOf = (
169
+ app: AdminApp,
170
+ resource: AdminResource,
171
+ request: AdminRouteRequest,
172
+ row: AdminRow,
173
+ picking: boolean,
174
+ ): Promise<WidgetContext> =>
175
+ relationsFor(
176
+ app.resources,
177
+ request.ctx,
178
+ relationNeeds([row], resource.fields, picking ? resource.formFields : []),
179
+ ).then((relations) => widgetContextOf(app, request.ctx, relations));
180
+
181
+ /** A posted action or delete, decided by the same gate that decided whether its button rendered. */
182
+ async function detailWrite(
183
+ app: AdminApp,
184
+ resource: AdminResource,
185
+ request: AdminRouteRequest,
186
+ form: Readonly<Record<string, unknown>>,
187
+ ): Promise<AdminRouteResponse> {
188
+ const id = idOf(request);
189
+ const confirmation = posted(form, 'confirmation');
190
+ const landing = listHref(app.basePath, resource);
191
+
192
+ if (posted(form, OPERATION_FIELD) === DELETE_OPERATION) {
193
+ const result = await adminDestroy(resource, request.ctx, id, confirmation);
194
+ if (result.ok) return { kind: 'redirect', location: landing };
195
+ if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
196
+ return notFound(app, resource, request);
197
+ }
198
+
199
+ if (posted(form, OPERATION_FIELD) !== ACTION_OPERATION) return notFound(app, resource, request);
200
+ return rowActionWrite(app, request, resource, form, () => notFound(app, resource, request));
201
+ }
202
+
203
+ /** How many entries of a row's history one detail page shows. Older ones are a link away. */
204
+ const HISTORY_PAGE = 20;
205
+
206
+ /** Two pages' needs as one: each target is still read once for the whole page. */
207
+ const mergedNeeds = (
208
+ all: readonly ReadonlyMap<string, RelationNeed>[],
209
+ ): ReadonlyMap<string, RelationNeed> => {
210
+ const out = new Map<string, { ids: Set<string>; pick: boolean }>();
211
+ for (const needs of all) {
212
+ for (const [entity, need] of needs) {
213
+ const held = out.get(entity) ?? { ids: new Set<string>(), pick: false };
214
+ for (const id of need.ids) held.ids.add(id);
215
+ held.pick ||= need.pick;
216
+ out.set(entity, held);
217
+ }
218
+ }
219
+ return out;
220
+ };
221
+
222
+ export function detailScreen(app: AdminApp, resource: AdminResource): AdminScreen {
223
+ return async (request) => {
224
+ if (request.method === 'POST') {
225
+ return detailWrite(app, resource, request, request.form ?? {});
226
+ }
227
+ const id = idOf(request);
228
+ const url = new URL(request.url);
229
+ const asked = url.searchParams.get(ACTION_PARAM);
230
+ if (asked !== null) {
231
+ return rowActionFormScreen(app, request, resource, asked, () =>
232
+ notFound(app, resource, request),
233
+ );
234
+ }
235
+ const result = await adminDetail<AdminRow>(resource, request.ctx, id);
236
+ if (!result.ok) {
237
+ return result.kind === 'denied'
238
+ ? refused(app, request, resource.titleKey, result.decision)
239
+ : notFound(app, resource, request);
240
+ }
241
+ if (result.row === null) return notFound(app, resource, request);
242
+ const row = result.row;
243
+
244
+ // Everything else the page shows, read beside each other: the related resources' own lists,
245
+ // and one keyset page of this row's history — what changed it or was refused, never who read.
246
+ const before = historyCursorOf(url.searchParams.get(HISTORY_PARAM));
247
+ const [related, history] = await Promise.all([
248
+ relatedLists(relatedOf(app.resources, resource), row, request.ctx),
249
+ request.ctx.audit.entries({
250
+ entity: resource.name,
251
+ entityId: id,
252
+ changes: true,
253
+ limit: HISTORY_PAGE,
254
+ // The actor's tenant's entries only, as on the audit screen — a row two orgs may open
255
+ // does not show one org who in the other changed it.
256
+ ...(request.ctx.actor.orgId === undefined ? {} : { orgId: request.ctx.actor.orgId }),
257
+ ...(before === undefined ? {} : { before }),
258
+ }),
259
+ ]);
260
+ // ONE label read per referenced target for the whole page — this row's references and every
261
+ // related row's together.
262
+ const relations = await relationsFor(
263
+ app.resources,
264
+ request.ctx,
265
+ mergedNeeds([
266
+ relationNeeds([row], resource.fields, []),
267
+ ...related.map((list) =>
268
+ relationNeeds(list.page.rows, list.related.resource.listFields, []),
269
+ ),
270
+ ]),
271
+ );
272
+ const older = auditCursorOf(history);
273
+ const self = rowHref(app, resource, id);
274
+
275
+ return framed(
276
+ app,
277
+ request,
278
+ resource.titleKey,
279
+ <AdminDetail
280
+ resource={resource}
281
+ row={row}
282
+ error={null}
283
+ ctx={widgetContextOf(app, request.ctx, relations)}
284
+ actor={request.ctx.actor}
285
+ authz={request.ctx.authz}
286
+ audit={history}
287
+ olderHref={
288
+ history.length < HISTORY_PAGE || older === null
289
+ ? null
290
+ : `${self}?${HISTORY_PARAM}=${encodeURIComponent(historyCursorText(older))}`
291
+ }
292
+ related={related}
293
+ relatedHref={(list) =>
294
+ // Through the target's own list grammar — only when the key is one of its filters, or
295
+ // the link would be the unfiltered list.
296
+ list.related.resource.filters.some((field) => field.name === list.filter.field)
297
+ ? listHref(app.basePath, list.related.resource, { scope: null, filters: [list.filter] })
298
+ : listHref(app.basePath, list.related.resource)
299
+ }
300
+ basePath={app.basePath}
301
+ may={{
302
+ update: decideOperation(resource, 'update', request.ctx, id, row).allowed,
303
+ delete: decideOperation(resource, 'delete', request.ctx, id, row).allowed,
304
+ }}
305
+ />,
306
+ );
307
+ };
308
+ }
309
+
310
+ async function form(
311
+ app: AdminApp,
312
+ resource: AdminResource,
313
+ request: AdminRouteRequest,
314
+ mode: 'create' | 'edit',
315
+ values: Readonly<Record<string, unknown>>,
316
+ issues: readonly ValidationIssue[],
317
+ version: string | null,
318
+ ): Promise<JSX.Element> {
319
+ const list = listHref(app.basePath, resource);
320
+ return (
321
+ <AdminForm
322
+ resource={resource}
323
+ mode={mode}
324
+ values={values}
325
+ issues={issues}
326
+ error={null}
327
+ // A reference input picks from its target: the option list of a small one, read once.
328
+ ctx={await relationsOf(app, resource, request, values, true)}
329
+ action={new URL(request.url).pathname}
330
+ cancelHref={mode === 'create' ? list : rowHref(app, resource, idOf(request))}
331
+ version={version}
332
+ />
333
+ );
334
+ }
335
+
336
+ /** The form-level issue a write against a stale version is re-rendered with. */
337
+ const ROW_CHANGED_ISSUE: ValidationIssue = {
338
+ path: VERSION_FIELD,
339
+ message: 'the row was changed after this form was opened',
340
+ messageKey: ROW_CHANGED_REASON,
341
+ };
342
+
343
+ export function createScreen(
344
+ app: AdminApp,
345
+ route: AdminRoute,
346
+ resource: AdminResource,
347
+ ): AdminScreen {
348
+ return async (request) => {
349
+ if (request.method === 'GET') {
350
+ const decision = decideOperation(resource, 'create', request.ctx);
351
+ if (!decision.allowed) return refusedPage(app, request, route, decision);
352
+ return framed(
353
+ app,
354
+ request,
355
+ resource.titleKey,
356
+ await form(app, resource, request, 'create', {}, [], null),
357
+ );
358
+ }
359
+ const input = decodeForm(resource, request.form ?? {});
360
+ const result = await adminCreate<AdminRow>(resource, request.ctx, input);
361
+ if (result.ok) {
362
+ const id = String(result.row?.[resource.idField] ?? '');
363
+ return { kind: 'redirect', location: rowHref(app, resource, id) };
364
+ }
365
+ if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
366
+ // A create has no row to have gone missing or stale: neither is an answer it can give.
367
+ if (result.kind !== 'invalid') return notFound(app, resource, request);
368
+ // 422, with what was typed: a refused form that came back empty is a form filled in twice.
369
+ return framed(
370
+ app,
371
+ request,
372
+ resource.titleKey,
373
+ await form(app, resource, request, 'create', input, result.issues, null),
374
+ 422,
375
+ );
376
+ };
377
+ }
378
+
379
+ export function editScreen(app: AdminApp, route: AdminRoute, resource: AdminResource): AdminScreen {
380
+ return async (request) => {
381
+ const id = idOf(request);
382
+ const row = await findRow(resource, request.ctx.actor, id);
383
+ if (request.method === 'GET') {
384
+ const decision = decideOperation(resource, 'update', request.ctx, id, row);
385
+ if (!decision.allowed) return refusedPage(app, request, route, decision);
386
+ if (row === null) return notFound(app, resource, request);
387
+ return framed(
388
+ app,
389
+ request,
390
+ resource.titleKey,
391
+ await form(app, resource, request, 'edit', row, [], rowVersion(row)),
392
+ );
393
+ }
394
+ const submitted = request.form ?? {};
395
+ // Decoded against the row as it is: an instant posted back as the minute it was drawn at is
396
+ // left out, not rewritten to that minute. A row that moved on since the form was rendered is
397
+ // refused below on its version, so "as it is" and "as it was drawn" are the same row here.
398
+ const input = decodeForm(resource, submitted, row ?? undefined);
399
+ // A post with no version is not a form this admin rendered: it is refused as stale, never run
400
+ // as an unversioned write — the version is how the form says which row it was looking at.
401
+ const version = posted(submitted, VERSION_FIELD) ?? '';
402
+ const result = await adminUpdate<AdminRow>(resource, request.ctx, id, input, { version });
403
+ if (result.ok) return { kind: 'redirect', location: rowHref(app, resource, id) };
404
+ if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
405
+ if (result.kind === 'missing') return notFound(app, resource, request);
406
+ if (result.kind === 'stale') {
407
+ // What they typed, over the row as it is NOW, under its current version: saving again is a
408
+ // decision taken with the other edit on screen, not an accident.
409
+ return framed(
410
+ app,
411
+ request,
412
+ resource.titleKey,
413
+ await form(
414
+ app,
415
+ resource,
416
+ request,
417
+ 'edit',
418
+ { ...result.row, ...input },
419
+ [ROW_CHANGED_ISSUE],
420
+ result.version,
421
+ ),
422
+ 409,
423
+ );
424
+ }
425
+ return framed(
426
+ app,
427
+ request,
428
+ resource.titleKey,
429
+ await form(
430
+ app,
431
+ resource,
432
+ request,
433
+ 'edit',
434
+ { ...(row ?? {}), ...input },
435
+ result.issues,
436
+ version,
437
+ ),
438
+ 422,
439
+ );
440
+ };
441
+ }
@@ -0,0 +1,75 @@
1
+ // The two built-in screens that are neither a resource nor the jobs dashboard (`jobs/`): the
2
+ // audit trail and cross-entity search. Each is decided by its own route's permission pair through
3
+ // `decideAll` — the same gate a custom page gets — before anything is read.
4
+
5
+ import { t } from '@ultimat3/i18n';
6
+ import { Card } from '@ultimat3/ui';
7
+ import type { AdminApp, AdminRoute } from './admin';
8
+ import styles from './admin.module.scss';
9
+ import { operationLabel } from './detail';
10
+ import { type AdminScreen, guardedScreen } from './screen-frame';
11
+ import { adminSearch } from './search';
12
+
13
+ /** How many audit entries one screen shows — newest first, from whichever log the admin names. */
14
+ const AUDIT_PAGE = 100;
15
+
16
+ export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
17
+ return guardedScreen(app, route, async (request) => {
18
+ // The actor's tenant's trail: `audit:read` held in org A is not a read of org B's operators,
19
+ // their ids or what they touched. An actor with no org — the platform — reads every tenant's.
20
+ const orgId = request.ctx.actor.orgId;
21
+ const entries = await app.audit.entries({
22
+ limit: AUDIT_PAGE,
23
+ ...(orgId === undefined ? {} : { orgId }),
24
+ });
25
+ return (
26
+ <Card>
27
+ {entries.length === 0 ? (
28
+ <p class="x-admin-empty">{t('admin.audit.empty')}</p>
29
+ ) : (
30
+ <ol class={styles['list']}>
31
+ {entries.map((entry) => (
32
+ <li>
33
+ <code>{entry.at}</code> <span>{entry.actor.id}</span>{' '}
34
+ <span>{operationLabel(entry)}</span>{' '}
35
+ <span class={styles['mono']}>{entry.entity}</span>{' '}
36
+ <span data-outcome={entry.outcome}>
37
+ {t(`admin.audit.outcome.${entry.outcome}`)}
38
+ </span>
39
+ </li>
40
+ ))}
41
+ </ol>
42
+ )}
43
+ </Card>
44
+ );
45
+ });
46
+ }
47
+
48
+ /** `?term=` — the shell's search box is a native GET at this route. */
49
+ export function searchScreen(app: AdminApp, route: AdminRoute): AdminScreen {
50
+ return guardedScreen(app, route, async (request) => {
51
+ const term = new URL(request.url).searchParams.get('term') ?? '';
52
+ const result = await adminSearch({ term, resources: app.resources, ctx: request.ctx });
53
+ return (
54
+ <Card>
55
+ {result.hits.length === 0 ? (
56
+ <p class="x-admin-empty">{t('admin.list.empty')}</p>
57
+ ) : (
58
+ <ul class={styles['list']}>
59
+ {result.hits.map((hit) => (
60
+ <li>
61
+ <a href={`${app.basePath}${hit.href}`}>{hit.label}</a>{' '}
62
+ <span class={styles['mono']}>{hit.entity}</span>
63
+ </li>
64
+ ))}
65
+ </ul>
66
+ )}
67
+ {result.skipped.map((skip) => (
68
+ <p class={styles['note']}>
69
+ <span class={styles['mono']}>{skip.entity}</span> {t(skip.reason)}
70
+ </p>
71
+ ))}
72
+ </Card>
73
+ );
74
+ });
75
+ }
package/src/screens.ts ADDED
@@ -0,0 +1,56 @@
1
+ // View → screen: the one table that says what renders at each route of the admin. A route whose
2
+ // view has no row here does not compile, so a new `AdminView` cannot mount with nothing behind it.
3
+
4
+ import type { AdminApp, AdminRoute, AdminView } from './admin';
5
+ import { AdminPagePathInvalidError } from './errors';
6
+ import { jobsOverviewScreen } from './jobs/overview';
7
+ import { type AdminScreen, guardedScreen } from './screen-frame';
8
+ import { homeScreen } from './screen-home';
9
+ import { lookupScreen } from './screen-lookup';
10
+ import { createScreen, detailScreen, editScreen, listScreen } from './screen-resource';
11
+ import { auditScreen, searchScreen } from './screen-system';
12
+
13
+ type Build = (app: AdminApp, route: AdminRoute) => AdminScreen;
14
+
15
+ /** A resource view names its entity; the table and `defineAdmin` are built from the same list. */
16
+ const resourceOf = (app: AdminApp, route: AdminRoute) => app.resource(route.entity ?? '');
17
+
18
+ const SCREENS: Readonly<Record<AdminView, Build>> = {
19
+ dashboard: homeScreen,
20
+ search: searchScreen,
21
+ jobs: jobsOverviewScreen,
22
+ audit: auditScreen,
23
+ list: (app, route) => listScreen(app, resourceOf(app, route)),
24
+ lookup: (app, route) => lookupScreen(app, resourceOf(app, route)),
25
+ detail: (app, route) => detailScreen(app, resourceOf(app, route)),
26
+ create: (app, route) => createScreen(app, route, resourceOf(app, route)),
27
+ edit: (app, route) => editScreen(app, route, resourceOf(app, route)),
28
+ page: (app, route) => {
29
+ const component = route.component;
30
+ // `pages.ts` sets it on every `view: 'page'` it builds; a hand-made route without one is a
31
+ // path with nothing behind it, refused where it would have been mounted.
32
+ if (component === undefined) {
33
+ throw new AdminPagePathInvalidError({
34
+ path: route.path,
35
+ cause: 'is a page route with no component',
36
+ fix: 'declare it in `pages:` on defineAdmin() — { path, titleKey, permissions, component }',
37
+ });
38
+ }
39
+ return guardedScreen(app, route, (request) => component(request));
40
+ },
41
+ };
42
+
43
+ /** The same table as a Map: a view that is not one of the nine has no screen, not a prototype's. */
44
+ const SCREEN_BY_VIEW: ReadonlyMap<string, Build> = new Map(Object.entries(SCREENS));
45
+
46
+ export function screenFor(app: AdminApp, route: AdminRoute): AdminScreen {
47
+ const build = SCREEN_BY_VIEW.get(route.view);
48
+ if (build === undefined) {
49
+ throw new AdminPagePathInvalidError({
50
+ path: route.path,
51
+ cause: `has the view "${route.view}", which no screen renders`,
52
+ fix: 'declare the screen in `pages:` on defineAdmin() — { path, titleKey, permissions, component }',
53
+ });
54
+ }
55
+ return build(app, route);
56
+ }
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,13 +104,8 @@ 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';
108
+ const SKIPPED_FAILED = 'admin.search.skipped.failed';
104
109
 
105
110
  export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchResult> {
106
111
  const { ctx } = input;
@@ -156,7 +161,6 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
156
161
  skipped.push({ entity: resource.name, reason: 'admin.search.skipped.no-repo' });
157
162
  continue;
158
163
  }
159
- searched.push(resource.name);
160
164
  // Appended BEFORE the read, so a repo that throws still leaves the record that the rows were
161
165
  // asked for — `audit.ts`: if it isn't logged, it didn't happen.
162
166
  audit.push(
@@ -172,7 +176,15 @@ 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
+ try {
180
+ hits.push(...(await searchResource(resource, ctx.actor, term, limit)));
181
+ searched.push(resource.name);
182
+ } catch {
183
+ // One resource whose repo fails is a resource this search could not read — listed, so the
184
+ // operator is never silently shown a subset — never a 500 for every other one. Nothing is
185
+ // read off the thrown value: a reason is a key.
186
+ skipped.push({ entity: resource.name, reason: SKIPPED_FAILED });
187
+ }
176
188
  }
177
189
 
178
190
  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');