@cosmicdrift/kumiko-bundled-features 0.206.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 -20
  4. package/src/admin-shell/web/i18n.ts +6 -11
  5. package/src/audit/i18n.ts +25 -25
  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 -136
  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 -2
  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 -63
  28. package/src/cap-counter/i18n.ts +5 -6
  29. package/src/compliance-profiles/i18n.ts +3 -3
  30. package/src/compliance-profiles/web/i18n.ts +0 -10
  31. package/src/config/i18n.ts +5 -5
  32. package/src/config/web/i18n.ts +1 -10
  33. package/src/custom-fields/web/i18n.ts +1 -9
  34. package/src/delivery/i18n.ts +3 -3
  35. package/src/delivery/web/i18n.ts +0 -10
  36. package/src/feature-toggles/i18n.ts +3 -3
  37. package/src/feature-toggles/web/i18n.ts +0 -12
  38. package/src/folders/web/i18n.ts +1 -26
  39. package/src/jobs/i18n.ts +40 -46
  40. package/src/jobs/web/i18n.ts +5 -9
  41. package/src/managed-pages/i18n.ts +23 -27
  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 -9
  45. package/src/personal-access-tokens/i18n.ts +2 -2
  46. package/src/personal-access-tokens/web/i18n.ts +0 -37
  47. package/src/sessions/i18n.ts +17 -17
  48. package/src/tags/i18n.ts +2 -2
  49. package/src/tags/web/i18n.ts +1 -27
  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 -14
  53. package/src/tenant/__tests__/members-screens.boot.test.ts +0 -2
  54. package/src/tenant/i18n.ts +13 -16
  55. package/src/tenant/web/i18n.ts +0 -19
  56. package/src/tier-engine/i18n.ts +3 -19
  57. package/src/user/i18n.ts +14 -15
  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 -22
  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 -88
  68. package/src/user-profile/i18n.ts +1 -36
@@ -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,40 +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 };
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": { de: "DSGVO-Exporte", en: "GDPR exports" },
8
- "screen:export-job-list.title": { de: "DSGVO-Exporte", en: "GDPR exports" },
9
- "screen:export-job-detail.title": { de: "Export-Job", en: "Export job" },
10
- "screen:download-attempt-list.title": { de: "Download-Versuche", en: "Download attempts" },
11
- "screen:privacy-center.title": { de: "Datenschutz", en: "Privacy" },
12
- "user-data-rights:entity:export-job:field:userId": { de: "Benutzer", en: "User" },
13
- "user-data-rights:entity:export-job:field:status": { de: "Status", en: "Status" },
14
- "user-data-rights:entity:export-job:field:requestedAt": { de: "Angefordert", en: "Requested" },
15
- "user-data-rights:entity:export-job:field:completedAt": { de: "Abgeschlossen", en: "Completed" },
16
- "user-data-rights:entity:export-job:field:expiresAt": { de: "Läuft ab", en: "Expires" },
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" },
17
17
  "user-data-rights:entity:export-job:field:requestedFromTenantId": {
18
- de: "Mandant",
19
18
  en: "Tenant",
20
19
  },
21
- "user-data-rights:entity:export-job:field:startedAt": { de: "Gestartet", en: "Started" },
20
+ "user-data-rights:entity:export-job:field:startedAt": { en: "Started" },
22
21
  "user-data-rights:entity:export-job:field:downloadStorageKey": {
23
- de: "Speicher-Schlüssel",
24
22
  en: "Storage key",
25
23
  },
26
- "user-data-rights:entity:export-job:field:bytesWritten": { de: "Bytes", en: "Bytes" },
27
- "user-data-rights:entity:export-job:field:errorMessage": { de: "Fehler", en: "Error" },
24
+ "user-data-rights:entity:export-job:field:bytesWritten": { en: "Bytes" },
25
+ "user-data-rights:entity:export-job:field:errorMessage": { en: "Error" },
28
26
  "user-data-rights:entity:download-attempt:field:attemptedAt": {
29
- de: "Zeitpunkt",
30
27
  en: "Attempted at",
31
28
  },
32
- "user-data-rights:entity:download-attempt:field:result": { de: "Ergebnis", en: "Result" },
33
- "user-data-rights:entity:download-attempt:field:via": { de: "Via", en: "Via" },
34
- "user-data-rights:entity:download-attempt:field:ip": { de: "IP", en: "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" },
35
32
  "user-data-rights:entity:download-attempt:field:attemptedByUserId": {
36
- de: "Benutzer",
37
33
  en: "User",
38
34
  },
