@cosmicdrift/kumiko-bundled-features 0.124.0 → 0.125.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.
Files changed (88) hide show
  1. package/package.json +14 -6
  2. package/src/admin-shell/__tests__/admin-shell-security.integration.test.ts +199 -0
  3. package/src/admin-shell/__tests__/admin-shell.boot.test.ts +131 -0
  4. package/src/admin-shell/__tests__/overview-allowlist.test.ts +33 -0
  5. package/src/admin-shell/__tests__/overview-screens.boot.test.ts +33 -0
  6. package/src/admin-shell/constants.ts +11 -0
  7. package/src/admin-shell/feature.ts +134 -0
  8. package/src/admin-shell/i18n.ts +35 -0
  9. package/src/admin-shell/index.ts +17 -0
  10. package/src/admin-shell/overview-allowlist.ts +32 -0
  11. package/src/admin-shell/web/client-plugin.tsx +26 -0
  12. package/src/admin-shell/web/i18n.ts +16 -0
  13. package/src/admin-shell/web/index.ts +2 -0
  14. package/src/admin-shell/web/overview-layout.tsx +68 -0
  15. package/src/admin-shell/web/overview-query.ts +15 -0
  16. package/src/admin-shell/web/platform-overview-screen.tsx +75 -0
  17. package/src/admin-shell/web/tenant-overview-screen.tsx +90 -0
  18. package/src/audit/__tests__/audit-screens.boot.test.ts +29 -0
  19. package/src/audit/__tests__/audit-security.integration.test.ts +151 -0
  20. package/src/audit/__tests__/audit.integration.test.ts +0 -4
  21. package/src/audit/constants.ts +5 -2
  22. package/src/audit/feature.ts +24 -1
  23. package/src/audit/handlers/list.query.ts +2 -2
  24. package/src/audit/i18n.ts +18 -0
  25. package/src/audit/web/audit-log-screen.tsx +136 -0
  26. package/src/audit/web/client-plugin.tsx +20 -0
  27. package/src/audit/web/i18n.ts +12 -0
  28. package/src/audit/web/index.ts +4 -0
  29. package/src/auth-email-password/handlers/invite-create.write.ts +2 -2
  30. package/src/auth-email-password/web/use-shell-user.ts +5 -2
  31. package/src/cap-counter/__tests__/cap-list.boot.test.ts +27 -0
  32. package/src/cap-counter/__tests__/cap-list.security.integration.test.ts +53 -0
  33. package/src/cap-counter/constants.ts +2 -0
  34. package/src/cap-counter/feature.ts +20 -1
  35. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +28 -0
  36. package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +91 -0
  37. package/src/compliance-profiles/constants.ts +15 -0
  38. package/src/compliance-profiles/feature.ts +19 -1
  39. package/src/compliance-profiles/handlers/set-profile.write.ts +2 -2
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/web/client-plugin.tsx +22 -0
  42. package/src/compliance-profiles/web/compliance-profile-screen.tsx +152 -0
  43. package/src/compliance-profiles/web/i18n.ts +25 -0
  44. package/src/compliance-profiles/web/index.ts +6 -0
  45. package/src/delivery/__tests__/delivery-screens.boot.test.ts +27 -0
  46. package/src/delivery/__tests__/delivery-security.integration.test.ts +106 -0
  47. package/src/delivery/constants.ts +13 -51
  48. package/src/delivery/feature.ts +19 -2
  49. package/src/delivery/handlers/log.query.ts +14 -6
  50. package/src/delivery/index.ts +1 -0
  51. package/src/delivery/public-names.ts +36 -0
  52. package/src/delivery/web/client-plugin.tsx +20 -0
  53. package/src/delivery/web/delivery-log-screen.tsx +103 -0
  54. package/src/delivery/web/i18n.ts +25 -0
  55. package/src/delivery/web/index.ts +3 -0
  56. package/src/feature-toggles/__tests__/toggle-admin.boot.test.ts +31 -0
  57. package/src/feature-toggles/__tests__/toggle-admin.security.integration.test.ts +69 -0
  58. package/src/feature-toggles/constants.ts +14 -0
  59. package/src/feature-toggles/feature.ts +14 -1
  60. package/src/feature-toggles/handlers/list.query.ts +1 -1
  61. package/src/feature-toggles/handlers/registered.query.ts +1 -1
  62. package/src/feature-toggles/index.ts +6 -0
  63. package/src/feature-toggles/web/client-plugin.tsx +22 -0
  64. package/src/feature-toggles/web/i18n.ts +29 -0
  65. package/src/feature-toggles/web/index.ts +6 -0
  66. package/src/feature-toggles/web/toggle-admin-screen.tsx +136 -0
  67. package/src/jobs/__tests__/jobs-screens.boot.test.ts +36 -0
  68. package/src/jobs/__tests__/jobs-security.integration.test.ts +108 -0
  69. package/src/jobs/constants.ts +8 -1
  70. package/src/jobs/feature.ts +26 -0
  71. package/src/jobs/i18n.ts +32 -0
  72. package/src/jobs/web/client-plugin.tsx +22 -0
  73. package/src/jobs/web/i18n.ts +12 -0
  74. package/src/jobs/web/index.ts +4 -0
  75. package/src/jobs/web/job-run-detail-screen.tsx +169 -0
  76. package/src/jobs/web/job-runs-screen.tsx +148 -0
  77. package/src/tenant/__tests__/members-screens.boot.test.ts +44 -0
  78. package/src/tenant/__tests__/tenant-security.integration.test.ts +295 -0
  79. package/src/tenant/constants.ts +5 -0
  80. package/src/tenant/feature.ts +16 -0
  81. package/src/tenant/handlers/cancel-invitation.write.ts +2 -2
  82. package/src/tenant/handlers/invitations.query.ts +2 -2
  83. package/src/tenant/handlers/members.query.ts +2 -2
  84. package/src/tenant/web/client-plugin.tsx +20 -0
  85. package/src/tenant/web/i18n.ts +43 -0
  86. package/src/tenant/web/index.ts +4 -0
  87. package/src/tenant/web/members-screen.tsx +265 -0
  88. package/src/tier-engine/web/tier-admin-screen.tsx +7 -6
