@ultimat3/admin 22.14.0 → 23.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 +73 -80
- package/README.md +276 -87
- package/package.json +19 -17
- package/src/action-form.tsx +139 -0
- package/src/action-gate.ts +127 -28
- 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 +92 -0
- package/src/batch.ts +431 -0
- package/src/catalog-keys.ts +55 -0
- package/src/crud-input.ts +104 -0
- package/src/crud-outcome.ts +166 -0
- package/src/crud.ts +129 -177
- package/src/describe.ts +131 -0
- package/src/detail.tsx +115 -43
- package/src/dev/data.ts +24 -6
- package/src/dev/facts.ts +27 -0
- 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.ts +6 -0
- package/src/dev/server.ts +15 -7
- package/src/entity-columns.ts +24 -5
- package/src/errors.ts +83 -0
- package/src/fields.ts +63 -4
- package/src/form-decode.ts +91 -0
- package/src/form.tsx +78 -45
- package/src/index.ts +198 -9
- package/src/jobs/fleet-repo.ts +143 -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 +103 -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/layout.tsx +55 -43
- package/src/list-columns.tsx +53 -0
- package/src/list-filter-bar.tsx +207 -0
- package/src/list-filters.ts +217 -0
- package/src/list-request.ts +127 -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 +108 -38
- package/src/mounts.ts +44 -0
- package/src/page-guard.tsx +26 -29
- package/src/policy-bridge.ts +110 -15
- package/src/registry.ts +138 -8
- package/src/related.ts +105 -0
- package/src/relations.ts +224 -0
- package/src/renderers.tsx +89 -0
- package/src/repo-entity.ts +204 -0
- package/src/resource-fields.ts +116 -0
- package/src/resource-layout.ts +89 -0
- package/src/resource-list.ts +185 -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/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 +393 -0
- package/src/screen-system.tsx +69 -0
- package/src/screens.ts +56 -0
- package/src/search.ts +13 -9
- 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 -12
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// The four generated screens of one resource — list, detail, create, edit — and the writes each
|
|
2
|
+
// one's forms post back at it. Every one asks `crud.ts` (or the action gate) FIRST and renders
|
|
3
|
+
// what it answered: a row-level rule sees the row, a refusal is audited by the gate that made it,
|
|
4
|
+
// and no screen holds a decision of its own.
|
|
5
|
+
|
|
6
|
+
import { t } from '@ultimat3/i18n';
|
|
7
|
+
import { ErrorState, Link } from '@ultimat3/ui';
|
|
8
|
+
import type { JSX } from 'solid-js';
|
|
9
|
+
import {
|
|
10
|
+
ACTION_OPERATION,
|
|
11
|
+
ACTION_PARAM,
|
|
12
|
+
BATCH_OPERATION,
|
|
13
|
+
DELETE_OPERATION,
|
|
14
|
+
OPERATION_FIELD,
|
|
15
|
+
} from './actions';
|
|
16
|
+
import type { AdminApp, AdminRoute } from './admin';
|
|
17
|
+
import styles from './admin.module.scss';
|
|
18
|
+
import { auditCursorOf } from './audit';
|
|
19
|
+
import {
|
|
20
|
+
adminCreate,
|
|
21
|
+
adminDestroy,
|
|
22
|
+
adminDetail,
|
|
23
|
+
adminList,
|
|
24
|
+
adminUpdate,
|
|
25
|
+
canOperate,
|
|
26
|
+
decideOperation,
|
|
27
|
+
} from './crud';
|
|
28
|
+
import { AdminDetail, HISTORY_PARAM, historyCursorOf, historyCursorText } from './detail';
|
|
29
|
+
import { AdminFilterInvalidError } from './errors';
|
|
30
|
+
import { AdminForm } from './form';
|
|
31
|
+
import { decodeForm, posted } from './form-decode';
|
|
32
|
+
import { AdminList } from './list';
|
|
33
|
+
import { type ListLocation, listHref, pageRequestOf } from './list-request';
|
|
34
|
+
import { type AdminListRequest, findRow, scopeCounts } from './list-scope';
|
|
35
|
+
import type { AdminRow } from './registry';
|
|
36
|
+
import { relatedLists, relatedOf } from './related';
|
|
37
|
+
import { type RelationNeed, relationNeeds, relationsFor } from './relations';
|
|
38
|
+
import type { AdminResource } from './resource';
|
|
39
|
+
import { batchWrite, rowActionFormScreen, rowActionWrite } from './screen-action';
|
|
40
|
+
import {
|
|
41
|
+
type AdminRouteRequest,
|
|
42
|
+
type AdminRouteResponse,
|
|
43
|
+
type AdminScreen,
|
|
44
|
+
framed,
|
|
45
|
+
missing,
|
|
46
|
+
refused,
|
|
47
|
+
refusedPage,
|
|
48
|
+
widgetContextOf,
|
|
49
|
+
} from './screen-frame';
|
|
50
|
+
import type { ValidationIssue } from './validate';
|
|
51
|
+
import type { WidgetContext } from './widget-value';
|
|
52
|
+
|
|
53
|
+
const rowHref = (app: AdminApp, resource: AdminResource, id: string): string =>
|
|
54
|
+
`${app.basePath}${resource.path}/${id}`;
|
|
55
|
+
|
|
56
|
+
const idOf = (request: AdminRouteRequest): string => request.params['id'] ?? '';
|
|
57
|
+
|
|
58
|
+
const notFound = (
|
|
59
|
+
app: AdminApp,
|
|
60
|
+
resource: AdminResource,
|
|
61
|
+
request: AdminRouteRequest,
|
|
62
|
+
): AdminRouteResponse =>
|
|
63
|
+
missing(
|
|
64
|
+
app,
|
|
65
|
+
request,
|
|
66
|
+
resource.titleKey,
|
|
67
|
+
<AdminDetail
|
|
68
|
+
resource={resource}
|
|
69
|
+
row={null}
|
|
70
|
+
error={null}
|
|
71
|
+
ctx={widgetContextOf(app, request.ctx)}
|
|
72
|
+
actor={request.ctx.actor}
|
|
73
|
+
authz={request.ctx.authz}
|
|
74
|
+
audit={[]}
|
|
75
|
+
basePath={app.basePath}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
export function listScreen(app: AdminApp, resource: AdminResource): AdminScreen {
|
|
80
|
+
const hrefFor = (location: ListLocation): string => listHref(app.basePath, resource, location);
|
|
81
|
+
return async (request) => {
|
|
82
|
+
// A batch is posted at the list's own URL: its query string is what "all matching" means.
|
|
83
|
+
if (request.method === 'POST') {
|
|
84
|
+
const form = request.form ?? {};
|
|
85
|
+
if (posted(form, OPERATION_FIELD) !== BATCH_OPERATION)
|
|
86
|
+
return notFound(app, resource, request);
|
|
87
|
+
try {
|
|
88
|
+
return await batchWrite(app, request, resource, form, () =>
|
|
89
|
+
notFound(app, resource, request),
|
|
90
|
+
);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (!(error instanceof AdminFilterInvalidError)) throw error;
|
|
93
|
+
return framed(app, request, resource.titleKey, <ErrorState error={error} />, 400);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
// Refused BEFORE the URL is read: an actor who may not list the table learns nothing about
|
|
97
|
+
// its filters or scopes from the answer. `adminList` makes and audits the refusal.
|
|
98
|
+
if (!canOperate(resource, 'list', request.ctx)) {
|
|
99
|
+
const denied = await adminList<AdminRow>(resource, request.ctx);
|
|
100
|
+
if (!denied.ok) return refused(app, request, resource.titleKey, denied.decision);
|
|
101
|
+
}
|
|
102
|
+
let asked: AdminListRequest;
|
|
103
|
+
let result: Awaited<ReturnType<typeof adminList<AdminRow>>>;
|
|
104
|
+
try {
|
|
105
|
+
asked = pageRequestOf(resource, new URL(request.url));
|
|
106
|
+
// Inside the same refusal: a repo that cannot answer a predicate the URL grammar allows (a
|
|
107
|
+
// store with one order, a filter it has no column for) refuses it by name too.
|
|
108
|
+
result = await adminList<AdminRow>(resource, request.ctx, asked);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (!(error instanceof AdminFilterInvalidError)) throw error;
|
|
111
|
+
// 400, naming what the list does answer — and the way back to it.
|
|
112
|
+
return framed(
|
|
113
|
+
app,
|
|
114
|
+
request,
|
|
115
|
+
resource.titleKey,
|
|
116
|
+
<>
|
|
117
|
+
<ErrorState error={error} />
|
|
118
|
+
<Link href={hrefFor({})}>{t('admin.filter.clear')}</Link>
|
|
119
|
+
</>,
|
|
120
|
+
400,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (!result.ok) return refused(app, request, resource.titleKey, result.decision);
|
|
124
|
+
|
|
125
|
+
// Everything else the page shows, read beside each other: one count per scope that asked for
|
|
126
|
+
// one, and one read per resource the page references — never one per row.
|
|
127
|
+
const [counts, relations] = await Promise.all([
|
|
128
|
+
scopeCounts(resource, request.ctx.actor),
|
|
129
|
+
relationsFor(
|
|
130
|
+
app.resources,
|
|
131
|
+
request.ctx,
|
|
132
|
+
relationNeeds(result.page.rows, resource.listFields, resource.filters),
|
|
133
|
+
),
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
return framed(
|
|
137
|
+
app,
|
|
138
|
+
request,
|
|
139
|
+
resource.titleKey,
|
|
140
|
+
<>
|
|
141
|
+
{canOperate(resource, 'create', request.ctx) ? (
|
|
142
|
+
<div class={styles['toolbar']}>
|
|
143
|
+
<Link appearance="button" href={`${hrefFor({})}/new`}>
|
|
144
|
+
{t('admin.form.create', { entity: t(resource.titleKey) })}
|
|
145
|
+
</Link>
|
|
146
|
+
</div>
|
|
147
|
+
) : null}
|
|
148
|
+
<AdminList
|
|
149
|
+
resource={resource}
|
|
150
|
+
page={result.page}
|
|
151
|
+
error={null}
|
|
152
|
+
ctx={widgetContextOf(app, request.ctx, relations)}
|
|
153
|
+
actor={request.ctx.actor}
|
|
154
|
+
authz={request.ctx.authz}
|
|
155
|
+
basePath={app.basePath}
|
|
156
|
+
request={asked}
|
|
157
|
+
scope={result.scope}
|
|
158
|
+
counts={counts}
|
|
159
|
+
hrefFor={hrefFor}
|
|
160
|
+
/>
|
|
161
|
+
</>,
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The labels, option lists and lookups one row's reference fields need, for a detail or a form. */
|
|
167
|
+
const relationsOf = (
|
|
168
|
+
app: AdminApp,
|
|
169
|
+
resource: AdminResource,
|
|
170
|
+
request: AdminRouteRequest,
|
|
171
|
+
row: AdminRow,
|
|
172
|
+
picking: boolean,
|
|
173
|
+
): Promise<WidgetContext> =>
|
|
174
|
+
relationsFor(
|
|
175
|
+
app.resources,
|
|
176
|
+
request.ctx,
|
|
177
|
+
relationNeeds([row], resource.fields, picking ? resource.formFields : []),
|
|
178
|
+
).then((relations) => widgetContextOf(app, request.ctx, relations));
|
|
179
|
+
|
|
180
|
+
/** A posted action or delete, decided by the same gate that decided whether its button rendered. */
|
|
181
|
+
async function detailWrite(
|
|
182
|
+
app: AdminApp,
|
|
183
|
+
resource: AdminResource,
|
|
184
|
+
request: AdminRouteRequest,
|
|
185
|
+
form: Readonly<Record<string, unknown>>,
|
|
186
|
+
): Promise<AdminRouteResponse> {
|
|
187
|
+
const id = idOf(request);
|
|
188
|
+
const confirmation = posted(form, 'confirmation');
|
|
189
|
+
const landing = listHref(app.basePath, resource);
|
|
190
|
+
|
|
191
|
+
if (posted(form, OPERATION_FIELD) === DELETE_OPERATION) {
|
|
192
|
+
const result = await adminDestroy(resource, request.ctx, id, confirmation);
|
|
193
|
+
if (result.ok) return { kind: 'redirect', location: landing };
|
|
194
|
+
if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
|
|
195
|
+
return notFound(app, resource, request);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (posted(form, OPERATION_FIELD) !== ACTION_OPERATION) return notFound(app, resource, request);
|
|
199
|
+
return rowActionWrite(app, request, resource, form, () => notFound(app, resource, request));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** How many entries of a row's history one detail page shows. Older ones are a link away. */
|
|
203
|
+
const HISTORY_PAGE = 20;
|
|
204
|
+
|
|
205
|
+
/** Two pages' needs as one: each target is still read once for the whole page. */
|
|
206
|
+
const mergedNeeds = (
|
|
207
|
+
all: readonly ReadonlyMap<string, RelationNeed>[],
|
|
208
|
+
): ReadonlyMap<string, RelationNeed> => {
|
|
209
|
+
const out = new Map<string, { ids: Set<string>; pick: boolean }>();
|
|
210
|
+
for (const needs of all) {
|
|
211
|
+
for (const [entity, need] of needs) {
|
|
212
|
+
const held = out.get(entity) ?? { ids: new Set<string>(), pick: false };
|
|
213
|
+
for (const id of need.ids) held.ids.add(id);
|
|
214
|
+
held.pick ||= need.pick;
|
|
215
|
+
out.set(entity, held);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
export function detailScreen(app: AdminApp, resource: AdminResource): AdminScreen {
|
|
222
|
+
return async (request) => {
|
|
223
|
+
if (request.method === 'POST') {
|
|
224
|
+
return detailWrite(app, resource, request, request.form ?? {});
|
|
225
|
+
}
|
|
226
|
+
const id = idOf(request);
|
|
227
|
+
const url = new URL(request.url);
|
|
228
|
+
const asked = url.searchParams.get(ACTION_PARAM);
|
|
229
|
+
if (asked !== null) {
|
|
230
|
+
return rowActionFormScreen(app, request, resource, asked, () =>
|
|
231
|
+
notFound(app, resource, request),
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
const result = await adminDetail<AdminRow>(resource, request.ctx, id);
|
|
235
|
+
if (!result.ok) {
|
|
236
|
+
return result.kind === 'denied'
|
|
237
|
+
? refused(app, request, resource.titleKey, result.decision)
|
|
238
|
+
: notFound(app, resource, request);
|
|
239
|
+
}
|
|
240
|
+
if (result.row === null) return notFound(app, resource, request);
|
|
241
|
+
const row = result.row;
|
|
242
|
+
|
|
243
|
+
// Everything else the page shows, read beside each other: the related resources' own lists,
|
|
244
|
+
// and one keyset page of this row's history — what changed it or was refused, never who read.
|
|
245
|
+
const before = historyCursorOf(url.searchParams.get(HISTORY_PARAM));
|
|
246
|
+
const [related, history] = await Promise.all([
|
|
247
|
+
relatedLists(relatedOf(app.resources, resource), row, request.ctx),
|
|
248
|
+
request.ctx.audit.entries({
|
|
249
|
+
entity: resource.name,
|
|
250
|
+
entityId: id,
|
|
251
|
+
changes: true,
|
|
252
|
+
limit: HISTORY_PAGE,
|
|
253
|
+
...(before === undefined ? {} : { before }),
|
|
254
|
+
}),
|
|
255
|
+
]);
|
|
256
|
+
// ONE label read per referenced target for the whole page — this row's references and every
|
|
257
|
+
// related row's together.
|
|
258
|
+
const relations = await relationsFor(
|
|
259
|
+
app.resources,
|
|
260
|
+
request.ctx,
|
|
261
|
+
mergedNeeds([
|
|
262
|
+
relationNeeds([row], resource.fields, []),
|
|
263
|
+
...related.map((list) =>
|
|
264
|
+
relationNeeds(list.page.rows, list.related.resource.listFields, []),
|
|
265
|
+
),
|
|
266
|
+
]),
|
|
267
|
+
);
|
|
268
|
+
const older = auditCursorOf(history);
|
|
269
|
+
const self = rowHref(app, resource, id);
|
|
270
|
+
|
|
271
|
+
return framed(
|
|
272
|
+
app,
|
|
273
|
+
request,
|
|
274
|
+
resource.titleKey,
|
|
275
|
+
<AdminDetail
|
|
276
|
+
resource={resource}
|
|
277
|
+
row={row}
|
|
278
|
+
error={null}
|
|
279
|
+
ctx={widgetContextOf(app, request.ctx, relations)}
|
|
280
|
+
actor={request.ctx.actor}
|
|
281
|
+
authz={request.ctx.authz}
|
|
282
|
+
audit={history}
|
|
283
|
+
olderHref={
|
|
284
|
+
history.length < HISTORY_PAGE || older === null
|
|
285
|
+
? null
|
|
286
|
+
: `${self}?${HISTORY_PARAM}=${encodeURIComponent(historyCursorText(older))}`
|
|
287
|
+
}
|
|
288
|
+
related={related}
|
|
289
|
+
relatedHref={(list) =>
|
|
290
|
+
// Through the target's own list grammar — only when the key is one of its filters, or
|
|
291
|
+
// the link would be the unfiltered list.
|
|
292
|
+
list.related.resource.filters.some((field) => field.name === list.filter.field)
|
|
293
|
+
? listHref(app.basePath, list.related.resource, { scope: null, filters: [list.filter] })
|
|
294
|
+
: listHref(app.basePath, list.related.resource)
|
|
295
|
+
}
|
|
296
|
+
basePath={app.basePath}
|
|
297
|
+
may={{
|
|
298
|
+
update: decideOperation(resource, 'update', request.ctx, id, row).allowed,
|
|
299
|
+
delete: decideOperation(resource, 'delete', request.ctx, id, row).allowed,
|
|
300
|
+
}}
|
|
301
|
+
/>,
|
|
302
|
+
);
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function form(
|
|
307
|
+
app: AdminApp,
|
|
308
|
+
resource: AdminResource,
|
|
309
|
+
request: AdminRouteRequest,
|
|
310
|
+
mode: 'create' | 'edit',
|
|
311
|
+
values: Readonly<Record<string, unknown>>,
|
|
312
|
+
issues: readonly ValidationIssue[],
|
|
313
|
+
): Promise<JSX.Element> {
|
|
314
|
+
const list = listHref(app.basePath, resource);
|
|
315
|
+
return (
|
|
316
|
+
<AdminForm
|
|
317
|
+
resource={resource}
|
|
318
|
+
mode={mode}
|
|
319
|
+
values={values}
|
|
320
|
+
issues={issues}
|
|
321
|
+
error={null}
|
|
322
|
+
// A reference input picks from its target: the option list of a small one, read once.
|
|
323
|
+
ctx={await relationsOf(app, resource, request, values, true)}
|
|
324
|
+
action={new URL(request.url).pathname}
|
|
325
|
+
cancelHref={mode === 'create' ? list : rowHref(app, resource, idOf(request))}
|
|
326
|
+
/>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function createScreen(
|
|
331
|
+
app: AdminApp,
|
|
332
|
+
route: AdminRoute,
|
|
333
|
+
resource: AdminResource,
|
|
334
|
+
): AdminScreen {
|
|
335
|
+
return async (request) => {
|
|
336
|
+
if (request.method === 'GET') {
|
|
337
|
+
const decision = decideOperation(resource, 'create', request.ctx);
|
|
338
|
+
if (!decision.allowed) return refusedPage(app, request, route, decision);
|
|
339
|
+
return framed(
|
|
340
|
+
app,
|
|
341
|
+
request,
|
|
342
|
+
resource.titleKey,
|
|
343
|
+
await form(app, resource, request, 'create', {}, []),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const input = decodeForm(resource, request.form ?? {});
|
|
347
|
+
const result = await adminCreate<AdminRow>(resource, request.ctx, input);
|
|
348
|
+
if (result.ok) {
|
|
349
|
+
const id = String(result.row?.[resource.idField] ?? '');
|
|
350
|
+
return { kind: 'redirect', location: rowHref(app, resource, id) };
|
|
351
|
+
}
|
|
352
|
+
if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
|
|
353
|
+
if (result.kind === 'missing') return notFound(app, resource, request);
|
|
354
|
+
// 422, with what was typed: a refused form that came back empty is a form filled in twice.
|
|
355
|
+
return framed(
|
|
356
|
+
app,
|
|
357
|
+
request,
|
|
358
|
+
resource.titleKey,
|
|
359
|
+
await form(app, resource, request, 'create', input, result.issues),
|
|
360
|
+
422,
|
|
361
|
+
);
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function editScreen(app: AdminApp, route: AdminRoute, resource: AdminResource): AdminScreen {
|
|
366
|
+
return async (request) => {
|
|
367
|
+
const id = idOf(request);
|
|
368
|
+
if (request.method === 'GET') {
|
|
369
|
+
const row = await findRow(resource, request.ctx.actor, id);
|
|
370
|
+
const decision = decideOperation(resource, 'update', request.ctx, id, row);
|
|
371
|
+
if (!decision.allowed) return refusedPage(app, request, route, decision);
|
|
372
|
+
if (row === null) return notFound(app, resource, request);
|
|
373
|
+
return framed(
|
|
374
|
+
app,
|
|
375
|
+
request,
|
|
376
|
+
resource.titleKey,
|
|
377
|
+
await form(app, resource, request, 'edit', row, []),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
const input = decodeForm(resource, request.form ?? {});
|
|
381
|
+
const result = await adminUpdate<AdminRow>(resource, request.ctx, id, input);
|
|
382
|
+
if (result.ok) return { kind: 'redirect', location: rowHref(app, resource, id) };
|
|
383
|
+
if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
|
|
384
|
+
if (result.kind === 'missing') return notFound(app, resource, request);
|
|
385
|
+
return framed(
|
|
386
|
+
app,
|
|
387
|
+
request,
|
|
388
|
+
resource.titleKey,
|
|
389
|
+
await form(app, resource, request, 'edit', input, result.issues),
|
|
390
|
+
422,
|
|
391
|
+
);
|
|
392
|
+
};
|
|
393
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The two built-in screens that are neither a resource nor the jobs dashboard (`jobs/`): the
|
|
2
|
+
// audit trail and cross-entity search. Each is decided by its own route's permission pair through
|
|
3
|
+
// `decideAll` — the same gate a custom page gets — before anything is read.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/i18n';
|
|
6
|
+
import { Card } from '@ultimat3/ui';
|
|
7
|
+
import type { AdminApp, AdminRoute } from './admin';
|
|
8
|
+
import styles from './admin.module.scss';
|
|
9
|
+
import { operationLabel } from './detail';
|
|
10
|
+
import { type AdminScreen, guardedScreen } from './screen-frame';
|
|
11
|
+
import { adminSearch } from './search';
|
|
12
|
+
|
|
13
|
+
/** How many audit entries one screen shows — newest first, from whichever log the admin names. */
|
|
14
|
+
const AUDIT_PAGE = 100;
|
|
15
|
+
|
|
16
|
+
export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
17
|
+
return guardedScreen(app, route, async () => {
|
|
18
|
+
const entries = await app.audit.entries({ limit: AUDIT_PAGE });
|
|
19
|
+
return (
|
|
20
|
+
<Card>
|
|
21
|
+
{entries.length === 0 ? (
|
|
22
|
+
<p class="x-admin-empty">{t('admin.audit.empty')}</p>
|
|
23
|
+
) : (
|
|
24
|
+
<ol class={styles['list']}>
|
|
25
|
+
{entries.map((entry) => (
|
|
26
|
+
<li>
|
|
27
|
+
<code>{entry.at}</code> <span>{entry.actor.id}</span>{' '}
|
|
28
|
+
<span>{operationLabel(entry)}</span>{' '}
|
|
29
|
+
<span class={styles['mono']}>{entry.entity}</span>{' '}
|
|
30
|
+
<span data-outcome={entry.outcome}>
|
|
31
|
+
{t(`admin.audit.outcome.${entry.outcome}`)}
|
|
32
|
+
</span>
|
|
33
|
+
</li>
|
|
34
|
+
))}
|
|
35
|
+
</ol>
|
|
36
|
+
)}
|
|
37
|
+
</Card>
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `?term=` — the shell's search box is a native GET at this route. */
|
|
43
|
+
export function searchScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
44
|
+
return guardedScreen(app, route, async (request) => {
|
|
45
|
+
const term = new URL(request.url).searchParams.get('term') ?? '';
|
|
46
|
+
const result = await adminSearch({ term, resources: app.resources, ctx: request.ctx });
|
|
47
|
+
return (
|
|
48
|
+
<Card>
|
|
49
|
+
{result.hits.length === 0 ? (
|
|
50
|
+
<p class="x-admin-empty">{t('admin.list.empty')}</p>
|
|
51
|
+
) : (
|
|
52
|
+
<ul class={styles['list']}>
|
|
53
|
+
{result.hits.map((hit) => (
|
|
54
|
+
<li>
|
|
55
|
+
<a href={`${app.basePath}${hit.href}`}>{hit.label}</a>{' '}
|
|
56
|
+
<span class={styles['mono']}>{hit.entity}</span>
|
|
57
|
+
</li>
|
|
58
|
+
))}
|
|
59
|
+
</ul>
|
|
60
|
+
)}
|
|
61
|
+
{result.skipped.map((skip) => (
|
|
62
|
+
<p class={styles['note']}>
|
|
63
|
+
<span class={styles['mono']}>{skip.entity}</span> {t(skip.reason)}
|
|
64
|
+
</p>
|
|
65
|
+
))}
|
|
66
|
+
</Card>
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
}
|
package/src/screens.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// View → screen: the one table that says what renders at each route of the admin. A route whose
|
|
2
|
+
// view has no row here does not compile, so a new `AdminView` cannot mount with nothing behind it.
|
|
3
|
+
|
|
4
|
+
import type { AdminApp, AdminRoute, AdminView } from './admin';
|
|
5
|
+
import { AdminPagePathInvalidError } from './errors';
|
|
6
|
+
import { jobsOverviewScreen } from './jobs/overview';
|
|
7
|
+
import { type AdminScreen, guardedScreen } from './screen-frame';
|
|
8
|
+
import { homeScreen } from './screen-home';
|
|
9
|
+
import { lookupScreen } from './screen-lookup';
|
|
10
|
+
import { createScreen, detailScreen, editScreen, listScreen } from './screen-resource';
|
|
11
|
+
import { auditScreen, searchScreen } from './screen-system';
|
|
12
|
+
|
|
13
|
+
type Build = (app: AdminApp, route: AdminRoute) => AdminScreen;
|
|
14
|
+
|
|
15
|
+
/** A resource view names its entity; the table and `defineAdmin` are built from the same list. */
|
|
16
|
+
const resourceOf = (app: AdminApp, route: AdminRoute) => app.resource(route.entity ?? '');
|
|
17
|
+
|
|
18
|
+
const SCREENS: Readonly<Record<AdminView, Build>> = {
|
|
19
|
+
dashboard: homeScreen,
|
|
20
|
+
search: searchScreen,
|
|
21
|
+
jobs: jobsOverviewScreen,
|
|
22
|
+
audit: auditScreen,
|
|
23
|
+
list: (app, route) => listScreen(app, resourceOf(app, route)),
|
|
24
|
+
lookup: (app, route) => lookupScreen(app, resourceOf(app, route)),
|
|
25
|
+
detail: (app, route) => detailScreen(app, resourceOf(app, route)),
|
|
26
|
+
create: (app, route) => createScreen(app, route, resourceOf(app, route)),
|
|
27
|
+
edit: (app, route) => editScreen(app, route, resourceOf(app, route)),
|
|
28
|
+
page: (app, route) => {
|
|
29
|
+
const component = route.component;
|
|
30
|
+
// `pages.ts` sets it on every `view: 'page'` it builds; a hand-made route without one is a
|
|
31
|
+
// path with nothing behind it, refused where it would have been mounted.
|
|
32
|
+
if (component === undefined) {
|
|
33
|
+
throw new AdminPagePathInvalidError({
|
|
34
|
+
path: route.path,
|
|
35
|
+
cause: 'is a page route with no component',
|
|
36
|
+
fix: 'declare it in `pages:` on defineAdmin() — { path, titleKey, permissions, component }',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return guardedScreen(app, route, (request) => component(request));
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The same table as a Map: a view that is not one of the nine has no screen, not a prototype's. */
|
|
44
|
+
const SCREEN_BY_VIEW: ReadonlyMap<string, Build> = new Map(Object.entries(SCREENS));
|
|
45
|
+
|
|
46
|
+
export function screenFor(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
47
|
+
const build = SCREEN_BY_VIEW.get(route.view);
|
|
48
|
+
if (build === undefined) {
|
|
49
|
+
throw new AdminPagePathInvalidError({
|
|
50
|
+
path: route.path,
|
|
51
|
+
cause: `has the view "${route.view}", which no screen renders`,
|
|
52
|
+
fix: 'declare the screen in `pages:` on defineAdmin() — { path, titleKey, permissions, component }',
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return build(app, route);
|
|
56
|
+
}
|