@cosmicdrift/kumiko-bundled-features 0.207.0 → 0.208.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 (68) hide show
  1. package/package.json +9 -8
  2. package/src/admin-shell/__tests__/admin-shell.boot.test.ts +1 -1
  3. package/src/admin-shell/i18n.ts +17 -39
  4. package/src/admin-shell/web/i18n.ts +6 -11
  5. package/src/audit/i18n.ts +25 -41
  6. package/src/audit/web/i18n.ts +5 -9
  7. package/src/auth-email-password/__tests__/email-templates.test.ts +4 -0
  8. package/src/auth-email-password/email-templates.ts +97 -149
  9. package/src/auth-email-password/i18n.ts +6 -265
  10. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +7 -7
  11. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +14 -14
  12. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +23 -25
  13. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +60 -68
  14. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +24 -24
  15. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +20 -20
  16. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +9 -9
  17. package/src/auth-email-password/web/__tests__/signup-screen.test.tsx +11 -11
  18. package/src/auth-email-password/web/__tests__/test-utils.tsx +2 -2
  19. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +3 -3
  20. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +3 -3
  21. package/src/auth-email-password/web/invite-accept-screen.tsx +3 -1
  22. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +2 -4
  23. package/src/auth-mfa/i18n.ts +1 -3
  24. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +15 -15
  25. package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +45 -49
  26. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +10 -10
  27. package/src/auth-mfa/web/i18n.ts +0 -126
  28. package/src/cap-counter/i18n.ts +5 -11
  29. package/src/compliance-profiles/i18n.ts +3 -11
  30. package/src/compliance-profiles/web/i18n.ts +0 -20
  31. package/src/config/i18n.ts +5 -5
  32. package/src/config/web/i18n.ts +1 -19
  33. package/src/custom-fields/web/i18n.ts +1 -18
  34. package/src/delivery/i18n.ts +3 -7
  35. package/src/delivery/web/i18n.ts +0 -20
  36. package/src/feature-toggles/i18n.ts +3 -11
  37. package/src/feature-toggles/web/i18n.ts +0 -24
  38. package/src/folders/web/i18n.ts +1 -51
  39. package/src/jobs/i18n.ts +40 -68
  40. package/src/jobs/web/i18n.ts +5 -9
  41. package/src/managed-pages/i18n.ts +23 -67
  42. package/src/managed-pages/web/i18n.ts +6 -11
  43. package/src/notes-history/i18n.ts +1 -1
  44. package/src/notes-history/web/i18n.ts +1 -17
  45. package/src/personal-access-tokens/i18n.ts +2 -6
  46. package/src/personal-access-tokens/web/i18n.ts +0 -72
  47. package/src/sessions/i18n.ts +17 -21
  48. package/src/tags/i18n.ts +2 -2
  49. package/src/tags/web/i18n.ts +1 -52
  50. package/src/template-resolver/web/__tests__/client-plugin.test.tsx +2 -2
  51. package/src/template-resolver/web/__tests__/editor-read-only.test.tsx +5 -5
  52. package/src/template-resolver/web/i18n.ts +0 -28
  53. package/src/tenant/__tests__/members-screens.boot.test.ts +0 -2
  54. package/src/tenant/i18n.ts +13 -27
  55. package/src/tenant/web/i18n.ts +0 -38
  56. package/src/tier-engine/i18n.ts +3 -39
  57. package/src/user/i18n.ts +14 -32
  58. package/src/user-data-rights/__tests__/default-mailers.test.ts +4 -0
  59. package/src/user-data-rights/__tests__/email-templates.test.ts +4 -0
  60. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  61. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +4 -0
  62. package/src/user-data-rights/email-templates.ts +55 -91
  63. package/src/user-data-rights/i18n.ts +18 -70
  64. package/src/user-data-rights/lib/default-mailers.ts +4 -1
  65. package/src/user-data-rights/web/__tests__/deletion-screens.test.tsx +11 -11
  66. package/src/user-data-rights/web/__tests__/public-deletion-gate.test.tsx +5 -5
  67. package/src/user-data-rights/web/i18n.ts +9 -157
  68. package/src/user-profile/i18n.ts +1 -71
@@ -1,37 +1,17 @@
1
1
  // @runtime client
2
2
  // Default-Bundles für den TierAdminScreen. Werden vom tierEngineClient()
3
3
  // als Fallback-Bundle in den LocaleProvider gehängt — Apps überschreiben
4
- // einzelne Keys via `tierEngineClient({ translations: { de: { … } } })`.
4
+ // einzelne Keys via `tierEngineClient({ translations })`.
5
5
 
6
6
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
7
7
 
8
- type LocalizedString = { readonly de: string; readonly en: string; readonly es: string };
8
+ type LocalizedString = { readonly en: string };
9
9
 
10
10
  export const TIER_ENGINE_I18N: Readonly<Record<string, LocalizedString>> = {
11
- "screen:tier-admin.title": {
12
- de: "Tier manuell zuweisen",
13
- en: "Assign tier manually",
14
- es: "Asignar tier manualmente",
15
- },
11
+ "screen:tier-admin.title": { en: "Assign tier manually" },
16
12
  };