39
- "user-data-rights:entity:download-attempt:field:jobId": { de: "Job", en: "Job" },
35
+ "user-data-rights:entity:download-attempt:field:jobId": { en: "Job" },
40
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
  });
@@ -20,7 +20,7 @@ import type { ReactNode } from "react";
20
20
  import { defaultTranslations } from "../i18n";
21
21
  import { makePublicDeletionGate } from "../public-deletion-gate";
22
22
 
23
- const resolver = createStaticLocaleResolver({ locale: "de" });
23
+ const resolver = createStaticLocaleResolver({ locale: "en" });
24
24
  const stubDispatcher = {
25
25
  write: async () => ({ isSuccess: true, data: {} }),
26
26
  } as unknown as Dispatcher;
@@ -57,7 +57,7 @@ describe("makePublicDeletionGate", () => {
57
57
  <div data-testid="app">APP</div>
58
58
  </Gate>,
59
59
  );
60
- expect(ui.getByText(/beantragen/)).toBeTruthy();
60
+ expect(ui.getByText(/Request account deletion/)).toBeTruthy();
61
61
  expect(ui.queryByTestId("app")).toBeNull();
62
62
  });
63
63
 
@@ -69,7 +69,7 @@ describe("makePublicDeletionGate", () => {
69
69
  <div data-testid="app">APP</div>
70
70
  </Gate>,
71
71
  );
72
- expect(ui.getByText(/bestätigen/)).toBeTruthy();
72
+ expect(ui.getByText(/Confirm account deletion/)).toBeTruthy();
73
73
  expect(ui.queryByTestId("app")).toBeNull();
74
74
  });
75
75
 
@@ -82,7 +82,7 @@ describe("makePublicDeletionGate", () => {
82
82
  </Gate>,
83
83
  );
84
84
  expect(ui.getByTestId("app")).toBeTruthy();
85
- expect(ui.queryByText(/beantragen/)).toBeNull();
85
+ expect(ui.queryByText(/Request account deletion/)).toBeNull();
86
86
  });
87
87
 
88
88
  test("custom shell wraps the matched screen", () => {
@@ -97,6 +97,6 @@ describe("makePublicDeletionGate", () => {
97
97
  </Gate>,
98
98
  );
99
99
  const shell = ui.getByTestId("shell");
100
- expect(within(shell).getByText(/beantragen/)).toBeTruthy();
100
+ expect(within(shell).getByText(/Request account deletion/)).toBeTruthy();
101
101
  });
102
102
  });
@@ -4,93 +4,14 @@
4
4
  // oder direkt) und können einzelne Keys überschreiben. Keys: `userDataRights.
5
5
  // deletion.<step>.<slug>`.
6
6
 
7
- import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
7
+ import {
8
+ mergeTranslations,
9
+ type TranslationsByLocale,
10
+ translationsByLocaleFromKeys,
11
+ } from "@cosmicdrift/kumiko-renderer";
8
12
  import { USER_DATA_RIGHTS_I18N } from "../i18n";
9
13
 
