@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.207.0",
3
+ "version": "0.208.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>",
@@ -126,12 +126,12 @@
126
126
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
127
127
  },
128
128
  "dependencies": {
129
- "@cosmicdrift/kumiko-dispatcher-live": "0.207.0",
130
- "@cosmicdrift/kumiko-framework": "0.207.0",
131
- "@cosmicdrift/kumiko-headless": "0.207.0",
132
- "@cosmicdrift/kumiko-renderer": "0.207.0",
133
- "@cosmicdrift/kumiko-renderer-web": "0.207.0",
134
- "@cosmicdrift/kumiko-types": "0.207.0",
129
+ "@cosmicdrift/kumiko-dispatcher-live": "0.208.0",
130
+ "@cosmicdrift/kumiko-framework": "0.208.0",
131
+ "@cosmicdrift/kumiko-headless": "0.208.0",
132
+ "@cosmicdrift/kumiko-renderer": "0.208.0",
133
+ "@cosmicdrift/kumiko-renderer-web": "0.208.0",
134
+ "@cosmicdrift/kumiko-types": "0.208.0",
135
135
  "@mollie/api-client": "^4.5.0",
136
136
  "@node-rs/argon2": "^2.0.2",
137
137
  "@types/mailparser": "^3.4.6",
@@ -159,6 +159,7 @@
159
159
  ],
160
160
  "devDependencies": {
161
161
  "@testing-library/user-event": "^14.6.1",
162
- "@types/qrcode": "^1.5.5"
162
+ "@types/qrcode": "^1.5.5",
163
+ "@cosmicdrift/kumiko-locale-de": "0.208.0"
163
164
  }
164
165
  }
@@ -141,7 +141,7 @@ describe("admin-shell boot + workspace composition", () => {
141
141
 
142
142
  test("admin-shell registers server translations bundle", () => {
143
143
  const shell = createAdminShellFeature();
144
- expect(shell.translations?.["admin-shell:nav.tenantOverview"]?.["de"]).toBe("Übersicht");
144
+ expect(shell.translations?.["admin-shell:nav.tenantOverview"]?.["en"]).toBe("Overview");
145
145
  expect(shell.translations?.["screen:tenant-overview.title"]?.["en"]).toBe("Overview");
146
146
  });
147
147
  });
@@ -2,54 +2,32 @@
2
2
  // Pure-Data i18n keys for admin-shell (server r.translations + client pivot).
3
3
  // Without these keys nav labels and workspace tabs render raw QNs in the shell.
4
4
 
5
- type LocalizedString = { readonly de: string; readonly en: string; readonly es: string };
5
+ type LocalizedString = { readonly en: string };
6
6
 
