@cosmicdrift/kumiko-bundled-features 0.125.2 → 0.127.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 (69) hide show
  1. package/package.json +7 -6
  2. package/src/audit/__tests__/audit-security.integration.test.ts +50 -0
  3. package/src/audit/i18n.ts +9 -0
  4. package/src/audit/web/audit-log-screen.tsx +112 -2
  5. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +206 -2
  6. package/src/billing-foundation/entities.ts +28 -4
  7. package/src/billing-foundation/events.ts +6 -2
  8. package/src/billing-foundation/feature.ts +9 -1
  9. package/src/billing-foundation/get-subscription-for-tenant.ts +16 -5
  10. package/src/billing-foundation/handlers/create-portal-session.write.ts +17 -1
  11. package/src/billing-foundation/handlers/list-subscriptions.query.ts +15 -1
  12. package/src/billing-foundation/handlers/process-event.write.ts +39 -3
  13. package/src/billing-foundation/tenant-destroy-hook.ts +47 -0
  14. package/src/cap-counter/entity.ts +1 -1
  15. package/src/cap-counter/feature.ts +10 -2
  16. package/src/cap-counter/i18n.ts +12 -0
  17. package/src/compliance-profiles/feature.ts +3 -0
  18. package/src/compliance-profiles/i18n.ts +6 -0
  19. package/src/config/feature.ts +3 -0
  20. package/src/config/i18n.ts +9 -0
  21. package/src/delivery/feature.ts +3 -0
  22. package/src/delivery/i18n.ts +6 -0
  23. package/src/feature-toggles/feature.ts +3 -0
  24. package/src/feature-toggles/i18n.ts +6 -0
  25. package/src/jobs/i18n.ts +4 -0
  26. package/src/jobs/web/job-runs-screen.tsx +9 -9
  27. package/src/managed-pages/i18n.ts +5 -0
  28. package/src/managed-pages/screens/page-screens.ts +5 -1
  29. package/src/personal-access-tokens/feature.ts +2 -0
  30. package/src/personal-access-tokens/i18n.ts +5 -0
  31. package/src/personal-access-tokens/web/pat-tokens-screen.tsx +7 -3
  32. package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +12 -1
  33. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +9 -0
  34. package/src/subscription-stripe/feature.ts +2 -0
  35. package/src/tags/feature.ts +2 -0
  36. package/src/tags/i18n.ts +5 -0
  37. package/src/tenant/__tests__/members-screens.boot.test.ts +7 -0
  38. package/src/tenant/__tests__/tenant-security.integration.test.ts +20 -5
  39. package/src/tenant/feature.ts +4 -1
  40. package/src/tenant/handlers/members.query.ts +19 -4
  41. package/src/tenant/i18n.ts +28 -0
  42. package/src/tenant/index.ts +7 -2
  43. package/src/tenant/schema/tenant.ts +27 -2
  44. package/src/tenant/screens.ts +3 -2
  45. package/src/tenant/web/members-screen.tsx +8 -3
  46. package/src/tenant-lifecycle/__tests__/stages.test.ts +22 -0
  47. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +469 -0
  48. package/src/tenant-lifecycle/constants.ts +56 -0
  49. package/src/tenant-lifecycle/events.ts +46 -0
  50. package/src/tenant-lifecycle/feature.ts +105 -0
  51. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +81 -0
  52. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +83 -0
  53. package/src/tenant-lifecycle/index.ts +7 -0
  54. package/src/tenant-lifecycle/lib/revoke-tenant-sessions.ts +16 -0
  55. package/src/tenant-lifecycle/lifecycle-gate.ts +58 -0
  56. package/src/tenant-lifecycle/run-tenant-destroy.ts +368 -0
  57. package/src/tenant-lifecycle/stages.ts +206 -0
  58. package/src/tier-engine/feature.ts +2 -0
  59. package/src/tier-engine/i18n.ts +8 -0
  60. package/src/tier-engine/web/tier-admin-screen.tsx +20 -14
  61. package/src/user/feature.ts +3 -0
  62. package/src/user/i18n.ts +20 -0
  63. package/src/user/schema/user.ts +1 -0
  64. package/src/user/screens.ts +3 -15
  65. package/src/user-data-rights/feature.ts +17 -0
  66. package/src/user-data-rights/i18n.ts +32 -33
  67. package/src/user-data-rights/schema/export-job.ts +2 -0
  68. package/src/user-data-rights/screens.ts +2 -1
  69. package/src/user-data-rights/web/i18n.ts +9 -0
@@ -23,6 +23,7 @@ import { resolveUserIdsQuery } from "./handlers/resolve-user-ids.query";
23
23
  import { disableWrite, enableWrite } from "./handlers/toggle-enabled.write";
24
24
  import { updateWrite } from "./handlers/update.write";
25
25
  import { updateMemberRolesWrite } from "./handlers/update-member-roles.write";
26
+ import { TENANT_I18N } from "./i18n";
26
27
  import { tenantInvitationEntity } from "./invitation-table";