17
13
 
18
14
  export const defaultTranslations: TranslationsByLocale = {
19
- de: {
20
- "screen:tier-admin.title": "Tier manuell zuweisen",
21
- "tier-admin.title": "Tier manuell zuweisen",
22
- "tier-admin.explainer":
23
- "Weise einem Tenant ein Tier ohne Kauf zu. Der Grant wird als „manuell“ markiert und von einem späteren Billing-Sync nicht überschrieben.",
24
- "tier-admin.tenant.label": "Tenant",
25
- "tier-admin.current.label": "Aktuelles Tier",
26
- "tier-admin.current.none": "— noch keins —",
27
- "tier-admin.tier.label": "Neues Tier",
28
- "tier-admin.submit": "Tier zuweisen",
29
- "tier-admin.success": "Tier „{tier}“ zugewiesen.",
30
- "tier-admin.error.generic": "Konnte das Tier nicht zuweisen.",
31
- "tier-admin.error.load": "Tenants konnten nicht geladen werden.",
32
- "tier-admin.error.noTiers":
33
- "Diese App hat keine TierMap konfiguriert — es gibt keine zuweisbaren Tiers.",
34
- },
35
15
  en: {
36
16
  "screen:tier-admin.title": "Assign tier manually",
37
17
  "tier-admin.title": "Assign tier manually",
@@ -48,20 +28,4 @@ export const defaultTranslations: TranslationsByLocale = {
48
28
  "tier-admin.error.noTiers":
49
29
  "This app has no TierMap configured — there are no assignable tiers.",
50
30
  },
51
- es: {
52
- "screen:tier-admin.title": "Asignar tier manualmente",
53
- "tier-admin.title": "Asignar tier manualmente",
54
- "tier-admin.explainer":
55
- "Asigna un tier a un tenant sin necesidad de compra. La asignación se marca como «manual» y no será sobrescrita por una sincronización de facturación posterior.",
56
- "tier-admin.tenant.label": "Tenant",
57
- "tier-admin.current.label": "Tier actual",
58
- "tier-admin.current.none": "— aún ninguno —",
59
- "tier-admin.tier.label": "Nuevo tier",
60
- "tier-admin.submit": "Asignar tier",
61
- "tier-admin.success": "Tier «{tier}» asignado.",
62
- "tier-admin.error.generic": "No se pudo asignar el tier.",
63
- "tier-admin.error.load": "No se pudieron cargar los tenants.",
64
- "tier-admin.error.noTiers":
65
- "Esta aplicación no tiene una TierMap configurada — no hay tiers asignables.",
66
- },
67
31
  };
package/src/user/i18n.ts CHANGED
@@ -1,38 +1,20 @@
1
- type LocalizedString = { readonly de: string; readonly en: string; readonly es: string };
1
+ type LocalizedString = { readonly en: string };
2
2
 
3
3
  export const USER_I18N: Readonly<Record<string, LocalizedString>> = {
4
- "screen:user-list.title": { de: "Benutzer", en: "Users", es: "Usuarios" },
5
- "screen:user-edit.title": { de: "Benutzer bearbeiten", en: "Edit user", es: "Editar usuario" },
6
- "user:entity:user:field:email": { de: "E-Mail", en: "Email", es: "Correo electrónico" },
7
- "user:entity:user:field:displayName": {
8
- de: "Anzeigename",
9
- en: "Display name",
10
- es: "Nombre para mostrar",
11
- },
12
- "user:entity:user:field:status": { de: "Status", en: "Status", es: "Estado" },
13
- "user:entity:user:field:emailVerified": {
14
- de: "E-Mail bestätigt",
15
- en: "Email verified",
16
- es: "Correo verificado",
17
- },
18
- "user:entity:user:field:locale": { de: "Sprache", en: "Locale", es: "Idioma" },
19
- "user:entity:user:field:timezone": { de: "Zeitzone", en: "Timezone", es: "Zona horaria" },
20
- "user:entity:user:field:status:option:active": { de: "Aktiv", en: "Active", es: "Activo" },
21
- "user:entity:user:field:status:option:restricted": {
22
- de: "Eingeschränkt",
23
- en: "Restricted",
24
- es: "Restringido",
25
- },
4
+ "screen:user-list.title": { en: "Users" },
5
+ "screen:user-edit.title": { en: "Edit user" },
6
+ "user:entity:user:field:email": { en: "Email" },
7
+ "user:entity:user:field:displayName": { en: "Display name" },
8
+ "user:entity:user:field:status": { en: "Status" },
9
+ "user:entity:user:field:emailVerified": { en: "Email verified" },
10
+ "user:entity:user:field:locale": { en: "Locale" },
11
+ "user:entity:user:field:timezone": { en: "Timezone" },
12
+ "user:entity:user:field:status:option:active": { en: "Active" },
13
+ "user:entity:user:field:status:option:restricted": { en: "Restricted" },
26
14
  "user:entity:user:field:status:option:deletionRequested": {
27
- de: "Löschung angefordert",
28
15
  en: "Deletion requested",
29
- es: "Eliminación solicitada",
30
- },
31
- "user:entity:user:field:status:option:deleted": {
32
- de: "Gelöscht",
33
- en: "Deleted",
34
- es: "Eliminado",
35
16
  },
36
- "user:entity:user:field:emailVerified:option:true": { de: "Ja", en: "Yes", es: "Sí" },
37
- "user:entity:user:field:emailVerified:option:false": { de: "Nein", en: "No", es: "No" },
17
+ "user:entity:user:field:status:option:deleted": { en: "Deleted" },
18
+ "user:entity:user:field:emailVerified:option:true": { en: "Yes" },
19
+ "user:entity:user:field:emailVerified:option:false": { en: "No" },
38
20
  };
@@ -10,6 +10,8 @@ import type {
10
10
  EmailTransport,
11
11
  } from "@cosmicdrift/kumiko-bundled-features/channel-email";
12
12
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
14
+ import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
13
15
  import {
14
16
  makeDefaultDeletionExecutedEmail,
15
17
  makeDefaultExportReadyEmail,
@@ -27,6 +29,8 @@ function capturingTransport(): { transport: EmailTransport; sent: EmailMessage[]
27
29
 
28
30
  const TENANT_A = "00000000-0000-4000-8000-00000000000a" as TenantId;
29
31
 
32
+ registerMailTranslations("de", localeDeBundle);
33
+
30
34
  describe("default-mailers dispatch", () => {
31
35
  test("export-ready: rendert Template + sendet an userEmail ueber tenant-transport", async () => {
32
36
  const cap = capturingTransport();
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
3
+ import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
2
4
  import {
3
5
  renderDeletionExecutedEmail,
4
6
  renderDeletionRequestedEmail,
@@ -6,6 +8,8 @@ import {
6
8
  renderExportReadyEmail,
7
9
  } from "../email-templates";
8
10
 
11
+ registerMailTranslations("de", localeDeBundle);
12
+
9
13
  describe("gdpr email-templates", () => {
10
14
  test("export-ready: subject + download-button + formatted expiry, de/en differ", () => {
11
15
  const en = renderExportReadyEmail({
@@ -21,6 +21,7 @@ import {
21
21
  } from "@cosmicdrift/kumiko-framework/stack";
22
22
  import { resetTestTables, seedRow } from "@cosmicdrift/kumiko-framework/testing";
23
23
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
24
+ import { localeDe } from "@cosmicdrift/kumiko-locale-de";
24
25
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
25
26
  import { configValuesTable, createConfigFeature, createConfigResolver } from "../../config";
26
27
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
@@ -63,6 +64,7 @@ beforeAll(async () => {
63
64
  mailTransportInMemoryFeature,
64
65
  // KEINE send*Email-Opts — die mail-foundation-Defaults muessen greifen.
65
66
  createUserDataRightsFeature(),
67
+ localeDe(),
66
68
  createUserDataRightsDefaultsFeature(),
67
69
  ],
68
70
  });
@@ -14,6 +14,7 @@ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth
14
14
  import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
15
15
  import { type JobContext, SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
16
16
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
17
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
17
18
  import {
18
19
  setupTestStack,
19
20
  type TestStack,
@@ -22,6 +23,7 @@ import {
22
23
  } from "@cosmicdrift/kumiko-framework/stack";
23
24
  import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
24
25
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
26
+ import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
25
27
  import {
26
28
  createComplianceProfilesFeature,
27
29
  tenantComplianceProfileEntity,
@@ -134,6 +136,8 @@ async function seedPendingJob(): Promise<string> {
134
136
  return res.jobId;
135
137
  }
136
138
 
139
+ registerMailTranslations("de", localeDeBundle);
140
+
137
141
  describe("run-export-jobs cron-context", () => {
138
142
  test("Cron-Job-Kontext (configResolver, KEIN config) → Export läuft durch, bytesWritten > 0", async () => {
139
143
  const jobId = await seedPendingJob();
@@ -1,34 +1,46 @@
1
- // Default-HTML-Renderer fuer die GDPR-Notification-Mails (Export-bereit,
2
- // Export-fehlgeschlagen, Loeschung-angefordert, Loeschung-ausgefuehrt).
3
- // Damit eine App keine eigenen send*Email-Callbacks schreiben muss: sie
4
- // mountet mail-foundation + einen mail-transport-* und user-data-rights
5
- // rendert + versendet ueber diese Templates (siehe lib/default-mailers.ts).
6
- //
7
- // Apps die ihr eigenes Branding wollen, uebergeben eigene send*Email-Opts —
8
- // dann greifen diese Defaults nicht. Pattern + plain-inline-HTML gespiegelt
9
- // von auth-email-password/email-templates.ts (kein CSS-Framework, kein
10
- // Bild-Asset; Mail-Clients rendern table-layout + inline-CSS verlaesslich).
11
- //
12
- // Locale: de + en. Apps mit anderen Sprachen uebergeben eigene Callbacks.
1
+ // Default HTML renderers for GDPR notification mails. English ships here;
2
+ // other languages register via @cosmicdrift/kumiko-locale-* (localeDe() /
3
+ // localeEs() call registerMailTranslations). Apps that want their own
4
+ // branding pass send*Email callbacks and these defaults do not run.
13
5
 
6
+ import { mailT, registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
14
7
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
15
8
  import { Temporal } from "temporal-polyfill";
16
9
 
17
- export type GdprMailLocale = "de" | "en";
10
+ export type GdprMailLocale = string;
18
11
 
19
- // user.locale ist Freitext ("de", "en", "de-DE", "fr", null). Die Templates
20
- // koennen nur de/en — alles andere (inkl. unbekannte Sprachen) faellt auf
21
- // undefined zurueck, sodass der Caller auf mailDefaults.locale bzw. "en" geht.
22
12
  export function normalizeGdprMailLocale(
23
13
  raw: string | null | undefined,
24
14
  ): GdprMailLocale | undefined {
25
15
  if (!raw) return undefined;
26
- const lower = raw.toLowerCase();
27
- if (lower.startsWith("de")) return "de";
28
- if (lower.startsWith("en")) return "en";
29
- return undefined;
16
+ const root = raw.toLowerCase().split("-")[0];
17
+ return root && root.length > 0 ? root : undefined;
30
18
  }
31
19
 
20
+ const GDPR_MAIL_EN: Readonly<Record<string, string>> = {
21
+ "gdpr.mail.appNameDefault": "Account",
22
+ "gdpr.mail.greeting": "Hi,",
23
+ "gdpr.mail.exportReady.subject": "{app} — Your data export is ready",
24
+ "gdpr.mail.exportReady.intro":
25
+ "your requested data export for {app} is ready. Download it using the link below:",
26
+ "gdpr.mail.exportReady.button": "Download data export",
27
+ "gdpr.mail.exportReady.expiry": "The download link expires on {when}.",
28
+ "gdpr.mail.exportFailed.subject": "{app} — Your data export failed",
29
+ "gdpr.mail.exportFailed.intro":
30
+ "your requested data export for {app} could not be created. Please request the export again.",
31
+ "gdpr.mail.deletionRequested.subject": "{app} — Account deletion requested",
32
+ "gdpr.mail.deletionRequested.intro":
33
+ "we received your request to delete your {app} account. Your account and associated data will be permanently deleted on {when}.",
34
+ "gdpr.mail.deletionRequested.cancel":
35
+ "If you didn't request this, sign in and cancel the deletion in your account settings before the deadline.",
36
+ "gdpr.mail.deletionExecuted.subject": "{app} — Your account has been deleted",
37
+ "gdpr.mail.deletionExecuted.intro":
38
+ "your {app} account and the associated personal data were deleted on {when}. This action is permanent.",
39
+ "gdpr.mail.fallbackUrl": "If the button doesn't work, copy this link into your browser:",
40
+ };
41
+
42
+ registerMailTranslations("en", GDPR_MAIL_EN);
43
+
32
44
  export type RenderedEmail = {
33
45
  readonly subject: string;
34
46
  readonly html: string;
@@ -58,76 +70,35 @@ export type RenderDeletionExecutedEmailArgs = {
58
70
  readonly appName?: string;
59
71
  };
60
72
 
61
- const STRINGS = {
62
- de: {
63
- greeting: "Hallo,",
64
- exportReadySubject: (app: string) => `${app} — Dein Datenexport ist bereit`,
65
- exportReadyIntro: (app: string) =>
66
- `dein angeforderter Datenexport fuer ${app} ist fertig. Lade ihn ueber den folgenden Link herunter:`,
67
- exportReadyButton: "Datenexport herunterladen",
68
- exportReadyExpiry: (when: string) => `Der Download-Link laeuft am ${when} ab.`,
69
- exportFailedSubject: (app: string) => `${app} — Dein Datenexport ist fehlgeschlagen`,
70
- exportFailedIntro: (app: string) =>
71
- `dein angeforderter Datenexport fuer ${app} konnte leider nicht erstellt werden. Bitte fordere den Export erneut an.`,
72
- deletionRequestedSubject: (app: string) => `${app} — Loeschung deines Kontos angefordert`,
73
- deletionRequestedIntro: (app: string, when: string) =>
74
- `wir haben deinen Antrag zur Loeschung deines ${app}-Kontos erhalten. Dein Konto und die zugehoerigen Daten werden am ${when} endgueltig geloescht.`,
75
- deletionRequestedCancel:
76
- "Falls du das nicht angefordert hast, melde dich an und brich die Loeschung in den Kontoeinstellungen ab, bevor die Frist ablaeuft.",
77
- deletionExecutedSubject: (app: string) => `${app} — Dein Konto wurde geloescht`,
78
- deletionExecutedIntro: (app: string, when: string) =>
79
- `dein ${app}-Konto und die zugehoerigen personenbezogenen Daten wurden am ${when} geloescht. Diese Aktion ist endgueltig.`,
80
- fallbackUrl: "Falls der Button nicht funktioniert, kopiere diesen Link in den Browser:",
81
- },
82
- en: {
83
- greeting: "Hi,",
84
- exportReadySubject: (app: string) => `${app} — Your data export is ready`,
85
- exportReadyIntro: (app: string) =>
86
- `your requested data export for ${app} is ready. Download it using the link below:`,
87
- exportReadyButton: "Download data export",
88
- exportReadyExpiry: (when: string) => `The download link expires on ${when}.`,
89
- exportFailedSubject: (app: string) => `${app} — Your data export failed`,
90
- exportFailedIntro: (app: string) =>
91
- `your requested data export for ${app} could not be created. Please request the export again.`,
92
- deletionRequestedSubject: (app: string) => `${app} — Account deletion requested`,
93
- deletionRequestedIntro: (app: string, when: string) =>
94
- `we received your request to delete your ${app} account. Your account and associated data will be permanently deleted on ${when}.`,
95
- deletionRequestedCancel:
96
- "If you didn't request this, sign in and cancel the deletion in your account settings before the deadline.",
97
- deletionExecutedSubject: (app: string) => `${app} — Your account has been deleted`,
98
- deletionExecutedIntro: (app: string, when: string) =>
99
- `your ${app} account and the associated personal data were deleted on ${when}. This action is permanent.`,
100
- fallbackUrl: "If the button doesn't work, copy this link into your browser:",
101
- },
102
- } as const;
73
+ function t(locale: string, key: string, params?: Readonly<Record<string, string>>): string {
74
+ return mailT(locale, key, params);
75
+ }
103
76
 
104
77
  function appNameFor(args: { locale?: GdprMailLocale; appName?: string }): string {
105
78
  const locale = args.locale ?? "en";
106
- return args.appName ?? (locale === "de" ? "Konto" : "Account");
79
+ return args.appName ?? t(locale, "gdpr.mail.appNameDefault");
107
80
  }
108
81
 
109
82
  export function renderExportReadyEmail(args: RenderExportReadyEmailArgs): RenderedEmail {
110
83
  const locale = args.locale ?? "en";
111
84
  const app = appNameFor(args);
112
- const t = STRINGS[locale];
113
- const subject = t.exportReadySubject(app);
85
+ const subject = t(locale, "gdpr.mail.exportReady.subject", { app });
114
86
  const body = `
115
- <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
116
- <p style="margin: 0 0 24px; font-size: 14px; line-height: 1.5;">${escapeHtml(t.exportReadyIntro(app))}</p>
117
- <p style="margin: 0 0 24px;">${renderButton({ url: args.downloadUrl, label: t.exportReadyButton })}</p>
118
- <p style="margin: 0 0 8px; font-size: 13px; color: #555;">${escapeHtml(t.exportReadyExpiry(formatTimestamp(args.expiresAt)))}</p>
119
- ${renderFallbackUrl({ url: args.downloadUrl, label: t.fallbackUrl })}`;
87
+ <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
88
+ <p style="margin: 0 0 24px; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.exportReady.intro", { app }))}</p>
89
+ <p style="margin: 0 0 24px;">${renderButton({ url: args.downloadUrl, label: t(locale, "gdpr.mail.exportReady.button") })}</p>
90
+ <p style="margin: 0 0 8px; font-size: 13px; color: #555;">${escapeHtml(t(locale, "gdpr.mail.exportReady.expiry", { when: formatTimestamp(args.expiresAt) }))}</p>
91
+ ${renderFallbackUrl({ url: args.downloadUrl, label: t(locale, "gdpr.mail.fallbackUrl") })}`;
120
92
  return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
121
93
  }
122
94
 
123
95
  export function renderExportFailedEmail(args: RenderExportFailedEmailArgs): RenderedEmail {
124
96
  const locale = args.locale ?? "en";
125
97
  const app = appNameFor(args);
126
- const t = STRINGS[locale];
127
- const subject = t.exportFailedSubject(app);
98
+ const subject = t(locale, "gdpr.mail.exportFailed.subject", { app });
128
99
  const body = `
129
- <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
130
- <p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t.exportFailedIntro(app))}</p>`;
100
+ <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
101
+ <p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.exportFailed.intro", { app }))}</p>`;
131
102
  return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
132
103
  }
133
104
 
@@ -136,23 +107,23 @@ export function renderDeletionRequestedEmail(
136
107
  ): RenderedEmail {
137
108
  const locale = args.locale ?? "en";
138
109
  const app = appNameFor(args);
139
- const t = STRINGS[locale];
140
- const subject = t.deletionRequestedSubject(app);
110
+ const when = formatTimestamp(args.gracePeriodEnd);
111
+ const subject = t(locale, "gdpr.mail.deletionRequested.subject", { app });
141
112
  const body = `
142
- <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
143
- <p style="margin: 0 0 16px; font-size: 14px; line-height: 1.5;">${escapeHtml(t.deletionRequestedIntro(app, formatTimestamp(args.gracePeriodEnd)))}</p>
144
- <p style="margin: 0; font-size: 13px; color: #555;">${escapeHtml(t.deletionRequestedCancel)}</p>`;
113
+ <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
114
+ <p style="margin: 0 0 16px; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.deletionRequested.intro", { app, when }))}</p>
115
+ <p style="margin: 0; font-size: 13px; color: #555;">${escapeHtml(t(locale, "gdpr.mail.deletionRequested.cancel"))}</p>`;
145
116
  return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
146
117
  }
147
118
 
148
119
  export function renderDeletionExecutedEmail(args: RenderDeletionExecutedEmailArgs): RenderedEmail {
149
120
  const locale = args.locale ?? "en";
150
121
  const app = appNameFor(args);
151
- const t = STRINGS[locale];
152
- const subject = t.deletionExecutedSubject(app);
122
+ const when = formatTimestamp(args.executedAt);
123
+ const subject = t(locale, "gdpr.mail.deletionExecuted.subject", { app });
153
124
  const body = `
154
- <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t.greeting)}</p>
155
- <p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t.deletionExecutedIntro(app, formatTimestamp(args.executedAt)))}</p>`;
125
+ <p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
126
+ <p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.deletionExecuted.intro", { app, when }))}</p>`;
156
127
  return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
157
128
  }
158
129
 
@@ -160,11 +131,9 @@ function wrapCell(bodyHtml: string): string {
160
131
  return `<tr><td>${bodyHtml}</td></tr>`;
161
132
  }
162
133
 
163
- // Plain inline-styled HTML — gespiegelt von auth-email-password.
164
- // guard:dup-ok — Email-HTML (table-layout, inline CSS) ≠ Web-HTML (legal-pages/markdown.ts)
165
134
  function renderShell(args: { title: string; bodyHtml: string; locale: GdprMailLocale }): string {
166
135
  return `<!DOCTYPE html>
167
- <html lang="${args.locale}">
136
+ <html lang="${escapeHtmlAttr(args.locale)}">
168
137
  <head>
169
138
  <meta charset="utf-8" />
170
139
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -184,19 +153,14 @@ function renderShell(args: { title: string; bodyHtml: string; locale: GdprMailLo
184
153
  </html>`;
185
154
  }
186
155
 
187
- // guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie auth-email-password, verschiedene Semantik
188
156
  function renderButton(args: { url: string; label: string }): string {
189
157
  return `<a href="${escapeHtmlAttr(args.url)}" style="display: inline-block; background: #1a1a1a; color: #ffffff; padding: 12px 24px; border-radius: 6px; text-decoration: none; font-weight: 500;">${escapeHtml(args.label)}</a>`;
190
158
  }
191
159
 
192
- // guard:dup-ok — Email-HTML-Helper; selbe normalisierte AST-Form wie auth-email-password, verschiedene Semantik
193
160
  function renderFallbackUrl(args: { url: string; label: string }): string {
194
161
  return `<p style="margin: 24px 0 0; font-size: 12px; color: #666;">${escapeHtml(args.label)}<br /><a href="${escapeHtmlAttr(args.url)}" style="color: #1a1a1a; word-break: break-all;">${escapeHtml(args.url)}</a></p>`;
195
162
  }
196
163
 
197
- // ISO-Timestamp → "2026-05-04 13:45 UTC". Locale-unabhaengig + UTC-Suffix
198
- // damit der User unabhaengig von seiner Tz sieht wann etwas passiert; bei
199
- // un-parsbarem Input faellt's auf den raw-string zurueck.
200
164
  function formatTimestamp(iso: string): string {
201
165
  try {
202
166
  const z = Temporal.Instant.from(iso).toZonedDateTimeISO("UTC");
@@ -1,88 +1,36 @@
1
1
  // @runtime client
2
2
  // Server + client i18n for user-data-rights (operator screens + nav). Pure
3
3
  // data, importable from the web bundle (web/i18n.ts derives the client keys).
4
- type LocalizedString = { readonly de: string; readonly en: string; readonly es: string };
4
+ type LocalizedString = { readonly en: string };
5
5
 
6
6
  export const USER_DATA_RIGHTS_I18N: Readonly<Record<string, LocalizedString>> = {
7
- "user-data-rights:nav.exportJobs": {
8
- de: "DSGVO-Exporte",
9
- en: "GDPR exports",
10
- es: "Exportaciones RGPD",
11
- },
12
- "screen:export-job-list.title": {
13
- de: "DSGVO-Exporte",
14
- en: "GDPR exports",
15
- es: "Exportaciones RGPD",
16
- },
17
- "screen:export-job-detail.title": {
18
- de: "Export-Job",
19
- en: "Export job",
20
- es: "Trabajo de exportación",
21
- },
22
- "screen:download-attempt-list.title": {
23
- de: "Download-Versuche",
24
- en: "Download attempts",
25
- es: "Intentos de descarga",
26
- },
27
- "screen:privacy-center.title": { de: "Datenschutz", en: "Privacy", es: "Privacidad" },
28
- "user-data-rights:entity:export-job:field:userId": { de: "Benutzer", en: "User", es: "Usuario" },
29
- "user-data-rights:entity:export-job:field:status": { de: "Status", en: "Status", es: "Estado" },
30
- "user-data-rights:entity:export-job:field:requestedAt": {
31
- de: "Angefordert",
32
- en: "Requested",
33
- es: "Solicitado",
34
- },
35
- "user-data-rights:entity:export-job:field:completedAt": {
36
- de: "Abgeschlossen",
37
- en: "Completed",
38
- es: "Completado",
39
- },
40
- "user-data-rights:entity:export-job:field:expiresAt": {
41
- de: "Läuft ab",
42
- en: "Expires",
43
- es: "Caduca",
44
- },
7
+ "user-data-rights:nav.exportJobs": { en: "GDPR exports" },
8
+ "screen:export-job-list.title": { en: "GDPR exports" },
9
+ "screen:export-job-detail.title": { en: "Export job" },
10
+ "screen:download-attempt-list.title": { en: "Download attempts" },
11
+ "screen:privacy-center.title": { en: "Privacy" },
12
+ "user-data-rights:entity:export-job:field:userId": { en: "User" },
13
+ "user-data-rights:entity:export-job:field:status": { en: "Status" },
14
+ "user-data-rights:entity:export-job:field:requestedAt": { en: "Requested" },
15
+ "user-data-rights:entity:export-job:field:completedAt": { en: "Completed" },
16
+ "user-data-rights:entity:export-job:field:expiresAt": { en: "Expires" },
45
17
  "user-data-rights:entity:export-job:field:requestedFromTenantId": {
46
- de: "Mandant",
47
18
  en: "Tenant",
48
- es: "Organización",
49
- },
50
- "user-data-rights:entity:export-job:field:startedAt": {
51
- de: "Gestartet",
52
- en: "Started",
53
- es: "Iniciado",
54
19
  },
20
+ "user-data-rights:entity:export-job:field:startedAt": { en: "Started" },
55
21
  "user-data-rights:entity:export-job:field:downloadStorageKey": {
56
- de: "Speicher-Schlüssel",
57
22
  en: "Storage key",
58
- es: "Clave de almacenamiento",
59
- },
60
- "user-data-rights:entity:export-job:field:bytesWritten": {
61
- de: "Bytes",
62
- en: "Bytes",
63
- es: "Bytes",
64
- },
65
- "user-data-rights:entity:export-job:field:errorMessage": {
66
- de: "Fehler",
67
- en: "Error",
68
- es: "Error",
69
23
  },
24
+ "user-data-rights:entity:export-job:field:bytesWritten": { en: "Bytes" },
25
+ "user-data-rights:entity:export-job:field:errorMessage": { en: "Error" },
70
26
  "user-data-rights:entity:download-attempt:field:attemptedAt": {
71
- de: "Zeitpunkt",
72
27
  en: "Attempted at",
73
- es: "Fecha y hora",
74
- },
75
- "user-data-rights:entity:download-attempt:field:result": {
76
- de: "Ergebnis",
77
- en: "Result",
78
- es: "Resultado",
79
28
  },
80
- "user-data-rights:entity:download-attempt:field:via": { de: "Via", en: "Via", es: "Vía" },
81
- "user-data-rights:entity:download-attempt:field:ip": { de: "IP", en: "IP", es: "IP" },
29
+ "user-data-rights:entity:download-attempt:field:result": { en: "Result" },
30
+ "user-data-rights:entity:download-attempt:field:via": { en: "Via" },
31
+ "user-data-rights:entity:download-attempt:field:ip": { en: "IP" },
82
32
  "user-data-rights:entity:download-attempt:field:attemptedByUserId": {
83
- de: "Benutzer",
84
33
  en: "User",
85
- es: "Usuario",
86
34
  },
87
- "user-data-rights:entity:download-attempt:field:jobId": { de: "Job", en: "Job", es: "Trabajo" },
35
+ "user-data-rights:entity:download-attempt:field:jobId": { en: "Job" },
88
36
  };
@@ -11,6 +11,7 @@
11
11
 
12
12
  import type { EmailTransport } from "@cosmicdrift/kumiko-bundled-features/channel-email";
13
13
  import type { Registry } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { hasMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
14
15
  import {
15
16
  type GdprMailLocale,
16
17
  normalizeGdprMailLocale,
@@ -44,7 +45,9 @@ function localeFor(
44
45
  userLocale: string | null,
45
46
  defaults: GdprMailDefaults,
46
47
  ): GdprMailLocale | undefined {
47
- return normalizeGdprMailLocale(userLocale) ?? defaults.locale;
48
+ const user = normalizeGdprMailLocale(userLocale);
49
+ if (user !== undefined && hasMailTranslations(user)) return user;
50
+ return defaults.locale;
48
51
  }
49
52
 
50
53
  export function makeDefaultExportReadyEmail(
@@ -14,7 +14,7 @@ import { ConfirmAccountDeletionScreen } from "../confirm-deletion-screen";
14
14
  import { defaultTranslations } from "../i18n";
15
15
  import { RequestAccountDeletionScreen } from "../request-deletion-screen";
16
16
 
17
- const resolver = createStaticLocaleResolver({ locale: "de" });
17
+ const resolver = createStaticLocaleResolver({ locale: "en" });
18
18
 
19
19
  type WriteCall = { readonly type: string; readonly payload: unknown };
20
20
 
@@ -62,7 +62,7 @@ describe("RequestAccountDeletionScreen", () => {
62
62
  const ui = renderWith(<RequestAccountDeletionScreen />, makeDispatcher(true, calls));
63
63
  fireEvent.change(ui.getByRole("textbox"), { target: { value: "a@b.com" } });
64
64
  fireEvent.click(ui.getByRole("button"));
65
- await waitFor(() => expect(ui.getByText(/Mail gesendet/)).toBeTruthy());
65
+ await waitFor(() => expect(ui.getByText(/Email sent/)).toBeTruthy());
66
66
  await waitFor(() => expect(calls).toHaveLength(1));
67
67
  expect(calls[0]?.type).toBe("user-data-rights:write:request-deletion-by-email");
68
68
  expect(calls[0]?.payload).toEqual({ email: "a@b.com" });
@@ -73,8 +73,8 @@ describe("RequestAccountDeletionScreen", () => {
73
73
  const ui = renderWith(<RequestAccountDeletionScreen />, makeDispatcher(false, calls));
74
74
  fireEvent.change(ui.getByRole("textbox"), { target: { value: "a@b.com" } });
75
75
  fireEvent.click(ui.getByRole("button"));
76
- await waitFor(() => expect(ui.getByText(/schief gegangen/)).toBeTruthy());
77
- expect(ui.queryByText(/Mail gesendet/)).toBeNull();
76
+ await waitFor(() => expect(ui.getByText(/went wrong/)).toBeTruthy());
77
+ expect(ui.queryByText(/Email sent/)).toBeNull();
78
78
  });
79
79
  });
80
80
 
@@ -82,7 +82,7 @@ describe("ConfirmAccountDeletionScreen", () => {
82
82
  test("ohne #token → missingToken, kein Confirm-Button", () => {
83
83
  window.history.replaceState({}, "", "/delete-account/confirm");
84
84
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, []));
85
- expect(ui.getByText(/Kein Token/)).toBeTruthy();
85
+ expect(ui.getByText(/No token/)).toBeTruthy();
86
86
  expect(ui.queryByRole("button")).toBeNull();
87
87
  });
88
88
 
@@ -91,7 +91,7 @@ describe("ConfirmAccountDeletionScreen", () => {
91
91
  const calls: WriteCall[] = [];
92
92
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(true, calls));
93
93
  fireEvent.click(ui.getByRole("button"));
94
- await waitFor(() => expect(ui.getByText(/vorgemerkt/)).toBeTruthy());
94
+ await waitFor(() => expect(ui.getByText(/Deletion scheduled/)).toBeTruthy());
95
95
  await waitFor(() => expect(calls).toHaveLength(1));
96
96
  expect(calls[0]?.type).toBe("user-data-rights:write:confirm-deletion-by-token");
97
97
  expect(calls[0]?.payload).toEqual({ token: "tok-123" });
@@ -101,16 +101,16 @@ describe("ConfirmAccountDeletionScreen", () => {
101
101
  window.history.replaceState({}, "", "/delete-account/confirm#token=bad");
102
102
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeDispatcher(false, []));
103
103
  fireEvent.click(ui.getByRole("button"));
104
- await waitFor(() => expect(ui.getByText(/ungültig oder abgelaufen/)).toBeTruthy());
105
- expect(ui.queryByText(/vorgemerkt/)).toBeNull();
104
+ await waitFor(() => expect(ui.getByText(/invalid or expired/)).toBeTruthy());
105
+ expect(ui.queryByText(/Deletion scheduled/)).toBeNull();
106
106
  });
107
107
 
108
108
  test("write wirft → generischer Error-Banner, NICHT invalidToken", async () => {
109
109
  window.history.replaceState({}, "", "/delete-account/confirm#token=tok-123");
110
110
  const ui = renderWith(<ConfirmAccountDeletionScreen />, makeThrowingDispatcher());
111
111
  fireEvent.click(ui.getByRole("button"));
112
- await waitFor(() => expect(ui.getByText(/schief gegangen/)).toBeTruthy());
113
- expect(ui.queryByText(/ungültig oder abgelaufen/)).toBeNull();
114
- expect(ui.queryByText(/vorgemerkt/)).toBeNull();
112
+ await waitFor(() => expect(ui.getByText(/went wrong/)).toBeTruthy());
113
+ expect(ui.queryByText(/invalid or expired/)).toBeNull();
114
+ expect(ui.queryByText(/Deletion scheduled/)).toBeNull();
115
115
  });
116
116
  });