@cosmicdrift/kumiko-samples 0.340.0 → 0.342.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 (124) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -1
  3. package/packages/bundled-features/src/audit/constants.ts +5 -0
  4. package/packages/bundled-features/src/audit/feature.ts +47 -6
  5. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +53 -0
  6. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +325 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +30 -0
  8. package/packages/bundled-features/src/auth-email-password/constants.ts +5 -0
  9. package/packages/bundled-features/src/auth-email-password/errors.ts +9 -0
  10. package/packages/bundled-features/src/auth-email-password/feature.ts +2 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +13 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept.write.ts +12 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +210 -113
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +6 -1
  15. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +1 -0
  16. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +1 -0
  17. package/packages/bundled-features/src/auth-email-password/index.ts +13 -0
  18. package/packages/bundled-features/src/auth-email-password/invite-token-store.ts +6 -0
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +1 -0
  20. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
  21. package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
  22. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
  23. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
  24. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
  25. package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
  26. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
  27. package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
  28. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
  29. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
  30. package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
  31. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  32. package/packages/bundled-features/src/auth-mfa/config.ts +12 -3
  33. package/packages/bundled-features/src/auth-mfa/feature.ts +7 -2
  34. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +2 -1
  35. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
  36. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
  37. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  38. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +1 -0
  39. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +1 -0
  40. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +1 -0
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +83 -34
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +4 -1
  44. package/packages/bundled-features/src/channel-discord/discord-channel.ts +5 -0
  45. package/packages/bundled-features/src/channel-discord/feature.ts +16 -1
  46. package/packages/bundled-features/src/channel-discord/index.ts +7 -1
  47. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  48. package/packages/bundled-features/src/channel-email/email-channel.ts +9 -1
  49. package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
  50. package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
  51. package/packages/bundled-features/src/channel-email/types.ts +4 -0
  52. package/packages/bundled-features/src/channel-slack/feature.ts +12 -1
  53. package/packages/bundled-features/src/channel-slack/index.ts +6 -1
  54. package/packages/bundled-features/src/channel-slack/slack-channel.ts +5 -0
  55. package/packages/bundled-features/src/channel-teams/feature.ts +12 -1
  56. package/packages/bundled-features/src/channel-teams/index.ts +6 -1
  57. package/packages/bundled-features/src/channel-teams/teams-channel.ts +5 -0
  58. package/packages/bundled-features/src/channel-telegram/changes.json +9 -0
  59. package/packages/bundled-features/src/channel-telegram/feature.ts +8 -2
  60. package/packages/bundled-features/src/channel-telegram/index.ts +8 -1
  61. package/packages/bundled-features/src/channel-telegram/telegram-channel.ts +39 -11
  62. package/packages/bundled-features/src/delivery/changes.json +7 -0
  63. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +18 -5
  64. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +19 -10
  65. package/packages/bundled-features/src/delivery/delivery-service.ts +78 -32
  66. package/packages/bundled-features/src/delivery/index.ts +4 -0
  67. package/packages/bundled-features/src/delivery/jobs.ts +3 -1
  68. package/packages/bundled-features/src/delivery/types.ts +10 -4
  69. package/packages/bundled-features/src/files/changes.json +13 -1
  70. package/packages/bundled-features/src/legal-pages/README.md +26 -3
  71. package/packages/bundled-features/src/legal-pages/feature.ts +27 -85
  72. package/packages/bundled-features/src/legal-pages/lib/boot-check.ts +57 -0
  73. package/packages/bundled-features/src/legal-pages/lib/validate-routes.ts +21 -0
  74. package/packages/bundled-features/src/page-render/index.ts +2 -0
  75. package/packages/bundled-features/src/page-render/layout.ts +17 -0
  76. package/packages/bundled-features/src/page-render/trailing-slash.ts +15 -0
  77. package/packages/bundled-features/src/renderer-simple/changes.json +6 -0
  78. package/packages/bundled-features/src/renderer-simple/feature.ts +1 -0
  79. package/packages/bundled-features/src/renderer-simple/index.ts +6 -1
  80. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +103 -19
  81. package/packages/bundled-features/src/secrets/announce-secret-change.ts +30 -0
  82. package/packages/bundled-features/src/secrets/changes.json +12 -0
  83. package/packages/bundled-features/src/secrets/feature.ts +1 -1
  84. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +2 -0
  85. package/packages/bundled-features/src/secrets/handlers/set.write.ts +5 -1
  86. package/packages/bundled-features/src/secrets/i18n.ts +4 -0
  87. package/packages/bundled-features/src/secrets/secrets-context.ts +9 -0
  88. package/packages/bundled-features/src/secrets/write-gate.ts +41 -0
  89. package/packages/bundled-features/src/tenant/changes.json +6 -0
  90. package/packages/bundled-features/src/tenant/constants.ts +1 -0
  91. package/packages/bundled-features/src/tenant/handlers/add-member.write.ts +27 -3
  92. package/packages/bundled-features/src/tenant/handlers/invitations.query.ts +15 -1
  93. package/packages/bundled-features/src/tenant/index.ts +1 -0
  94. package/packages/bundled-features/src/tenant/invitation-table.ts +10 -0
  95. package/packages/bundled-features/src/tenant-lifecycle/lifecycle-gate.ts +2 -0
  96. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +2 -2
  97. package/packages/bundled-features/src/tier-engine/feature.ts +92 -14
  98. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +18 -13
  99. package/packages/bundled-features/src/user/invited-global-roles.ts +58 -0
  100. package/packages/bundled-features/src/user-data-rights/feature.ts +3 -0
  101. package/packages/bundled-features/src/waitlist/changes.json +23 -0
  102. package/packages/bundled-features/src/waitlist/constants.ts +44 -0
  103. package/packages/bundled-features/src/waitlist/entity.ts +76 -0
  104. package/packages/bundled-features/src/waitlist/feature.ts +50 -0
  105. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +175 -0
  106. package/packages/bundled-features/src/waitlist/handlers/list.query.ts +12 -0
  107. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +120 -0
  108. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +163 -0
  109. package/packages/bundled-features/src/waitlist/i18n.ts +33 -0
  110. package/packages/bundled-features/src/waitlist/index.ts +15 -0
  111. package/packages/bundled-features/src/waitlist/lib.ts +36 -0
  112. package/packages/bundled-features/src/waitlist/mail.ts +114 -0
  113. package/packages/bundled-features/src/waitlist/options.ts +42 -0
  114. package/packages/bundled-features/src/waitlist/payloads.ts +55 -0
  115. package/packages/bundled-features/src/waitlist/screens.ts +43 -0
  116. package/packages/bundled-features/src/waitlist/user-data-hooks.ts +82 -0
  117. package/samples/apps/use-all-bundled/feature-manifest.json +63 -7
  118. package/samples/apps/use-all-bundled/kumiko/migrations/0033_waitlist.rebuild.json +4 -0
  119. package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
  120. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  121. package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
  122. package/samples/recipes/chat-channels/README.md +2 -0
  123. package/samples/recipes/managed-config/README.md +1 -1
  124. package/samples/recipes/rate-limiting/README.md +21 -0