7
7
  export const ADMIN_SHELL_I18N: Readonly<Record<string, LocalizedString>> = {
8
- "screen:tenant-overview.title": { de: "Übersicht", en: "Overview", es: "Resumen" },
9
- "screen:platform-overview.title": { de: "Übersicht", en: "Overview", es: "Resumen" },
10
- "admin-shell:workspace.tenant": {
11
- de: "Administration",
12
- en: "Administration",
13
- es: "Administración",
14
- },
15
- "admin-shell:workspace.platform": { de: "Plattform", en: "Platform", es: "Plataforma" },
16
- "admin-shell:nav.tenantOverview": { de: "Übersicht", en: "Overview", es: "Resumen" },
17
- "admin-shell:nav.platformOverview": { de: "Übersicht", en: "Overview", es: "Resumen" },
18
- "admin-shell:nav.tenants": { de: "Mandanten", en: "Tenants", es: "Organizaciones" },
19
- "admin-shell:nav.tierAdmin": { de: "Tier zuweisen", en: "Assign tier", es: "Asignar tier" },
20
- "admin-shell:overview.tenantTitle": {
21
- de: "Administration",
22
- en: "Administration",
23
- es: "Administración",
24
- },
25
- "admin-shell:overview.platformTitle": { de: "Plattform", en: "Platform", es: "Plataforma" },
26
- "admin-shell:overview.loading": { de: "Lade…", en: "Loading…", es: "Cargando…" },
8
+ "screen:tenant-overview.title": { en: "Overview" },
9
+ "screen:platform-overview.title": { en: "Overview" },
10
+ "admin-shell:workspace.tenant": { en: "Administration" },
11
+ "admin-shell:workspace.platform": { en: "Platform" },
12
+ "admin-shell:nav.tenantOverview": { en: "Overview" },
13
+ "admin-shell:nav.platformOverview": { en: "Overview" },
14
+ "admin-shell:nav.tenants": { en: "Tenants" },
15
+ "admin-shell:nav.tierAdmin": { en: "Assign tier" },
16
+ "admin-shell:overview.tenantTitle": { en: "Administration" },
17
+ "admin-shell:overview.platformTitle": { en: "Platform" },
18
+ "admin-shell:overview.loading": { en: "Loading…" },
27
19
  "admin-shell:overview.pendingInvitations": {
28
- de: "Ausstehende Einladungen",
29
20
  en: "Pending invitations",
30
- es: "Invitaciones pendientes",
31
21
  },
32
- "admin-shell:overview.members": { de: "Mitglieder", en: "Members", es: "Miembros" },
22
+ "admin-shell:overview.members": { en: "Members" },
33
23
  "admin-shell:overview.missingConfig": {
34
- de: "Fehlende Konfiguration",
35
24
  en: "Missing configuration",
36
- es: "Configuración incompleta",
37
25
  },
38
26
  "admin-shell:overview.missingConfigHint": {
39
- de: "Pflichtfelder in den Einstellungen prüfen",
40
27
  en: "Check required settings",
41
- es: "Revisa los ajustes obligatorios",
42
- },
43
- "admin-shell:overview.tenants": { de: "Mandanten", en: "Tenants", es: "Organizaciones" },
44
- "admin-shell:overview.users": { de: "Benutzer", en: "Users", es: "Usuarios" },
45
- "admin-shell:overview.failedJobs": {
46
- de: "Fehlgeschlagene Jobs",
47
- en: "Failed jobs",
48
- es: "Trabajos fallidos",
49
- },
50
- "admin-shell:overview.failedJobsHint": {
51
- de: "Job-Runs prüfen",
52
- en: "Review job runs",
53
- es: "Revisa las ejecuciones de trabajos",
54
28
  },
29
+ "admin-shell:overview.tenants": { en: "Tenants" },
30
+ "admin-shell:overview.users": { en: "Users" },
31
+ "admin-shell:overview.failedJobs": { en: "Failed jobs" },
32
+ "admin-shell:overview.failedJobsHint": { en: "Review job runs" },
55
33
  };
@@ -1,16 +1,11 @@
1
1
  // @runtime client
2
2
  // Client pivot of ADMIN_SHELL_I18N — same keys as server r.translations bundle.
3
3
 
4
- import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
4
+ import {
5
+ type TranslationsByLocale,
6
+ translationsByLocaleFromKeys,
7
+ } from "@cosmicdrift/kumiko-renderer";
5
8
  import { ADMIN_SHELL_I18N } from "../i18n";
6
9
 
7
- const LOCALES = ["de", "en", "es"] as const;
8
-
9
- export const defaultTranslations: TranslationsByLocale = Object.fromEntries(
10
- LOCALES.map((locale) => [
11
- locale,
12
- Object.fromEntries(
13
- Object.entries(ADMIN_SHELL_I18N).map(([key, value]) => [key, value[locale]]),
14
- ),
15
- ]),
16
- );
10
+ export const defaultTranslations: TranslationsByLocale =
11
+ translationsByLocaleFromKeys(ADMIN_SHELL_I18N);
package/src/audit/i18n.ts CHANGED
@@ -1,47 +1,31 @@
1
1
  // @runtime client
2
2
  // Server + client i18n for audit (nav labels + AuditLogScreen).
3
3
 
4
- type LocalizedString = { readonly de: string; readonly en: string; readonly es: string };
4
+ type LocalizedString = { readonly en: string };
5
5
 
