@cosmicdrift/kumiko-samples 0.341.0 → 0.343.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 (85) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/constants.ts +9 -0
  5. package/packages/bundled-features/src/audit/escape-hatch-audit-sink.ts +1 -0
  6. package/packages/bundled-features/src/audit/feature.ts +47 -6
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +144 -0
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +18 -2
  9. package/packages/bundled-features/src/auth-email-password/changes.json +19 -0
  10. package/packages/bundled-features/src/auth-email-password/email-templates.ts +76 -16
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -1
  12. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -1
  13. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -0
  14. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +4 -0
  15. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +24 -6
  16. package/packages/bundled-features/src/auth-email-password/web/confirm-account-unlock-screen.tsx +12 -18
  17. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +4 -4
  18. package/packages/bundled-features/src/auth-email-password/web/invite-accept-screen.tsx +65 -69
  19. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +93 -95
  20. package/packages/bundled-features/src/auth-email-password/web/request-account-unlock-screen.tsx +4 -4
  21. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +8 -10
  22. package/packages/bundled-features/src/auth-email-password/web/session-bootstrap-error.tsx +13 -15
  23. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +43 -49
  24. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +4 -4
  25. package/packages/bundled-features/src/auth-email-password/web/verify-email-screen.tsx +12 -18
  26. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +83 -85
  27. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +24 -26
  28. package/packages/bundled-features/src/channel-discord/feature.ts +1 -0
  29. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  30. package/packages/bundled-features/src/channel-email/email-channel.ts +8 -1
  31. package/packages/bundled-features/src/channel-email/feature.ts +1 -0
  32. package/packages/bundled-features/src/channel-email/pii-guard.ts +6 -5
  33. package/packages/bundled-features/src/channel-email/smtp-transport.ts +27 -1
  34. package/packages/bundled-features/src/channel-email/types.ts +4 -0
  35. package/packages/bundled-features/src/channel-in-app/feature.ts +1 -0
  36. package/packages/bundled-features/src/channel-push/feature.ts +1 -0
  37. package/packages/bundled-features/src/channel-slack/feature.ts +1 -0
  38. package/packages/bundled-features/src/channel-teams/changes.json +8 -0
  39. package/packages/bundled-features/src/channel-teams/feature.ts +1 -0
  40. package/packages/bundled-features/src/channel-teams/teams-channel.ts +16 -0
  41. package/packages/bundled-features/src/channel-telegram/feature.ts +1 -0
  42. package/packages/bundled-features/src/delivery/changes.json +32 -0
  43. package/packages/bundled-features/src/delivery/chat-webhook-channel.ts +7 -2
  44. package/packages/bundled-features/src/delivery/chat-webhook-sender.ts +60 -14
  45. package/packages/bundled-features/src/delivery/constants.ts +4 -0
  46. package/packages/bundled-features/src/delivery/delivery-service.ts +70 -5
  47. package/packages/bundled-features/src/delivery/events.ts +15 -1
  48. package/packages/bundled-features/src/delivery/feature.ts +32 -4
  49. package/packages/bundled-features/src/delivery/handlers/log.query.ts +65 -20
  50. package/packages/bundled-features/src/delivery/i18n.ts +55 -0
  51. package/packages/bundled-features/src/delivery/index.ts +2 -0
  52. package/packages/bundled-features/src/delivery/jobs.ts +43 -20
  53. package/packages/bundled-features/src/delivery/notify-factory.ts +19 -0
  54. package/packages/bundled-features/src/delivery/public-names.ts +4 -0
  55. package/packages/bundled-features/src/delivery/tables.ts +3 -0
  56. package/packages/bundled-features/src/delivery/testing.ts +5 -9
  57. package/packages/bundled-features/src/delivery/types.ts +6 -2
  58. package/packages/bundled-features/src/delivery/web/client-plugin.tsx +16 -1
  59. package/packages/bundled-features/src/delivery/web/delivery-channel-cell.tsx +13 -0
  60. package/packages/bundled-features/src/delivery/web/delivery-error-cell.tsx +17 -0
  61. package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +10 -3
  62. package/packages/bundled-features/src/delivery/web/delivery-time-cell.tsx +8 -0
  63. package/packages/bundled-features/src/delivery/web/delivery-type-cell.tsx +20 -0
  64. package/packages/bundled-features/src/jobs/changes.json +6 -0
  65. package/packages/bundled-features/src/secrets/changes.json +6 -0
  66. package/packages/bundled-features/src/secrets/constants.ts +10 -0
  67. package/packages/bundled-features/src/secrets/feature.ts +4 -0
  68. package/packages/bundled-features/src/secrets/i18n.ts +5 -3
  69. package/packages/bundled-features/src/secrets/index.ts +8 -1
  70. package/packages/bundled-features/src/secrets/write-gate.ts +17 -5
  71. package/packages/bundled-features/src/shared/index.ts +6 -0
  72. package/packages/bundled-features/src/shared/redact.ts +27 -0
  73. package/packages/bundled-features/src/step-dispatcher/feature.ts +2 -6
  74. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +0 -6
  75. package/packages/bundled-features/src/tenant/web/member-roles-cell.tsx +1 -1
  76. package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
  77. package/packages/bundled-features/src/tenant-lifecycle/feature.ts +12 -2
  78. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +23 -8
  79. package/packages/bundled-features/src/user/index.ts +1 -0
  80. package/packages/bundled-features/src/user/resolve-display-names.ts +35 -0
  81. package/packages/bundled-features/src/waitlist/changes.json +6 -0
  82. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +1 -1
  83. package/packages/bundled-features/src/waitlist/options.ts +3 -2
  84. package/samples/apps/use-all-bundled/feature-manifest.json +27 -2
  85. /package/packages/bundled-features/src/{tenant → shared}/web/translate-or-raw.ts +0 -0