10
- const LOCALES = ["de", "en"] as const;
11
-
12
- // Operator keys (export-job list/detail titles + entity field labels + nav)
13
- // live in the server i18n; the SystemAdmin entityList screens render them
14
- // client-side, so the web bundle must carry them too — otherwise raw keys
15
- // show. Derive from the single server source instead of duplicating.
16
- const operatorTranslations: TranslationsByLocale = Object.fromEntries(
17
- LOCALES.map((locale) => [
18
- locale,
19
- Object.fromEntries(
20
- Object.entries(USER_DATA_RIGHTS_I18N).map(([key, value]) => [key, value[locale]]),
21
- ),
22
- ]),
23
- );
24
-
25
14
  const apexTranslations: TranslationsByLocale = {
26
- de: {
27
- "userDataRights.deletion.request.title": "Account-Löschung beantragen",
28
- "userDataRights.deletion.request.intro":
29
- "Gib die E-Mail-Adresse deines Kontos ein. Falls ein Konto existiert, schicken wir dir einen Bestätigungs-Link zum Löschen.",
30
- "userDataRights.deletion.request.email": "E-Mail",
31
- "userDataRights.deletion.request.submit": "Bestätigungs-Link anfordern",
32
- "userDataRights.deletion.request.submitting": "…",
33
- "userDataRights.deletion.request.successTitle": "Mail gesendet",
34
- "userDataRights.deletion.request.successBody":
35
- "Falls die E-Mail in unserem System existiert, ist eine Nachricht mit einem Bestätigungs-Link unterwegs. Bitte schau in deinen Posteingang.",
36
- "userDataRights.deletion.request.error": "Etwas ist schief gegangen. Bitte erneut versuchen.",
37
- "userDataRights.deletion.confirm.title": "Account-Löschung bestätigen",
38
- "userDataRights.deletion.confirm.intro":
39
- "Mit dem Bestätigen startet die Lösch-Frist. Bis sie abläuft kannst du die Löschung im eingeloggten Account wieder abbrechen.",
40
- "userDataRights.deletion.confirm.submit": "Löschung bestätigen",
41
- "userDataRights.deletion.confirm.submitting": "…",
42
- "userDataRights.deletion.confirm.successTitle": "Löschung vorgemerkt",
43
- "userDataRights.deletion.confirm.successBody":
44
- "Dein Account wird nach Ablauf der Frist gelöscht. Du kannst die Löschung bis dahin im eingeloggten Account abbrechen.",
45
- "userDataRights.deletion.confirm.invalidToken":
46
- "Der Link ist ungültig oder abgelaufen. Bitte fordere einen neuen an.",
47
- "userDataRights.deletion.confirm.missingToken":
48
- "Kein Token im Link gefunden. Bitte öffne den Link aus der E-Mail erneut.",
49
- "userDataRights.deletion.confirm.error": "Etwas ist schief gegangen. Bitte erneut versuchen.",
50
-
51
- "userDataRights.privacyCenter.title": "Datenschutz",
52
- "userDataRights.privacyCenter.intro":
53
- "Verwalte deine Rechte nach DSGVO: Datenauskunft, Export, Einschränkung und Löschung deines Kontos.",
54
- "userDataRights.privacyCenter.loading": "Lädt …",
55
- "userDataRights.privacyCenter.loadError": "Deine Daten konnten nicht geladen werden.",
56
- "userDataRights.privacyCenter.errors.generic":
57
- "Etwas ist schief gegangen. Bitte erneut versuchen.",
58
-
59
- "userDataRights.privacyCenter.export.title": "Daten exportieren (Art. 20)",
60
- "userDataRights.privacyCenter.export.intro":
61
- "Fordere eine Kopie deiner Daten an. Die Erstellung läuft im Hintergrund; sobald sie fertig ist, kannst du sie hier herunterladen.",
62
- "userDataRights.privacyCenter.export.request": "Daten-Export anfordern",
63
- "userDataRights.privacyCenter.export.requesting": "Wird angefordert …",
64
- "userDataRights.privacyCenter.export.pending":
65
- "Dein Export wird erstellt. Bitte später erneut schauen.",
66
- "userDataRights.privacyCenter.export.failed":
67
- "Die letzte Export-Erstellung ist fehlgeschlagen. Du kannst es erneut versuchen.",
68
- "userDataRights.privacyCenter.export.ready": "Dein Export ist fertig.",
69
- "userDataRights.privacyCenter.export.download": "Export herunterladen",
70
- "userDataRights.privacyCenter.export.availableUntil": "Verfügbar bis {date}",
71
- "userDataRights.privacyCenter.export.requestNew": "Neuen Export anfordern",
72
-
73
- "userDataRights.privacyCenter.restriction.title": "Verarbeitung einschränken (Art. 18)",
74
- "userDataRights.privacyCenter.restriction.explainer":
75
- "Friere dein Konto ein: Die Verarbeitung deiner Daten wird pausiert und du wirst abgemeldet. Das Aufheben der Einschränkung ist danach nur über den Support möglich.",
76
- "userDataRights.privacyCenter.restriction.restrict": "Konto einschränken",
77
- "userDataRights.privacyCenter.restriction.dialogTitle": "Konto wirklich einschränken?",
78
- "userDataRights.privacyCenter.restriction.dialogDescription":
79
- "Du wirst sofort abgemeldet und kannst dich nicht mehr anmelden, bis der Support die Einschränkung aufhebt.",
80
- "userDataRights.privacyCenter.restriction.restricted":
81
- "Dein Konto ist eingeschränkt. Wende dich an den Support, um die Einschränkung aufzuheben.",
82
-
83
- "userDataRights.privacyCenter.deletion.title": "Konto löschen (Art. 17)",
84
- "userDataRights.privacyCenter.deletion.explainer":
85
- "Beantrage die Löschung deines Kontos. Bis zum Ablauf der Frist kannst du die Löschung wieder abbrechen.",
86
- "userDataRights.privacyCenter.deletion.delete": "Konto löschen",
87
- "userDataRights.privacyCenter.deletion.requested": "Dein Konto wird am {date} gelöscht.",
88
- "userDataRights.privacyCenter.deletion.cancel": "Löschung abbrechen",
89
- "userDataRights.privacyCenter.deletion.cancelSuccess": "Die Löschung wurde abgebrochen.",
90
- "userDataRights.privacyCenter.deletion.dialogTitle": "Konto wirklich löschen?",
91
- "userDataRights.privacyCenter.deletion.dialogDescription":
92
- "Mit dem Bestätigen startet die Lösch-Frist. Du kannst die Löschung bis zu ihrem Ablauf wieder abbrechen.",
93
- },
94
15
  en: {
95
16
  "userDataRights.deletion.request.title": "Request account deletion",
96
17
  "userDataRights.deletion.request.intro":
@@ -169,7 +90,7 @@ const apexTranslations: TranslationsByLocale = {
169
90
  },
170
91
  };
171
92
 
172
- export const defaultTranslations: TranslationsByLocale = {
173
- de: { ...apexTranslations["de"], ...operatorTranslations["de"] },
174
- en: { ...apexTranslations["en"], ...operatorTranslations["en"] },
175
- };
93
+ export const defaultTranslations: TranslationsByLocale = mergeTranslations(
94
+ translationsByLocaleFromKeys(USER_DATA_RIGHTS_I18N),
95
+ apexTranslations,
96
+ );
@@ -1,7 +1,7 @@
1
1
  // @runtime client
2
2
  // Default-Bundles für den ProfileScreen. Werden vom userProfileClient()
3
3
  // als Fallback-Bundle in den LocaleProvider gehängt — Apps überschreiben
4
- // einzelne Keys via `userProfileClient({ translations: { de: { … } } })`.
4
+ // einzelne Keys via `userProfileClient({ translations })`.
5
5
  // `auth.errors.invalidCredentials` + `user.errors.emailAlreadyExists`
6
6
  // sind hier gedoppelt, damit der Screen auch ohne die jeweiligen
7
7
  // Feature-Bundles vollständig übersetzt.
@@ -9,41 +9,6 @@
9
9
  import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
10
10
 
11
11
  export const defaultTranslations: TranslationsByLocale = {
12
- de: {
13
- "profile.title": "Profil",
14
-
15
- "profile.email.title": "E-Mail-Adresse",
16
- "profile.email.current": "Aktuelle E-Mail",
17
- "profile.email.new": "Neue E-Mail",
18
- "profile.email.currentPassword": "Aktuelles Passwort",
19
- "profile.email.submit": "E-Mail ändern",
20
- "profile.email.success": "E-Mail geändert. Bitte bestätige deine neue Adresse.",
21
-
22
- "profile.password.title": "Passwort",
23
- "profile.password.old": "Aktuelles Passwort",
24
- "profile.password.new": "Neues Passwort",
25
- "profile.password.confirm": "Neues Passwort bestätigen",
26
- "profile.password.submit": "Passwort ändern",
27
- "profile.password.success": "Passwort geändert.",
28
- "profile.password.mismatch": "Die Passwörter stimmen nicht überein.",
29
-
30
- "profile.danger.title": "Konto löschen",
31
- "profile.danger.explainer":
32
- "Dein Konto wird nach einer Frist endgültig gelöscht. Bis dahin kannst du die Löschung jederzeit abbrechen.",
33
- "profile.danger.delete": "Konto löschen",
34
- "profile.danger.dialogTitle": "Konto wirklich löschen?",
35
- "profile.danger.dialogDescription":
36
- "Nach Ablauf der Frist werden deine Daten endgültig gelöscht. Bis dahin kannst du die Löschung abbrechen.",
37
- "profile.danger.requested":
38
- "Löschung beantragt — dein Konto wird am {date} endgültig gelöscht.",
39
- "profile.danger.cancelDeletion": "Löschung abbrechen",
40
- "profile.danger.cancelSuccess": "Löschung abgebrochen. Dein Konto bleibt bestehen.",
41
-
42
- "profile.errors.generic": "Etwas ist schiefgegangen.",
43
- "profile.errors.emailUnchanged": "Das ist bereits deine E-Mail-Adresse.",
44
- "user.errors.emailAlreadyExists": "Diese E-Mail-Adresse wird bereits verwendet.",
45
- "auth.errors.invalidCredentials": "E-Mail oder Passwort falsch.",
46
- },
47
12
  en: {
48
13
  "profile.title": "Profile",
49
14