@ultimat3/admin 29.3.2 → 30.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.
- package/CLAUDE.md +7 -6
- package/README.md +36 -2
- package/package.json +29 -18
- package/src/audit-pg.ts +7 -1
- package/src/audit-request.ts +129 -0
- package/src/audit-schema.ts +7 -3
- package/src/audit.ts +61 -9
- package/src/crud-input.ts +9 -7
- package/src/detail.tsx +39 -30
- package/src/dev/data.ts +9 -0
- package/src/dev/facts.ts +25 -0
- package/src/dev/index.ts +2 -0
- package/src/dev/panel-mail.ts +29 -4
- package/src/error-groups-pages.ts +50 -0
- package/src/error-groups-pg.ts +163 -0
- package/src/error-groups-schema.ts +36 -0
- package/src/error-groups-sink.ts +181 -0
- package/src/error-groups-store.ts +173 -0
- package/src/error-groups.ts +24 -0
- package/src/errors-detail.tsx +60 -0
- package/src/errors-list.tsx +74 -0
- package/src/errors.ts +7 -1
- package/src/index.ts +12 -1
- package/src/list-filters.ts +14 -10
- package/src/mcp-tools.ts +2 -1
- package/src/registry.ts +5 -0
- package/src/resource-operations.ts +36 -0
- package/src/resource.ts +3 -2
- package/src/screen-resource.tsx +2 -1
- package/src/screen-system.tsx +136 -14
package/CLAUDE.md
CHANGED
|
@@ -11,10 +11,11 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
11
11
|
- **The MCP caller is the ambient actor for the whole call, tenant included.** `mcp.ts` wraps `callAdminTool` in a child context (a fresh root one over stdio); `resolveToken` and `adminActorOf` both carry `orgId`. Pinned in `mcp-context.test.ts`, `mcp-tenant.test.ts`.
|
|
12
12
|
- **`defineAdmin({ entities, db })` is a served admin; a host writes nothing.** `routes.ts` gives EVERY route a screen (`screens.ts` — a `Record<AdminView, …>`, so a new view cannot mount with nothing behind it) and `AdminRouteConfig.respond(request)` is the only way in: GET or a posted form in, `{ status, body }` or a 303 out. `repo-entity.ts` is the ONE adapter from the typed handle to `AdminRepo`; it adds no predicate (tenancy, soft delete, sealing and invariants are the handle's) and never spreads a row. A resource with neither a table in `db` nor a `repo:` is `X_ADMIN_REPO_UNBOUND` at `defineAdmin`. Pinned in `mounted-screens.test.ts`, `repo-entity.test.ts`, `admin-bind.test.ts`.
|
|
13
13
|
- **A screen's guard is composed, never written.** A resource screen asks `crud.ts` FIRST and renders what it answered — only it holds the row a row-level rule reads. Every other screen (a `pages:` entry, dashboard, jobs, audit, search) is built through `guardedScreen()` (`screen-frame.tsx`): `decideAll` over the route's permissions, the refusal audited (`auditRefusal`), answered 403. `pages.ts` composes `[admin:read, …declared]`; `AdminPageProps.ctx` is required by the type; an empty permission list is `X_ADMIN_PAGE_UNGUARDED` at `defineAdmin`.
|
|
14
|
-
- **No screen ships a script; an app file that CLAIMED its route may** (`claimAdminRoute`: a `pages:` entry or the dashboard; `load` = `screenFor`, `frame: 'none'`). Only a config `claims.ts` issued for that path passes `claimable`, else `X_ROUTE_DUPLICATE`. Every admin route is `ssr` + `hydrate: 'never'`, so a control is a link or a native form
|
|
14
|
+
- **No screen ships a script; an app file that CLAIMED its route may** (`claimAdminRoute`: a `pages:` entry or the dashboard; `load` = `screenFor`, `frame: 'none'`). Only a config `claims.ts` issued for that path passes `claimable`, else `X_ROUTE_DUPLICATE`. Every admin route is `ssr` + `hydrate: 'never'`, so a control is a link or a native form ([which](../../docs/history/admin.md#moved-out-2026-11-03-plan-101-wave-3-the-claudemd-ceiling)).
|
|
15
15
|
- **A list's state is its URL, and a parameter the resource does not derive is REFUSED.** `list-request.ts` is the one parser and builder — `?cursor=`, `?sort=<field>:<asc|desc>`, `?scope=<name>` (`*` = none, not even the default; undeclarable), `?f.<field>[.<op>]=<value>` (repeat for `in`; empty = absent). `list-filters.ts` owns the operators per filter shape and the ONE validator (`checkedFilter`) the URL and the MCP list tool's `where` both pass. Unknown field, operator, sort, scope or parameter, or a value that is not the column's type: `X_ADMIN_FILTER_INVALID`, a 400 naming what the list answers — never the unfiltered list.
|
|
16
|
+
- **The audit trail's state is its URL, and a parameter it does not read is REFUSED.** `audit-request.ts` is the one parser and builder (`?entity= ?actor= ?outcome= ?since= ?until= ?changes=1 ?before=`); `orgId` is the actor's, so `?org=` is a 400. The screen re-checks each entry with `auditEntryMatches`: a custom log that ignores a field narrows a page, never widens it. Audit diffs mask `maskedFieldsOf(resource)` (`detail.tsx`). Store parity: `audit-query-fixture.ts`.
|
|
16
17
|
- **One composition of predicates** (`list-scope.ts`): row scope (`rows`), then scope, then filters. Every read of a resource builds its `where` there, and every read of ONE row is `findRow` — never `repo.find` from a screen, `crud.ts` or `mcp.ts` — so a row outside `rows(actor)` is "not found" on list, search, detail, edit, action, lookup, labels and MCP alike (`row-scope.test.ts`). App-declared predicates (`scopes`, `rows`) pass `declaredFilters`: any column, never a sealed one.
|
|
17
|
-
- **A widget never reads a row.** What a page shows of the resources it references is read ONCE per target by `relations.ts` (`relationDataFor`: one statement, two for a picked target over `LOOKUP_SELECT_MAX`) and handed down as `WidgetContext.labelFor` / `optionsFor` / `lookupHref`. A target the actor may not list is an id with no name. `list-relations.test.ts` asserts the statement count at the driver.
|
|
18
|
+
- **A widget never reads a row.** What a page shows of the resources it references is read ONCE per target by `relations.ts` (`relationDataFor`: one statement, two for a picked target over `LOOKUP_SELECT_MAX`) and handed down as `WidgetContext.labelFor` / `optionsFor` / `lookupHref`. A target the actor may not list is an id with no name. `list-relations.test.ts` asserts the statement count at the driver.
|
|
18
19
|
- **A sealed column is never a filter, a scope or row-scope predicate, a label, a lookup key or a computed column's input** — `.sealed({ lookup: true })` included: a filter value rides a URL, and a URL is logged. `computedRow` drops sealed names from the row a column's `value` is handed.
|
|
19
20
|
- **The tenant column is the handle's, never an input** (`crud-input.ts`): not a form field, a list column or a filter; a create is stamped with the ACTOR's tenant and a patch never carries the column.
|
|
20
21
|
- **Computed columns are a closed renderer set** (`renderers.tsx`: `badge`, `relative-time`, `money`, `truncate`, `link`, `json`) or a component; `AdminComputedColumn` is typed over `AdminRow` so a resource stays covariant in `Row`.
|
|
@@ -59,17 +60,17 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
59
60
|
- **A job-store predicate the store cannot answer is refused by name** (`job-where.ts`, `X_ADMIN_FILTER_INVALID`): one order (`createdAt desc`), equality on state/queue/name/tenant, an id PREFIX for the id box. `listScreen` answers a repo's refusal 400, as the URL's. "All matching" over jobs is `AdminAction.matching` onto the store's bulk verbs, never a walk.
|
|
60
61
|
- **A resource's permissions are named after its `permission` noun** (`resource.permission`, default the entity name) — every `permissionsForOperation` site reads it, never `resource.name`.
|
|
61
62
|
- **An action's admin gate is ONE function** (`adminPermissionForAction`, `action-gate.ts`): `destructive` → `admin:destroy`; `matching` → `admin:write`, `readonly` beside it or not — it changes every row the list matches; `readonly` → `admin:read`; else `admin:write`. A contradiction fails closed. The same value is its MCP tool's `scope`. `action-gate.test.ts`.
|
|
62
|
-
- **Every admin MCP tool carries ONE scope** — `admin:read`, `admin:write` or `admin:destroy`, by what it does (`mcp-tools.ts`); `adminToolScopes` hands that cut to `defineAppMcp({ scopes })`.
|
|
63
|
+
- **Every admin MCP tool carries ONE scope** — `admin:read`, `admin:write` or `admin:destroy`, by what it does (`mcp-tools.ts`); `adminToolScopes` hands that cut to `defineAppMcp({ scopes })`. **Unstated = full strength** (the actor's); **`[]` = granted nothing**, every tool refused `X_MCP_SCOPE_DENIED`; a non-admin scope is dropped. The scope gate runs before the actor's policy, which still decides. `mcp-scopes.test.ts`.
|
|
63
64
|
- **The `/admin` home counts only what opted in** — `count: true` on the resource (`screen-home-counts.ts`): one `count()` over `rowWhere(actor)` per visit, so it is never a default. Only resources the actor may list; a repo with no `count()`, or one that throws, has no tile — never a zero.
|
|
64
65
|
- **`matching` is "all matching" as ONE call** (`batch-matching.ts`): the list's `where`, gated by the batch's grant, confirmed as `<entity>:all matching`, audited once (`admin.batch.matching`). A checked selection never reaches it.
|
|
65
66
|
- **An action on a row the actor cannot see is refused, `when` or none** (`actionApplies`: a `null` row is no row).
|
|
66
67
|
- **An action's input field is labelled `admin.input.<action>.<field>`** — never under `admin.action.<action>`, which is a leaf: a JSON catalog cannot hold both.
|
|
67
|
-
-
|
|
68
|
+
- **`./schema` (`audit-schema.ts` + `error-groups-schema.ts`) is the DDL leaf**, exempt in `serve-graph.test.ts`. **`./error-groups`**: sink + stores, no screen, off the barrel.
|
|
68
69
|
- Money via `assertMoney`, timestamps via `assertZone`, pagination via `pagination.ts`. No `offset`, ever.
|
|
69
70
|
- **One cursor codec.** `pagination.ts` is the only file calling core's `encodeCursor`/`decodeCursor`; it wraps them as `encodeAdminCursor`/`decodeAdminCursor`, scoped `admin:<resource>`. An invalid cursor is page one here, not an error page — but the signature is checked first, so a forged one cannot seek.
|
|
70
71
|
- Labels are i18n keys derived in `resource.ts` (`admin.<entity>.field.<name>`); only `.tsx` calls `t()`. An action's is `actionLabelKey` (`action-label.ts`), never respelled. `AdminApp.catalogKeys()` (`catalog-keys.ts`) is every derived key — the CLI's `i18n` step audits exactly that list. MCP tool descriptions are literal English (protocol payload, not UI copy).
|
|
71
72
|
- Colours only via `ThemeTokenRef` (`--x-*`). Raw hex does not typecheck.
|
|
72
|
-
- **
|
|
73
|
+
- **The walker is `@ultimat3/core/render-nodes`**: `nodesOf(…, { components: 'keep' })` reads the props a view handed a ui component; `'stop'` keeps ui's internals out. `inert-jsx-fixture.ts` keeps only `h`, `renderHtml`, `renderComponent`; `inert-jsx.test.ts` holds them.
|
|
73
74
|
- **A `/_x` panel orders by CODE UNIT, never `localeCompare`**; **the locale picker reads `registeredLocales()`**, never a bundled list.
|
|
74
75
|
- Views are pure functions of props: no `createSignal`, local state or event handler.
|
|
75
76
|
- **No test reaches a `.tsx` statically**: `await import('@ultimat3/render/server')`, then `await import('./routes')`.
|
|
@@ -93,7 +94,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
93
94
|
| `action-row.ts` / `action-input.ts` / `action-form.tsx` / `screen-action.tsx` | one action on one row / input schema ⇄ form fields / the action's form / row-action and batch writes |
|
|
94
95
|
| `batch.ts` / `batch-queue.ts` / `batch-bar.tsx` / `batch-job.ts` | a batch through the gate / its queued half / the bar and row checkboxes / the `admin.batch` job |
|
|
95
96
|
| `resource-fields.ts` / `resource-layout.ts` / `related.ts` / `list-columns.tsx` | one column → one field / sections and form groups / related lists / a resource's table columns |
|
|
96
|
-
| `audit.ts` / `audit-pg.ts` / `json-text.ts` | the log interface + memory log / the Postgres log / readable JSON for a cell or a diff |
|
|
97
|
+
| `audit.ts` / `audit-pg.ts` / `json-text.ts` / `audit-request.ts` | the log interface + memory log / the Postgres log / readable JSON for a cell or a diff / the trail's URL ⇄ `AuditRequest`, the history cursor |
|
|
97
98
|
| `mcp-tools.ts` / `mcp-scopes.ts` / `mcp.ts` | tool derivation, each tool's scope (pure) / scope → tools for `defineAppMcp`, a token's expanded scopes / transport wiring |
|
|
98
99
|
| `test-ctx.ts` | `adminTestCtx({ actor?, granted? })` — the `CrudCtx` a test hands a page or a CRUD call; `staticAuthz` over a grant list, its own audit log. What `x g admin:page`'s emitted render test calls |
|
|
99
100
|
| `jobs/` | the jobs dashboard: entities, repos, the where translation, actions, labels, resources, the overview and its `/_x` tab |
|
package/README.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
**Two dashboards live here. They are not the same thing — and they have two doors.**
|
|
4
4
|
|
|
5
|
+
`bun add @ultimat3/admin` · guide: [Admin Dashboard](https://github.com/developerz-ai/ultimate/wiki/Admin-Dashboard)
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { staticAuthz } from '@ultimat3/admin';
|
|
9
|
+
import { defaultDevSources, devDashboard } from '@ultimat3/admin/dev';
|
|
10
|
+
|
|
11
|
+
const authz = staticAuthz(['admin:read']);
|
|
12
|
+
const actors = [{ id: 'dev-admin', roles: ['admin'] }];
|
|
13
|
+
export const dev = devDashboard({ sources: defaultDevSources({ authz, actors }) }); // /_x
|
|
14
|
+
```
|
|
15
|
+
|
|
5
16
|
| | `/_x` — framework dev dashboard | `admin` — generated app admin |
|
|
6
17
|
|---|---|---|
|
|
7
18
|
| Import | `@ultimat3/admin/dev` | `@ultimat3/admin` |
|
|
@@ -22,7 +33,7 @@ One panel per file. Each kills one question, and each is available as `--json`
|
|
|
22
33
|
| `live` | what does each subscriber receive, and **why** — the matcher's decision trace |
|
|
23
34
|
| `jobs` | a ring of the recent runs by state (`DonutChart`), then the admin's own jobs overview (`/admin/jobs`'s component) over this process's queue; the payload — queue depth, step traces, retry-from-step target, dead letter — folded under it |
|
|
24
35
|
| `db` | psql in a tab (read-only twice: `assertReadOnly` refuses DML, and `readOnlySql(client)` — what a host wires as `runSql` — runs it in a `BEGIN READ ONLY` transaction with a timeout and `DEV_SQL_MAX_ROWS`), schema + drift (`null` unless a host wires the check) |
|
|
25
|
-
| `mail` | caught mail, rendered, per locale, with the locale gaps listed |
|
|
36
|
+
| `mail` | caught mail, rendered, per locale, with the locale gaps listed; beside it every declared mail's `preview` rendered per locale (`mailTemplates`, the host's source: `x dev` wires it) |
|
|
26
37
|
| `cache` | the tag graph — what invalidated what, and which tags are orphans |
|
|
27
38
|
| `policy` | the permission matrix per actor, every cell carrying its trace |
|
|
28
39
|
| `manifest` | emitted `x.manifest.json` diffed against the committed one |
|
|
@@ -272,7 +283,30 @@ table only as `[redacted]` — `@ultimat3/core`'s `REDACTED`, the logger's marke
|
|
|
272
283
|
table is applied at boot with every other framework table (`@ultimat3/cli`'s `FRAMEWORK_SCHEMA`),
|
|
273
284
|
from the leaf module `@ultimat3/admin/schema` (`SQL_ADMIN_AUDIT_TABLE`, `ADMIN_AUDIT_TABLE`) —
|
|
274
285
|
no migration, no screen loaded to install it. That subpath is their one import path: 25.0.0 took
|
|
275
|
-
them off the barrel.
|
|
286
|
+
them off the barrel. `x_error_groups` (`SQL_ERROR_GROUPS_TABLE`, `ERROR_GROUPS_TABLE`) rides the
|
|
287
|
+
same subpath.
|
|
288
|
+
|
|
289
|
+
**Error groups** (`As of 2026-11`). `@ultimat3/admin/error-groups` is the sink and its stores, and
|
|
290
|
+
nothing else, because every role installs it: `errorGroupsSink({ store, onNewGroup?, ignore?,
|
|
291
|
+
perMinute? })` is an `ErrorReporter` folding each report into its `errorFingerprint` group —
|
|
292
|
+
outside the request's transaction, capped per minute, `onNewGroup` (a job handle) enqueued once
|
|
293
|
+
per new group fleet-wide (`xmax = 0`). Install it beside any other reporter with core's
|
|
294
|
+
`chainErrorReporters`. Stores: `postgresErrorGroupsStore()`, `memoryErrorGroupsStore()`;
|
|
295
|
+
retention `errorGroupsPurgeTarget(store, { olderThanMs })`. The screens are the barrel's:
|
|
296
|
+
`pages: errorGroupsPages({ store, navGroup? })` mounts `/admin/errors` behind `errors:read`
|
|
297
|
+
(`ERRORS_READ`).
|
|
298
|
+
|
|
299
|
+
`/admin/audit` is the whole trail, 100 entries per page, newest first, behind one native GET form:
|
|
300
|
+
`?entity=`, `?actor=`, `?outcome=`, `?since=`, `?until=` (an instant with an offset or `Z`, a
|
|
301
|
+
bare `YYYY-MM-DD` as that day's first UTC instant, or the form's `YYYY-MM-DDTHH:mm`, given its
|
|
302
|
+
`Z` by `utcFormInstantOf` and nowhere else; `until` is exclusive), `?changes=1`, and the
|
|
303
|
+
keyset `?before=`, which the older link writes with the filters kept. `auditRequestOf(url)`
|
|
304
|
+
is the one parser and `auditHref(path, request)` the one builder; a parameter it does not
|
|
305
|
+
read (`?org=` too: the tenant is the actor's) is `X_ADMIN_FILTER_INVALID`, a 400. Both stores
|
|
306
|
+
take `outcome`, `since` and `until` (`AuditQuery`). An app's own log should too: the screen
|
|
307
|
+
re-checks every entry it returns with `auditEntryMatches`, so a log that ignores a field draws
|
|
308
|
+
a short page, never another tenant's or another outcome's entry. A diff on either screen
|
|
309
|
+
masks a `sensitive` or sealed field's value, whatever the stored entry holds.
|
|
276
310
|
|
|
277
311
|
### A sealed column
|
|
278
312
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/admin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "30.0.0",
|
|
4
4
|
"description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ultimate",
|
|
7
|
+
"bun",
|
|
8
|
+
"framework",
|
|
9
|
+
"admin",
|
|
10
|
+
"dashboard",
|
|
11
|
+
"crud",
|
|
12
|
+
"mcp"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/developerz-ai/ultimate/wiki/Admin-Dashboard",
|
|
5
15
|
"license": "MIT",
|
|
6
16
|
"type": "module",
|
|
7
17
|
"repository": {
|
|
@@ -16,7 +26,8 @@
|
|
|
16
26
|
"exports": {
|
|
17
27
|
".": "./src/index.ts",
|
|
18
28
|
"./dev": "./src/dev/index.ts",
|
|
19
|
-
"./schema": "./src/audit-schema.ts"
|
|
29
|
+
"./schema": "./src/audit-schema.ts",
|
|
30
|
+
"./error-groups": "./src/error-groups.ts"
|
|
20
31
|
},
|
|
21
32
|
"files": [
|
|
22
33
|
"src",
|
|
@@ -34,21 +45,21 @@
|
|
|
34
45
|
"test": "bun test"
|
|
35
46
|
},
|
|
36
47
|
"dependencies": {
|
|
37
|
-
"@ultimat3/action": "
|
|
38
|
-
"@ultimat3/ai": "
|
|
39
|
-
"@ultimat3/cache": "
|
|
40
|
-
"@ultimat3/core": "
|
|
41
|
-
"@ultimat3/db": "
|
|
42
|
-
"@ultimat3/entity": "
|
|
43
|
-
"@ultimat3/http": "
|
|
44
|
-
"@ultimat3/i18n": "
|
|
45
|
-
"@ultimat3/jobs": "
|
|
46
|
-
"@ultimat3/mcp": "
|
|
47
|
-
"@ultimat3/money": "
|
|
48
|
-
"@ultimat3/policy": "
|
|
49
|
-
"@ultimat3/query": "
|
|
50
|
-
"@ultimat3/render": "
|
|
51
|
-
"@ultimat3/schema": "
|
|
52
|
-
"@ultimat3/ui": "
|
|
48
|
+
"@ultimat3/action": "30.0.0",
|
|
49
|
+
"@ultimat3/ai": "30.0.0",
|
|
50
|
+
"@ultimat3/cache": "30.0.0",
|
|
51
|
+
"@ultimat3/core": "30.0.0",
|
|
52
|
+
"@ultimat3/db": "30.0.0",
|
|
53
|
+
"@ultimat3/entity": "30.0.0",
|
|
54
|
+
"@ultimat3/http": "30.0.0",
|
|
55
|
+
"@ultimat3/i18n": "30.0.0",
|
|
56
|
+
"@ultimat3/jobs": "30.0.0",
|
|
57
|
+
"@ultimat3/mcp": "30.0.0",
|
|
58
|
+
"@ultimat3/money": "30.0.0",
|
|
59
|
+
"@ultimat3/policy": "30.0.0",
|
|
60
|
+
"@ultimat3/query": "30.0.0",
|
|
61
|
+
"@ultimat3/render": "30.0.0",
|
|
62
|
+
"@ultimat3/schema": "30.0.0",
|
|
63
|
+
"@ultimat3/ui": "30.0.0"
|
|
53
64
|
}
|
|
54
65
|
}
|
package/src/audit-pg.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type AuditSink,
|
|
16
16
|
auditEntry,
|
|
17
17
|
auditLimit,
|
|
18
|
+
auditWindow,
|
|
18
19
|
isAllowedRead,
|
|
19
20
|
} from './audit';
|
|
20
21
|
import { ADMIN_AUDIT_TABLE } from './audit-schema';
|
|
@@ -105,6 +106,10 @@ function selectFor(query: AuditQuery, limit: number): { text: string; values: un
|
|
|
105
106
|
if (query.entityId !== undefined) where.push(`entity_id = ${bind(query.entityId)}`);
|
|
106
107
|
if (query.actorId !== undefined) where.push(`actor_id = ${bind(query.actorId)}`);
|
|
107
108
|
if (query.orgId !== undefined) where.push(`org_id = ${bind(query.orgId)}`);
|
|
109
|
+
if (query.outcome !== undefined) where.push(`outcome = ${bind(query.outcome)}`);
|
|
110
|
+
// Normalised by `auditWindow` before this runs: an ISO instant, never a bare local time.
|
|
111
|
+
if (query.since !== undefined) where.push(`at >= ${bind(query.since)}::timestamptz`);
|
|
112
|
+
if (query.until !== undefined) where.push(`at < ${bind(query.until)}::timestamptz`);
|
|
108
113
|
if (query.before !== undefined) {
|
|
109
114
|
where.push(
|
|
110
115
|
`(at, id) < (${bind(query.before.at)}::timestamptz, ${bind(query.before.id)}::uuid)`,
|
|
@@ -187,7 +192,8 @@ export function postgresAuditLog(options: PostgresAuditLogOptions = {}): AuditLo
|
|
|
187
192
|
for (const sink of sinks) await sink.write(entry);
|
|
188
193
|
return entry;
|
|
189
194
|
},
|
|
190
|
-
async entries(
|
|
195
|
+
async entries(asked = {}): Promise<readonly AuditEntry[]> {
|
|
196
|
+
const query = auditWindow(SUBJECT, asked);
|
|
191
197
|
const select = selectFor(query, auditLimit(SUBJECT, query));
|
|
192
198
|
const rows = await client().query<AuditRow>(select);
|
|
193
199
|
return rows.map(entryOf);
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// URL ⇄ the slice of the audit trail `/admin/audit` is asked for. The screen's whole state lives in
|
|
2
|
+
// its query string, as a list's does (`list-request.ts`): the filter form is a native GET and the
|
|
3
|
+
// older link is a URL. One parser and one builder, so the two cannot disagree about a name. A
|
|
4
|
+
// parameter the screen does not answer is REFUSED — never the unfiltered trail.
|
|
5
|
+
//
|
|
6
|
+
// ?entity=<name> ?actor=<id> ?outcome=<allowed|denied|failed> ?changes=1
|
|
7
|
+
// ?since=<instant> ?until=<instant> ISO-8601 with an offset, YYYY-MM-DD as UTC, or the
|
|
8
|
+
// form's YYYY-MM-DDTHH:mm, given the Z its UTC label states
|
|
9
|
+
// ?before=<at>~<id> keyset: the entry the previous page ended on
|
|
10
|
+
//
|
|
11
|
+
// An EMPTY value is no parameter at all: a GET form submits every input it holds. `orgId` is
|
|
12
|
+
// never a parameter — it is the actor's (`screen-system.tsx`), so `?org=` is unknown and a 400.
|
|
13
|
+
|
|
14
|
+
import type { AuditCursor, AuditOutcome, AuditQuery } from './audit';
|
|
15
|
+
import { AdminFilterInvalidError } from './errors';
|
|
16
|
+
import { utcFormInstantOf } from './list-filters';
|
|
17
|
+
|
|
18
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
19
|
+
|
|
20
|
+
/** The query parameter an older page of a row's history is asked for by: `?history=<at>~<id>`. */
|
|
21
|
+
export const HISTORY_PARAM = 'history';
|
|
22
|
+
|
|
23
|
+
/** The cursor as one parameter value — an ISO instant holds no `~`, a uuid neither. */
|
|
24
|
+
export const historyCursorText = (cursor: AuditCursor): string => `${cursor.at}~${cursor.id}`;
|
|
25
|
+
|
|
26
|
+
/** A posted-back cursor, or `undefined` for anything that is not one: page one, never an error. */
|
|
27
|
+
export function historyCursorOf(raw: string | null): AuditCursor | undefined {
|
|
28
|
+
if (raw === null) return undefined;
|
|
29
|
+
const at = raw.indexOf('~');
|
|
30
|
+
if (at <= 0) return undefined;
|
|
31
|
+
const instant = raw.slice(0, at);
|
|
32
|
+
const id = raw.slice(at + 1);
|
|
33
|
+
// A uuid, because the durable log compares it as one: anything else would be a cast error from
|
|
34
|
+
// the database where a hand-edited URL deserves page one.
|
|
35
|
+
return Number.isNaN(Date.parse(instant)) || !UUID.test(id) ? undefined : { at: instant, id };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const AUDIT_OUTCOMES: readonly AuditOutcome[] = ['allowed', 'denied', 'failed'];
|
|
39
|
+
|
|
40
|
+
/** The parameter names, in the order the builder writes them. */
|
|
41
|
+
export const AUDIT_PARAMS = [
|
|
42
|
+
'entity',
|
|
43
|
+
'actor',
|
|
44
|
+
'outcome',
|
|
45
|
+
'since',
|
|
46
|
+
'until',
|
|
47
|
+
'changes',
|
|
48
|
+
'before',
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The refusal's fix: a URL at the screen's own path (`basePath` included) that it answers, with
|
|
53
|
+
* every parameter it reads named beside it.
|
|
54
|
+
*/
|
|
55
|
+
export const auditUrlFix = (path: string): string =>
|
|
56
|
+
`${path}?outcome=denied&since=2026-11-01 # keep only entity, actor, outcome (allowed|denied|failed), since, until (YYYY-MM-DD or ISO-8601 with an offset), changes=1, before; drop every other parameter`;
|
|
57
|
+
|
|
58
|
+
type AuditParam = (typeof AUDIT_PARAMS)[number];
|
|
59
|
+
|
|
60
|
+
/** What a URL may ask of the trail: `AuditQuery` minus the actor's tenant and the page size. */
|
|
61
|
+
export type AuditRequest = Pick<
|
|
62
|
+
AuditQuery,
|
|
63
|
+
'entity' | 'actorId' | 'outcome' | 'since' | 'until' | 'changes' | 'before'
|
|
64
|
+
>;
|
|
65
|
+
|
|
66
|
+
const isParam = (key: string): key is AuditParam =>
|
|
67
|
+
(AUDIT_PARAMS as readonly string[]).includes(key);
|
|
68
|
+
|
|
69
|
+
/** The trail a URL asks for. Throws `X_ADMIN_FILTER_INVALID` for anything it cannot read. */
|
|
70
|
+
export function auditRequestOf(url: URL): AuditRequest {
|
|
71
|
+
const refuse = (asked: string, cause: string): never => {
|
|
72
|
+
throw new AdminFilterInvalidError({
|
|
73
|
+
entity: url.pathname,
|
|
74
|
+
asked,
|
|
75
|
+
cause,
|
|
76
|
+
known: AUDIT_PARAMS.map((name) => `${name}=`),
|
|
77
|
+
fix: auditUrlFix(url.pathname),
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
const out: { -readonly [K in keyof AuditRequest]: AuditRequest[K] } = {};
|
|
81
|
+
for (const key of new Set(url.searchParams.keys())) {
|
|
82
|
+
const values = url.searchParams.getAll(key).filter((value) => value !== '');
|
|
83
|
+
const last = values[values.length - 1];
|
|
84
|
+
if (!isParam(key)) refuse(key, 'is not a parameter the audit trail reads');
|
|
85
|
+
if (last === undefined) continue;
|
|
86
|
+
const asked = `${key}=${last}`;
|
|
87
|
+
switch (key) {
|
|
88
|
+
case 'entity':
|
|
89
|
+
out.entity = last;
|
|
90
|
+
break;
|
|
91
|
+
case 'actor':
|
|
92
|
+
out.actorId = last;
|
|
93
|
+
break;
|
|
94
|
+
case 'outcome':
|
|
95
|
+
out.outcome =
|
|
96
|
+
AUDIT_OUTCOMES.find((known) => known === last) ??
|
|
97
|
+
refuse(asked, `is not an outcome (${AUDIT_OUTCOMES.join(', ')})`);
|
|
98
|
+
break;
|
|
99
|
+
case 'since':
|
|
100
|
+
case 'until':
|
|
101
|
+
out[key] =
|
|
102
|
+
utcFormInstantOf(last) ??
|
|
103
|
+
refuse(asked, 'is neither an ISO-8601 instant with an offset nor a YYYY-MM-DD date');
|
|
104
|
+
break;
|
|
105
|
+
case 'changes':
|
|
106
|
+
out.changes = last === '1' || refuse(asked, 'takes 1, or is left out');
|
|
107
|
+
break;
|
|
108
|
+
case 'before':
|
|
109
|
+
out.before =
|
|
110
|
+
historyCursorOf(last) ?? refuse(asked, 'is not a position this trail wrote (<at>~<id>)');
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The URL of one slice of the trail, rooted at the screen's own path. */
|
|
118
|
+
export function auditHref(path: string, request: AuditRequest): string {
|
|
119
|
+
const query = new URLSearchParams();
|
|
120
|
+
if (request.entity !== undefined) query.set('entity', request.entity);
|
|
121
|
+
if (request.actorId !== undefined) query.set('actor', request.actorId);
|
|
122
|
+
if (request.outcome !== undefined) query.set('outcome', request.outcome);
|
|
123
|
+
if (request.since !== undefined) query.set('since', request.since);
|
|
124
|
+
if (request.until !== undefined) query.set('until', request.until);
|
|
125
|
+
if (request.changes === true) query.set('changes', '1');
|
|
126
|
+
if (request.before !== undefined) query.set('before', historyCursorText(request.before));
|
|
127
|
+
const search = query.toString();
|
|
128
|
+
return search === '' ? path : `${path}?${search}`;
|
|
129
|
+
}
|
package/src/audit-schema.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
// The DDL of the admin's
|
|
2
|
-
// `FRAMEWORK_SCHEMA` (`@ultimat3/cli`) can apply
|
|
3
|
-
// without pulling one screen, one view or one stylesheet into a worker's module graph.
|
|
1
|
+
// The DDL of the admin's framework tables, and nothing else: a LEAF module, so the boot's
|
|
2
|
+
// `FRAMEWORK_SCHEMA` (`@ultimat3/cli`) can apply them in every role through `@ultimat3/admin/schema`
|
|
3
|
+
// without pulling one screen, one view or one stylesheet into a worker's module graph. The audit
|
|
4
|
+
// table is declared here; the error-groups table beside it, in its own leaf, re-exported below.
|
|
5
|
+
|
|
6
|
+
/** `x_error_groups` — one subpath for every admin table the boot applies, one import line there. */
|
|
7
|
+
export { ERROR_GROUPS_TABLE, SQL_ERROR_GROUPS_TABLE } from './error-groups-schema';
|
|
4
8
|
|
|
5
9
|
/** The table. One name, read by this DDL and by `audit-pg.ts`'s insert and select. */
|
|
6
10
|
export const ADMIN_AUDIT_TABLE = 'x_admin_audit';
|
package/src/audit.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { canonicalJson, finiteCount, REDACTED } from '@ultimat3/core';
|
|
6
6
|
import type { AdminActor, AdminDecision } from './authz';
|
|
7
|
+
import { AdminFilterInvalidError } from './errors';
|
|
8
|
+
import { instantOf } from './list-filters';
|
|
7
9
|
import type { AdminRow } from './registry';
|
|
8
10
|
|
|
9
11
|
/** Named once, so both refusals below say the same thing about the same call. */
|
|
@@ -79,6 +81,15 @@ export interface AuditQuery {
|
|
|
79
81
|
* changed something or was refused. What a row's history shows.
|
|
80
82
|
*/
|
|
81
83
|
readonly changes?: boolean;
|
|
84
|
+
/** Only entries with this outcome: what was refused is `denied`, what threw is `failed`. */
|
|
85
|
+
readonly outcome?: AuditOutcome;
|
|
86
|
+
/**
|
|
87
|
+
* Only entries at or after this instant: ISO-8601 with an offset, or a bare `YYYY-MM-DD` as that
|
|
88
|
+
* day's first instant in UTC. Anything else is `X_ADMIN_FILTER_INVALID` — no store invents a zone.
|
|
89
|
+
*/
|
|
90
|
+
readonly since?: string;
|
|
91
|
+
/** Only entries strictly BEFORE this instant — exclusive, so two windows never share an entry. */
|
|
92
|
+
readonly until?: string;
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
export interface AuditLog {
|
|
@@ -86,6 +97,9 @@ export interface AuditLog {
|
|
|
86
97
|
/**
|
|
87
98
|
* Newest first, at most `limit` (`AUDIT_PAGE_MAX` at the outside). A copy — the log cannot be
|
|
88
99
|
* mutated through what it hands out. Async because the record is a table, not this process.
|
|
100
|
+
* A log of an app's own must honour EVERY field of `AuditQuery`: `/admin/audit` re-checks each
|
|
101
|
+
* returned entry against the query (`auditEntryMatches`) and drops what does not match, so a log
|
|
102
|
+
* that ignores `outcome` or a bound draws a short page — never an unfiltered one.
|
|
89
103
|
*/
|
|
90
104
|
entries(query?: AuditQuery): Promise<readonly AuditEntry[]>;
|
|
91
105
|
/**
|
|
@@ -133,6 +147,50 @@ export const auditLimit = (subject: string, query: AuditQuery): number =>
|
|
|
133
147
|
finiteCount(subject, 'entries limit', query.limit),
|
|
134
148
|
);
|
|
135
149
|
|
|
150
|
+
/** The store refusal's fix: the two spellings a bound in `AuditQuery` may take. */
|
|
151
|
+
export const AUDIT_BOUND_FIX =
|
|
152
|
+
"entries({ since: '2026-11-01', until: '2026-11-02T00:00:00+02:00' }) # a bound is YYYY-MM-DD (UTC) or ISO-8601 with an offset";
|
|
153
|
+
|
|
154
|
+
/** One bound, as the ISO instant a store compares — or refused, naming the grammar it takes. */
|
|
155
|
+
const boundOf = (subject: string, name: 'since' | 'until', raw: string): string => {
|
|
156
|
+
const at = instantOf(raw);
|
|
157
|
+
if (at !== undefined) return at;
|
|
158
|
+
throw new AdminFilterInvalidError({
|
|
159
|
+
entity: subject,
|
|
160
|
+
asked: `${name}=${raw}`,
|
|
161
|
+
cause: 'is neither an ISO-8601 instant with an offset nor a YYYY-MM-DD date (read as UTC)',
|
|
162
|
+
known: ['since=2026-11-01', 'until=2026-11-01T12:00:00Z'],
|
|
163
|
+
fix: AUDIT_BOUND_FIX,
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A query with its bounds normalised to ISO instants, or refused. Every store calls it before it
|
|
169
|
+
* reads, so a bound no store can read is an error in both — never "no bound" in one of them.
|
|
170
|
+
*/
|
|
171
|
+
export function auditWindow(subject: string, query: AuditQuery): AuditQuery {
|
|
172
|
+
return {
|
|
173
|
+
...query,
|
|
174
|
+
...(query.since === undefined ? {} : { since: boundOf(subject, 'since', query.since) }),
|
|
175
|
+
...(query.until === undefined ? {} : { until: boundOf(subject, 'until', query.until) }),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether one entry answers a query, `before` and `limit` aside — the memory log's filter, and the
|
|
181
|
+
* check `/admin/audit` runs again on whatever a log of an app's own returned. A bound that does not
|
|
182
|
+
* parse matches nothing: this predicate fails closed.
|
|
183
|
+
*/
|
|
184
|
+
export const auditEntryMatches = (entry: AuditEntry, query: AuditQuery): boolean =>
|
|
185
|
+
(query.entity === undefined || entry.entity === query.entity) &&
|
|
186
|
+
(query.entityId === undefined || entry.entityId === query.entityId) &&
|
|
187
|
+
(query.actorId === undefined || entry.actor.id === query.actorId) &&
|
|
188
|
+
(query.orgId === undefined || entry.actor.orgId === query.orgId) &&
|
|
189
|
+
(query.outcome === undefined || entry.outcome === query.outcome) &&
|
|
190
|
+
(query.since === undefined || Date.parse(entry.at) >= Date.parse(query.since)) &&
|
|
191
|
+
(query.until === undefined || Date.parse(entry.at) < Date.parse(query.until)) &&
|
|
192
|
+
(query.changes !== true || !isAllowedRead(entry));
|
|
193
|
+
|
|
136
194
|
/** Where the page after `entries` starts, or `null` for an empty page. */
|
|
137
195
|
export const auditCursorOf = (entries: readonly AuditEntry[]): AuditCursor | null => {
|
|
138
196
|
const last = entries[entries.length - 1];
|
|
@@ -186,7 +244,8 @@ export function memoryAuditLog(opts: AuditLogOptions = {}): AuditLog {
|
|
|
186
244
|
for (const sink of sinks) await sink.write(entry);
|
|
187
245
|
return entry;
|
|
188
246
|
},
|
|
189
|
-
async entries(
|
|
247
|
+
async entries(asked = {}): Promise<readonly AuditEntry[]> {
|
|
248
|
+
const query = auditWindow(SUBJECT, asked);
|
|
190
249
|
const limit = auditLimit(SUBJECT, query);
|
|
191
250
|
const before = query.before;
|
|
192
251
|
// Insertion order reversed IS newest first, ties included: two entries of one millisecond
|
|
@@ -199,14 +258,7 @@ export function memoryAuditLog(opts: AuditLogOptions = {}): AuditLog {
|
|
|
199
258
|
? log.slice(0, at)
|
|
200
259
|
: log.filter((entry) => newestFirst(before, entry) < 0);
|
|
201
260
|
return older
|
|
202
|
-
.filter(
|
|
203
|
-
(entry) =>
|
|
204
|
-
(query.entity === undefined || entry.entity === query.entity) &&
|
|
205
|
-
(query.entityId === undefined || entry.entityId === query.entityId) &&
|
|
206
|
-
(query.actorId === undefined || entry.actor.id === query.actorId) &&
|
|
207
|
-
(query.orgId === undefined || entry.actor.orgId === query.orgId) &&
|
|
208
|
-
(query.changes !== true || !isAllowedRead(entry)),
|
|
209
|
-
)
|
|
261
|
+
.filter((entry) => auditEntryMatches(entry, query))
|
|
210
262
|
.reverse()
|
|
211
263
|
.slice(0, limit);
|
|
212
264
|
},
|
package/src/crud-input.ts
CHANGED
|
@@ -66,22 +66,24 @@ export function withTenant(
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
69
|
+
* Only what this resource lets a caller write, on this side. A `hidden` field is in no field
|
|
70
|
+
* list and a `readOnly` one (a generated column, the tenant, an override) is in the HTML form and
|
|
71
|
+
* the MCP schema for nobody, so a direct `adminCreate`/`adminUpdate` caller is cut the same way —
|
|
72
|
+
* skipping the form is not a way around it. A `sensitive` field stays writable: unreadable is not
|
|
73
|
+
* read-only. A field declared `on: 'create'` is set once and never patched; one declared
|
|
74
|
+
* `on: 'update'` has no value until the row exists.
|
|
72
75
|
*/
|
|
73
76
|
export function onlyFor(
|
|
74
77
|
resource: AdminResource,
|
|
75
78
|
side: 'create' | 'update',
|
|
76
79
|
input: Readonly<Record<string, unknown>>,
|
|
77
80
|
): Readonly<Record<string, unknown>> {
|
|
78
|
-
const
|
|
81
|
+
const offered = new Set(
|
|
79
82
|
[...resource.fields, ...resource.secretFields]
|
|
80
|
-
.filter((field) => field.on
|
|
83
|
+
.filter((field) => !field.readOnly && (field.on === undefined || field.on === side))
|
|
81
84
|
.map((field) => field.name),
|
|
82
85
|
);
|
|
83
|
-
|
|
84
|
-
return Object.fromEntries(Object.entries(input).filter(([key]) => !other.has(key)));
|
|
86
|
+
return Object.fromEntries(Object.entries(input).filter(([key]) => offered.has(key)));
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
/**
|
package/src/detail.tsx
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
// to this row, the row's history, and the actions this actor may run on it — each a link or a
|
|
3
3
|
// native form, so the screen ships no script. A detail page with no row is a state, not a card.
|
|
4
4
|
|
|
5
|
+
import { REDACTED } from '@ultimat3/core';
|
|
5
6
|
import { t } from '@ultimat3/i18n';
|
|
6
7
|
import { Button, Card, DataTable, ErrorState, Link } from '@ultimat3/ui';
|
|
7
8
|
import type { JSX } from 'solid-js';
|
|
8
9
|
import { AdminActions, ConfirmationField, DELETE_OPERATION, OPERATION_FIELD } from './actions';
|
|
9
10
|
import styles from './admin.module.scss';
|
|
10
|
-
import type {
|
|
11
|
+
import type { AuditEntry, AuditFieldDiff } from './audit';
|
|
11
12
|
import type { AdminActor, AdminAuthz } from './authz';
|
|
12
13
|
import { type AdminErrorParts, adminErrorFrom } from './errors';
|
|
13
14
|
import { valueText } from './json-text';
|
|
@@ -55,6 +56,41 @@ export const operationLabel = (entry: AuditEntry): string =>
|
|
|
55
56
|
? t(`admin.action.${entry.operation}`)
|
|
56
57
|
: t(`admin.operation.${entry.operation}`);
|
|
57
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The fields a diff never shows a value of: a `sensitive` field and a sealed column. The log is
|
|
61
|
+
* written redacted (`crud-input.ts`), and the view masks again, so an entry written by an app's own
|
|
62
|
+
* log, or before a field was marked, still draws no plaintext.
|
|
63
|
+
*/
|
|
64
|
+
export const maskedFieldsOf = (
|
|
65
|
+
resource: Pick<AdminResource, 'fields' | 'secretFields'>,
|
|
66
|
+
): ReadonlySet<string> =>
|
|
67
|
+
new Set(
|
|
68
|
+
[...resource.fields, ...resource.secretFields]
|
|
69
|
+
.filter((field) => field.sensitive)
|
|
70
|
+
.map((field) => field.name),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const shown = (masked: ReadonlySet<string>, field: string, value: unknown): string =>
|
|
74
|
+
value === undefined ? '' : masked.has(field) ? REDACTED : valueText(value);
|
|
75
|
+
|
|
76
|
+
/** One entry's diff: field, before struck out, after inserted. Masked fields say only that. */
|
|
77
|
+
export function AuditDiff(props: {
|
|
78
|
+
readonly diff: readonly AuditFieldDiff[];
|
|
79
|
+
readonly masked: ReadonlySet<string>;
|
|
80
|
+
}): JSX.Element | null {
|
|
81
|
+
if (props.diff.length === 0) return null;
|
|
82
|
+
return (
|
|
83
|
+
<ul>
|
|
84
|
+
{props.diff.map((change) => (
|
|
85
|
+
<li>
|
|
86
|
+
<code>{change.field}</code>: <del>{shown(props.masked, change.field, change.before)}</del>{' '}
|
|
87
|
+
<ins>{shown(props.masked, change.field, change.after)}</ins>
|
|
88
|
+
</li>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
58
94
|
export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>): JSX.Element {
|
|
59
95
|
if (props.error !== null) {
|
|
60
96
|
return <ErrorState error={adminErrorFrom(props.error)} />;
|
|
@@ -157,7 +193,7 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
157
193
|
|
|
158
194
|
<Card header={<h2>{t('admin.audit.title')}</h2>}>
|
|
159
195
|
{props.audit.length === 0 ? (
|
|
160
|
-
<p class="x-admin-empty">{t('admin.audit.empty')}</p>
|
|
196
|
+
<p class="x-admin-empty">{t('admin.audit.empty-row')}</p>
|
|
161
197
|
) : (
|
|
162
198
|
<ol class="x-admin-audit">
|
|
163
199
|
{props.audit.map((entry) => (
|
|
@@ -167,14 +203,7 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
167
203
|
<span data-outcome={entry.outcome}>
|
|
168
204
|
{t(`admin.audit.outcome.${entry.outcome}`)}
|
|
169
205
|
</span>
|
|
170
|
-
<
|
|
171
|
-
{entry.diff.map((change) => (
|
|
172
|
-
<li>
|
|
173
|
-
<code>{change.field}</code>: <del>{valueText(change.before)}</del>{' '}
|
|
174
|
-
<ins>{valueText(change.after)}</ins>
|
|
175
|
-
</li>
|
|
176
|
-
))}
|
|
177
|
-
</ul>
|
|
206
|
+
<AuditDiff diff={entry.diff} masked={maskedFieldsOf(props.resource)} />
|
|
178
207
|
</li>
|
|
179
208
|
))}
|
|
180
209
|
</ol>
|
|
@@ -186,23 +215,3 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
186
215
|
</>
|
|
187
216
|
);
|
|
188
217
|
}
|
|
189
|
-
|
|
190
|
-
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
191
|
-
|
|
192
|
-
/** The query parameter an older page of a row's history is asked for by: `?history=<at>~<id>`. */
|
|
193
|
-
export const HISTORY_PARAM = 'history';
|
|
194
|
-
|
|
195
|
-
/** The cursor as one parameter value — an ISO instant holds no `~`, a uuid neither. */
|
|
196
|
-
export const historyCursorText = (cursor: AuditCursor): string => `${cursor.at}~${cursor.id}`;
|
|
197
|
-
|
|
198
|
-
/** A posted-back cursor, or `undefined` for anything that is not one: page one, never an error. */
|
|
199
|
-
export function historyCursorOf(raw: string | null): AuditCursor | undefined {
|
|
200
|
-
if (raw === null) return undefined;
|
|
201
|
-
const at = raw.indexOf('~');
|
|
202
|
-
if (at <= 0) return undefined;
|
|
203
|
-
const instant = raw.slice(0, at);
|
|
204
|
-
const id = raw.slice(at + 1);
|
|
205
|
-
// A uuid, because the durable log compares it as one: anything else would be a cast error from
|
|
206
|
-
// the database where a hand-edited URL deserves page one.
|
|
207
|
-
return Number.isNaN(Date.parse(instant)) || !UUID.test(id) ? undefined : { at: instant, id };
|
|
208
|
-
}
|