@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
@@ -10,10 +10,15 @@
10
10
  // kennt.
11
11
 
12
12
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import {
14
+ configuredPiiSubjectKms,
15
+ decryptPiiFieldValues,
16
+ } from "@cosmicdrift/kumiko-framework/crypto";
13
17
  import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
14
18
  import { z } from "zod";
15
19
  import { subscriptionAggregateId } from "../aggregate-id";
16
20
  import { SUBSCRIPTION_PROVIDER_EXTENSION } from "../constants";
21
+ import { SUBSCRIPTION_PII_FIELDS } from "../entities";
17
22
  import { subscriptionsProjectionTable as subTable } from "../projection";
18
23
  import type { SubscriptionProviderPlugin } from "../types";
19
24
 
@@ -41,8 +46,19 @@ export const createPortalSessionHandler: WriteHandlerDef = {
41
46
  "subscription-foundation: no active subscription for this tenant. Create one via create-checkout-session first.",
42
47
  );
43
48
  }
49
+ const piiKms = configuredPiiSubjectKms();
50
+ const decrypted = piiKms
51
+ ? await decryptPiiFieldValues(
52
+ row as Record<string, unknown>,
53
+ SUBSCRIPTION_PII_FIELDS,
54
+ piiKms,
55
+ {
56
+ requestId: `billing-foundation:create-portal-session:${tenantId}`,
57
+ },
58
+ )
59
+ : (row as Record<string, unknown>);
44
60
  const providerName = row["providerName"] as string; // @cast-boundary db-row
45
- const providerCustomerId = row["providerCustomerId"] as string; // @cast-boundary db-row
61
+ const providerCustomerId = decrypted["providerCustomerId"] as string; // @cast-boundary db-row
46
62
 
47
63
  // 2. Plugin-Lookup
48
64
  const usages = ctx.registry.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION);
@@ -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,
@@ -0,0 +1,47 @@
1
+ import { deleteMany, type EntityTableMeta, updateMany } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
4
+ import { resolveProfileForTenant } from "../compliance-profiles";
5
+ import { subscriptionAggregateId } from "./aggregate-id";
6
+ import { SUBSCRIPTION_AGGREGATE_TYPE } from "./events";
7
+ import { subscriptionsProjectionTable } from "./projection";
8
+
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. */
21
+ export async function subscriptionTenantDestroyHook(ctx: {
22
+ readonly db: import("@cosmicdrift/kumiko-framework/db").DbRunner;
23
+ readonly tenantId: TenantId;
24
+ }): Promise<void> {
25
+ const { profile } = await resolveProfileForTenant({
26
+ db: ctx.db,
27
+ tenantId: ctx.tenantId,
28
+ });
29
+ const aggregateId = subscriptionAggregateId(ctx.tenantId);
30
+ if (profile.key === "de-hr-dsgvo-hgb") {
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 });
39
+ }
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",
46
+ });
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>
@@ -39,6 +39,10 @@ import type {
39
39
  Subscription as MollieSubscription,
40
40
  } from "@mollie/api-client";
41
41
  import { Hono } from "hono";
42
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
43
+ import { createConfigFeature } from "../../config";
44
+ import { createTenantFeature } from "../../tenant/feature";
45
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
42
46
  import { createSubscriptionMollieFeature } from "../feature";
43
47
  import type { MollieClientShape } from "../verify-webhook";
44
48
 
@@ -133,7 +137,14 @@ beforeAll(async () => {
133
137
  });
134
138
 
135
139
  stack = await setupTestStack({
136
- features: [billingFoundationFeature, mollieFeature],
140
+ features: [
141
+ createConfigFeature(),
142
+ createTenantFeature(),
143
+ createComplianceProfilesFeature(),
144
+ createTenantLifecycleFeature(),
145
+ billingFoundationFeature,
146
+ mollieFeature,
147
+ ],
137
148
  });
138
149
  db = stack.db;
139
150
  // subscriptionsProjectionTable wird von setupTestStack automatisch
@@ -34,6 +34,7 @@ import {
34
34
  import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
35
35
  import { Hono } from "hono";
36
36
  import Stripe from "stripe";
37
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
37
38
  import { configValuesTable, createConfigFeature } from "../../config";
38
39
  import { createConfigAccessorFactory } from "../../config/feature";
39
40
  import { createConfigResolver } from "../../config/resolver";
@@ -43,6 +44,8 @@ import {
43
44
  type SecretsContext,
44
45
  tenantSecretsTable,
45
46
  } from "../../secrets";
47
+ import { createTenantFeature } from "../../tenant/feature";
48
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
46
49
  import { createSubscriptionStripeFeature } from "../feature";
47
50
 
48
51
  // Qualified-names der runtime-keys (drift-pin: müssen 1:1 dem entsprechen,
@@ -93,6 +96,9 @@ beforeAll(async () => {
93
96
  features: [
94
97
  createConfigFeature(),
95
98
  createSecretsFeature(),
99
+ createTenantFeature(),
100
+ createComplianceProfilesFeature(),
101
+ createTenantLifecycleFeature(),
96
102
  billingFoundationFeature,
97
103
  stripeFeature,
98
104
  ],
@@ -483,6 +489,9 @@ describe("scenario 6: billing-live gate end-to-end (#104)", () => {
483
489
  features: [
484
490
  createConfigFeature(),
485
491
  createSecretsFeature(),
492
+ createTenantFeature(),
493
+ createComplianceProfilesFeature(),
494
+ createTenantLifecycleFeature(),
486
495
  billingFoundationFeature,
487
496
  stripeFeature,
488
497
  ],
@@ -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
+ };
@@ -5,6 +5,7 @@ import { AuthHandlers } from "../../auth-email-password/constants";
5
5
  import { createConfigFeature } from "../../config/feature";
6
6
  import { MEMBERS_SCREEN_ID, TenantHandlers, TenantQueries } from "../constants";
7
7
  import { createTenantFeature } from "../feature";
8
+ import { defaultTranslations } from "../web/i18n";
8
9
 
9
10
  describe("tenant members screen + handler access alignment", () => {
10
11
  const features = [createConfigFeature(), createTenantFeature()];
@@ -34,6 +35,12 @@ describe("tenant members screen + handler access alignment", () => {
34
35
  void TenantQueries;
35
36
  });
36
37
 
38
+ test("members nav label uses tenantClient i18n key (not feature:nav.* alias)", () => {
39
+ const nav = createTenantFeature().navs["members"];
40
+ expect(nav?.label).toBe("tenant.nav.members");
41
+ expect(defaultTranslations["de"]?.["tenant.nav.members"]).toBe("Team");
42
+ });
43
+
37
44
  test("updateMemberRoles stays SystemAdmin/system-only (not on members screen)", () => {
38
45
  const tenant = createTenantFeature();
39
46
  expect(rolesOf(tenant.writeHandlers["updateMemberRoles"]?.access)).toEqual([
@@ -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", () => {