@cosmicdrift/kumiko-bundled-features 0.126.0 → 0.128.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 (61) hide show
  1. package/package.json +6 -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 +195 -2
  6. package/src/billing-foundation/entities.ts +28 -2
  7. package/src/billing-foundation/events.ts +6 -2
  8. package/src/billing-foundation/feature.ts +2 -0
  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 +30 -7
  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-stripe/feature.ts +2 -0
  33. package/src/tags/feature.ts +2 -0
  34. package/src/tags/i18n.ts +5 -0
  35. package/src/tenant/__tests__/tenant-security.integration.test.ts +20 -5
  36. package/src/tenant/feature.ts +3 -0
  37. package/src/tenant/handlers/members.query.ts +19 -4
  38. package/src/tenant/i18n.ts +28 -0
  39. package/src/tenant/index.ts +1 -1
  40. package/src/tenant/schema/tenant.ts +2 -2
  41. package/src/tenant/screens.ts +3 -2
  42. package/src/tenant/web/members-screen.tsx +8 -3
  43. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +161 -1
  44. package/src/tenant-lifecycle/feature.ts +1 -0
  45. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -0
  46. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +2 -0
  47. package/src/tenant-lifecycle/lifecycle-gate.ts +58 -0
  48. package/src/tenant-lifecycle/run-tenant-destroy.ts +50 -39
  49. package/src/tenant-lifecycle/stages.ts +36 -6
  50. package/src/tier-engine/feature.ts +2 -0
  51. package/src/tier-engine/i18n.ts +8 -0
  52. package/src/tier-engine/web/tier-admin-screen.tsx +20 -14
  53. package/src/user/feature.ts +3 -0
  54. package/src/user/i18n.ts +20 -0
  55. package/src/user/schema/user.ts +1 -0
  56. package/src/user/screens.ts +3 -15
  57. package/src/user-data-rights/feature.ts +17 -0
  58. package/src/user-data-rights/i18n.ts +32 -33
  59. package/src/user-data-rights/schema/export-job.ts +2 -0
  60. package/src/user-data-rights/screens.ts +2 -1
  61. package/src/user-data-rights/web/i18n.ts +9 -0
@@ -4,8 +4,13 @@
4
4
  // tenant-scoped, gibt automatisch nur die row des Callers zurück).
5
5
 
6
6
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
7
+ import {
8
+ configuredPiiSubjectKms,
9
+ decryptPiiFieldValues,
10
+ } from "@cosmicdrift/kumiko-framework/crypto";
7
11
  import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
8
12
  import { z } from "zod";
13
+ import { SUBSCRIPTION_PII_FIELDS } from "../entities";
9
14
  import { subscriptionsProjectionTable } from "../projection";
10
15
 
11
16
  const listSchema = z.object({}).strict();
@@ -18,6 +23,15 @@ export const listSubscriptionsQuery: QueryHandlerDef = {
18
23
  const rows = await selectMany(ctx.db.raw, subscriptionsProjectionTable, {
19
24
  tenantId: ctx.user.tenantId,
20
25
  });
21
- return { rows };
26
+ const piiKms = configuredPiiSubjectKms();
27
+ if (!piiKms) return { rows };
28
+ const decryptedRows = await Promise.all(
29
+ rows.map((row) =>
30
+ decryptPiiFieldValues(row as Record<string, unknown>, SUBSCRIPTION_PII_FIELDS, piiKms, {
31
+ requestId: `billing-foundation:list-subscriptions:${ctx.user.tenantId}`,
32
+ }),
33
+ ),
34
+ );
35
+ return { rows: decryptedRows };
22
36
  },
23
37
  };
@@ -12,10 +12,15 @@
12
12
  // 3. ctx.unsafeAppendEvent — Inline-projection materialisiert die
13
13
  // `read_subscriptions`-row in derselben TX.
14
14
 
15
+ import {
16
+ configuredPiiSubjectKms,
17
+ encryptPiiFieldValues,
18
+ } from "@cosmicdrift/kumiko-framework/crypto";
15
19
  import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
16
20
  import { z } from "zod";
17
21
  import { subscriptionAggregateId } from "../aggregate-id";
18
22
  import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