@@ -112,12 +112,10 @@ export function SignupCompleteScreen({
112
112
  if (token === "") {
113
113
  return (
114
114
  <AuthCard title={effectiveTitle}>
115
- <div className="p-6 pt-0 flex flex-col gap-4">
116
- <p className="text-sm text-muted-foreground">{t("auth.signupComplete.missingToken")}</p>
117
- <Link href={loginHref} variant="muted">
118
- {t("auth.signup.haveAccount")}
119
- </Link>
120
- </div>
115
+ <p className="text-sm text-muted-foreground">{t("auth.signupComplete.missingToken")}</p>
116
+ <Link href={loginHref} variant="muted">
117
+ {t("auth.signup.haveAccount")}
118
+ </Link>
121
119
  </AuthCard>
122
120
  );
123
121
  }
@@ -125,57 +123,53 @@ export function SignupCompleteScreen({
125
123
  if (continueHref !== null) {
126
124
  return (
127
125
  <AuthCard title={effectiveTitle}>
128
- <div className="p-6 pt-0 flex flex-col gap-4">
129
- <p className="text-sm text-muted-foreground" role="status">
130
- {t("auth.signupComplete.activated")}
131
- </p>
132
- <Link href={continueHref} variant="button">
133
- {t("auth.signupComplete.continue")}
134
- </Link>
135
- </div>
126
+ <p className="text-sm text-muted-foreground" role="status">
127
+ {t("auth.signupComplete.activated")}
128
+ </p>
129
+ <Link href={continueHref} variant="button">
130
+ {t("auth.signupComplete.continue")}
131
+ </Link>
136
132
  </AuthCard>
137
133
  );
138
134
  }
139
135
 
140
136
  return (
141
137
  <AuthCard title={effectiveTitle}>
142
- <div className="p-6 pt-0 flex flex-col gap-4">
143
- <p className="text-sm text-muted-foreground">{t("auth.signupComplete.intro")}</p>
144
- <Form onSubmit={onSubmit}>
145
- <Field id="signup-password" label={t("auth.signupComplete.password")} required>
146
- <Input
147
- kind="password"
148
- id="signup-password"
149
- name="signup-password"
150
- value={password}
151
- onChange={setPassword}
152
- disabled={submitting}
153
- required
154
- autoComplete="new-password"
155
- />
156
- </Field>
157
- <Field
138
+ <p className="text-sm text-muted-foreground">{t("auth.signupComplete.intro")}</p>
139
+ <Form onSubmit={onSubmit}>
140
+ <Field id="signup-password" label={t("auth.signupComplete.password")} required>
141
+ <Input
142
+ kind="password"
143
+ id="signup-password"
144
+ name="signup-password"
145
+ value={password}
146
+ onChange={setPassword}
147
+ disabled={submitting}
148
+ required
149
+ autoComplete="new-password"
150
+ />
151
+ </Field>
152
+ <Field
153
+ id="signup-confirm-password"
154
+ label={t("auth.signupComplete.confirmPassword")}
155
+ required
156
+ >
157
+ <Input
158
+ kind="password"
158
159
  id="signup-confirm-password"
159
- label={t("auth.signupComplete.confirmPassword")}
160
+ name="signup-confirm-password"
161
+ value={confirmPassword}
162
+ onChange={setConfirmPassword}
163
+ disabled={submitting}
160
164
  required
161
- >
162
- <Input
163
- kind="password"
164
- id="signup-confirm-password"
165
- name="signup-confirm-password"
166
- value={confirmPassword}
167
- onChange={setConfirmPassword}
168
- disabled={submitting}
169
- required
170
- autoComplete="new-password"
171
- />
172
- </Field>
173
- {error !== null && <Banner variant="error">{error}</Banner>}
174
- <Button type="submit" loading={submitting} disabled={submitting}>
175
- {submitting ? t("auth.signupComplete.submitting") : t("auth.signupComplete.submit")}
176
- </Button>
177
- </Form>
178
- </div>
165
+ autoComplete="new-password"
166
+ />
167
+ </Field>
168
+ {error !== null && <Banner variant="error">{error}</Banner>}
169
+ <Button type="submit" loading={submitting} disabled={submitting}>
170
+ {submitting ? t("auth.signupComplete.submitting") : t("auth.signupComplete.submit")}
171
+ </Button>
172
+ </Form>
179
173
  </AuthCard>
180
174
  );
181
175
  }
@@ -85,7 +85,7 @@ export function SignupScreen({
85
85
  return (
86
86
  <AuthCard title={effectiveTitle} subtitle={subtitle}>
87
87
  {done ? (
88
- <div className="p-6 pt-0 flex flex-col gap-4">
88
+ <>
89
89
  <Banner variant="info">
90
90
  <p className="font-medium text-foreground">{t("auth.signup.successTitle")}</p>
91
91
  <p className="mt-1">{t("auth.signup.successBody")}</p>
@@ -96,9 +96,9 @@ export function SignupScreen({
96
96
  <Link href={loginHref} variant="muted">
97
97
  {t("auth.signup.haveAccount")}
98
98
  </Link>
99
- </div>
99
+ </>
100
100
  ) : (
101
- <div className="p-6 pt-0 flex flex-col gap-4">
101
+ <>
102
102
  <p className="text-sm text-muted-foreground">{t("auth.signup.intro")}</p>
103
103
  <Form onSubmit={onSubmit}>
104
104
  <Field id="signup-email" label={t("auth.signup.email")} required>
@@ -121,7 +121,7 @@ export function SignupScreen({
121
121
  <Link href={loginHref} variant="muted" className="self-center">
122
122
  {t("auth.signup.haveAccount")}
123
123
  </Link>
124
- </div>
124
+ </>
125
125
  )}
126
126
  </AuthCard>
127
127
  );
@@ -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
  }
@@ -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
  }
@@ -47,6 +47,7 @@ export function createChannelDiscordFeature(
47
47
  });
48
48
 
49
49
  const channel = createDiscordChannel(options, webhooks.keyFor);
50
+ r.translations({ keys: { "delivery.channel.discord": { en: "Discord" } } });
50
51
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "discord", {
51
52
  mode: channel.mode,
52
53
  send: channel.send,
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.342.0",
4
+ "type": "improvement",
5
+ "title": "EmailMessage.fromName sets the From display name",
6
+ "detail": "channel-email: fromName sets the display name of the From header\n`EmailMessage` gets an optional `fromName`, and the email channel passes `data.fromName` through like `replyTo`. The SMTP transport combines it with the address of `message.from` or the transport default, so `from: \"App <noreply@x>\"` plus `fromName: \"Tenant via veridom\"` sends from `noreply@x` under the new name. Control characters in the name are replaced by spaces before sending, and an empty name behaves like no name. The PII ciphertext guard refuses a ciphertext `fromName`."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -53,11 +53,13 @@ function listUnsubscribeHeaders(
53
53
 
54
54
  function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined): {
55
55
  from?: string;
56
+ fromName?: string;
56
57
  replyTo?: string;
57
58
  headers?: Readonly<Record<string, string>>;
58
59
  } {
59
60
  if (!data) return {};
60
61
  const from = typeof data["from"] === "string" ? data["from"] : undefined;
62
+ const fromName = typeof data["fromName"] === "string" ? data["fromName"] : undefined;
61
63
  const replyTo = typeof data["replyTo"] === "string" ? data["replyTo"] : undefined;
62
64
  const autoHeaders = listUnsubscribeHeaders(data);
63
65
  const explicitHeaders = stringHeaders(data["headers"]);
@@ -71,7 +73,12 @@ function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined):
71
73
  (!overridesUnsubscribe && autoHeaders) || explicitHeaders
72
74
  ? { ...(overridesUnsubscribe ? undefined : autoHeaders), ...explicitHeaders }
73
75
  : undefined;
74
- return { ...(from && { from }), ...(replyTo && { replyTo }), ...(headers && { headers }) };
76
+ return {
77
+ ...(from && { from }),
78
+ ...(fromName && { fromName }),
79
+ ...(replyTo && { replyTo }),
80
+ ...(headers && { headers }),
81
+ };
75
82
  }
76
83
 
77
84
  export type EmailChannelOptions = {
@@ -16,6 +16,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
16
16
  });
17
17
  r.requires("delivery");
18
18
 
19
+ r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
19
20
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "email", {
20
21
  mode: channel.mode,
21
22
  resolve: channel.resolve,
@@ -12,16 +12,17 @@ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
12
12
  // blob); ciphertext in subject/body fails loud in dev and is redacted+logged
13
13
  // in prod.
14
14
  export function guardEmailMessage(message: EmailMessage): EmailMessage {
15
- // Every envelope address, not just `to`: a ciphertext From or Reply-To is
15
+ // Every envelope address, not just `to`: a ciphertext From, From-Name or Reply-To is
16
16
  // the same garbage as a ciphertext recipient — refuse rather than mail it.
17
17
  for (const [label, address] of [
18
- ["recipient", message.to],
19
- ["from", message.from],
20
- ["reply-to", message.replyTo],
18
+ ["recipient address", message.to],
19
+ ["from address", message.from],
20
+ ["from name", message.fromName],
21
+ ["reply-to address", message.replyTo],
21
22
  ] as const) {
22
23
  if (address?.includes(CIPHERTEXT_MARKER)) {
23
24
  throw new Error(
24
- `[channel-email] refusing to send: ${label} address is a PII ciphertext ` +
25
+ `[channel-email] refusing to send: ${label} is a PII ciphertext ` +
25
26
  `("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
26
27
  );
27
28
  }
@@ -17,6 +17,7 @@
17
17
  // dedizierter Queue (BullMQ + retry) drüberlegen.
18
18
 
19
19
  import { createTransport, type Transporter } from "nodemailer";
20
+ import addressparser from "nodemailer/lib/addressparser";
20
21
  import type { EmailMessage, EmailTransport } from "./types.js";
21
22
 
22
23
  export type SmtpTransportOptions = {
@@ -48,6 +49,31 @@ export type SmtpTransportOptions = {
48
49
  readonly servername?: string;
49
50
  };
50
51
 
52
+ function isControlCharacter(char: string): boolean {
53
+ const code = char.charCodeAt(0);
54
+ return code <= 0x1f || code === 0x7f;
55
+ }
56
+
57
+ // Control characters in a display name would let a caller inject headers.
58
+ function sanitizedDisplayName(name: string | undefined): string | undefined {
59
+ const cleaned = name
60
+ ?.split("")
61
+ .map((char) => (isControlCharacter(char) ? " " : char))
62
+ .join("")
63
+ .trim();
64
+ return cleaned === undefined || cleaned === "" ? undefined : cleaned;
65
+ }
66
+
67
+ function fromHeader(
68
+ address: string,
69
+ fromName: string | undefined,
70
+ ): string | { readonly name: string; readonly address: string } {
71
+ const name = sanitizedDisplayName(fromName);
72
+ if (name === undefined) return address;
73
+ const [parsed] = addressparser(address, { flatten: true });
74
+ return parsed?.address ? { name, address: parsed.address } : address;
75
+ }
76
+
51
77
  export function createSmtpTransport(options: SmtpTransportOptions): EmailTransport {
52
78
  const transporter: Transporter = createTransport({
53
79
  host: options.host,
@@ -62,7 +88,7 @@ export function createSmtpTransport(options: SmtpTransportOptions): EmailTranspo
62
88
  return {
63
89
  async send(message: EmailMessage): Promise<void> {
64
90
  await transporter.sendMail({
65
- from: message.from ?? options.from,
91
+ from: fromHeader(message.from ?? options.from, message.fromName),
66
92
  to: message.to,
67
93
  subject: message.subject,
68
94
  html: message.html,
@@ -7,6 +7,10 @@ export type EmailMessage = {
7
7
  // app-wide default). Set when one send must originate from a specific
8
8
  // mailbox — a reply from the address the original mail reached, not noreply@.
9
9
  readonly from?: string;
10
+ // Display name for the From header. Combined with the address of `from`
11
+ // (or the transport default), so a per-tenant sender name needs no
12
+ // hand-built "Name <addr>" string.
13
+ readonly fromName?: string;
10
14
  // Reply-To header: where a recipient's answer goes when it differs from From.
11
15
  readonly replyTo?: string;
12
16
  // Extra RFC-5322 headers — In-Reply-To / References thread a reply into the
@@ -24,6 +24,7 @@ export function createChannelInAppFeature(): FeatureDefinition {
24
24
  });
25
25
 
26
26
  // Register as delivery channel via extension system
27
+ r.translations({ keys: { "delivery.channel.inApp": { en: "In-app" } } });
27
28
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "inApp", {
28
29
  mode: inAppChannel.mode,
29
30
  resolve: inAppChannel.resolve,
@@ -16,6 +16,7 @@ export function createChannelPushFeature(options: PushChannelOptions): FeatureDe
16
16
  });
17
17
  r.requires("delivery");
18
18
 
19
+ r.translations({ keys: { "delivery.channel.push": { en: "Push" } } });
19
20
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "push", {
20
21
  mode: channel.mode,
21
22
  resolve: channel.resolve,
@@ -41,6 +41,7 @@ export function createChannelSlackFeature(options: SlackChannelOptions = {}): Fe
41
41
  });
42
42
 
43
43
  const channel = createSlackChannel(options, webhooks.keyFor);
44
+ r.translations({ keys: { "delivery.channel.slack": { en: "Slack" } } });
44
45
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "slack", {
45
46
  mode: channel.mode,
46
47
  send: channel.send,
@@ -0,0 +1,8 @@
1
+ [
2
+ {
3
+ "version": "0.343.0",
4
+ "type": "fix",
5
+ "title": "Teams no longer reports any 2xx as delivered",
6
+ "detail": "`channel-teams` no longer reports every 2xx answer as sent. A 200 with the body `1` (classic Office connector) is a confirmed send. A 202 (Power Automate / Workflows endpoints) means the message was accepted but not confirmed: the attempt is `sent` and `NotifyResult.deliveries[].confirmed` is `false`. Any other 2xx, including an empty 200 as a made-up URL returns, fails with `unexpected_response`.\n`NotifyDelivery.confirmed` and `ChannelResult.confirmed` are new and only ever `false`. `createChatWebhookChannel` takes an optional `classifyResponse`, which `postChatWebhook` calls for 2xx answers with a status and a lazy `readBodyPrefix()` (at most 64 bytes; the body is always cancelled afterwards). `ChatSendResult` success now carries `confirmed`. `confirmed` is returned for inline delivery only; jobs do not carry it yet."
7
+ }
8
+ ]
@@ -45,6 +45,7 @@ export function createChannelTeamsFeature(options: TeamsChannelOptions = {}): Fe
45
45
  });
46
46
 
47
47
  const channel = createTeamsChannel(options, webhooks.keyFor);
48
+ r.translations({ keys: { "delivery.channel.teams": { en: "Microsoft Teams" } } });
48
49
  r.useExtension(DELIVERY_CHANNEL_EXTENSION, "teams", {
49
50
  mode: channel.mode,
50
51
  send: channel.send,