@@ -50,12 +50,10 @@ export function VerifyEmailScreen({
50
50
  if (status === "missing-token") {
51
51
  return (
52
52
  <AuthCard title={title ?? t("auth.verifyEmail.errorTitle")}>
53
- <div className="p-6 pt-0 flex flex-col gap-4">
54
- <p className="text-sm text-muted-foreground">{t("auth.verifyEmail.missingToken")}</p>
55
- <Link href={loginHref} variant="muted">
56
- {t("auth.verifyEmail.goToLogin")}
57
- </Link>
58
- </div>
53
+ <p className="text-sm text-muted-foreground">{t("auth.verifyEmail.missingToken")}</p>
54
+ <Link href={loginHref} variant="muted">
55
+ {t("auth.verifyEmail.goToLogin")}
56
+ </Link>
59
57
  </AuthCard>
60
58
  );
61
59
  }
@@ -75,12 +73,10 @@ export function VerifyEmailScreen({
75
73
  if (status === "success") {
76
74
  return (
77
75
  <AuthCard title={title ?? t("auth.verifyEmail.successTitle")}>
78
- <div className="p-6 pt-0 flex flex-col gap-4">
79
- <p className="text-sm text-muted-foreground">{t("auth.verifyEmail.successBody")}</p>
80
- <Link href={loginHref} variant="button">
81
- {t("auth.verifyEmail.goToLogin")}
82
- </Link>
83
- </div>
76
+ <p className="text-sm text-muted-foreground">{t("auth.verifyEmail.successBody")}</p>
77
+ <Link href={loginHref} variant="button">
78
+ {t("auth.verifyEmail.goToLogin")}
79
+ </Link>
84
80
  </AuthCard>
85
81
  );
86
82
  }
@@ -88,12 +84,10 @@ export function VerifyEmailScreen({
88
84
  // status === "error"
89
85
  return (
90
86
  <AuthCard title={title ?? t("auth.verifyEmail.errorTitle")}>
91
- <div className="p-6 pt-0 flex flex-col gap-4">
92
- <p className="text-sm text-muted-foreground">{t("auth.verifyEmail.errorBody")}</p>
93
- <Link href={loginHref} variant="muted">
94
- {t("auth.verifyEmail.goToLogin")}
95
- </Link>
96
- </div>
87
+ <p className="text-sm text-muted-foreground">{t("auth.verifyEmail.errorBody")}</p>
88
+ <Link href={loginHref} variant="muted">
89
+ {t("auth.verifyEmail.goToLogin")}
90
+ </Link>
97
91
  </AuthCard>
98
92
  );
99
93
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "breaking",
5
+ "title": "MFA required policy is writable by the system only",
6
+ "migration": "Tenant admins can no longer write auth-mfa:config:required (write access is now system-only). Set the app-wide default with createAuthMfaFeature({ requiredPolicy }) / the mfa options and per-tenant overrides with runBootstrap tenants[].config or a system write (createSystemUser). Code that wrote the key as TenantAdmin/Admin/SystemAdmin over HTTP must switch to one of these."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -8,7 +8,11 @@
8
8
  // UI). Flipping this away from "optional" before PR3 locks out every
9
9
  // unenrolled matching user with no recovery path. Fine for a tenant that
10
10
  // enrolls its admins out-of-band first; a footgun otherwise.
11
- import { type ConfigKeyHandle, createTenantConfig } from "@cosmicdrift/kumiko-framework/engine";
11
+ import {
12
+ access,
13
+ type ConfigKeyHandle,
14
+ createTenantConfig,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
12
16
 
13
17
  export const MFA_REQUIRED_POLICIES = ["optional", "admins", "all"] as const;
14
18
  export type MfaRequiredPolicy = (typeof MFA_REQUIRED_POLICIES)[number];
@@ -18,9 +22,14 @@ export const mfaRequiredConfigHandle: ConfigKeyHandle<"select"> = {
18
22
  type: "select",
19
23
  };
20
24
 
21
- export function mfaRequiredConfigKey() {
25
+ // Write access is machine-only: a TenantAdmin must not be able to relax the
26
+ // policy back to "optional". The app-wide default comes from
27
+ // `createAuthMfaFeature({ requiredPolicy })`, per-tenant overrides are set by
28
+ // system writes (runBootstrap `tenants[].config`, jobs, SystemAdmin tooling).
29
+ export function mfaRequiredConfigKey(defaultPolicy: MfaRequiredPolicy = "optional") {
22
30
  return createTenantConfig("select", {
23
- default: "optional" satisfies MfaRequiredPolicy,
31
+ default: defaultPolicy,
24
32
  options: [...MFA_REQUIRED_POLICIES],
33
+ write: access.system,
25
34
  });
26
35
  }
@@ -1,6 +1,6 @@
1
1
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
- import { mfaRequiredConfigKey } from "./config.js";
3
+ import { type MfaRequiredPolicy, mfaRequiredConfigKey } from "./config.js";
4
4
  import { createDisableHandler } from "./handlers/disable.write.js";
5
5
  import { createEnableConfirmHandler } from "./handlers/enable-confirm.write.js";
6
6
  import { createEnableConfirmPreauthHandler } from "./handlers/enable-confirm-preauth.write.js";
@@ -33,6 +33,10 @@ export type AuthMfaFeatureOptions = {
33
33
  // secret from setupTokenSecret — a compromised setup-token secret must
34
34
  // not also forge login challenges.
35
35
  readonly challengeTokenSecret: string;
36
+ // App-wide MFA enforcement default when no tenant row overrides it.
37
+ // Tenant admins cannot change the policy (system-only write), so this plus
38
+ // system writes is the only way to set it. Defaults to "optional".
39
+ readonly requiredPolicy?: MfaRequiredPolicy;
36
40
  };
37
41
 
38
42
  export type BindMfaRevokeAllOtherSessions = (
@@ -127,7 +131,7 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
127
131
  // the app boots clean and the first MFA login dies at runtime on an
128
132
  // unknown handler instead of failing at mount time.
129
133
  r.requires("tenant");
130
- r.config("required", mfaRequiredConfigKey());
134
+ r.config("required", mfaRequiredConfigKey(opts.requiredPolicy));
131
135
 
132
136
  // Declarative secretMint screen — mint (no input) -> reveal QR/secret/
133
137
  // recovery-codes -> confirm with a code. App places it via r.nav in its
@@ -225,6 +229,7 @@ export function createAuthMfaFeature(opts: AuthMfaFeatureOptions): FeatureDefini
225
229
  // at app-boot time.
226
230
  const checkMfaStatus = createMfaStatusChecker({
227
231
  challengeTokenSecret: opts.challengeTokenSecret,
232
+ ...(opts.requiredPolicy && { requiredPolicy: opts.requiredPolicy }),
228
233
  });
229
234
 
230
235
  // No late-bind needed — same reasoning as checkMfaStatus: only needs the
@@ -40,6 +40,7 @@ export type MfaStatusChecker = (
40
40
 
41
41
  export function createMfaStatusChecker(opts: {
42
42
  readonly challengeTokenSecret: string;
43
+ readonly requiredPolicy?: MfaRequiredPolicy;
43
44
  }): MfaStatusChecker {
44
45
  return async (ctx, userId, tenantId, roles) => {
45
46
  declareEscapeHatch({
@@ -70,7 +71,7 @@ export function createMfaStatusChecker(opts: {
70
71
  // against the declared `options` at write-time by the config feature.
71
72
  const policy = ((await ctx.configResolver?.get(
72
73
  mfaRequiredConfigHandle.name,
73
- mfaRequiredConfigKey(),
74
+ mfaRequiredConfigKey(opts.requiredPolicy),
74
75
  tenantId,
75
76
  userId,
76
77
  scopedDb,
@@ -164,106 +164,104 @@ export function MfaSetupPreauthScreen({
164
164
  title={title ?? t("auth.mfa.setup.title")}
165
165
  subtitle={subtitle ?? t("auth.mfa.setup.subtitle")}
166
166
  >
167
- <div className="p-6 pt-0 flex flex-col gap-4">
168
- {error !== null ? (
169
- <Banner variant="error">
170
- {error === "too_many_attempts" && retryAfterSeconds !== null
171
- ? t("auth.mfa.errors.tooManyAttemptsWithSeconds", { seconds: retryAfterSeconds })
172
- : t(reasonToKey(error))}
173
- </Banner>
174
- ) : null}
167
+ {error !== null ? (
168
+ <Banner variant="error">
169
+ {error === "too_many_attempts" && retryAfterSeconds !== null
170
+ ? t("auth.mfa.errors.tooManyAttemptsWithSeconds", { seconds: retryAfterSeconds })
171
+ : t(reasonToKey(error))}
172
+ </Banner>
173
+ ) : null}
175
174
 
176
- {!setup && (
175
+ {!setup && (
176
+ <Section
177
+ testId="mfa-setup-preauth-intro"
178
+ actions={
179
+ <Button
180
+ variant="primary"
181
+ onClick={() => void startSetup()}
182
+ loading={busy}
183
+ disabled={busy}
184
+ >
185
+ {t("auth.mfa.setup.start")}
186
+ </Button>
187
+ }
188
+ >
189
+ <span className="text-sm text-muted-foreground">{t("auth.mfa.setup.intro")}</span>
190
+ </Section>
191
+ )}
192
+
193
+ {setup && (
194
+ <Form onSubmit={onSubmit}>
177
195
  <Section
178
- testId="mfa-setup-preauth-intro"
196
+ testId="mfa-setup-preauth-setup"
179
197
  actions={
180
198
  <Button
199
+ type="submit"
181
200
  variant="primary"
182
- onClick={() => void startSetup()}
183
201
  loading={busy}
184
- disabled={busy}
202
+ disabled={busy || !acknowledged || code.length !== 6}
185
203
  >
186
- {t("auth.mfa.setup.start")}
204
+ {t("auth.mfa.setup.confirm")}
187
205
  </Button>
188
206
  }
189
207
  >
190
- <span className="text-sm text-muted-foreground">{t("auth.mfa.setup.intro")}</span>
191
- </Section>
192
- )}
208
+ <div className="flex flex-col items-center gap-2 text-center">
209
+ <span className="text-sm font-semibold">{t("auth.mfa.enable.scanTitle")}</span>
210
+ {/* qrcode's own SVG string output, not user input — safe to inline */}
211
+ <div
212
+ className="h-40 w-40"
213
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
214
+ dangerouslySetInnerHTML={{ __html: setup.qrSvg }}
215
+ />
216
+ <span className="text-xs text-muted-foreground">
217
+ {t("auth.mfa.enable.manualEntry")}
218
+ </span>
219
+ <code className="inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
220
+ {setup.secretParam}
221
+ </code>
222
+ </div>
193
223
 
194
- {setup && (
195
- <Form onSubmit={onSubmit}>
196
- <Section
197
- testId="mfa-setup-preauth-setup"
198
- actions={
199
- <Button
200
- type="submit"
201
- variant="primary"
202
- loading={busy}
203
- disabled={busy || !acknowledged || code.length !== 6}
204
- >
205
- {t("auth.mfa.setup.confirm")}
206
- </Button>
207
- }
208
- >
209
- <div className="flex flex-col items-center gap-2 text-center">
210
- <span className="text-sm font-semibold">{t("auth.mfa.enable.scanTitle")}</span>
211
- {/* qrcode's own SVG string output, not user input — safe to inline */}
212
- <div
213
- className="h-40 w-40"
214
- // biome-ignore lint/security/noDangerouslySetInnerHtml: qrcode-generated SVG, no user input
215
- dangerouslySetInnerHTML={{ __html: setup.qrSvg }}
216
- />
217
- <span className="text-xs text-muted-foreground">
218
- {t("auth.mfa.enable.manualEntry")}
219
- </span>
220
- <code className="inline-block break-all rounded bg-muted px-3 py-2 font-mono text-sm">
221
- {setup.secretParam}
222
- </code>
223
- </div>
224
-
225
- <div className="flex flex-col gap-2">
226
- <span className="text-sm font-semibold">{t("auth.mfa.enable.recoveryTitle")}</span>
227
- <span className="text-xs text-muted-foreground">
228
- {t("auth.mfa.enable.recoveryHint")}
229
- </span>
230
- <code className="block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
231
- {setup.recoveryCodes.join("\n")}
232
- </code>
233
- <Field id="mfa-setup-preauth-ack" label={t("auth.mfa.enable.acknowledge")}>
234
- <Input
235
- kind="boolean"
236
- id="mfa-setup-preauth-ack"
237
- name="mfa-setup-preauth-ack"
238
- value={acknowledged}
239
- onChange={setAcknowledged}
240
- />
241
- </Field>
242
- </div>
243
-
244
- <Field id="mfa-setup-preauth-code" label={t("auth.mfa.enable.code")} required>
224
+ <div className="flex flex-col gap-2">
225
+ <span className="text-sm font-semibold">{t("auth.mfa.enable.recoveryTitle")}</span>
226
+ <span className="text-xs text-muted-foreground">
227
+ {t("auth.mfa.enable.recoveryHint")}
228
+ </span>
229
+ <code className="block whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm">
230
+ {setup.recoveryCodes.join("\n")}
231
+ </code>
232
+ <Field id="mfa-setup-preauth-ack" label={t("auth.mfa.enable.acknowledge")}>
245
233
  <Input
246
- kind="text"
247
- id="mfa-setup-preauth-code"
248
- name="mfa-setup-preauth-code"
249
- value={code}
250
- onChange={setCode}
251
- disabled={busy || !acknowledged}
252
- autoComplete="one-time-code"
234
+ kind="boolean"
235
+ id="mfa-setup-preauth-ack"
236
+ name="mfa-setup-preauth-ack"
237
+ value={acknowledged}
238
+ onChange={setAcknowledged}
253
239
  />
254
240
  </Field>
255
- </Section>
256
- </Form>
257
- )}
241
+ </div>
258
242
 
259
- {onCancel ? (
260
- <div className="flex justify-center">
261
- <Button type="button" variant="secondary" onClick={onCancel} disabled={busy}>
262
- {t("auth.mfa.verify.backToLogin")}
263
- </Button>
264
- </div>
265
- ) : null}
266
- </div>
243
+ <Field id="mfa-setup-preauth-code" label={t("auth.mfa.enable.code")} required>
244
+ <Input
245
+ kind="text"
246
+ id="mfa-setup-preauth-code"
247
+ name="mfa-setup-preauth-code"
248
+ value={code}
249
+ onChange={setCode}
250
+ disabled={busy || !acknowledged}
251
+ autoComplete="one-time-code"
252
+ />
253
+ </Field>
254
+ </Section>
255
+ </Form>
256
+ )}
257
+
258
+ {onCancel ? (
259
+ <div className="flex justify-center">
260
+ <Button type="button" variant="secondary" onClick={onCancel} disabled={busy}>
261
+ {t("auth.mfa.verify.backToLogin")}
262
+ </Button>
263
+ </div>
264
+ ) : null}
267
265
  </AuthCard>
268
266
  );
269
267
  }
@@ -85,33 +85,31 @@ export function MfaVerifyScreen({
85
85
  title={title ?? t("auth.mfa.verify.title")}
86
86
  subtitle={subtitle ?? t("auth.mfa.verify.subtitle")}
87
87
  >
88
- <div className="p-6 pt-0 flex flex-col gap-4">
89
- <Form onSubmit={onSubmit}>
90
- <Field id="mfa-verify-code" label={t("auth.mfa.verify.code")} required>
91
- <Input
92
- kind="text"
93
- id="mfa-verify-code"
94
- name="mfa-verify-code"
95
- value={code}
96
- onChange={setCode}
97
- disabled={submitting}
98
- required
99
- autoComplete="one-time-code"
100
- />
101
- </Field>
102
- {error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
103
- <Button type="submit" loading={submitting} disabled={submitting}>
104
- {submitting
105
- ? t("auth.mfa.verify.submitting")
106
- : (submitLabel ?? t("auth.mfa.verify.submit"))}
88
+ <Form onSubmit={onSubmit}>
89
+ <Field id="mfa-verify-code" label={t("auth.mfa.verify.code")} required>
90
+ <Input
91
+ kind="text"
92
+ id="mfa-verify-code"
93
+ name="mfa-verify-code"
94
+ value={code}
95
+ onChange={setCode}
96
+ disabled={submitting}
97
+ required
98
+ autoComplete="one-time-code"
99
+ />
100
+ </Field>
101
+ {error !== null ? <Banner variant="error">{t(reasonToKey(error))}</Banner> : null}
102
+ <Button type="submit" loading={submitting} disabled={submitting}>
103
+ {submitting
104
+ ? t("auth.mfa.verify.submitting")
105
+ : (submitLabel ?? t("auth.mfa.verify.submit"))}
106
+ </Button>
107
+ {onCancel ? (
108
+ <Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
109
+ {t("auth.mfa.verify.backToLogin")}
107
110
  </Button>
108
- {onCancel ? (
109
- <Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
110
- {t("auth.mfa.verify.backToLogin")}
111
- </Button>
112
- ) : null}
113
- </Form>
114
- </div>
111
+ ) : null}
112
+ </Form>
115
113
  </AuthCard>
116
114
  );
117
115
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.341.0",
4
+ "type": "improvement",
5
+ "title": "App layout around termination pages, trailing-slash redirects, receipt greeting",
6
+ "detail": "`createContractTerminationRoutes` accepts a `wrapLayout`, the same function `createLegalPagesFeature` takes. It wraps every termination page (form, review, result, 429, error); the security headers stay with the framework. The layout also receives `alternates`, a map from locale to the path of the same page, so it can render a language switch from plain links. `page-render` exports the shared type `PublicPageWrapLayout`; `LegalPagesWrapLayout` is now an alias of it. The page body sits in `<div data-kumiko-page=\"contract-termination\">` with `data-kumiko-*` hooks on the form, field groups, buttons and review table. The page CSP keeps `script-src 'none'`, so a layout must not rely on JavaScript.\nThe default `wrapInLayout` stylesheet gains base rules for input, select, textarea and button, so unstyled forms stay readable.\nThe termination pages and every legal page now answer the other trailing-slash form of their path (`/legal/kuendigen/`, `/legal/impressum/`) with a 301 to the configured path for GET and HEAD, query string kept. POST has no alias.\nThe termination receipt mail text changed: it now starts with a greeting line (\"Hallo,\" / \"Hello,\") and the English sentence starts capitalized (\"We confirm receipt ...\"). The receipt content still depends only on the declarant's input."
7
+ },
2
8
  {
3
9
  "version": "0.340.0",
4
10
  "type": "improvement",
@@ -188,6 +188,7 @@ export function createIssueContractConfirmationHandler(
188
188
  await ctx.notify(CONTRACT_CONFIRMATION_NOTIFICATION_TYPE, {
189
189
  route: { email: recipient.data.email },
190
190
  data: content,
191
+ locale,
191
192
  priority: "critical",
192
193
  });
193
194
 
@@ -87,6 +87,7 @@ export function renderTerminationReceipt(
87
87
  subject,
88
88
  header: subject,
89
89
  sections: [
90
+ { text: texts.receiptGreeting },
90
91
  { text: texts.receiptIntro },
91
92
  { text: `${texts.receivedAt}: ${formatReceivedAt(args.receivedAtIso, args.locale)}` },
92
93
  { text: `${texts.requestId}: ${args.requestId}` },
@@ -39,6 +39,7 @@ export async function notifyOperator(
39
39
  ...plan,
40
40
  operatorEmail: consumerProtection.operatorEmail,
41
41
  }),
42
+ locale: plan.locale,
42
43
  priority: "critical",
43
44
  });
44
45
  }