@ultimat3/admin 29.4.0 → 30.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 +7 -6
- package/README.md +36 -2
- package/package.json +29 -18
- package/src/audit-pg.ts +7 -1
- package/src/audit-request.ts +129 -0
- package/src/audit-schema.ts +7 -3
- package/src/audit.ts +61 -9
- package/src/crud-input.ts +9 -7
- package/src/detail.tsx +39 -30
- package/src/dev/data.ts +9 -0
- package/src/dev/facts.ts +25 -0
- package/src/dev/index.ts +2 -0
- package/src/dev/panel-mail.ts +29 -4
- package/src/error-groups-pages.ts +50 -0
- package/src/error-groups-pg.ts +163 -0
- package/src/error-groups-schema.ts +36 -0
- package/src/error-groups-sink.ts +181 -0
- package/src/error-groups-store.ts +173 -0
- package/src/error-groups.ts +24 -0
- package/src/errors-detail.tsx +60 -0
- package/src/errors-list.tsx +74 -0
- package/src/errors.ts +7 -1
- package/src/index.ts +12 -1
- package/src/list-filters.ts +14 -10
- package/src/mcp-tools.ts +2 -1
- package/src/registry.ts +5 -0
- package/src/resource-operations.ts +36 -0
- package/src/resource.ts +3 -2
- package/src/screen-resource.tsx +2 -1
- package/src/screen-system.tsx +136 -14
package/src/screen-system.tsx
CHANGED
|
@@ -3,43 +3,165 @@
|
|
|
3
3
|
// `decideAll` — the same gate a custom page gets — before anything is read.
|
|
4
4
|
|
|
5
5
|
import { t } from '@ultimat3/i18n';
|
|
6
|
-
import { Card } from '@ultimat3/ui';
|
|
6
|
+
import { Button, Card, Checkbox, Field, Input, Link, Select } from '@ultimat3/ui';
|
|
7
|
+
import type { JSX } from 'solid-js';
|
|
7
8
|
import type { AdminApp, AdminRoute } from './admin';
|
|
8
9
|
import styles from './admin.module.scss';
|
|
9
|
-
import {
|
|
10
|
+
import { type AuditEntry, type AuditQuery, auditCursorOf, auditEntryMatches } from './audit';
|
|
11
|
+
import { AUDIT_OUTCOMES, type AuditRequest, auditHref, auditRequestOf } from './audit-request';
|
|
12
|
+
import { canOperate } from './crud';
|
|
13
|
+
import { AuditDiff, maskedFieldsOf, operationLabel } from './detail';
|
|
10
14
|
import { type AdminScreen, guardedScreen } from './screen-frame';
|
|
11
15
|
import { adminSearch } from './search';
|
|
12
16
|
|
|
13
17
|
/** How many audit entries one screen shows — newest first, from whichever log the admin names. */
|
|
14
18
|
const AUDIT_PAGE = 100;
|
|
15
19
|
|
|
20
|
+
/** `datetime-local` holds minutes and says UTC beside the box: the parser reads it back as UTC. */
|
|
21
|
+
const minuteOf = (instant: string | undefined): string => instant?.slice(0, 16) ?? '';
|
|
22
|
+
|
|
23
|
+
/** One native GET form at the screen's own path: submitting it IS the navigation. No script. */
|
|
24
|
+
function AuditFilter(props: {
|
|
25
|
+
readonly action: string;
|
|
26
|
+
readonly asked: AuditRequest;
|
|
27
|
+
}): JSX.Element {
|
|
28
|
+
const { asked } = props;
|
|
29
|
+
const filtered = Object.keys(asked).some((key) => key !== 'before');
|
|
30
|
+
return (
|
|
31
|
+
<search>
|
|
32
|
+
<form class={styles['filters']} method="get" action={props.action}>
|
|
33
|
+
<Field label={t('admin.audit.filter.entity')}>
|
|
34
|
+
{(own) => <Input id={own.id} name="entity" value={asked.entity ?? ''} />}
|
|
35
|
+
</Field>
|
|
36
|
+
<Field label={t('admin.audit.filter.actor')}>
|
|
37
|
+
{(own) => <Input id={own.id} name="actor" value={asked.actorId ?? ''} />}
|
|
38
|
+
</Field>
|
|
39
|
+
<Field label={t('admin.audit.filter.outcome')}>
|
|
40
|
+
{(own) => (
|
|
41
|
+
<Select
|
|
42
|
+
id={own.id}
|
|
43
|
+
name="outcome"
|
|
44
|
+
value={asked.outcome ?? ''}
|
|
45
|
+
options={[
|
|
46
|
+
{ value: '', label: t('admin.filter.any') },
|
|
47
|
+
...AUDIT_OUTCOMES.map((one) => ({
|
|
48
|
+
value: one,
|
|
49
|
+
label: t(`admin.audit.outcome.${one}`),
|
|
50
|
+
})),
|
|
51
|
+
]}
|
|
52
|
+
/>
|
|
53
|
+
)}
|
|
54
|
+
</Field>
|
|
55
|
+
<Field label={t('admin.audit.filter.since')}>
|
|
56
|
+
{(own) => (
|
|
57
|
+
<Input
|
|
58
|
+
id={own.id}
|
|
59
|
+
name="since"
|
|
60
|
+
type="datetime-local"
|
|
61
|
+
suffix="UTC"
|
|
62
|
+
value={minuteOf(asked.since)}
|
|
63
|
+
/>
|
|
64
|
+
)}
|
|
65
|
+
</Field>
|
|
66
|
+
<Field label={t('admin.audit.filter.until')}>
|
|
67
|
+
{(own) => (
|
|
68
|
+
<Input
|
|
69
|
+
id={own.id}
|
|
70
|
+
name="until"
|
|
71
|
+
type="datetime-local"
|
|
72
|
+
suffix="UTC"
|
|
73
|
+
value={minuteOf(asked.until)}
|
|
74
|
+
/>
|
|
75
|
+
)}
|
|
76
|
+
</Field>
|
|
77
|
+
<Checkbox
|
|
78
|
+
name="changes"
|
|
79
|
+
value="1"
|
|
80
|
+
checked={asked.changes === true}
|
|
81
|
+
label={t('admin.audit.filter.changes')}
|
|
82
|
+
/>
|
|
83
|
+
<div class={styles['actions']}>
|
|
84
|
+
<Button type="submit" variant="secondary">
|
|
85
|
+
{t('admin.filter.apply')}
|
|
86
|
+
</Button>
|
|
87
|
+
{filtered ? (
|
|
88
|
+
<Link appearance="button" variant="ghost" tone="neutral" href={props.action}>
|
|
89
|
+
{t('admin.filter.clear')}
|
|
90
|
+
</Link>
|
|
91
|
+
) : null}
|
|
92
|
+
</div>
|
|
93
|
+
</form>
|
|
94
|
+
</search>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
16
98
|
export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
|
|
17
99
|
return guardedScreen(app, route, async (request) => {
|
|
100
|
+
// Refused before anything is read: a parameter this screen does not answer is a 400 naming
|
|
101
|
+
// what it does (`guardedScreen`), never the unfiltered trail.
|
|
102
|
+
const asked = auditRequestOf(new URL(request.url));
|
|
18
103
|
// The actor's tenant's trail: `audit:read` held in org A is not a read of org B's operators,
|
|
19
104
|
// their ids or what they touched. An actor with no org — the platform — reads every tenant's.
|
|
105
|
+
// Never a parameter: the URL cannot name another tenant.
|
|
20
106
|
const orgId = request.ctx.actor.orgId;
|
|
21
|
-
const
|
|
107
|
+
const query: AuditQuery = {
|
|
108
|
+
...asked,
|
|
22
109
|
limit: AUDIT_PAGE,
|
|
23
110
|
...(orgId === undefined ? {} : { orgId }),
|
|
24
|
-
}
|
|
111
|
+
};
|
|
112
|
+
const page = await app.audit.entries(query);
|
|
113
|
+
// An app's own log that ignores a field — `outcome`, a bound, the tenant — answers a short
|
|
114
|
+
// page here, never a wider one. The cursor is the page's as the log answered it, so paging
|
|
115
|
+
// still walks every entry.
|
|
116
|
+
const entries = page.filter((entry) => auditEntryMatches(entry, query));
|
|
117
|
+
const last = page.length < AUDIT_PAGE ? null : auditCursorOf(page);
|
|
118
|
+
const resourceOf = (entry: AuditEntry) =>
|
|
119
|
+
app.resources.find((resource) => resource.name === entry.entity);
|
|
120
|
+
const rowHrefOf = (entry: AuditEntry): string | null => {
|
|
121
|
+
const resource = resourceOf(entry);
|
|
122
|
+
return resource === undefined ||
|
|
123
|
+
entry.entityId === null ||
|
|
124
|
+
!canOperate(resource, 'detail', request.ctx)
|
|
125
|
+
? null
|
|
126
|
+
: `${app.basePath}${resource.path}/${encodeURIComponent(entry.entityId)}`;
|
|
127
|
+
};
|
|
128
|
+
const { before: _page, ...filters } = asked;
|
|
25
129
|
return (
|
|
26
130
|
<Card>
|
|
131
|
+
<AuditFilter action={route.path} asked={asked} />
|
|
27
132
|
{entries.length === 0 ? (
|
|
28
133
|
<p class="x-admin-empty">{t('admin.audit.empty')}</p>
|
|
29
134
|
) : (
|
|
30
135
|
<ol class={styles['list']}>
|
|
31
|
-
{entries.map((entry) =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
{
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
136
|
+
{entries.map((entry) => {
|
|
137
|
+
const href = rowHrefOf(entry);
|
|
138
|
+
const resource = resourceOf(entry);
|
|
139
|
+
return (
|
|
140
|
+
<li>
|
|
141
|
+
<code>{entry.at}</code>{' '}
|
|
142
|
+
<a href={auditHref(route.path, { ...filters, actorId: entry.actor.id })}>
|
|
143
|
+
{entry.actor.id}
|
|
144
|
+
</a>{' '}
|
|
145
|
+
<span>{operationLabel(entry)}</span>{' '}
|
|
146
|
+
<span class={styles['mono']}>{entry.entity}</span>{' '}
|
|
147
|
+
{href === null ? null : <a href={href}>{entry.entityId}</a>}{' '}
|
|
148
|
+
<span data-outcome={entry.outcome}>
|
|
149
|
+
{t(`admin.audit.outcome.${entry.outcome}`)}
|
|
150
|
+
</span>
|
|
151
|
+
<AuditDiff
|
|
152
|
+
diff={entry.diff}
|
|
153
|
+
masked={resource === undefined ? new Set() : maskedFieldsOf(resource)}
|
|
154
|
+
/>
|
|
155
|
+
</li>
|
|
156
|
+
);
|
|
157
|
+
})}
|
|
41
158
|
</ol>
|
|
42
159
|
)}
|
|
160
|
+
{last === null ? null : (
|
|
161
|
+
<Link href={auditHref(route.path, { ...filters, before: last })}>
|
|
162
|
+
{t('admin.audit.older')}
|
|
163
|
+
</Link>
|
|
164
|
+
)}
|
|
43
165
|
</Card>
|
|
44
166
|
);
|
|
45
167
|
});
|