@ultimat3/admin 1.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/LICENSE +21 -0
- package/README.md +115 -0
- package/package.json +48 -0
- package/src/action-gate.ts +202 -0
- package/src/actions.tsx +94 -0
- package/src/admin.ts +186 -0
- package/src/ai-panes.ts +139 -0
- package/src/audit.ts +183 -0
- package/src/authz.ts +129 -0
- package/src/crud.ts +278 -0
- package/src/detail.tsx +121 -0
- package/src/dev/data.ts +344 -0
- package/src/dev/facts.ts +180 -0
- package/src/dev/index.ts +47 -0
- package/src/dev/panel-cache.ts +47 -0
- package/src/dev/panel-db.ts +59 -0
- package/src/dev/panel-jobs.ts +66 -0
- package/src/dev/panel-live.ts +46 -0
- package/src/dev/panel-mail.ts +51 -0
- package/src/dev/panel-manifest.ts +39 -0
- package/src/dev/panel-policy.ts +61 -0
- package/src/dev/panel-routes.ts +43 -0
- package/src/dev/panel-timeline.ts +82 -0
- package/src/dev/panel.ts +58 -0
- package/src/dev/server.ts +189 -0
- package/src/entity-columns.ts +95 -0
- package/src/errors.ts +145 -0
- package/src/fields.ts +151 -0
- package/src/form.tsx +97 -0
- package/src/index.ts +214 -0
- package/src/layout.tsx +104 -0
- package/src/list.tsx +120 -0
- package/src/mcp-tools.ts +201 -0
- package/src/mcp.ts +304 -0
- package/src/nav.ts +97 -0
- package/src/pagination.ts +152 -0
- package/src/permissions.ts +92 -0
- package/src/policy-bridge.ts +64 -0
- package/src/registry.ts +181 -0
- package/src/resource.ts +321 -0
- package/src/routes.ts +35 -0
- package/src/search.ts +108 -0
- package/src/theme.ts +59 -0
- package/src/validate.ts +65 -0
- package/src/widget-value.ts +217 -0
- package/src/widgets.tsx +262 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 developerz.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# @ultimat3/admin 🛠️
|
|
2
|
+
|
|
3
|
+
**Two dashboards live here. They are not the same thing — and they have two doors.**
|
|
4
|
+
|
|
5
|
+
| | `/_x` — framework dev dashboard | `admin` — generated app admin |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| Import | `@ultimat3/admin/dev` | `@ultimat3/admin` |
|
|
8
|
+
| Audience | you, debugging the framework | your operators, and their agents |
|
|
9
|
+
| Environment | development **only** — mounting it with `env=production` or `role=production` throws `X_DEV_DASHBOARD_IN_PROD` | production |
|
|
10
|
+
| Authz | none: it is your own machine | the app's policies, one decision per surface |
|
|
11
|
+
| Data | introspection calls (`describeRoutes`, `inspect`, `dependentsOf`, …) | the entity registry + repos |
|
|
12
|
+
| Shipped in the app image | never mounted | mounted at `/admin` |
|
|
13
|
+
|
|
14
|
+
## `/_x` panels
|
|
15
|
+
|
|
16
|
+
One panel per file. Each kills one question, and each is available as `--json` — the tab is a rendering of the payload, not a second source.
|
|
17
|
+
|
|
18
|
+
| Panel | Kills |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `routes` | which handler serves this? — render mode, offline strategy, budget, meta |
|
|
21
|
+
| `timeline` | where did the time go? — flamegraph of SQL, cache, action, policy spans + the N+1 count |
|
|
22
|
+
| `live` | what does each subscriber receive, and **why** — the matcher's decision trace |
|
|
23
|
+
| `jobs` | queue depth, step traces, retry-from-step target, dead letter |
|
|
24
|
+
| `db` | psql in a tab (read-only; `assertReadOnly` refuses DML), schema diff vs migrations |
|
|
25
|
+
| `mail` | caught mail, rendered, per locale, with the locale gaps listed |
|
|
26
|
+
| `cache` | the tag graph — what invalidated what, and which tags are orphans |
|
|
27
|
+
| `policy` | the permission matrix per actor, every cell carrying its trace |
|
|
28
|
+
| `manifest` | emitted `x.manifest.json` diffed against the committed one |
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { devDashboard, defaultDevSources } from '@ultimat3/admin/dev';
|
|
32
|
+
|
|
33
|
+
const dev = devDashboard({ sources: defaultDevSources({ authz, actors }) }); // throws in prod
|
|
34
|
+
const response = await dev.handle(request); // null when the path is not /_x
|
|
35
|
+
await dev.json('jobs'); // the same payload `x dev --panel jobs --json` prints
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The root barrel does not re-export any of this: `x dev` mounts `/_x` without pulling a Solid
|
|
39
|
+
component tree into the process, and an admin view cannot reach a dev panel by accident.
|
|
40
|
+
|
|
41
|
+
## The generated admin
|
|
42
|
+
|
|
43
|
+
One call, a working CRUD admin: columns from the entity's columns, filters from indexed columns, validation from the entity's schema, labels from i18n keys.
|
|
44
|
+
|
|
45
|
+
`entities` takes the entities themselves — the objects `entity()` returned, not their `describeEntities()` projection. The admin reads `$columns`, `$primaryKey`, `$schema` and `$describe()` off them; `RegisteredEntity` in `registry.ts` is the compile-time check that it may.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { defineAdmin, adminRoutes, policyAuthz, memoryAuditLog } from '@ultimat3/admin';
|
|
49
|
+
import { posts, users } from '@app/db/schema';
|
|
50
|
+
|
|
51
|
+
export const admin = defineAdmin({
|
|
52
|
+
entities: [posts, users],
|
|
53
|
+
// `AdminAction` is the admin's own shape: a `permission` (never optional) plus a handler.
|
|
54
|
+
actions: [{ name: 'post.publish', permission: 'post:publish', entity: 'posts', handle }],
|
|
55
|
+
resources: { posts: { repo: postsAdminRepo, listFields: ['title', 'status', 'publishedAt'] } },
|
|
56
|
+
branding: { nameKey: 'admin.brand.name', accent: '--x-color-brand', mode: 'system' },
|
|
57
|
+
auth: { actor: (request) => session(request), authz: policyAuthz({ policies }) },
|
|
58
|
+
audit: memoryAuditLog({ sinks: [auditTable] }),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const routes = adminRoutes(admin); // every page `spa`, `network-only`, noindex
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Derived from the entity, and only from it
|
|
65
|
+
|
|
66
|
+
| Admin decision | Read from |
|
|
67
|
+
|---|---|
|
|
68
|
+
| field type + widget | `$meta.kind`, refined by `values` (select) and `references` (reference) |
|
|
69
|
+
| one line vs prose | `text({ max })` has a length; `text()` does not |
|
|
70
|
+
| read-only | a **generated** default (`uuid()`, `defaultNow()`, `onUpdateNow()`) or a key column |
|
|
71
|
+
| filters, sort | `$meta.index` / `unique` / `primaryKey` — never an unindexed column |
|
|
72
|
+
| row address | `$primaryKey[0]`, composite keys included |
|
|
73
|
+
| validation | `$schema`, the entity's own Standard Schema |
|
|
74
|
+
|
|
75
|
+
`sensitive`, a fixed `currency` and `labelField` have no entity source and are never guessed: declare them in `resources: { <entity>: { … } }` or they are absent.
|
|
76
|
+
|
|
77
|
+
### Rules it enforces for you
|
|
78
|
+
|
|
79
|
+
| Rule | Where |
|
|
80
|
+
|---|---|
|
|
81
|
+
| Money is `{ minor, currency }` — a float throws `X_ADMIN_FIELD_UNSUPPORTED` | `widgetProps` |
|
|
82
|
+
| A timestamp never renders without an IANA zone | `assertZone` |
|
|
83
|
+
| Pagination is keyset — `AdminListQuery` has no `offset` field | `pagination.ts` |
|
|
84
|
+
| A cursor is signed by `@ultimat3/core` and scoped to its resource — a forged or borrowed one is page one, never another table's position | `pagination.ts` |
|
|
85
|
+
| A button an actor cannot press is never rendered, and the call is refused by the same decision | `action-gate.ts` |
|
|
86
|
+
| Destructive operations re-confirm (`<entity>:<id>`) and are always audited | `permissions.ts`, `crud.ts` |
|
|
87
|
+
| Every mutation and every denial is on the audit log, with a before/after diff | `audit.ts` |
|
|
88
|
+
| Branding aliases tokens only — `accent: '#7c3aed'` is a compile error | `theme.ts` |
|
|
89
|
+
|
|
90
|
+
Single-row reads are audited; list pages are not (volume).
|
|
91
|
+
|
|
92
|
+
## AI-first
|
|
93
|
+
|
|
94
|
+
The admin exposes **its own MCP surface**, derived from the same resources and gated by the same authz — an agent sees exactly the tools its actor could have clicked.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { adminMcp, adminMcpTools } from '@ultimat3/admin';
|
|
98
|
+
|
|
99
|
+
export const mcp = adminMcp({ app: admin, actor: (session) => actorFor(session.token) });
|
|
100
|
+
adminMcpTools(admin, ctx); // admin.post.list · admin.post.read · admin.search · admin.action.post.publish
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Opt-in AI panes, each declaring the scope it needs (`aiPanes({ enable: ['anomaly'] })`):
|
|
104
|
+
|
|
105
|
+
| Pane | Scope |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `anomaly` | `jobs:read`, `metrics:read` |
|
|
108
|
+
| `nl-query` | `db:read-only` |
|
|
109
|
+
| `backlog-forecast` | `jobs:read`, `metrics:read` |
|
|
110
|
+
|
|
111
|
+
Panes are off until enabled, and `runAiPane` refuses (never no-ops) without a runner.
|
|
112
|
+
|
|
113
|
+
## Errors
|
|
114
|
+
|
|
115
|
+
`X_ADMIN_ENTITY_UNKNOWN` · `X_ADMIN_FIELD_UNSUPPORTED` · `X_ADMIN_POLICY_MISSING` · `X_DEV_DASHBOARD_IN_PROD` · `X_NOT_IMPLEMENTED` (an unwired `/_x` source, carrying the wiring line).
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ultimat3/admin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/developerz-ai/ultimate.git",
|
|
10
|
+
"directory": "packages/admin"
|
|
11
|
+
},
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"access": "public",
|
|
14
|
+
"provenance": true
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.ts",
|
|
18
|
+
"./dev": "./src/dev/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"!src/**/*.test.ts",
|
|
23
|
+
"README.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"bun": ">=1.3.0"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
31
|
+
"test": "bun test"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@ultimat3/action": "1.0.0",
|
|
35
|
+
"@ultimat3/ai": "1.0.0",
|
|
36
|
+
"@ultimat3/cache": "1.0.0",
|
|
37
|
+
"@ultimat3/core": "1.0.0",
|
|
38
|
+
"@ultimat3/entity": "1.0.0",
|
|
39
|
+
"@ultimat3/i18n": "1.0.0",
|
|
40
|
+
"@ultimat3/jobs": "1.0.0",
|
|
41
|
+
"@ultimat3/mcp": "1.0.0",
|
|
42
|
+
"@ultimat3/money": "1.0.0",
|
|
43
|
+
"@ultimat3/policy": "1.0.0",
|
|
44
|
+
"@ultimat3/query": "1.0.0",
|
|
45
|
+
"@ultimat3/render": "1.0.0",
|
|
46
|
+
"@ultimat3/ui": "1.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// One decision, two consumers. `actionButtons()` decides what renders; `invokeAdminAction()`
|
|
2
|
+
// decides what runs — both call `decideAll()` with the same permissions against the same
|
|
3
|
+
// authz, so a button that renders is a call that is allowed and a call that is denied had no
|
|
4
|
+
// button. The admin's whole authz story is this file plus authz.ts.
|
|
5
|
+
|
|
6
|
+
import { type AuditEntry, type AuditFieldDiff, type AuditLog, deniedDraft } from './audit';
|
|
7
|
+
import {
|
|
8
|
+
type AdminActor,
|
|
9
|
+
type AdminAuthz,
|
|
10
|
+
type AdminDecision,
|
|
11
|
+
type AdminSubject,
|
|
12
|
+
decideAll,
|
|
13
|
+
} from './authz';
|
|
14
|
+
import { ADMIN_DESTROY, ADMIN_WRITE, CONFIRMATION_REQUIRED_REASON } from './permissions';
|
|
15
|
+
import type { AdminAction, AdminActionCtx } from './registry';
|
|
16
|
+
|
|
17
|
+
export interface AdminActionButton {
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly labelKey: string;
|
|
20
|
+
readonly destructive: boolean;
|
|
21
|
+
readonly permission: string;
|
|
22
|
+
readonly entity: string | null;
|
|
23
|
+
/** Carried so the `/_x` policy panel can show why this button is on screen. */
|
|
24
|
+
readonly decision: AdminDecision;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The permissions an action needs: the admin-level gate, then the action's own policy. */
|
|
28
|
+
export function permissionsForAction<Input, Output>(
|
|
29
|
+
action: AdminAction<Input, Output>,
|
|
30
|
+
): readonly string[] {
|
|
31
|
+
return [action.destructive === true ? ADMIN_DESTROY : ADMIN_WRITE, action.permission];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function decideAction<Input, Output>(
|
|
35
|
+
action: AdminAction<Input, Output>,
|
|
36
|
+
actor: AdminActor,
|
|
37
|
+
authz: AdminAuthz,
|
|
38
|
+
subject?: AdminSubject,
|
|
39
|
+
): AdminDecision {
|
|
40
|
+
return decideAll(authz, permissionsForAction(action), actor, subject);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ActionGateInput {
|
|
44
|
+
readonly actions: readonly AdminAction[];
|
|
45
|
+
readonly actor: AdminActor;
|
|
46
|
+
readonly authz: AdminAuthz;
|
|
47
|
+
readonly subject?: AdminSubject;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every action with its decision — what the `/_x` policy panel and tests want to see. */
|
|
51
|
+
export function actionDecisions(
|
|
52
|
+
input: ActionGateInput,
|
|
53
|
+
): readonly { readonly action: AdminAction; readonly decision: AdminDecision }[] {
|
|
54
|
+
return input.actions.map((action) => ({
|
|
55
|
+
action,
|
|
56
|
+
decision: decideAction(action, input.actor, input.authz, input.subject),
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Only the buttons this actor may press. A denied action has no button, ever. */
|
|
61
|
+
export function actionButtons(input: ActionGateInput): readonly AdminActionButton[] {
|
|
62
|
+
return actionDecisions(input)
|
|
63
|
+
.filter(({ decision }) => decision.allowed)
|
|
64
|
+
.map(({ action, decision }) => ({
|
|
65
|
+
name: action.name,
|
|
66
|
+
labelKey: action.labelKey ?? `admin.action.${action.name}`,
|
|
67
|
+
destructive: action.destructive === true,
|
|
68
|
+
permission: action.permission,
|
|
69
|
+
entity: action.entity ?? null,
|
|
70
|
+
decision,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type InvokeResult<Output> =
|
|
75
|
+
| { readonly ok: true; readonly value: Output; readonly audit: AuditEntry }
|
|
76
|
+
| {
|
|
77
|
+
readonly ok: false;
|
|
78
|
+
readonly decision: AdminDecision;
|
|
79
|
+
readonly confirmationRequired: boolean;
|
|
80
|
+
readonly audit: AuditEntry;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export interface InvokeInput<Input, Output> {
|
|
84
|
+
readonly action: AdminAction<Input, Output>;
|
|
85
|
+
readonly input: Input;
|
|
86
|
+
readonly actor: AdminActor;
|
|
87
|
+
readonly authz: AdminAuthz;
|
|
88
|
+
readonly audit: AuditLog;
|
|
89
|
+
readonly requestId: string;
|
|
90
|
+
readonly subject?: AdminSubject;
|
|
91
|
+
/** Echo of `confirmationToken(entity, id)`. Required for a destructive action. */
|
|
92
|
+
readonly confirmation?: string;
|
|
93
|
+
readonly expectedConfirmation?: string;
|
|
94
|
+
readonly locale?: string;
|
|
95
|
+
readonly timeZone?: string;
|
|
96
|
+
/** Before/after of the affected row, when the caller knows it. Always logged. */
|
|
97
|
+
readonly diff?: readonly AuditFieldDiff[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Run an admin action. The policy is consulted first, the confirmation second, the handler
|
|
102
|
+
* last, and all three outcomes are audited before this function returns.
|
|
103
|
+
*/
|
|
104
|
+
export async function invokeAdminAction<Input, Output>(
|
|
105
|
+
args: InvokeInput<Input, Output>,
|
|
106
|
+
): Promise<InvokeResult<Output>> {
|
|
107
|
+
const { action, actor, authz, audit, requestId } = args;
|
|
108
|
+
const entity = action.entity ?? 'admin';
|
|
109
|
+
const entityId = args.subject?.id ?? null;
|
|
110
|
+
const decision = decideAction(action, actor, authz, args.subject);
|
|
111
|
+
|
|
112
|
+
if (!decision.allowed) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
decision,
|
|
116
|
+
confirmationRequired: false,
|
|
117
|
+
audit: await audit.append(
|
|
118
|
+
deniedDraft({
|
|
119
|
+
requestId,
|
|
120
|
+
actor,
|
|
121
|
+
operation: action.name,
|
|
122
|
+
kind: 'action',
|
|
123
|
+
entity,
|
|
124
|
+
entityId,
|
|
125
|
+
decision,
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (action.destructive === true && args.confirmation !== args.expectedConfirmation) {
|
|
132
|
+
const refused: AdminDecision = {
|
|
133
|
+
allowed: false,
|
|
134
|
+
permission: ADMIN_DESTROY,
|
|
135
|
+
reason: CONFIRMATION_REQUIRED_REASON,
|
|
136
|
+
trace: [`confirmation: expected "${args.expectedConfirmation ?? ''}"`],
|
|
137
|
+
};
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
decision: refused,
|
|
141
|
+
confirmationRequired: true,
|
|
142
|
+
audit: await audit.append(
|
|
143
|
+
deniedDraft({
|
|
144
|
+
requestId,
|
|
145
|
+
actor,
|
|
146
|
+
operation: action.name,
|
|
147
|
+
kind: 'action',
|
|
148
|
+
entity,
|
|
149
|
+
entityId,
|
|
150
|
+
decision: refused,
|
|
151
|
+
}),
|
|
152
|
+
),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const ctx: AdminActionCtx = {
|
|
157
|
+
requestId,
|
|
158
|
+
actorId: actor.id,
|
|
159
|
+
locale: args.locale ?? actor.locale ?? 'en',
|
|
160
|
+
timeZone: args.timeZone ?? actor.timeZone ?? 'UTC',
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const value = await action.handle({ input: args.input, ctx });
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
value,
|
|
168
|
+
audit: await audit.append({
|
|
169
|
+
requestId,
|
|
170
|
+
actor,
|
|
171
|
+
operation: action.name,
|
|
172
|
+
kind: 'action',
|
|
173
|
+
entity,
|
|
174
|
+
entityId,
|
|
175
|
+
permission: action.permission,
|
|
176
|
+
outcome: 'allowed',
|
|
177
|
+
reason: decision.reason,
|
|
178
|
+
diff: args.diff ?? [],
|
|
179
|
+
}),
|
|
180
|
+
};
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const failed: AdminDecision = {
|
|
183
|
+
allowed: false,
|
|
184
|
+
permission: action.permission,
|
|
185
|
+
reason: 'admin.error.action-failed',
|
|
186
|
+
trace: [error instanceof Error ? `${error.name}: ${error.message}` : String(error)],
|
|
187
|
+
};
|
|
188
|
+
await audit.append({
|
|
189
|
+
requestId,
|
|
190
|
+
actor,
|
|
191
|
+
operation: action.name,
|
|
192
|
+
kind: 'action',
|
|
193
|
+
entity,
|
|
194
|
+
entityId,
|
|
195
|
+
permission: action.permission,
|
|
196
|
+
outcome: 'failed',
|
|
197
|
+
reason: failed.reason,
|
|
198
|
+
diff: [],
|
|
199
|
+
});
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
package/src/actions.tsx
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Action buttons. The component renders `actionButtons()` and nothing else: if the gate did
|
|
2
|
+
// not return a button, no markup exists for it — not hidden with CSS, not disabled. The same
|
|
3
|
+
// gate then decides the call, so there is one authz system and the UI cannot lie about it.
|
|
4
|
+
//
|
|
5
|
+
// Fully controlled (no local signals): the route owns "which destructive action is pending",
|
|
6
|
+
// which keeps this file a pure function of props and keeps the confirmation state somewhere
|
|
7
|
+
// the server round-trip can also see.
|
|
8
|
+
|
|
9
|
+
import { t } from '@ultimat3/i18n';
|
|
10
|
+
import { Dialog } from '@ultimat3/ui';
|
|
11
|
+
import type { JSX } from 'solid-js';
|
|
12
|
+
import { type AdminActionButton, actionButtons } from './action-gate';
|
|
13
|
+
import type { AdminActor, AdminAuthz, AdminSubject } from './authz';
|
|
14
|
+
import { confirmationToken } from './permissions';
|
|
15
|
+
import type { AdminAction } from './registry';
|
|
16
|
+
|
|
17
|
+
export interface AdminActionsProps {
|
|
18
|
+
readonly actions: readonly AdminAction[];
|
|
19
|
+
readonly actor: AdminActor;
|
|
20
|
+
readonly authz: AdminAuthz;
|
|
21
|
+
// `| undefined` is explicit on every optional prop: under exactOptionalPropertyTypes a
|
|
22
|
+
// parent that forwards its own optional handler would otherwise not typecheck.
|
|
23
|
+
readonly subject?: AdminSubject | undefined;
|
|
24
|
+
/** A non-destructive press, or a confirmed destructive one. */
|
|
25
|
+
readonly onRun?: ((button: AdminActionButton, confirmation: string) => void) | undefined;
|
|
26
|
+
/** A destructive press: the route sets `pending` and re-renders. */
|
|
27
|
+
readonly onRequestConfirm?: ((button: AdminActionButton) => void) | undefined;
|
|
28
|
+
readonly pending?: AdminActionButton | null | undefined;
|
|
29
|
+
readonly confirmation?: string | undefined;
|
|
30
|
+
readonly onConfirmationInput?: ((value: string) => void) | undefined;
|
|
31
|
+
readonly onCancel?: (() => void) | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function AdminActions(props: AdminActionsProps): JSX.Element {
|
|
35
|
+
const buttons = actionButtons({
|
|
36
|
+
actions: props.actions,
|
|
37
|
+
actor: props.actor,
|
|
38
|
+
authz: props.authz,
|
|
39
|
+
...(props.subject === undefined ? {} : { subject: props.subject }),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const expected = confirmationToken(props.subject?.entity ?? 'admin', props.subject?.id ?? '');
|
|
43
|
+
const pending = props.pending ?? null;
|
|
44
|
+
const typed = props.confirmation ?? '';
|
|
45
|
+
|
|
46
|
+
const press = (button: AdminActionButton): void => {
|
|
47
|
+
if (button.destructive) props.onRequestConfirm?.(button);
|
|
48
|
+
else props.onRun?.(button, '');
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (buttons.length === 0) return <span class="x-admin-actions-empty" />;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<fieldset class="x-admin-actions" aria-label={t('admin.actions.label')}>
|
|
55
|
+
{buttons.map((button) => (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
data-destructive={button.destructive ? 'true' : undefined}
|
|
59
|
+
onClick={() => press(button)}
|
|
60
|
+
>
|
|
61
|
+
{t(button.labelKey)}
|
|
62
|
+
</button>
|
|
63
|
+
))}
|
|
64
|
+
|
|
65
|
+
<Dialog
|
|
66
|
+
open={pending !== null}
|
|
67
|
+
title={t('admin.actions.confirm.title')}
|
|
68
|
+
onClose={() => props.onCancel?.()}
|
|
69
|
+
>
|
|
70
|
+
<p>{t('admin.actions.confirm.body', { token: expected })}</p>
|
|
71
|
+
<label for="x-admin-confirm">{t('admin.actions.confirm.label')}</label>
|
|
72
|
+
<input
|
|
73
|
+
id="x-admin-confirm"
|
|
74
|
+
name="confirmation"
|
|
75
|
+
autocomplete="off"
|
|
76
|
+
value={typed}
|
|
77
|
+
onInput={(event: InputEvent) => {
|
|
78
|
+
const target = event.currentTarget;
|
|
79
|
+
props.onConfirmationInput?.(target instanceof HTMLInputElement ? target.value : '');
|
|
80
|
+
}}
|
|
81
|
+
/>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
disabled={typed !== expected}
|
|
85
|
+
onClick={() => {
|
|
86
|
+
if (pending !== null) props.onRun?.(pending, typed);
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
{t('admin.actions.confirm.submit')}
|
|
90
|
+
</button>
|
|
91
|
+
</Dialog>
|
|
92
|
+
</fieldset>
|
|
93
|
+
);
|
|
94
|
+
}
|
package/src/admin.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// `defineAdmin()` — one call, a working dashboard. It derives a resource per entity, hangs
|
|
2
|
+
// each action off the entity it names, builds the nav, and returns the route table plus the
|
|
3
|
+
// audit log and authz every surface then shares. Nothing here queries or renders; it wires.
|
|
4
|
+
|
|
5
|
+
import { type AuditLog, memoryAuditLog } from './audit';
|
|
6
|
+
import type { AdminActor, AdminAuthz } from './authz';
|
|
7
|
+
import type { CrudCtx } from './crud';
|
|
8
|
+
import { permissionsForOperation } from './crud';
|
|
9
|
+
import { adminNav, type NavGroup, type NavOptions, visibleNav } from './nav';
|
|
10
|
+
import type { AdminOperation } from './permissions';
|
|
11
|
+
import type { AdminAction, AdminEntity, AdminJobSummary, AdminRow } from './registry';
|
|
12
|
+
import {
|
|
13
|
+
type AdminResource,
|
|
14
|
+
type AdminResourceOptions,
|
|
15
|
+
adminResource,
|
|
16
|
+
resourceFor,
|
|
17
|
+
} from './resource';
|
|
18
|
+
import { type AdminBranding, adminBranding, type ThemeAttributes, themeAttributes } from './theme';
|
|
19
|
+
|
|
20
|
+
/** The host app's auth hook: it owns sessions, the admin owns what a session may do. */
|
|
21
|
+
export interface AdminAuth {
|
|
22
|
+
actor(request: Request): Promise<AdminActor | null> | AdminActor | null;
|
|
23
|
+
readonly authz: AdminAuthz;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AdminView =
|
|
27
|
+
| 'list'
|
|
28
|
+
| 'detail'
|
|
29
|
+
| 'create'
|
|
30
|
+
| 'edit'
|
|
31
|
+
| 'search'
|
|
32
|
+
| 'jobs'
|
|
33
|
+
| 'audit'
|
|
34
|
+
| 'dashboard';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A route as data. `routes.ts` turns these into `defineRoute()` configs; keeping the table
|
|
38
|
+
* declarative means the MCP surface and the nav read the same paths the router serves.
|
|
39
|
+
*/
|
|
40
|
+
export interface AdminRoute {
|
|
41
|
+
readonly path: string;
|
|
42
|
+
readonly view: AdminView;
|
|
43
|
+
readonly entity: string | null;
|
|
44
|
+
readonly titleKey: string;
|
|
45
|
+
readonly permissions: readonly string[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface DefineAdminInput {
|
|
49
|
+
readonly entities: readonly AdminEntity[];
|
|
50
|
+
/** Per-entity overrides and repo binding, keyed by entity name. */
|
|
51
|
+
readonly resources?: Readonly<Record<string, AdminResourceOptions>>;
|
|
52
|
+
/** Registered actions. Each is attached to `action.entity`, or the global toolbar. */
|
|
53
|
+
readonly actions?: readonly AdminAction[];
|
|
54
|
+
readonly jobs?: readonly AdminJobSummary[];
|
|
55
|
+
readonly nav?: NavOptions;
|
|
56
|
+
readonly branding?: Partial<AdminBranding>;
|
|
57
|
+
readonly auth: AdminAuth;
|
|
58
|
+
readonly audit?: AuditLog;
|
|
59
|
+
/** Mount point. Every route path is prefixed with it. */
|
|
60
|
+
readonly basePath?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface AdminApp {
|
|
64
|
+
readonly basePath: string;
|
|
65
|
+
readonly branding: AdminBranding;
|
|
66
|
+
readonly theme: ThemeAttributes;
|
|
67
|
+
readonly resources: readonly AdminResource[];
|
|
68
|
+
/** Actions with no entity: imports, backfills, anything app-wide. */
|
|
69
|
+
readonly globalActions: readonly AdminAction[];
|
|
70
|
+
readonly jobs: readonly AdminJobSummary[];
|
|
71
|
+
readonly nav: readonly NavGroup[];
|
|
72
|
+
readonly routes: readonly AdminRoute[];
|
|
73
|
+
readonly audit: AuditLog;
|
|
74
|
+
readonly authz: AdminAuthz;
|
|
75
|
+
readonly auth: AdminAuth;
|
|
76
|
+
resource(name: string): AdminResource;
|
|
77
|
+
/** Nav for one actor, with everything they cannot open removed. */
|
|
78
|
+
navFor(ctx: CrudCtx): readonly NavGroup[];
|
|
79
|
+
ctx(input: { readonly actor: AdminActor; readonly requestId: string }): CrudCtx;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const VIEW_OPERATION: Readonly<
|
|
83
|
+
Record<Exclude<AdminView, 'jobs' | 'audit' | 'dashboard'>, AdminOperation>
|
|
84
|
+
> = {
|
|
85
|
+
list: 'list',
|
|
86
|
+
detail: 'detail',
|
|
87
|
+
create: 'create',
|
|
88
|
+
edit: 'update',
|
|
89
|
+
search: 'search',
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
function resourceRoutes(basePath: string, resource: AdminResource): readonly AdminRoute[] {
|
|
93
|
+
const base = `${basePath}${resource.path}`;
|
|
94
|
+
const routes: AdminRoute[] = [];
|
|
95
|
+
const add = (view: Exclude<AdminView, 'jobs' | 'audit' | 'dashboard'>, path: string): void => {
|
|
96
|
+
const op = VIEW_OPERATION[view];
|
|
97
|
+
if (!resource.operations.includes(op)) return;
|
|
98
|
+
routes.push({
|
|
99
|
+
path,
|
|
100
|
+
view,
|
|
101
|
+
entity: resource.name,
|
|
102
|
+
titleKey: resource.titleKey,
|
|
103
|
+
permissions: permissionsForOperation(resource.name, op),
|
|
104
|
+
});
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
add('list', base);
|
|
108
|
+
add('create', `${base}/new`);
|
|
109
|
+
add('detail', `${base}/:id`);
|
|
110
|
+
add('edit', `${base}/:id/edit`);
|
|
111
|
+
return routes;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Derive the whole admin from the registries. */
|
|
115
|
+
export function defineAdmin(input: DefineAdminInput): AdminApp {
|
|
116
|
+
const basePath = input.basePath ?? '/admin';
|
|
117
|
+
const branding = adminBranding(input.branding ?? {});
|
|
118
|
+
const audit = input.audit ?? memoryAuditLog();
|
|
119
|
+
const actions = input.actions ?? [];
|
|
120
|
+
const overrides = input.resources ?? {};
|
|
121
|
+
|
|
122
|
+
const resources = input.entities.map((entity) => {
|
|
123
|
+
const own = actions.filter((action) => action.entity === entity.$name);
|
|
124
|
+
const opts = overrides[entity.$name];
|
|
125
|
+
return adminResource<AdminRow>(entity, {
|
|
126
|
+
...(opts ?? {}),
|
|
127
|
+
actions: [...(opts?.actions ?? []), ...own],
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const nav = adminNav(resources, input.nav ?? {});
|
|
132
|
+
const routes: AdminRoute[] = [
|
|
133
|
+
{
|
|
134
|
+
path: basePath,
|
|
135
|
+
view: 'dashboard',
|
|
136
|
+
entity: null,
|
|
137
|
+
titleKey: 'admin.dashboard.title',
|
|
138
|
+
permissions: permissionsForOperation('admin', 'list'),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
path: `${basePath}/search`,
|
|
142
|
+
view: 'search',
|
|
143
|
+
entity: null,
|
|
144
|
+
titleKey: 'admin.search.title',
|
|
145
|
+
permissions: permissionsForOperation('admin', 'search'),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
path: `${basePath}/jobs`,
|
|
149
|
+
view: 'jobs',
|
|
150
|
+
entity: null,
|
|
151
|
+
titleKey: 'admin.jobs.title',
|
|
152
|
+
permissions: permissionsForOperation('job', 'list'),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
path: `${basePath}/audit`,
|
|
156
|
+
view: 'audit',
|
|
157
|
+
entity: null,
|
|
158
|
+
titleKey: 'admin.audit.title',
|
|
159
|
+
permissions: permissionsForOperation('audit', 'list'),
|
|
160
|
+
},
|
|
161
|
+
...resources.flatMap((resource) => resourceRoutes(basePath, resource)),
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
basePath,
|
|
166
|
+
branding,
|
|
167
|
+
theme: themeAttributes(branding),
|
|
168
|
+
resources,
|
|
169
|
+
globalActions: actions.filter((action) => action.entity === undefined),
|
|
170
|
+
jobs: input.jobs ?? [],
|
|
171
|
+
nav,
|
|
172
|
+
routes,
|
|
173
|
+
audit,
|
|
174
|
+
authz: input.auth.authz,
|
|
175
|
+
auth: input.auth,
|
|
176
|
+
resource(name: string): AdminResource {
|
|
177
|
+
return resourceFor(resources, name);
|
|
178
|
+
},
|
|
179
|
+
navFor(ctx: CrudCtx): readonly NavGroup[] {
|
|
180
|
+
return visibleNav(nav, resources, ctx);
|
|
181
|
+
},
|
|
182
|
+
ctx({ actor, requestId }): CrudCtx {
|
|
183
|
+
return { actor, requestId, audit, authz: input.auth.authz };
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|