@cosmicdrift/kumiko-bundled-features 0.255.2 → 0.257.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.255.2",
3
+ "version": "0.257.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.255.2",
134
- "@cosmicdrift/kumiko-framework": "0.255.2",
135
- "@cosmicdrift/kumiko-headless": "0.255.2",
136
- "@cosmicdrift/kumiko-renderer": "0.255.2",
137
- "@cosmicdrift/kumiko-renderer-web": "0.255.2",
138
- "@cosmicdrift/kumiko-types": "0.255.2",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.257.0",
134
+ "@cosmicdrift/kumiko-framework": "0.257.0",
135
+ "@cosmicdrift/kumiko-headless": "0.257.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.257.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.257.0",
138
+ "@cosmicdrift/kumiko-types": "0.257.0",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.255.2",
168
- "@cosmicdrift/kumiko-locale-es": "0.255.2"
167
+ "@cosmicdrift/kumiko-locale-de": "0.257.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.257.0"
169
169
  }
170
170
  }
@@ -2,6 +2,7 @@ import {
2
2
  access,
3
3
  defineFeature,
4
4
  type FeatureDefinition,
5
+ i18nKey,
5
6
  } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import { AUDIT_LOG_DETAIL_SCREEN_ID, AUDIT_LOG_SCREEN_ID, AuditQueries } from "./constants";
7
8
  import { detailsQuery } from "./handlers/details.query";