27
28
  import { tenantMembershipEntity } from "./membership-table";
28
29
  import { tenantEntity } from "./schema/tenant";
@@ -145,12 +146,14 @@ export function createTenantFeature(): FeatureDefinition {
145
146
  });
146
147
  r.nav({
147
148
  id: "members",
148
- label: "tenant:nav.members",
149
+ label: "tenant.nav.members",
149
150
  icon: "users",
150
151
  screen: "tenant:screen:members",
151
152
  order: 20,
152
153
  });
153
154
 
155
+ r.translations({ keys: TENANT_I18N });
156
+
154
157
  return { handlers, queries };
155
158
  });
156
159
  }
@@ -2,8 +2,11 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
4
4
  import { z } from "zod";
5
+ import { userTable } from "../../user";
5
6
  import { tenantMembershipsTable } from "../membership-table";
6
7
 
8
+ type UserRow = { readonly id: unknown; readonly email?: unknown; readonly displayName?: unknown };
9
+
7
10
  export const membersQuery = defineQueryHandler({
8
11
  name: "members",
9
12
  schema: z.object({}),
@@ -13,9 +16,21 @@ export const membersQuery = defineQueryHandler({
13
16
  tenantId: query.user.tenantId,
14
17
  });
15
18
 
16
- return rows.map((row) => ({
17
- ...row,
18
- roles: parseRoles(row["roles"]),
19
- }));
19
+ const userIds = [...new Set(rows.map((row) => row["userId"]))];
20
+ const users =
21
+ userIds.length > 0 ? await selectMany<UserRow>(ctx.db, userTable, { id: userIds }) : [];
22
+ const userById = new Map(users.map((u) => [String(u.id), u]));
23
+
24
+ return rows.map((row) => {
25
+ const user = userById.get(String(row["userId"]));
26
+ const email = typeof user?.email === "string" ? user.email : null;
27
+ const displayName = typeof user?.displayName === "string" ? user.displayName : null;
28
+ return {
29
+ ...row,
30
+ email,
31
+ displayName,
32
+ roles: parseRoles(row["roles"]),
33
+ };
34
+ });
20
35
  },
21
36
  });
@@ -0,0 +1,28 @@
1
+ type LocalizedString = { readonly de: string; readonly en: string };
2
+
3
+ export const TENANT_I18N: Readonly<Record<string, LocalizedString>> = {
4
+ "screen:tenant-list.title": { de: "Mandanten", en: "Tenants" },
5
+ "screen:tenant-edit.title": { de: "Mandant bearbeiten", en: "Edit tenant" },
6
+ "screen:members.title": { de: "Team", en: "Team" },
7
+ "tenant.nav.members": { de: "Team", en: "Team" },
8
+ "tenant:entity:tenant:field:key": { de: "Schlüssel", en: "Key" },
9
+ "tenant:entity:tenant:field:name": { de: "Name", en: "Name" },
10
+ "tenant:entity:tenant:field:isEnabled": { de: "Aktiv", en: "Enabled" },
11
+ "tenant:entity:tenant:field:status": { de: "Status", en: "Status" },
12
+ "tenant:entity:tenant:field:status:option:active": { de: "Aktiv", en: "Active" },
13
+ "tenant:entity:tenant:field:status:option:destroyRequested": {
14
+ de: "Löschung angefordert",
15
+ en: "Destroy requested",
16
+ },
17
+ "tenant:entity:tenant:field:status:option:destroying": {
18
+ de: "Wird gelöscht",
19
+ en: "Destroying",
20
+ },
21
+ "tenant:entity:tenant:field:status:option:destroyFailed": {
22
+ de: "Löschung fehlgeschlagen",
23
+ en: "Destroy failed",
24
+ },
25
+ "tenant:entity:tenant:field:status:option:destroyed": { de: "Gelöscht", en: "Destroyed" },
26
+ "tenant:entity:tenant:field:isEnabled:option:true": { de: "Ja", en: "Yes" },
27
+ "tenant:entity:tenant:field:isEnabled:option:false": { de: "Nein", en: "No" },
28
+ };
@@ -8,5 +8,10 @@ export {
8
8
  tenantInvitationEntity,
9
9
  tenantInvitationsTable,
10
10
  } from "./invitation-table";
