@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.
Files changed (44) hide show
  1. package/CLAUDE.md +14 -11
  2. package/README.md +39 -8
  3. package/package.json +17 -17
  4. package/src/action-gate.ts +18 -1
  5. package/src/admin.module.scss +118 -4
  6. package/src/audit-pg.ts +3 -3
  7. package/src/audit.ts +1 -3
  8. package/src/authz.ts +8 -8
  9. package/src/batch.ts +3 -8
  10. package/src/crud-input.ts +2 -1
  11. package/src/describe.ts +6 -0
  12. package/src/dev/data.ts +2 -2
  13. package/src/dev/dev-t.ts +9 -0
  14. package/src/dev/jobs-state-chart.tsx +38 -0
  15. package/src/dev/panel-jobs.ts +10 -5
  16. package/src/dev/panel-timeline.ts +9 -1
  17. package/src/dev/panel.ts +6 -3
  18. package/src/dev/server.ts +42 -13
  19. package/src/dev/timeline-style.ts +47 -0
  20. package/src/dev/timeline-waterfall.ts +96 -0
  21. package/src/form-decode.ts +13 -4
  22. package/src/index.ts +8 -7
  23. package/src/jobs/operator.ts +2 -2
  24. package/src/jobs/overview-tab.tsx +2 -2
  25. package/src/layout.tsx +34 -8
  26. package/src/list.tsx +1 -1
  27. package/src/mcp-scopes.ts +36 -0
  28. package/src/mcp-tools.ts +16 -2
  29. package/src/mcp.ts +23 -11
  30. package/src/pagination.ts +22 -18
  31. package/src/policy-bridge.ts +6 -6
  32. package/src/registry.ts +7 -0
  33. package/src/related.ts +2 -2
  34. package/src/relations.ts +1 -1
  35. package/src/resource.ts +10 -0
  36. package/src/screen-home-access.tsx +117 -0
  37. package/src/screen-home-counts.ts +42 -0
  38. package/src/screen-home-kpis.tsx +64 -0
  39. package/src/screen-home.module.scss +169 -0
  40. package/src/screen-home.tsx +21 -82
  41. package/src/screen-lookup.tsx +1 -1
  42. package/src/screen-resource.tsx +4 -4
  43. package/src/search.ts +2 -2
  44. 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 { AdminOperation } from './permissions';
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<AdminActor | null> | AdminActor | null;
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(createContext({ actor, requestId }), run);
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: adminToolCatalog(opts.app).map((tool) => toMcpTool(opts, requestId, tool)),
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: new Set(),
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 { decodeCursor, encodeCursor, isUltimateError } from '@ultimat3/core';
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
- export interface AdminPage<Row extends AdminRow> {
70
- readonly rows: readonly Row[];
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
- /** A page exists AFTER this one — what the Next control is enabled by, in both directions. */
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
- return {
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, {
@@ -17,8 +17,8 @@ import {
17
17
  type AdminAuthz,
18
18
  type AdminDecision,
19
19
  type AdminSubject,
20
- allowed,
21
- denied,
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 ? allowed(permission, reason, trace) : denied(permission, reason, trace);
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 denied(permission, 'admin.policy.missing', [
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 allowed(permission, carried.reason, [
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 denied(permission, 'admin.policy.missing', [
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 as entityRelationsFor } from '@ultimat3/entity';
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 = entityRelationsFor(resource.name);
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 relationsFor(
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
+ }