@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
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// One registered entity → the flat facts the derivation reads, in declaration order.
|
|
2
|
+
//
|
|
3
|
+
// Two things are not one-to-one and are decided here, once. Money stays ONE property: the
|
|
4
|
+
// admin renders rows, and a row carries `{ minor, currency }` where the migration emits two
|
|
5
|
+
// physical columns. And a foreign key is read back from `$describe()`, because the binding
|
|
6
|
+
// that turns `() => orgs.id` into `"orgs.id"` is private to @ultimat3/entity.
|
|
7
|
+
|
|
8
|
+
import { AdminFieldUnsupportedError } from './errors';
|
|
9
|
+
import type { AdminColumnMeta, AdminEntity } from './registry';
|
|
10
|
+
|
|
11
|
+
export interface AdminColumnReference {
|
|
12
|
+
/** The entity the value points at. */
|
|
13
|
+
readonly entity: string;
|
|
14
|
+
/** The column the value IS a value of — the honest default label for the reference. */
|
|
15
|
+
readonly column: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** One column, flattened. Everything `fields.ts` and `resource.ts` decide from. */
|
|
19
|
+
export interface AdminColumnFacts {
|
|
20
|
+
/** Property key on the row: what a form input, a filter and an MCP argument are named. */
|
|
21
|
+
readonly name: string;
|
|
22
|
+
/** The entity's column kind, mapped to a widget by `fields.ts`. */
|
|
23
|
+
readonly kind: string;
|
|
24
|
+
readonly nullable: boolean;
|
|
25
|
+
readonly primaryKey: boolean;
|
|
26
|
+
readonly unique: boolean;
|
|
27
|
+
readonly index: boolean;
|
|
28
|
+
/** Written by the DB or the framework (`id`, `createdAt`): read-only in every form. */
|
|
29
|
+
readonly generated: boolean;
|
|
30
|
+
/** Declared max length. Absent on an unbounded text column, which is prose. */
|
|
31
|
+
readonly length?: number;
|
|
32
|
+
readonly values?: readonly string[];
|
|
33
|
+
readonly references?: AdminColumnReference;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const parseReference = (entity: string, property: string, target: string): AdminColumnReference => {
|
|
37
|
+
const dot = target.lastIndexOf('.');
|
|
38
|
+
const entityName = dot < 0 ? '' : target.slice(0, dot);
|
|
39
|
+
const column = dot < 0 ? '' : target.slice(dot + 1);
|
|
40
|
+
if (entityName === '' || column === '') {
|
|
41
|
+
throw new AdminFieldUnsupportedError({
|
|
42
|
+
entity,
|
|
43
|
+
field: property,
|
|
44
|
+
cause: `reference target "${target}" is not "<entity>.<column>", so the admin cannot link it`,
|
|
45
|
+
fix: `references(() => other.id) — pass a column of an entity() result, then: x manifest`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return { entity: entityName, column };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Resolved foreign keys by property. `$describe()` splits a money property into its two
|
|
53
|
+
* physical columns; neither carries a reference, so they simply never match a property here.
|
|
54
|
+
*/
|
|
55
|
+
const referencesOf = (entity: AdminEntity): ReadonlyMap<string, AdminColumnReference> => {
|
|
56
|
+
const out = new Map<string, AdminColumnReference>();
|
|
57
|
+
for (const column of entity.$describe().columns) {
|
|
58
|
+
if (column.references === null) continue;
|
|
59
|
+
out.set(column.property, parseReference(entity.$name, column.property, column.references));
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const factsFor = (
|
|
65
|
+
name: string,
|
|
66
|
+
meta: AdminColumnMeta,
|
|
67
|
+
primaryKey: boolean,
|
|
68
|
+
references: AdminColumnReference | undefined,
|
|
69
|
+
): AdminColumnFacts => ({
|
|
70
|
+
name,
|
|
71
|
+
kind: meta.kind,
|
|
72
|
+
nullable: !meta.notNull,
|
|
73
|
+
primaryKey,
|
|
74
|
+
unique: meta.unique,
|
|
75
|
+
index: meta.index,
|
|
76
|
+
// A value the writer never supplies. `.default('free')` is a starting value the operator
|
|
77
|
+
// may still change, so it stays writable — only a generated one is read-only.
|
|
78
|
+
generated: meta.default?.kind === 'generated' || meta.onUpdate !== undefined,
|
|
79
|
+
...(meta.length === undefined ? {} : { length: meta.length }),
|
|
80
|
+
...(meta.values === undefined ? {} : { values: meta.values }),
|
|
81
|
+
...(references === undefined ? {} : { references }),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Declaration order, which is the order every list, form and MCP schema is built in. A
|
|
86
|
+
* composite key marks each of its members, the way `$describe()` does — the admin then treats
|
|
87
|
+
* them as it treats any key column: addressable, and never editable.
|
|
88
|
+
*/
|
|
89
|
+
export function adminColumnsOf(entity: AdminEntity): readonly AdminColumnFacts[] {
|
|
90
|
+
const references = referencesOf(entity);
|
|
91
|
+
const keys = new Set(entity.$primaryKey);
|
|
92
|
+
return Object.entries(entity.$columns).map(([name, column]) =>
|
|
93
|
+
factsFor(name, column.$meta, column.$meta.primaryKey || keys.has(name), references.get(name)),
|
|
94
|
+
);
|
|
95
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The X_* codes owned by @ultimat3/admin. Every one names the exact edit that fixes it,
|
|
2
|
+
// because the two dashboards fail at boot (bad registry, bad mount) where an agent has no
|
|
3
|
+
// stack trace to reason from — only the message.
|
|
4
|
+
import { registerErrorCodes, UltimateError } from '@ultimat3/core';
|
|
5
|
+
|
|
6
|
+
/** Codes this package declares and owns. */
|
|
7
|
+
export const ADMIN_OWNED_ERROR_CODES = [
|
|
8
|
+
'X_ADMIN_ENTITY_UNKNOWN',
|
|
9
|
+
'X_ADMIN_FIELD_UNSUPPORTED',
|
|
10
|
+
'X_ADMIN_POLICY_MISSING',
|
|
11
|
+
'X_DEV_DASHBOARD_IN_PROD',
|
|
12
|
+
// `mcp.ts` returns these three on `AdminToolResult.error` instead of throwing — a refusal an
|
|
13
|
+
// agent reads, not a stack an operator reads. They stayed unregistered because of that, so
|
|
14
|
+
// `x errors explain X_ADMIN_DENIED` refused a code the admin had just answered with. A code
|
|
15
|
+
// an agent can be handed is a code the package owns, whichever way it travels.
|
|
16
|
+
'X_ADMIN_DENIED',
|
|
17
|
+
'X_ADMIN_TOOL_FORBIDDEN',
|
|
18
|
+
'X_ADMIN_INVALID',
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `DevSourceUnavailableError` throws it; this package
|
|
23
|
+
* neither titles nor registers it, because the owner's title is the only one that may exist.
|
|
24
|
+
*/
|
|
25
|
+
export const ADMIN_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
|
|
26
|
+
|
|
27
|
+
/** Every code admin can throw: the ones it owns plus the one it borrows. */
|
|
28
|
+
export const ADMIN_ERROR_CODES = [
|
|
29
|
+
...ADMIN_OWNED_ERROR_CODES,
|
|
30
|
+
...ADMIN_BORROWED_ERROR_CODES,
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export type AdminOwnedErrorCode = (typeof ADMIN_OWNED_ERROR_CODES)[number];
|
|
34
|
+
export type AdminErrorCode = (typeof ADMIN_ERROR_CODES)[number];
|
|
35
|
+
|
|
36
|
+
export const ADMIN_ERROR_TITLES: Readonly<Record<AdminOwnedErrorCode, string>> = {
|
|
37
|
+
X_ADMIN_ENTITY_UNKNOWN: 'the admin references an entity that does not exist',
|
|
38
|
+
X_ADMIN_FIELD_UNSUPPORTED: 'a column type the admin cannot render',
|
|
39
|
+
X_ADMIN_POLICY_MISSING: 'an admin-exposed subject has no policy',
|
|
40
|
+
X_DEV_DASHBOARD_IN_PROD: '/_x was mounted outside dev',
|
|
41
|
+
X_ADMIN_DENIED: 'the actor may not use this admin surface',
|
|
42
|
+
X_ADMIN_TOOL_FORBIDDEN: 'an admin MCP tool was called without permission',
|
|
43
|
+
X_ADMIN_INVALID: "an admin tool's arguments failed the resource schema",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// One unconditional call, so a second package claiming one of admin's codes throws
|
|
47
|
+
// X_ERROR_CODE_DUPLICATE instead of losing silently to whichever module imported first.
|
|
48
|
+
registerErrorCodes(
|
|
49
|
+
Object.fromEntries(Object.entries(ADMIN_ERROR_TITLES).map(([code, title]) => [code, { title }])),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const docsFor = (code: AdminErrorCode): string => `https://ultimate.dev/errors/${code}`;
|
|
53
|
+
|
|
54
|
+
/** A resource, nav item, or MCP tool named an entity the registry does not have. */
|
|
55
|
+
export class AdminEntityUnknownError extends UltimateError {
|
|
56
|
+
constructor(input: { entity: string; known: readonly string[]; cause?: string }) {
|
|
57
|
+
super({
|
|
58
|
+
code: 'X_ADMIN_ENTITY_UNKNOWN',
|
|
59
|
+
cause:
|
|
60
|
+
input.cause ??
|
|
61
|
+
`entity "${input.entity}" is not registered (registered: ${
|
|
62
|
+
input.known.length > 0 ? input.known.join(', ') : 'none'
|
|
63
|
+
})`,
|
|
64
|
+
fix: `x g entity ${input.entity} # then: x manifest`,
|
|
65
|
+
docs: docsFor('X_ADMIN_ENTITY_UNKNOWN'),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A column has no widget, or a value cannot be rendered safely in the widget it maps to.
|
|
72
|
+
* Money arriving as a float and a timestamptz arriving without an IANA zone are the same
|
|
73
|
+
* class of bug: the admin would render a number that is wrong for somebody.
|
|
74
|
+
*/
|
|
75
|
+
export class AdminFieldUnsupportedError extends UltimateError {
|
|
76
|
+
constructor(input: { entity: string; field: string; cause: string; fix: string }) {
|
|
77
|
+
super({
|
|
78
|
+
code: 'X_ADMIN_FIELD_UNSUPPORTED',
|
|
79
|
+
cause: `${input.entity}.${input.field}: ${input.cause}`,
|
|
80
|
+
fix: input.fix,
|
|
81
|
+
docs: docsFor('X_ADMIN_FIELD_UNSUPPORTED'),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** An action reached the admin without a policy — the button would be an open door. */
|
|
87
|
+
export class AdminPolicyMissingError extends UltimateError {
|
|
88
|
+
constructor(input: { subject: string; kind: 'action' | 'resource' }) {
|
|
89
|
+
super({
|
|
90
|
+
code: 'X_ADMIN_POLICY_MISSING',
|
|
91
|
+
cause: `${input.kind} "${input.subject}" is exposed in the admin with no policy`,
|
|
92
|
+
fix: `add policy: can('${input.subject}') to the ${input.kind} definition`,
|
|
93
|
+
docs: docsFor('X_ADMIN_POLICY_MISSING'),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A `/_x` panel needs a fact the framework cannot introspect on its own — request traces,
|
|
100
|
+
* caught mail, the read-only SQL tool, the committed manifest. Thrown instead of drawing an
|
|
101
|
+
* empty panel, because an empty panel reads as "nothing happened".
|
|
102
|
+
*/
|
|
103
|
+
export class DevSourceUnavailableError extends UltimateError {
|
|
104
|
+
constructor(input: { source: string; panel: string; wiring?: string }) {
|
|
105
|
+
super({
|
|
106
|
+
code: 'X_NOT_IMPLEMENTED',
|
|
107
|
+
cause: `the /_x ${input.panel} panel needs the "${input.source}" source, which is not wired in this process`,
|
|
108
|
+
// `wiring`, when supplied, replaces the whole `defaultDevSources(...)` argument text —
|
|
109
|
+
// the default `hooks: { <source> }` phrasing is only valid when the source really is a
|
|
110
|
+
// `DevSources` key. "authz + actors" is not one; it is two `DevSourceOptions` fields, so
|
|
111
|
+
// that call site passes its own `wiring` rather than render `hooks: { authz + actors }`,
|
|
112
|
+
// which is not syntax an agent could run.
|
|
113
|
+
fix: `devDashboard({ sources: defaultDevSources(${input.wiring ?? `{ hooks: { ${input.source} } }`}) })`,
|
|
114
|
+
docs: docsFor('X_NOT_IMPLEMENTED'),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** `/_x` is a development tool: it prints SQL, policy traces, and caught mail. */
|
|
120
|
+
export class DevDashboardInProdError extends UltimateError {
|
|
121
|
+
constructor(input: { role: string; env: string }) {
|
|
122
|
+
super({
|
|
123
|
+
code: 'X_DEV_DASHBOARD_IN_PROD',
|
|
124
|
+
cause: `devDashboard() was called with role="${input.role}" env="${input.env}"; /_x exposes SQL, policy traces, and caught mail`,
|
|
125
|
+
fix: 'delete the /_x mount from the production entrypoint; run `x dev` locally instead',
|
|
126
|
+
docs: docsFor('X_DEV_DASHBOARD_IN_PROD'),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The three strings a failed admin request carries to the view. */
|
|
132
|
+
export interface AdminErrorParts {
|
|
133
|
+
readonly code: string;
|
|
134
|
+
readonly cause: string;
|
|
135
|
+
readonly fix: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A route hands a view the error as data, because it crossed a wire and lost its class. The
|
|
140
|
+
* views render it through ui's `<ErrorState>`, which reads an UltimateError — so it is
|
|
141
|
+
* rehydrated here rather than paraphrased into a second, drifting rendering.
|
|
142
|
+
*/
|
|
143
|
+
export function adminErrorFrom(parts: AdminErrorParts): UltimateError {
|
|
144
|
+
return new UltimateError({ code: parts.code, cause: parts.cause, fix: parts.fix });
|
|
145
|
+
}
|
package/src/fields.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// The one field-type → widget table. Adding a column kind means adding a row here, so
|
|
2
|
+
// there is never a second opinion about how money or a timestamp is rendered — and an
|
|
3
|
+
// unmapped column is a loud X_ADMIN_FIELD_UNSUPPORTED at derive time, not a blank cell.
|
|
4
|
+
|
|
5
|
+
import type { AdminColumnFacts } from './entity-columns';
|
|
6
|
+
import { AdminFieldUnsupportedError } from './errors';
|
|
7
|
+
|
|
8
|
+
export type AdminFieldType =
|
|
9
|
+
| 'text'
|
|
10
|
+
| 'textarea'
|
|
11
|
+
| 'number'
|
|
12
|
+
| 'money'
|
|
13
|
+
| 'boolean'
|
|
14
|
+
| 'enum'
|
|
15
|
+
| 'date'
|
|
16
|
+
| 'timestamptz'
|
|
17
|
+
| 'timezone'
|
|
18
|
+
| 'locale'
|
|
19
|
+
| 'json'
|
|
20
|
+
| 'relation'
|
|
21
|
+
| 'file';
|
|
22
|
+
|
|
23
|
+
export type AdminWidget =
|
|
24
|
+
| 'text-input'
|
|
25
|
+
| 'textarea'
|
|
26
|
+
| 'number-input'
|
|
27
|
+
| 'money'
|
|
28
|
+
| 'checkbox'
|
|
29
|
+
| 'select'
|
|
30
|
+
| 'datetime'
|
|
31
|
+
| 'timezone-picker'
|
|
32
|
+
| 'locale-picker'
|
|
33
|
+
| 'json-editor'
|
|
34
|
+
| 'reference'
|
|
35
|
+
| 'upload';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A derived field: everything a list cell, a detail row, a form input, a filter, and an MCP
|
|
39
|
+
* tool schema need, decided once at derive time. `resource.ts` builds these; nothing else
|
|
40
|
+
* inspects a column.
|
|
41
|
+
*/
|
|
42
|
+
export interface AdminField {
|
|
43
|
+
readonly entity: string;
|
|
44
|
+
readonly name: string;
|
|
45
|
+
readonly type: AdminFieldType;
|
|
46
|
+
readonly widget: AdminWidget;
|
|
47
|
+
/** i18n key, never a string. `admin.<entity>.field.<name>`. */
|
|
48
|
+
readonly labelKey: string;
|
|
49
|
+
readonly required: boolean;
|
|
50
|
+
readonly readOnly: boolean;
|
|
51
|
+
/** Never rendered, redacted in the audit diff. */
|
|
52
|
+
readonly sensitive: boolean;
|
|
53
|
+
readonly inList: boolean;
|
|
54
|
+
readonly filterable: boolean;
|
|
55
|
+
readonly sortable: boolean;
|
|
56
|
+
readonly searchable: boolean;
|
|
57
|
+
readonly values?: readonly string[];
|
|
58
|
+
readonly currency?: string;
|
|
59
|
+
readonly relation?: { readonly entity: string; readonly labelField?: string };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The table. Money always the Money widget; both date kinds always the DateTime widget. */
|
|
63
|
+
export const WIDGET_BY_FIELD_TYPE: Readonly<Record<AdminFieldType, AdminWidget>> = {
|
|
64
|
+
text: 'text-input',
|
|
65
|
+
textarea: 'textarea',
|
|
66
|
+
number: 'number-input',
|
|
67
|
+
money: 'money',
|
|
68
|
+
boolean: 'checkbox',
|
|
69
|
+
enum: 'select',
|
|
70
|
+
date: 'datetime',
|
|
71
|
+
timestamptz: 'datetime',
|
|
72
|
+
timezone: 'timezone-picker',
|
|
73
|
+
locale: 'locale-picker',
|
|
74
|
+
json: 'json-editor',
|
|
75
|
+
relation: 'reference',
|
|
76
|
+
file: 'upload',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function widgetFor(type: AdminFieldType): AdminWidget {
|
|
80
|
+
return WIDGET_BY_FIELD_TYPE[type];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every kind `@ultimat3/entity` can put on a column, and nothing else: a name that no column
|
|
85
|
+
* builder emits would be a widget nobody can reach. `text` is the entry the length rule below
|
|
86
|
+
* then refines.
|
|
87
|
+
*/
|
|
88
|
+
const FIELD_TYPE_BY_COLUMN_KIND: Readonly<Record<string, AdminFieldType>> = {
|
|
89
|
+
uuid: 'text',
|
|
90
|
+
text: 'textarea',
|
|
91
|
+
char: 'text',
|
|
92
|
+
boolean: 'boolean',
|
|
93
|
+
integer: 'number',
|
|
94
|
+
bigint: 'number',
|
|
95
|
+
timestamptz: 'timestamptz',
|
|
96
|
+
jsonb: 'json',
|
|
97
|
+
money: 'money',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Derivation order matters: a FK is a relation whatever its kind, and declared `values` mean
|
|
102
|
+
* a select whatever its kind. Only then does the column kind get a vote — and a text column
|
|
103
|
+
* with a declared max is one line, where an unbounded `text()` is prose.
|
|
104
|
+
*/
|
|
105
|
+
export function fieldTypeFromColumn(
|
|
106
|
+
entity: string,
|
|
107
|
+
field: string,
|
|
108
|
+
column: AdminColumnFacts,
|
|
109
|
+
): AdminFieldType {
|
|
110
|
+
if (column.references !== undefined) return 'relation';
|
|
111
|
+
if (column.values !== undefined && column.values.length > 0) return 'enum';
|
|
112
|
+
|
|
113
|
+
const mapped = FIELD_TYPE_BY_COLUMN_KIND[column.kind];
|
|
114
|
+
if (mapped === undefined) {
|
|
115
|
+
throw new AdminFieldUnsupportedError({
|
|
116
|
+
entity,
|
|
117
|
+
field,
|
|
118
|
+
cause: `column kind "${column.kind}" has no admin widget`,
|
|
119
|
+
fix: `adminResource(${entity}, { fields: { ${field}: { widget: 'json-editor' } } }) # or { hidden: true }`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return mapped === 'textarea' && column.length !== undefined ? 'text' : mapped;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `false` for kinds whose value is never worth a list column (blobs, big JSON, prose). */
|
|
126
|
+
export function listable(type: AdminFieldType): boolean {
|
|
127
|
+
return type !== 'json' && type !== 'file' && type !== 'textarea';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Only indexed, unique, enum, boolean, and FK columns are offered as filters. */
|
|
131
|
+
export function filterable(type: AdminFieldType, column: AdminColumnFacts): boolean {
|
|
132
|
+
if (column.index || column.unique || column.primaryKey) return true;
|
|
133
|
+
return type === 'enum' || type === 'boolean' || type === 'relation';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Sortable means "usable as the keyset cursor", so it is deliberately narrow: an ordered
|
|
138
|
+
* scalar, or a text column with an index behind it. Sorting on an unindexed column would
|
|
139
|
+
* turn every page of the admin into a sort of the whole table.
|
|
140
|
+
*/
|
|
141
|
+
export function sortable(type: AdminFieldType, column: AdminColumnFacts): boolean {
|
|
142
|
+
if (type === 'number' || type === 'money' || type === 'date' || type === 'timestamptz') {
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
if (type !== 'text') return false;
|
|
146
|
+
return column.index || column.unique || column.primaryKey;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function searchable(type: AdminFieldType): boolean {
|
|
150
|
+
return type === 'text' || type === 'textarea';
|
|
151
|
+
}
|
package/src/form.tsx
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Create/edit form. Fields, labels, and required flags come from the resource; validation
|
|
2
|
+
// comes from the entity's own schema, so the form rejects exactly what the action would
|
|
3
|
+
// reject. Issues render against the field they name, and the summary is focusable.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/i18n';
|
|
6
|
+
import { Card, ErrorState, Field } from '@ultimat3/ui';
|
|
7
|
+
import type { JSX } from 'solid-js';
|
|
8
|
+
import { type AdminErrorParts, adminErrorFrom } from './errors';
|
|
9
|
+
import type { AdminRow } from './registry';
|
|
10
|
+
import type { AdminResource } from './resource';
|
|
11
|
+
import type { ValidationIssue } from './validate';
|
|
12
|
+
import type { WidgetContext } from './widget-value';
|
|
13
|
+
import { Widget } from './widgets';
|
|
14
|
+
|
|
15
|
+
export interface AdminFormProps<Row extends AdminRow> {
|
|
16
|
+
readonly resource: AdminResource<Row>;
|
|
17
|
+
readonly mode: 'create' | 'edit';
|
|
18
|
+
/** Current values, controlled by the route. Empty object for create. */
|
|
19
|
+
readonly values: Readonly<Record<string, unknown>>;
|
|
20
|
+
readonly issues: readonly ValidationIssue[];
|
|
21
|
+
readonly submitting: boolean;
|
|
22
|
+
readonly error: AdminErrorParts | null;
|
|
23
|
+
readonly ctx: WidgetContext;
|
|
24
|
+
readonly onInput: (field: string, value: unknown) => void;
|
|
25
|
+
readonly onSubmit: () => void;
|
|
26
|
+
readonly onCancel: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const issuesFor = (issues: readonly ValidationIssue[], field: string): readonly ValidationIssue[] =>
|
|
30
|
+
issues.filter((issue) => issue.path === field);
|
|
31
|
+
|
|
32
|
+
export function AdminForm<Row extends AdminRow>(props: AdminFormProps<Row>): JSX.Element {
|
|
33
|
+
if (props.error !== null) {
|
|
34
|
+
return <ErrorState error={adminErrorFrom(props.error)} />;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const titleKey = props.mode === 'create' ? 'admin.form.create' : 'admin.form.edit';
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Card header={<h2>{t(titleKey, { entity: t(props.resource.titleKey) })}</h2>}>
|
|
41
|
+
<form
|
|
42
|
+
onSubmit={(event: SubmitEvent) => {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
props.onSubmit();
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
{props.issues.length === 0 ? null : (
|
|
48
|
+
<div class="x-admin-issues" role="alert" tabindex={-1}>
|
|
49
|
+
<h3>{t('admin.form.issues')}</h3>
|
|
50
|
+
<ul>
|
|
51
|
+
{props.issues.map((issue) => (
|
|
52
|
+
<li>
|
|
53
|
+
<a href={`#x-admin-field-${issue.path}`}>{issue.path}</a>: {issue.message}
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
|
|
60
|
+
{props.resource.formFields.map((field) => {
|
|
61
|
+
const own = issuesFor(props.issues, field.name);
|
|
62
|
+
return (
|
|
63
|
+
// The anchor target the issue summary links to. <Field> owns the control's own
|
|
64
|
+
// id, so the deep link lands on the wrapper and the label still points at the input.
|
|
65
|
+
<div id={`x-admin-field-${field.name}`}>
|
|
66
|
+
<Field
|
|
67
|
+
label={t(field.labelKey)}
|
|
68
|
+
required={field.required}
|
|
69
|
+
error={own.length === 0 ? undefined : own.map((issue) => issue.message).join(' ')}
|
|
70
|
+
>
|
|
71
|
+
{(control) => (
|
|
72
|
+
<Widget
|
|
73
|
+
field={field}
|
|
74
|
+
value={props.values[field.name]}
|
|
75
|
+
ctx={props.ctx}
|
|
76
|
+
mode="edit"
|
|
77
|
+
control={control}
|
|
78
|
+
onInput={props.onInput}
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
</Field>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
})}
|
|
85
|
+
|
|
86
|
+
<div class="x-admin-form-actions">
|
|
87
|
+
<button type="submit" disabled={props.submitting}>
|
|
88
|
+
{t(props.submitting ? 'admin.form.saving' : 'admin.form.save')}
|
|
89
|
+
</button>
|
|
90
|
+
<button type="button" onClick={() => props.onCancel()}>
|
|
91
|
+
{t('admin.form.cancel')}
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
</form>
|
|
95
|
+
</Card>
|
|
96
|
+
);
|
|
97
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// The public surface of @ultimat3/admin: the generated app admin, and the /_x dev dashboard.
|
|
2
|
+
// Explicit exports only — a barrel that re-exports everything is how internal helpers become
|
|
3
|
+
// someone's dependency.
|
|
4
|
+
|
|
5
|
+
export {
|
|
6
|
+
type ActionGateInput,
|
|
7
|
+
type AdminActionButton,
|
|
8
|
+
actionButtons,
|
|
9
|
+
actionDecisions,
|
|
10
|
+
decideAction,
|
|
11
|
+
type InvokeInput,
|
|
12
|
+
type InvokeResult,
|
|
13
|
+
invokeAdminAction,
|
|
14
|
+
permissionsForAction,
|
|
15
|
+
} from './action-gate';
|
|
16
|
+
export { AdminActions, type AdminActionsProps } from './actions';
|
|
17
|
+
export {
|
|
18
|
+
type AdminApp,
|
|
19
|
+
type AdminAuth,
|
|
20
|
+
type AdminRoute,
|
|
21
|
+
type AdminView,
|
|
22
|
+
type DefineAdminInput,
|
|
23
|
+
defineAdmin,
|
|
24
|
+
} from './admin';
|
|
25
|
+
export {
|
|
26
|
+
AI_PANES,
|
|
27
|
+
type AiPane,
|
|
28
|
+
type AiPaneFacts,
|
|
29
|
+
type AiPaneRequest,
|
|
30
|
+
type AiPaneResult,
|
|
31
|
+
type AiPaneScope,
|
|
32
|
+
type AiPanesOptions,
|
|
33
|
+
type AiRunner,
|
|
34
|
+
aiPanes,
|
|
35
|
+
type GatewayAdapter,
|
|
36
|
+
runAiPane,
|
|
37
|
+
} from './ai-panes';
|
|
38
|
+
export {
|
|
39
|
+
type AuditDraft,
|
|
40
|
+
type AuditEntry,
|
|
41
|
+
type AuditFieldDiff,
|
|
42
|
+
type AuditLog,
|
|
43
|
+
type AuditLogOptions,
|
|
44
|
+
type AuditOutcome,
|
|
45
|
+
type AuditSink,
|
|
46
|
+
auditEntry,
|
|
47
|
+
deniedDraft,
|
|
48
|
+
diffRows,
|
|
49
|
+
memoryAuditLog,
|
|
50
|
+
REDACTED,
|
|
51
|
+
} from './audit';
|
|
52
|
+
export {
|
|
53
|
+
type AdminActor,
|
|
54
|
+
type AdminAuthz,
|
|
55
|
+
type AdminAuthzQuery,
|
|
56
|
+
type AdminDecision,
|
|
57
|
+
type AdminSubject,
|
|
58
|
+
allowed,
|
|
59
|
+
anonymousAuthz,
|
|
60
|
+
decideAll,
|
|
61
|
+
denied,
|
|
62
|
+
expandPermissions,
|
|
63
|
+
isAllowed,
|
|
64
|
+
staticAuthz,
|
|
65
|
+
} from './authz';
|
|
66
|
+
export {
|
|
67
|
+
adminCreate,
|
|
68
|
+
adminDestroy,
|
|
69
|
+
adminDetail,
|
|
70
|
+
adminList,
|
|
71
|
+
adminUpdate,
|
|
72
|
+
type CrudCtx,
|
|
73
|
+
type CrudResult,
|
|
74
|
+
canOperate,
|
|
75
|
+
decideOperation,
|
|
76
|
+
type ListResult,
|
|
77
|
+
permissionsForOperation,
|
|
78
|
+
} from './crud';
|
|
79
|
+
export { AdminDetail, type AdminDetailProps } from './detail';
|
|
80
|
+
export {
|
|
81
|
+
type AdminColumnFacts,
|
|
82
|
+
type AdminColumnReference,
|
|
83
|
+
adminColumnsOf,
|
|
84
|
+
} from './entity-columns';
|
|
85
|
+
// The /_x dashboard is NOT re-exported here — it has its own door, `@ultimat3/admin/dev`, so a
|
|
86
|
+
// host that only mounts the dev panels never loads a production admin component.
|
|
87
|
+
export {
|
|
88
|
+
ADMIN_ERROR_CODES,
|
|
89
|
+
ADMIN_ERROR_TITLES,
|
|
90
|
+
AdminEntityUnknownError,
|
|
91
|
+
type AdminErrorCode,
|
|
92
|
+
type AdminErrorParts,
|
|
93
|
+
AdminFieldUnsupportedError,
|
|
94
|
+
AdminPolicyMissingError,
|
|
95
|
+
adminErrorFrom,
|
|
96
|
+
DevDashboardInProdError,
|
|
97
|
+
DevSourceUnavailableError,
|
|
98
|
+
} from './errors';
|
|
99
|
+
export {
|
|
100
|
+
type AdminField,
|
|
101
|
+
type AdminFieldType,
|
|
102
|
+
type AdminWidget,
|
|
103
|
+
fieldTypeFromColumn,
|
|
104
|
+
filterable,
|
|
105
|
+
listable,
|
|
106
|
+
searchable,
|
|
107
|
+
sortable,
|
|
108
|
+
WIDGET_BY_FIELD_TYPE,
|
|
109
|
+
widgetFor,
|
|
110
|
+
} from './fields';
|
|
111
|
+
export { AdminForm, type AdminFormProps } from './form';
|
|
112
|
+
export { AdminLayout, type AdminLayoutProps } from './layout';
|
|
113
|
+
export { AdminList, type AdminListProps } from './list';
|
|
114
|
+
export {
|
|
115
|
+
type AdminMcpOptions,
|
|
116
|
+
type AdminToolResult,
|
|
117
|
+
adminMcp,
|
|
118
|
+
callAdminTool,
|
|
119
|
+
type McpInput,
|
|
120
|
+
} from './mcp';
|
|
121
|
+
export {
|
|
122
|
+
type AdminMcpTool,
|
|
123
|
+
type AdminToolField,
|
|
124
|
+
type AdminToolKind,
|
|
125
|
+
adminMcpTools,
|
|
126
|
+
adminToolCatalog,
|
|
127
|
+
adminToolDecisions,
|
|
128
|
+
} from './mcp-tools';
|
|
129
|
+
export { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
|
|
130
|
+
export {
|
|
131
|
+
type AdminCursor,
|
|
132
|
+
type AdminPage,
|
|
133
|
+
decodeAdminCursor,
|
|
134
|
+
encodeAdminCursor,
|
|
135
|
+
fetchPage,
|
|
136
|
+
listQuery,
|
|
137
|
+
type PageRequest,
|
|
138
|
+
pageFrom,
|
|
139
|
+
} from './pagination';
|
|
140
|
+
export {
|
|
141
|
+
ADMIN_DESTROY,
|
|
142
|
+
ADMIN_IMPERSONATE,
|
|
143
|
+
ADMIN_OPERATION_RULES,
|
|
144
|
+
ADMIN_OPERATIONS,
|
|
145
|
+
ADMIN_PERMISSION_SPEC,
|
|
146
|
+
ADMIN_PERMISSIONS,
|
|
147
|
+
ADMIN_READ,
|
|
148
|
+
ADMIN_WRITE,
|
|
149
|
+
type AdminOperation,
|
|
150
|
+
type AdminPermission,
|
|
151
|
+
type AdminPermissionRule,
|
|
152
|
+
adminPermissionFor,
|
|
153
|
+
CONFIRMATION_REQUIRED_REASON,
|
|
154
|
+
confirmationToken,
|
|
155
|
+
entityPermissionFor,
|
|
156
|
+
isDestructive,
|
|
157
|
+
ruleFor,
|
|
158
|
+
} from './permissions';
|
|
159
|
+
export { adminPermissions, type PolicyAuthzInput, policyAuthz } from './policy-bridge';
|
|
160
|
+
export {
|
|
161
|
+
type AdminAction,
|
|
162
|
+
type AdminActionCtx,
|
|
163
|
+
type AdminColumn,
|
|
164
|
+
type AdminColumnDescription,
|
|
165
|
+
type AdminColumnMeta,
|
|
166
|
+
type AdminEntity,
|
|
167
|
+
type AdminEntityDescription,
|
|
168
|
+
type AdminFilter,
|
|
169
|
+
type AdminJobSummary,
|
|
170
|
+
type AdminListQuery,
|
|
171
|
+
type AdminRepo,
|
|
172
|
+
type AdminRow,
|
|
173
|
+
type AdminSort,
|
|
174
|
+
type FilterOp,
|
|
175
|
+
type KeysetBound,
|
|
176
|
+
type RegisteredEntity,
|
|
177
|
+
type RegisteredRepo,
|
|
178
|
+
readField,
|
|
179
|
+
rowId,
|
|
180
|
+
} from './registry';
|
|
181
|
+
export {
|
|
182
|
+
type AdminFieldOverride,
|
|
183
|
+
type AdminResource,
|
|
184
|
+
type AdminResourceOptions,
|
|
185
|
+
adminResource,
|
|
186
|
+
repoOf,
|
|
187
|
+
resourceFor,
|
|
188
|
+
} from './resource';
|
|
189
|
+
export { type AdminRouteConfig, adminRouteConfig, adminRoutes } from './routes';
|
|
190
|
+
export {
|
|
191
|
+
type AdminSearchHit,
|
|
192
|
+
type AdminSearchInput,
|
|
193
|
+
type AdminSearchResult,
|
|
194
|
+
adminSearch,
|
|
195
|
+
} from './search';
|
|
196
|
+
export {
|
|
197
|
+
type AdminBranding,
|
|
198
|
+
adminBranding,
|
|
199
|
+
defaultBranding,
|
|
200
|
+
type ThemeAttributes,
|
|
201
|
+
type ThemeMode,
|
|
202
|
+
type ThemeTokenRef,
|
|
203
|
+
themeAttributes,
|
|
204
|
+
} from './theme';
|
|
205
|
+
export { type ValidationIssue, type ValidationResult, validateInput } from './validate';
|
|
206
|
+
export {
|
|
207
|
+
assertMoney,
|
|
208
|
+
assertZone,
|
|
209
|
+
type SelectOption,
|
|
210
|
+
type WidgetContext,
|
|
211
|
+
type WidgetProps,
|
|
212
|
+
widgetProps,
|
|
213
|
+
} from './widget-value';
|
|
214
|
+
export { Widget, type WidgetInput } from './widgets';
|