11
- export { tenantMembershipsTable } from "./membership-table";
12
- export { tenantEntity, tenantTable } from "./schema/tenant";
11
+ export { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table";
12
+ export {
13
+ TENANT_LIFECYCLE_STATUSES,
14
+ type TenantLifecycleStatus,
15
+ tenantEntity,
16
+ tenantTable,
17
+ } from "./schema/tenant";
@@ -2,9 +2,21 @@ import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
2
2
  import {
3
3
  createBooleanField,
4
4
  createEntity,
5
+ createSelectField,
5
6
  createTextField,
7
+ createTimestampField,
6
8
  } from "@cosmicdrift/kumiko-framework/engine";
7
9
 
10
+ export const TENANT_LIFECYCLE_STATUSES = [
11
+ "active",
12
+ "destroyRequested",
13
+ "destroying",
14
+ "destroyFailed",
15
+ "destroyed",
16
+ ] as const;
17
+
18
+ export type TenantLifecycleStatus = (typeof TENANT_LIFECYCLE_STATUSES)[number];
19
+
8
20
  export const tenantEntity = createEntity({
9
21
  table: "read_tenants",
10
22
  // tenant.id IS the tenantId-value that every other table references as FK.
@@ -12,8 +24,21 @@ export const tenantEntity = createEntity({
12
24
  // UUID sein, sonst findet der tenants-Lookup nie. Default gen_random_uuid().
13
25
  fields: {
14
26
  key: createTextField({ required: true, maxLength: 50 }),
15
- name: createTextField({ required: true, maxLength: 200, searchable: true }),
16
- isEnabled: createBooleanField({ default: true }),
27
+ name: createTextField({ required: true, maxLength: 200, searchable: true, sortable: true }),
28
+ isEnabled: createBooleanField({ default: true, filterable: true }),
29
+ // Tenant-destroy lifecycle (tenant-lifecycle feature). Defaults keep
30
+ // existing tenants valid when the feature is not mounted.
31
+ status: createSelectField({
32
+ options: TENANT_LIFECYCLE_STATUSES,
33
+ default: "active",
34
+ required: true,
35
+ filterable: true,
36
+ }),
37
+ destroyRequestedAt: createTimestampField({}),
38
+ destroyRequestedBy: createTextField({ maxLength: 36 }),
39
+ gracePeriodEnd: createTimestampField({}),
40
+ destroyStartedAt: createTimestampField({}),
41
+ destroyedAt: createTimestampField({}),
17
42
  },
18
43
  // tenant.key wird in Admin-URLs verwendet (`admin.<host>/<key>/...`) und
19
44
  // muss eindeutig sein. Ohne unique-constraint hätte ein konkurrenter
@@ -16,7 +16,7 @@ export const tenantListScreen: EntityListScreenDefinition = {
16
16
  id: "tenant-list",
17
17
  type: "entityList",
18
18
  entity: "tenant",
19
- columns: ["key", "name", "isEnabled"],
19
+ columns: ["key", "name", "status", "isEnabled"],
20
20
  rowActions: [
21
21
  {
22
22
  kind: "navigate",
@@ -26,7 +26,8 @@ export const tenantListScreen: EntityListScreenDefinition = {
26
26
  entityId: "id",
27
27
  },
28
28
  ],
29
- searchable: false,
29
+ defaultSort: { field: "name", dir: "asc" },
30
+ searchable: true,
30
31
  access: { roles: ["SystemAdmin"] },
31
32
  };
32
33
 
@@ -12,6 +12,8 @@ type MemberRow = {
12
12
  readonly id: string;
13
13
  readonly userId: string;
14
14
  readonly tenantId: string;
15
+ readonly email: string | null;
16
+ readonly displayName: string | null;
15
17
  readonly roles: readonly string[];
16
18
  };
17
19
 
@@ -131,8 +133,8 @@ export function MembersScreen({
131
133
  testId="members-active-table"
132
134
  columns={[
133
135
  {
134
- field: "userId",
135
- label: t("tenant.members.col.userId"),
136
+ field: "email",
137
+ label: t("tenant.members.col.email"),
136
138
  type: "string",
137
139
  sortable: false,
138
140
  },
@@ -145,7 +147,10 @@ export function MembersScreen({
145
147
  ]}
146
148
  rows={state.members.map((m) => ({
147
149
  id: m.id,
148
- values: { userId: m.userId, roles: m.roles.join(", ") },
150
+ values: {
151
+ email: m.email ?? `${m.userId.slice(0, 8)}…`,
152
+ roles: m.roles.join(", "),
153
+ },
149
154
  }))}
150
155
  />
151
156
  </Card>
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { DESTRUCTION_STAGES, isDestructionPipelineComplete, pickNextStage } from "../stages";
3
+
4
+ describe("tenant-lifecycle stages", () => {
5
+ test("pickNextStage halts when any stage was abandoned", () => {
6
+ const completed = new Set(["external-resources", "search-indices"]);
7
+ const abandoned = new Set(["app-data"]);
8
+ expect(pickNextStage(completed, abandoned)).toBeNull();
9
+ });
10
+
11
+ test("pickNextStage returns first incomplete stage when healthy", () => {
12
+ const completed = new Set(["external-resources"]);
13
+ expect(pickNextStage(completed, new Set())?.name).toBe("search-indices");
14
+ });
15
+
16
+ test("isDestructionPipelineComplete requires every stage", () => {
17
+ const partial = new Set(DESTRUCTION_STAGES.slice(0, 3).map((s) => s.name));
18
+ expect(isDestructionPipelineComplete(partial)).toBe(false);
19
+ const all = new Set(DESTRUCTION_STAGES.map((s) => s.name));
20
+ expect(isDestructionPipelineComplete(all)).toBe(true);
21
+ });
22
+ });