@@ -0,0 +1,136 @@
1
+ // @runtime client
2
+ // Paginated tenant-scoped audit log (event store).
3
+
4
+ import { useDispatcher, 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 AuditRow = {
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
+ };
18
+
19
+ type AuditResponse = { readonly rows: readonly AuditRow[]; readonly nextBefore: string | null };
20
+
21
+ type State =
22
+ | { readonly kind: "loading" }
23
+ | { readonly kind: "error"; readonly message: string }
24
+ | {
25
+ readonly kind: "ready";
26
+ readonly rows: readonly AuditRow[];
27
+ readonly nextBefore: string | null;
28
+ };
29
+
30
+ export function AuditLogScreen(): ReactNode {
31
+ const t = useTranslation();
32
+ const { Banner, Button, Card, DataTable, Heading, Text } = usePrimitives();
33
+ const dispatcher = useDispatcher();
34
+ const [state, setState] = useState<State>({ kind: "loading" });
35
+ const [before, setBefore] = useState<string | undefined>(undefined);
36
+
37
+ const load = useCallback(
38
+ async (cursor?: string): Promise<void> => {
39
+ setState({ kind: "loading" });
40
+ const res = await dispatcher.query<AuditResponse>(AuditQueries.list, {
41
+ limit: 50,
42
+ ...(cursor !== undefined && { before: cursor }),
43
+ });
44
+ if (!res.isSuccess) {
45
+ setState({ kind: "error", message: res.error.message });
46
+ return;
47
+ }
48
+ setState({ kind: "ready", rows: res.data.rows, nextBefore: res.data.nextBefore });
49
+ },
50
+ [dispatcher],
51
+ );
52
+
53
+ useEffect(() => {
54
+ void load(before);
55
+ }, [load, before]);
56
+
57
+ if (state.kind === "loading") {
58
+ return (
59
+ <FormScreenShell testId="audit-log-screen">
60
+ <Text variant="small">{t("audit.log.loading")}</Text>
61
+ </FormScreenShell>
62
+ );
63
+ }
64
+
65
+ if (state.kind === "error") {
66
+ return (
67
+ <FormScreenShell testId="audit-log-screen">
68
+ <Banner variant="error">{state.message}</Banner>
69
+ </FormScreenShell>
70
+ );
71
+ }
72
+
73
+ return (
74
+ <FormScreenShell testId="audit-log-screen" className="flex max-w-5xl flex-col gap-6">
75
+ <Heading variant="page">{t("audit.log.title")}</Heading>
76
+
77
+ <Card options={{ padded: false }}>
78
+ <DataTable
79
+ testId="audit-log-table"
80
+ columns={[
81
+ { field: "when", label: t("audit.log.col.when"), type: "string", sortable: false },
82
+ { field: "type", label: t("audit.log.col.type"), type: "string", sortable: false },
83
+ {
84
+ field: "aggregate",
85
+ label: t("audit.log.col.aggregate"),
86
+ type: "string",
87
+ sortable: false,
88
+ },
89
+ { field: "actor", label: t("audit.log.col.actor"), type: "string", sortable: false },
90
+ ]}
91
+ rows={state.rows.map((row) => ({
92
+ id: row.id,
93
+ values: {
94
+ when: formatWhen(row.createdAt),
95
+ type: row.type,
96
+ aggregate: `${row.aggregateType} / ${row.aggregateId}`,
97
+ actor: row.createdBy,
98
+ },
99
+ }))}
100
+ emptyState={<Text variant="small">{t("audit.log.empty")}</Text>}
101
+ />
102
+ </Card>
103
+
104
+ <div className="flex gap-2">
105
+ {before !== undefined && (
106
+ <Button
107
+ type="button"
108
+ variant="secondary"
109
+ onClick={() => setBefore(undefined)}
110
+ testId="audit-log-newest"
111
+ >
112
+ {t("audit.log.newest")}
113
+ </Button>
114
+ )}
115
+ {state.nextBefore !== null && (
116
+ <Button
117
+ type="button"
118
+ variant="secondary"
119
+ onClick={() => setBefore(state.nextBefore ?? undefined)}
120
+ testId="audit-log-older"
121
+ >
122
+ {t("audit.log.older")}
123
+ </Button>
124
+ )}
125
+ </div>
126
+ </FormScreenShell>
127
+ );
128
+ }
129
+
130
+ function formatWhen(value: string): string {
131
+ try {
132
+ return new Date(value).toLocaleString();
133
+ } catch {
134
+ return value;
135
+ }
136
+ }
@@ -0,0 +1,20 @@
1
+ // @runtime client
2
+ import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
+ import { AUDIT_FEATURE, AUDIT_LOG_SCREEN_ID } from "../constants";
5
+ import { AuditLogScreen } from "./audit-log-screen";
6
+ import { defaultTranslations } from "./i18n";
7
+
8
+ export type AuditClientOptions = {
9
+ readonly translations?: TranslationsByLocale;
10
+ };
11
+
12
+ export function auditClient(options?: AuditClientOptions): ClientFeatureDefinition {
13
+ return {
14
+ name: AUDIT_FEATURE,
15
+ translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
16
+ components: {
17
+ [AUDIT_LOG_SCREEN_ID]: AuditLogScreen,
18
+ },
19
+ };
20
+ }
@@ -0,0 +1,12 @@
1
+ // @runtime client
2
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
+ import { AUDIT_I18N } from "../i18n";
4
+
5
+ const LOCALES = ["de", "en"] as const;
6
+
7
+ export const defaultTranslations: TranslationsByLocale = Object.fromEntries(
8
+ LOCALES.map((locale) => [
9
+ locale,
10
+ Object.fromEntries(Object.entries(AUDIT_I18N).map(([key, value]) => [key, value[locale]])),
11
+ ]),
12
+ );
@@ -0,0 +1,4 @@
1
+ // @runtime client
2
+
3
+ export { AuditLogScreen } from "./audit-log-screen";
4
+ export { type AuditClientOptions, auditClient } from "./client-plugin";
@@ -19,7 +19,7 @@
19
19
  import { generateToken } from "@cosmicdrift/kumiko-framework/api";
20
20
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
21
21
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
22
- import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
22
+ import { access, defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
23
23
  import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
24
24
  import { Temporal } from "temporal-polyfill";
25
25
  import { z } from "zod";
@@ -77,7 +77,7 @@ export function createInviteCreateHandler(opts: InviteCreateOptions) {
77
77
  return defineWriteHandler<"invite-create", typeof InviteCreateSchema, InviteCreateData>({
78
78
  name: "invite-create",
79
79
  schema: InviteCreateSchema,
80
- access: { roles: ["Admin"] },
80
+ access: { roles: access.admin },
81
81
  handler: async (event, ctx) => {
82
82
  if (!ctx.redis) {
83
83
  return writeFailure(
@@ -29,6 +29,9 @@ export function useShellUser(): ShellUser | undefined {
29
29
  if (session.status !== "authenticated" || session.user === null) return undefined;
30
30
  const activeTenant = session.tenants.find((t) => t.tenantId === session.activeTenantId);
31
31
  if (activeTenant === undefined) return undefined;
32
- return { id: session.user.id, roles: activeTenant.roles };
33
- }, [session.status, session.user, session.activeTenantId, session.tenants]);
32
+ // session.roles merges globalRoles (e.g. SystemAdmin) + membership — same
33
+ // merge the server uses for handler access; membership-only broke platform
34
+ // workspace gating for global operators.
35
+ return { id: session.user.id, roles: session.roles };
36
+ }, [session.status, session.user, session.activeTenantId, session.tenants, session.roles]);
34
37
  }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
+ import { CAP_COUNTER_LIST_SCREEN_ID } from "../constants";
5
+ import { capCounterFeature } from "../feature";
6
+
7
+ describe("cap-counter list screen + handler access alignment", () => {
8
+ const features = [capCounterFeature];
9
+
10
+ test("boot-validates with cap-list screen registered", () => {
11
+ expect(() => validateBoot(features)).not.toThrow();
12
+ });
13
+
14
+ test("cap-list screen is entityList, SystemAdmin-gated", () => {
15
+ const screen = capCounterFeature.screens[CAP_COUNTER_LIST_SCREEN_ID];
16
+ expect(screen?.type).toBe("entityList");
17
+ if (screen && "access" in screen && screen.access && "roles" in screen.access) {
18
+ expect(screen.access.roles).toEqual(["SystemAdmin"]);
19
+ }
20
+ });
21
+
22
+ test("cap-counter list query shares SystemAdmin access", () => {
23
+ expect(rolesOf(capCounterFeature.queryHandlers["cap-counter:list"]?.access)).toEqual([
24
+ "SystemAdmin",
25
+ ]);
26
+ });
27
+ });
@@ -0,0 +1,53 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import {
3
+ createTestUser,
4
+ setupTestStack,
5
+ type TestStack,
6
+ unsafeCreateEntityTable,
7
+ } from "@cosmicdrift/kumiko-framework/stack";
8
+ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
9
+ import { CAP_COUNTER_LIST_SCREEN_ID, CapCounterQueries } from "../constants";
10
+ import { capCounterEntity } from "../entity";
11
+ import { capCounterFeature } from "../feature";
12
+
13
+ let stack: TestStack;
14
+
15
+ beforeAll(async () => {
16
+ stack = await setupTestStack({
17
+ features: [capCounterFeature],
18
+ });
19
+ await unsafeCreateEntityTable(stack.db, capCounterEntity);
20
+ });
21
+
22
+ afterAll(async () => {
23
+ await stack.cleanup();
24
+ });
25
+
26
+ describe("cap-counter access matrix", () => {
27
+ test("cap-list screen and list query are SystemAdmin-only", () => {
28
+ expect(rolesOf(stack.registry.getQueryHandler(CapCounterQueries.list)?.access)).toEqual([
29
+ "SystemAdmin",
30
+ ]);
31
+ const screen = capCounterFeature.screens[CAP_COUNTER_LIST_SCREEN_ID];
32
+ if (screen && "access" in screen && screen.access && "roles" in screen.access) {
33
+ expect(screen.access.roles).toEqual(["SystemAdmin"]);
34
+ }
35
+ });
36
+ });
37
+
38
+ describe("cap-counter list HTTP access", () => {
39
+ test("SystemAdmin can list counters", async () => {
40
+ const admin = createTestUser({ id: 41, roles: ["SystemAdmin"] });
41
+ const res = await stack.http.queryOk<{ rows: readonly unknown[] }>(
42
+ CapCounterQueries.list,
43
+ {},
44
+ admin,
45
+ );
46
+ expect(Array.isArray(res.rows)).toBe(true);
47
+ });
48
+
49
+ test("TenantAdmin gets 403 on list", async () => {
50
+ const user = createTestUser({ id: 42, roles: ["TenantAdmin"] });
51
+ expect((await stack.http.query(CapCounterQueries.list, {}, user)).status).toBe(403);
52
+ });
53
+ });
@@ -1,6 +1,8 @@
1
1
  // Feature name
2
2
  export const CAP_COUNTER_FEATURE = "cap-counter" as const;
3
3
 
4
+ export const CAP_COUNTER_LIST_SCREEN_ID = "cap-list" as const;
5
+
4
6
  // Aggregate types — calendar-period-Counter benutzt CRUD-Events der
5
7
  // projection-row. Rolling-Window-Counter benutzt einen eigenen
6
8
  // aggregate-type mit custom increment-events (no projection — der
@@ -47,7 +47,11 @@
47
47
  // vom Framework-Default (Base-Column tenantId).
48
48
 
49
49
  import { defineEntityListHandler, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
50
- import { CAP_COUNTER_FEATURE, ROLLING_INCREMENTED_EVENT_SHORT } from "./constants";
50
+ import {
51
+ CAP_COUNTER_FEATURE,
52
+ CAP_COUNTER_LIST_SCREEN_ID,
53
+ ROLLING_INCREMENTED_EVENT_SHORT,
54
+ } from "./constants";
51
55
  import { capCounterEntity } from "./entity";
52
56
  import { getCounterQuery } from "./handlers/get-counter.query";
53
57
  import { incrementCapHandler } from "./handlers/increment.write";
@@ -91,4 +95,19 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
91
95
  // über aggregate-id).
92
96
  r.queryHandler(defineEntityListHandler("cap-counter", capCounterEntity, sysadminAccess));
93
97
  r.queryHandler(getCounterQuery);
98
+
99
+ r.screen({
100
+ id: CAP_COUNTER_LIST_SCREEN_ID,
101
+ type: "entityList",
102
+ entity: "cap-counter",
103
+ columns: ["capName", "value", "periodStart", "lastSoftWarnedAt"],
104
+ searchable: false,
105
+ access: { roles: ["SystemAdmin"] },
106
+ });
107
+ r.nav({
108
+ id: "cap-list",
109
+ label: "Caps",
110
+ screen: "cap-counter:screen:cap-list",
111
+ order: 60,
112
+ });
94
113
  });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
4
+ import { COMPLIANCE_PROFILE_SCREEN_ID, ComplianceProfileHandlers } from "../constants";
5
+ import { createComplianceProfilesFeature } from "../feature";
6
+
7
+ describe("compliance profile screen + handler access alignment", () => {
8
+ const features = [createComplianceProfilesFeature()];
9
+
10
+ test("boot-validates with profile-picker screen registered", () => {
11
+ expect(() => validateBoot(features)).not.toThrow();
12
+ });
13
+
14
+ test("profile-picker screen is custom, access.admin-gated", () => {
15
+ const feature = createComplianceProfilesFeature();
16
+ const screen = feature.screens[COMPLIANCE_PROFILE_SCREEN_ID];
17
+ expect(screen?.type).toBe("custom");
18
+ if (screen && "access" in screen && screen.access && "roles" in screen.access) {
19
+ expect(screen.access.roles).toEqual(access.admin);
20
+ }
21
+ });
22
+
23
+ test("set-profile handler shares access.admin", () => {
24
+ const feature = createComplianceProfilesFeature();
25
+ expect(rolesOf(feature.writeHandlers["set-profile"]?.access)).toEqual([...access.admin]);
26
+ void ComplianceProfileHandlers;
27
+ });
28
+ });
@@ -0,0 +1,91 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
3
+ import { access } from "@cosmicdrift/kumiko-framework/engine";
4
+ import {
5
+ createTestUser,
6
+ setupTestStack,
7
+ type TestStack,
8
+ unsafeCreateEntityTable,
9
+ } from "@cosmicdrift/kumiko-framework/stack";
10
+ import { rolesOf } from "@cosmicdrift/kumiko-framework/testing";
11
+ import {
12
+ COMPLIANCE_PROFILE_SCREEN_ID,
13
+ ComplianceProfileHandlers,
14
+ ComplianceProfileQueries,
15
+ } from "../constants";
16
+ import { createComplianceProfilesFeature } from "../feature";
17
+ import {
18
+ tenantComplianceProfileEntity,
19
+ tenantComplianceProfileTable,
20
+ } from "../schema/profile-selection";
21
+
22
+ let stack: TestStack;
23
+
24
+ beforeAll(async () => {
25
+ stack = await setupTestStack({
26
+ features: [createComplianceProfilesFeature()],
27
+ });
28
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
29
+ });
30
+
31
+ afterAll(async () => {
32
+ await stack.cleanup();
33
+ });
34
+
35
+ beforeEach(async () => {
36
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantComplianceProfileTable.tableName}"`);
37
+ });
38
+
39
+ describe("compliance-profiles access matrix", () => {
40
+ test("set-profile handler and screen share access.admin", () => {
41
+ expect(
42
+ rolesOf(stack.registry.getWriteHandler(ComplianceProfileHandlers.setProfile)?.access),
43
+ ).toEqual([...access.admin]);
44
+ const feature = createComplianceProfilesFeature();
45
+ const screen = feature.screens[COMPLIANCE_PROFILE_SCREEN_ID];
46
+ if (screen && "access" in screen && screen.access && "roles" in screen.access) {
47
+ expect(screen.access.roles).toEqual(access.admin);
48
+ }
49
+ });
50
+ });
51
+
52
+ describe("compliance profile HTTP access", () => {
53
+ test("historic Admin can set a profile for own tenant", async () => {
54
+ const admin = createTestUser({ id: 31, roles: ["Admin"] });
55
+ const res = await stack.http.writeOk<{ profileKey: string; isNew: boolean }>(
56
+ ComplianceProfileHandlers.setProfile,
57
+ { profileKey: "eu-dsgvo" },
58
+ admin,
59
+ );
60
+ expect(res.profileKey).toBe("eu-dsgvo");
61
+ const current = await stack.http.queryOk<{ profile: { key: string } }>(
62
+ ComplianceProfileQueries.forTenant,
63
+ {},
64
+ admin,
65
+ );
66
+ expect(current.profile.key).toBe("eu-dsgvo");
67
+ });
68
+
69
+ test("TenantAdmin can set a profile for own tenant", async () => {
70
+ const admin = createTestUser({ id: 32, roles: ["TenantAdmin"] });
71
+ const res = await stack.http.writeOk<{ profileKey: string }>(
72
+ ComplianceProfileHandlers.setProfile,
73
+ { profileKey: "swiss-dsg" },
74
+ admin,
75
+ );
76
+ expect(res.profileKey).toBe("swiss-dsg");
77
+ });
78
+
79
+ test("regular User gets 403 on set-profile", async () => {
80
+ const user = createTestUser({ id: 33, roles: ["User"] });
81
+ expect(
82
+ (
83
+ await stack.http.write(
84
+ ComplianceProfileHandlers.setProfile,
85
+ { profileKey: "eu-dsgvo" },
86
+ user,
87
+ )
88
+ ).status,
89
+ ).toBe(403);
90
+ });
91
+ });
@@ -0,0 +1,15 @@
1
+ // @runtime client
2
+ export const COMPLIANCE_PROFILES_FEATURE = "compliance-profiles" as const;
3
+
4
+ export const ComplianceProfileHandlers = {
5
+ setProfile: "compliance-profiles:write:set-profile",
6
+ } as const;
7
+
8
+ export const ComplianceProfileQueries = {
9
+ forTenant: "compliance-profiles:query:for-tenant",
10
+ listProfiles: "compliance-profiles:query:list-profiles",
11
+ needsProfile: "compliance-profiles:query:needs-profile",
12
+ subProcessors: "compliance-profiles:query:sub-processors",
13
+ } as const;
14
+
15
+ export const COMPLIANCE_PROFILE_SCREEN_ID = "profile-picker" as const;
@@ -1,4 +1,9 @@
1
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ access,
3
+ defineFeature,
4
+ type FeatureDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { COMPLIANCE_PROFILE_SCREEN_ID } from "./constants";
2
7
  import { forTenantQuery } from "./handlers/for-tenant.query";
3
8
  import { listProfilesQuery } from "./handlers/list-profiles.query";
4
9
  import { needsProfileQuery } from "./handlers/needs-profile.query";
@@ -54,6 +59,19 @@ export function createComplianceProfilesFeature(): FeatureDefinition {
54
59
  needsProfile: r.queryHandler(needsProfileQuery),
55
60
  };
56
61
 
62
+ r.screen({
63
+ id: COMPLIANCE_PROFILE_SCREEN_ID,
64
+ type: "custom",
65
+ renderer: { react: { __component: "ComplianceProfileScreen" } },
66
+ access: { roles: access.admin },
67
+ });
68
+ r.nav({
69
+ id: "profile-picker",
70
+ label: "compliance-profiles:nav.profilePicker",
71
+ screen: "compliance-profiles:screen:profile-picker",
72
+ order: 50,
73
+ });
74
+
57
75
  return { handlers, queries };
58
76
  });