23
+ import { SUBSCRIPTION_PII_FIELDS, subscriptionEntity } from "../entities";
19
24
  import {
20
25
  INVOICE_PAID_EVENT_QN,
21
26
  INVOICE_PAYMENT_FAILED_EVENT_QN,
@@ -125,13 +130,44 @@ export const processEventHandler: WriteHandlerDef = {
125
130
  }
126
131
 
127
132
  // ---------------------------------------------------------------
128
- // 3. Append event auf den subscription-stream. Inline-projection
133
+ // 3. Encrypt the two provider-subject PII fields before they touch
134
+ // storage — this is the ONLY write path onto the subscription
135
+ // stream, so encrypting here covers both the event-log payload AND
136
+ // (via projection.ts copying the event fields as-is) the
137
+ // read_subscriptions row with a single call. The subject is the
138
+ // TENANT (tenantOwned) — tenant-destroy's subject-keys stage
139
+ // (eraseSubjectKeys) erases exactly this key, so both copies become
140
+ // genuinely unreadable (#800) once that stage runs, not just
141
+ // "encrypted at rest". No adapter configured = engine off (fields
142
+ // stay plaintext, pre-#724-phase-C behavior) — mirrors how the
143
+ // event-store-executor treats an absent piiKms().
144
+ // ---------------------------------------------------------------
145
+ const piiKms = configuredPiiSubjectKms();
146
+ const encryptedFields = piiKms
147
+ ? await encryptPiiFieldValues(
148
+ {
149
+ tenantId,
150
+ providerCustomerId: payload.providerCustomerId,
151
+ providerSubscriptionId: payload.providerSubscriptionId,
152
+ },
153
+ subscriptionEntity,
154
+ SUBSCRIPTION_PII_FIELDS,
155
+ piiKms,
156
+ { requestId: `billing-foundation:process-event:${payload.providerEventId}`, tenantId },
157
+ )
158
+ : {
159
+ providerCustomerId: payload.providerCustomerId,
160
+ providerSubscriptionId: payload.providerSubscriptionId,
161
+ };
162
+
163
+ // ---------------------------------------------------------------
164
+ // 4. Append event auf den subscription-stream. Inline-projection
129
165
  // materialisiert die read_subscriptions-row in derselben TX.
130
166
  // ---------------------------------------------------------------
131
167
  const eventPayload: SubscriptionEventPayload = {
132
168
  providerName: payload.providerName,
133
- providerCustomerId: payload.providerCustomerId,
134
- providerSubscriptionId: payload.providerSubscriptionId,
169
+ providerCustomerId: encryptedFields["providerCustomerId"] as string,
170
+ providerSubscriptionId: encryptedFields["providerSubscriptionId"] as string,
135
171
  status: payload.status,
136
172
  tier: payload.tier,
137
173
  currentPeriodEndIso: payload.currentPeriodEndIso,
@@ -1,11 +1,23 @@
1
- import { deleteMany, type EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
1
+ import { deleteMany, type EntityTableMeta, updateMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
3
4
  import { resolveProfileForTenant } from "../compliance-profiles";
4
5
  import { subscriptionAggregateId } from "./aggregate-id";
6
+ import { SUBSCRIPTION_AGGREGATE_TYPE } from "./events";
5
7
  import { subscriptionsProjectionTable } from "./projection";
6
8
 
7
- /** Tenant-destroy hook for billing PII (#800). HGB keeps ciphertext row
8
- * (subject-keys stage erases tenant DEK); other profiles hard-delete. */
9
+ // No field-level encryption is wired up for `read_subscriptions` (see
10
+ // entities.ts) a crypto-shredding retention strategy would need that built
11
+ // first (envelope-encrypt on write, decrypt on every read call site, backfill
12
+ // existing rows). Redaction achieves the same GDPR outcome without it: HGB
13
+ // needs the accounting facts (status/tier/period), not the Stripe/Mollie
14
+ // subject identifiers.
15
+ const REDACTED_PII_VALUE = "[erased]";
16
+
17
+ /** Tenant-destroy hook for billing PII (#800). HGB retains the accounting
18
+ * row but redacts the two provider-subject PII fields; other profiles
19
+ * hard-delete the row. Either way the subscription stream is archived so a
20
+ * future projection rebuild can't resurrect what was erased. */
9
21
  export async function subscriptionTenantDestroyHook(ctx: {
10
22
  readonly db: import("@cosmicdrift/kumiko-framework/db").DbRunner;
11
23
  readonly tenantId: TenantId;
@@ -14,11 +26,22 @@ export async function subscriptionTenantDestroyHook(ctx: {
14
26
  db: ctx.db,
15
27
  tenantId: ctx.tenantId,
16
28
  });
29
+ const aggregateId = subscriptionAggregateId(ctx.tenantId);
17
30
  if (profile.key === "de-hr-dsgvo-hgb") {
18
- // skip: HGB retention — row stays until crypto-shredded by subject-keys stage
19
- return;
31
+ await updateMany(
32
+ ctx.db,
33
+ subscriptionsProjectionTable as EntityTableMeta,
34
+ { providerCustomerId: REDACTED_PII_VALUE, providerSubscriptionId: REDACTED_PII_VALUE },
35
+ { id: aggregateId },
36
+ );
37
+ } else {
38
+ await deleteMany(ctx.db, subscriptionsProjectionTable as EntityTableMeta, { id: aggregateId });
20
39
  }
21
- await deleteMany(ctx.db, subscriptionsProjectionTable as EntityTableMeta, {
22
- id: subscriptionAggregateId(ctx.tenantId),
40
+ await archiveStream(ctx.db, {
41
+ tenantId: ctx.tenantId,
42
+ aggregateId,
43
+ aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
44
+ archivedBy: "tenant-lifecycle:destroy",
45
+ reason: "tenant_destroy",
23
46
  });
24
47
  }
@@ -40,7 +40,7 @@ import {
40
40
  export const capCounterEntity = createEntity({
41
41
  table: "read_cap_counters",
42
42
  fields: {
43
- capName: createTextField({ required: true, maxLength: 100 }),
43
+ capName: createTextField({ required: true, maxLength: 100, sortable: true, searchable: true }),
44
44
  value: createNumberField({ required: true, default: 0 }),
45
45
  periodStart: createTimestampField({ required: true }),
46
46
  lastSoftWarnedAt: createTimestampField(),
@@ -60,6 +60,7 @@ import {
60
60
  rollingIncrementedSchema,
61
61
  } from "./handlers/increment-rolling.write";
62
62
  import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write";
63
+ import { CAP_COUNTER_I18N } from "./i18n";
63
64
 
64
65
  const sysadminAccess = { access: { roles: ["SystemAdmin"] } } as const;
65
66
 
@@ -101,13 +102,20 @@ export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
101
102
  type: "entityList",
102
103
  entity: "cap-counter",
103
104
  columns: ["capName", "value", "periodStart", "lastSoftWarnedAt"],
104
- searchable: false,
105
+ defaultSort: { field: "capName", dir: "asc" },
106
+ searchable: true,
105
107
  access: { roles: ["SystemAdmin"] },
106
108
  });
107
109
  r.nav({
108
110
  id: "cap-list",
109
- label: "Caps",
111
+ label: "cap-counter:nav.cap-list",
110
112
  screen: "cap-counter:screen:cap-list",
111
113
  order: 60,
112
114
  });
115
+ r.translations({
116
+ keys: {
117
+ ...CAP_COUNTER_I18N,
118
+ "cap-counter:nav.cap-list": { de: "Limits", en: "Caps" },
119
+ },
120
+ });
113
121
  });
@@ -0,0 +1,12 @@
1
+ type LocalizedString = { readonly de: string; readonly en: string };
2
+
3
+ export const CAP_COUNTER_I18N: Readonly<Record<string, LocalizedString>> = {
4
+ "screen:cap-list.title": { de: "Nutzungslimits", en: "Usage caps" },
5
+ "cap-counter:entity:cap-counter:field:capName": { de: "Limit", en: "Cap" },
6
+ "cap-counter:entity:cap-counter:field:value": { de: "Wert", en: "Value" },
7
+ "cap-counter:entity:cap-counter:field:periodStart": { de: "Periodenstart", en: "Period start" },
8
+ "cap-counter:entity:cap-counter:field:lastSoftWarnedAt": {
9
+ de: "Letzte Soft-Warnung",
10
+ en: "Last soft warning",
11
+ },
12
+ };
@@ -9,6 +9,7 @@ import { listProfilesQuery } from "./handlers/list-profiles.query";
9
9
  import { needsProfileQuery } from "./handlers/needs-profile.query";
10
10
  import { setProfileWrite } from "./handlers/set-profile.write";
11
11
  import { subProcessorsQuery } from "./handlers/sub-processors.query";
12
+ import { COMPLIANCE_PROFILES_I18N } from "./i18n";
12
13
  import { tenantComplianceProfileEntity } from "./schema/profile-selection";
13
14
 
14
15
  export {
@@ -72,6 +73,8 @@ export function createComplianceProfilesFeature(): FeatureDefinition {
72
73
  order: 50,
73
74
  });
74
75
 
76
+ r.translations({ keys: COMPLIANCE_PROFILES_I18N });
77
+
75
78
  return { handlers, queries };
76
79
  });
77
80
  }
@@ -0,0 +1,6 @@
1
+ type LocalizedString = { readonly de: string; readonly en: string };
2
+
3
+ export const COMPLIANCE_PROFILES_I18N: Readonly<Record<string, LocalizedString>> = {
4
+ "screen:profile-picker.title": { de: "Compliance-Profil", en: "Compliance profile" },
5
+ "compliance-profiles:nav.profilePicker": { de: "Compliance", en: "Compliance" },
6
+ };
@@ -21,6 +21,7 @@ import { resetWrite } from "./handlers/reset.write";
21
21
  import { schemaQuery } from "./handlers/schema.query";
22
22
  import { setWrite } from "./handlers/set.write";
23
23
  import { valuesQuery } from "./handlers/values.query";
24
+ import { CONFIG_FEATURE_I18N } from "./i18n";
24
25
  import type { ConfigResolver } from "./resolver";
25
26
  import { configValueEntity } from "./table";
26
27
 
@@ -63,6 +64,8 @@ export function createConfigFeature(): FeatureDefinition {
63
64
  // legacy CONFIG_ENCRYPTION_KEY.
64
65
  r.job("reencrypt", { trigger: { manual: true } }, reencryptJob);
65
66
 
67
+ r.translations({ keys: CONFIG_FEATURE_I18N });
68
+
66
69
  return { handlers, queries };
67
70
  });
68
71
  }
@@ -0,0 +1,9 @@
1
+ type LocalizedString = { readonly de: string; readonly en: string };
2
+
3
+ /** Server boot keys for the auto-generated Settings-Hub (mirrors web/i18n.ts). */
4
+ export const CONFIG_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
5
+ "config.settings.title": { de: "Einstellungen", en: "Settings" },
6
+ "config.settings.system": { de: "Plattform", en: "Platform" },
7
+ "config.settings.tenant": { de: "Organisation", en: "Organization" },
8
+ "config.settings.user": { de: "Persönlich", en: "Personal" },
9
+ };
@@ -10,6 +10,7 @@ import { deliveryAttemptSchema } from "./events";
10
10
  import { logQuery } from "./handlers/log.query";
11
11
  import { preferencesQuery } from "./handlers/preferences.query";
12
12
  import { setPreferenceWrite } from "./handlers/set-preference.write";
13
+ import { DELIVERY_I18N } from "./i18n";
13
14
  import { deliveryRenderJob, deliverySendJob } from "./jobs";
14
15
  import {
15
16
  deliveryAttemptsTable,
@@ -134,6 +135,8 @@ export function createDeliveryFeature(): FeatureDefinition {
134
135
  order: 40,
135
136
  });
136
137
 
138
+ r.translations({ keys: DELIVERY_I18N });
139
+
137
140
  return { handlers, queries };
138
141
  });
