@cosmicdrift/kumiko-bundled-features 0.240.0 → 0.241.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/package.json +9 -9
- package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +16 -7
- package/src/admin-shell/__tests__/overview-allowlist.test.ts +30 -0
- package/src/admin-shell/__tests__/overview-screens.boot.test.ts +31 -23
- package/src/admin-shell/changes.json +6 -0
- package/src/admin-shell/feature.ts +60 -6
- package/src/admin-shell/i18n.ts +0 -7
- package/src/admin-shell/web/client-plugin.tsx +1 -11
- package/src/audit/__tests__/audit-screens.boot.test.ts +26 -6
- package/src/audit/changes.json +7 -0
- package/src/audit/feature.ts +57 -7
- package/src/audit/handlers/list.query.ts +50 -14
- package/src/audit/i18n.ts +3 -18
- package/src/audit/web/client-plugin.tsx +1 -7
- package/src/audit/web/index.ts +0 -2
- package/src/config/__tests__/config.integration.test.ts +31 -0
- package/src/config/handlers/readiness.query.ts +8 -1
- package/src/jobs/__tests__/jobs-screens.boot.test.ts +7 -2
- package/src/jobs/__tests__/jobs-security.integration.test.ts +41 -2
- package/src/jobs/changes.json +7 -0
- package/src/jobs/feature.ts +101 -9
- package/src/jobs/handlers/list.query.ts +21 -4
- package/src/jobs/handlers/trigger.write.ts +16 -3
- package/src/jobs/i18n.ts +3 -25
- package/src/jobs/index.ts +1 -0
- package/src/jobs/web/client-plugin.tsx +1 -7
- package/src/jobs/web/index.ts +0 -2
- package/src/tenant/__tests__/tenant.integration.test.ts +32 -0
- package/src/tenant/handlers/list.query.ts +1 -0
- package/src/tier-engine/__tests__/drift.test.ts +1 -1
- package/src/tier-engine/__tests__/tier-engine-screens.boot.test.ts +82 -0
- package/src/tier-engine/changes.json +9 -1
- package/src/tier-engine/constants.ts +5 -5
- package/src/tier-engine/feature.ts +36 -10
- package/src/tier-engine/i18n.ts +19 -22
- package/src/tier-engine/web/client-plugin.tsx +5 -11
- package/src/tier-engine/web/index.ts +0 -1
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +99 -0
- package/src/user-data-rights/changes.json +7 -0
- package/src/user-data-rights/constants.ts +20 -6
- package/src/user-data-rights/feature.ts +113 -14
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +29 -71
- package/src/user-data-rights/web/client-plugin.tsx +19 -21
- package/src/user-data-rights/web/i18n.ts +6 -11
- package/src/user-data-rights/web/index.ts +1 -1
- package/src/user-data-rights/web/privacy-center-screen.tsx +32 -245
- package/src/user-profile/__tests__/profile-screen.boot.test.ts +97 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +140 -104
- package/src/user-profile/changes.json +7 -0
- package/src/user-profile/constants.ts +15 -7
- package/src/user-profile/feature.ts +96 -9
- package/src/user-profile/i18n.ts +8 -12
- package/src/user-profile/web/client-plugin.ts +22 -5
- package/src/user-profile/web/index.ts +1 -1
- package/src/user-profile/web/profile-screen.tsx +122 -265
- package/src/admin-shell/web/overview-layout.tsx +0 -69
- package/src/admin-shell/web/overview-query.ts +0 -15
- package/src/admin-shell/web/platform-overview-screen.tsx +0 -91
- package/src/admin-shell/web/tenant-overview-screen.tsx +0 -91
- package/src/audit/__tests__/audit-log-screen.test.tsx +0 -302
- package/src/audit/web/audit-log-detail-screen.tsx +0 -169
- package/src/audit/web/audit-log-screen.tsx +0 -290
- package/src/jobs/web/__tests__/job-runs-screen.test.tsx +0 -225
- package/src/jobs/web/job-run-detail-screen.tsx +0 -181
- package/src/jobs/web/job-runs-screen.tsx +0 -290
- package/src/tier-engine/web/tier-admin-screen.tsx +0 -168
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.241.0",
|
|
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>",
|
|
@@ -130,12 +130,12 @@
|
|
|
130
130
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
131
131
|
},
|
|
132
132
|
"dependencies": {
|
|
133
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
133
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.241.0",
|
|
134
|
+
"@cosmicdrift/kumiko-framework": "0.241.0",
|
|
135
|
+
"@cosmicdrift/kumiko-headless": "0.241.0",
|
|
136
|
+
"@cosmicdrift/kumiko-renderer": "0.241.0",
|
|
137
|
+
"@cosmicdrift/kumiko-renderer-web": "0.241.0",
|
|
138
|
+
"@cosmicdrift/kumiko-types": "0.241.0",
|
|
139
139
|
"@mollie/api-client": "^4.5.0",
|
|
140
140
|
"@node-rs/argon2": "^2.0.2",
|
|
141
141
|
"@types/mailparser": "^3.4.6",
|
|
@@ -164,7 +164,7 @@
|
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@testing-library/user-event": "^14.6.1",
|
|
166
166
|
"@types/qrcode": "^1.5.5",
|
|
167
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
167
|
+
"@cosmicdrift/kumiko-locale-de": "0.241.0",
|
|
168
|
+
"@cosmicdrift/kumiko-locale-es": "0.241.0"
|
|
169
169
|
}
|
|
170
170
|
}
|
|
@@ -39,12 +39,10 @@ import {
|
|
|
39
39
|
ADMIN_SHELL_FEATURE,
|
|
40
40
|
DEFAULT_PLATFORM_WORKSPACE_ID,
|
|
41
41
|
DEFAULT_TENANT_WORKSPACE_ID,
|
|
42
|
+
TENANT_OVERVIEW_SCREEN_ID,
|
|
42
43
|
} from "../constants";
|
|
43
44
|
import { createAdminShellFeature } from "../feature";
|
|
44
|
-
import {
|
|
45
|
-
TENANT_OVERVIEW_ALLOWED_QUERIES,
|
|
46
|
-
TENANT_OVERVIEW_FORBIDDEN_QUERIES,
|
|
47
|
-
} from "../overview-allowlist";
|
|
45
|
+
import { isOverviewQueryAllowed, TENANT_OVERVIEW_FORBIDDEN_QUERIES } from "../overview-allowlist";
|
|
48
46
|
|
|
49
47
|
let stack: TestStack;
|
|
50
48
|
let TENANT_ID: TenantId;
|
|
@@ -175,9 +173,20 @@ describe("SystemAdmin platform queries", () => {
|
|
|
175
173
|
});
|
|
176
174
|
|
|
177
175
|
describe("tenant overview query allowlist", () => {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
// The tenant-overview screen dispatches only the queries baked into its
|
|
177
|
+
// own dashboard panels (no client-side allowlist gate anymore, fw#2312) —
|
|
178
|
+
// this checks the actual screen definition against the allowlist instead
|
|
179
|
+
// of the allowlist against itself.
|
|
180
|
+
test("tenant-overview screen panels stay inside the allowlist and off the forbidden list", () => {
|
|
181
|
+
const screen = createAdminShellFeature().screens[TENANT_OVERVIEW_SCREEN_ID];
|
|
182
|
+
if (screen?.type !== "dashboard")
|
|
183
|
+
throw new Error("expected tenant-overview to be a dashboard screen");
|
|
184
|
+
for (const panel of screen.panels) {
|
|
185
|
+
if (panel.kind !== "stat") throw new Error(`expected stat panel, got ${panel.kind}`);
|
|
186
|
+
expect(isOverviewQueryAllowed("tenant", panel.query)).toBe(true);
|
|
187
|
+
expect((TENANT_OVERVIEW_FORBIDDEN_QUERIES as readonly string[]).includes(panel.query)).toBe(
|
|
188
|
+
false,
|
|
189
|
+
);
|
|
181
190
|
}
|
|
182
191
|
});
|
|
183
192
|
|
|
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { JobQueries } from "../../jobs/constants";
|
|
3
3
|
import { TenantQueries } from "../../tenant/constants";
|
|
4
4
|
import { UserQueries } from "../../user/constants";
|
|
5
|
+
import { PLATFORM_OVERVIEW_SCREEN_ID, TENANT_OVERVIEW_SCREEN_ID } from "../constants";
|
|
6
|
+
import { createAdminShellFeature } from "../feature";
|
|
5
7
|
import {
|
|
6
8
|
isOverviewQueryAllowed,
|
|
7
9
|
PLATFORM_OVERVIEW_ALLOWED_QUERIES,
|
|
@@ -9,6 +11,19 @@ import {
|
|
|
9
11
|
TENANT_OVERVIEW_FORBIDDEN_QUERIES,
|
|
10
12
|
} from "../overview-allowlist";
|
|
11
13
|
|
|
14
|
+
// Every screen dispatches only the queries baked into its own panel
|
|
15
|
+
// definitions (no client-side allowlist gate anymore, fw#2312) — the
|
|
16
|
+
// allowlist's regression value now lives in this static cross-check instead
|
|
17
|
+
// of a runtime guard.
|
|
18
|
+
function statPanelQueries(screenId: string): readonly string[] {
|
|
19
|
+
const screen = createAdminShellFeature().screens[screenId];
|
|
20
|
+
if (screen?.type !== "dashboard") throw new Error(`expected dashboard screen: ${screenId}`);
|
|
21
|
+
return screen.panels.map((panel) => {
|
|
22
|
+
if (panel.kind !== "stat") throw new Error(`expected stat panel on ${screenId}: ${panel.kind}`);
|
|
23
|
+
return panel.query;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
12
27
|
describe("overview query allowlist", () => {
|
|
13
28
|
test("tenant allowlist excludes platform-only queries", () => {
|
|
14
29
|
for (const forbidden of TENANT_OVERVIEW_FORBIDDEN_QUERIES) {
|
|
@@ -41,3 +56,18 @@ describe("overview query allowlist", () => {
|
|
|
41
56
|
expect(isOverviewQueryAllowed("tenant", UserQueries.list)).toBe(false);
|
|
42
57
|
});
|
|
43
58
|
});
|
|
59
|
+
|
|
60
|
+
describe("overview screen panels vs allowlist (fw#2312 regression)", () => {
|
|
61
|
+
test("tenant-overview panels use only allowlisted, never forbidden, queries", () => {
|
|
62
|
+
for (const query of statPanelQueries(TENANT_OVERVIEW_SCREEN_ID)) {
|
|
63
|
+
expect(isOverviewQueryAllowed("tenant", query)).toBe(true);
|
|
64
|
+
expect((TENANT_OVERVIEW_FORBIDDEN_QUERIES as readonly string[]).includes(query)).toBe(false);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("platform-overview panels use only allowlisted queries", () => {
|
|
69
|
+
for (const query of statPanelQueries(PLATFORM_OVERVIEW_SCREEN_ID)) {
|
|
70
|
+
expect(isOverviewQueryAllowed("platform", query)).toBe(true);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -1,35 +1,43 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { access
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { tierEngineFeature } from "../../tier-engine/feature";
|
|
8
|
-
import { createUserFeature } from "../../user/feature";
|
|
2
|
+
import { access } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { ConfigQueries } from "../../config/constants";
|
|
4
|
+
import { JobQueries } from "../../jobs/constants";
|
|
5
|
+
import { TenantQueries } from "../../tenant/constants";
|
|
6
|
+
import { UserQueries } from "../../user/constants";
|
|
9
7
|
import { PLATFORM_OVERVIEW_SCREEN_ID, TENANT_OVERVIEW_SCREEN_ID } from "../constants";
|
|
10
8
|
import { createAdminShellFeature } from "../feature";
|
|
11
9
|
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
const adminShell = createAdminShellFeature();
|
|
11
|
+
|
|
12
|
+
function statPanelQueries(screenId: string): readonly string[] {
|
|
13
|
+
const screen = adminShell.screens[screenId];
|
|
14
|
+
if (screen?.type !== "dashboard") throw new Error(`expected dashboard screen: ${screenId}`);
|
|
15
|
+
return screen.panels.map((panel) => {
|
|
16
|
+
if (panel.kind !== "stat") throw new Error(`expected stat panel on ${screenId}: ${panel.kind}`);
|
|
17
|
+
return panel.query;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
21
20
|
|
|
22
21
|
describe("overview screens boot", () => {
|
|
23
|
-
test("tenant-overview screen is access.admin", () => {
|
|
24
|
-
const
|
|
25
|
-
const screen = registry.getScreen(`admin-shell:screen:${TENANT_OVERVIEW_SCREEN_ID}`);
|
|
22
|
+
test("tenant-overview screen is access.admin, dashboard with 3 stat panels", () => {
|
|
23
|
+
const screen = adminShell.screens[TENANT_OVERVIEW_SCREEN_ID];
|
|
26
24
|
expect(screen?.access).toEqual({ roles: access.admin });
|
|
27
|
-
expect(screen?.type).toBe("
|
|
25
|
+
expect(screen?.type).toBe("dashboard");
|
|
26
|
+
expect(statPanelQueries(TENANT_OVERVIEW_SCREEN_ID)).toEqual([
|
|
27
|
+
TenantQueries.invitations,
|
|
28
|
+
TenantQueries.members,
|
|
29
|
+
ConfigQueries.readiness,
|
|
30
|
+
]);
|
|
28
31
|
});
|
|
29
32
|
|
|
30
|
-
test("platform-overview screen is SystemAdmin-only", () => {
|
|
31
|
-
const
|
|
32
|
-
const screen = registry.getScreen(`admin-shell:screen:${PLATFORM_OVERVIEW_SCREEN_ID}`);
|
|
33
|
+
test("platform-overview screen is SystemAdmin-only, dashboard with 3 stat panels", () => {
|
|
34
|
+
const screen = adminShell.screens[PLATFORM_OVERVIEW_SCREEN_ID];
|
|
33
35
|
expect(screen?.access).toEqual({ roles: access.systemAdmin });
|
|
36
|
+
expect(screen?.type).toBe("dashboard");
|
|
37
|
+
expect(statPanelQueries(PLATFORM_OVERVIEW_SCREEN_ID)).toEqual([
|
|
38
|
+
TenantQueries.list,
|
|
39
|
+
UserQueries.list,
|
|
40
|
+
JobQueries.list,
|
|
41
|
+
]);
|
|
34
42
|
});
|
|
35
43
|
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.241.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "tenant-overview/platform-overview dashboards no longer show \"undefined\" tiles; both converted to declarative dashboard screens (fw#2312).",
|
|
6
|
+
"detail": "Both overview screens sent `totalCount: true` to `tenant:query:list`/`jobs:query:list`, but neither handler's Zod schema declared the field, so it was stripped before the handler saw it and the tile rendered the literal string \"undefined\". Both handlers now accept `totalCount: boolean` and return `total` when set — `jobs:query:list`'s total is a real count (via `countWhere`), not `rows.length`, so it isn't capped by `limit`. `config:query:readiness` gains `missingCount`/`missingTone` alongside the existing `missing` array. `PlatformOverviewScreen`/`TenantOverviewScreen` (never public exports) and their `overview-layout`/`overview-query` support modules are gone — both screens are now `type: \"dashboard\"` (`kind: \"stat\"` panels) rendered by the generic renderer, using the new `DashboardStatPanel.params` (see framework changelog) to pin the failed-jobs tile's `status: \"failed\"` filter server-side. The `overview-allowlist` exports (`isOverviewQueryAllowed`, `overviewAllowedQueries`, the allow/forbidden-list constants) are unchanged and still importable — they're now checked against the screen definitions in tests instead of gating a deleted client-side dispatch call."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.125.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
defineFeature,
|
|
8
8
|
type FeatureDefinition,
|
|
9
9
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { ConfigQueries } from "../config";
|
|
11
|
+
import { JobQueries } from "../jobs";
|
|
12
|
+
import { TenantQueries } from "../tenant";
|
|
13
|
+
import { UserQueries } from "../user";
|
|
10
14
|
import {
|
|
11
15
|
ADMIN_SHELL_FEATURE,
|
|
12
16
|
DEFAULT_PLATFORM_WORKSPACE_ID,
|
|
@@ -71,14 +75,40 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
|
|
|
71
75
|
...(includeTierAdmin ? (["admin-shell:nav:tier-admin"] as const) : []),
|
|
72
76
|
];
|
|
73
77
|
|
|
74
|
-
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
75
78
|
r.screen({
|
|
76
79
|
id: TENANT_OVERVIEW_SCREEN_ID,
|
|
77
|
-
type: "
|
|
78
|
-
renderer: { react: { __component: "TenantOverviewScreen" } },
|
|
80
|
+
type: "dashboard",
|
|
79
81
|
access: { roles: access.admin },
|
|
80
82
|
description:
|
|
81
83
|
"Landing page of the tenant-admin workspace, showing pending invitation, member and missing-config counts for the caller's own tenant so an operator sees what needs attention there.",
|
|
84
|
+
panels: [
|
|
85
|
+
{
|
|
86
|
+
kind: "stat",
|
|
87
|
+
id: "pending-invitations",
|
|
88
|
+
label: "admin-shell:overview.pendingInvitations",
|
|
89
|
+
query: TenantQueries.invitations,
|
|
90
|
+
// tenant:query:invitations returns a bare array, not a paged
|
|
91
|
+
// envelope — "length" reads record["length"], i.e. the array's
|
|
92
|
+
// own .length. Not a typo.
|
|
93
|
+
valueField: "length",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
kind: "stat",
|
|
97
|
+
id: "members",
|
|
98
|
+
label: "admin-shell:overview.members",
|
|
99
|
+
query: TenantQueries.members,
|
|
100
|
+
// Same array-shaped response as invitations above.
|
|
101
|
+
valueField: "length",
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
kind: "stat",
|
|
105
|
+
id: "missing-config",
|
|
106
|
+
label: "admin-shell:overview.missingConfig",
|
|
107
|
+
query: ConfigQueries.readiness,
|
|
108
|
+
valueField: "missingCount",
|
|
109
|
+
toneField: "missingTone",
|
|
110
|
+
},
|
|
111
|
+
],
|
|
82
112
|
});
|
|
83
113
|
r.nav({
|
|
84
114
|
id: "tenant-overview",
|
|
@@ -88,14 +118,38 @@ export function createAdminShellFeature(options: CreateAdminShellOptions = {}):
|
|
|
88
118
|
order: 1,
|
|
89
119
|
});
|
|
90
120
|
|
|
91
|
-
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
92
121
|
r.screen({
|
|
93
122
|
id: PLATFORM_OVERVIEW_SCREEN_ID,
|
|
94
|
-
type: "
|
|
95
|
-
renderer: { react: { __component: "PlatformOverviewScreen" } },
|
|
123
|
+
type: "dashboard",
|
|
96
124
|
access: { roles: access.systemAdmin },
|
|
97
125
|
description:
|
|
98
126
|
"Landing page of the platform-admin workspace, showing installation-wide tenant, user and failed-job counts so a system admin sees the health of the whole deployment at a glance.",
|
|
127
|
+
panels: [
|
|
128
|
+
{
|
|
129
|
+
kind: "stat",
|
|
130
|
+
id: "tenants",
|
|
131
|
+
label: "admin-shell:overview.tenants",
|
|
132
|
+
query: TenantQueries.list,
|
|
133
|
+
params: { totalCount: true },
|
|
134
|
+
valueField: "total",
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
kind: "stat",
|
|
138
|
+
id: "users",
|
|
139
|
+
label: "admin-shell:overview.users",
|
|
140
|
+
query: UserQueries.list,
|
|
141
|
+
params: { totalCount: true },
|
|
142
|
+
valueField: "total",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
kind: "stat",
|
|
146
|
+
id: "failed-jobs",
|
|
147
|
+
label: "admin-shell:overview.failedJobs",
|
|
148
|
+
query: JobQueries.list,
|
|
149
|
+
params: { status: "failed", totalCount: true },
|
|
150
|
+
valueField: "total",
|
|
151
|
+
},
|
|
152
|
+
],
|
|
99
153
|
});
|
|
100
154
|
r.nav({
|
|
101
155
|
id: "platform-overview",
|
package/src/admin-shell/i18n.ts
CHANGED
|
@@ -15,9 +15,6 @@ export const ADMIN_SHELL_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
15
15
|
"admin-shell:nav.tierAdmin": { en: "Assign tier" },
|
|
16
16
|
"admin-shell:nav.myCaps": { en: "Plans & Caps" },
|
|
17
17
|
"admin-shell:nav.tenantCaps": { en: "Plans & Caps" },
|
|
18
|
-
"admin-shell:overview.tenantTitle": { en: "Administration" },
|
|
19
|
-
"admin-shell:overview.platformTitle": { en: "Platform" },
|
|
20
|
-
"admin-shell:overview.loading": { en: "Loading…" },
|
|
21
18
|
"admin-shell:overview.pendingInvitations": {
|
|
22
19
|
en: "Pending invitations",
|
|
23
20
|
},
|
|
@@ -25,11 +22,7 @@ export const ADMIN_SHELL_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
25
22
|
"admin-shell:overview.missingConfig": {
|
|
26
23
|
en: "Missing configuration",
|
|
27
24
|
},
|
|
28
|
-
"admin-shell:overview.missingConfigHint": {
|
|
29
|
-
en: "Check required settings",
|
|
30
|
-
},
|
|
31
25
|
"admin-shell:overview.tenants": { en: "Tenants" },
|
|
32
26
|
"admin-shell:overview.users": { en: "Users" },
|
|
33
27
|
"admin-shell:overview.failedJobs": { en: "Failed jobs" },
|
|
34
|
-
"admin-shell:overview.failedJobsHint": { en: "Review job runs" },
|
|
35
28
|
};
|
|
@@ -1,14 +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 {
|
|
5
|
-
ADMIN_SHELL_FEATURE,
|
|
6
|
-
PLATFORM_OVERVIEW_SCREEN_ID,
|
|
7
|
-
TENANT_OVERVIEW_SCREEN_ID,
|
|
8
|
-
} from "../constants";
|
|
4
|
+
import { ADMIN_SHELL_FEATURE } from "../constants";
|
|
9
5
|
import { defaultTranslations } from "./i18n";
|
|
10
|
-
import { PlatformOverviewScreen } from "./platform-overview-screen";
|
|
11
|
-
import { TenantOverviewScreen } from "./tenant-overview-screen";
|
|
12
6
|
|
|
13
7
|
export type AdminShellClientOptions = {
|
|
14
8
|
readonly translations?: TranslationsByLocale;
|
|
@@ -18,9 +12,5 @@ export function adminShellClient(options?: AdminShellClientOptions): ClientFeatu
|
|
|
18
12
|
return {
|
|
19
13
|
name: ADMIN_SHELL_FEATURE,
|
|
20
14
|
translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
|
|
21
|
-
components: {
|
|
22
|
-
[TENANT_OVERVIEW_SCREEN_ID]: TenantOverviewScreen,
|
|
23
|
-
[PLATFORM_OVERVIEW_SCREEN_ID]: PlatformOverviewScreen,
|
|
24
|
-
},
|
|
25
15
|
};
|
|
26
16
|
}
|
|
@@ -5,6 +5,7 @@ import { createConfigFeature } from "../../config/feature";
|
|
|
5
5
|
import { createTenantFeature } from "../../tenant/feature";
|
|
6
6
|
import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "../constants";
|
|
7
7
|
import { createAuditFeature } from "../feature";
|
|
8
|
+
import { AUDIT_I18N } from "../i18n";
|
|
8
9
|
|
|
9
10
|
describe("audit log screen + handler access alignment", () => {
|
|
10
11
|
const features = [createConfigFeature(), createTenantFeature(), createAuditFeature()];
|
|
@@ -13,11 +14,13 @@ describe("audit log screen + handler access alignment", () => {
|
|
|
13
14
|
expect(() => validateBoot(features)).not.toThrow();
|
|
14
15
|
});
|
|
15
16
|
|
|
16
|
-
test("audit-log screen is
|
|
17
|
+
test("audit-log screen is declarative, access.admin-gated", () => {
|
|
17
18
|
const audit = createAuditFeature();
|
|
18
19
|
const screen = audit.screens[AUDIT_LOG_SCREEN_ID];
|
|
19
|
-
if (screen?.type !== "
|
|
20
|
-
throw new Error(
|
|
20
|
+
if (screen?.type !== "projectionList") {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`expected a projectionList screen for ${AUDIT_LOG_SCREEN_ID}, got ${screen?.type}`,
|
|
23
|
+
);
|
|
21
24
|
}
|
|
22
25
|
if (!("access" in screen) || !screen.access || !("roles" in screen.access)) {
|
|
23
26
|
throw new Error(`expected role-gated access on ${AUDIT_LOG_SCREEN_ID}`);
|
|
@@ -25,12 +28,12 @@ describe("audit log screen + handler access alignment", () => {
|
|
|
25
28
|
expect(screen.access.roles).toEqual(access.admin);
|
|
26
29
|
});
|
|
27
30
|
|
|
28
|
-
test("audit-log-detail screen is
|
|
31
|
+
test("audit-log-detail screen is declarative, admin-gated, breadcrumb-linked to list", () => {
|
|
29
32
|
const audit = createAuditFeature();
|
|
30
33
|
const screen = audit.screens[AUDIT_LOG_DETAIL_SCREEN_ID];
|
|
31
|
-
if (screen?.type !== "
|
|
34
|
+
if (screen?.type !== "projectionDetail") {
|
|
32
35
|
throw new Error(
|
|
33
|
-
`expected a
|
|
36
|
+
`expected a projectionDetail screen for ${AUDIT_LOG_DETAIL_SCREEN_ID}, got ${screen?.type}`,
|
|
34
37
|
);
|
|
35
38
|
}
|
|
36
39
|
if (!("listScreenId" in screen)) {
|
|
@@ -43,6 +46,23 @@ describe("audit log screen + handler access alignment", () => {
|
|
|
43
46
|
expect(screen.access.roles).toEqual(access.admin);
|
|
44
47
|
});
|
|
45
48
|
|
|
49
|
+
test("audit-log-detail: aggregate type and aggregate id resolve to distinct labels", () => {
|
|
50
|
+
const audit = createAuditFeature();
|
|
51
|
+
const screen = audit.screens[AUDIT_LOG_DETAIL_SCREEN_ID];
|
|
52
|
+
if (screen?.type !== "projectionDetail") {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`expected a projectionDetail screen for ${AUDIT_LOG_DETAIL_SCREEN_ID}, got ${screen?.type}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const aggregateTypeKey = screen.fieldLabels?.["aggregateType"];
|
|
58
|
+
const aggregateIdKey = screen.fieldLabels?.["aggregateId"];
|
|
59
|
+
if (aggregateTypeKey === undefined || aggregateIdKey === undefined) {
|
|
60
|
+
throw new Error("expected fieldLabels for both aggregateType and aggregateId");
|
|
61
|
+
}
|
|
62
|
+
expect(aggregateTypeKey).not.toBe(aggregateIdKey);
|
|
63
|
+
expect(AUDIT_I18N[aggregateTypeKey]?.en).not.toBe(AUDIT_I18N[aggregateIdKey]?.en);
|
|
64
|
+
});
|
|
65
|
+
|
|
46
66
|
test("audit queries use access.admin (screen ⊆ handler)", () => {
|
|
47
67
|
const audit = createAuditFeature();
|
|
48
68
|
expect(rolesOf(audit.queryHandlers["list"]?.access)).toEqual([...access.admin]);
|
package/src/audit/changes.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.241.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "AuditLogScreen and AuditLogDetailScreen removed; audit-log/audit-log-detail are now declarative screens (fw#2312).",
|
|
6
|
+
"detail": "`audit-log` and `audit-log-detail` render through the generic renderer (`projectionList`/`projectionDetail`) instead of custom React components — the renderer selects a screen by its `screen.type`, not a client component registry, so `AuditLogScreen`/`AuditLogDetailScreen` were dead exports the moment the screen definitions switched. The `payload`/`metadata` fields now use the `format: \"json\"` field-renderer (see framework changelog) instead of a raw escaped-string dump.",
|
|
7
|
+
"migration": "Breaking only if you imported these components directly (no shipped consumer app did). Remove `import { AuditLogScreen, AuditLogDetailScreen } from \"@cosmicdrift/kumiko-bundled-features/audit/web\"` and any `components: { AuditLogScreen: ..., AuditLogDetailScreen: ... }` entry in your `auditClient()`/renderer setup — the bundled feature registers its own `audit-log`/`audit-log-detail` screens now, nothing left for an app to wire."
|
|
8
|
+
},
|
|
2
9
|
{
|
|
3
10
|
"version": "0.127.0",
|
|
4
11
|
"type": "improvement",
|
package/src/audit/feature.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
defineFeature,
|
|
4
4
|
type FeatureDefinition,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
-
import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID } from "./constants";
|
|
6
|
+
import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "./constants";
|
|
7
7
|
import { detailsQuery } from "./handlers/details.query";
|
|
8
8
|
import { listQuery } from "./handlers/list.query";
|
|
9
9
|
import { AUDIT_I18N } from "./i18n";
|
|
@@ -39,20 +39,70 @@ export function createAuditFeature(): FeatureDefinition {
|
|
|
39
39
|
details: r.queryHandler(detailsQuery),
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
43
42
|
r.screen({
|
|
44
43
|
id: AUDIT_LOG_SCREEN_ID,
|
|
45
|
-
type: "
|
|
46
|
-
|
|
44
|
+
type: "projectionList",
|
|
45
|
+
query: AuditQueries.list,
|
|
46
|
+
columns: [
|
|
47
|
+
{ field: "createdAt", label: "audit.log.col.when", renderer: { format: "timestamp" } },
|
|
48
|
+
{ field: "type", label: "audit.log.col.type" },
|
|
49
|
+
{ field: "createdBy", label: "audit.log.col.actor" },
|
|
50
|
+
],
|
|
51
|
+
searchable: true,
|
|
52
|
+
defaultSort: { field: "createdAt", dir: "desc" },
|
|
53
|
+
rowActions: [
|
|
54
|
+
{
|
|
55
|
+
kind: "navigate",
|
|
56
|
+
id: "details",
|
|
57
|
+
label: "audit.log.details",
|
|
58
|
+
screen: AUDIT_LOG_DETAIL_SCREEN_ID,
|
|
59
|
+
entityId: "id",
|
|
60
|
+
rowClick: true,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
pagination: "infinite",
|
|
47
64
|
description:
|
|
48
65
|
"Admin table of the tenant's audit-trail events with actor names and event-type/date filters; open it to browse recent changes and drill into a single event.",
|
|
49
66
|
access: { roles: access.admin },
|
|
50
67
|
});
|
|
51
|
-
// kumiko-lint-ignore app-feature-structure Phase-3 conversion tracked in #2312
|
|
52
68
|
r.screen({
|
|
53
69
|
id: AUDIT_LOG_DETAIL_SCREEN_ID,
|
|
54
|
-
type: "
|
|
55
|
-
|
|
70
|
+
type: "projectionDetail",
|
|
71
|
+
query: AuditQueries.details,
|
|
72
|
+
idParam: "id",
|
|
73
|
+
fieldLabels: {
|
|
74
|
+
type: "audit.log.col.type",
|
|
75
|
+
createdAt: "audit.log.col.when",
|
|
76
|
+
aggregateType: "audit.log.col.aggregateType",
|
|
77
|
+
aggregateId: "audit.log.col.aggregateId",
|
|
78
|
+
createdBy: "audit.log.col.actor",
|
|
79
|
+
id: "audit.log.detail.field.id",
|
|
80
|
+
payload: "audit.log.detail.payload",
|
|
81
|
+
metadata: "audit.log.detail.metadata",
|
|
82
|
+
},
|
|
83
|
+
layout: {
|
|
84
|
+
sections: [
|
|
85
|
+
{
|
|
86
|
+
fields: [
|
|
87
|
+
"type",
|
|
88
|
+
{ field: "createdAt", renderer: { format: "timestamp" } },
|
|
89
|
+
"aggregateType",
|
|
90
|
+
"aggregateId",
|
|
91
|
+
"createdBy",
|
|
92
|
+
"id",
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
// No section `title` here (fw#2312 label-dedup fix): each section
|
|
96
|
+
// holds exactly one field, so a title would repeat the field's own
|
|
97
|
+
// label (rendered by RenderField as the Field's heading) verbatim.
|
|
98
|
+
{
|
|
99
|
+
fields: [{ field: "payload", renderer: { format: "json" } }],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
fields: [{ field: "metadata", renderer: { format: "json" } }],
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
56
106
|
description:
|
|
57
107
|
"Read-only detail view of one audit event showing actor, timestamp, aggregate and the raw event payload and metadata; reached from a row of the audit log.",
|
|
58
108
|
listScreenId: AUDIT_LOG_SCREEN_ID,
|
|
@@ -19,18 +19,60 @@ import { z } from "zod";
|
|
|
19
19
|
|
|
20
20
|
const MAX_LIMIT = 100;
|
|
21
21
|
|
|
22
|
+
function buildDateRange(
|
|
23
|
+
from: string | undefined,
|
|
24
|
+
to: string | undefined,
|
|
25
|
+
): { gte?: unknown; lte?: unknown } | null {
|
|
26
|
+
if (!from && !to) return null;
|
|
27
|
+
const range: { gte?: unknown; lte?: unknown } = {};
|
|
28
|
+
if (from) range.gte = Temporal.Instant.from(from);
|
|
29
|
+
if (to) range.lte = Temporal.Instant.from(to);
|
|
30
|
+
return range;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildAuditWhere(
|
|
34
|
+
tenantId: string,
|
|
35
|
+
p: {
|
|
36
|
+
aggregateType?: string;
|
|
37
|
+
aggregateId?: string;
|
|
38
|
+
eventType?: string;
|
|
39
|
+
search?: string;
|
|
40
|
+
userId?: string;
|
|
41
|
+
from?: string;
|
|
42
|
+
to?: string;
|
|
43
|
+
cursor?: string;
|
|
44
|
+
before?: string;
|
|
45
|
+
},
|
|
46
|
+
): WhereObject {
|
|
47
|
+
const where: WhereObject = { tenantId };
|
|
48
|
+
if (p.aggregateType) where["aggregateType"] = p.aggregateType;
|
|
49
|
+
if (p.aggregateId) where["aggregateId"] = p.aggregateId;
|
|
50
|
+
const type = p.eventType ?? p.search;
|
|
51
|
+
if (type) where["type"] = type;
|
|
52
|
+
if (p.userId) where["createdBy"] = p.userId;
|
|
53
|
+
const range = buildDateRange(p.from, p.to);
|
|
54
|
+
if (range) where["createdAt"] = range;
|
|
55
|
+
const cursor = p.cursor ?? p.before;
|
|
56
|
+
if (cursor) where["id"] = { lt: BigInt(cursor) };
|
|
57
|
+
return where;
|
|
58
|
+
}
|
|
59
|
+
|
|
22
60
|
export const listQuery = defineQueryHandler({
|
|
23
61
|
name: "list",
|
|
24
62
|
description:
|
|
25
63
|
"Lists the tenant's audit-trail events newest-first with cursor paging, filterable by aggregate type, aggregate id, event type, actor and time range; use it to answer who changed what and when.",
|
|
26
64
|
schema: z
|
|
27
65
|
.object({
|
|
66
|
+
cursor: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
|
|
28
67
|
before: z.string().regex(/^\d+$/, "cursor must be a positive integer").optional(),
|
|
68
|
+
search: z.string().trim().optional(),
|
|
29
69
|
limit: z.number().int().min(1).max(MAX_LIMIT).default(50),
|
|
30
70
|
aggregateType: z.string().optional(),
|
|
31
71
|
aggregateId: z.uuid().optional(),
|
|
32
72
|
eventType: z.string().optional(),
|
|
33
73
|
userId: z.string().optional(),
|
|
74
|
+
sort: z.enum(["createdAt", "type"]).optional(),
|
|
75
|
+
sortDirection: z.enum(["asc", "desc"]).optional(),
|
|
34
76
|
from: z.iso.datetime().optional(),
|
|
35
77
|
to: z.iso.datetime().optional(),
|
|
36
78
|
})
|
|
@@ -41,18 +83,7 @@ export const listQuery = defineQueryHandler({
|
|
|
41
83
|
access: { roles: access.admin },
|
|
42
84
|
handler: async (query, ctx) => {
|
|
43
85
|
const p = query.payload;
|
|
44
|
-
const where
|
|
45
|
-
if (p.aggregateType) where["aggregateType"] = p.aggregateType;
|
|
46
|
-
if (p.aggregateId) where["aggregateId"] = p.aggregateId;
|
|
47
|
-
if (p.eventType) where["type"] = p.eventType;
|
|
48
|
-
if (p.userId) where["createdBy"] = p.userId;
|
|
49
|
-
if (p.from || p.to) {
|
|
50
|
-
const range: { gte?: unknown; lte?: unknown } = {};
|
|
51
|
-
if (p.from) range.gte = Temporal.Instant.from(p.from);
|
|
52
|
-
if (p.to) range.lte = Temporal.Instant.from(p.to);
|
|
53
|
-
where["createdAt"] = range;
|
|
54
|
-
}
|
|
55
|
-
if (p.before) where["id"] = { lt: BigInt(p.before) };
|
|
86
|
+
const where = buildAuditWhere(query.user.tenantId, p);
|
|
56
87
|
|
|
57
88
|
const rows = await selectMany<{
|
|
58
89
|
id: bigint;
|
|
@@ -65,7 +96,10 @@ export const listQuery = defineQueryHandler({
|
|
|
65
96
|
createdAt: unknown;
|
|
66
97
|
createdBy: string;
|
|
67
98
|
}>(ctx.db, eventsTable, where, {
|
|
68
|
-
orderBy: {
|
|
99
|
+
orderBy: {
|
|
100
|
+
col: query.payload.sort === "type" ? "type" : "createdAt",
|
|
101
|
+
direction: query.payload.sortDirection ?? "desc",
|
|
102
|
+
},
|
|
69
103
|
limit: p.limit,
|
|
70
104
|
});
|
|
71
105
|
|
|
@@ -81,9 +115,11 @@ export const listQuery = defineQueryHandler({
|
|
|
81
115
|
createdBy: r.createdBy,
|
|
82
116
|
}));
|
|
83
117
|
const last = serialised[serialised.length - 1];
|
|
118
|
+
const nextId = serialised.length === p.limit && last ? last.id : null;
|
|
84
119
|
return {
|
|
85
120
|
rows: serialised,
|
|
86
|
-
|
|
121
|
+
nextCursor: nextId,
|
|
122
|
+
nextBefore: nextId,
|
|
87
123
|
};
|
|
88
124
|
},
|
|
89
125
|
});
|
package/src/audit/i18n.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// @runtime client
|
|
2
|
-
// Server + client i18n for audit (nav labels +
|
|
2
|
+
// Server + client i18n for audit (nav labels + audit-log/-detail screens).
|
|
3
3
|
|
|
4
4
|
type LocalizedString = { readonly en: string };
|
|
5
5
|
|
|
@@ -7,27 +7,12 @@ export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
7
7
|
"screen:audit-log.title": { en: "Audit log" },
|
|
8
8
|
"screen:audit-log-detail.title": { en: "Event" },
|
|
9
9
|
"audit:nav.auditLog": { en: "Audit" },
|
|
10
|
-
"audit.log.title": { en: "Audit log" },
|
|
11
|
-
"audit.log.loading": { en: "Loading events…" },
|
|
12
|
-
"audit.log.empty": { en: "No events." },
|
|
13
|
-
"audit.log.newest": { en: "Newest" },
|
|
14
|
-
"audit.log.older": { en: "Load older" },
|
|
15
10
|
"audit.log.col.when": { en: "When" },
|
|
16
11
|
"audit.log.col.type": { en: "Event" },
|
|
17
|
-
"audit.log.col.
|
|
12
|
+
"audit.log.col.aggregateType": { en: "Aggregate type" },
|
|
13
|
+
"audit.log.col.aggregateId": { en: "Aggregate ID" },
|
|
18
14
|
"audit.log.col.actor": { en: "Actor" },
|
|
19
|
-
"audit.log.actor.system": { en: "System" },
|
|
20
|
-
"audit.log.actor.anonymous": { en: "Anonymous" },
|
|
21
|
-
"audit.log.actor.unknown": { en: "Unknown actor" },
|
|
22
|
-
"audit.log.filter.eventType": { en: "Event type" },
|
|
23
|
-
"audit.log.filter.aggregateType": { en: "Aggregate type" },
|
|
24
|
-
"audit.log.filter.from": { en: "From" },
|
|
25
|
-
"audit.log.filter.to": { en: "To" },
|
|
26
|
-
"audit.log.filter.apply": { en: "Filter" },
|
|
27
|
-
"audit.log.filter.reset": { en: "Reset" },
|
|
28
15
|
"audit.log.details": { en: "Details" },
|
|
29
|
-
"audit.log.detail.loading": { en: "Loading event…" },
|
|
30
|
-
"audit.log.detail.missing": { en: "Event not found." },
|
|
31
16
|
"audit.log.detail.payload": { en: "Event payload" },
|
|
32
17
|
"audit.log.detail.metadata": { en: "Metadata" },
|
|
33
18
|
"audit.log.detail.field.id": { en: "Event ID" },
|