@cosmicdrift/kumiko-bundled-features 0.130.0 → 0.130.2
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/admin-shell/__tests__/admin-shell.boot.test.ts +17 -1
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +11 -2
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +2 -0
- package/src/admin-shell/feature.ts +2 -1
- package/src/admin-shell/i18n.ts +1 -0
- package/src/admin-shell/overview-allowlist.ts +5 -1
- package/src/admin-shell/web/platform-overview-screen.tsx +18 -1
- 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 +28 -3
- package/src/text-content/web/client-plugin.tsx +124 -104
- package/src/tier-engine/web/tier-admin-screen.tsx +2 -2
- package/src/user/constants.ts +1 -0
- 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.2",
|
|
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.2",
|
|
111
|
+
"@cosmicdrift/kumiko-framework": "0.130.2",
|
|
112
|
+
"@cosmicdrift/kumiko-headless": "0.130.2",
|
|
113
|
+
"@cosmicdrift/kumiko-renderer": "0.130.2",
|
|
114
|
+
"@cosmicdrift/kumiko-renderer-web": "0.130.2",
|
|
115
115
|
"@mollie/api-client": "^4.5.0",
|
|
116
116
|
"@node-rs/argon2": "^2.0.2",
|
|
117
117
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -5,6 +5,7 @@ import { createConfigFeature } from "../../config/feature";
|
|
|
5
5
|
import { createJobsFeature } from "../../jobs/feature";
|
|
6
6
|
import { createTenantFeature } from "../../tenant/feature";
|
|
7
7
|
import { tierEngineFeature } from "../../tier-engine/feature";
|
|
8
|
+
import { createUserFeature } from "../../user/feature";
|
|
8
9
|
import {
|
|
9
10
|
ADMIN_SHELL_FEATURE,
|
|
10
11
|
DEFAULT_PLATFORM_WORKSPACE_ID,
|
|
@@ -14,6 +15,7 @@ import { createAdminShellFeature } from "../feature";
|
|
|
14
15
|
|
|
15
16
|
const features = [
|
|
16
17
|
createConfigFeature(),
|
|
18
|
+
createUserFeature(),
|
|
17
19
|
createTenantFeature(),
|
|
18
20
|
createAuditFeature(),
|
|
19
21
|
createJobsFeature(),
|
|
@@ -22,10 +24,22 @@ const features = [
|
|
|
22
24
|
];
|
|
23
25
|
|
|
24
26
|
describe("admin-shell boot + workspace composition", () => {
|
|
25
|
-
test("validateBoot with tenant, audit, jobs, tier-engine", () => {
|
|
27
|
+
test("validateBoot with user, tenant, audit, jobs, tier-engine", () => {
|
|
26
28
|
expect(() => validateBoot(features)).not.toThrow();
|
|
27
29
|
});
|
|
28
30
|
|
|
31
|
+
test("validateBoot fails without user (platform-overview user-count requires it)", () => {
|
|
32
|
+
const withoutUser = [
|
|
33
|
+
createConfigFeature(),
|
|
34
|
+
createTenantFeature(),
|
|
35
|
+
createAuditFeature(),
|
|
36
|
+
createJobsFeature(),
|
|
37
|
+
tierEngineFeature,
|
|
38
|
+
createAdminShellFeature(),
|
|
39
|
+
];
|
|
40
|
+
expect(() => validateBoot(withoutUser)).toThrow();
|
|
41
|
+
});
|
|
42
|
+
|
|
29
43
|
test("registers tenant + platform workspaces with qualified ids", () => {
|
|
30
44
|
const registry = createRegistry(features);
|
|
31
45
|
expect(
|
|
@@ -82,6 +96,7 @@ describe("admin-shell boot + workspace composition", () => {
|
|
|
82
96
|
});
|
|
83
97
|
const registry = createRegistry([
|
|
84
98
|
createConfigFeature(),
|
|
99
|
+
createUserFeature(),
|
|
85
100
|
createTenantFeature(),
|
|
86
101
|
createAuditFeature(),
|
|
87
102
|
createJobsFeature(),
|
|
@@ -103,6 +118,7 @@ describe("admin-shell boot + workspace composition", () => {
|
|
|
103
118
|
});
|
|
104
119
|
const registry = createRegistry([
|
|
105
120
|
createConfigFeature(),
|
|
121
|
+
createUserFeature(),
|
|
106
122
|
createTenantFeature(),
|
|
107
123
|
createAuditFeature(),
|
|
108
124
|
createJobsFeature(),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { JobQueries } from "../../jobs/constants";
|
|
3
3
|
import { TenantQueries } from "../../tenant/constants";
|
|
4
|
+
import { UserQueries } from "../../user/constants";
|
|
4
5
|
import {
|
|
5
6
|
isOverviewQueryAllowed,
|
|
6
7
|
PLATFORM_OVERVIEW_ALLOWED_QUERIES,
|
|
@@ -22,8 +23,16 @@ describe("overview query allowlist", () => {
|
|
|
22
23
|
expect(TENANT_OVERVIEW_ALLOWED_QUERIES).toContain("config:query:readiness");
|
|
23
24
|
});
|
|
24
25
|
|
|
25
|
-
test("platform allowlist is tenant:list + jobs:list
|
|
26
|
-
expect(PLATFORM_OVERVIEW_ALLOWED_QUERIES).toEqual([
|
|
26
|
+
test("platform allowlist is tenant:list + jobs:list + user:list", () => {
|
|
27
|
+
expect(PLATFORM_OVERVIEW_ALLOWED_QUERIES).toEqual([
|
|
28
|
+
TenantQueries.list,
|
|
29
|
+
JobQueries.list,
|
|
30
|
+
UserQueries.list,
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("platform overview allows the user-count query (fw#891 regression)", () => {
|
|
35
|
+
expect(isOverviewQueryAllowed("platform", UserQueries.list)).toBe(true);
|
|
27
36
|
});
|
|
28
37
|
|
|
29
38
|
test("platform queries are not tenant-allowlisted", () => {
|
|
@@ -5,11 +5,13 @@ import { createConfigFeature } from "../../config/feature";
|
|
|
5
5
|
import { createJobsFeature } from "../../jobs/feature";
|
|
6
6
|
import { createTenantFeature } from "../../tenant/feature";
|
|
7
7
|
import { tierEngineFeature } from "../../tier-engine/feature";
|
|
8
|
+
import { createUserFeature } from "../../user/feature";
|
|
8
9
|
import { PLATFORM_OVERVIEW_SCREEN_ID, TENANT_OVERVIEW_SCREEN_ID } from "../constants";
|
|
9
10
|
import { createAdminShellFeature } from "../feature";
|
|
10
11
|
|
|
11
12
|
const features = [
|
|
12
13
|
createConfigFeature(),
|
|
14
|
+
createUserFeature(),
|
|
13
15
|
createTenantFeature(),
|
|
14
16
|
createAuditFeature(),
|
|
15
17
|
createJobsFeature(),
|
|
@@ -36,13 +36,14 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
|
|
|
36
36
|
|
|
37
37
|
return defineFeature(ADMIN_SHELL_FEATURE, (r) => {
|
|
38
38
|
r.describe(
|
|
39
|
-
"Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`). Mount after tenant, audit, and jobs; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.",
|
|
39
|
+
"Registers tenant-admin and platform-admin workspaces with provider nav into owner-feature screens (`tenant:screen:members`, `audit:screen:audit-log`, `tenant:screen:tenant-list`, `jobs:screen:job-runs`, optional `tier-engine:screen:tier-admin`). Mount after user, tenant, audit, and jobs; pass `workspaceIds` to match app URL conventions (e.g. Studio `d`/`s`, PublicStatus `admin`/`sysadmin`). Client: `adminShellClient()`, `tenantClient()`, `auditClient()`, `jobsClient()`, optional `tierEngineClient()`.",
|
|
40
40
|
);
|
|
41
41
|
r.uiHints({
|
|
42
42
|
displayLabel: "Admin Shell",
|
|
43
43
|
category: "operations",
|
|
44
44
|
recommended: false,
|
|
45
45
|
});
|
|
46
|
+
r.requires("user");
|
|
46
47
|
r.requires("tenant");
|
|
47
48
|
r.requires("audit");
|
|
48
49
|
r.requires("jobs");
|
package/src/admin-shell/i18n.ts
CHANGED
|
@@ -30,6 +30,7 @@ export const ADMIN_SHELL_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
30
30
|
en: "Check required settings",
|
|
31
31
|
},
|
|
32
32
|
"admin-shell:overview.tenants": { de: "Mandanten", en: "Tenants" },
|
|
33
|
+
"admin-shell:overview.users": { de: "Benutzer", en: "Users" },
|
|
33
34
|
"admin-shell:overview.failedJobs": { de: "Fehlgeschlagene Jobs", en: "Failed jobs" },
|
|
34
35
|
"admin-shell:overview.failedJobsHint": { de: "Job-Runs prüfen", en: "Review job runs" },
|
|
35
36
|
};
|
|
@@ -13,7 +13,11 @@ export const TENANT_OVERVIEW_ALLOWED_QUERIES = [
|
|
|
13
13
|
] as const;
|
|
14
14
|
|
|
15
15
|
/** Platform workspace overview may only call these queries. */
|
|
16
|
-
export const PLATFORM_OVERVIEW_ALLOWED_QUERIES = [
|
|
16
|
+
export const PLATFORM_OVERVIEW_ALLOWED_QUERIES = [
|
|
17
|
+
"tenant:query:list",
|
|
18
|
+
"jobs:query:list",
|
|
19
|
+
"user:query:user:list",
|
|
20
|
+
] as const;
|
|
17
21
|
|
|
18
22
|
/** Regression guard — TenantAdmin overview must never touch these (HTTP 403). */
|
|
19
23
|
export const TENANT_OVERVIEW_FORBIDDEN_QUERIES = [
|
|
@@ -4,6 +4,7 @@ import { useDispatcher, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
|
4
4
|
import { type ReactNode, useEffect, useState } from "react";
|
|
5
5
|
import { JobQueries } from "../../jobs/constants";
|
|
6
6
|
import { TenantQueries } from "../../tenant/constants";
|
|
7
|
+
import { UserQueries } from "../../user/constants";
|
|
7
8
|
import { OverviewLayout, type OverviewState } from "./overview-layout";
|
|
8
9
|
import { overviewQuery } from "./overview-query";
|
|
9
10
|
|
|
@@ -27,6 +28,18 @@ export function PlatformOverviewScreen(): ReactNode {
|
|
|
27
28
|
return;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
const usersRes = await overviewQuery<{ readonly rows: readonly unknown[] }>(
|
|
32
|
+
"platform",
|
|
33
|
+
dispatcher,
|
|
34
|
+
UserQueries.list,
|
|
35
|
+
{},
|
|
36
|
+
);
|
|
37
|
+
if (cancelled) return;
|
|
38
|
+
if (!usersRes.isSuccess) {
|
|
39
|
+
setState({ kind: "error", message: usersRes.error.message });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
const failedJobsRes = await overviewQuery<{ readonly rows: readonly unknown[] }>(
|
|
31
44
|
"platform",
|
|
32
45
|
dispatcher,
|
|
@@ -46,6 +59,10 @@ export function PlatformOverviewScreen(): ReactNode {
|
|
|
46
59
|
label: t("admin-shell:overview.tenants"),
|
|
47
60
|
value: String(tenantsRes.data.rows.length),
|
|
48
61
|
},
|
|
62
|
+
{
|
|
63
|
+
label: t("admin-shell:overview.users"),
|
|
64
|
+
value: String(usersRes.data.rows.length),
|
|
65
|
+
},
|
|
49
66
|
{
|
|
50
67
|
label: t("admin-shell:overview.failedJobs"),
|
|
51
68
|
value: String(failedJobsRes.data.rows.length),
|
|
@@ -69,7 +86,7 @@ export function PlatformOverviewScreen(): ReactNode {
|
|
|
69
86
|
title={t("admin-shell:overview.platformTitle")}
|
|
70
87
|
state={state}
|
|
71
88
|
loadingLabel={t("admin-shell:overview.loading")}
|
|
72
|
-
columns={
|
|
89
|
+
columns={3}
|
|
73
90
|
/>
|
|
74
91
|
);
|
|
75
92
|
}
|
|
@@ -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
|
+
}
|