139
142
  }
@@ -0,0 +1,6 @@
1
+ type LocalizedString = { readonly de: string; readonly en: string };
2
+
3
+ export const DELIVERY_I18N: Readonly<Record<string, LocalizedString>> = {
4
+ "screen:delivery-log.title": { de: "Delivery-Log", en: "Delivery log" },
5
+ "delivery:nav.deliveryLog": { de: "Zustellungen", en: "Delivery" },
6
+ };
@@ -4,6 +4,7 @@ import { featureToggleSetSchema } from "./events";
4
4
  import { listQuery } from "./handlers/list.query";
5
5
  import { registeredQuery } from "./handlers/registered.query";
6
6
  import { createSetWriteHandler } from "./handlers/set.write";
7
+ import { FEATURE_TOGGLES_I18N } from "./i18n";
7
8
  import type { GlobalFeatureToggleRuntime } from "./toggle-runtime";
8
9
 
9
10
  // IMPORTANT: feature-toggles itself is NOT r.toggleable. Making it
@@ -75,6 +76,8 @@ export function createFeatureTogglesFeature(
75
76
  order: 20,
76
77
  });
77
78
 
79
+ r.translations({ keys: FEATURE_TOGGLES_I18N });
80
+
78
81
  // toggle-cache-sync — multi-instance snapshot propagation. Every
