@cosmicdrift/kumiko-bundled-features 0.130.0 → 0.130.1
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/package.json +6 -6
- package/src/audit/__tests__/audit-screens.boot.test.ts +15 -2
- package/src/audit/constants.ts +4 -0
- package/src/audit/feature.ts +10 -1
- package/src/audit/handlers/details.query.ts +47 -0
- package/src/audit/i18n.ts +6 -2
- package/src/audit/web/audit-log-detail-screen.tsx +133 -0
- package/src/audit/web/audit-log-screen.tsx +141 -124
- package/src/audit/web/client-plugin.tsx +3 -1
- package/src/audit/web/index.ts +1 -0
- package/src/jobs/feature.ts +1 -0
- package/src/jobs/i18n.ts +3 -2
- package/src/jobs/web/job-run-detail-screen.tsx +25 -12
- package/src/jobs/web/job-runs-screen.tsx +79 -63
- package/src/text-content/web/__tests__/group-blocks.test.ts +25 -0
- package/src/text-content/web/client-plugin.tsx +73 -48
- package/src/tier-engine/web/tier-admin-screen.tsx +2 -2
- package/src/user-data-rights/feature.ts +1 -6
- package/src/user-data-rights/i18n.ts +4 -0
- package/src/user-data-rights/web/i18n.ts +22 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.130.
|
|
3
|
+
"version": "0.130.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -107,11 +107,11 @@
|
|
|
107
107
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
108
108
|
},
|
|
109
109
|
"dependencies": {
|
|
110
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.130.
|
|
111
|
-
"@cosmicdrift/kumiko-framework": "0.130.
|
|
112
|
-
"@cosmicdrift/kumiko-headless": "0.130.
|
|
113
|
-
"@cosmicdrift/kumiko-renderer": "0.130.
|
|
114
|
-
"@cosmicdrift/kumiko-renderer-web": "0.130.
|
|
110
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.130.1",
|
|
111
|
+
"@cosmicdrift/kumiko-framework": "0.130.1",
|
|
112
|
+
"@cosmicdrift/kumiko-headless": "0.130.1",
|
|
113
|
+
"@cosmicdrift/kumiko-renderer": "0.130.1",
|
|
114
|
+
"@cosmicdrift/kumiko-renderer-web": "0.130.1",
|
|
115
115
|
"@mollie/api-client": "^4.5.0",
|
|
116
116
|
"@node-rs/argon2": "^2.0.2",
|
|
117
117
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
|
|
4
4
|
import { createConfigFeature } from "../../config/feature";
|
|
5
|
-
import { AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
|
|
5
|
+
import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
|
|
6
6
|
import { createAuditFeature } from "../feature";
|
|
7
7
|
|
|
8
8
|
describe("audit log screen + handler access alignment", () => {
|
|
@@ -21,9 +21,22 @@ describe("audit log screen + handler access alignment", () => {
|
|
|
21
21
|
}
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
test("audit
|
|
24
|
+
test("audit-log-detail screen is custom, admin-gated, breadcrumb-linked to list", () => {
|
|
25
|
+
const audit = createAuditFeature();
|
|
26
|
+
const screen = audit.screens[AUDIT_LOG_DETAIL_SCREEN_ID];
|
|
27
|
+
expect(screen?.type).toBe("custom");
|
|
28
|
+
if (screen && "listScreenId" in screen) {
|
|
29
|
+
expect(screen.listScreenId).toBe(AUDIT_LOG_SCREEN_ID);
|
|
30
|
+
}
|
|
31
|
+
if (screen && "access" in screen && screen.access && "roles" in screen.access) {
|
|
32
|
+
expect(screen.access.roles).toEqual(access.admin);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("audit queries use access.admin (screen ⊆ handler)", () => {
|
|
25
37
|
const audit = createAuditFeature();
|
|
26
38
|
expect(rolesOf(audit.queryHandlers["list"]?.access)).toEqual([...access.admin]);
|
|
39
|
+
expect(rolesOf(audit.queryHandlers["details"]?.access)).toEqual([...access.admin]);
|
|
27
40
|
void AuditQueries;
|
|
28
41
|
});
|
|
29
42
|
});
|
package/src/audit/constants.ts
CHANGED
|
@@ -4,7 +4,11 @@ export const AUDIT_FEATURE = "audit" as const;
|
|
|
4
4
|
|
|
5
5
|
export const AuditQueries = {
|
|
6
6
|
list: "audit:query:list",
|
|
7
|
+
details: "audit:query:details",
|
|
7
8
|
} as const;
|
|
8
9
|
|
|
9
10
|
/** Tenant-admin audit log screen. Nav: `audit:screen:audit-log`. */
|
|
10
11
|
export const AUDIT_LOG_SCREEN_ID = "audit-log" as const;
|
|
12
|
+
|
|
13
|
+
/** Single-event detail screen, breadcrumb-linked to the audit-log list. */
|
|
14
|
+
export const AUDIT_LOG_DETAIL_SCREEN_ID = "audit-log-detail" as const;
|
package/src/audit/feature.ts
CHANGED
|
@@ -3,7 +3,8 @@ import {
|
|
|
3
3
|
defineFeature,
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
-
import { AUDIT_LOG_SCREEN_ID } from "./constants";
|
|
6
|
+
import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID } from "./constants";
|
|
7
|
+
import { detailsQuery } from "./handlers/details.query";
|
|
7
8
|
import { listQuery } from "./handlers/list.query";
|
|
8
9
|
import { AUDIT_I18N } from "./i18n";
|
|
9
10
|
|
|
@@ -33,6 +34,7 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
33
34
|
|
|
34
35
|
const queries = {
|
|
35
36
|
list: r.queryHandler(listQuery),
|
|
37
|
+
details: r.queryHandler(detailsQuery),
|
|
36
38
|
};
|
|
37
39
|
|
|
38
40
|
r.screen({
|
|
@@ -41,6 +43,13 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
41
43
|
renderer: { react: { __component: "AuditLogScreen" } },
|
|
42
44
|
access: { roles: access.admin },
|
|
43
45
|
});
|
|
46
|
+
r.screen({
|
|
47
|
+
id: AUDIT_LOG_DETAIL_SCREEN_ID,
|
|
48
|
+
type: "custom",
|
|
49
|
+
renderer: { react: { __component: "AuditLogDetailScreen" } },
|
|
50
|
+
listScreenId: AUDIT_LOG_SCREEN_ID,
|
|
51
|
+
access: { roles: access.admin },
|
|
52
|
+
});
|
|
44
53
|
r.nav({
|
|
45
54
|
id: "audit-log",
|
|
46
55
|
label: "audit:nav.auditLog",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Single audit event by its event-store id — backs the audit-log-detail
|
|
2
|
+
// screen. Tenant-isolated at the WHERE level like list.query, so a caller
|
|
3
|
+
// can only read events in their own tenant.
|
|
4
|
+
|
|
5
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
|
+
import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
export const detailsQuery = defineQueryHandler({
|
|
11
|
+
name: "details",
|
|
12
|
+
schema: z.object({
|
|
13
|
+
id: z.string().regex(/^\d+$/, "id must be a positive integer"),
|
|
14
|
+
}),
|
|
15
|
+
access: { roles: access.admin },
|
|
16
|
+
handler: async (query, ctx) => {
|
|
17
|
+
const rows = await selectMany<{
|
|
18
|
+
id: bigint;
|
|
19
|
+
aggregateId: string;
|
|
20
|
+
aggregateType: string;
|
|
21
|
+
version: number;
|
|
22
|
+
type: string;
|
|
23
|
+
payload: Record<string, unknown>;
|
|
24
|
+
metadata: Record<string, unknown>;
|
|
25
|
+
createdAt: unknown;
|
|
26
|
+
createdBy: string;
|
|
27
|
+
}>(
|
|
28
|
+
ctx.db,
|
|
29
|
+
eventsTable,
|
|
30
|
+
{ tenantId: query.user.tenantId, id: BigInt(query.payload.id) },
|
|
31
|
+
{ limit: 1 },
|
|
32
|
+
);
|
|
33
|
+
const row = rows[0];
|
|
34
|
+
if (row === undefined) return null;
|
|
35
|
+
return {
|
|
36
|
+
id: String(row.id),
|
|
37
|
+
aggregateId: row.aggregateId,
|
|
38
|
+
aggregateType: row.aggregateType,
|
|
39
|
+
version: row.version,
|
|
40
|
+
type: row.type,
|
|
41
|
+
payload: row.payload,
|
|
42
|
+
metadata: row.metadata,
|
|
43
|
+
createdAt: row.createdAt,
|
|
44
|
+
createdBy: row.createdBy,
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
});
|
package/src/audit/i18n.ts
CHANGED
|
@@ -5,6 +5,7 @@ type LocalizedString = { readonly de: string; readonly en: string };
|
|
|
5
5
|
|
|
6
6
|
export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
7
7
|
"screen:audit-log.title": { de: "Audit-Log", en: "Audit log" },
|
|
8
|
+
"screen:audit-log-detail.title": { de: "Ereignis", en: "Event" },
|
|
8
9
|
"audit:nav.auditLog": { de: "Audit", en: "Audit" },
|
|
9
10
|
"audit.log.title": { de: "Audit-Log", en: "Audit log" },
|
|
10
11
|
"audit.log.loading": { de: "Lade Ereignisse…", en: "Loading events…" },
|
|
@@ -22,6 +23,9 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
22
23
|
"audit.log.filter.apply": { de: "Filtern", en: "Filter" },
|
|
23
24
|
"audit.log.filter.reset": { de: "Zurücksetzen", en: "Reset" },
|
|
24
25
|
"audit.log.details": { de: "Details", en: "Details" },
|
|
25
|
-
"audit.log.detail.
|
|
26
|
-
"audit.log.detail.
|
|
26
|
+
"audit.log.detail.loading": { de: "Lade Ereignis…", en: "Loading event…" },
|
|
27
|
+
"audit.log.detail.missing": { de: "Ereignis nicht gefunden.", en: "Event not found." },
|
|
28
|
+
"audit.log.detail.payload": { de: "Ereignis-Payload", en: "Event payload" },
|
|
29
|
+
"audit.log.detail.metadata": { de: "Metadaten", en: "Metadata" },
|
|
30
|
+
"audit.log.detail.field.id": { de: "Ereignis-ID", en: "Event ID" },
|
|
27
31
|
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Single audit event detail. Route entityId = event-store id (bigint string).
|
|
3
|
+
|
|
4
|
+
import { useDispatcher, useNav, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
5
|
+
import { FormScreenShell } from "@cosmicdrift/kumiko-renderer-web";
|
|
6
|
+
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
7
|
+
import { AuditQueries } from "../constants";
|
|
8
|
+
|
|
9
|
+
type AuditDetail = {
|
|
10
|
+
readonly id: string;
|
|
11
|
+
readonly aggregateId: string;
|
|
12
|
+
readonly aggregateType: string;
|
|
13
|
+
readonly type: string;
|
|
14
|
+
readonly createdBy: string;
|
|
15
|
+
readonly createdAt: string;
|
|
16
|
+
readonly payload: Record<string, unknown>;
|
|
17
|
+
readonly metadata: Record<string, unknown>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type State =
|
|
21
|
+
| { readonly kind: "loading" }
|
|
22
|
+
| { readonly kind: "missing" }
|
|
23
|
+
| { readonly kind: "error"; readonly message: string }
|
|
24
|
+
| { readonly kind: "ready"; readonly event: AuditDetail };
|
|
25
|
+
|
|
26
|
+
export function AuditLogDetailScreen(): ReactNode {
|
|
27
|
+
const t = useTranslation();
|
|
28
|
+
const { Banner, Card, Text } = usePrimitives();
|
|
29
|
+
const dispatcher = useDispatcher();
|
|
30
|
+
const nav = useNav();
|
|
31
|
+
const eventId = nav.route?.entityId;
|
|
32
|
+
const [state, setState] = useState<State>({ kind: "loading" });
|
|
33
|
+
|
|
34
|
+
const load = useCallback(async (): Promise<void> => {
|
|
35
|
+
if (eventId === undefined || eventId === "") {
|
|
36
|
+
setState({ kind: "missing" });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
setState({ kind: "loading" });
|
|
40
|
+
const res = await dispatcher.query<AuditDetail | null>(AuditQueries.details, { id: eventId });
|
|
41
|
+
if (!res.isSuccess) {
|
|
42
|
+
setState({ kind: "error", message: res.error.message });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (res.data === null) {
|
|
46
|
+
setState({ kind: "missing" });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
setState({ kind: "ready", event: res.data });
|
|
50
|
+
}, [dispatcher, eventId]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
void load();
|
|
54
|
+
}, [load]);
|
|
55
|
+
|
|
56
|
+
if (state.kind === "loading") {
|
|
57
|
+
return (
|
|
58
|
+
<FormScreenShell testId="audit-log-detail-screen">
|
|
59
|
+
<Text variant="small">{t("audit.log.detail.loading")}</Text>
|
|
60
|
+
</FormScreenShell>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (state.kind === "missing") {
|
|
65
|
+
return (
|
|
66
|
+
<FormScreenShell testId="audit-log-detail-screen">
|
|
67
|
+
<Banner variant="error">{t("audit.log.detail.missing")}</Banner>
|
|
68
|
+
</FormScreenShell>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (state.kind === "error") {
|
|
73
|
+
return (
|
|
74
|
+
<FormScreenShell testId="audit-log-detail-screen">
|
|
75
|
+
<Banner variant="error">{state.message}</Banner>
|
|
76
|
+
</FormScreenShell>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { event } = state;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<FormScreenShell testId="audit-log-detail-screen" className="flex flex-col gap-6">
|
|
84
|
+
<Card slots={{ title: event.type }}>
|
|
85
|
+
<dl className="grid gap-3 text-sm">
|
|
86
|
+
<div>
|
|
87
|
+
<dt className="font-medium">{t("audit.log.col.when")}</dt>
|
|
88
|
+
<dd data-testid="audit-detail-when">{formatWhen(event.createdAt)}</dd>
|
|
89
|
+
</div>
|
|
90
|
+
<div>
|
|
91
|
+
<dt className="font-medium">{t("audit.log.col.aggregate")}</dt>
|
|
92
|
+
<dd data-testid="audit-detail-aggregate">
|
|
93
|
+
{event.aggregateType} / {event.aggregateId}
|
|
94
|
+
</dd>
|
|
95
|
+
</div>
|
|
96
|
+
<div>
|
|
97
|
+
<dt className="font-medium">{t("audit.log.col.actor")}</dt>
|
|
98
|
+
<dd data-testid="audit-detail-actor">{event.createdBy}</dd>
|
|
99
|
+
</div>
|
|
100
|
+
<div>
|
|
101
|
+
<dt className="font-medium">{t("audit.log.detail.field.id")}</dt>
|
|
102
|
+
<dd>
|
|
103
|
+
<Text variant="code">{event.id}</Text>
|
|
104
|
+
</dd>
|
|
105
|
+
</div>
|
|
106
|
+
</dl>
|
|
107
|
+
</Card>
|
|
108
|
+
|
|
109
|
+
<Card slots={{ title: t("audit.log.detail.payload") }}>
|
|
110
|
+
<pre
|
|
111
|
+
className="max-h-96 overflow-auto whitespace-pre-wrap break-all text-xs"
|
|
112
|
+
data-testid="audit-detail-payload"
|
|
113
|
+
>
|
|
114
|
+
{JSON.stringify(event.payload, null, 2)}
|
|
115
|
+
</pre>
|
|
116
|
+
</Card>
|
|
117
|
+
|
|
118
|
+
<Card slots={{ title: t("audit.log.detail.metadata") }}>
|
|
119
|
+
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all text-xs">
|
|
120
|
+
{JSON.stringify(event.metadata, null, 2)}
|
|
121
|
+
</pre>
|
|
122
|
+
</Card>
|
|
123
|
+
</FormScreenShell>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function formatWhen(value: string): string {
|
|
128
|
+
try {
|
|
129
|
+
return new Date(value).toLocaleString();
|
|
130
|
+
} catch {
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// Paginated tenant-scoped audit log (event store).
|
|
2
|
+
// Paginated tenant-scoped audit log (event store). Rows link to the
|
|
3
|
+
// audit-log-detail screen; the screen title lives in the shell breadcrumb.
|
|
3
4
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
5
|
+
import {
|
|
6
|
+
type DataTableSort,
|
|
7
|
+
useDispatcher,
|
|
8
|
+
useNav,
|
|
9
|
+
usePrimitives,
|
|
10
|
+
useTranslation,
|
|
11
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
6
12
|
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
7
|
-
import { AuditQueries } from "../constants";
|
|
13
|
+
import { AUDIT_LOG_DETAIL_SCREEN_ID, AuditQueries } from "../constants";
|
|
8
14
|
|
|
9
15
|
type AuditRow = {
|
|
10
16
|
readonly id: string;
|
|
@@ -38,12 +44,13 @@ const EMPTY_FILTERS: Filters = { eventType: "", aggregateType: "", from: "", to:
|
|
|
38
44
|
|
|
39
45
|
export function AuditLogScreen(): ReactNode {
|
|
40
46
|
const t = useTranslation();
|
|
41
|
-
const { Banner, Button,
|
|
47
|
+
const { Banner, Button, DataTable, Field, Input, Text } = usePrimitives();
|
|
42
48
|
const dispatcher = useDispatcher();
|
|
49
|
+
const nav = useNav();
|
|
43
50
|
const [state, setState] = useState<State>({ kind: "loading" });
|
|
44
51
|
const [before, setBefore] = useState<string | undefined>(undefined);
|
|
45
52
|
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
|
46
|
-
const [
|
|
53
|
+
const [sort, setSort] = useState<DataTableSort | null>(null);
|
|
47
54
|
|
|
48
55
|
const load = useCallback(
|
|
49
56
|
async (cursor?: string): Promise<void> => {
|
|
@@ -71,139 +78,130 @@ export function AuditLogScreen(): ReactNode {
|
|
|
71
78
|
void load(before);
|
|
72
79
|
}, [load, before]);
|
|
73
80
|
|
|
74
|
-
const detailRow = state.kind === "ready" ? state.rows.find((r) => r.id === detailId) : undefined;
|
|
75
|
-
|
|
76
81
|
if (state.kind === "loading") {
|
|
77
82
|
return (
|
|
78
|
-
<
|
|
83
|
+
<div className="p-6" data-testid="audit-log-screen">
|
|
79
84
|
<Text variant="small">{t("audit.log.loading")}</Text>
|
|
80
|
-
</
|
|
85
|
+
</div>
|
|
81
86
|
);
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
if (state.kind === "error") {
|
|
85
90
|
return (
|
|
86
|
-
<
|
|
91
|
+
<div className="p-6" data-testid="audit-log-screen">
|
|
87
92
|
<Banner variant="error">{state.message}</Banner>
|
|
88
|
-
</
|
|
93
|
+
</div>
|
|
89
94
|
);
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
const openDetail = (id: string): void =>
|
|
98
|
+
nav.navigate({ screenId: AUDIT_LOG_DETAIL_SCREEN_ID, entityId: id });
|
|
99
|
+
|
|
92
100
|
return (
|
|
93
|
-
<
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
</
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
</
|
|
101
|
+
<div className="w-full" data-testid="audit-log-screen">
|
|
102
|
+
<div className="flex flex-col gap-4 px-6 pt-6">
|
|
103
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
104
|
+
<Field id="audit-filter-event" label={t("audit.log.filter.eventType")}>
|
|
105
|
+
<Input
|
|
106
|
+
kind="text"
|
|
107
|
+
id="audit-filter-event"
|
|
108
|
+
name="audit-filter-event"
|
|
109
|
+
value={filters.eventType}
|
|
110
|
+
onChange={(v: string) => setFilters((f) => ({ ...f, eventType: v }))}
|
|
111
|
+
/>
|
|
112
|
+
</Field>
|
|
113
|
+
<Field id="audit-filter-aggregate" label={t("audit.log.filter.aggregateType")}>
|
|
114
|
+
<Input
|
|
115
|
+
kind="text"
|
|
116
|
+
id="audit-filter-aggregate"
|
|
117
|
+
name="audit-filter-aggregate"
|
|
118
|
+
value={filters.aggregateType}
|
|
119
|
+
onChange={(v) => setFilters((f) => ({ ...f, aggregateType: v }))}
|
|
120
|
+
/>
|
|
121
|
+
</Field>
|
|
122
|
+
<Field id="audit-filter-from" label={t("audit.log.filter.from")}>
|
|
123
|
+
<Input
|
|
124
|
+
kind="date"
|
|
125
|
+
id="audit-filter-from"
|
|
126
|
+
name="audit-filter-from"
|
|
127
|
+
value={filters.from}
|
|
128
|
+
onChange={(v) => setFilters((f) => ({ ...f, from: v ?? "" }))}
|
|
129
|
+
/>
|
|
130
|
+
</Field>
|
|
131
|
+
<Field id="audit-filter-to" label={t("audit.log.filter.to")}>
|
|
132
|
+
<Input
|
|
133
|
+
kind="date"
|
|
134
|
+
id="audit-filter-to"
|
|
135
|
+
name="audit-filter-to"
|
|
136
|
+
value={filters.to}
|
|
137
|
+
onChange={(v) => setFilters((f) => ({ ...f, to: v ?? "" }))}
|
|
138
|
+
/>
|
|
139
|
+
</Field>
|
|
140
|
+
</div>
|
|
141
|
+
<div className="flex gap-2">
|
|
142
|
+
<Button
|
|
143
|
+
type="button"
|
|
144
|
+
variant="primary"
|
|
145
|
+
onClick={() => {
|
|
146
|
+
setBefore(undefined);
|
|
147
|
+
void load(undefined);
|
|
148
|
+
}}
|
|
149
|
+
testId="audit-log-apply-filters"
|
|
150
|
+
>
|
|
151
|
+
{t("audit.log.filter.apply")}
|
|
152
|
+
</Button>
|
|
153
|
+
<Button
|
|
154
|
+
type="button"
|
|
155
|
+
variant="secondary"
|
|
156
|
+
onClick={() => {
|
|
157
|
+
setFilters(EMPTY_FILTERS);
|
|
158
|
+
setBefore(undefined);
|
|
159
|
+
}}
|
|
160
|
+
testId="audit-log-reset-filters"
|
|
161
|
+
>
|
|
162
|
+
{t("audit.log.filter.reset")}
|
|
163
|
+
</Button>
|
|
164
|
+
</div>
|
|
157
165
|
</div>
|
|
158
166
|
|
|
159
|
-
<
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
{detailRow !== undefined && (
|
|
196
|
-
<Card slots={{ title: t("audit.log.detail.title") }} options={{ padded: true }}>
|
|
197
|
-
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all text-xs">
|
|
198
|
-
{JSON.stringify(detailRow.payload, null, 2)}
|
|
199
|
-
</pre>
|
|
200
|
-
<Button type="button" variant="secondary" onClick={() => setDetailId(null)}>
|
|
201
|
-
{t("audit.log.detail.close")}
|
|
202
|
-
</Button>
|
|
203
|
-
</Card>
|
|
204
|
-
)}
|
|
167
|
+
<DataTable
|
|
168
|
+
testId="audit-log-table"
|
|
169
|
+
columns={[
|
|
170
|
+
{ field: "when", label: t("audit.log.col.when"), type: "string", sortable: true },
|
|
171
|
+
{ field: "type", label: t("audit.log.col.type"), type: "string", sortable: true },
|
|
172
|
+
{
|
|
173
|
+
field: "aggregate",
|
|
174
|
+
label: t("audit.log.col.aggregate"),
|
|
175
|
+
type: "string",
|
|
176
|
+
sortable: false,
|
|
177
|
+
},
|
|
178
|
+
{ field: "actor", label: t("audit.log.col.actor"), type: "string", sortable: false },
|
|
179
|
+
]}
|
|
180
|
+
sort={sort}
|
|
181
|
+
onSortChange={setSort}
|
|
182
|
+
rows={sortAudit(state.rows, sort).map((row) => ({
|
|
183
|
+
id: row.id,
|
|
184
|
+
values: {
|
|
185
|
+
when: formatWhen(row.createdAt),
|
|
186
|
+
type: row.type,
|
|
187
|
+
aggregate: `${row.aggregateType} / ${row.aggregateId}`,
|
|
188
|
+
actor: row.createdBy,
|
|
189
|
+
},
|
|
190
|
+
}))}
|
|
191
|
+
onRowClick={(row) => openDetail(row.id)}
|
|
192
|
+
rowActions={[
|
|
193
|
+
{
|
|
194
|
+
id: "details",
|
|
195
|
+
label: t("audit.log.details"),
|
|
196
|
+
style: "secondary",
|
|
197
|
+
onTrigger: (row) => openDetail(row.id),
|
|
198
|
+
},
|
|
199
|
+
]}
|
|
200
|
+
rowActionMode="inline"
|
|
201
|
+
emptyState={<Text variant="small">{t("audit.log.empty")}</Text>}
|
|
202
|
+
/>
|
|
205
203
|
|
|
206
|
-
<div className="flex gap-2">
|
|
204
|
+
<div className="flex gap-2 px-6 pb-6">
|
|
207
205
|
{before !== undefined && (
|
|
208
206
|
<Button
|
|
209
207
|
type="button"
|
|
@@ -225,10 +223,29 @@ export function AuditLogScreen(): ReactNode {
|
|
|
225
223
|
</Button>
|
|
226
224
|
)}
|
|
227
225
|
</div>
|
|
228
|
-
</
|
|
226
|
+
</div>
|
|
229
227
|
);
|
|
230
228
|
}
|
|
231
229
|
|
|
230
|
+
// Client-sort over the loaded page (≤50 rows). createdAt is an ISO string, so
|
|
231
|
+
// lexicographic compare is chronological. Cross-page order stays cursor-based.
|
|
232
|
+
const SORT_ACCESSORS: Record<string, (r: AuditRow) => string> = {
|
|
233
|
+
when: (r) => r.createdAt,
|
|
234
|
+
type: (r) => r.type,
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
function sortAudit(rows: readonly AuditRow[], sort: DataTableSort | null): readonly AuditRow[] {
|
|
238
|
+
if (sort === null) return rows;
|
|
239
|
+
const accessor = SORT_ACCESSORS[sort.field];
|
|
240
|
+
if (accessor === undefined) return rows;
|
|
241
|
+
const factor = sort.dir === "asc" ? 1 : -1;
|
|
242
|
+
return [...rows].sort((a, b) => {
|
|
243
|
+
const av = accessor(a);
|
|
244
|
+
const bv = accessor(b);
|
|
245
|
+
return av < bv ? -factor : av > bv ? factor : 0;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
232
249
|
function formatWhen(value: string): string {
|
|
233
250
|
try {
|
|
234
251
|
return new Date(value).toLocaleString();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
3
3
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
4
|
-
import { AUDIT_FEATURE, AUDIT_LOG_SCREEN_ID } from "../constants";
|
|
4
|
+
import { AUDIT_FEATURE, AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID } from "../constants";
|
|
5
|
+
import { AuditLogDetailScreen } from "./audit-log-detail-screen";
|
|
5
6
|
import { AuditLogScreen } from "./audit-log-screen";
|
|
6
7
|
import { defaultTranslations } from "./i18n";
|
|
7
8
|
|
|
@@ -15,6 +16,7 @@ export function auditClient(options?: AuditClientOptions): ClientFeatureDefiniti
|
|
|
15
16
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
16
17
|
components: {
|
|
17
18
|
[AUDIT_LOG_SCREEN_ID]: AuditLogScreen,
|
|
19
|
+
[AUDIT_LOG_DETAIL_SCREEN_ID]: AuditLogDetailScreen,
|
|
18
20
|
},
|
|
19
21
|
};
|
|
20
22
|
}
|
package/src/audit/web/index.ts
CHANGED
package/src/jobs/feature.ts
CHANGED
|
@@ -198,6 +198,7 @@ export function createJobsFeature(): FeatureDefinition {
|
|
|
198
198
|
id: JOB_RUN_DETAIL_SCREEN_ID,
|
|
199
199
|
type: "custom",
|
|
200
200
|
renderer: { react: { __component: "JobRunDetailScreen" } },
|
|
201
|
+
listScreenId: JOB_RUNS_SCREEN_ID,
|
|
201
202
|
access: systemAdminAccess,
|
|
202
203
|
});
|
|
203
204
|
r.nav({
|
package/src/jobs/i18n.ts
CHANGED
|
@@ -21,13 +21,14 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
21
21
|
"jobs.runs.col.status": { de: "Status", en: "Status" },
|
|
22
22
|
"jobs.runs.col.started": { de: "Gestartet", en: "Started" },
|
|
23
23
|
"jobs.runs.col.duration": { de: "Dauer (ms)", en: "Duration (ms)" },
|
|
24
|
-
"jobs.detail.title": { de: "Job-Lauf", en: "Job run" },
|
|
25
24
|
"jobs.detail.loading": { de: "Lade Details…", en: "Loading details…" },
|
|
26
25
|
"jobs.detail.missing": { de: "Lauf nicht gefunden.", en: "Run not found." },
|
|
27
|
-
"jobs.detail.back": { de: "← Zurück zur Liste", en: "← Back to list" },
|
|
28
26
|
"jobs.detail.field.job": { de: "Job", en: "Job" },
|
|
29
27
|
"jobs.detail.field.status": { de: "Status", en: "Status" },
|
|
30
28
|
"jobs.detail.field.id": { de: "Run-ID", en: "Run ID" },
|
|
29
|
+
"jobs.detail.field.started": { de: "Gestartet", en: "Started" },
|
|
30
|
+
"jobs.detail.field.finished": { de: "Beendet", en: "Finished" },
|
|
31
|
+
"jobs.detail.field.duration": { de: "Dauer (ms)", en: "Duration (ms)" },
|
|
31
32
|
"jobs.detail.field.error": { de: "Fehler", en: "Error" },
|
|
32
33
|
"jobs.detail.logs": { de: "Logs", en: "Logs" },
|
|
33
34
|
"jobs.detail.logs.empty": { de: "Keine Log-Zeilen.", en: "No log lines." },
|
|
@@ -31,7 +31,7 @@ type State =
|
|
|
31
31
|
|
|
32
32
|
export function JobRunDetailScreen(): ReactNode {
|
|
33
33
|
const t = useTranslation();
|
|
34
|
-
const { Banner, Button, Card,
|
|
34
|
+
const { Banner, Button, Card, Text } = usePrimitives();
|
|
35
35
|
const dispatcher = useDispatcher();
|
|
36
36
|
const nav = useNav();
|
|
37
37
|
const runId = nav.route?.entityId;
|
|
@@ -102,23 +102,28 @@ export function JobRunDetailScreen(): ReactNode {
|
|
|
102
102
|
|
|
103
103
|
return (
|
|
104
104
|
<FormScreenShell testId="job-run-detail-screen" className="flex flex-col gap-6">
|
|
105
|
-
<Button
|
|
106
|
-
type="button"
|
|
107
|
-
variant="secondary"
|
|
108
|
-
onClick={() => nav.navigate({ screenId: JOB_RUNS_SCREEN_ID })}
|
|
109
|
-
testId="job-run-back"
|
|
110
|
-
>
|
|
111
|
-
{t("jobs.detail.back")}
|
|
112
|
-
</Button>
|
|
113
|
-
|
|
114
|
-
<Heading variant="page">{t("jobs.detail.title")}</Heading>
|
|
115
|
-
|
|
116
105
|
<Card slots={{ title: run.jobName }}>
|
|
117
106
|
<dl className="grid gap-3 text-sm">
|
|
118
107
|
<div>
|
|
119
108
|
<dt className="font-medium">{t("jobs.detail.field.status")}</dt>
|
|
120
109
|
<dd data-testid="job-run-status">{run.status}</dd>
|
|
121
110
|
</div>
|
|
111
|
+
<div>
|
|
112
|
+
<dt className="font-medium">{t("jobs.detail.field.started")}</dt>
|
|
113
|
+
<dd data-testid="job-run-started">{formatWhen(run.startedAt)}</dd>
|
|
114
|
+
</div>
|
|
115
|
+
{run.finishedAt !== undefined && run.finishedAt !== null && (
|
|
116
|
+
<div>
|
|
117
|
+
<dt className="font-medium">{t("jobs.detail.field.finished")}</dt>
|
|
118
|
+
<dd data-testid="job-run-finished">{formatWhen(run.finishedAt)}</dd>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
{run.duration !== undefined && run.duration !== null && (
|
|
122
|
+
<div>
|
|
123
|
+
<dt className="font-medium">{t("jobs.detail.field.duration")}</dt>
|
|
124
|
+
<dd data-testid="job-run-duration">{run.duration}</dd>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
122
127
|
<div>
|
|
123
128
|
<dt className="font-medium">{t("jobs.detail.field.id")}</dt>
|
|
124
129
|
<dd>
|
|
@@ -167,3 +172,11 @@ export function JobRunDetailScreen(): ReactNode {
|
|
|
167
172
|
</FormScreenShell>
|
|
168
173
|
);
|
|
169
174
|
}
|
|
175
|
+
|
|
176
|
+
function formatWhen(value: string): string {
|
|
177
|
+
try {
|
|
178
|
+
return new Date(value).toLocaleString();
|
|
179
|
+
} catch {
|
|
180
|
+
return value;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
// SystemAdmin job-run list with link to detail screen.
|
|
3
3
|
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
type DataTableSort,
|
|
6
|
+
useDispatcher,
|
|
7
|
+
useNav,
|
|
8
|
+
usePrimitives,
|
|
9
|
+
useTranslation,
|
|
10
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
6
11
|
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
7
12
|
import { JOB_RUN_DETAIL_SCREEN_ID, JobQueries } from "../constants";
|
|
8
13
|
|
|
@@ -33,11 +38,12 @@ const STATUS_FILTER_OPTIONS = [
|
|
|
33
38
|
|
|
34
39
|
export function JobRunsScreen(): ReactNode {
|
|
35
40
|
const t = useTranslation();
|
|
36
|
-
const { Banner,
|
|
41
|
+
const { Banner, DataTable, Field, Input, Text } = usePrimitives();
|
|
37
42
|
const dispatcher = useDispatcher();
|
|
38
43
|
const nav = useNav();
|
|
39
44
|
const [state, setState] = useState<State>({ kind: "loading" });
|
|
40
45
|
const [statusFilter, setStatusFilter] = useState("");
|
|
46
|
+
const [sort, setSort] = useState<DataTableSort | null>(null);
|
|
41
47
|
|
|
42
48
|
const filterOptions = STATUS_FILTER_OPTIONS.map((opt) => ({
|
|
43
49
|
value: opt.value,
|
|
@@ -65,80 +71,90 @@ export function JobRunsScreen(): ReactNode {
|
|
|
65
71
|
|
|
66
72
|
if (state.kind === "loading") {
|
|
67
73
|
return (
|
|
68
|
-
<
|
|
74
|
+
<div className="p-6" data-testid="job-runs-screen">
|
|
69
75
|
<Text variant="small">{t("jobs.runs.loading")}</Text>
|
|
70
|
-
</
|
|
76
|
+
</div>
|
|
71
77
|
);
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
if (state.kind === "error") {
|
|
75
81
|
return (
|
|
76
|
-
<
|
|
82
|
+
<div className="p-6" data-testid="job-runs-screen">
|
|
77
83
|
<Banner variant="error">{state.message}</Banner>
|
|
78
|
-
</
|
|
84
|
+
</div>
|
|
79
85
|
);
|
|
80
86
|
}
|
|
81
87
|
|
|
88
|
+
const openDetail = (id: string): void =>
|
|
89
|
+
nav.navigate({ screenId: JOB_RUN_DETAIL_SCREEN_ID, entityId: id });
|
|
90
|
+
|
|
82
91
|
return (
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
{
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
{
|
|
127
|
-
id: "open",
|
|
128
|
-
label: t("jobs.runs.open"),
|
|
129
|
-
style: "secondary",
|
|
130
|
-
onTrigger: (row) =>
|
|
131
|
-
nav.navigate({ screenId: JOB_RUN_DETAIL_SCREEN_ID, entityId: row.id }),
|
|
132
|
-
},
|
|
133
|
-
]}
|
|
134
|
-
rowActionMode="inline"
|
|
135
|
-
emptyState={<Text variant="small">{t("jobs.runs.empty")}</Text>}
|
|
136
|
-
/>
|
|
137
|
-
</Card>
|
|
138
|
-
</FormScreenShell>
|
|
92
|
+
<DataTable
|
|
93
|
+
testId="job-runs-table"
|
|
94
|
+
columns={[
|
|
95
|
+
{ field: "job", label: t("jobs.runs.col.job"), type: "string", sortable: true },
|
|
96
|
+
{ field: "status", label: t("jobs.runs.col.status"), type: "string", sortable: true },
|
|
97
|
+
{ field: "started", label: t("jobs.runs.col.started"), type: "string", sortable: true },
|
|
98
|
+
{ field: "duration", label: t("jobs.runs.col.duration"), type: "string", sortable: false },
|
|
99
|
+
]}
|
|
100
|
+
sort={sort}
|
|
101
|
+
onSortChange={setSort}
|
|
102
|
+
rows={sortJobRuns(state.rows, sort).map((row) => ({
|
|
103
|
+
id: row.id,
|
|
104
|
+
values: {
|
|
105
|
+
job: row.jobName,
|
|
106
|
+
status: row.status,
|
|
107
|
+
started: formatWhen(row.startedAt),
|
|
108
|
+
duration: row.duration ?? "—",
|
|
109
|
+
},
|
|
110
|
+
}))}
|
|
111
|
+
onRowClick={(row) => openDetail(row.id)}
|
|
112
|
+
toolbarStart={
|
|
113
|
+
<Field id="job-runs-status-filter" label={t("jobs.runs.filter.status")}>
|
|
114
|
+
<Input
|
|
115
|
+
kind="select"
|
|
116
|
+
id="job-runs-status-filter"
|
|
117
|
+
name="job-runs-status-filter"
|
|
118
|
+
value={statusFilter}
|
|
119
|
+
onChange={setStatusFilter}
|
|
120
|
+
options={filterOptions}
|
|
121
|
+
/>
|
|
122
|
+
</Field>
|
|
123
|
+
}
|
|
124
|
+
rowActions={[
|
|
125
|
+
{
|
|
126
|
+
id: "open",
|
|
127
|
+
label: t("jobs.runs.open"),
|
|
128
|
+
style: "secondary",
|
|
129
|
+
onTrigger: (row) => openDetail(row.id),
|
|
130
|
+
},
|
|
131
|
+
]}
|
|
132
|
+
rowActionMode="inline"
|
|
133
|
+
emptyState={<Text variant="small">{t("jobs.runs.empty")}</Text>}
|
|
134
|
+
/>
|
|
139
135
|
);
|
|
140
136
|
}
|
|
141
137
|
|
|
138
|
+
// Client-sort over the loaded page (≤50 rows). startedAt is an ISO string, so
|
|
139
|
+
// lexicographic compare is chronological — no Date parsing needed.
|
|
140
|
+
const SORT_ACCESSORS: Record<string, (r: JobRunRow) => string | number> = {
|
|
141
|
+
job: (r) => r.jobName,
|
|
142
|
+
status: (r) => r.status,
|
|
143
|
+
started: (r) => r.startedAt,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function sortJobRuns(rows: readonly JobRunRow[], sort: DataTableSort | null): readonly JobRunRow[] {
|
|
147
|
+
if (sort === null) return rows;
|
|
148
|
+
const accessor = SORT_ACCESSORS[sort.field];
|
|
149
|
+
if (accessor === undefined) return rows;
|
|
150
|
+
const factor = sort.dir === "asc" ? 1 : -1;
|
|
151
|
+
return [...rows].sort((a, b) => {
|
|
152
|
+
const av = accessor(a);
|
|
153
|
+
const bv = accessor(b);
|
|
154
|
+
return av < bv ? -factor : av > bv ? factor : 0;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
142
158
|
function formatWhen(value: string): string {
|
|
143
159
|
try {
|
|
144
160
|
return new Date(value).toLocaleString();
|
|
@@ -190,6 +190,31 @@ describe("groupBlocksByFolder", () => {
|
|
|
190
190
|
expect(folder?.icon).toBe("folder");
|
|
191
191
|
expect(folder?.target).toBeUndefined();
|
|
192
192
|
});
|
|
193
|
+
test("tenantIdOverride wird in die Edit-Target-Args gereicht (SystemAdmin SYSTEM-Tenant-Content)", () => {
|
|
194
|
+
const nodes = inside(
|
|
195
|
+
groupBlocksByFolder(
|
|
196
|
+
[block({ slug: "imprint", folder: null }), block({ slug: "hero", folder: "page" })],
|
|
197
|
+
"00000000-0000-4000-8000-000000000000",
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
// Root-Leaf trägt den Override.
|
|
201
|
+
expect(nodes[0]?.target?.args).toEqual({
|
|
202
|
+
slug: "imprint",
|
|
203
|
+
lang: "de",
|
|
204
|
+
tenantIdOverride: "00000000-0000-4000-8000-000000000000",
|
|
205
|
+
});
|
|
206
|
+
// Auch der Leaf im Folder (rekursiv durchgereicht).
|
|
207
|
+
const folderChild = childrenArray(nodes[1]?.children)[0];
|
|
208
|
+
expect(folderChild?.target?.args).toEqual({
|
|
209
|
+
slug: "hero",
|
|
210
|
+
lang: "de",
|
|
211
|
+
tenantIdOverride: "00000000-0000-4000-8000-000000000000",
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
test("ohne tenantIdOverride bleiben die Args frei davon (Default: Session-Tenant)", () => {
|
|
215
|
+
const nodes = inside(groupBlocksByFolder([block({ slug: "imprint", folder: null })]));
|
|
216
|
+
expect(nodes[0]?.target?.args).toEqual({ slug: "imprint", lang: "de" });
|
|
217
|
+
});
|
|
193
218
|
test("Wrapper-Folder 'Content' umschließt alle blocks", () => {
|
|
194
219
|
// V.1.5d Wrapper-Convention: groupBlocksByFolder gibt EINEN Knoten
|
|
195
220
|
// zurück (den Wrapper), Inhalt liegt eine Ebene tiefer.
|
|
@@ -74,13 +74,20 @@ function newFolderNode(): FolderNode {
|
|
|
74
74
|
return { leaves: [], subFolders: new Map() };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
function attachBlock(root: FolderNode, block: BlockSummary): void {
|
|
77
|
+
function attachBlock(root: FolderNode, block: BlockSummary, tenantIdOverride?: string): void {
|
|
78
78
|
const leaf: TreeNode = {
|
|
79
79
|
label: block.title || block.slug,
|
|
80
80
|
target: {
|
|
81
81
|
featureId: "text-content",
|
|
82
82
|
action: "edit",
|
|
83
|
-
|
|
83
|
+
// tenantIdOverride travels with the node → the editor's by-slug read +
|
|
84
|
+
// set write target the same tenant the tree was loaded from (SystemAdmin
|
|
85
|
+
// editing SYSTEM-tenant content). Omitted → session tenant.
|
|
86
|
+
args: {
|
|
87
|
+
slug: block.slug,
|
|
88
|
+
lang: block.lang,
|
|
89
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
90
|
+
},
|
|
84
91
|
},
|
|
85
92
|
state: block.body ? "filled" : "stub",
|
|
86
93
|
};
|
|
@@ -116,10 +123,13 @@ function renderFolderNode(node: FolderNode): TreeNode[] {
|
|
|
116
123
|
return result;
|
|
117
124
|
}
|
|
118
125
|
|
|
119
|
-
export function groupBlocksByFolder(
|
|
126
|
+
export function groupBlocksByFolder(
|
|
127
|
+
blocks: readonly BlockSummary[],
|
|
128
|
+
tenantIdOverride?: string,
|
|
129
|
+
): readonly TreeNode[] {
|
|
120
130
|
const root = newFolderNode();
|
|
121
131
|
for (const block of blocks) {
|
|
122
|
-
attachBlock(root, block);
|
|
132
|
+
attachBlock(root, block, tenantIdOverride);
|
|
123
133
|
}
|
|
124
134
|
const rendered = renderFolderNode(root);
|
|
125
135
|
if (rendered.length === 0) return [];
|
|
@@ -133,47 +143,52 @@ export function groupBlocksByFolder(blocks: readonly BlockSummary[]): readonly T
|
|
|
133
143
|
];
|
|
134
144
|
}
|
|
135
145
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const text = await r.text().catch(() => r.statusText);
|
|
155
|
-
throw new Error(`text-content load failed: ${r.status} ${text}`);
|
|
156
|
-
}
|
|
157
|
-
return r.json();
|
|
158
|
-
})
|
|
159
|
-
.then((data: ByTenantResponse) => {
|
|
160
|
-
// Der App-seitige r.nav-Knoten IST der "Content"-Container → die
|
|
161
|
-
// Provider-Kinder sind die Folder/Leaves darunter, nicht der Wrapper.
|
|
162
|
-
const content = groupBlocksByFolder(data.data.blocks)[0];
|
|
163
|
-
emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
|
|
146
|
+
// tenantIdOverride (SystemAdmin-only) lets an app point the Content tree at a
|
|
147
|
+
// tenant other than the session's — publicstatus seeds marketing/legal blocks
|
|
148
|
+
// on SYSTEM_TENANT_ID, so its SystemAdmin must read that tenant, not their own.
|
|
149
|
+
function makeTreeProvider(tenantIdOverride?: string): TreeChildrenSubscribe {
|
|
150
|
+
return () => (emit, emitError) => {
|
|
151
|
+
// CSRF-Header bei authenticated requests pflicht (auth-middleware
|
|
152
|
+
// double-submit pattern). Anonymous/Pre-Login wäre csrf-token=undefined
|
|
153
|
+
// → header weggelassen → server lässt die anonymous-Variante durch.
|
|
154
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
155
|
+
const csrf = readCsrfToken();
|
|
156
|
+
if (csrf !== undefined) headers[CSRF_HEADER_NAME] = csrf;
|
|
157
|
+
fetch("/api/query", {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers,
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
type: TextContentQueries.byTenant,
|
|
162
|
+
payload: tenantIdOverride !== undefined ? { tenantIdOverride } : {},
|
|
163
|
+
}),
|
|
164
164
|
})
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
165
|
+
.then(async (r) => {
|
|
166
|
+
if (!r.ok) {
|
|
167
|
+
// 403 (CSRF/auth) oder 5xx — explicit error statt silent empty.
|
|
168
|
+
const text = await r.text().catch(() => r.statusText);
|
|
169
|
+
throw new Error(`text-content load failed: ${r.status} ${text}`);
|
|
170
|
+
}
|
|
171
|
+
return r.json();
|
|
172
|
+
})
|
|
173
|
+
.then((data: ByTenantResponse) => {
|
|
174
|
+
// Der App-seitige r.nav-Knoten IST der "Content"-Container → die
|
|
175
|
+
// Provider-Kinder sind die Folder/Leaves darunter, nicht der Wrapper.
|
|
176
|
+
const content = groupBlocksByFolder(data.data.blocks, tenantIdOverride)[0];
|
|
177
|
+
emit(content !== undefined && Array.isArray(content.children) ? content.children : []);
|
|
178
|
+
})
|
|
179
|
+
.catch((e) => {
|
|
180
|
+
// V.1.4: explicit error-Signal via emitError. ProviderBranch zeigt
|
|
181
|
+
// Banner + Retry-Button. Fallback auf emit([]) wenn der Consumer
|
|
182
|
+
// kein emitError unterstützt (Tests etc.).
|
|
183
|
+
if (emitError) {
|
|
184
|
+
emitError(e instanceof Error ? e : new Error(String(e)));
|
|
185
|
+
} else {
|
|
186
|
+
emit([]);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
return () => {};
|
|
190
|
+
};
|
|
191
|
+
}
|
|
177
192
|
|
|
178
193
|
// V.1.3 echte Edit-Form: lädt aktuelle Werte via by-slug-query, lässt
|
|
179
194
|
// TenantAdmin/SystemAdmin title + body editieren, dispatcht set-write
|
|
@@ -213,9 +228,12 @@ function TextContentEditor({
|
|
|
213
228
|
// zu Event-Payloads. Optional-Chain absorbiert fehlende Felder ohne
|
|
214
229
|
// throw, damit der Editor auch bei manuellem URL-Tampering nicht
|
|
215
230
|
// crasht (TargetRef könnte aus old localStorage / URL-State stammen).
|
|
216
|
-
const args = target.args as
|
|
231
|
+
const args = target.args as
|
|
232
|
+
| { slug?: string; lang?: string; tenantIdOverride?: string }
|
|
233
|
+
| undefined;
|
|
217
234
|
const slug = args?.slug ?? "";
|
|
218
235
|
const lang = args?.lang ?? "";
|
|
236
|
+
const tenantIdOverride = args?.tenantIdOverride;
|
|
219
237
|
|
|
220
238
|
const { Form, Field, Input, Button, Banner } = usePrimitives();
|
|
221
239
|
const dispatcher = useDispatcher();
|
|
@@ -232,7 +250,7 @@ function TextContentEditor({
|
|
|
232
250
|
error: loadError,
|
|
233
251
|
} = useQuery<TextBlock | null>(
|
|
234
252
|
TextContentQueries.bySlug,
|
|
235
|
-
{ slug, lang },
|
|
253
|
+
{ slug, lang, ...(tenantIdOverride !== undefined && { tenantIdOverride }) },
|
|
236
254
|
{ enabled: slug !== "" && lang !== "" },
|
|
237
255
|
);
|
|
238
256
|
|
|
@@ -266,6 +284,7 @@ function TextContentEditor({
|
|
|
266
284
|
lang,
|
|
267
285
|
title,
|
|
268
286
|
body: body.length > 0 ? body : null,
|
|
287
|
+
...(tenantIdOverride !== undefined && { tenantIdOverride }),
|
|
269
288
|
});
|
|
270
289
|
if (result.isSuccess) {
|
|
271
290
|
setSavedMsg(result.data.isNew ? "Neu angelegt." : "Gespeichert.");
|
|
@@ -353,12 +372,18 @@ function TextContentEditor({
|
|
|
353
372
|
// Label/Icon/Access des Knotens (managed-pages-Konvention) — das Feature
|
|
354
373
|
// liefert nur die Kinder + den Editor. Ohne navId: nur der Resolver, kein
|
|
355
374
|
// Sidebar-Knoten (server-only-Consumer wie money-horse leaken nichts).
|
|
356
|
-
|
|
375
|
+
// `tenantId` (SystemAdmin-only): welchen Tenant der Content-Tree + Editor
|
|
376
|
+
// bedienen — SYSTEM_TENANT_ID für Apps, die Marketing/Legal dort seeden.
|
|
377
|
+
// Weglassen → Session-Tenant (Default; kein Cross-Tenant-Zugriff).
|
|
378
|
+
export function textContentClient(opts?: {
|
|
379
|
+
readonly navId?: string;
|
|
380
|
+
readonly tenantId?: string;
|
|
381
|
+
}): ClientFeatureDefinition {
|
|
357
382
|
const navId = opts?.navId;
|
|
358
383
|
return {
|
|
359
384
|
name: "text-content",
|
|
360
385
|
...(navId !== undefined && {
|
|
361
|
-
navProviders: { [navId]:
|
|
386
|
+
navProviders: { [navId]: makeTreeProvider(opts?.tenantId) },
|
|
362
387
|
// SSE-Refresh: jedes text-block-Event re-fired den Provider → Tree live.
|
|
363
388
|
navEntities: { [navId]: ["text-block"] },
|
|
364
389
|
}),
|
|
@@ -93,8 +93,6 @@ export function TierAdminScreen(): ReactNode {
|
|
|
93
93
|
|
|
94
94
|
return (
|
|
95
95
|
<FormScreenShell testId="tier-admin-screen" className="flex flex-col gap-6">
|
|
96
|
-
<Text variant="small">{t("tier-admin.explainer")}</Text>
|
|
97
|
-
|
|
98
96
|
{tenantsQuery.error !== null && (
|
|
99
97
|
<Banner variant="error" testId="tier-admin-load-error">
|
|
100
98
|
{t("tier-admin.error.load")}
|
|
@@ -123,6 +121,8 @@ export function TierAdminScreen(): ReactNode {
|
|
|
123
121
|
</Button>
|
|
124
122
|
}
|
|
125
123
|
>
|
|
124
|
+
<Text variant="small">{t("tier-admin.explainer")}</Text>
|
|
125
|
+
|
|
126
126
|
<Field id="tier-admin-tenant" label={t("tier-admin.tenant.label")} required>
|
|
127
127
|
<Input
|
|
128
128
|
kind="select"
|
|
@@ -267,12 +267,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
|
|
|
267
267
|
access: { roles: ["SystemAdmin"] },
|
|
268
268
|
});
|
|
269
269
|
|
|
270
|
-
r.translations({
|
|
271
|
-
keys: {
|
|
272
|
-
...USER_DATA_RIGHTS_I18N,
|
|
273
|
-
"user-data-rights:nav.exportJobs": { de: "DSGVO-Exporte", en: "GDPR exports" },
|
|
274
|
-
},
|
|
275
|
-
});
|
|
270
|
+
r.translations({ keys: USER_DATA_RIGHTS_I18N });
|
|
276
271
|
|
|
277
272
|
// Dormant Self-Service-Screen (Art. 15/17/18/20): Export, Aktivitäts-
|
|
278
273
|
// protokoll, Einschränkung, Löschung in einem Screen. Kein r.nav — die
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Server + client i18n for user-data-rights (operator screens + nav). Pure
|
|
3
|
+
// data, importable from the web bundle (web/i18n.ts derives the client keys).
|
|
1
4
|
type LocalizedString = { readonly de: string; readonly en: string };
|
|
2
5
|
|
|
3
6
|
export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
7
|
+
"user-data-rights:nav.exportJobs": { de: "DSGVO-Exporte", en: "GDPR exports" },
|
|
4
8
|
"screen:export-job-list.title": { de: "DSGVO-Exporte", en: "GDPR exports" },
|
|
5
9
|
"screen:export-job-detail.title": { de: "Export-Job", en: "Export job" },
|
|
6
10
|
"screen:download-attempt-list.title": { de: "Download-Versuche", en: "Download attempts" },
|
|
@@ -5,8 +5,24 @@
|
|
|
5
5
|
// deletion.<step>.<slug>`.
|
|
6
6
|
|
|
7
7
|
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
8
|
+
import { USER_DATA_RIGHTS_I18N } from "../i18n";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
const LOCALES = ["de", "en"] as const;
|
|
11
|
+
|
|
12
|
+
// Operator keys (export-job list/detail titles + entity field labels + nav)
|
|
13
|
+
// live in the server i18n; the SystemAdmin entityList screens render them
|
|
14
|
+
// client-side, so the web bundle must carry them too — otherwise raw keys
|
|
15
|
+
// show. Derive from the single server source instead of duplicating.
|
|
16
|
+
const operatorTranslations: TranslationsByLocale = Object.fromEntries(
|
|
17
|
+
LOCALES.map((locale) => [
|
|
18
|
+
locale,
|
|
19
|
+
Object.fromEntries(
|
|
20
|
+
Object.entries(USER_DATA_RIGHTS_I18N).map(([key, value]) => [key, value[locale]]),
|
|
21
|
+
),
|
|
22
|
+
]),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const apexTranslations: TranslationsByLocale = {
|
|
10
26
|
de: {
|
|
11
27
|
"userDataRights.deletion.request.title": "Account-Löschung beantragen",
|
|
12
28
|
"userDataRights.deletion.request.intro":
|
|
@@ -152,3 +168,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
152
168
|
"The download is currently unavailable due to a server configuration issue. The operator has been notified.",
|
|
153
169
|
},
|
|
154
170
|
};
|
|
171
|
+
|
|
172
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
173
|
+
de: { ...apexTranslations["de"], ...operatorTranslations["de"] },
|
|
174
|
+
en: { ...apexTranslations["en"], ...operatorTranslations["en"] },
|
|
175
|
+
};
|