@ultimat3/admin 30.9.0 → 31.1.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 +2 -2
- package/README.md +6 -4
- package/package.json +18 -17
- package/src/adapter.ts +5 -0
- package/src/admin.ts +6 -0
- package/src/audit-pg.ts +2 -2
- package/src/audit.ts +6 -6
- package/src/dev/panel-timeline.ts +24 -8
- package/src/dev/sql-runner.ts +21 -5
- package/src/error-groups-sink.ts +5 -2
- package/src/error-groups-store.ts +1 -0
- package/src/index.ts +46 -202
- package/src/jobs/operator.ts +2 -2
- package/src/layout.tsx +1 -1
- package/src/localized-app.ts +29 -0
- package/src/routes.ts +9 -1
- package/src/screen-system.tsx +6 -5
package/CLAUDE.md
CHANGED
|
@@ -46,7 +46,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
46
46
|
- **An edit is optimistic** (`row-version.ts`): the form posts `_version` = `rowVersion(row)` (keyed), `adminUpdate(…, { version })` answers `stale` — 409, `admin.error.row-changed`, the form re-drawn over the row as it is now — when the row moved on; a post with no version is stale. An instant posted back as it was drawn (`datetimeInputValue`) is LEFT OUT of the patch (`decodeForm(…, before)`): `datetime-local` holds minutes. A create or update is decided again on the validated values as `subject.input` (update: beside `row`).
|
|
47
47
|
- **The audit trail is the actor's tenant's**: `/admin/audit` and the history card pass `orgId` to `entries()` when the actor has one (`audit-tenancy.test.ts`).
|
|
48
48
|
- **A table read by a runtime key is `Object.hasOwn` / a `Map`, never a bare index** — `impliedBy`, the `/_x` counters, `list-filters.ts`' operator table, `renderers.tsx` (`bun run proto-index`).
|
|
49
|
-
- **The `/_x` SQL runs in `readOnlyQuery`** (`dev/sql-runner.ts`, `readOnlySql(client)
|
|
49
|
+
- **The `/_x` SQL runs in `readOnlyQuery`** (`dev/sql-runner.ts`, `readOnlySql(client)`, a host's `runSql`): read-only txn, timeout, `DEV_SQL_MAX_ROWS`, SELECT-only role.
|
|
50
50
|
- **One read-only SQL guard, and it is `@ultimat3/mcp`'s** — no local keyword scan; the panel keeps only the emptiness test and the way out (`psql "$DATABASE_URL"`, the developer's own client — `x db` has no `psql`).
|
|
51
51
|
- **Every admin operation is audited, reads included**, not `/admin`'s counts (a count reads no row). `ListResult` carries its `AuditEntry` on both branches (`entityId: null`); `AdminSearchResult.audit` one entry per resource decided, and a skipped resource (no text field, no repo, a repo that threw) is in `skipped`, never a 500.
|
|
52
52
|
- **A write's RESULT satisfies the actor's row scope** (`row-scope-write.ts`): a create, and an update over the fields it touches, outside `rows(actor)` is refused before the repo — `admin.error.row-out-of-scope`, audited as a denial. The one predicate evaluator in this package; an ordering over text is refused, never guessed. `row-scope-write.test.ts`.
|
|
@@ -109,6 +109,6 @@ From the repo root — the seal-key preload is the root's, so `bun test --filter
|
|
|
109
109
|
directory fails on the first sealed column:
|
|
110
110
|
|
|
111
111
|
```bash
|
|
112
|
-
bun test packages/admin
|
|
112
|
+
bun test ./packages/admin/
|
|
113
113
|
bunx tsc --noEmit -p packages/admin/tsconfig.json
|
|
114
114
|
```
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ One panel per file. Each kills one question, and each is available as `--json`
|
|
|
32
32
|
| `timeline` | where did the time go? — a server-rendered waterfall of SQL, cache, action, policy spans (one row per span, bar placed by the payload's own `offset`/`width`), time per kind, and the N+1 detector's verdicts with their fix lines; the payload folded under it |
|
|
33
33
|
| `live` | what does each subscriber receive, and **why** — the matcher's decision trace |
|
|
34
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 |
|
|
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 `
|
|
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, `DEV_SQL_MAX_ROWS` and the SELECT-only role `ensureReadOnlyRole` grants), schema + drift (`null` unless a host wires the check) |
|
|
36
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) |
|
|
37
37
|
| `cache` | the tag graph — what invalidated what, and which tags are orphans |
|
|
38
38
|
| `policy` | the permission matrix per actor, every cell carrying its trace |
|
|
@@ -330,9 +330,9 @@ import { AdminList, listHref, Widget } from '@ultimat3/admin';
|
|
|
330
330
|
<Widget field={field} value={row.total} ctx={ctx} mode="read" />;
|
|
331
331
|
```
|
|
332
332
|
|
|
333
|
-
`Widget` takes the field and the raw row value, not pre-derived props:
|
|
334
|
-
guard (money is minor units, a timestamp has an IANA zone) and the component
|
|
335
|
-
no way to render a cell that skipped it.
|
|
333
|
+
`Widget` takes the field and the raw row value, not pre-derived props: the internal
|
|
334
|
+
`widgetProps()` is the guard (money is minor units, a timestamp has an IANA zone) and the component
|
|
335
|
+
calls it, so there is no way to render a cell that skipped it.
|
|
336
336
|
|
|
337
337
|
### Mounting it yourself
|
|
338
338
|
|
|
@@ -522,6 +522,8 @@ Opt-in AI panes, each declaring the scope it needs (`aiPanes({ enable: ['anomaly
|
|
|
522
522
|
| `backlog-forecast` | `jobs:read`, `metrics:read` |
|
|
523
523
|
|
|
524
524
|
Panes are off until enabled, and `runAiPane` refuses (never no-ops) without a runner.
|
|
525
|
+
`GatewayAdapter` — the type a host narrows `@ultimat3/ai`'s `Gateway` to an `AiRunner` with — is
|
|
526
|
+
`@ultimat3/admin/adapter`'s, off the barrel.
|
|
525
527
|
|
|
526
528
|
## Errors
|
|
527
529
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/admin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "31.1.0",
|
|
4
4
|
"description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"exports": {
|
|
27
27
|
".": "./src/index.ts",
|
|
28
|
+
"./adapter": "./src/adapter.ts",
|
|
28
29
|
"./dev": "./src/dev/index.ts",
|
|
29
30
|
"./schema": "./src/audit-schema.ts",
|
|
30
31
|
"./error-groups": "./src/error-groups.ts"
|
|
@@ -45,21 +46,21 @@
|
|
|
45
46
|
"test": "bun test"
|
|
46
47
|
},
|
|
47
48
|
"dependencies": {
|
|
48
|
-
"@ultimat3/action": "
|
|
49
|
-
"@ultimat3/ai": "
|
|
50
|
-
"@ultimat3/cache": "
|
|
51
|
-
"@ultimat3/core": "
|
|
52
|
-
"@ultimat3/db": "
|
|
53
|
-
"@ultimat3/entity": "
|
|
54
|
-
"@ultimat3/http": "
|
|
55
|
-
"@ultimat3/i18n": "
|
|
56
|
-
"@ultimat3/jobs": "
|
|
57
|
-
"@ultimat3/mcp": "
|
|
58
|
-
"@ultimat3/money": "
|
|
59
|
-
"@ultimat3/policy": "
|
|
60
|
-
"@ultimat3/query": "
|
|
61
|
-
"@ultimat3/render": "
|
|
62
|
-
"@ultimat3/schema": "
|
|
63
|
-
"@ultimat3/ui": "
|
|
49
|
+
"@ultimat3/action": "31.1.0",
|
|
50
|
+
"@ultimat3/ai": "31.1.0",
|
|
51
|
+
"@ultimat3/cache": "31.1.0",
|
|
52
|
+
"@ultimat3/core": "31.1.0",
|
|
53
|
+
"@ultimat3/db": "31.1.0",
|
|
54
|
+
"@ultimat3/entity": "31.1.0",
|
|
55
|
+
"@ultimat3/http": "31.1.0",
|
|
56
|
+
"@ultimat3/i18n": "31.1.0",
|
|
57
|
+
"@ultimat3/jobs": "31.1.0",
|
|
58
|
+
"@ultimat3/mcp": "31.1.0",
|
|
59
|
+
"@ultimat3/money": "31.1.0",
|
|
60
|
+
"@ultimat3/policy": "31.1.0",
|
|
61
|
+
"@ultimat3/query": "31.1.0",
|
|
62
|
+
"@ultimat3/render": "31.1.0",
|
|
63
|
+
"@ultimat3/schema": "31.1.0",
|
|
64
|
+
"@ultimat3/ui": "31.1.0"
|
|
64
65
|
}
|
|
65
66
|
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// `@ultimat3/admin/adapter` — what a host wiring an AI gateway into the admin reads: the shape of
|
|
2
|
+
// the function that narrows `@ultimat3/ai`'s `Gateway` to the runner a pane may call. Never the
|
|
3
|
+
// `.` barrel: an app declares an admin; only the code that hands it a gateway needs this.
|
|
4
|
+
|
|
5
|
+
export type { GatewayAdapter } from './ai-panes';
|
package/src/admin.ts
CHANGED
|
@@ -105,6 +105,11 @@ export interface DefineAdminInput {
|
|
|
105
105
|
|
|
106
106
|
export interface AdminApp {
|
|
107
107
|
readonly basePath: string;
|
|
108
|
+
/**
|
|
109
|
+
* Where "back to the app" goes: `/` as declared; the root a request was served under behind an
|
|
110
|
+
* HTTP base path or a routed locale (`/myapp/es/`) — `adminAppFor`, `localized-app.ts`.
|
|
111
|
+
*/
|
|
112
|
+
readonly home?: string;
|
|
108
113
|
readonly branding: AdminBranding;
|
|
109
114
|
readonly theme: ThemeAttributes;
|
|
110
115
|
readonly resources: readonly AdminResource[];
|
|
@@ -414,6 +419,7 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
|
|
|
414
419
|
|
|
415
420
|
const app: AdminApp = {
|
|
416
421
|
basePath,
|
|
422
|
+
home: '/',
|
|
417
423
|
branding,
|
|
418
424
|
theme: themeAttributes(branding),
|
|
419
425
|
resources,
|
package/src/audit-pg.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// every restart, and a row's history that ends at the last deploy is not a history.
|
|
4
4
|
|
|
5
5
|
import { isUltimateError, uuidV7 } from '@ultimat3/core';
|
|
6
|
+
import type { AuditSink } from '@ultimat3/core/adapter';
|
|
6
7
|
import { type DbClient, db, withTransaction } from '@ultimat3/db';
|
|
7
8
|
import {
|
|
8
9
|
AUDIT_READ_OPERATIONS,
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
type AuditLog,
|
|
13
14
|
type AuditOutcome,
|
|
14
15
|
type AuditQuery,
|
|
15
|
-
type AuditSink,
|
|
16
16
|
auditEntry,
|
|
17
17
|
auditLimit,
|
|
18
18
|
auditWindow,
|
|
@@ -138,7 +138,7 @@ export interface PostgresAuditLogOptions {
|
|
|
138
138
|
* read is an event and is always written.
|
|
139
139
|
*/
|
|
140
140
|
readonly reads?: boolean;
|
|
141
|
-
readonly sinks?: readonly AuditSink[];
|
|
141
|
+
readonly sinks?: readonly AuditSink<AuditEntry>[];
|
|
142
142
|
readonly now?: () => Date;
|
|
143
143
|
/** Must answer a uuid: the column is one. Omitted, a time-ordered v7. */
|
|
144
144
|
readonly nextId?: () => string;
|
package/src/audit.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// there is deliberately no update or delete on this interface.
|
|
4
4
|
|
|
5
5
|
import { canonicalJson, finiteCount, REDACTED } from '@ultimat3/core';
|
|
6
|
+
import type { AuditSink } from '@ultimat3/core/adapter';
|
|
6
7
|
import type { AdminActor, AdminDecision } from './authz';
|
|
7
8
|
import { AdminFilterInvalidError } from './errors';
|
|
8
9
|
import { instantOf } from './list-filters';
|
|
@@ -55,11 +56,6 @@ export interface AuditDraft {
|
|
|
55
56
|
readonly diff?: readonly AuditFieldDiff[];
|
|
56
57
|
}
|
|
57
58
|
|
|
58
|
-
/** Where a copy of every entry goes besides the log's own store: stdout as JSON lines, OTel. */
|
|
59
|
-
export interface AuditSink {
|
|
60
|
-
write(entry: AuditEntry): Promise<void> | void;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
59
|
/** A position in the trail: the entry a page ended on. Entries are ordered `at` then `id`. */
|
|
64
60
|
export interface AuditCursor {
|
|
65
61
|
readonly at: string;
|
|
@@ -113,7 +109,11 @@ export interface AuditLog {
|
|
|
113
109
|
}
|
|
114
110
|
|
|
115
111
|
export interface AuditLogOptions {
|
|
116
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Where a copy of every entry goes besides the log's own store: stdout as JSON lines, OTel.
|
|
114
|
+
* `@ultimat3/core`'s one `AuditSink`, over this package's entry — there is no second interface.
|
|
115
|
+
*/
|
|
116
|
+
readonly sinks?: readonly AuditSink<AuditEntry>[];
|
|
117
117
|
/** Injected so tests get deterministic timestamps and ids. */
|
|
118
118
|
readonly now?: () => Date;
|
|
119
119
|
readonly nextId?: () => string;
|
|
@@ -39,23 +39,39 @@ export interface TimelinePanelData {
|
|
|
39
39
|
readonly nPlusOne: readonly StatementLoopFact[] | null;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Depth-first by start time. A span whose parent is not in this recording — work handed off after
|
|
44
|
+
* its trace's early spans were evicted, or a host's own parenting — is drawn at depth 0, as is one
|
|
45
|
+
* caught in a parent cycle: `totalsByKind` counts every span, so the flame must draw every span,
|
|
46
|
+
* and a walk from `null` alone left those out of it without a word.
|
|
47
|
+
*/
|
|
42
48
|
function flatten(trace: RequestTrace): readonly FlameRow[] {
|
|
49
|
+
const ids = new Set(trace.spans.map((span) => span.id));
|
|
43
50
|
const byParent = new Map<string | null, TimelineSpan[]>();
|
|
44
51
|
for (const span of trace.spans) {
|
|
45
|
-
const
|
|
52
|
+
const parent =
|
|
53
|
+
span.parentId !== null && span.parentId !== span.id && ids.has(span.parentId)
|
|
54
|
+
? span.parentId
|
|
55
|
+
: null;
|
|
56
|
+
const bucket = byParent.get(parent) ?? [];
|
|
46
57
|
bucket.push(span);
|
|
47
|
-
byParent.set(
|
|
58
|
+
byParent.set(parent, bucket);
|
|
48
59
|
}
|
|
49
60
|
const total = trace.totalMs === 0 ? 1 : trace.totalMs;
|
|
50
61
|
const rows: FlameRow[] = [];
|
|
62
|
+
const drawn = new Set<TimelineSpan>();
|
|
63
|
+
const byStart = (spans: readonly TimelineSpan[]) =>
|
|
64
|
+
[...spans].sort((a, b) => a.startMs - b.startMs);
|
|
51
65
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
66
|
+
const draw = (span: TimelineSpan, depth: number): void => {
|
|
67
|
+
if (drawn.has(span)) return;
|
|
68
|
+
drawn.add(span);
|
|
69
|
+
rows.push({ span, depth, offset: span.startMs / total, width: span.durationMs / total });
|
|
70
|
+
for (const child of byStart(byParent.get(span.id) ?? [])) draw(child, depth + 1);
|
|
57
71
|
};
|
|
58
|
-
|
|
72
|
+
for (const span of byStart(byParent.get(null) ?? [])) draw(span, 0);
|
|
73
|
+
// Only a cycle of two or more spans is left: none of them is anyone's root.
|
|
74
|
+
for (const span of byStart(trace.spans)) draw(span, 0);
|
|
59
75
|
return rows;
|
|
60
76
|
}
|
|
61
77
|
|
package/src/dev/sql-runner.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The `/_x` DB panel's statement, RUN: inside `readOnlyQuery` — its own `BEGIN READ ONLY`
|
|
2
|
-
// transaction, a statement timeout
|
|
3
|
-
// A host wires `runSql: readOnlySql(client)`; the guard upstream decides
|
|
2
|
+
// transaction, a statement timeout, a row ceiling and the SELECT-only role — never through the
|
|
3
|
+
// app's plain `db.query`. A host wires `runSql: readOnlySql(client)`; the guard upstream decides
|
|
4
|
+
// what reaches it.
|
|
4
5
|
|
|
5
|
-
import { type DbClient, readOnlyQuery } from '@ultimat3/db';
|
|
6
|
+
import { type DbClient, ensureReadOnlyRole, readOnlyQuery } from '@ultimat3/db';
|
|
6
7
|
import type { SqlResult } from './facts';
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -14,15 +15,30 @@ export const DEV_SQL_MAX_ROWS = 500;
|
|
|
14
15
|
/**
|
|
15
16
|
* The runner a `/_x` host hands the DB panel. `assertReadOnlyQuery` (`panel-db.ts`) is the parse
|
|
16
17
|
* guard; this is the SERVER saying no: a statement the guard let through that writes, locks or
|
|
17
|
-
* runs away still meets a read-only transaction that always rolls back,
|
|
18
|
-
*
|
|
18
|
+
* runs away still meets a read-only transaction that always rolls back, run as the role
|
|
19
|
+
* `ensureReadOnlyRole` grants SELECT and nothing else — the layer MCP's `db.query` already had.
|
|
20
|
+
* The app's own credentials are often a dev superuser, so without it a parse-guard miss met one
|
|
21
|
+
* layer, not two. Columns are the first row's keys — the driver hands objects.
|
|
22
|
+
*
|
|
23
|
+
* The role's DDL runs until it succeeds, then never again for this runner. `ensureReadOnlyRole`
|
|
24
|
+
* never throws: a database that refuses `CREATE ROLE` answers `null`, the read runs without that
|
|
25
|
+
* layer, and the next statement asks again.
|
|
19
26
|
*/
|
|
20
27
|
export function readOnlySql(client: DbClient): (sql: string) => Promise<SqlResult> {
|
|
28
|
+
let role: Promise<string | null> | undefined;
|
|
21
29
|
return async (sql) => {
|
|
30
|
+
role ??= ensureReadOnlyRole(client);
|
|
31
|
+
const assumed = await role;
|
|
32
|
+
// `null` is not an answer to keep: a lost race (`x mcp serve` creating the role at the same
|
|
33
|
+
// moment) reads exactly like a refusal, and caching it ran every later statement without the
|
|
34
|
+
// layer. Asked again on the next statement; a role that exists is kept.
|
|
35
|
+
if (assumed === null) role = undefined;
|
|
36
|
+
// After the role: the panel times the statement, not the first call's one-off DDL.
|
|
22
37
|
const started = performance.now();
|
|
23
38
|
const { rows } = await readOnlyQuery<Readonly<Record<string, unknown>>>(sql, {
|
|
24
39
|
client,
|
|
25
40
|
maxRows: DEV_SQL_MAX_ROWS,
|
|
41
|
+
role: assumed,
|
|
26
42
|
});
|
|
27
43
|
const elapsedMs = Math.round(performance.now() - started);
|
|
28
44
|
const columns = Object.keys(rows[0] ?? {});
|
package/src/error-groups-sink.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
errorFingerprint,
|
|
8
8
|
finiteCount,
|
|
9
9
|
isRedactedKey,
|
|
10
|
+
localErrorSink,
|
|
10
11
|
logger,
|
|
11
12
|
REDACTED,
|
|
12
13
|
renderThrowable,
|
|
@@ -158,7 +159,9 @@ export function errorGroupsSink(options: ErrorGroupsSinkOptions): ErrorGroupsSin
|
|
|
158
159
|
}
|
|
159
160
|
};
|
|
160
161
|
|
|
161
|
-
|
|
162
|
+
// Local: the groups live in this app's own table, so a boot with `SENTRY_DSN` still adds its
|
|
163
|
+
// transport beside this sink rather than reading it as the app's monitor.
|
|
164
|
+
return localErrorSink({
|
|
162
165
|
report(report: ErrorReport): void {
|
|
163
166
|
try {
|
|
164
167
|
if (options.ignore?.(report) === true) return;
|
|
@@ -177,5 +180,5 @@ export function errorGroupsSink(options: ErrorGroupsSinkOptions): ErrorGroupsSin
|
|
|
177
180
|
async settled(): Promise<void> {
|
|
178
181
|
while (pending.size > 0) await Promise.all([...pending]);
|
|
179
182
|
},
|
|
180
|
-
};
|
|
183
|
+
});
|
|
181
184
|
}
|
|
@@ -113,6 +113,7 @@ export function memoryErrorGroupsStore(): ErrorGroupsStore {
|
|
|
113
113
|
...seen,
|
|
114
114
|
message: occurrence.message,
|
|
115
115
|
status: statusAfterOccurrence(seen.status),
|
|
116
|
+
// biome-ignore lint/complexity/useMathMinMax: ISO-8601 STRINGS, compared as text — Math.max would return NaN.
|
|
116
117
|
lastSeen: at > seen.lastSeen ? at : seen.lastSeen,
|
|
117
118
|
count: seen.count + occurrence.count,
|
|
118
119
|
sample: occurrence.sample,
|
package/src/index.ts
CHANGED
|
@@ -2,13 +2,10 @@
|
|
|
2
2
|
// Explicit exports only — a barrel that re-exports everything is how internal helpers become
|
|
3
3
|
// someone's dependency.
|
|
4
4
|
|
|
5
|
-
export { AdminActionForm, type AdminActionFormProps } from './action-form';
|
|
6
5
|
export {
|
|
7
|
-
ACTION_NOT_APPLICABLE_REASON,
|
|
8
6
|
type ActionGateInput,
|
|
9
7
|
type AdminActionButton,
|
|
10
8
|
actionButtons,
|
|
11
|
-
actionDecisions,
|
|
12
9
|
adminPermissionForAction,
|
|
13
10
|
decideAction,
|
|
14
11
|
type InvokeInput,
|
|
@@ -16,30 +13,9 @@ export {
|
|
|
16
13
|
invokeAdminAction,
|
|
17
14
|
permissionsForAction,
|
|
18
15
|
} from './action-gate';
|
|
19
|
-
export {
|
|
20
|
-
ACTION_INPUT_PREFIX,
|
|
21
|
-
type ActionInputControl,
|
|
22
|
-
type ActionInputField,
|
|
23
|
-
actionInputFields,
|
|
24
|
-
decodeActionInput,
|
|
25
|
-
} from './action-input';
|
|
26
16
|
export { invokeRowAction, type RowActionInput } from './action-row';
|
|
27
|
-
export {
|
|
28
|
-
|
|
29
|
-
ACTION_PARAM,
|
|
30
|
-
AdminActions,
|
|
31
|
-
type AdminActionsProps,
|
|
32
|
-
actionFormHref,
|
|
33
|
-
BATCH_OPERATION,
|
|
34
|
-
DELETE_OPERATION,
|
|
35
|
-
OPERATION_FIELD,
|
|
36
|
-
} from './actions';
|
|
37
|
-
export {
|
|
38
|
-
type AdminRequestActor,
|
|
39
|
-
ANONYMOUS_ADMIN_ACTOR,
|
|
40
|
-
adminActorFrom,
|
|
41
|
-
requestActor,
|
|
42
|
-
} from './actor';
|
|
17
|
+
export { AdminActions, type AdminActionsProps } from './actions';
|
|
18
|
+
export { adminActorFrom } from './actor';
|
|
43
19
|
export {
|
|
44
20
|
type AdminApp,
|
|
45
21
|
type AdminAuth,
|
|
@@ -58,12 +34,9 @@ export {
|
|
|
58
34
|
type AiPanesOptions,
|
|
59
35
|
type AiRunner,
|
|
60
36
|
aiPanes,
|
|
61
|
-
type GatewayAdapter,
|
|
62
37
|
runAiPane,
|
|
63
38
|
} from './ai-panes';
|
|
64
39
|
export {
|
|
65
|
-
AUDIT_PAGE_DEFAULT,
|
|
66
|
-
AUDIT_PAGE_MAX,
|
|
67
40
|
type AuditCursor,
|
|
68
41
|
type AuditDraft,
|
|
69
42
|
type AuditEntry,
|
|
@@ -72,25 +45,15 @@ export {
|
|
|
72
45
|
type AuditLogOptions,
|
|
73
46
|
type AuditOutcome,
|
|
74
47
|
type AuditQuery,
|
|
75
|
-
type AuditSink,
|
|
76
48
|
auditCursorOf,
|
|
77
|
-
auditEntry,
|
|
78
49
|
auditEntryMatches,
|
|
79
50
|
auditWindow,
|
|
80
51
|
deniedDraft,
|
|
81
52
|
diffRows,
|
|
82
53
|
memoryAuditLog,
|
|
83
54
|
} from './audit';
|
|
84
|
-
export {
|
|
85
|
-
|
|
86
|
-
postgresAuditLog,
|
|
87
|
-
} from './audit-pg';
|
|
88
|
-
export {
|
|
89
|
-
type AuditRequest,
|
|
90
|
-
auditHref,
|
|
91
|
-
auditRequestOf,
|
|
92
|
-
HISTORY_PARAM,
|
|
93
|
-
} from './audit-request';
|
|
55
|
+
export { type PostgresAuditLogOptions, postgresAuditLog } from './audit-pg';
|
|
56
|
+
export { type AuditRequest, auditHref, auditRequestOf, HISTORY_PARAM } from './audit-request';
|
|
94
57
|
// The audit table's DDL is `@ultimat3/admin/schema`'s alone — one import path for one statement.
|
|
95
58
|
export {
|
|
96
59
|
type AdminActor,
|
|
@@ -100,36 +63,23 @@ export {
|
|
|
100
63
|
type AdminSubject,
|
|
101
64
|
adminAllowed,
|
|
102
65
|
adminDenied,
|
|
103
|
-
anonymousAuthz,
|
|
104
66
|
decideAll,
|
|
105
67
|
expandPermissions,
|
|
106
|
-
isAllowed,
|
|
107
68
|
staticAuthz,
|
|
108
69
|
} from './authz';
|
|
109
70
|
export {
|
|
110
71
|
type AdminBatchAnswer,
|
|
111
72
|
type AdminBatchInput,
|
|
112
73
|
type AdminBatchResult,
|
|
113
|
-
BATCH_NOT_OFFERED_REASON,
|
|
114
74
|
type BatchEnqueue,
|
|
115
75
|
type BatchRow,
|
|
116
76
|
type BatchRowOutcome,
|
|
117
77
|
type BatchSelection,
|
|
118
|
-
batchConfirmationToken,
|
|
119
78
|
batchPlan,
|
|
120
79
|
MAX_BATCH_QUEUED_ROWS,
|
|
121
80
|
MAX_BATCH_ROWS,
|
|
122
81
|
runAdminBatch,
|
|
123
82
|
} from './batch';
|
|
124
|
-
export {
|
|
125
|
-
AdminBatchBar,
|
|
126
|
-
type AdminBatchBarProps,
|
|
127
|
-
} from './batch-bar';
|
|
128
|
-
export {
|
|
129
|
-
ADMIN_BATCH_JOB,
|
|
130
|
-
batchEnqueue,
|
|
131
|
-
} from './batch-job';
|
|
132
|
-
export { BATCH_MATCHING_REASON, matchingConfirmationToken } from './batch-matching';
|
|
133
83
|
export { BATCH_QUEUED_REASON } from './batch-queue';
|
|
134
84
|
export {
|
|
135
85
|
type AdminClaimData,
|
|
@@ -151,37 +101,26 @@ export {
|
|
|
151
101
|
type ListResult,
|
|
152
102
|
permissionsForOperation,
|
|
153
103
|
} from './crud';
|
|
154
|
-
export {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
describeAdmin,
|
|
104
|
+
export type {
|
|
105
|
+
AdminActionDescription,
|
|
106
|
+
AdminDescription,
|
|
107
|
+
AdminResourceDescription,
|
|
108
|
+
AdminRouteDescription,
|
|
109
|
+
AdminScopeDescription,
|
|
110
|
+
AdminSectionDescription,
|
|
162
111
|
} from './describe';
|
|
163
|
-
export {
|
|
164
|
-
|
|
165
|
-
type AdminDetailProps,
|
|
166
|
-
} from './detail';
|
|
167
|
-
export {
|
|
168
|
-
type AdminColumnFacts,
|
|
169
|
-
type AdminColumnReference,
|
|
170
|
-
adminColumnsOf,
|
|
171
|
-
adminSealedColumnsOf,
|
|
172
|
-
} from './entity-columns';
|
|
112
|
+
export { AdminDetail, type AdminDetailProps } from './detail';
|
|
113
|
+
export type { AdminColumnFacts, AdminColumnReference } from './entity-columns';
|
|
173
114
|
// The screens only. The sink and its stores are `@ultimat3/admin/error-groups`'s alone: every role
|
|
174
115
|
// installs the sink, and this barrel would carry every admin screen into a worker's graph.
|
|
175
116
|
export { ERRORS_READ, type ErrorGroupsPagesOptions, errorGroupsPages } from './error-groups-pages';
|
|
117
|
+
export type { AdminErrorCode } from './errors';
|
|
176
118
|
// The /_x dashboard is NOT re-exported here — it has its own door, `@ultimat3/admin/dev`, so a
|
|
177
119
|
// host that only mounts the dev panels never loads a production admin component.
|
|
178
120
|
export {
|
|
179
|
-
ADMIN_ERROR_CODES,
|
|
180
|
-
ADMIN_ERROR_TITLES,
|
|
181
121
|
AdminActionDuplicateError,
|
|
182
122
|
AdminActionNotApplicableError,
|
|
183
123
|
AdminEntityUnknownError,
|
|
184
|
-
type AdminErrorCode,
|
|
185
124
|
type AdminErrorParts,
|
|
186
125
|
AdminFieldUnsupportedError,
|
|
187
126
|
AdminFilterInvalidError,
|
|
@@ -190,7 +129,6 @@ export {
|
|
|
190
129
|
AdminPageUnguardedError,
|
|
191
130
|
AdminPolicyMissingError,
|
|
192
131
|
AdminRepoUnboundError,
|
|
193
|
-
adminErrorFrom,
|
|
194
132
|
DevDashboardInProdError,
|
|
195
133
|
DevSourceUnavailableError,
|
|
196
134
|
} from './errors';
|
|
@@ -199,51 +137,19 @@ export {
|
|
|
199
137
|
type AdminFieldType,
|
|
200
138
|
type AdminFilterKind,
|
|
201
139
|
type AdminWidget,
|
|
202
|
-
fieldTypeFromColumn,
|
|
203
140
|
filterable,
|
|
204
|
-
filterKindFor,
|
|
205
141
|
listable,
|
|
206
142
|
searchable,
|
|
207
143
|
sortable,
|
|
208
|
-
WIDGET_BY_FIELD_TYPE,
|
|
209
|
-
widgetFor,
|
|
210
144
|
} from './fields';
|
|
211
|
-
export { AdminForm, type AdminFormProps
|
|
212
|
-
export {
|
|
145
|
+
export { AdminForm, type AdminFormProps } from './form';
|
|
146
|
+
export { posted } from './form-decode';
|
|
213
147
|
export { JOB_MANAGE, JOB_READ } from './jobs/job-actions';
|
|
214
|
-
export {
|
|
215
|
-
export { JOBS_PATH, jobRowScope } from './jobs/job-resources';
|
|
148
|
+
export { jobRowScope } from './jobs/job-resources';
|
|
216
149
|
export { AdminLayout, type AdminLayoutProps, adminActorLabel } from './layout';
|
|
217
150
|
export { AdminList, type AdminListProps } from './list';
|
|
218
|
-
export { type
|
|
219
|
-
export {
|
|
220
|
-
export {
|
|
221
|
-
type AskedFilter,
|
|
222
|
-
checkedFilter,
|
|
223
|
-
declaredFilters,
|
|
224
|
-
FILTER_PREFIX,
|
|
225
|
-
filterOpsOf,
|
|
226
|
-
filterParam,
|
|
227
|
-
knownFilterParams,
|
|
228
|
-
} from './list-filters';
|
|
229
|
-
export {
|
|
230
|
-
CURSOR_PARAM,
|
|
231
|
-
type ListLocation,
|
|
232
|
-
listHref,
|
|
233
|
-
pageRequestOf,
|
|
234
|
-
SCOPE_PARAM,
|
|
235
|
-
SORT_PARAM,
|
|
236
|
-
} from './list-request';
|
|
237
|
-
export {
|
|
238
|
-
type AdminListRequest,
|
|
239
|
-
findRow,
|
|
240
|
-
type ListWhere,
|
|
241
|
-
listWhere,
|
|
242
|
-
rowWhere,
|
|
243
|
-
scopeCounts,
|
|
244
|
-
scopeNamed,
|
|
245
|
-
} from './list-scope';
|
|
246
|
-
export { AdminScopeTabs, type AdminScopeTabsProps } from './list-scope-tabs';
|
|
151
|
+
export { type ListLocation, listHref, pageRequestOf } from './list-request';
|
|
152
|
+
export { type AdminListRequest, findRow, type ListWhere, listWhere } from './list-scope';
|
|
247
153
|
export { type AdminMcpActor, type AdminMcpOptions, adminMcp } from './mcp';
|
|
248
154
|
export { type AdminToolResult, callAdminTool, type McpInput } from './mcp-dispatch';
|
|
249
155
|
export { adminTokenScopes, adminToolScopes } from './mcp-scopes';
|
|
@@ -253,40 +159,21 @@ export {
|
|
|
253
159
|
type AdminToolKind,
|
|
254
160
|
adminMcpTools,
|
|
255
161
|
adminToolCatalog,
|
|
256
|
-
adminToolDecisions,
|
|
257
162
|
} from './mcp-tools';
|
|
258
|
-
export {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
adminMounts,
|
|
262
|
-
clearAdminMounts,
|
|
263
|
-
registerAdminMount,
|
|
264
|
-
} from './mounts';
|
|
265
|
-
export { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
|
|
266
|
-
export { AdminPageDenied, auditRefusal } from './page-guard';
|
|
163
|
+
export { ADMIN_MOUNTS, adminMounts, clearAdminMounts } from './mounts';
|
|
164
|
+
export { type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
|
|
165
|
+
export { AdminPageDenied } from './page-guard';
|
|
267
166
|
export {
|
|
268
167
|
type AdminCustomPage,
|
|
269
168
|
type AdminPageComponent,
|
|
270
169
|
type AdminPageProps,
|
|
271
|
-
pageNavItems,
|
|
272
170
|
pagePermissions,
|
|
273
171
|
pageRoutes,
|
|
274
172
|
} from './pages';
|
|
275
|
-
export {
|
|
276
|
-
type AdminCursor,
|
|
277
|
-
type AdminPage,
|
|
278
|
-
decodeAdminCursor,
|
|
279
|
-
encodeAdminCursor,
|
|
280
|
-
fetchPage,
|
|
281
|
-
listQuery,
|
|
282
|
-
type PageRequest,
|
|
283
|
-
pageFrom,
|
|
284
|
-
} from './pagination';
|
|
173
|
+
export { type AdminCursor, type AdminPage, type PageRequest, pageFrom } from './pagination';
|
|
285
174
|
export {
|
|
286
175
|
ADMIN_DESTROY,
|
|
287
|
-
ADMIN_IMPERSONATE,
|
|
288
176
|
ADMIN_OPERATION_RULES,
|
|
289
|
-
ADMIN_OPERATIONS,
|
|
290
177
|
ADMIN_PERMISSION_SPEC,
|
|
291
178
|
ADMIN_PERMISSIONS,
|
|
292
179
|
ADMIN_READ,
|
|
@@ -294,8 +181,6 @@ export {
|
|
|
294
181
|
type AdminOperation,
|
|
295
182
|
type AdminPermission,
|
|
296
183
|
type AdminPermissionRule,
|
|
297
|
-
adminPermissionFor,
|
|
298
|
-
CONFIRMATION_REQUIRED_REASON,
|
|
299
184
|
confirmationToken,
|
|
300
185
|
entityPermissionFor,
|
|
301
186
|
isDestructive,
|
|
@@ -327,64 +212,38 @@ export {
|
|
|
327
212
|
type AdminSort,
|
|
328
213
|
type AdminTable,
|
|
329
214
|
type AdminTableRead,
|
|
330
|
-
computedRow,
|
|
331
|
-
FILTER_OPS,
|
|
332
215
|
type FilterOp,
|
|
333
216
|
type KeysetBound,
|
|
334
|
-
type RegisteredEntity,
|
|
335
|
-
type RegisteredTable,
|
|
336
217
|
readField,
|
|
337
218
|
rowId,
|
|
338
219
|
} from './registry';
|
|
220
|
+
export { type AdminRelated, type AdminRelatedList, relatedLists, relatedOf } from './related';
|
|
339
221
|
export {
|
|
340
|
-
type AdminRelated,
|
|
341
|
-
type AdminRelatedList,
|
|
342
|
-
RELATED_PAGE_SIZE,
|
|
343
|
-
relatedLists,
|
|
344
|
-
relatedOf,
|
|
345
|
-
} from './related';
|
|
346
|
-
export {
|
|
347
|
-
type AdminLookupRequest,
|
|
348
|
-
type AdminLookupResult,
|
|
349
222
|
type AdminOption,
|
|
350
|
-
adminLookup,
|
|
351
|
-
LOOKUP_SELECT_MAX,
|
|
352
223
|
labelOf,
|
|
353
224
|
type RelationData,
|
|
354
225
|
type RelationNeed,
|
|
355
226
|
relationDataFor,
|
|
356
|
-
relationNeeds,
|
|
357
227
|
} from './relations';
|
|
358
|
-
export {
|
|
359
|
-
export {
|
|
360
|
-
export {
|
|
361
|
-
type AdminResource,
|
|
362
|
-
type AdminResourceOptions,
|
|
363
|
-
adminResource,
|
|
364
|
-
repoOf,
|
|
365
|
-
resourceFor,
|
|
366
|
-
} from './resource';
|
|
228
|
+
export { adminEntitiesOf, adminRepoFor } from './repo-entity';
|
|
229
|
+
export { type AdminResource, type AdminResourceOptions, adminResource, repoOf } from './resource';
|
|
367
230
|
export type { AdminFieldOverride } from './resource-fields';
|
|
368
|
-
export {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
DEFAULT_SECTION_KEY,
|
|
231
|
+
export type {
|
|
232
|
+
AdminFormGroupOptions,
|
|
233
|
+
AdminSection,
|
|
234
|
+
AdminSectionOptions,
|
|
373
235
|
} from './resource-layout';
|
|
374
|
-
export {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
type AdminScopeWhere,
|
|
385
|
-
LOOKUP_SEGMENT,
|
|
236
|
+
export type {
|
|
237
|
+
AdminColumnComponent,
|
|
238
|
+
AdminColumnRenderProps,
|
|
239
|
+
AdminComputedColumn,
|
|
240
|
+
AdminComputedColumnOptions,
|
|
241
|
+
AdminRenderer,
|
|
242
|
+
AdminRowScope,
|
|
243
|
+
AdminScope,
|
|
244
|
+
AdminScopeOptions,
|
|
245
|
+
AdminScopeWhere,
|
|
386
246
|
} from './resource-list';
|
|
387
|
-
export { AdminViewWriteError, VIEW_OPERATIONS } from './resource-operations';
|
|
388
247
|
export {
|
|
389
248
|
type AdminRouteConfig,
|
|
390
249
|
type AdminRouteMatch,
|
|
@@ -393,17 +252,13 @@ export {
|
|
|
393
252
|
adminRouteMatch,
|
|
394
253
|
adminRoutes,
|
|
395
254
|
} from './routes';
|
|
396
|
-
export { ROW_OUT_OF_SCOPE_REASON } from './row-scope-write';
|
|
397
255
|
export { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
|
|
398
256
|
export {
|
|
399
257
|
type AdminRouteRequest,
|
|
400
258
|
type AdminRouteResponse,
|
|
401
259
|
type AdminScreen,
|
|
402
260
|
guardedScreen,
|
|
403
|
-
lookupHref,
|
|
404
|
-
widgetContextOf,
|
|
405
261
|
} from './screen-frame';
|
|
406
|
-
export { type OperationDecision, OperationMatrix, operationMatrix } from './screen-home';
|
|
407
262
|
export {
|
|
408
263
|
type AdminSearchHit,
|
|
409
264
|
type AdminSearchInput,
|
|
@@ -411,23 +266,12 @@ export {
|
|
|
411
266
|
adminSearch,
|
|
412
267
|
} from './search';
|
|
413
268
|
export { type AdminTestCtxInput, adminTestCtx } from './test-ctx';
|
|
414
|
-
export {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
type ThemeMode,
|
|
420
|
-
type ThemeTokenRef,
|
|
421
|
-
themeAttributes,
|
|
269
|
+
export type {
|
|
270
|
+
AdminBranding,
|
|
271
|
+
ThemeAttributes,
|
|
272
|
+
ThemeMode,
|
|
273
|
+
ThemeTokenRef,
|
|
422
274
|
} from './theme';
|
|
423
275
|
export { type ValidationIssue, type ValidationResult, validateInput } from './validate';
|
|
424
|
-
export {
|
|
425
|
-
assertMoney,
|
|
426
|
-
assertZone,
|
|
427
|
-
type GuardedField,
|
|
428
|
-
type SelectOption,
|
|
429
|
-
type WidgetContext,
|
|
430
|
-
type WidgetProps,
|
|
431
|
-
widgetProps,
|
|
432
|
-
} from './widget-value';
|
|
276
|
+
export type { SelectOption, WidgetContext, WidgetProps } from './widget-value';
|
|
433
277
|
export { optionLabel, Widget, type WidgetInput } from './widgets';
|
package/src/jobs/operator.ts
CHANGED
|
@@ -21,13 +21,13 @@ export function jobsOperator(): JobsOperator {
|
|
|
21
21
|
throw new DriverUnavailableError({
|
|
22
22
|
driver: 'none',
|
|
23
23
|
cause: 'the jobs dashboard was asked for rows and this process installed no queue',
|
|
24
|
-
fix: 'run the app through x dev or the container entry, which install the queue — or in a test:
|
|
24
|
+
fix: 'run the app through x dev or the container entry, which install the queue — or in a test: installJobDriver(memoryJobDriver()) from @ultimat3/jobs/adapter',
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
27
|
if (driver.introspect === undefined) {
|
|
28
28
|
throw new NotImplementedError({
|
|
29
29
|
cause: `the operator surface of the "${driver.name}" jobs driver is not implemented: the driver has no introspect`,
|
|
30
|
-
fix: '
|
|
30
|
+
fix: 'export const runtime = { jobs: postgresJobDriver() } # apps/<app>/runtime.ts — or delete that line so the boot installs it; the pg and memory drivers implement introspect',
|
|
31
31
|
});
|
|
32
32
|
}
|
|
33
33
|
return { driver, introspect: driver.introspect };
|
package/src/layout.tsx
CHANGED
|
@@ -128,7 +128,7 @@ export function AdminLayout(props: AdminLayoutProps): JSX.Element {
|
|
|
128
128
|
|
|
129
129
|
{/* The way out. The admin's route table never points back at the app, so without this an
|
|
130
130
|
operator who opened the dashboard has no link home and edits the URL bar. */}
|
|
131
|
-
<a class={styles['navLink']} href=
|
|
131
|
+
<a class={styles['navLink']} href={props.app.home ?? '/'}>
|
|
132
132
|
{t('admin.backToApp')}
|
|
133
133
|
</a>
|
|
134
134
|
</aside>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The admin as one request reached it: the same declaration, its links rooted where the request
|
|
2
|
+
// was SERVED — behind an HTTP base path, under a routed locale, or both (`/myapp/es/admin`). Every
|
|
3
|
+
// link, form action and redirect a screen writes starts from `app.basePath` or `app.home`, so this
|
|
4
|
+
// one substitution keeps every click where the operator is instead of dropping the prefix.
|
|
5
|
+
|
|
6
|
+
import type { AdminApp } from './admin';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `app` itself when it was reached at its declared base path; otherwise a copy whose `basePath` is
|
|
10
|
+
* the pathname up to and including the declared base (`/myapp/es/admin`) and whose `home` is the
|
|
11
|
+
* part before it with a trailing slash (`/myapp/es/`). The first match from the left is the mount:
|
|
12
|
+
* a row id or a page path that repeats the base comes after it. The route table (`routes`) is left
|
|
13
|
+
* as declared: the host matches on the stripped route path, and the audit names the route.
|
|
14
|
+
*/
|
|
15
|
+
export function adminAppFor(app: AdminApp, url: string): AdminApp {
|
|
16
|
+
const segments = (URL.parse(url, 'http://localhost')?.pathname ?? '').split('/').slice(1);
|
|
17
|
+
const base = app.basePath.split('/').filter((segment) => segment !== '');
|
|
18
|
+
for (let at = 0; at + base.length <= segments.length; at += 1) {
|
|
19
|
+
if (!base.every((segment, i) => segments[at + i] === segment)) continue;
|
|
20
|
+
if (at === 0) return app;
|
|
21
|
+
const served = segments.slice(0, at);
|
|
22
|
+
return {
|
|
23
|
+
...app,
|
|
24
|
+
basePath: `/${[...served, ...base].join('/')}`,
|
|
25
|
+
home: `/${served.join('/')}/`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return app;
|
|
29
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { compilePattern, type RouteConfig, type RouteGuard } from '@ultimat3/render';
|
|
6
6
|
import type { AdminApp, AdminRoute } from './admin';
|
|
7
7
|
import { AdminPagePathInvalidError } from './errors';
|
|
8
|
+
import { adminAppFor } from './localized-app';
|
|
8
9
|
import { adminRouteDefinition } from './route-config';
|
|
9
10
|
import type { AdminRouteRequest, AdminRouteResponse } from './screen-frame';
|
|
10
11
|
import { screenFor } from './screens';
|
|
@@ -32,12 +33,19 @@ export interface AdminRouteConfig {
|
|
|
32
33
|
/** One route of the table, with its gate, its served config and its screen. */
|
|
33
34
|
export function adminRouteConfig(app: AdminApp, route: AdminRoute): AdminRouteConfig {
|
|
34
35
|
const { policy, config } = adminRouteDefinition(route);
|
|
36
|
+
// Built now, so a route with nothing behind it is refused at mount, not at its first request.
|
|
37
|
+
const screen = screenFor(app, route);
|
|
35
38
|
return {
|
|
36
39
|
path: route.path,
|
|
37
40
|
view: route.view,
|
|
38
41
|
entity: route.entity,
|
|
39
42
|
permissions: route.permissions,
|
|
40
|
-
|
|
43
|
+
// Under a routed locale the screen is built over the admin as that locale spells it, so every
|
|
44
|
+
// link and redirect keeps the prefix (`localized-app.ts`).
|
|
45
|
+
respond: (request) => {
|
|
46
|
+
const under = adminAppFor(app, request.url);
|
|
47
|
+
return (under === app ? screen : screenFor(under, route))(request);
|
|
48
|
+
},
|
|
41
49
|
policy,
|
|
42
50
|
config,
|
|
43
51
|
};
|
package/src/screen-system.tsx
CHANGED
|
@@ -100,6 +100,9 @@ export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
|
100
100
|
// Refused before anything is read: a parameter this screen does not answer is a 400 naming
|
|
101
101
|
// what it does (`guardedScreen`), never the unfiltered trail.
|
|
102
102
|
const asked = auditRequestOf(new URL(request.url));
|
|
103
|
+
// The trail's links and its filter form point at the page AS SERVED — under `/es/admin/audit`
|
|
104
|
+
// that keeps the locale, where the declared `route.path` dropped it on every click.
|
|
105
|
+
const here = new URL(request.url).pathname;
|
|
103
106
|
// The actor's tenant's trail: `audit:read` held in org A is not a read of org B's operators,
|
|
104
107
|
// their ids or what they touched. An actor with no org — the platform — reads every tenant's.
|
|
105
108
|
// Never a parameter: the URL cannot name another tenant.
|
|
@@ -128,7 +131,7 @@ export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
|
128
131
|
const { before: _page, ...filters } = asked;
|
|
129
132
|
return (
|
|
130
133
|
<Card>
|
|
131
|
-
<AuditFilter action={
|
|
134
|
+
<AuditFilter action={here} asked={asked} />
|
|
132
135
|
{entries.length === 0 ? (
|
|
133
136
|
<p class="x-admin-empty">{t('admin.audit.empty')}</p>
|
|
134
137
|
) : (
|
|
@@ -139,7 +142,7 @@ export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
|
139
142
|
return (
|
|
140
143
|
<li>
|
|
141
144
|
<code>{entry.at}</code>{' '}
|
|
142
|
-
<a href={auditHref(
|
|
145
|
+
<a href={auditHref(here, { ...filters, actorId: entry.actor.id })}>
|
|
143
146
|
{entry.actor.id}
|
|
144
147
|
</a>{' '}
|
|
145
148
|
<span>{operationLabel(entry)}</span>{' '}
|
|
@@ -158,9 +161,7 @@ export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
|
158
161
|
</ol>
|
|
159
162
|
)}
|
|
160
163
|
{last === null ? null : (
|
|
161
|
-
<Link href={auditHref(
|
|
162
|
-
{t('admin.audit.older')}
|
|
163
|
-
</Link>
|
|
164
|
+
<Link href={auditHref(here, { ...filters, before: last })}>{t('admin.audit.older')}</Link>
|
|
164
165
|
)}
|
|
165
166
|
</Card>
|
|
166
167
|
);
|