79
82
  // API/worker instance runs its own dispatcher cursor on this MSP
80
83
  // (delivery: "per-instance") and converges its in-memory snapshot on
@@ -0,0 +1,6 @@
1
+ type LocalizedString = { readonly de: string; readonly en: string };
2
+
3
+ export const FEATURE_TOGGLES_I18N: Readonly<Record<string, LocalizedString>> = {
4
+ "screen:toggle-admin.title": { de: "Feature-Toggles", en: "Feature toggles" },
5
+ "feature-toggles:nav.toggleAdmin": { de: "Feature-Toggles", en: "Feature toggles" },
6
+ };
package/src/jobs/i18n.ts CHANGED
@@ -13,6 +13,10 @@ export const JOBS_I18N: Readonly<Record<string, LocalizedString>> = {
13
13
  "jobs.runs.open": { de: "Details", en: "Details" },
14
14
  "jobs.runs.filter.status": { de: "Status", en: "Status" },
15
15
  "jobs.runs.filter.all": { de: "Alle", en: "All" },
16
+ "jobs.runs.filter.completed": { de: "Abgeschlossen", en: "Completed" },
17
+ "jobs.runs.filter.failed": { de: "Fehlgeschlagen", en: "Failed" },
18
+ "jobs.runs.filter.running": { de: "Läuft", en: "Running" },
19
+ "jobs.runs.filter.queued": { de: "Wartend", en: "Queued" },
16
20
  "jobs.runs.col.job": { de: "Job", en: "Job" },
17
21
  "jobs.runs.col.status": { de: "Status", en: "Status" },
18
22
  "jobs.runs.col.started": { de: "Gestartet", en: "Started" },
@@ -24,11 +24,11 @@ type State =
24
24
  | { readonly kind: "ready"; readonly rows: readonly JobRunRow[] };
25
25
 
26
26
  const STATUS_FILTER_OPTIONS = [
27
- { value: "", label: "all" },
28
- { value: "completed", label: "completed" },
29
- { value: "failed", label: "failed" },
30
- { value: "running", label: "running" },
31
- { value: "queued", label: "queued" },
27
+ { value: "", labelKey: "jobs.runs.filter.all" },
28
+ { value: "completed", labelKey: "jobs.runs.filter.completed" },
29
+ { value: "failed", labelKey: "jobs.runs.filter.failed" },
30
+ { value: "running", labelKey: "jobs.runs.filter.running" },
31
+ { value: "queued", labelKey: "jobs.runs.filter.queued" },
32
32
  ] as const;
33
33
 
34
34
  export function JobRunsScreen(): ReactNode {
@@ -41,7 +41,7 @@ export function JobRunsScreen(): ReactNode {
41
41
 
42
42
  const filterOptions = STATUS_FILTER_OPTIONS.map((opt) => ({
43
43
  value: opt.value,
44
- label: opt.value === "" ? t("jobs.runs.filter.all") : opt.label,
44
+ label: t(opt.labelKey),
45
45
  }));
46
46
 
47
47
  const refresh = useCallback(async (): Promise<void> => {
@@ -98,13 +98,13 @@ export function JobRunsScreen(): ReactNode {
98
98
  <DataTable
99
99
  testId="job-runs-table"
100
100
  columns={[
101
- { field: "job", label: t("jobs.runs.col.job"), type: "string", sortable: false },
102
- { field: "status", label: t("jobs.runs.col.status"), type: "string", sortable: false },
101
+ { field: "job", label: t("jobs.runs.col.job"), type: "string", sortable: true },
102
+ { field: "status", label: t("jobs.runs.col.status"), type: "string", sortable: true },
103
103
  {
104
104
  field: "started",
105
105
  label: t("jobs.runs.col.started"),
106
106
  type: "string",
107
- sortable: false,
107
+ sortable: true,
108
108
  },
109
109
  {
110
110
  field: "duration",
@@ -24,6 +24,11 @@ export const MANAGED_PAGES_I18N: Readonly<Record<string, LocalizedString>> = {
24
24
  "managed-pages:entity:page:field:lang": { de: "Sprache", en: "Language" },
25
25
  "managed-pages:entity:page:field:title": { de: "Titel", en: "Title" },
26
26
  "managed-pages:entity:page:field:published": { de: "Veröffentlicht", en: "Published" },
27
+ "managed-pages:entity:page:field:published:option:true": {
28
+ de: "Veröffentlicht",
29
+ en: "Published",
30
+ },
31
+ "managed-pages:entity:page:field:published:option:false": { de: "Entwurf", en: "Draft" },
27
32
  "managed-pages:entity:page:field:description": { de: "Beschreibung", en: "Description" },
28
33
  "managed-pages:entity:page:field:ogImage": { de: "OG-Bild", en: "OG image" },
29
34
  "managed-pages:entity:page:field:body": { de: "Inhalt", en: "Content" },
@@ -24,7 +24,11 @@ export const pageListScreen: EntityListScreenDefinition = {
24
24
  "title",
25
25
  {
26
26
  field: "published",
27
- renderer: { format: "boolean", trueLabel: "Published", falseLabel: "Draft" },
27
+ renderer: {
28
+ format: "boolean",
29
+ trueLabel: "managed-pages:entity:page:field:published:option:true",
30
+ falseLabel: "managed-pages:entity:page:field:published:option:false",
31
+ },
28
32
  },
29
33
  ],
30
34
  defaultSort: { field: "slug", dir: "asc" },
@@ -5,6 +5,7 @@ import { buildAvailableScopesQuery } from "./handlers/available-scopes.query";
5
5
  import { createPatWrite } from "./handlers/create.write";
6
6
  import { listPatQuery } from "./handlers/list.query";
7
7
  import { revokePatWrite } from "./handlers/revoke.write";
8
+ import { PAT_FEATURE_I18N } from "./i18n";
8
9
  import { apiTokenEntity } from "./schema/api-token";
9
10
  import type { PatScopeConfig } from "./scopes";
10
11
 
@@ -75,6 +76,7 @@ export function createPersonalAccessTokensFeature(
75
76
  renderer: { react: { __component: "PatTokensScreen" } },
76
77
  access: { openToAll: true },
77
78
  });
79
+ r.translations({ keys: PAT_FEATURE_I18N });
78
80
 
79
81
  // scopes + rateLimit flow into feature.exports so run-prod-app builds the
80
82
  // resolver + limiter from the same declaration — single source of truth.
@@ -0,0 +1,5 @@
1
+ type LocalizedString = { readonly de: string; readonly en: string };
2
+
3
+ export const PAT_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
4
+ "screen:api-tokens.title": { de: "Personal Access Tokens", en: "Personal Access Tokens" },
5
+ };
@@ -41,7 +41,11 @@ function isoDate(value: string | null): string | null {
41
41
  return typeof value === "string" && value.length >= 10 ? value.slice(0, 10) : null;
42
42
  }
43
43
 
44
- export function PatTokensScreen(): ReactNode {
44
+ export function PatTokensScreen({
45
+ embedded = false,
46
+ }: {
47
+ readonly embedded?: boolean;
48
+ } = {}): ReactNode {
45
49
  const t = useTranslation();
46
50
  const { Form, Field, Input, Button, Banner, Card, Heading } = usePrimitives();
47
51
  const dispatcher = useDispatcher();
@@ -130,7 +134,7 @@ export function PatTokensScreen(): ReactNode {
130
134
  };
131
135
 
132
136
  return (
133
- <div className="flex max-w-3xl flex-col gap-6 p-6">
137
+ <div className={embedded ? "flex flex-col gap-6" : "flex max-w-3xl flex-col gap-6 p-6"}>
134
138
  <Heading>{t("pat.title")}</Heading>
135
139
 
136
140
  {minted && (
@@ -232,7 +236,7 @@ export function PatTokensScreen(): ReactNode {
232
236
  <div className="flex items-center justify-between gap-3">
233
237
  <div className="flex min-w-0 flex-col gap-0.5">
234
238
  <span className="truncate text-sm font-semibold">{row.name}</span>
235
- <span className="truncate text-xs text-muted-foreground">
239
+ <span className="whitespace-normal text-xs text-muted-foreground">
236
240
  {revoked ? t("pat.list.revoked") : meta(row)}
237
241
  </span>
238
242
  </div>
@@ -172,6 +172,8 @@ export function createSubscriptionStripeFeature(
172
172
  de: "Stripe Billing live",
173
173
  en: "Stripe Billing Live",
174
174
  },
175
+ "subscription-stripe.settings": { de: "Stripe", en: "Stripe" },
176
+ "screen:subscription-stripe-system.title": { de: "Stripe", en: "Stripe" },
175
177
  },
176
178
  });
177
179
 
@@ -29,6 +29,7 @@ import { createCreateTagHandler } from "./handlers/create-tag.write";
29
29
  import { createDeleteTagHandler } from "./handlers/delete-tag.write";
30
30
  import { createRemoveTagHandler } from "./handlers/remove-tag.write";
31
31
  import { createUpdateTagHandler } from "./handlers/update-tag.write";
32
+ import { TAGS_FEATURE_I18N } from "./i18n";
32
33
 
33
34
  // Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
34
35
  // dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
@@ -76,6 +77,7 @@ function registerTags(
76
77
  renderer: { react: { __component: "TagsScreen" } },
77
78
  access,
78
79
  });
80
+ r.translations({ keys: TAGS_FEATURE_I18N });
79
81
  }
80
82
 
81
83
  export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
@@ -0,0 +1,5 @@
1
+ type LocalizedString = { readonly de: string; readonly en: string };
2
+
3
+ export const TAGS_FEATURE_I18N: Readonly<Record<string, LocalizedString>> = {
4
+ "screen:tag-list.title": { de: "Tags", en: "Tags" },
5
+ };
@@ -197,12 +197,12 @@ describe("TenantAdmin can use members-admin HTTP surface", () => {
197
197
  { email: "pending@example.com", role: "Editor" },
198
198
  tenantAdminA(),
199
199
  );
200
- const members = await stack.http.queryOk<readonly { userId: string }[]>(
201
- TenantQueries.members,
202
- {},
203
- tenantAdminA(),
204
- );
200
+ const members = await stack.http.queryOk<
201
+ readonly { userId: string; email: string | null; displayName: string | null }[]
202
+ >(TenantQueries.members, {}, tenantAdminA());
205
203
  expect(members.some((m) => m.userId === tenantAdminAId)).toBe(true);
204
+ const self = members.find((m) => m.userId === tenantAdminAId);
205
+ expect(self?.email).toBe("admin-a@example.com");
206
206
  const invitations = await stack.http.queryOk<readonly { email: string }[]>(
207
207
  TenantQueries.invitations,
208
208
  {},
@@ -211,6 +211,21 @@ describe("TenantAdmin can use members-admin HTTP surface", () => {
211
211
  expect(invitations).toHaveLength(1);
212
212
  expect(invitations[0]?.email).toBe("pending@example.com");
213
213
  });
214
+
215
+ test("members email is null when user row is missing", async () => {
216
+ const orphanUserId = "00000000-0000-4000-8000-00000000dead";
217
+ await seedTenantMembership(stack.db, {
218
+ userId: orphanUserId,
219
+ tenantId: TENANT_A_ID,
220
+ roles: ["User"],
221
+ });
222
+ const members = await stack.http.queryOk<readonly { userId: string; email: string | null }[]>(
223
+ TenantQueries.members,
224
+ {},
225
+ tenantAdminA(),
226
+ );
227
+ expect(members.find((m) => m.userId === orphanUserId)?.email).toBeNull();
228
+ });
214
229
  });
215
230
 
216
231
  describe("regular User is denied members-admin surface", () => {
@@ -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";
@@ -151,6 +152,8 @@ export function createTenantFeature(): FeatureDefinition {
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,7 +8,7 @@ export {
8
8
  tenantInvitationEntity,
9
9
  tenantInvitationsTable,
10
10
  } from "./invitation-table";
11
- export { tenantMembershipsTable } from "./membership-table";
11
+ export { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table";
12
12
  export {
13
13
  TENANT_LIFECYCLE_STATUSES,
14
14
  type TenantLifecycleStatus,
@@ -24,8 +24,8 @@ export const tenantEntity = createEntity({
24
24
  // UUID sein, sonst findet der tenants-Lookup nie. Default gen_random_uuid().
25
25
  fields: {
26
26
  key: createTextField({ required: true, maxLength: 50 }),
27
- name: createTextField({ required: true, maxLength: 200, searchable: true }),
28
- isEnabled: createBooleanField({ default: true }),
27
+ name: createTextField({ required: true, maxLength: 200, searchable: true, sortable: true }),
28
+ isEnabled: createBooleanField({ default: true, filterable: true }),
29
29
  // Tenant-destroy lifecycle (tenant-lifecycle feature). Defaults keep
30
30
  // existing tenants valid when the feature is not mounted.
31
31
  status: createSelectField({