@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.
@@ -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 { operationLabel } from './detail';
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 entries = await app.audit.entries({
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
- <li>
33
- <code>{entry.at}</code> <span>{entry.actor.id}</span>{' '}
34
- <span>{operationLabel(entry)}</span>{' '}
35
- <span class={styles['mono']}>{entry.entity}</span>{' '}
36
- <span data-outcome={entry.outcome}>
37
- {t(`admin.audit.outcome.${entry.outcome}`)}
38
- </span>
39
- </li>
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
  });