6
6
  export const AUDIT_I18N: Readonly<Record<string, LocalizedString>> = {
7
- "screen:audit-log.title": { de: "Audit-Log", en: "Audit log", es: "Registro de auditoría" },
8
- "screen:audit-log-detail.title": { de: "Ereignis", en: "Event", es: "Evento" },
9
- "audit:nav.auditLog": { de: "Audit", en: "Audit", es: "Auditoría" },
10
- "audit.log.title": { de: "Audit-Log", en: "Audit log", es: "Registro de auditoría" },
11
- "audit.log.loading": { de: "Lade Ereignisse…", en: "Loading events…", es: "Cargando eventos…" },
12
- "audit.log.empty": { de: "Keine Ereignisse.", en: "No events.", es: "No hay eventos." },
13
- "audit.log.newest": { de: "Neueste", en: "Newest", es: "Más recientes" },
14
- "audit.log.older": { de: "Ältere laden", en: "Load older", es: "Cargar más antiguos" },
15
- "audit.log.col.when": { de: "Zeit", en: "When", es: "Fecha" },
16
- "audit.log.col.type": { de: "Ereignis", en: "Event", es: "Evento" },
17
- "audit.log.col.aggregate": { de: "Aggregate", en: "Aggregate", es: "Agregado" },
18
- "audit.log.col.actor": { de: "Akteur", en: "Actor", es: "Actor" },
19
- "audit.log.filter.eventType": { de: "Ereignistyp", en: "Event type", es: "Tipo de evento" },
20
- "audit.log.filter.aggregateType": {
21
- de: "Aggregate-Typ",
22
- en: "Aggregate type",
23
- es: "Tipo de agregado",
24
- },
25
- "audit.log.filter.from": { de: "Von", en: "From", es: "Desde" },
26
- "audit.log.filter.to": { de: "Bis", en: "To", es: "Hasta" },
27
- "audit.log.filter.apply": { de: "Filtern", en: "Filter", es: "Filtrar" },
28
- "audit.log.filter.reset": { de: "Zurücksetzen", en: "Reset", es: "Restablecer" },
29
- "audit.log.details": { de: "Details", en: "Details", es: "Detalles" },
30
- "audit.log.detail.loading": {
31
- de: "Lade Ereignis…",
32
- en: "Loading event…",
33
- es: "Cargando evento…",
34
- },
35
- "audit.log.detail.missing": {
36
- de: "Ereignis nicht gefunden.",
37
- en: "Event not found.",
38
- es: "Evento no encontrado.",
39
- },
40
- "audit.log.detail.payload": {
41
- de: "Ereignis-Payload",
42
- en: "Event payload",
43
- es: "Payload del evento",
44
- },
45
- "audit.log.detail.metadata": { de: "Metadaten", en: "Metadata", es: "Metadatos" },
46
- "audit.log.detail.field.id": { de: "Ereignis-ID", en: "Event ID", es: "ID del evento" },
7
+ "screen:audit-log.title": { en: "Audit log" },
8
+ "screen:audit-log-detail.title": { en: "Event" },
9
+ "audit:nav.auditLog": { en: "Audit" },
10
+ "audit.log.title": { en: "Audit log" },
11
+ "audit.log.loading": { en: "Loading events…" },
12
+ "audit.log.empty": { en: "No events." },
13
+ "audit.log.newest": { en: "Newest" },
14
+ "audit.log.older": { en: "Load older" },
15
+ "audit.log.col.when": { en: "When" },
16
+ "audit.log.col.type": { en: "Event" },
17
+ "audit.log.col.aggregate": { en: "Aggregate" },
18
+ "audit.log.col.actor": { en: "Actor" },
19
+ "audit.log.filter.eventType": { en: "Event type" },
20
+ "audit.log.filter.aggregateType": { en: "Aggregate type" },
21
+ "audit.log.filter.from": { en: "From" },
22
+ "audit.log.filter.to": { en: "To" },
23
+ "audit.log.filter.apply": { en: "Filter" },
24
+ "audit.log.filter.reset": { en: "Reset" },
25
+ "audit.log.details": { en: "Details" },
26
+ "audit.log.detail.loading": { en: "Loading event…" },
27
+ "audit.log.detail.missing": { en: "Event not found." },
28
+ "audit.log.detail.payload": { en: "Event payload" },
29
+ "audit.log.detail.metadata": { en: "Metadata" },
30
+ "audit.log.detail.field.id": { en: "Event ID" },
47
31
  };
