@ultimat3/admin 24.0.0 → 25.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +14 -11
- package/README.md +39 -8
- package/package.json +17 -17
- package/src/action-gate.ts +18 -1
- package/src/admin.module.scss +118 -4
- package/src/audit-pg.ts +3 -3
- package/src/audit.ts +1 -3
- package/src/authz.ts +8 -8
- package/src/batch.ts +3 -8
- package/src/crud-input.ts +2 -1
- package/src/describe.ts +6 -0
- package/src/dev/data.ts +2 -2
- package/src/dev/dev-t.ts +9 -0
- package/src/dev/jobs-state-chart.tsx +38 -0
- package/src/dev/panel-jobs.ts +10 -5
- package/src/dev/panel-timeline.ts +9 -1
- package/src/dev/panel.ts +6 -3
- package/src/dev/server.ts +42 -13
- package/src/dev/timeline-style.ts +47 -0
- package/src/dev/timeline-waterfall.ts +96 -0
- package/src/form-decode.ts +13 -4
- package/src/index.ts +8 -7
- package/src/jobs/operator.ts +2 -2
- package/src/jobs/overview-tab.tsx +2 -2
- package/src/layout.tsx +34 -8
- package/src/list.tsx +1 -1
- package/src/mcp-scopes.ts +36 -0
- package/src/mcp-tools.ts +16 -2
- package/src/mcp.ts +23 -11
- package/src/pagination.ts +22 -18
- package/src/policy-bridge.ts +6 -6
- package/src/registry.ts +7 -0
- package/src/related.ts +2 -2
- package/src/relations.ts +1 -1
- package/src/resource.ts +10 -0
- package/src/screen-home-access.tsx +117 -0
- package/src/screen-home-counts.ts +42 -0
- package/src/screen-home-kpis.tsx +64 -0
- package/src/screen-home.module.scss +169 -0
- package/src/screen-home.tsx +21 -82
- package/src/screen-lookup.tsx +1 -1
- package/src/screen-resource.tsx +4 -4
- package/src/search.ts +2 -2
- package/src/inert-jsx.ts +0 -182
package/src/mcp-tools.ts
CHANGED
|
@@ -5,12 +5,12 @@
|
|
|
5
5
|
// Tool descriptions are literal English on purpose: they are protocol payload read by a
|
|
6
6
|
// model, not UI copy read by a person, so they are not `t()` keys.
|
|
7
7
|
|
|
8
|
-
import { decideAction, permissionsForAction } from './action-gate';
|
|
8
|
+
import { adminPermissionForAction, decideAction, permissionsForAction } from './action-gate';
|
|
9
9
|
import type { AdminApp } from './admin';
|
|
10
10
|
import { type AdminDecision, decideAll } from './authz';
|
|
11
11
|
import { type CrudCtx, decideOperation, permissionsForOperation } from './crud';
|
|
12
12
|
import type { AdminFieldType } from './fields';
|
|
13
|
-
import type
|
|
13
|
+
import { type AdminOperation, type AdminPermission, adminPermissionFor } from './permissions';
|
|
14
14
|
import type { AdminAction } from './registry';
|
|
15
15
|
import type { AdminResource } from './resource';
|
|
16
16
|
|
|
@@ -32,6 +32,12 @@ export interface AdminMcpTool {
|
|
|
32
32
|
readonly entity: string | null;
|
|
33
33
|
readonly action: string | null;
|
|
34
34
|
readonly permissions: readonly string[];
|
|
35
|
+
/**
|
|
36
|
+
* The MCP scope a token must carry to call it: the admin-level permission it needs — `admin:read`
|
|
37
|
+
* for a read and a `readonly` action, `admin:write` for a write, `admin:destroy` for a delete or a
|
|
38
|
+
* destructive action. The connection's gate, checked before the actor's own policy.
|
|
39
|
+
*/
|
|
40
|
+
readonly scope: AdminPermission;
|
|
35
41
|
/** Destructive tools require the confirmation token; agents must read before they delete. */
|
|
36
42
|
readonly destructive: boolean;
|
|
37
43
|
readonly input: readonly AdminToolField[];
|
|
@@ -110,6 +116,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
|
|
|
110
116
|
entity: resource.name,
|
|
111
117
|
action: null,
|
|
112
118
|
permissions: permissionsForOperation(resource.permission, 'list'),
|
|
119
|
+
scope: adminPermissionFor('list'),
|
|
113
120
|
destructive: false,
|
|
114
121
|
input: [
|
|
115
122
|
CURSOR_FIELD,
|
|
@@ -126,6 +133,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
|
|
|
126
133
|
entity: resource.name,
|
|
127
134
|
action: null,
|
|
128
135
|
permissions: permissionsForOperation(resource.permission, 'detail'),
|
|
136
|
+
scope: adminPermissionFor('detail'),
|
|
129
137
|
destructive: false,
|
|
130
138
|
input: [ID_FIELD],
|
|
131
139
|
};
|
|
@@ -137,6 +145,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
|
|
|
137
145
|
entity: resource.name,
|
|
138
146
|
action: null,
|
|
139
147
|
permissions: permissionsForOperation(resource.permission, 'create'),
|
|
148
|
+
scope: adminPermissionFor('create'),
|
|
140
149
|
destructive: false,
|
|
141
150
|
input: formFields(resource),
|
|
142
151
|
};
|
|
@@ -148,6 +157,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
|
|
|
148
157
|
entity: resource.name,
|
|
149
158
|
action: null,
|
|
150
159
|
permissions: permissionsForOperation(resource.permission, 'update'),
|
|
160
|
+
scope: adminPermissionFor('update'),
|
|
151
161
|
destructive: false,
|
|
152
162
|
input: [ID_FIELD, ...formFields(resource)],
|
|
153
163
|
};
|
|
@@ -159,6 +169,7 @@ function toolFor(resource: AdminResource, op: AdminOperation): AdminMcpTool | nu
|
|
|
159
169
|
entity: resource.name,
|
|
160
170
|
action: null,
|
|
161
171
|
permissions: permissionsForOperation(resource.permission, 'delete'),
|
|
172
|
+
scope: adminPermissionFor('delete'),
|
|
162
173
|
destructive: true,
|
|
163
174
|
input: [ID_FIELD, { name: 'confirmation', type: 'text', required: true }],
|
|
164
175
|
};
|
|
@@ -202,6 +213,7 @@ function toolGates(app: AdminApp): readonly ToolGate[] {
|
|
|
202
213
|
entity: action.entity ?? null,
|
|
203
214
|
action: action.name,
|
|
204
215
|
permissions: permissionsForAction(action),
|
|
216
|
+
scope: adminPermissionForAction(action),
|
|
205
217
|
destructive: action.destructive === true,
|
|
206
218
|
input: actionEnvelope(action),
|
|
207
219
|
},
|
|
@@ -220,6 +232,7 @@ function toolGates(app: AdminApp): readonly ToolGate[] {
|
|
|
220
232
|
entity: null,
|
|
221
233
|
action: null,
|
|
222
234
|
permissions: permissionsForOperation('admin', 'search'),
|
|
235
|
+
scope: adminPermissionFor('search'),
|
|
223
236
|
destructive: false,
|
|
224
237
|
input: [{ name: 'term', type: 'text', required: true }],
|
|
225
238
|
},
|
|
@@ -237,6 +250,7 @@ function toolGates(app: AdminApp): readonly ToolGate[] {
|
|
|
237
250
|
entity: null,
|
|
238
251
|
action: action.name,
|
|
239
252
|
permissions: permissionsForAction(action),
|
|
253
|
+
scope: adminPermissionForAction(action),
|
|
240
254
|
destructive: action.destructive === true,
|
|
241
255
|
input: actionEnvelope(action),
|
|
242
256
|
},
|
package/src/mcp.ts
CHANGED
|
@@ -3,13 +3,7 @@
|
|
|
3
3
|
// confirmation rules as the buttons — this file adds a transport, not a second back door.
|
|
4
4
|
|
|
5
5
|
import type { Actor } from '@ultimat3/core';
|
|
6
|
-
import {
|
|
7
|
-
agentActor,
|
|
8
|
-
createContext,
|
|
9
|
-
hasContext,
|
|
10
|
-
runWithContext,
|
|
11
|
-
withChildContext,
|
|
12
|
-
} from '@ultimat3/core';
|
|
6
|
+
import { agentActor, ctxOf, hasContext, runWithContext, withChildContext } from '@ultimat3/core';
|
|
13
7
|
import {
|
|
14
8
|
type AnyMcpTool,
|
|
15
9
|
type AppMcp,
|
|
@@ -39,6 +33,7 @@ import {
|
|
|
39
33
|
import { AdminFilterInvalidError } from './errors';
|
|
40
34
|
import type { AdminFieldType } from './fields';
|
|
41
35
|
import { type AskedFilter, checkedFilter } from './list-filters';
|
|
36
|
+
import { adminTokenScopes, adminToolScopes } from './mcp-scopes';
|
|
42
37
|
import { type AdminMcpTool, adminMcpTools, adminToolCatalog } from './mcp-tools';
|
|
43
38
|
import type { AdminAction, AdminRow } from './registry';
|
|
44
39
|
import { adminSearch } from './search';
|
|
@@ -241,10 +236,22 @@ function crudResult(result: CrudResult<AdminRow>): AdminToolResult {
|
|
|
241
236
|
return invalidIssues(result.issues);
|
|
242
237
|
}
|
|
243
238
|
|
|
239
|
+
/**
|
|
240
|
+
* What a session token resolves to: the actor, and optionally the admin scopes the TOKEN was
|
|
241
|
+
* issued for — narrower than the actor, never wider (the actor's policy still decides each tool).
|
|
242
|
+
* `['admin:read']` is a read-only token: list, read, search and `readonly` actions. Absent: the
|
|
243
|
+
* token is as strong as its actor (`adminTokenScopes`). Named `tokenScopes`, not `scopes`, because
|
|
244
|
+
* core's `Actor.scopes` is a different thing (framework capabilities) and an actor handed straight
|
|
245
|
+
* through must not have its `[]` read as "this token may do nothing".
|
|
246
|
+
*/
|
|
247
|
+
export interface AdminMcpActor extends AdminActor {
|
|
248
|
+
readonly tokenScopes?: readonly string[];
|
|
249
|
+
}
|
|
250
|
+
|
|
244
251
|
export interface AdminMcpOptions {
|
|
245
252
|
readonly app: AdminApp;
|
|
246
253
|
/** Resolve the MCP session's actor. The same hook the HTTP surface uses, never a bypass. */
|
|
247
|
-
actor(session: { readonly token?: string }): Promise<
|
|
254
|
+
actor(session: { readonly token?: string }): Promise<AdminMcpActor | null> | AdminMcpActor | null;
|
|
248
255
|
readonly requestId?: () => string;
|
|
249
256
|
}
|
|
250
257
|
|
|
@@ -374,7 +381,7 @@ function allowedToolNames(
|
|
|
374
381
|
const asCaller = <T>(actor: Actor, requestId: string, run: () => Promise<T>): Promise<T> =>
|
|
375
382
|
hasContext()
|
|
376
383
|
? withChildContext({ actor }, run)
|
|
377
|
-
: runWithContext(
|
|
384
|
+
: runWithContext(ctxOf({ actor, requestId }), run);
|
|
378
385
|
|
|
379
386
|
function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMcpTool): AnyMcpTool {
|
|
380
387
|
return {
|
|
@@ -425,10 +432,15 @@ function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMc
|
|
|
425
432
|
*/
|
|
426
433
|
export function adminMcp(opts: AdminMcpOptions): AppMcp {
|
|
427
434
|
const requestId = opts.requestId ?? ((): string => crypto.randomUUID());
|
|
435
|
+
const catalog = adminToolCatalog(opts.app);
|
|
428
436
|
|
|
429
437
|
return defineAppMcp({
|
|
430
438
|
name: 'admin',
|
|
431
|
-
tools:
|
|
439
|
+
tools: catalog.map((tool) => toMcpTool(opts, requestId, tool)),
|
|
440
|
+
// The connection gate: each tool needs its admin-level permission as a token scope, checked
|
|
441
|
+
// by the registry BEFORE the actor's policy. A stdio host builds its caller's scopes with
|
|
442
|
+
// `adminTokenScopes`, as `resolveToken` does below.
|
|
443
|
+
scopes: adminToolScopes(catalog),
|
|
432
444
|
async resolveToken(token: string): Promise<ResolvedToken | null> {
|
|
433
445
|
const actor = await opts.actor({ token });
|
|
434
446
|
// `kind: 'agent'` — the same actor shape an agent gets everywhere else, so a policy
|
|
@@ -442,7 +454,7 @@ export function adminMcp(opts: AdminMcpOptions): AppMcp {
|
|
|
442
454
|
? null
|
|
443
455
|
: {
|
|
444
456
|
actor: agentActor({ id: actor.id, roles: actor.roles ?? [], orgId: actor.orgId }),
|
|
445
|
-
scopes:
|
|
457
|
+
scopes: adminTokenScopes(actor.tokenScopes),
|
|
446
458
|
};
|
|
447
459
|
},
|
|
448
460
|
});
|
package/src/pagination.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// otherwise skip and repeat rows, and every page would re-scan everything before it.
|
|
4
4
|
// The position itself is encoded by `@ultimat3/core` — one signed cursor format, framework-wide.
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import type { Page } from '@ultimat3/core';
|
|
7
|
+
import { decodeCursor, encodeCursor, isUltimateError, pageOf } from '@ultimat3/core';
|
|
7
8
|
import type { AdminFilter, AdminListQuery, AdminRow, AdminSort } from './registry';
|
|
8
9
|
import { rowId } from './registry';
|
|
9
10
|
import { type AdminResource, repoOf } from './resource';
|
|
@@ -66,15 +67,16 @@ export interface PageRequest {
|
|
|
66
67
|
readonly where?: readonly AdminFilter[];
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
/**
|
|
71
|
+
* `@ultimat3/core`'s `Page` — `hasMore` (a page exists AFTER this one, what the Next control is
|
|
72
|
+
* enabled by, in both directions) and `nextCursor` (`null` exactly when it is false) — plus what
|
|
73
|
+
* only a screen pages by: the sort, the size, and the cursor that walks BACK.
|
|
74
|
+
*/
|
|
75
|
+
export type AdminPage<Row extends AdminRow> = Page<Row> & {
|
|
71
76
|
readonly sort: AdminSort;
|
|
72
77
|
readonly pageSize: number;
|
|
73
|
-
readonly nextCursor: string | null;
|
|
74
78
|
readonly prevCursor: string | null;
|
|
75
|
-
|
|
76
|
-
readonly hasMore: boolean;
|
|
77
|
-
}
|
|
79
|
+
};
|
|
78
80
|
|
|
79
81
|
/** The repo query for one page. Asks for `limit + 1` to learn whether a next page exists. */
|
|
80
82
|
export function listQuery<Row extends AdminRow>(
|
|
@@ -147,20 +149,22 @@ export function pageFrom<Row extends AdminRow>(
|
|
|
147
149
|
const hasMore = backwards ? true : overflow;
|
|
148
150
|
const hasPrevious = backwards ? overflow : incoming !== null;
|
|
149
151
|
|
|
150
|
-
|
|
152
|
+
// `pageOf` derives `hasMore` from the cursor, so Next is enabled exactly when it leads somewhere.
|
|
153
|
+
const next = pageOf(
|
|
151
154
|
rows,
|
|
155
|
+
hasMore && last !== undefined
|
|
156
|
+
? encodeAdminCursor(resource, {
|
|
157
|
+
direction: 'after',
|
|
158
|
+
field: sort.field,
|
|
159
|
+
value: cursorValue(last, sort.field),
|
|
160
|
+
id: rowId(last, resource.idField),
|
|
161
|
+
})
|
|
162
|
+
: null,
|
|
163
|
+
);
|
|
164
|
+
return {
|
|
165
|
+
...next,
|
|
152
166
|
sort,
|
|
153
167
|
pageSize,
|
|
154
|
-
hasMore,
|
|
155
|
-
nextCursor:
|
|
156
|
-
hasMore && last !== undefined
|
|
157
|
-
? encodeAdminCursor(resource, {
|
|
158
|
-
direction: 'after',
|
|
159
|
-
field: sort.field,
|
|
160
|
-
value: cursorValue(last, sort.field),
|
|
161
|
-
id: rowId(last, resource.idField),
|
|
162
|
-
})
|
|
163
|
-
: null,
|
|
164
168
|
prevCursor:
|
|
165
169
|
hasPrevious && first !== undefined
|
|
166
170
|
? encodeAdminCursor(resource, {
|
package/src/policy-bridge.ts
CHANGED
|
@@ -17,8 +17,8 @@ import {
|
|
|
17
17
|
type AdminAuthz,
|
|
18
18
|
type AdminDecision,
|
|
19
19
|
type AdminSubject,
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
adminAllowed,
|
|
21
|
+
adminDenied,
|
|
22
22
|
impliersOf,
|
|
23
23
|
} from './authz';
|
|
24
24
|
import { ADMIN_PERMISSIONS } from './permissions';
|
|
@@ -52,7 +52,7 @@ function readDecision(permission: string, result: unknown): AdminDecision {
|
|
|
52
52
|
const verdict = result === true || bag.allowed === true;
|
|
53
53
|
const reason = typeof inner.reason === 'string' ? inner.reason : 'admin.policy.evaluated';
|
|
54
54
|
const trace = Array.isArray(bag.trace) ? bag.trace.map((line) => String(line)) : [];
|
|
55
|
-
return verdict ?
|
|
55
|
+
return verdict ? adminAllowed(permission, reason, trace) : adminDenied(permission, reason, trace);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** `resource:verb` — the only shape `can()` takes. Anything else is denied, never thrown. */
|
|
@@ -104,7 +104,7 @@ export function roleAuthz(): AdminAuthz {
|
|
|
104
104
|
// Asked BEFORE `can()`, which throws on a name the registry lacks: a decision is an answer,
|
|
105
105
|
// and an undeclared permission is a refusal with its fix, never a 500 out of a nav render.
|
|
106
106
|
if (!isPermission(permission) || !isKnownPermission(permission)) {
|
|
107
|
-
return
|
|
107
|
+
return adminDenied(permission, 'admin.policy.missing', [
|
|
108
108
|
`"${permission}" is not a declared resource:verb permission`,
|
|
109
109
|
`fix: definePermissions(['${permission}']), then grant it to a role in defineRoles()`,
|
|
110
110
|
]);
|
|
@@ -118,7 +118,7 @@ export function roleAuthz(): AdminAuthz {
|
|
|
118
118
|
for (const implier of impliersOf(permission)) {
|
|
119
119
|
const carried = evaluated(implier, can(implier as KnownPermission), actor, subject);
|
|
120
120
|
if (carried.allowed) {
|
|
121
|
-
return
|
|
121
|
+
return adminAllowed(permission, carried.reason, [
|
|
122
122
|
...carried.trace,
|
|
123
123
|
`${permission} is implied by ${implier}`,
|
|
124
124
|
]);
|
|
@@ -157,7 +157,7 @@ export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
|
|
|
157
157
|
decide({ permission, actor, subject }): AdminDecision {
|
|
158
158
|
const policy = input.policies[permission];
|
|
159
159
|
if (policy === undefined) {
|
|
160
|
-
return
|
|
160
|
+
return adminDenied(permission, 'admin.policy.missing', [
|
|
161
161
|
`no policy registered for "${permission}"`,
|
|
162
162
|
`fix: definePermissions({ '${permission}': … }) or can('${permission}') on the action`,
|
|
163
163
|
]);
|
package/src/registry.ts
CHANGED
|
@@ -247,6 +247,13 @@ export interface AdminAction<Input = Readonly<Record<string, unknown>>, Output =
|
|
|
247
247
|
/** The entity the button belongs to. Absent = a global action in the toolbar. */
|
|
248
248
|
readonly entity?: string;
|
|
249
249
|
readonly destructive?: boolean;
|
|
250
|
+
/**
|
|
251
|
+
* The action changes nothing — an export, a verification, a recount — so its admin-level gate is
|
|
252
|
+
* `admin:read` rather than `admin:write`, and a read-only staff role (or an `admin:read` MCP
|
|
253
|
+
* token) may run it. `destructive` and `matching` (a set-based write) win over it: the stricter
|
|
254
|
+
* gate is the one that applies.
|
|
255
|
+
*/
|
|
256
|
+
readonly readonly?: boolean;
|
|
250
257
|
/** Absent: `admin.action.<name>` — `actionLabelKey`, the one spelling every screen reads. */
|
|
251
258
|
readonly labelKey?: string;
|
|
252
259
|
/** Mirrors the action's own `mcp` block; the admin MCP surface honours `expose`. */
|
package/src/related.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// related resource's own list — its gate, its row scope, its columns — narrowed to the rows that
|
|
3
3
|
// point at this one. No second table is declared anywhere: the related resource already is one.
|
|
4
4
|
|
|
5
|
-
import { relationsFor
|
|
5
|
+
import { relationsFor } from '@ultimat3/entity';
|
|
6
6
|
import { adminList, type CrudCtx, canOperate } from './crud';
|
|
7
7
|
import { AdminFieldUnsupportedError } from './errors';
|
|
8
8
|
import type { AdminPage } from './pagination';
|
|
@@ -36,7 +36,7 @@ export function relatedOf(
|
|
|
36
36
|
if (resource.related.length === 0) return [];
|
|
37
37
|
// The one read of `@ultimat3/entity`'s relations in this package: the FK already written IS
|
|
38
38
|
// the relation, so the admin derives nothing of its own and names nothing twice.
|
|
39
|
-
const relations =
|
|
39
|
+
const relations = relationsFor(resource.name);
|
|
40
40
|
const offered = Object.values(relations)
|
|
41
41
|
.filter((relation) => relation.kind === 'hasMany')
|
|
42
42
|
.map((relation) => relation.name);
|
package/src/relations.ts
CHANGED
|
@@ -196,7 +196,7 @@ async function relationOf(
|
|
|
196
196
|
* per target — two for a picked target too large for a `<select>` — whatever the page's row count
|
|
197
197
|
* and however many columns point at the same target.
|
|
198
198
|
*/
|
|
199
|
-
export async function
|
|
199
|
+
export async function relationDataFor(
|
|
200
200
|
resources: readonly AdminResource[],
|
|
201
201
|
ctx: CrudCtx,
|
|
202
202
|
needs: ReadonlyMap<string, RelationNeed>,
|
package/src/resource.ts
CHANGED
|
@@ -69,6 +69,13 @@ export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
|
|
|
69
69
|
* At most one `default: true`; `count: true` is one extra query per tab that asks for it.
|
|
70
70
|
*/
|
|
71
71
|
readonly scopes?: Readonly<Record<string, AdminScopeOptions>>;
|
|
72
|
+
/**
|
|
73
|
+
* Show this resource's row count on the `/admin` home. Opt-in, as a scope tab's `count` is: the
|
|
74
|
+
* figure is one full `count()` over the actor's rows on EVERY visit to the front page, so on a
|
|
75
|
+
* big table any operator reloading it is a load generator. Absent: no tile, the resource is still
|
|
76
|
+
* listed in the permission matrix.
|
|
77
|
+
*/
|
|
78
|
+
readonly count?: boolean;
|
|
72
79
|
/**
|
|
73
80
|
* The rows this ACTOR may see at all. Applied to every read of the resource — list, search,
|
|
74
81
|
* detail, lookup, labels, MCP — so it is the one place a second audience is declared.
|
|
@@ -134,6 +141,8 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
|
|
|
134
141
|
readonly columns: readonly AdminComputedColumn[];
|
|
135
142
|
/** Declared scopes, in tab order. Empty for a resource that declares none. */
|
|
136
143
|
readonly scopes: readonly AdminScope[];
|
|
144
|
+
/** The `/admin` home counts this resource — `count: true` declared. */
|
|
145
|
+
readonly count: boolean;
|
|
137
146
|
/** `rows`, validated per call. Absent for a resource every permitted actor sees whole. */
|
|
138
147
|
readonly rowScope?: AdminRowScope;
|
|
139
148
|
/** The detail page's groups. Always at least one: undeclared fields have a default section. */
|
|
@@ -309,6 +318,7 @@ export function adminResource<Row extends AdminRow = AdminRow>(
|
|
|
309
318
|
searchFields: fields.filter((field) => field.searchable),
|
|
310
319
|
columns: computedColumnsOf(declared, opts.columns),
|
|
311
320
|
scopes: scopesOf(declared, opts.scopes, opts.repo),
|
|
321
|
+
count: opts.count === true,
|
|
312
322
|
...(rowScope === undefined ? {} : { rowScope }),
|
|
313
323
|
sections: layoutOf(
|
|
314
324
|
{
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The `/admin` home's "Access" half: per resource the actor may open, the decision behind every
|
|
2
|
+
// operation it offers — the same decision the call obeys. Data-second on the page, so each matrix is
|
|
3
|
+
// a native `<details>`, closed, its summary the resource and how many of its operations are allowed.
|
|
4
|
+
|
|
5
|
+
import { t } from '@ultimat3/i18n';
|
|
6
|
+
import { Badge } from '@ultimat3/ui';
|
|
7
|
+
import type { JSX } from 'solid-js';
|
|
8
|
+
import styles from './admin.module.scss';
|
|
9
|
+
import type { CrudCtx } from './crud';
|
|
10
|
+
import { decideOperation, permissionsForOperation } from './crud';
|
|
11
|
+
import { ADMIN_OPERATIONS, type AdminOperation } from './permissions';
|
|
12
|
+
import type { AdminResource } from './resource';
|
|
13
|
+
import homeStyles from './screen-home.module.scss';
|
|
14
|
+
|
|
15
|
+
/** One row of an operation matrix: the decision the dashboard renders AND the call obeys. */
|
|
16
|
+
export interface OperationDecision {
|
|
17
|
+
readonly operation: AdminOperation;
|
|
18
|
+
readonly allowed: boolean;
|
|
19
|
+
readonly permissions: readonly string[];
|
|
20
|
+
readonly reason: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Every operation the resource OFFERS, with its decision. This IS the view-only proof, rendered
|
|
25
|
+
* rather than asserted. One it does not offer has no row: "denied" would read as a missing grant
|
|
26
|
+
* the operator should ask for, when no grant opens it.
|
|
27
|
+
*/
|
|
28
|
+
export const operationMatrix = (
|
|
29
|
+
resource: AdminResource,
|
|
30
|
+
ctx: CrudCtx,
|
|
31
|
+
): readonly OperationDecision[] =>
|
|
32
|
+
ADMIN_OPERATIONS.filter((operation) => resource.operations.includes(operation)).map(
|
|
33
|
+
(operation) => {
|
|
34
|
+
const decision = decideOperation(resource, operation, ctx);
|
|
35
|
+
return {
|
|
36
|
+
operation,
|
|
37
|
+
allowed: decision.allowed,
|
|
38
|
+
permissions: permissionsForOperation(resource.permission, operation),
|
|
39
|
+
reason: decision.reason,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The permission matrix, rendered. A reader can see that `create`, `update` and `delete` are
|
|
46
|
+
* refused and WHICH permission refused them — the same decision the call obeys, not a note about
|
|
47
|
+
* a button that was left out.
|
|
48
|
+
*/
|
|
49
|
+
export function OperationMatrix(props: {
|
|
50
|
+
readonly rows: readonly OperationDecision[];
|
|
51
|
+
}): JSX.Element {
|
|
52
|
+
return (
|
|
53
|
+
<table class={styles['matrix']}>
|
|
54
|
+
<thead>
|
|
55
|
+
<tr>
|
|
56
|
+
<th>{t('admin.matrix.operation')}</th>
|
|
57
|
+
<th>{t('admin.matrix.permissions')}</th>
|
|
58
|
+
<th>{t('admin.matrix.verdict')}</th>
|
|
59
|
+
</tr>
|
|
60
|
+
</thead>
|
|
61
|
+
<tbody>
|
|
62
|
+
{props.rows.map((row) => (
|
|
63
|
+
<tr class={row.allowed ? styles['allowed'] : styles['denied']}>
|
|
64
|
+
<td>{t(`admin.operation.${row.operation}`)}</td>
|
|
65
|
+
<td class={styles['mono']}>{row.permissions.join(' + ')}</td>
|
|
66
|
+
<td>
|
|
67
|
+
{row.allowed
|
|
68
|
+
? t('admin.matrix.allowed')
|
|
69
|
+
: `${t('admin.matrix.deniedBy')} ${row.reason}`}
|
|
70
|
+
</td>
|
|
71
|
+
</tr>
|
|
72
|
+
))}
|
|
73
|
+
</tbody>
|
|
74
|
+
</table>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface HomeAccessProps {
|
|
79
|
+
readonly basePath: string;
|
|
80
|
+
readonly entries: readonly {
|
|
81
|
+
readonly resource: AdminResource;
|
|
82
|
+
readonly rows: readonly OperationDecision[];
|
|
83
|
+
}[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Every matrix is in the document — closed is a presentation, never a withheld fact — and the
|
|
88
|
+
* summary's badge says at a glance whether the actor holds the whole resource or only part of it.
|
|
89
|
+
*/
|
|
90
|
+
export function HomeAccess(props: HomeAccessProps): JSX.Element {
|
|
91
|
+
return (
|
|
92
|
+
<section class={homeStyles['access']} aria-labelledby="admin-home-access">
|
|
93
|
+
<h2 id="admin-home-access" class={homeStyles['heading']}>
|
|
94
|
+
{t('admin.dashboard.access.title')}
|
|
95
|
+
</h2>
|
|
96
|
+
{props.entries.map(({ resource, rows }) => {
|
|
97
|
+
const allowed = rows.filter((row) => row.allowed).length;
|
|
98
|
+
return (
|
|
99
|
+
<details class={homeStyles['resource']}>
|
|
100
|
+
<summary class={homeStyles['summary']}>
|
|
101
|
+
<span class={homeStyles['name']}>{t(resource.titleKey)}</span>
|
|
102
|
+
<Badge tone={allowed === rows.length ? 'success' : 'neutral'} size="sm">
|
|
103
|
+
{t('admin.dashboard.access.allowed', { allowed, total: rows.length })}
|
|
104
|
+
</Badge>
|
|
105
|
+
</summary>
|
|
106
|
+
<div class={homeStyles['body']}>
|
|
107
|
+
<a href={`${props.basePath}${resource.path}`}>{t('admin.dashboard.access.open')}</a>
|
|
108
|
+
<div class={homeStyles['matrixScroll']}>
|
|
109
|
+
<OperationMatrix rows={rows} />
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</details>
|
|
113
|
+
);
|
|
114
|
+
})}
|
|
115
|
+
</section>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The `/admin` home's figures: how many rows of each resource this actor could open. One
|
|
2
|
+
// `AdminRepo.count` per resource that declared `count: true`, over its row scope and nothing else
|
|
3
|
+
// — `rowWhere`, the predicate every list read starts from — so a tile never counts a hidden row.
|
|
4
|
+
|
|
5
|
+
import type { AdminActor } from './authz';
|
|
6
|
+
import { rowWhere } from './list-scope';
|
|
7
|
+
import type { AdminResource } from './resource';
|
|
8
|
+
|
|
9
|
+
export interface ResourceCount {
|
|
10
|
+
readonly resource: AdminResource;
|
|
11
|
+
readonly count: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The count of every resource handed in that declared `count: true`, in the order handed in. The caller hands in only what the
|
|
16
|
+
* actor may LIST (`canOperate(…, 'list', …)`): a size is a fact about a table, and a resource the
|
|
17
|
+
* actor may not open must not leak one. A repo with no `count()` — a hand-written one may leave it
|
|
18
|
+
* out — has no figure, so it is left out rather than shown as a zero it never measured.
|
|
19
|
+
*
|
|
20
|
+
* In parallel: these are independent reads, and the home page is the front door. A count the
|
|
21
|
+
* store refuses — a tenant-scoped table asked by an actor with no org is `X_TENANCY_UNSCOPED` —
|
|
22
|
+
* costs that resource its figure and nothing else, the way search lists a resource whose repo threw
|
|
23
|
+
* as skipped rather than answering 500 for the rest. The list behind the tile still reports it.
|
|
24
|
+
*/
|
|
25
|
+
export async function resourceCounts(
|
|
26
|
+
resources: readonly AdminResource[],
|
|
27
|
+
actor: AdminActor,
|
|
28
|
+
): Promise<readonly ResourceCount[]> {
|
|
29
|
+
const countable = resources.flatMap((resource) => {
|
|
30
|
+
// Only a resource that asked: a home visit is otherwise one full count per table, every time.
|
|
31
|
+
if (!resource.count) return [];
|
|
32
|
+
const count = resource.repo?.count;
|
|
33
|
+
return count === undefined ? [] : [{ resource, count: count.bind(resource.repo) }];
|
|
34
|
+
});
|
|
35
|
+
const totals = await Promise.allSettled(
|
|
36
|
+
countable.map(({ resource, count }) => count(rowWhere(resource, actor))),
|
|
37
|
+
);
|
|
38
|
+
return countable.flatMap(({ resource }, index) => {
|
|
39
|
+
const total = totals[index];
|
|
40
|
+
return total?.status === 'fulfilled' ? [{ resource, count: total.value }] : [];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The `/admin` home's top band: one figure tile per resource the actor may open, each a link into
|
|
2
|
+
// the list it counts, and the same figures as a ring of shares. Server markup only — the admin
|
|
3
|
+
// ships no script — and a pure function of the counts it is handed.
|
|
4
|
+
|
|
5
|
+
import { currentLocale, t } from '@ultimat3/i18n';
|
|
6
|
+
import { Card, DonutChart, StatTile } from '@ultimat3/ui';
|
|
7
|
+
import type { JSX } from 'solid-js';
|
|
8
|
+
import styles from './screen-home.module.scss';
|
|
9
|
+
import type { ResourceCount } from './screen-home-counts';
|
|
10
|
+
|
|
11
|
+
export interface HomeKpisProps {
|
|
12
|
+
readonly basePath: string;
|
|
13
|
+
readonly counts: readonly ResourceCount[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `data-stat` for a resource's tile: the stable hook a test or a live check reads it through. */
|
|
17
|
+
export const countStat = (entity: string): string => `admin-count-${entity}`;
|
|
18
|
+
|
|
19
|
+
export function HomeKpis(props: HomeKpisProps): JSX.Element {
|
|
20
|
+
// The figure follows the page's locale: `Intl.NumberFormat` needs no time zone.
|
|
21
|
+
const count = new Intl.NumberFormat(currentLocale());
|
|
22
|
+
// Largest first, so the ring reads clockwise from the biggest share and the legend agrees. An
|
|
23
|
+
// empty table is no share of anything: its tile says 0, and a legend of "0 · 0%" rows is noise.
|
|
24
|
+
const ranked = props.counts
|
|
25
|
+
.filter((entry) => entry.count > 0)
|
|
26
|
+
.sort((left, right) => right.count - left.count);
|
|
27
|
+
return (
|
|
28
|
+
<section class={styles['band']} aria-labelledby="admin-home-records">
|
|
29
|
+
<h2 id="admin-home-records" class={styles['heading']}>
|
|
30
|
+
{t('admin.dashboard.records.title')}
|
|
31
|
+
</h2>
|
|
32
|
+
<ul class={styles['kpis']}>
|
|
33
|
+
{props.counts.map(({ resource, count: total }) => (
|
|
34
|
+
<li>
|
|
35
|
+
<a class={styles['kpi']} href={`${props.basePath}${resource.path}`}>
|
|
36
|
+
<StatTile
|
|
37
|
+
label={t(resource.titleKey)}
|
|
38
|
+
value={count.format(total)}
|
|
39
|
+
stat={countStat(resource.name)}
|
|
40
|
+
/>
|
|
41
|
+
</a>
|
|
42
|
+
</li>
|
|
43
|
+
))}
|
|
44
|
+
</ul>
|
|
45
|
+
{/* One non-empty resource is a whole with one part: a ring of 100% says nothing new. */}
|
|
46
|
+
{ranked.length < 2 ? null : (
|
|
47
|
+
<Card class={styles['share']}>
|
|
48
|
+
<DonutChart
|
|
49
|
+
label={t('admin.dashboard.records.label')}
|
|
50
|
+
showCaption
|
|
51
|
+
segments={ranked.map(({ resource, count: total }) => ({
|
|
52
|
+
label: t(resource.titleKey),
|
|
53
|
+
value: total,
|
|
54
|
+
}))}
|
|
55
|
+
centreLabel={t('admin.dashboard.records.total')}
|
|
56
|
+
keyLabel={t('admin.dashboard.records.resource')}
|
|
57
|
+
valueLabel={t('admin.dashboard.records.title')}
|
|
58
|
+
format={(value: number) => count.format(value)}
|
|
59
|
+
/>
|
|
60
|
+
</Card>
|
|
61
|
+
)}
|
|
62
|
+
</section>
|
|
63
|
+
);
|
|
64
|
+
}
|