@ultimat3/admin 22.15.0 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +83 -91
- package/README.md +282 -89
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +141 -68
- package/src/action-input.ts +119 -0
- package/src/action-label.ts +9 -0
- package/src/action-row.ts +37 -0
- package/src/actions.tsx +89 -66
- package/src/actor.ts +53 -0
- package/src/admin.module.scss +298 -0
- package/src/admin.ts +187 -23
- package/src/audit-pg.ts +207 -0
- package/src/audit-schema.ts +47 -0
- package/src/audit.ts +109 -22
- package/src/authz.ts +11 -0
- package/src/batch-bar.tsx +66 -0
- package/src/batch-job.ts +152 -0
- package/src/batch-matching.ts +87 -0
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +396 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +224 -0
- package/src/crud.ts +172 -176
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +27 -12
- package/src/dev/facts.ts +28 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-db.ts +37 -5
- package/src/dev/panel-jobs.ts +8 -0
- package/src/dev/panel-routes.ts +10 -0
- package/src/dev/panel-timeline.ts +7 -2
- package/src/dev/panel.ts +10 -2
- package/src/dev/server.ts +16 -11
- package/src/dev/sql-runner.ts +31 -0
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +92 -5
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +132 -0
- package/src/form.tsx +96 -49
- package/src/index.ts +199 -9
- package/src/jobs/fleet-repo.ts +155 -0
- package/src/jobs/job-actions.ts +172 -0
- package/src/jobs/job-entities.ts +118 -0
- package/src/jobs/job-labels.ts +108 -0
- package/src/jobs/job-repo.ts +137 -0
- package/src/jobs/job-resources.ts +211 -0
- package/src/jobs/job-where.ts +170 -0
- package/src/jobs/operator.ts +34 -0
- package/src/jobs/overview-data.ts +129 -0
- package/src/jobs/overview-tab.tsx +61 -0
- package/src/jobs/overview.tsx +189 -0
- package/src/json-text.ts +52 -0
- package/src/keyset-seek.ts +189 -0
- package/src/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +242 -0
- package/src/list-request.ts +129 -0
- package/src/list-scope-tabs.tsx +60 -0
- package/src/list-scope.ts +113 -0
- package/src/list.tsx +111 -72
- package/src/mcp-tools.ts +57 -13
- package/src/mcp.ts +112 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +115 -18
- package/src/registry.ts +140 -9
- package/src/related.ts +105 -0
- package/src/relations.ts +240 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +195 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +194 -0
- package/src/resource.ts +129 -79
- package/src/route-config.ts +71 -0
- package/src/routes.ts +55 -54
- package/src/row-scope-write.ts +130 -0
- package/src/row-version.ts +20 -0
- package/src/screen-action.tsx +333 -0
- package/src/screen-frame.tsx +167 -0
- package/src/screen-home.tsx +201 -0
- package/src/screen-lookup.tsx +157 -0
- package/src/screen-resource.tsx +441 -0
- package/src/screen-system.tsx +75 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +22 -10
- package/src/stylesheet-scope.ts +8 -0
- package/src/test-ctx.ts +31 -0
- package/src/validate.ts +3 -0
- package/src/widget-value.ts +36 -8
- package/src/widgets.tsx +84 -16
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// What every mounted screen shares: the shape of its request and its answer, the shell it is
|
|
2
|
+
// framed in, and the refusal it renders. A screen DECIDES first — through `crud.ts`,
|
|
3
|
+
// `action-gate.ts` or `page-guard.tsx` — and only then builds a body, so the frame is the one
|
|
4
|
+
// place a status meets a document.
|
|
5
|
+
|
|
6
|
+
import { localeConfig, t } from '@ultimat3/i18n';
|
|
7
|
+
import { ErrorState } from '@ultimat3/ui';
|
|
8
|
+
import type { JSX } from 'solid-js';
|
|
9
|
+
import type { AdminApp, AdminRoute } from './admin';
|
|
10
|
+
import { type AdminDecision, decideAll } from './authz';
|
|
11
|
+
import { type CrudCtx, canOperate } from './crud';
|
|
12
|
+
import { AdminFilterInvalidError } from './errors';
|
|
13
|
+
import { AdminLayout } from './layout';
|
|
14
|
+
import { AdminPageDenied, auditRefusal } from './page-guard';
|
|
15
|
+
import type { AdminPageProps } from './pages';
|
|
16
|
+
import type { RelationData } from './relations';
|
|
17
|
+
import type { AdminResource } from './resource';
|
|
18
|
+
import { LOOKUP_SEGMENT } from './resource-list';
|
|
19
|
+
import type { WidgetContext } from './widget-value';
|
|
20
|
+
|
|
21
|
+
/** One request to one admin route: a GET, or the form a screen posted back at itself. */
|
|
22
|
+
export interface AdminRouteRequest extends AdminPageProps {
|
|
23
|
+
readonly method: 'GET' | 'POST';
|
|
24
|
+
/** The parsed form. `null` on a GET; a POST with no fields is `{}`. */
|
|
25
|
+
readonly form: Readonly<Record<string, unknown>> | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AdminRouteResponse =
|
|
29
|
+
| {
|
|
30
|
+
readonly kind: 'document';
|
|
31
|
+
readonly status: number;
|
|
32
|
+
/** The framed screen: shell, nav for this actor, and the body. */
|
|
33
|
+
readonly body: JSX.Element;
|
|
34
|
+
}
|
|
35
|
+
/** A write that worked: the browser is sent to the row or the list it changed. Always a 303. */
|
|
36
|
+
| { readonly kind: 'redirect'; readonly location: string };
|
|
37
|
+
|
|
38
|
+
/** A mounted screen. `routes.ts` is the only thing that hands one to a host. */
|
|
39
|
+
export type AdminScreen = (request: AdminRouteRequest) => Promise<AdminRouteResponse>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The zone and locale every formatter on a screen reads — the ACTOR's, never the server's — and
|
|
43
|
+
* what the page read of the resources it references. `relations` is the whole of it: a widget
|
|
44
|
+
* shows a label or an option list the page already holds, and never reads a row of its own.
|
|
45
|
+
*/
|
|
46
|
+
export function widgetContextOf(
|
|
47
|
+
app: AdminApp,
|
|
48
|
+
ctx: CrudCtx,
|
|
49
|
+
relations: ReadonlyMap<string, RelationData> = new Map(),
|
|
50
|
+
): WidgetContext {
|
|
51
|
+
const target = (entity: string): AdminResource | undefined =>
|
|
52
|
+
app.resources.find((resource) => resource.name === entity);
|
|
53
|
+
return {
|
|
54
|
+
timeZone: ctx.actor.timeZone ?? 'UTC',
|
|
55
|
+
// The app's declared default, read back off the framework — never a second literal.
|
|
56
|
+
locale: ctx.actor.locale ?? localeConfig().fallback,
|
|
57
|
+
// The route table is the app's; a widget three layers down gets a link or no link.
|
|
58
|
+
hrefFor: (entity, id) => {
|
|
59
|
+
const found = target(entity);
|
|
60
|
+
return found === undefined ? null : `${app.basePath}${found.path}/${id}`;
|
|
61
|
+
},
|
|
62
|
+
labelFor: (entity, id) => relations.get(entity)?.labels.get(id),
|
|
63
|
+
optionsFor: (entity) => relations.get(entity)?.options ?? null,
|
|
64
|
+
lookupHref: (entity) => {
|
|
65
|
+
const found = target(entity);
|
|
66
|
+
// Only a resource whose list this actor may read has a lookup to send them to.
|
|
67
|
+
return found === undefined || !canOperate(found, 'list', ctx)
|
|
68
|
+
? null
|
|
69
|
+
: lookupHref(app.basePath, found);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Where a resource's lookup screen lives. One spelling, read by the route table and every link. */
|
|
75
|
+
export const lookupHref = (basePath: string, resource: Pick<AdminResource, 'path'>): string =>
|
|
76
|
+
`${basePath}${resource.path}/${LOOKUP_SEGMENT}`;
|
|
77
|
+
|
|
78
|
+
export function framed(
|
|
79
|
+
app: AdminApp,
|
|
80
|
+
request: AdminRouteRequest,
|
|
81
|
+
/** The screen's `<h1>`. `null` when the body carries its own — a refusal does. */
|
|
82
|
+
titleKey: string | null,
|
|
83
|
+
body: JSX.Element,
|
|
84
|
+
status = 200,
|
|
85
|
+
): AdminRouteResponse {
|
|
86
|
+
return {
|
|
87
|
+
kind: 'document',
|
|
88
|
+
status,
|
|
89
|
+
body: (
|
|
90
|
+
<AdminLayout
|
|
91
|
+
app={app}
|
|
92
|
+
nav={app.navFor(request.ctx)}
|
|
93
|
+
currentPath={new URL(request.url).pathname}
|
|
94
|
+
actor={request.ctx.actor}
|
|
95
|
+
{...(titleKey === null ? {} : { title: t(titleKey) })}
|
|
96
|
+
>
|
|
97
|
+
{body}
|
|
98
|
+
</AdminLayout>
|
|
99
|
+
),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A refusal the gate already audited: the page names the permission, and the status is 403. */
|
|
104
|
+
export function refused(
|
|
105
|
+
app: AdminApp,
|
|
106
|
+
request: AdminRouteRequest,
|
|
107
|
+
titleKey: string,
|
|
108
|
+
decision: AdminDecision,
|
|
109
|
+
): AdminRouteResponse {
|
|
110
|
+
return framed(
|
|
111
|
+
app,
|
|
112
|
+
request,
|
|
113
|
+
null,
|
|
114
|
+
<AdminPageDenied titleKey={titleKey} decision={decision} />,
|
|
115
|
+
403,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A refusal decided HERE rather than inside a crud call — a form's GET, a page — so the audit
|
|
121
|
+
* entry is written here too. The page is the subject: its path is what the row names.
|
|
122
|
+
*/
|
|
123
|
+
export async function refusedPage(
|
|
124
|
+
app: AdminApp,
|
|
125
|
+
request: AdminRouteRequest,
|
|
126
|
+
route: AdminRoute,
|
|
127
|
+
decision: AdminDecision,
|
|
128
|
+
): Promise<AdminRouteResponse> {
|
|
129
|
+
await auditRefusal(request.ctx, route.path, decision);
|
|
130
|
+
return refused(app, request, route.titleKey, decision);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A URL the admin serves and a thing it does not have: a row that is gone, an unknown action. */
|
|
134
|
+
export function missing(
|
|
135
|
+
app: AdminApp,
|
|
136
|
+
request: AdminRouteRequest,
|
|
137
|
+
titleKey: string,
|
|
138
|
+
body: JSX.Element,
|
|
139
|
+
): AdminRouteResponse {
|
|
140
|
+
return framed(app, request, titleKey, body, 404);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The wrapper that makes a screen's authz unskippable: a custom page, the dashboard, jobs, audit
|
|
145
|
+
* and search are all built through it, so "the screen that forgot its policy line" has nowhere to
|
|
146
|
+
* exist. It asks the SAME `decideAll` every CRUD call and every nav item asks, audits the
|
|
147
|
+
* refusal, answers it 403, and only then calls the body. A resource screen is the one thing not
|
|
148
|
+
* built here — `crud.ts` decides those, because only it holds the row a row-level rule reads.
|
|
149
|
+
*/
|
|
150
|
+
export function guardedScreen(
|
|
151
|
+
app: AdminApp,
|
|
152
|
+
route: AdminRoute,
|
|
153
|
+
body: (request: AdminRouteRequest) => JSX.Element | Promise<JSX.Element>,
|
|
154
|
+
): AdminScreen {
|
|
155
|
+
return async (request) => {
|
|
156
|
+
const decision = decideAll(request.ctx.authz, route.permissions, request.ctx.actor);
|
|
157
|
+
if (!decision.allowed) return refusedPage(app, request, route, decision);
|
|
158
|
+
try {
|
|
159
|
+
return framed(app, request, route.titleKey, await body(request));
|
|
160
|
+
} catch (error) {
|
|
161
|
+
// A body that refuses its URL — a parameter it does not answer — is the list's 400: the
|
|
162
|
+
// error, naming what the screen does answer. Anything else is not this frame's to render.
|
|
163
|
+
if (!(error instanceof AdminFilterInvalidError)) throw error;
|
|
164
|
+
return framed(app, request, route.titleKey, <ErrorState error={error} />, 400);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// `/admin` — the dashboard: every resource this actor may open, the decision behind each of its
|
|
2
|
+
// operations, and the app-wide actions. No row is read here: a dashboard that listed five rows of
|
|
3
|
+
// every resource would be one query per entity on every visit to the front page.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/i18n';
|
|
6
|
+
import { Card } from '@ultimat3/ui';
|
|
7
|
+
import type { JSX } from 'solid-js';
|
|
8
|
+
import { AdminActionForm } from './action-form';
|
|
9
|
+
import { actionButtons, invokeAdminAction } from './action-gate';
|
|
10
|
+
import { decodeActionInput, postedActionInput } from './action-input';
|
|
11
|
+
import { ACTION_OPERATION, ACTION_PARAM, AdminActions, OPERATION_FIELD } from './actions';
|
|
12
|
+
import type { AdminApp, AdminRoute } from './admin';
|
|
13
|
+
import styles from './admin.module.scss';
|
|
14
|
+
import { decideAll } from './authz';
|
|
15
|
+
import type { CrudCtx } from './crud';
|
|
16
|
+
import { canOperate, decideOperation, permissionsForOperation } from './crud';
|
|
17
|
+
import { posted } from './form-decode';
|
|
18
|
+
import { ADMIN_OPERATIONS, type AdminOperation, confirmationToken } from './permissions';
|
|
19
|
+
import type { AdminAction } from './registry';
|
|
20
|
+
import type { AdminResource } from './resource';
|
|
21
|
+
import {
|
|
22
|
+
type AdminRouteRequest,
|
|
23
|
+
type AdminRouteResponse,
|
|
24
|
+
type AdminScreen,
|
|
25
|
+
framed,
|
|
26
|
+
refused,
|
|
27
|
+
refusedPage,
|
|
28
|
+
} from './screen-frame';
|
|
29
|
+
import type { ValidationIssue } from './validate';
|
|
30
|
+
|
|
31
|
+
/** One row of an operation matrix: the decision the dashboard renders AND the call obeys. */
|
|
32
|
+
export interface OperationDecision {
|
|
33
|
+
readonly operation: AdminOperation;
|
|
34
|
+
readonly allowed: boolean;
|
|
35
|
+
readonly permissions: readonly string[];
|
|
36
|
+
readonly reason: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every operation the resource OFFERS, with its decision. This IS the view-only proof, rendered
|
|
41
|
+
* rather than asserted. One it does not offer has no row: "denied" would read as a missing grant
|
|
42
|
+
* the operator should ask for, when no grant opens it.
|
|
43
|
+
*/
|
|
44
|
+
export const operationMatrix = (
|
|
45
|
+
resource: AdminResource,
|
|
46
|
+
ctx: CrudCtx,
|
|
47
|
+
): readonly OperationDecision[] =>
|
|
48
|
+
ADMIN_OPERATIONS.filter((operation) => resource.operations.includes(operation)).map(
|
|
49
|
+
(operation) => {
|
|
50
|
+
const decision = decideOperation(resource, operation, ctx);
|
|
51
|
+
return {
|
|
52
|
+
operation,
|
|
53
|
+
allowed: decision.allowed,
|
|
54
|
+
permissions: permissionsForOperation(resource.permission, operation),
|
|
55
|
+
reason: decision.reason,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The permission matrix, rendered. A reader can see that `create`, `update` and `delete` are
|
|
62
|
+
* refused and WHICH permission refused them — the same decision the call obeys, not a note about
|
|
63
|
+
* a button that was left out.
|
|
64
|
+
*/
|
|
65
|
+
export function OperationMatrix(props: {
|
|
66
|
+
readonly rows: readonly OperationDecision[];
|
|
67
|
+
}): JSX.Element {
|
|
68
|
+
return (
|
|
69
|
+
<table class={styles['matrix']}>
|
|
70
|
+
<thead>
|
|
71
|
+
<tr>
|
|
72
|
+
<th>{t('admin.matrix.operation')}</th>
|
|
73
|
+
<th>{t('admin.matrix.permissions')}</th>
|
|
74
|
+
<th>{t('admin.matrix.verdict')}</th>
|
|
75
|
+
</tr>
|
|
76
|
+
</thead>
|
|
77
|
+
<tbody>
|
|
78
|
+
{props.rows.map((row) => (
|
|
79
|
+
<tr class={row.allowed ? styles['allowed'] : styles['denied']}>
|
|
80
|
+
<td>{t(`admin.operation.${row.operation}`)}</td>
|
|
81
|
+
<td class={styles['mono']}>{row.permissions.join(' + ')}</td>
|
|
82
|
+
<td>
|
|
83
|
+
{row.allowed
|
|
84
|
+
? t('admin.matrix.allowed')
|
|
85
|
+
: `${t('admin.matrix.deniedBy')} ${row.reason}`}
|
|
86
|
+
</td>
|
|
87
|
+
</tr>
|
|
88
|
+
))}
|
|
89
|
+
</tbody>
|
|
90
|
+
</table>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An app-wide action's POST: the same gate, with no row for a subject. */
|
|
95
|
+
async function globalWrite(
|
|
96
|
+
app: AdminApp,
|
|
97
|
+
route: AdminRoute,
|
|
98
|
+
request: AdminRouteRequest,
|
|
99
|
+
form: Readonly<Record<string, unknown>>,
|
|
100
|
+
): Promise<AdminRouteResponse> {
|
|
101
|
+
const name = posted(form, 'name') ?? '';
|
|
102
|
+
const action =
|
|
103
|
+
posted(form, OPERATION_FIELD) === ACTION_OPERATION
|
|
104
|
+
? app.globalActions.find((declared) => declared.name === name)
|
|
105
|
+
: undefined;
|
|
106
|
+
if (action === undefined) {
|
|
107
|
+
return framed(app, request, route.titleKey, <p>{t('admin.actions.unknown')}</p>, 404);
|
|
108
|
+
}
|
|
109
|
+
const confirmation = posted(form, 'confirmation');
|
|
110
|
+
const result = await invokeAdminAction({
|
|
111
|
+
action,
|
|
112
|
+
input: decodeActionInput(action, form),
|
|
113
|
+
actor: request.ctx.actor,
|
|
114
|
+
authz: request.ctx.authz,
|
|
115
|
+
audit: request.ctx.audit,
|
|
116
|
+
requestId: request.ctx.requestId,
|
|
117
|
+
...(confirmation === undefined ? {} : { confirmation }),
|
|
118
|
+
});
|
|
119
|
+
if (result.ok) return { kind: 'redirect', location: app.basePath };
|
|
120
|
+
if (result.kind === 'invalid') {
|
|
121
|
+
return globalForm(app, route, request, action, postedActionInput(action, form), result.issues);
|
|
122
|
+
}
|
|
123
|
+
// A global action has no row, so its `when` is never asked: only a refusal is left.
|
|
124
|
+
return refused(app, request, route.titleKey, result.decision);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** An app-wide action's own form: `GET /admin?action=<name>`, and its refused post. */
|
|
128
|
+
function globalForm(
|
|
129
|
+
app: AdminApp,
|
|
130
|
+
route: AdminRoute,
|
|
131
|
+
request: AdminRouteRequest,
|
|
132
|
+
action: AdminAction,
|
|
133
|
+
values: Readonly<Record<string, string>>,
|
|
134
|
+
issues: readonly ValidationIssue[],
|
|
135
|
+
): AdminRouteResponse {
|
|
136
|
+
return framed(
|
|
137
|
+
app,
|
|
138
|
+
request,
|
|
139
|
+
route.titleKey,
|
|
140
|
+
<AdminActionForm
|
|
141
|
+
action={action}
|
|
142
|
+
href={app.basePath}
|
|
143
|
+
operation={ACTION_OPERATION}
|
|
144
|
+
values={values}
|
|
145
|
+
issues={issues}
|
|
146
|
+
confirmation={action.destructive === true ? confirmationToken('admin', '') : null}
|
|
147
|
+
subject={t('admin.actions.subject.app')}
|
|
148
|
+
cancelHref={app.basePath}
|
|
149
|
+
/>,
|
|
150
|
+
issues.length === 0 ? 200 : 422,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function homeScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
155
|
+
return async (request) => {
|
|
156
|
+
const decision = decideAll(request.ctx.authz, route.permissions, request.ctx.actor);
|
|
157
|
+
if (!decision.allowed) return refusedPage(app, request, route, decision);
|
|
158
|
+
if (request.method === 'POST') return globalWrite(app, route, request, request.form ?? {});
|
|
159
|
+
const asked = new URL(request.url).searchParams.get(ACTION_PARAM);
|
|
160
|
+
if (asked !== null) {
|
|
161
|
+
// Only an action this actor may run: a link to a form is never a way round a missing button.
|
|
162
|
+
const offered = actionButtons({
|
|
163
|
+
actions: app.globalActions,
|
|
164
|
+
actor: request.ctx.actor,
|
|
165
|
+
authz: request.ctx.authz,
|
|
166
|
+
}).some((button) => button.name === asked);
|
|
167
|
+
const action = app.globalActions.find((declared) => declared.name === asked);
|
|
168
|
+
if (!offered || action === undefined) {
|
|
169
|
+
return framed(app, request, route.titleKey, <p>{t('admin.actions.unknown')}</p>, 404);
|
|
170
|
+
}
|
|
171
|
+
return globalForm(app, route, request, action, {}, []);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const open = app.resources.filter((resource) => canOperate(resource, 'list', request.ctx));
|
|
175
|
+
return framed(
|
|
176
|
+
app,
|
|
177
|
+
request,
|
|
178
|
+
route.titleKey,
|
|
179
|
+
<div class={styles['panel']}>
|
|
180
|
+
<AdminActions
|
|
181
|
+
actions={app.globalActions}
|
|
182
|
+
actor={request.ctx.actor}
|
|
183
|
+
authz={request.ctx.authz}
|
|
184
|
+
href={app.basePath}
|
|
185
|
+
/>
|
|
186
|
+
{open.length === 0 ? <p class={styles['note']}>{t('admin.actions.none')}</p> : null}
|
|
187
|
+
{open.map((resource) => (
|
|
188
|
+
<Card
|
|
189
|
+
header={
|
|
190
|
+
<h2>
|
|
191
|
+
<a href={`${app.basePath}${resource.path}`}>{t(resource.titleKey)}</a>
|
|
192
|
+
</h2>
|
|
193
|
+
}
|
|
194
|
+
>
|
|
195
|
+
<OperationMatrix rows={operationMatrix(resource, request.ctx)} />
|
|
196
|
+
</Card>
|
|
197
|
+
))}
|
|
198
|
+
</div>,
|
|
199
|
+
);
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// A resource's lookup screen: the picker for every filter and every input that references it.
|
|
2
|
+
// Zero script — a GET form over the label, results as links, keyset paging as links. Opened from
|
|
3
|
+
// a filter it carries `return` and `as`, and each result links back to that list with the
|
|
4
|
+
// parameter set to the row's id; opened on its own, a result opens the row.
|
|
5
|
+
|
|
6
|
+
import { t } from '@ultimat3/i18n';
|
|
7
|
+
import { Button, Card, ErrorState, Input, Pagination } from '@ultimat3/ui';
|
|
8
|
+
import type { JSX } from 'solid-js';
|
|
9
|
+
import type { AdminApp } from './admin';
|
|
10
|
+
import styles from './admin.module.scss';
|
|
11
|
+
import { canOperate } from './crud';
|
|
12
|
+
import { AdminFilterInvalidError } from './errors';
|
|
13
|
+
import { FILTER_PREFIX } from './list-filters';
|
|
14
|
+
import { CURSOR_PARAM } from './list-request';
|
|
15
|
+
import { adminLookup } from './relations';
|
|
16
|
+
import type { AdminResource } from './resource';
|
|
17
|
+
import { type AdminScreen, framed, lookupHref, refused } from './screen-frame';
|
|
18
|
+
|
|
19
|
+
const TERM_PARAM = 'term';
|
|
20
|
+
const RETURN_PARAM = 'return';
|
|
21
|
+
const AS_PARAM = 'as';
|
|
22
|
+
const KNOWN: readonly string[] = [TERM_PARAM, CURSOR_PARAM, RETURN_PARAM, AS_PARAM];
|
|
23
|
+
|
|
24
|
+
interface Asked {
|
|
25
|
+
readonly term: string;
|
|
26
|
+
readonly cursor: string | null;
|
|
27
|
+
/** The list to go back to, and the filter parameter to set on it. Both, or neither. */
|
|
28
|
+
readonly back: { readonly to: string; readonly as: string } | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What the URL asks for. `return` is followed by a link this screen renders, so it is held to the
|
|
33
|
+
* admin's own paths — an absolute URL or a `//host` here is somebody else's page wearing the
|
|
34
|
+
* admin's chrome — and `as` to a filter parameter, the only thing a lookup ever sets.
|
|
35
|
+
*/
|
|
36
|
+
function askedOf(app: AdminApp, resource: AdminResource, url: URL): Asked {
|
|
37
|
+
const refuse = (asked: string, cause: string): never => {
|
|
38
|
+
throw new AdminFilterInvalidError({ entity: resource.name, asked, cause, known: KNOWN });
|
|
39
|
+
};
|
|
40
|
+
for (const key of url.searchParams.keys()) {
|
|
41
|
+
if (!KNOWN.includes(key)) refuse(key, 'is not a parameter a lookup reads');
|
|
42
|
+
}
|
|
43
|
+
const to = url.searchParams.get(RETURN_PARAM) ?? '';
|
|
44
|
+
const as = url.searchParams.get(AS_PARAM) ?? '';
|
|
45
|
+
if ((to === '') !== (as === ''))
|
|
46
|
+
refuse(RETURN_PARAM, 'and `as` are given together or not at all');
|
|
47
|
+
if (to !== '' && !(to === app.basePath || to.startsWith(`${app.basePath}/`))) {
|
|
48
|
+
refuse(`${RETURN_PARAM}=${to}`, `is not a path under ${app.basePath}`);
|
|
49
|
+
}
|
|
50
|
+
if (as !== '' && !as.startsWith(FILTER_PREFIX)) {
|
|
51
|
+
refuse(`${AS_PARAM}=${as}`, `is not a filter parameter (${FILTER_PREFIX}<field>)`);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
term: url.searchParams.get(TERM_PARAM) ?? '',
|
|
55
|
+
cursor: url.searchParams.get(CURSOR_PARAM),
|
|
56
|
+
back: to === '' ? null : { to, as },
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The list a result returns to, with the picked id set and the old page position dropped. */
|
|
61
|
+
const picked = (back: { readonly to: string; readonly as: string }, id: string): string => {
|
|
62
|
+
const [path = '', search = ''] = back.to.split('?', 2);
|
|
63
|
+
const query = new URLSearchParams(search);
|
|
64
|
+
query.set(back.as, id);
|
|
65
|
+
query.delete(CURSOR_PARAM);
|
|
66
|
+
return `${path}?${query.toString()}`;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function lookupScreen(app: AdminApp, resource: AdminResource): AdminScreen {
|
|
70
|
+
const here = lookupHref(app.basePath, resource);
|
|
71
|
+
return async (request) => {
|
|
72
|
+
const url = new URL(request.url);
|
|
73
|
+
// Decided before the URL is read: an actor who may not list the target learns nothing about
|
|
74
|
+
// which parameters its lookup takes.
|
|
75
|
+
if (!canOperate(resource, 'list', request.ctx)) {
|
|
76
|
+
const denied = await adminLookup(resource, request.ctx);
|
|
77
|
+
if (!denied.ok) return refused(app, request, resource.titleKey, denied.decision);
|
|
78
|
+
}
|
|
79
|
+
let asked: Asked;
|
|
80
|
+
try {
|
|
81
|
+
asked = askedOf(app, resource, url);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (!(error instanceof AdminFilterInvalidError)) throw error;
|
|
84
|
+
return framed(app, request, resource.titleKey, <ErrorState error={error} />, 400);
|
|
85
|
+
}
|
|
86
|
+
const result = await adminLookup(resource, request.ctx, {
|
|
87
|
+
term: asked.term,
|
|
88
|
+
cursor: asked.cursor,
|
|
89
|
+
});
|
|
90
|
+
if (!result.ok) return refused(app, request, resource.titleKey, result.decision);
|
|
91
|
+
|
|
92
|
+
const state = (cursor: string | null): string => {
|
|
93
|
+
const query = new URLSearchParams();
|
|
94
|
+
if (asked.term !== '') query.set(TERM_PARAM, asked.term);
|
|
95
|
+
if (asked.back !== null) {
|
|
96
|
+
query.set(RETURN_PARAM, asked.back.to);
|
|
97
|
+
query.set(AS_PARAM, asked.back.as);
|
|
98
|
+
}
|
|
99
|
+
if (cursor !== null) query.set(CURSOR_PARAM, cursor);
|
|
100
|
+
const search = query.toString();
|
|
101
|
+
return search === '' ? here : `${here}?${search}`;
|
|
102
|
+
};
|
|
103
|
+
const hidden = (name: string, value: string): JSX.Element => (
|
|
104
|
+
<input type="hidden" name={name} value={value} />
|
|
105
|
+
);
|
|
106
|
+
const { page } = result;
|
|
107
|
+
|
|
108
|
+
return framed(
|
|
109
|
+
app,
|
|
110
|
+
request,
|
|
111
|
+
resource.titleKey,
|
|
112
|
+
<Card header={<h2>{t('admin.lookup.title', { entity: t(resource.titleKey) })}</h2>}>
|
|
113
|
+
<search>
|
|
114
|
+
<form class={styles['filters']} method="get" action={here}>
|
|
115
|
+
<Input
|
|
116
|
+
name={TERM_PARAM}
|
|
117
|
+
type="search"
|
|
118
|
+
value={asked.term}
|
|
119
|
+
aria-label={t('admin.search.label')}
|
|
120
|
+
placeholder={t('admin.lookup.placeholder')}
|
|
121
|
+
/>
|
|
122
|
+
{asked.back === null ? null : hidden(RETURN_PARAM, asked.back.to)}
|
|
123
|
+
{asked.back === null ? null : hidden(AS_PARAM, asked.back.as)}
|
|
124
|
+
<Button type="submit" variant="secondary">
|
|
125
|
+
{t('admin.filter.apply')}
|
|
126
|
+
</Button>
|
|
127
|
+
</form>
|
|
128
|
+
</search>
|
|
129
|
+
{result.options.length === 0 ? (
|
|
130
|
+
<p class="x-admin-empty">{t('admin.list.empty')}</p>
|
|
131
|
+
) : (
|
|
132
|
+
<ul class={styles['list']}>
|
|
133
|
+
{result.options.map((option) => (
|
|
134
|
+
<li>
|
|
135
|
+
<a
|
|
136
|
+
href={
|
|
137
|
+
asked.back === null
|
|
138
|
+
? `${app.basePath}${resource.path}/${option.id}`
|
|
139
|
+
: picked(asked.back, option.id)
|
|
140
|
+
}
|
|
141
|
+
>
|
|
142
|
+
{option.label}
|
|
143
|
+
</a>{' '}
|
|
144
|
+
<span class={styles['mono']}>{option.id}</span>
|
|
145
|
+
</li>
|
|
146
|
+
))}
|
|
147
|
+
</ul>
|
|
148
|
+
)}
|
|
149
|
+
<Pagination
|
|
150
|
+
nextCursor={page.hasMore ? (page.nextCursor ?? undefined) : undefined}
|
|
151
|
+
prevCursor={page.prevCursor ?? undefined}
|
|
152
|
+
hrefFor={(cursor) => state(cursor)}
|
|
153
|
+
/>
|
|
154
|
+
</Card>,
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
}
|