@@ -44,9 +45,13 @@ export function createAuditFeature(): FeatureDefinition {
44
45
  type: "projectionList",
45
46
  query: AuditQueries.list,
46
47
  columns: [
47
- { field: "createdAt", label: "audit.log.col.when", renderer: { format: "timestamp" } },
48
- { field: "type", label: "audit.log.col.type" },
49
- { field: "createdBy", label: "audit.log.col.actor" },
48
+ {
49
+ field: "createdAt",
50
+ label: i18nKey("audit.log.col.when"),
51
+ renderer: { format: "timestamp" },
52
+ },
53
+ { field: "type", label: i18nKey("audit.log.col.type") },
54
+ { field: "createdBy", label: i18nKey("audit.log.col.actor") },
50
55
  ],
51
56
  searchable: true,
52
57
  defaultSort: { field: "createdAt", dir: "desc" },
@@ -54,7 +59,7 @@ export function createAuditFeature(): FeatureDefinition {
54
59
  {
55
60
  kind: "navigate",
56
61
  id: "details",
57
- label: "audit.log.details",
62
+ label: i18nKey("audit.log.details"),
58
63
  screen: AUDIT_LOG_DETAIL_SCREEN_ID,
59
64
  entityId: "id",
60
65
  rowClick: true,
@@ -71,14 +76,14 @@ export function createAuditFeature(): FeatureDefinition {
71
76
  query: AuditQueries.details,
72
77
  idParam: "id",
73
78
  fieldLabels: {
74
- type: "audit.log.col.type",
75
- createdAt: "audit.log.col.when",
76
- aggregateType: "audit.log.col.aggregateType",
77
- aggregateId: "audit.log.col.aggregateId",
78
- createdBy: "audit.log.col.actor",
79
- id: "audit.log.detail.field.id",
80
- payload: "audit.log.detail.payload",
81
- metadata: "audit.log.detail.metadata",
79
+ type: i18nKey("audit.log.col.type"),
80
+ createdAt: i18nKey("audit.log.col.when"),
81
+ aggregateType: i18nKey("audit.log.col.aggregateType"),
82
+ aggregateId: i18nKey("audit.log.col.aggregateId"),
83
+ createdBy: i18nKey("audit.log.col.actor"),
84
+ id: i18nKey("audit.log.detail.field.id"),
85
+ payload: i18nKey("audit.log.detail.payload"),
86
+ metadata: i18nKey("audit.log.detail.metadata"),
82
87
  },
83
88
  layout: {
84
89
  sections: [
@@ -3,6 +3,7 @@ import {
3
3
  access,
4
4
  defineFeature,
5
5
  type FeatureDefinition,
6
+ i18nKey,
6
7
  } from "@cosmicdrift/kumiko-framework/engine";
7
8
  import type { z } from "zod";
8
9
  import {
@@ -145,16 +146,16 @@ export function createDeliveryFeature(options?: DeliveryFeatureOptions): Feature
145
146
  columns: [
146
147
  {
147
148
  field: "tenantId",
148
- label: "delivery.log.col.tenantId",
149
+ label: i18nKey("delivery.log.col.tenantId"),
149
150
  refEntity: "tenant:tenant",
150
151
  refLabelField: "name",
151
152
  },
152
- { field: "type", label: "delivery.log.col.type" },
153
- { field: "channel", label: "delivery.log.col.channel" },
154
- { field: "recipient", label: "delivery.log.col.recipient" },
153
+ { field: "type", label: i18nKey("delivery.log.col.type") },
154
+ { field: "channel", label: i18nKey("delivery.log.col.channel") },
155
+ { field: "recipient", label: i18nKey("delivery.log.col.recipient") },
155
156
  {
156
157
  field: "status",
157
- label: "delivery.log.col.status",
158
+ label: i18nKey("delivery.log.col.status"),
158
159
  renderer: { react: { __component: DELIVERY_STATUS_CELL_COMPONENT } },
159
160
  },
160
161
  ],
@@ -1,4 +1,8 @@
1
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ defineFeature,
3
+ type FeatureDefinition,
4
+ i18nKey,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
2
6
  import { JOB_RUN_DETAIL_SCREEN_ID, JOB_RUNS_SCREEN_ID, JobHandlers, JobQueries } from "./constants";
3
7
  import { catalogQuery } from "./handlers/catalog.query";
4
8
  import { detailQuery } from "./handlers/detail.query";
@@ -119,22 +123,26 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
119
123
  type: "projectionList",
120
124
  query: JobQueries.list,
121
125
  columns: [
122
- { field: "jobName", label: "jobs.runs.col.job" },
123
- { field: "status", label: "jobs.runs.col.status" },
124
- { field: "startedAt", label: "jobs.runs.col.started", renderer: { format: "timestamp" } },
125
- { field: "duration", label: "jobs.runs.col.duration" },
126
+ { field: "jobName", label: i18nKey("jobs.runs.col.job") },
127
+ { field: "status", label: i18nKey("jobs.runs.col.status") },
128
+ {
129
+ field: "startedAt",
130
+ label: i18nKey("jobs.runs.col.started"),
131
+ renderer: { format: "timestamp" },
132
+ },
133
+ { field: "duration", label: i18nKey("jobs.runs.col.duration") },
126
134
  ],
127
135
  defaultSort: { field: "startedAt", dir: "desc" },
128
136
  facets: [
129
137
  {
130
138
  field: "status",
131
139
  type: "select",
132
- label: "jobs.runs.filter.status",
140
+ label: i18nKey("jobs.runs.filter.status"),
133
141
  options: [
134
- { value: "queued", label: "jobs.runs.filter.queued" },
135
- { value: "running", label: "jobs.runs.filter.running" },
136
- { value: "completed", label: "jobs.runs.filter.completed" },
137
- { value: "failed", label: "jobs.runs.filter.failed" },
142
+ { value: "queued", label: i18nKey("jobs.runs.filter.queued") },
143
+ { value: "running", label: i18nKey("jobs.runs.filter.running") },
144
+ { value: "completed", label: i18nKey("jobs.runs.filter.completed") },
145
+ { value: "failed", label: i18nKey("jobs.runs.filter.failed") },
138
146
  ],
139
147
  },
140
148
  ],
@@ -142,7 +150,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
142
150
  {
143
151
  kind: "navigate",
144
152
  id: "open",
145
- label: "jobs.runs.open",
153
+ label: i18nKey("jobs.runs.open"),
146
154
  screen: JOB_RUN_DETAIL_SCREEN_ID,
147
155
  entityId: "id",
148
156
  rowClick: true,
@@ -152,7 +160,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
152
160
  {
153
161
  kind: "drawer",
154
162
  id: "trigger",
155
- label: "jobs.trigger.title",
163
+ label: i18nKey("jobs.trigger.title"),
156
164
  screen: "job-trigger",
157
165
  style: "primary",
158
166
  },
@@ -169,7 +177,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
169
177
  payload: { type: "longText", multiline: true, default: "{}" },
170
178
  },
171
179
  layout: { sections: [{ fields: ["jobName", "payload"] }] },
172
- submitLabel: "jobs.trigger.submit",
180
+ submitLabel: i18nKey("jobs.trigger.submit"),
173
181
  access: systemAdminAccess,
174
182
  description: "Manually trigger a job by name with an optional JSON object payload.",
175
183
  });
@@ -179,14 +187,14 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
179
187
  query: JobQueries.details,
180
188
  idParam: "runId",
181
189
  fieldLabels: {
182
- jobName: "jobs.detail.field.job",
183
- status: "jobs.detail.field.status",
184
- id: "jobs.detail.field.id",
185
- startedAt: "jobs.detail.field.started",
186
- finishedAt: "jobs.detail.field.finished",
187
- duration: "jobs.detail.field.duration",
188
- error: "jobs.detail.field.error",
189
- logs: "jobs.detail.logs",
190
+ jobName: i18nKey("jobs.detail.field.job"),
191
+ status: i18nKey("jobs.detail.field.status"),
192
+ id: i18nKey("jobs.detail.field.id"),
193
+ startedAt: i18nKey("jobs.detail.field.started"),
194
+ finishedAt: i18nKey("jobs.detail.field.finished"),
195
+ duration: i18nKey("jobs.detail.field.duration"),
196
+ error: i18nKey("jobs.detail.field.error"),
197
+ logs: i18nKey("jobs.detail.logs"),
190
198
  },
191
199
  layout: {
192
200
  sections: [
@@ -213,7 +221,7 @@ export function createJobsFeature(options: JobsFeatureOptions = {}): FeatureDefi
213
221
  {
214
222
  kind: "writeHandler",
215
223
  id: "retry",
216
- label: "jobs.detail.retry",
224
+ label: i18nKey("jobs.detail.retry"),
217
225
  handler: JobHandlers.retry,
218
226
  payload: { map: { runId: "id" } },
219
227
  visible: { field: "status", eq: "failed" },
@@ -64,7 +64,12 @@ async function createAccount(name: string, type: AccountType, user = admin): Pro
64
64
 
65
65
  async function createTransaction(
66
66
  lines: readonly Posting[],
67
- opts: { date?: string; description?: string } = {},
67
+ opts: {
68
+ date?: string;
69
+ description?: string;
70
+ subjectType?: string;
71
+ subjectId?: string;
72
+ } = {},
68
73
  user = admin,
69
74
  ): Promise<{ id: string }> {
70
75
  return stack.http.writeOk<{ id: string }>(
@@ -73,15 +78,20 @@ async function createTransaction(
73
78
  date: opts.date ?? "2026-01-15",
74
79
  description: opts.description ?? "Test entry",
75
80
  lines,
81
+ ...(opts.subjectType !== undefined && { subjectType: opts.subjectType }),
82
+ ...(opts.subjectId !== undefined && { subjectId: opts.subjectId }),
76
83
  },
77
84
  user,
78
85
  );
79
86
  }
80
87
 
81
- async function listTransactions(user = admin): Promise<Array<Record<string, unknown>>> {
88
+ async function listTransactions(
89
+ user = admin,
90
+ query: Record<string, unknown> = {},
91
+ ): Promise<Array<Record<string, unknown>>> {
82
92
  const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
83
93
  LedgerQueries.transactionList,
84
- {},
94
+ query,
85
95
  user,
86
96
  );
87
97
  return res.rows;
@@ -350,7 +360,13 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
350
360
  async function createSchedule(
351
361
  debitAccountId: string,
352
362
  creditAccountId: string,
353
- over: { amount?: number; description?: string; startDate?: string } = {},
363
+ over: {
364
+ amount?: number;
365
+ description?: string;
366
+ startDate?: string;
367
+ subjectType?: string;
368
+ subjectId?: string;
369
+ } = {},
354
370
  ): Promise<string> {
355
371
  const s = await stack.http.writeOk<{ id: string }>(
356
372
  LedgerHandlers.createSchedule,
@@ -361,6 +377,8 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
361
377
  amount: over.amount ?? 50000,
362
378
  debitAccountId,
363
379
  creditAccountId,
380
+ ...(over.subjectType !== undefined && { subjectType: over.subjectType }),
381
+ ...(over.subjectId !== undefined && { subjectId: over.subjectId }),
364
382
  },
365
383
  admin,
366
384
  );
@@ -457,4 +475,93 @@ describe("ledger integration — confirm-schedule-period (recurring)", () => {
457
475
  // Books net to zero (original cancels Storno) plus the fresh 48000 confirmation.
458
476
  expect(trialBalance(rows)).toBe(0);
459
477
  });
478
+
479
+ test("confirming a period on a schedule with a subject stamps the same subject onto the transaction", async () => {
480
+ const bank = await createAccount("Bank", "asset");
481
+ const rent = await createAccount("Mieterträge", "income");
482
+ const scheduleId = await createSchedule(bank, rent, {
483
+ subjectType: "lease",
484
+ subjectId: "lease-1",
485
+ });
486
+
487
+ await confirm(scheduleId, "2026-01");
488
+
489
+ const rows = await listTransactions();
490
+ expect(rows[0]?.["subjectType"]).toBe("lease");
491
+ expect(rows[0]?.["subjectId"]).toBe("lease-1");
492
+ });
493
+
494
+ test("confirming a period on a schedule without a subject leaves the transaction without one", async () => {
495
+ const bank = await createAccount("Bank", "asset");
496
+ const rent = await createAccount("Mieterträge", "income");
497
+ const scheduleId = await createSchedule(bank, rent);
498
+
499
+ await confirm(scheduleId, "2026-01");
500
+
501
+ const rows = await listTransactions();
502
+ expect(rows[0]?.["subjectType"]).toBeNull();
503
+ expect(rows[0]?.["subjectId"]).toBeNull();
504
+ });
505
+ });
506
+
507
+ describe("ledger integration — subject dimension (filterable business-object reference)", () => {
508
+ test("a booking with subjectType/subjectId round-trips and is findable via an eq filter", async () => {
509
+ const bank = await createAccount("Bank", "asset");
510
+ const rent = await createAccount("Mieterträge", "income");
511
+
512
+ const tx = await createTransaction(
513
+ [
514
+ { accountId: bank, amount: 100000 },
515
+ { accountId: rent, amount: -100000 },
516
+ ],
517
+ { description: "Miete WE1", subjectType: "lease", subjectId: "lease-1" },
518
+ );
519
+ await createTransaction(
520
+ [
521
+ { accountId: bank, amount: 60000 },
522
+ { accountId: rent, amount: -60000 },
523
+ ],
524
+ { description: "Miete WE2", subjectType: "lease", subjectId: "lease-2" },
525
+ );
526
+
527
+ const rows = await listTransactions(admin, {
528
+ filter: { field: "subjectId", op: "eq", value: "lease-1" },
529
+ });
530
+ expect(rows).toHaveLength(1);
531
+ expect(rows[0]?.["id"]).toBe(tx.id);
532
+ expect(rows[0]?.["subjectType"]).toBe("lease");
533
+ expect(rows[0]?.["subjectId"]).toBe("lease-1");
534
+ });
535
+
536
+ test("an in-filter over multiple subjectId values returns exactly the matching bookings", async () => {
537
+ const bank = await createAccount("Bank", "asset");
538
+ const rent = await createAccount("Mieterträge", "income");
539
+
540
+ await createTransaction(
541
+ [
542
+ { accountId: bank, amount: 10000 },
543
+ { accountId: rent, amount: -10000 },
544
+ ],
545
+ { subjectType: "lease", subjectId: "lease-1" },
546
+ );
547
+ await createTransaction(
548
+ [
549
+ { accountId: bank, amount: 20000 },
550
+ { accountId: rent, amount: -20000 },
551
+ ],
552
+ { subjectType: "lease", subjectId: "lease-2" },
553
+ );
554
+ await createTransaction(
555
+ [
556
+ { accountId: bank, amount: 30000 },
557
+ { accountId: rent, amount: -30000 },
558
+ ],
559
+ { subjectType: "lease", subjectId: "lease-3" },
560
+ );
561
+
562
+ const rows = await listTransactions(admin, {
563
+ filters: [{ field: "subjectId", op: "in", value: ["lease-1", "lease-3"] }],
564
+ });
565
+ expect(rows.map((r) => r["subjectId"]).sort()).toEqual(["lease-1", "lease-3"]);
566
+ });
460
567
  });
@@ -78,7 +78,22 @@ export const transactionEntity = createEntity({
78
78
  },
79
79
  { required: true },
80
80
  ),
81
+ // Optional business-object reference (e.g. a lease contract) so entries can
82
+ // be filtered by what they're about, not just grepped out of `reference`.
83
+ subjectType: createTextField({
84
+ maxLength: 64,
85
+ personal: false,
86
+ reason: "technical_reference",
87
+ filterable: true,
88
+ }),
89
+ subjectId: createTextField({
90
+ maxLength: 128,
91
+ personal: false,
92
+ reason: "technical_reference",
93
+ filterable: true,
94
+ }),
81
95
  },
96
+ indexes: [{ columns: ["tenantId", "subjectType", "subjectId"] }],
82
97
  });
83
98
 
84
99
  // schedule — a recurring booking template (Dauerauftrag): "book `amount` from
@@ -116,5 +131,20 @@ export const scheduleEntity = createEntity({
116
131
  personal: false,
117
132
  reason: "technical_reference",
118
133
  }),
134
+ // Confirmed periods inherit this from the schedule (see
135
+ // confirm-schedule-period.write.ts) rather than repeating it per period.
136
+ subjectType: createTextField({
137
+ maxLength: 64,
138
+ personal: false,
139
+ reason: "technical_reference",
140
+ filterable: true,
141
+ }),
142
+ subjectId: createTextField({
143
+ maxLength: 128,
144
+ personal: false,
145
+ reason: "technical_reference",
146
+ filterable: true,
147
+ }),
119
148
  },
149
+ indexes: [{ columns: ["tenantId", "subjectType", "subjectId"] }],
120
150
  });
@@ -96,6 +96,11 @@ export function createConfirmSchedulePeriodHandler(
96
96
  }
97
97
 
98
98
  const amount = payload.amount ?? Number(schedule["amount"]);
99
+ // The subject is a property of the schedule, not the confirm call — a
100
+ // period without one stays without one, no backfill/error.
101
+ const subjectType =
102
+ typeof schedule["subjectType"] === "string" ? schedule["subjectType"] : null;
103
+ const subjectId = typeof schedule["subjectId"] === "string" ? schedule["subjectId"] : null;
99
104
  const created = await transactionExecutor.create(
100
105
  {
101
106
  id: generateId(),
@@ -107,6 +112,8 @@ export function createConfirmSchedulePeriodHandler(
107
112
  { accountId: debitAccountId, amount },
108
113
  { accountId: creditAccountId, amount: -amount },
109
114
  ],
115
+ subjectType,
116
+ subjectId,
110
117
  },
111
118
  event.user,
112
119
  ctx.db,
@@ -35,6 +35,8 @@ export function createCreateTransactionHandler(
35
35
  reference: payload.reference ?? null,
36
36
  status: payload.status ?? "posted",
37
37
  lines: payload.lines,
38
+ subjectType: payload.subjectType ?? null,
39
+ subjectId: payload.subjectId ?? null,
38
40
  },
39
41
  event.user,
40
42
  ctx.db,
@@ -27,6 +27,9 @@ export const createTransactionPayloadSchema = z
27
27
  // the Soll/recurring work — Phase 0 only ever creates posted entries.
28
28
  status: z.enum(TRANSACTION_STATUS).optional(),
29
29
  lines: z.array(postingSchema).min(2),
30
+ // Business-object reference (e.g. a lease contract) this entry is about.
31
+ subjectType: z.string().max(64).optional(),
32
+ subjectId: z.string().max(128).optional(),
30
33
  })