59
77
  }
@@ -1,4 +1,3 @@
1
- import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
2
1
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
3
2
  import {
4
3
  complianceProfileOverrideSchema,
@@ -6,6 +5,7 @@ import {
6
5
  } from "@cosmicdrift/kumiko-framework/compliance";
7
6
  import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
8
7
  import {
8
+ access,
9
9
  crossTenantOverrideDenied,
10
10
  defineWriteHandler,
11
11
  type TenantId,
@@ -58,7 +58,7 @@ export const setProfileWrite = defineWriteHandler({
58
58
  }),
59
59
  // SystemAdmin kann Profile fuer Customer-Setup setzen (Plattform-
60
60
  // Operator-Pfad). TenantAdmin nur fuer eigenen Tenant.
61
- access: { roles: [ROLES.TenantAdmin, ROLES.SystemAdmin] },
61
+ access: { roles: access.admin },
62
62
  handler: async (event, ctx) => {
63
63
  const tenantOverride = event.payload.tenantIdOverride;
64
64
  const overrideDenied = crossTenantOverrideDenied(
@@ -1,3 +1,9 @@
1
+ export {
2
+ COMPLIANCE_PROFILE_SCREEN_ID,
3
+ COMPLIANCE_PROFILES_FEATURE,
4
+ ComplianceProfileHandlers,
5
+ ComplianceProfileQueries,
6
+ } from "./constants";
1
7
  export {
2
8
  createComplianceProfilesFeature,
3
9
  tenantComplianceProfileEntity,
@@ -0,0 +1,22 @@
1
+ // @runtime client
2
+ import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
+ import { COMPLIANCE_PROFILE_SCREEN_ID, COMPLIANCE_PROFILES_FEATURE } from "../constants";
5
+ import { ComplianceProfileScreen } from "./compliance-profile-screen";
6
+ import { defaultTranslations } from "./i18n";
7
+
8
+ export type ComplianceProfilesClientOptions = {
9
+ readonly translations?: TranslationsByLocale;
10
+ };
11
+
12
+ export function complianceProfilesClient(
13
+ options?: ComplianceProfilesClientOptions,
14
+ ): ClientFeatureDefinition {
15
+ return {
16
+ name: COMPLIANCE_PROFILES_FEATURE,
17
+ translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
18
+ components: {
19
+ [COMPLIANCE_PROFILE_SCREEN_ID]: ComplianceProfileScreen,
20
+ },
21
+ };
22
+ }