@@ -1,12 +1,8 @@
1
1
  // @runtime client
2
- import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
2
+ import {
3
+ type TranslationsByLocale,
4
+ translationsByLocaleFromKeys,
5
+ } from "@cosmicdrift/kumiko-renderer";
3
6
  import { AUDIT_I18N } from "../i18n";
4
7
 
5
- const LOCALES = ["de", "en", "es"] as const;
6
-
7
- export const defaultTranslations: TranslationsByLocale = Object.fromEntries(
8
- LOCALES.map((locale) => [
9
- locale,
10
- Object.fromEntries(Object.entries(AUDIT_I18N).map(([key, value]) => [key, value[locale]])),
11
- ]),
12
- );
8
+ export const defaultTranslations: TranslationsByLocale = translationsByLocaleFromKeys(AUDIT_I18N);
@@ -4,6 +4,8 @@
4
4
  // dort. Diese Tests prüfen daher die strukturierte Content, nicht HTML.
5
5
 
6
6
  import { describe, expect, test } from "bun:test";
7
+ import { registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
8
+ import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
7
9
  import type { AuthMailContent } from "../email-templates";
8
10
  import { renderResetPasswordEmail, renderVerifyEmail } from "../email-templates";
9
11
 
@@ -18,6 +20,8 @@ function textOf(content: AuthMailContent): string {
18
20
  return content.sections.map((section) => ("text" in section ? section.text : "")).join(" ");
19
21
  }
20
22
 
23
+ registerMailTranslations("de", localeDeBundle);
24
+
21
25
  describe("renderResetPasswordEmail", () => {
22
26
  const baseArgs = {
23
27
  url: "https://acme.example/reset?token=t-abc123",
@@ -5,33 +5,71 @@
5
5
  // renderer; these templates are deliberately plain so the renderer (and the
6
6
  // operator reading the mailer log) can rely on them.
7
7
  //
8
- // Locale: de + en. Apps with other languages render themselves.
8
+ // English ships here. Other languages register via @cosmicdrift/kumiko-locale-*
9
+ // (localeDe() / localeEs() call registerMailTranslations).
9
10
 
11
+ import { mailT, registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
10
12
  import { Temporal } from "temporal-polyfill";
11
13
 
12
- export type AuthMailLocale = "de" | "en";
14
+ export type AuthMailLocale = string;
15
+
16
+ const AUTH_MAIL_EN: Readonly<Record<string, string>> = {
17
+ "auth.mail.appNameDefault": "Account",
18
+ "auth.mail.reset.subject": "{app} — Reset your password",
19
+ "auth.mail.reset.greeting": "Hi,",
20
+ "auth.mail.reset.intro":
21
+ "you requested a password reset for {app}. Click the link below to set a new password:",
22
+ "auth.mail.reset.button": "Reset password",
23
+ "auth.mail.reset.expiry": "The link expires on {when}.",
24
+ "auth.mail.reset.ignore":
25
+ "If you didn't request a reset, you can safely ignore this email — your password won't change.",
26
+ "auth.mail.verify.subject": "{app} — Verify your email",
27
+ "auth.mail.verify.greeting": "Welcome,",
28
+ "auth.mail.verify.intro": "please verify your email address for {app} to activate your account:",
29
+ "auth.mail.verify.button": "Verify email",
30
+ "auth.mail.verify.expiry": "The link expires on {when}.",
31
+ "auth.mail.verify.ignore": "If you didn't create this account, you can ignore this email.",
32
+ "auth.mail.activation.subject": "{app} — Activate your account",
33
+ "auth.mail.activation.greeting": "Welcome,",
34
+ "auth.mail.activation.intro":
35
+ "click the link below to activate your {app} account. The next step is choosing your password:",
36
+ "auth.mail.activation.button": "Activate account",
37
+ "auth.mail.activation.expiry": "The link expires on {when}.",
38
+ "auth.mail.activation.ignore":
39
+ "If you didn't sign up, you can ignore this email — no account is created until you open the link.",
40
+ "auth.mail.invite.subject": "{app} — Workspace invitation",
41
+ "auth.mail.invite.greeting": "Hi,",
42
+ "auth.mail.invite.intro":
43
+ "you've been invited to a {app} workspace as {role}. Click the link below to accept:",
44
+ "auth.mail.invite.button": "Accept invitation",
45
+ "auth.mail.invite.expiry": "The link expires on {when}.",
46
+ "auth.mail.invite.ignore": "If you weren't expecting this invitation, you can ignore this email.",
47
+ "auth.mail.unlock.subject": "{app} — Unlock your account",
48
+ "auth.mail.unlock.greeting": "Hi,",
49
+ "auth.mail.unlock.intro":
50
+ "your {app} account was temporarily locked after several failed sign-in attempts. Click the link below to unlock it immediately:",
51
+ "auth.mail.unlock.button": "Unlock account",
52
+ "auth.mail.unlock.expiry": "The link expires on {when}.",
53
+ "auth.mail.unlock.ignore":
54
+ "If you didn't trigger this lock, you can ignore this email — the lock expires on its own.",
55
+ };
56
+
57
+ registerMailTranslations("en", AUTH_MAIL_EN);
13
58
 
14
- // Unified args for the structured token-mail renderers (reset + verify).
15
- // `url` is the fully-built magic-link — the handler already appended ?token=.
16
59
  export type RenderTokenContentArgs = {
17
60
  readonly url: string;
18
61
  readonly expiresAt: string;
19
62
  readonly locale?: AuthMailLocale;
20
- /** Optional: App-Name fürs Subject + Header. Default "Account". */
63
+ /** Optional app name for subject + intro. Default from auth.mail.appNameDefault. */
21
64
  readonly appName?: string;
22
65
  };
23
66
 
24
- // Invite adds the role to the unified token-content args; `url` is the fully-
25
- // built magic-link (the handler already appended ?token=).
26
67
  export type RenderInviteEmailArgs = RenderTokenContentArgs & { readonly role: string };
27
68
 
28
69
  export type AuthMailSection =
29
70
  | { readonly text: string }
30
71
  | { readonly button: { readonly label: string; readonly url: string } };
31
72
 
32
- // Structured content the magic-link handlers pass as the delivery `data`
33
- // payload: renderer-simple turns header/sections/footer into HTML, the
34
- // email-channel reads `subject`. No pre-rendered HTML — the renderer owns layout.
35
73
  export type AuthMailContent = {
36
74
  readonly subject: string;
37
75
  readonly header: string;
@@ -39,95 +77,6 @@ export type AuthMailContent = {
39
77
  readonly footer: string;
40
78
  };
41
79
 
42
- const STRINGS = {
43
- de: {
44
- resetSubject: (app: string) => `${app} — Passwort zurücksetzen`,
45
- resetGreeting: "Hallo,",
46
- // guard:dup-ok — false positive: i18n-String-Template, gleiche Arrow-Struktur wie anonymous fn in collect-table-metas
47
- resetIntro: (app: string) =>
48
- `du hast den Reset deines Passworts für ${app} angefordert. Klicke auf den folgenden Link, um ein neues Passwort zu setzen:`,
49
- resetButton: "Passwort zurücksetzen",
50
- resetExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
51
- resetIgnore:
52
- "Falls du keinen Reset angefordert hast, kannst du diese E-Mail einfach ignorieren — dein Passwort bleibt unverändert.",
53
- verifySubject: (app: string) => `${app} — E-Mail bestätigen`,
54
- verifyGreeting: "Willkommen,",
55
- verifyIntro: (app: string) =>
56
- `bitte bestätige deine E-Mail-Adresse für ${app}, um dein Konto zu aktivieren:`,
57
- verifyButton: "E-Mail bestätigen",
58
- verifyExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
59
- verifyIgnore: "Falls du dieses Konto nicht angelegt hast, kannst du diese E-Mail ignorieren.",
60
- activationSubject: (app: string) => `${app} — Account aktivieren`,
61
- activationGreeting: "Willkommen,",
62
- activationIntro: (app: string) =>
63
- `klicke auf den folgenden Link, um deinen ${app}-Account zu aktivieren. Im nächsten Schritt setzt du dein Passwort:`,
64
- activationButton: "Account aktivieren",
65
- activationExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
66
- activationIgnore:
67
- "Falls du dich nicht registriert hast, kannst du diese E-Mail ignorieren — es wird kein Account erstellt, solange du den Link nicht öffnest.",
68
- inviteSubject: (app: string) => `${app} — Einladung zum Workspace`,
69
- inviteGreeting: "Hallo,",
70
- inviteIntro: (app: string, role: string) =>
71
- `du wurdest zu einem ${app}-Workspace als ${role} eingeladen. Klicke auf den folgenden Link, um die Einladung anzunehmen:`,
72
- inviteButton: "Einladung annehmen",
73
- inviteExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
74
- inviteIgnore:
75
- "Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.",
76
- unlockSubject: (app: string) => `${app} — Konto entsperren`,
77
- unlockGreeting: "Hallo,",
78
- unlockIntro: (app: string) =>
79
- `dein ${app}-Konto wurde nach mehreren fehlgeschlagenen Anmeldeversuchen vorübergehend gesperrt. Klicke auf den folgenden Link, um es sofort zu entsperren:`,
80
- unlockButton: "Konto entsperren",
81
- unlockExpiry: (when: string) => `Der Link läuft am ${when} ab.`,
82
- unlockIgnore:
83
- "Falls du diese Sperre nicht ausgelöst hast, kannst du diese E-Mail ignorieren — die Sperre läuft von selbst ab.",
84
- },
85
- en: {
86
- resetSubject: (app: string) => `${app} — Reset your password`,
87
- resetGreeting: "Hi,",
88
- resetIntro: (app: string) =>
89
- `you requested a password reset for ${app}. Click the link below to set a new password:`,
90
- resetButton: "Reset password",
91
- resetExpiry: (when: string) => `The link expires on ${when}.`,
92
- resetIgnore:
93
- "If you didn't request a reset, you can safely ignore this email — your password won't change.",
94
- verifySubject: (app: string) => `${app} — Verify your email`,
95
- verifyGreeting: "Welcome,",
96
- verifyIntro: (app: string) =>
97
- `please verify your email address for ${app} to activate your account:`,
98
- verifyButton: "Verify email",
99
- verifyExpiry: (when: string) => `The link expires on ${when}.`,
100
- verifyIgnore: "If you didn't create this account, you can ignore this email.",
101
- activationSubject: (app: string) => `${app} — Activate your account`,
102
- activationGreeting: "Welcome,",
103
- activationIntro: (app: string) =>
104
- `click the link below to activate your ${app} account. The next step is choosing your password:`,
105
- activationButton: "Activate account",
106
- activationExpiry: (when: string) => `The link expires on ${when}.`,
107
- activationIgnore:
108
- "If you didn't sign up, you can ignore this email — no account is created until you open the link.",
109
- inviteSubject: (app: string) => `${app} — Workspace invitation`,
110
- inviteGreeting: "Hi,",
111
- inviteIntro: (app: string, role: string) =>
112
- `you've been invited to a ${app} workspace as ${role}. Click the link below to accept:`,
113
- inviteButton: "Accept invitation",
114
- inviteExpiry: (when: string) => `The link expires on ${when}.`,
115
- inviteIgnore: "If you weren't expecting this invitation, you can ignore this email.",
116
- unlockSubject: (app: string) => `${app} — Unlock your account`,
117
- unlockGreeting: "Hi,",
118
- unlockIntro: (app: string) =>
119
- `your ${app} account was temporarily locked after several failed sign-in attempts. Click the link below to unlock it immediately:`,
120
- unlockButton: "Unlock account",
121
- unlockExpiry: (when: string) => `The link expires on ${when}.`,
122
- unlockIgnore:
123
- "If you didn't trigger this lock, you can ignore this email — the lock expires on its own.",
124
- },
125
- } as const;
126
-
127
- // Structured content for the delivery path. header = action title (CTA label),
128
- // sections = greeting/intro/button/expiry, footer = the "ignore if not you"
129
- // reassurance. The old plain-text fallback-URL link drops out — renderer-simple
130
- // has no link-text section and the button carries the URL.
131
80
  function tokenMailContent(spec: {
132
81
  readonly subject: string;
133
82
  readonly header: string;
@@ -151,91 +100,90 @@ function tokenMailContent(spec: {
151
100
  };
152
101
  }
153
102
 
103
+ function t(locale: string, key: string, params?: Readonly<Record<string, string>>): string {
104
+ return mailT(locale, key, params);
105
+ }
106
+
107
+ function appNameFor(args: RenderTokenContentArgs): string {
108
+ const locale = args.locale ?? "en";
109
+ return args.appName ?? t(locale, "auth.mail.appNameDefault");
110
+ }
111
+
154
112
  export function renderResetPasswordEmail(args: RenderTokenContentArgs): AuthMailContent {
155
113
  const locale = args.locale ?? "en";
156
- const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
157
- const t = STRINGS[locale];
114
+ const app = appNameFor(args);
158
115
  return tokenMailContent({
159
- subject: t.resetSubject(appName),
160
- header: t.resetButton,
161
- greeting: t.resetGreeting,
162
- intro: t.resetIntro(appName),
163
- buttonLabel: t.resetButton,
116
+ subject: t(locale, "auth.mail.reset.subject", { app }),
117
+ header: t(locale, "auth.mail.reset.button"),
118
+ greeting: t(locale, "auth.mail.reset.greeting"),
119
+ intro: t(locale, "auth.mail.reset.intro", { app }),
120
+ buttonLabel: t(locale, "auth.mail.reset.button"),
164
121
  buttonUrl: args.url,
165
- expiry: t.resetExpiry(formatExpiry(args.expiresAt)),
166
- ignore: t.resetIgnore,
122
+ expiry: t(locale, "auth.mail.reset.expiry", { when: formatExpiry(args.expiresAt) }),
123
+ ignore: t(locale, "auth.mail.reset.ignore"),
167
124
  });
168
125
  }
169
126
 
170
127
  export function renderUnlockAccountEmail(args: RenderTokenContentArgs): AuthMailContent {
171
128
  const locale = args.locale ?? "en";
172
- const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
173
- const t = STRINGS[locale];
129
+ const app = appNameFor(args);
174
130
  return tokenMailContent({
175
- subject: t.unlockSubject(appName),
176
- header: t.unlockButton,
177
- greeting: t.unlockGreeting,
178
- intro: t.unlockIntro(appName),
179
- buttonLabel: t.unlockButton,
131
+ subject: t(locale, "auth.mail.unlock.subject", { app }),
132
+ header: t(locale, "auth.mail.unlock.button"),
133
+ greeting: t(locale, "auth.mail.unlock.greeting"),
134
+ intro: t(locale, "auth.mail.unlock.intro", { app }),
135
+ buttonLabel: t(locale, "auth.mail.unlock.button"),
180
136
  buttonUrl: args.url,
181
- expiry: t.unlockExpiry(formatExpiry(args.expiresAt)),
182
- ignore: t.unlockIgnore,
137
+ expiry: t(locale, "auth.mail.unlock.expiry", { when: formatExpiry(args.expiresAt) }),
138
+ ignore: t(locale, "auth.mail.unlock.ignore"),
183
139
  });
184
140
  }
185
141
 
186
142
  export function renderVerifyEmail(args: RenderTokenContentArgs): AuthMailContent {
187
143
  const locale = args.locale ?? "en";
188
- const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
189
- const t = STRINGS[locale];
144
+ const app = appNameFor(args);
190
145
  return tokenMailContent({
191
- subject: t.verifySubject(appName),
192
- header: t.verifyButton,
193
- greeting: t.verifyGreeting,
194
- intro: t.verifyIntro(appName),
195
- buttonLabel: t.verifyButton,
146
+ subject: t(locale, "auth.mail.verify.subject", { app }),
147
+ header: t(locale, "auth.mail.verify.button"),
148
+ greeting: t(locale, "auth.mail.verify.greeting"),
149
+ intro: t(locale, "auth.mail.verify.intro", { app }),
150
+ buttonLabel: t(locale, "auth.mail.verify.button"),
196
151
  buttonUrl: args.url,
197
- expiry: t.verifyExpiry(formatExpiry(args.expiresAt)),
198
- ignore: t.verifyIgnore,
152
+ expiry: t(locale, "auth.mail.verify.expiry", { when: formatExpiry(args.expiresAt) }),
153
+ ignore: t(locale, "auth.mail.verify.ignore"),
199
154
  });
200
155
  }
201
156
 
202
157
  export function renderActivationEmail(args: RenderTokenContentArgs): AuthMailContent {
203
158
  const locale = args.locale ?? "en";
204
- const appName = args.appName ?? (locale === "de" ? "Konto" : "Account");
205
- const t = STRINGS[locale];
159
+ const app = appNameFor(args);
206
160
  return tokenMailContent({
207
- subject: t.activationSubject(appName),
208
- header: t.activationButton,
209
- greeting: t.activationGreeting,
210
- intro: t.activationIntro(appName),
211
- buttonLabel: t.activationButton,
161
+ subject: t(locale, "auth.mail.activation.subject", { app }),
162
+ header: t(locale, "auth.mail.activation.button"),
163
+ greeting: t(locale, "auth.mail.activation.greeting"),
164
+ intro: t(locale, "auth.mail.activation.intro", { app }),
165
+ buttonLabel: t(locale, "auth.mail.activation.button"),
212
166
  buttonUrl: args.url,
213
- expiry: t.activationExpiry(formatExpiry(args.expiresAt)),
214
- ignore: t.activationIgnore,
167
+ expiry: t(locale, "auth.mail.activation.expiry", { when: formatExpiry(args.expiresAt) }),
168
+ ignore: t(locale, "auth.mail.activation.ignore"),
215
169
  });
216
170
  }
217
171
 
218
172
  export function renderInviteEmail(args: RenderInviteEmailArgs): AuthMailContent {
219
173
  const locale = args.locale ?? "en";
220
- const appName = args.appName ?? "Workspace";
221
- const t = STRINGS[locale];
174
+ const app = args.appName ?? "Workspace";
222
175
  return tokenMailContent({
223
- subject: t.inviteSubject(appName),
224
- header: t.inviteButton,
225
- greeting: t.inviteGreeting,
226
- intro: t.inviteIntro(appName, args.role),
227
- buttonLabel: t.inviteButton,
176
+ subject: t(locale, "auth.mail.invite.subject", { app }),
177
+ header: t(locale, "auth.mail.invite.button"),
178
+ greeting: t(locale, "auth.mail.invite.greeting"),
179
+ intro: t(locale, "auth.mail.invite.intro", { app, role: args.role }),
180
+ buttonLabel: t(locale, "auth.mail.invite.button"),
228
181
  buttonUrl: args.url,
229
- expiry: t.inviteExpiry(formatExpiry(args.expiresAt)),
230
- ignore: t.inviteIgnore,
182
+ expiry: t(locale, "auth.mail.invite.expiry", { when: formatExpiry(args.expiresAt) }),
183
+ ignore: t(locale, "auth.mail.invite.ignore"),
231
184
  });
232
185
  }
233
186
 
234
- // ISO-Timestamp aus dem Token-Handler ("2026-05-04T13:45:00.000Z") in
235
- // "2026-05-04 13:45 UTC" rendern. Locale-unabhängig damit der Mail-
236
- // Renderer keine locale-spezifischen Number-Formatter mitschleppt; UTC-
237
- // Suffix damit der User unabhängig von seiner Tz sieht wann der Link
238
- // abläuft. Bei un-parsbarem Input fällt's auf den raw-string zurück.
239
187
  function formatExpiry(iso: string): string {
240
188
  try {
241
189
  const z = Temporal.Instant.from(iso).toZonedDateTimeISO("UTC");