31
34
  .refine((p) => sumIsZero(p.lines), {
32
35
  message: "Transaction must balance: Σ of posting amounts must equal 0",
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  access,
3
+ i18nKey,
3
4
  type ProjectionDetailScreenDefinition,
4
5
  type ProjectionListScreenDefinition,
5
6
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -16,26 +17,26 @@ export const sessionListScreen: ProjectionListScreenDefinition = {
16
17
  // be present once the query accepts `sort` (fw#2230).
17
18
  defaultSort: { field: "createdAt", dir: "desc" },
18
19
  columns: [
19
- { field: "id", label: "sessions.list.col.id" },
20
+ { field: "id", label: i18nKey("sessions.list.col.id") },
20
21
  {
21
22
  field: "userId",
22
- label: "sessions.list.col.userId",
23
+ label: i18nKey("sessions.list.col.userId"),
23
24
  refEntity: "user:user",
24
25
  refLabelField: "displayName",
25
26
  },
26
27
  {
27
28
  field: "createdAt",
28
- label: "sessions.list.col.createdAt",
29
+ label: i18nKey("sessions.list.col.createdAt"),
29
30
  renderer: { format: "timestamp" },
30
31
  },
31
32
  {
32
33
  field: "expiresAt",
33
- label: "sessions.list.col.expiresAt",
34
+ label: i18nKey("sessions.list.col.expiresAt"),
34
35
  renderer: { format: "timestamp" },
35
36
  },
36
37
  {
37
38
  field: "revokedAt",
38
- label: "sessions.list.col.revokedAt",
39
+ label: i18nKey("sessions.list.col.revokedAt"),
39
40
  renderer: { format: "timestamp" },
40
41
  },
41
42
  ],
@@ -43,7 +44,7 @@ export const sessionListScreen: ProjectionListScreenDefinition = {
43
44
  {
44
45
  kind: "navigate",
45
46
  id: "open",
46
- label: "sessions.list.action.open",
47
+ label: i18nKey("sessions.list.action.open"),
47
48
  screen: SESSION_DETAIL_SCREEN_ID,
48
49
  entityId: "id",
49
50
  rowClick: true,
@@ -77,13 +78,13 @@ export const sessionDetailScreen: ProjectionDetailScreenDefinition = {
77
78
  ],
78
79
  },
79
80
  fieldLabels: {
80
- id: "sessions.detail.field.id",
81
- userId: "sessions.detail.field.userId",
82
- createdAt: "sessions.detail.field.createdAt",
83
- expiresAt: "sessions.detail.field.expiresAt",
84
- revokedAt: "sessions.detail.field.revokedAt",
85
- ip: "sessions.detail.field.ip",
86
- userAgent: "sessions.detail.field.userAgent",
81
+ id: i18nKey("sessions.detail.field.id"),
82
+ userId: i18nKey("sessions.detail.field.userId"),
83
+ createdAt: i18nKey("sessions.detail.field.createdAt"),
84
+ expiresAt: i18nKey("sessions.detail.field.expiresAt"),
85
+ revokedAt: i18nKey("sessions.detail.field.revokedAt"),
86
+ ip: i18nKey("sessions.detail.field.ip"),
87
+ userAgent: i18nKey("sessions.detail.field.userAgent"),
87
88
  },
88
89
  access: listAccess,
89
90
  };
@@ -1,7 +1,8 @@
1
- import type {
2
- AccessRule,
3
- EntityEditScreenDefinition,
4
- EntityListScreenDefinition,
1
+ import {
2
+ type AccessRule,
3
+ type EntityEditScreenDefinition,
4
+ type EntityListScreenDefinition,
5
+ i18nKey,
5
6
  } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import { DEFAULT_TAG_ACCESS, TAGS_EDIT_SCREEN_ID, TAGS_SCREEN_ID } from "./constants";
7
8
 
@@ -23,18 +24,18 @@ export function createTagListScreen(
23
24
  {
24
25
  kind: "navigate",
25
26
  id: "edit",
26
- label: "kumiko.actions.edit",
27
+ label: i18nKey("kumiko.actions.edit"),
27
28
  screen: TAGS_EDIT_SCREEN_ID,
28
29
  entityId: "id",
29
30
  },
30
31
  {
31
32
  kind: "writeHandler",
32
33
  id: "delete",
33
- label: "kumiko.actions.delete",
34
+ label: i18nKey("kumiko.actions.delete"),
34
35
  // Convention QN — same cascade body as legacy tags:write:delete-tag.
35
36
  handler: "tags:write:tag:delete",
36
37
  payload: { pick: ["id"] },
37
- confirm: "kumiko.actions.delete-confirm",
38
+ confirm: i18nKey("kumiko.actions.delete-confirm"),
38
39
  style: "danger",
39
40
  },
40
41
  ],
@@ -7,6 +7,7 @@ import {
7
7
  defineEntityUpdateHandler,
8
8
  defineFeature,
9
9
  type FeatureDefinition,
10
+ i18nKey,
10
11
  } from "@cosmicdrift/kumiko-framework/engine";
11
12
  import { activeTenantIdsQuery } from "./handlers/active-tenant-ids.query";
12
13
  import { addMemberWrite } from "./handlers/add-member.write";
@@ -176,7 +177,7 @@ export function createTenantFeature(options?: TenantFeatureOptions): FeatureDefi
176
177
  }
177
178
  r.nav({
178
179
  id: "members",
179
- label: "tenant.nav.members",
180
+ label: i18nKey("tenant.nav.members"),
180
181
  icon: "users",
181
182
  screen: "tenant:screen:members",
182
183
  order: 20,
@@ -3,6 +3,7 @@ import {
3
3
  access,
4
4
  type EntityEditScreenDefinition,
5
5
  type EntityListScreenDefinition,
6
+ i18nKey,
6
7
  type ScreenDefinition,
7
8
  } from "@cosmicdrift/kumiko-framework/engine";
8
9
  import {
@@ -35,7 +36,7 @@ export const tenantListScreen: EntityListScreenDefinition = {
35
36
  {
36
37
  kind: "navigate",
37
38
  id: "edit",
38
- label: "kumiko.actions.edit",
39
+ label: i18nKey("kumiko.actions.edit"),
39
40
  screen: "tenant-edit",
40
41
  entityId: "id",
41
42
  },
@@ -81,25 +82,25 @@ export function createMembersScreen(options?: {
81
82
  type: "projectionList",
82
83
  query: TenantQueries.teamList,
83
84
  columns: [
84
- { field: "email", label: "tenant.members.col.email" },
85
+ { field: "email", label: i18nKey("tenant.members.col.email") },
85
86
  {
86
87
  field: "roles",
87
- label: "tenant.members.col.roles",
88
+ label: i18nKey("tenant.members.col.roles"),
88
89
  renderer: { react: { __component: MEMBER_ROLES_CELL_COMPONENT } },
89
90
  },
90
91
  {
91
92
  field: "status",
92
- label: "tenant.members.col.status",
93
+ label: i18nKey("tenant.members.col.status"),
93
94
  renderer: { react: { __component: MEMBER_STATUS_CELL_COMPONENT } },
94
95
  },
95
96
  {
96
97
  field: "createdAt",
97
- label: "tenant.members.col.created",
98
+ label: i18nKey("tenant.members.col.created"),
98
99
  renderer: { format: "timestamp" },
99
100
  },
100
101
  {
101
102
  field: "lastSeenAt",
102
- label: "tenant.members.col.lastActivity",
103
+ label: i18nKey("tenant.members.col.lastActivity"),
103
104
  renderer: { format: "timestamp" },
104
105
  },
105
106
  ],
@@ -109,10 +110,10 @@ export function createMembersScreen(options?: {
109
110
  {
110
111
  field: "status",
111
112
  type: "select",
112
- label: "tenant.members.filter.status",
113
+ label: i18nKey("tenant.members.filter.status"),
113
114
  options: [
114
- { value: "active", label: "tenant.members.filter.status.option.active" },
115
- { value: "pending", label: "tenant.members.filter.status.option.pending" },
115
+ { value: "active", label: i18nKey("tenant.members.filter.status.option.active") },
116
+ { value: "pending", label: i18nKey("tenant.members.filter.status.option.pending") },
116
117
  ],
117
118
  },
118
119
  ],
@@ -120,7 +121,7 @@ export function createMembersScreen(options?: {
120
121
  {
121
122
  kind: "navigate",
122
123
  id: "edit-roles",
123
- label: "tenant.members.actions.editRoles",
124
+ label: i18nKey("tenant.members.actions.editRoles"),
124
125
  screen: MEMBER_ROLES_EDIT_SCREEN_ID,
125
126
  params: { map: { userId: "userId", roles: "roles" } },
126
127
  visible: { field: "status", eq: "active" },
@@ -128,10 +129,10 @@ export function createMembersScreen(options?: {
128
129
  {
129
130
  kind: "writeHandler",
130
131
  id: "cancel-invitation",
131
- label: "tenant.members.cancel",
132
+ label: i18nKey("tenant.members.cancel"),
132
133
  handler: TenantHandlers.cancelInvitation,
133
134
  payload: { map: { invitationId: "id" } },
134
- confirm: "tenant.members.cancel.confirm",
135
+ confirm: i18nKey("tenant.members.cancel.confirm"),
135
136
  style: "danger",
136
137
  visible: { field: "status", eq: "pending" },
137
138
  },
@@ -141,7 +142,7 @@ export function createMembersScreen(options?: {
141
142
  {
142
143
  kind: "drawer",
143
144
  id: "invite",
144
- label: "tenant.members.invite.title",
145
+ label: i18nKey("tenant.members.invite.title"),
145
146
  screen: INVITE_CREATE_SCREEN_ID,
146
147
  style: "primary",
147
148
  },
@@ -168,7 +169,7 @@ export const inviteCreateScreen = {
168
169
  layout: {
169
170
  sections: [{ fields: ["email", "role"] }],
170
171
  },
171
- submitLabel: "tenant.members.invite.submit",
172
+ submitLabel: i18nKey("tenant.members.invite.submit"),
172
173
  access: { roles: access.admin },
173
174
  } satisfies ScreenDefinition;
174
175
 
@@ -190,6 +191,6 @@ export const memberRolesEditScreen = {
190
191
  // Prefill userId from rowAction; readOnly so the operator cannot retarget.
191
192
  sections: [{ fields: [{ field: "userId", readOnly: true }, "roles"] }],
192
193
  },
193
- submitLabel: "tenant.members.roles.edit.submit",
194
+ submitLabel: i18nKey("tenant.members.roles.edit.submit"),
194
195
  access: { roles: access.admin },
195
196
  } satisfies ScreenDefinition;
@@ -1,6 +1,7 @@
1
- import type {
2
- EntityEditScreenDefinition,
3
- EntityListScreenDefinition,
1
+ import {
2
+ type EntityEditScreenDefinition,
3
+ type EntityListScreenDefinition,
4
+ i18nKey,
4
5
  } from "@cosmicdrift/kumiko-framework/engine";
5
6
 
6
7
  // Cross-tenant platform admin view of the user identity. Because the user
@@ -21,7 +22,7 @@ export const userListScreen: EntityListScreenDefinition = {
21
22
  {
22
23
  kind: "navigate",
23
24
  id: "edit",
24
- label: "kumiko.actions.edit",
25
+ label: i18nKey("kumiko.actions.edit"),
25
26
  screen: "user-edit",
26
27
  entityId: "id",
27
28
  },
@@ -4,6 +4,7 @@ import {
4
4
  defineFeature,
5
5
  EXT_USER_DATA,
6
6
  type FeatureDefinition,
7
+ i18nKey,
7
8
  SYSTEM_USER_ID,
8
9
  } from "@cosmicdrift/kumiko-framework/engine";
9
10
  import { validateGdprHookCompleteness, validateGdprPiiHookCoverage } from "./boot-checks";
@@ -300,6 +301,27 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
300
301
  });
301
302
 
302
303
  r.translations({ keys: USER_DATA_RIGHTS_I18N });
304
+ // Privacy-center dot-form labels (i18nKey()-marked below), duplicated in
305
+ // web/i18n.ts's client bundle since privacy-center-screen.tsx renders
306
+ // them client-side.
307
+ r.translations({
308
+ keys: {
309
+ "userDataRights.privacyCenter.field.status": { en: "Account status" },
310
+ "userDataRights.privacyCenter.field.gracePeriodEnd": { en: "Scheduled deletion date" },
311
+ "userDataRights.privacyCenter.export.title": { en: "Export your data (Art. 20)" },
312
+ "userDataRights.privacyCenter.restriction.title": { en: "Restrict processing (Art. 18)" },
313
+ "userDataRights.privacyCenter.restriction.restrict": { en: "Restrict account" },
314
+ "userDataRights.privacyCenter.restriction.dialogDescription": {
315
+ en: "You will be signed out immediately and cannot sign in again until support lifts the restriction.",
316
+ },
317
+ "userDataRights.privacyCenter.deletion.title": { en: "Delete account (Art. 17)" },
318
+ "userDataRights.privacyCenter.deletion.delete": { en: "Delete account" },
319
+ "userDataRights.privacyCenter.deletion.cancel": { en: "Cancel deletion" },
320
+ "userDataRights.privacyCenter.deletion.dialogDescription": {
321
+ en: "Confirming starts the deletion grace period. You can cancel the deletion until it ends.",
322
+ },
323
+ },
324
+ });
303
325
 
304
326
  // Self-service screen (Art. 15/17/18/20): export, restriction, deletion
305
327
  // in one projectionDetail screen bound to the user's own `me` row. No
@@ -326,14 +348,14 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
326
348
  description:
327
349
  "Logged-in GDPR self-service page where a user requests and downloads a data export (Art. 20), restricts processing of their account (Art. 18) and requests its deletion (Art. 17).",
328
350
  fieldLabels: {
329
- status: "userDataRights.privacyCenter.field.status",
330
- gracePeriodEnd: "userDataRights.privacyCenter.field.gracePeriodEnd",
351
+ status: i18nKey("userDataRights.privacyCenter.field.status"),
352
+ gracePeriodEnd: i18nKey("userDataRights.privacyCenter.field.gracePeriodEnd"),
331
353
  },
332
354
  layout: {
333
355
  sections: [
334
356
  {
335
357
  kind: "extension",
336
- title: "userDataRights.privacyCenter.export.title",
358
+ title: i18nKey("userDataRights.privacyCenter.export.title"),
337
359
  component: { react: { __component: EXPORT_SECTION_EXTENSION_NAME } },
338
360
  entityName: "export-job",
339
361
  },
@@ -342,7 +364,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
342
364
  // section always on the screen — Deletion is dropped entirely
343
365
  // when `showDeletion` is false, and status covers restriction
344
366
  // AND deletion state, so it can't hang off the optional section.
345
- title: "userDataRights.privacyCenter.restriction.title",
367
+ title: i18nKey("userDataRights.privacyCenter.restriction.title"),
346
368
  description: "userDataRights.privacyCenter.restriction.explainer",
347
369
  fields: [
348
370
  {
@@ -354,7 +376,7 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
354
376
  ...(showDeletion
355
377
  ? [
356
378
  {
357
- title: "userDataRights.privacyCenter.deletion.title",
379
+ title: i18nKey("userDataRights.privacyCenter.deletion.title"),
358
380
  description: "userDataRights.privacyCenter.deletion.explainer",
359
381
  // gracePeriodEnd is only meaningful once a deletion is
360
382
  // actually pending — hide it instead of showing an empty
@@ -374,9 +396,9 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
374
396
  actions: [
375
397
  {
376
398
  id: "restrict",
377
- label: "userDataRights.privacyCenter.restriction.restrict",
399
+ label: i18nKey("userDataRights.privacyCenter.restriction.restrict"),
378
400
  handler: UserDataRightsHandlers.restrictAccount,
379
- confirm: "userDataRights.privacyCenter.restriction.dialogDescription",
401
+ confirm: i18nKey("userDataRights.privacyCenter.restriction.dialogDescription"),
380
402
  visible: { field: "status", ne: "restricted" },
381
403
  style: "danger",
382
404
  },
@@ -384,15 +406,15 @@ export function createUserDataRightsFeature(opts: UserDataRightsOptions = {}): F
384
406
  ? [
385
407
  {
386
408
  id: "request-deletion",
387
- label: "userDataRights.privacyCenter.deletion.delete",
409
+ label: i18nKey("userDataRights.privacyCenter.deletion.delete"),
388
410
  handler: UserDataRightsHandlers.requestDeletion,
389
- confirm: "userDataRights.privacyCenter.deletion.dialogDescription",
411
+ confirm: i18nKey("userDataRights.privacyCenter.deletion.dialogDescription"),
390
412
  visible: { field: "status", ne: "deletionRequested" },
391
413
  style: "danger" as const,
392
414
  },
393
415
  {
394
416
  id: "cancel-deletion",
395
- label: "userDataRights.privacyCenter.deletion.cancel",
417
+ label: i18nKey("userDataRights.privacyCenter.deletion.cancel"),
396
418
  handler: UserDataRightsHandlers.cancelDeletion,
397
419
  visible: { field: "status", eq: "deletionRequested" },
398
420
  style: "secondary" as const,
@@ -2,6 +2,7 @@ import {
2
2
  access,
3
3
  type EntityEditScreenDefinition,
4
4
  type EntityListScreenDefinition,
5
+ i18nKey,
5
6
  } from "@cosmicdrift/kumiko-framework/engine";
6
7
 
7
8
  // Read-only operator inspector for the GDPR data-rights read-models. All
@@ -23,7 +24,7 @@ export const exportJobListScreen: EntityListScreenDefinition = {
23
24
  {
24
25
  kind: "navigate",
25
26
  id: "view",
26
- label: "kumiko.actions.view",
27
+ label: i18nKey("kumiko.actions.view"),
27
28
  screen: "export-job-detail",
28
29
  entityId: "id",
29
30
  },
@@ -1,4 +1,8 @@
1
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ defineFeature,
3
+ type FeatureDefinition,
4
+ i18nKey,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
2
6
  import {
3
7
  CHANGE_EMAIL_SECTION_EXTENSION_NAME,
4
8
  CHANGE_PASSWORD_SECTION_EXTENSION_NAME,
@@ -38,12 +42,26 @@ export function createUserProfileFeature(): FeatureDefinition {
38
42
  changeEmail: r.writeHandler(changeEmailWrite),
39
43
  };
40
44
 
41
- // Boot-validator requires a "screen:<id>.title" key per r.screen()
42
- // kept inline (not imported from ./i18n) so feature.ts, part of the
43
- // server barrel, stays importable from a server-only sample without a
44
- // jsx tsconfig (i18n.ts pulls @cosmicdrift/kumiko-renderer's types, see
45
- // index.ts). "Profile" is duplicated in i18n.ts's client bundle.
46
- r.translations({ keys: { "screen:profile.title": { en: "Profile" } } });
45
+ // Boot-validator requires a "screen:<id>.title" key per r.screen(), plus
46
+ // one entry per i18nKey()-marked dot-form label below kept inline (not
47
+ // imported from ./i18n) so feature.ts, part of the server barrel, stays
48
+ // importable from a server-only sample without a jsx tsconfig (i18n.ts
49
+ // pulls @cosmicdrift/kumiko-renderer's types, see index.ts). Values are
50
+ // duplicated in i18n.ts's client bundle.
51
+ r.translations({
52
+ keys: {
53
+ "screen:profile.title": { en: "Profile" },
54
+ "profile.email.title": { en: "Email address" },
55
+ "profile.password.title": { en: "Password" },
56
+ "profile.danger.title": { en: "Delete account" },
57
+ "profile.danger.gracePeriodEnd": { en: "Deletion date" },
58
+ "profile.danger.delete": { en: "Delete account" },
59
+ "profile.danger.dialogDescription": {
60
+ en: "After the grace period your data will be permanently deleted. Until then you can cancel.",
61
+ },
62
+ "profile.danger.cancelDeletion": { en: "Cancel deletion" },
63
+ },
64
+ });
47
65
 
48
66
  // Self-service account screen: change-email/change-password stay
49
67
  // self-persisting extension sections (re-auth, own dispatcher writes —
@@ -65,24 +83,24 @@ export function createUserProfileFeature(): FeatureDefinition {
65
83
  "a verification-mail follow-up), and request or cancel account deletion " +
66
84
  "(user-data-rights grace period).",
67
85
  fieldLabels: {
68
- gracePeriodEnd: "profile.danger.gracePeriodEnd",
86
+ gracePeriodEnd: i18nKey("profile.danger.gracePeriodEnd"),
69
87
  },
70
88
  layout: {
71
89
  sections: [
72
90
  {
73
91
  kind: "extension",
74
- title: "profile.email.title",
92
+ title: i18nKey("profile.email.title"),
75
93
  component: { react: { __component: CHANGE_EMAIL_SECTION_EXTENSION_NAME } },
76
94
  entityName: "user",
77
95
  },
78
96
  {
79
97
  kind: "extension",
80
- title: "profile.password.title",
98
+ title: i18nKey("profile.password.title"),
81
99
  component: { react: { __component: CHANGE_PASSWORD_SECTION_EXTENSION_NAME } },
82
100
  entityName: "user",
83
101
  },
84
102
  {
85
- title: "profile.danger.title",
103
+ title: i18nKey("profile.danger.title"),
86
104
  description: "profile.danger.explainer",
87
105
  // gracePeriodEnd is only meaningful once a deletion is actually
88
106
  // pending — hide it instead of showing an empty date when status
@@ -100,15 +118,15 @@ export function createUserProfileFeature(): FeatureDefinition {
100
118
  actions: [
101
119
  {
102
120
  id: "request-deletion",
103
- label: "profile.danger.delete",
121
+ label: i18nKey("profile.danger.delete"),
104
122
  handler: UserDataRightsHandlers.requestDeletion,
105
- confirm: "profile.danger.dialogDescription",
123
+ confirm: i18nKey("profile.danger.dialogDescription"),
106
124
  visible: { field: "status", ne: "deletionRequested" },
107
125
  style: "danger",
108
126
  },
109
127
  {
110
128
  id: "cancel-deletion",
111
- label: "profile.danger.cancelDeletion",
129
+ label: i18nKey("profile.danger.cancelDeletion"),
112
130
  handler: UserDataRightsHandlers.cancelDeletion,
113
131
  visible: { field: "status", eq: "deletionRequested" },
114
132
  style: "secondary",