@ultimat3/admin 23.0.0 → 25.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 +32 -30
- package/README.md +51 -16
- package/package.json +17 -17
- package/src/action-gate.ts +33 -42
- package/src/admin.module.scss +118 -4
- package/src/audit-pg.ts +2 -2
- package/src/audit.ts +1 -3
- package/src/authz.ts +8 -8
- package/src/batch-matching.ts +26 -31
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +11 -51
- package/src/crud-input.ts +2 -1
- package/src/crud-outcome.ts +96 -38
- package/src/crud.ts +48 -4
- package/src/describe.ts +6 -0
- package/src/dev/data.ts +5 -8
- package/src/dev/dev-t.ts +9 -0
- package/src/dev/facts.ts +1 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/jobs-state-chart.tsx +38 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-jobs.ts +10 -5
- package/src/dev/panel-timeline.ts +16 -3
- package/src/dev/panel.ts +10 -5
- package/src/dev/server.ts +43 -17
- package/src/dev/sql-runner.ts +31 -0
- package/src/dev/timeline-style.ts +47 -0
- package/src/dev/timeline-waterfall.ts +96 -0
- package/src/errors.ts +9 -5
- package/src/form-decode.ts +55 -5
- package/src/form.tsx +19 -5
- package/src/index.ts +11 -9
- package/src/jobs/fleet-repo.ts +15 -3
- package/src/jobs/job-repo.ts +51 -17
- package/src/jobs/operator.ts +5 -5
- package/src/jobs/overview-tab.tsx +2 -2
- package/src/keyset-seek.ts +189 -0
- package/src/layout.tsx +34 -8
- package/src/list-filters.ts +30 -5
- package/src/list-request.ts +8 -6
- package/src/list.tsx +1 -1
- package/src/mcp-scopes.ts +36 -0
- package/src/mcp-tools.ts +16 -2
- package/src/mcp.ts +27 -11
- package/src/pagination.ts +32 -22
- package/src/policy-bridge.ts +16 -14
- package/src/registry.ts +9 -1
- package/src/related.ts +2 -2
- package/src/relations.ts +26 -10
- package/src/repo-entity.ts +37 -46
- package/src/resource-list.ts +9 -0
- package/src/resource.ts +10 -0
- package/src/row-version.ts +20 -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 +60 -12
- package/src/screen-system.tsx +8 -2
- package/src/search.ts +12 -4
- package/src/widgets.tsx +2 -6
- package/src/inert-jsx.ts +0 -182
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// The `/admin` home's own layer: the KPI band and the access grid under it. Tiles wrap to two
|
|
2
|
+
// across a phone and as many as fit beside the ring on a monitor; the matrix cards pair up only
|
|
3
|
+
// once a card is wide enough that its permission column does not wrap mid-name.
|
|
4
|
+
|
|
5
|
+
@use '@ultimat3/ui/tokens' as t;
|
|
6
|
+
|
|
7
|
+
.band {
|
|
8
|
+
display: grid;
|
|
9
|
+
gap: t.space(4);
|
|
10
|
+
min-inline-size: 0;
|
|
11
|
+
|
|
12
|
+
@include t.respond-to('lg') {
|
|
13
|
+
grid-template-columns: minmax(0, 1fr) minmax(t.rem(320), t.rem(400));
|
|
14
|
+
align-items: start;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.heading {
|
|
19
|
+
@include t.label-caps;
|
|
20
|
+
|
|
21
|
+
grid-column: 1 / -1;
|
|
22
|
+
margin: 0;
|
|
23
|
+
color: t.role('fg-muted');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.kpis {
|
|
27
|
+
display: grid;
|
|
28
|
+
grid-template-columns: repeat(auto-fill, minmax(t.rem(150), 1fr));
|
|
29
|
+
gap: t.space(3);
|
|
30
|
+
padding: 0;
|
|
31
|
+
margin: 0;
|
|
32
|
+
list-style: none;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The whole tile is the link: one target for a pointer and for a keyboard alike, never smaller
|
|
36
|
+
// than the touch target.
|
|
37
|
+
.kpi {
|
|
38
|
+
@include t.focus-ring;
|
|
39
|
+
|
|
40
|
+
display: block;
|
|
41
|
+
block-size: 100%;
|
|
42
|
+
min-block-size: t.touch(target);
|
|
43
|
+
border-radius: t.radius('lg');
|
|
44
|
+
color: inherit;
|
|
45
|
+
text-decoration: none;
|
|
46
|
+
|
|
47
|
+
> * {
|
|
48
|
+
block-size: 100%;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
&:hover > * {
|
|
52
|
+
border-color: t.role('accent', 0.6);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.share {
|
|
57
|
+
min-inline-size: 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.access {
|
|
61
|
+
display: grid;
|
|
62
|
+
gap: t.space(4);
|
|
63
|
+
min-inline-size: 0;
|
|
64
|
+
|
|
65
|
+
@include t.respond-to('xl') {
|
|
66
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
67
|
+
align-items: start;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// One resource's matrix, closed by default: a native disclosure, so it opens with no script and its
|
|
72
|
+
// summary row is one touch target with the name and the allowed-count badge on a line.
|
|
73
|
+
.resource {
|
|
74
|
+
min-inline-size: 0;
|
|
75
|
+
border: t.stroke(hairline) solid t.role('line');
|
|
76
|
+
border-radius: t.radius('lg');
|
|
77
|
+
background: t.role('surface');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.summary {
|
|
81
|
+
@include t.focus-ring;
|
|
82
|
+
|
|
83
|
+
display: flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
justify-content: space-between;
|
|
86
|
+
gap: t.space(3);
|
|
87
|
+
min-block-size: t.touch(target);
|
|
88
|
+
padding: t.space(2) t.space(4);
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
list-style: none;
|
|
91
|
+
|
|
92
|
+
.resource[open] > & {
|
|
93
|
+
border-block-end: t.stroke(hairline) solid t.role('line');
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// `display: flex` drops the native marker, so the disclosure draws its own chevron: pointing along
|
|
98
|
+
// the reading direction while closed, down once open.
|
|
99
|
+
.summary::-webkit-details-marker {
|
|
100
|
+
display: none;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.summary::before {
|
|
104
|
+
flex: none;
|
|
105
|
+
inline-size: t.rem(8);
|
|
106
|
+
block-size: t.rem(8);
|
|
107
|
+
border-inline-end: t.stroke(thick) solid t.role('fg-muted');
|
|
108
|
+
border-block-end: t.stroke(thick) solid t.role('fg-muted');
|
|
109
|
+
content: '';
|
|
110
|
+
transform: rotate(-45deg);
|
|
111
|
+
|
|
112
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
113
|
+
transition: transform t.duration(fast) t.easing(out);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
[dir='rtl'] & {
|
|
117
|
+
transform: rotate(135deg);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.resource[open] > & {
|
|
121
|
+
transform: rotate(45deg);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.name {
|
|
126
|
+
flex: 1;
|
|
127
|
+
min-inline-size: 0;
|
|
128
|
+
font-weight: t.weight('semibold');
|
|
129
|
+
color: t.role('fg-strong');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.body {
|
|
133
|
+
display: grid;
|
|
134
|
+
gap: t.space(3);
|
|
135
|
+
padding: t.space(3) t.space(4) t.space(4);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// A matrix is three columns of short words and one of permission pairs. On a phone the pair takes
|
|
139
|
+
// a line of its own under the operation and its verdict, so no column is cut at the card's edge;
|
|
140
|
+
// the header row goes to assistive tech only, since each cell already reads in place.
|
|
141
|
+
.matrixScroll {
|
|
142
|
+
min-inline-size: 0;
|
|
143
|
+
|
|
144
|
+
@include t.respond-down('sm') {
|
|
145
|
+
thead {
|
|
146
|
+
@include t.visually-hidden;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
tr {
|
|
150
|
+
display: grid;
|
|
151
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
152
|
+
column-gap: t.space(3);
|
|
153
|
+
padding-block: t.space(2);
|
|
154
|
+
border-block-end: t.stroke(hairline) solid t.role('line');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
td {
|
|
158
|
+
padding: 0;
|
|
159
|
+
border: 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
td:nth-child(2) {
|
|
163
|
+
grid-column: 1 / -1;
|
|
164
|
+
grid-row: 2;
|
|
165
|
+
overflow-wrap: anywhere;
|
|
166
|
+
color: t.role('fg-muted');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/screen-home.tsx
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
// `/admin` — the dashboard:
|
|
2
|
-
// operations, and the app-wide actions. No row is
|
|
3
|
-
//
|
|
1
|
+
// `/admin` — the dashboard: how many rows of each resource this actor may open, the decision behind
|
|
2
|
+
// each of its operations, and the app-wide actions. No row is READ here: a figure is one
|
|
3
|
+
// `count()` per resource (`screen-home-counts.ts`), never a page of rows per entity per visit.
|
|
4
4
|
|
|
5
5
|
import { t } from '@ultimat3/i18n';
|
|
6
|
-
import { Card } from '@ultimat3/ui';
|
|
7
|
-
import type { JSX } from 'solid-js';
|
|
8
6
|
import { AdminActionForm } from './action-form';
|
|
9
7
|
import { actionButtons, invokeAdminAction } from './action-gate';
|
|
10
8
|
import { decodeActionInput, postedActionInput } from './action-input';
|
|
@@ -12,12 +10,10 @@ import { ACTION_OPERATION, ACTION_PARAM, AdminActions, OPERATION_FIELD } from '.
|
|
|
12
10
|
import type { AdminApp, AdminRoute } from './admin';
|
|
13
11
|
import styles from './admin.module.scss';
|
|
14
12
|
import { decideAll } from './authz';
|
|
15
|
-
import
|
|
16
|
-
import { canOperate, decideOperation, permissionsForOperation } from './crud';
|
|
13
|
+
import { canOperate } from './crud';
|
|
17
14
|
import { posted } from './form-decode';
|
|
18
|
-
import {
|
|
15
|
+
import { confirmationToken } from './permissions';
|
|
19
16
|
import type { AdminAction } from './registry';
|
|
20
|
-
import type { AdminResource } from './resource';
|
|
21
17
|
import {
|
|
22
18
|
type AdminRouteRequest,
|
|
23
19
|
type AdminRouteResponse,
|
|
@@ -26,70 +22,12 @@ import {
|
|
|
26
22
|
refused,
|
|
27
23
|
refusedPage,
|
|
28
24
|
} from './screen-frame';
|
|
25
|
+
import { HomeAccess, operationMatrix } from './screen-home-access';
|
|
26
|
+
import { resourceCounts } from './screen-home-counts';
|
|
27
|
+
import { HomeKpis } from './screen-home-kpis';
|
|
29
28
|
import type { ValidationIssue } from './validate';
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
export interface OperationDecision {
|
|
33
|
-
readonly operation: AdminOperation;
|
|
34
|
-
readonly allowed: boolean;
|
|
35
|
-
readonly permissions: readonly string[];
|
|
36
|
-
readonly reason: string;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Every operation the resource OFFERS, with its decision. This IS the view-only proof, rendered
|
|
41
|
-
* rather than asserted. One it does not offer has no row: "denied" would read as a missing grant
|
|
42
|
-
* the operator should ask for, when no grant opens it.
|
|
43
|
-
*/
|
|
44
|
-
export const operationMatrix = (
|
|
45
|
-
resource: AdminResource,
|
|
46
|
-
ctx: CrudCtx,
|
|
47
|
-
): readonly OperationDecision[] =>
|
|
48
|
-
ADMIN_OPERATIONS.filter((operation) => resource.operations.includes(operation)).map(
|
|
49
|
-
(operation) => {
|
|
50
|
-
const decision = decideOperation(resource, operation, ctx);
|
|
51
|
-
return {
|
|
52
|
-
operation,
|
|
53
|
-
allowed: decision.allowed,
|
|
54
|
-
permissions: permissionsForOperation(resource.permission, operation),
|
|
55
|
-
reason: decision.reason,
|
|
56
|
-
};
|
|
57
|
-
},
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The permission matrix, rendered. A reader can see that `create`, `update` and `delete` are
|
|
62
|
-
* refused and WHICH permission refused them — the same decision the call obeys, not a note about
|
|
63
|
-
* a button that was left out.
|
|
64
|
-
*/
|
|
65
|
-
export function OperationMatrix(props: {
|
|
66
|
-
readonly rows: readonly OperationDecision[];
|
|
67
|
-
}): JSX.Element {
|
|
68
|
-
return (
|
|
69
|
-
<table class={styles['matrix']}>
|
|
70
|
-
<thead>
|
|
71
|
-
<tr>
|
|
72
|
-
<th>{t('admin.matrix.operation')}</th>
|
|
73
|
-
<th>{t('admin.matrix.permissions')}</th>
|
|
74
|
-
<th>{t('admin.matrix.verdict')}</th>
|
|
75
|
-
</tr>
|
|
76
|
-
</thead>
|
|
77
|
-
<tbody>
|
|
78
|
-
{props.rows.map((row) => (
|
|
79
|
-
<tr class={row.allowed ? styles['allowed'] : styles['denied']}>
|
|
80
|
-
<td>{t(`admin.operation.${row.operation}`)}</td>
|
|
81
|
-
<td class={styles['mono']}>{row.permissions.join(' + ')}</td>
|
|
82
|
-
<td>
|
|
83
|
-
{row.allowed
|
|
84
|
-
? t('admin.matrix.allowed')
|
|
85
|
-
: `${t('admin.matrix.deniedBy')} ${row.reason}`}
|
|
86
|
-
</td>
|
|
87
|
-
</tr>
|
|
88
|
-
))}
|
|
89
|
-
</tbody>
|
|
90
|
-
</table>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
30
|
+
export { type OperationDecision, OperationMatrix, operationMatrix } from './screen-home-access';
|
|
93
31
|
|
|
94
32
|
/** An app-wide action's POST: the same gate, with no row for a subject. */
|
|
95
33
|
async function globalWrite(
|
|
@@ -172,6 +110,8 @@ export function homeScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
|
172
110
|
}
|
|
173
111
|
|
|
174
112
|
const open = app.resources.filter((resource) => canOperate(resource, 'list', request.ctx));
|
|
113
|
+
// Counted only over what the actor may list: the size of a table is itself a fact to gate.
|
|
114
|
+
const counts = await resourceCounts(open, request.ctx.actor);
|
|
175
115
|
return framed(
|
|
176
116
|
app,
|
|
177
117
|
request,
|
|
@@ -184,17 +124,16 @@ export function homeScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
|
184
124
|
href={app.basePath}
|
|
185
125
|
/>
|
|
186
126
|
{open.length === 0 ? <p class={styles['note']}>{t('admin.actions.none')}</p> : null}
|
|
187
|
-
{
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
))}
|
|
127
|
+
{counts.length === 0 ? null : <HomeKpis basePath={app.basePath} counts={counts} />}
|
|
128
|
+
{open.length === 0 ? null : (
|
|
129
|
+
<HomeAccess
|
|
130
|
+
basePath={app.basePath}
|
|
131
|
+
entries={open.map((resource) => ({
|
|
132
|
+
resource,
|
|
133
|
+
rows: operationMatrix(resource, request.ctx),
|
|
134
|
+
}))}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
198
137
|
</div>,
|
|
199
138
|
);
|
|
200
139
|
};
|
package/src/screen-lookup.tsx
CHANGED
|
@@ -147,7 +147,7 @@ export function lookupScreen(app: AdminApp, resource: AdminResource): AdminScree
|
|
|
147
147
|
</ul>
|
|
148
148
|
)}
|
|
149
149
|
<Pagination
|
|
150
|
-
nextCursor={page.hasMore ?
|
|
150
|
+
nextCursor={page.hasMore ? page.nextCursor : undefined}
|
|
151
151
|
prevCursor={page.prevCursor ?? undefined}
|
|
152
152
|
hrefFor={(cursor) => state(cursor)}
|
|
153
153
|
/>
|