@eifi1/ui-kit 0.29.0 → 0.31.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 (307) hide show
  1. package/README.md +65 -6
  2. package/dist/account/account-parts.d.ts +21 -0
  3. package/dist/account/account-parts.js +40 -0
  4. package/dist/account/account-parts.js.map +1 -0
  5. package/dist/account/data-export-setting.d.ts +73 -0
  6. package/dist/account/data-export-setting.js +114 -0
  7. package/dist/account/data-export-setting.js.map +1 -0
  8. package/dist/account/delete-account-setting.d.ts +95 -0
  9. package/dist/account/delete-account-setting.js +200 -0
  10. package/dist/account/delete-account-setting.js.map +1 -0
  11. package/dist/account/email-change-setting.d.ts +136 -0
  12. package/dist/account/email-change-setting.js +278 -0
  13. package/dist/account/email-change-setting.js.map +1 -0
  14. package/dist/account/sessions-setting.d.ts +110 -0
  15. package/dist/account/sessions-setting.js +147 -0
  16. package/dist/account/sessions-setting.js.map +1 -0
  17. package/dist/admin/admin-action-confirm.d.ts +95 -0
  18. package/dist/admin/admin-action-confirm.js +276 -0
  19. package/dist/admin/admin-action-confirm.js.map +1 -0
  20. package/dist/admin/admin-action-log.d.ts +105 -0
  21. package/dist/admin/admin-action-log.js +153 -0
  22. package/dist/admin/admin-action-log.js.map +1 -0
  23. package/dist/admin/admin-parts.d.ts +40 -0
  24. package/dist/admin/admin-parts.js +45 -0
  25. package/dist/admin/admin-parts.js.map +1 -0
  26. package/dist/admin/invitations-panel.d.ts +95 -0
  27. package/dist/admin/invitations-panel.js +290 -0
  28. package/dist/admin/invitations-panel.js.map +1 -0
  29. package/dist/admin/reviewer-scope-editor.d.ts +95 -0
  30. package/dist/admin/reviewer-scope-editor.js +134 -0
  31. package/dist/admin/reviewer-scope-editor.js.map +1 -0
  32. package/dist/admin/role-select.d.ts +95 -0
  33. package/dist/admin/role-select.js +128 -0
  34. package/dist/admin/role-select.js.map +1 -0
  35. package/dist/admin/transfer-ownership-dialog.d.ts +95 -0
  36. package/dist/admin/transfer-ownership-dialog.js +83 -0
  37. package/dist/admin/transfer-ownership-dialog.js.map +1 -0
  38. package/dist/admin/user-roster.d.ts +95 -0
  39. package/dist/admin/user-roster.js +285 -0
  40. package/dist/admin/user-roster.js.map +1 -0
  41. package/dist/auth/auth-errors.d.ts +103 -6
  42. package/dist/auth/auth-errors.js +68 -2
  43. package/dist/auth/auth-errors.js.map +1 -1
  44. package/dist/auth/forgot-password-form.d.ts +10 -3
  45. package/dist/auth/forgot-password-form.js +6 -3
  46. package/dist/auth/forgot-password-form.js.map +1 -1
  47. package/dist/auth/register-form.d.ts +21 -12
  48. package/dist/auth/register-form.js +6 -4
  49. package/dist/auth/register-form.js.map +1 -1
  50. package/dist/auth/reset-password-form.d.ts +10 -3
  51. package/dist/auth/reset-password-form.js +6 -3
  52. package/dist/auth/reset-password-form.js.map +1 -1
  53. package/dist/auth/sign-in-form.d.ts +75 -9
  54. package/dist/auth/sign-in-form.js +105 -15
  55. package/dist/auth/sign-in-form.js.map +1 -1
  56. package/dist/auth/verify-email.d.ts +7 -2
  57. package/dist/auth/verify-email.js +44 -8
  58. package/dist/auth/verify-email.js.map +1 -1
  59. package/dist/chart.d.ts +20 -11
  60. package/dist/components/account-chips.d.ts +47 -7
  61. package/dist/components/account-chips.js +19 -3
  62. package/dist/components/account-chips.js.map +1 -1
  63. package/dist/components/account-settings-labels.d.ts +24 -1
  64. package/dist/components/account-settings-labels.js +6 -2
  65. package/dist/components/account-settings-labels.js.map +1 -1
  66. package/dist/components/account-settings.d.ts +69 -8
  67. package/dist/components/account-settings.js +133 -25
  68. package/dist/components/account-settings.js.map +1 -1
  69. package/dist/components/amount-input.d.ts +21 -12
  70. package/dist/components/button-group.d.ts +21 -12
  71. package/dist/components/calculator.d.ts +21 -12
  72. package/dist/components/confirm-dialog.d.ts +21 -12
  73. package/dist/components/copy-button.d.ts +18 -9
  74. package/dist/components/danger-confirm.d.ts +21 -12
  75. package/dist/components/data-table.js +8 -1
  76. package/dist/components/data-table.js.map +1 -1
  77. package/dist/components/date-picker.js +9 -4
  78. package/dist/components/date-picker.js.map +1 -1
  79. package/dist/components/facing-pair.d.ts +21 -12
  80. package/dist/components/file-button.d.ts +21 -12
  81. package/dist/components/file-dropzone.d.ts +21 -12
  82. package/dist/components/form-actions.d.ts +21 -12
  83. package/dist/components/iban-input.d.ts +21 -12
  84. package/dist/components/language-select.d.ts +21 -12
  85. package/dist/components/legal-page.d.ts +21 -12
  86. package/dist/components/legal.d.ts +21 -12
  87. package/dist/components/money-field.d.ts +21 -12
  88. package/dist/components/number-field.d.ts +21 -12
  89. package/dist/components/number-input.d.ts +21 -12
  90. package/dist/components/numpad-sheet.d.ts +21 -12
  91. package/dist/components/passkeys-setting.js +2 -1
  92. package/dist/components/passkeys-setting.js.map +1 -1
  93. package/dist/components/phone-input.d.ts +21 -12
  94. package/dist/components/series-chart.d.ts +21 -12
  95. package/dist/components/settings-fields.d.ts +55 -13
  96. package/dist/components/settings-fields.js +20 -1
  97. package/dist/components/settings-fields.js.map +1 -1
  98. package/dist/components/share-card.d.ts +20 -11
  99. package/dist/components/text-link.d.ts +21 -12
  100. package/dist/components/time-input.d.ts +21 -12
  101. package/dist/components/ui.d.ts +17 -8
  102. package/dist/{register-form-V1OeNlMr.d.ts → delete-account-setting-DCFS5Vof.d.ts} +6612 -5612
  103. package/dist/demo/demo-banner.d.ts +51 -0
  104. package/dist/demo/demo-banner.js +97 -0
  105. package/dist/demo/demo-banner.js.map +1 -0
  106. package/dist/demo/demo-countdown.d.ts +58 -0
  107. package/dist/demo/demo-countdown.js +79 -0
  108. package/dist/demo/demo-countdown.js.map +1 -0
  109. package/dist/demo/demo-ended.d.ts +45 -0
  110. package/dist/demo/demo-ended.js +42 -0
  111. package/dist/demo/demo-ended.js.map +1 -0
  112. package/dist/demo/demo-labels.d.ts +53 -0
  113. package/dist/demo/demo-labels.js +29 -0
  114. package/dist/demo/demo-labels.js.map +1 -0
  115. package/dist/demo/demo-session.d.ts +30 -0
  116. package/dist/demo/demo-session.js +8 -0
  117. package/dist/demo/demo-session.js.map +1 -0
  118. package/dist/demo/demo-start.d.ts +54 -0
  119. package/dist/demo/demo-start.js +68 -0
  120. package/dist/demo/demo-start.js.map +1 -0
  121. package/dist/feedback/feedback-attachment.d.ts +21 -12
  122. package/dist/feedback/feedback-context.d.ts +21 -12
  123. package/dist/feedback/feedback-crash.d.ts +21 -12
  124. package/dist/feedback/feedback-dialog.d.ts +21 -12
  125. package/dist/feedback/feedback-inbox.d.ts +21 -12
  126. package/dist/feedback/feedback-inbox.js +12 -2
  127. package/dist/feedback/feedback-inbox.js.map +1 -1
  128. package/dist/feedback/feedback-labels.d.ts +21 -12
  129. package/dist/feedback/feedback-labels.js +1 -0
  130. package/dist/feedback/feedback-labels.js.map +1 -1
  131. package/dist/feedback/feedback-menu.d.ts +21 -12
  132. package/dist/feedback/feedback-record.d.ts +21 -12
  133. package/dist/feedback/feedback-record.js +2 -0
  134. package/dist/feedback/feedback-record.js.map +1 -1
  135. package/dist/feedback/feedback-row-detail.d.ts +21 -12
  136. package/dist/feedback/feedback-row-detail.js +7 -0
  137. package/dist/feedback/feedback-row-detail.js.map +1 -1
  138. package/dist/feedback/feedback-status-undo.d.ts +21 -12
  139. package/dist/feedback/feedback-submit.d.ts +21 -12
  140. package/dist/feedback/feedback-swipe.d.ts +22 -13
  141. package/dist/feedback/feedback-swipe.js.map +1 -1
  142. package/dist/feedback/feedback-table.d.ts +18 -9
  143. package/dist/feedback/feedback-thread.d.ts +21 -12
  144. package/dist/feedback.d.ts +21 -12
  145. package/dist/hooks/use-anchored-panel.d.ts +6 -2
  146. package/dist/hooks/use-anchored-panel.js +2 -1
  147. package/dist/hooks/use-anchored-panel.js.map +1 -1
  148. package/dist/hooks/use-file-drop.d.ts +21 -12
  149. package/dist/i18n/defaults.d.ts +21 -12
  150. package/dist/i18n/defaults.js +28 -0
  151. package/dist/i18n/defaults.js.map +1 -1
  152. package/dist/i18n/german.d.ts +21 -12
  153. package/dist/i18n/german.js +298 -12
  154. package/dist/i18n/german.js.map +1 -1
  155. package/dist/i18n/kit-labels.d.ts +14 -5
  156. package/dist/i18n/kit-labels.js.map +1 -1
  157. package/dist/i18n/languages.d.ts +15 -6
  158. package/dist/i18n/locales/de-CH.d.ts +21 -12
  159. package/dist/i18n/locales/en.d.ts +21 -12
  160. package/dist/i18n/locales/es.d.ts +21 -12
  161. package/dist/i18n/locales/es.js +287 -10
  162. package/dist/i18n/locales/es.js.map +1 -1
  163. package/dist/i18n/locales/fr.d.ts +21 -12
  164. package/dist/i18n/locales/fr.js +290 -7
  165. package/dist/i18n/locales/fr.js.map +1 -1
  166. package/dist/i18n/locales/hu.d.ts +21 -12
  167. package/dist/i18n/locales/hu.js +288 -12
  168. package/dist/i18n/locales/hu.js.map +1 -1
  169. package/dist/i18n/locales/it.d.ts +21 -12
  170. package/dist/i18n/locales/it.js +289 -7
  171. package/dist/i18n/locales/it.js.map +1 -1
  172. package/dist/i18n/locales/zh.d.ts +21 -12
  173. package/dist/i18n/locales/zh.js +279 -7
  174. package/dist/i18n/locales/zh.js.map +1 -1
  175. package/dist/i18n/review.d.ts +21 -12
  176. package/dist/i18n/review.js +50 -1
  177. package/dist/i18n/review.js.map +1 -1
  178. package/dist/index.d.ts +31 -4
  179. package/dist/index.js +148 -0
  180. package/dist/index.js.map +1 -1
  181. package/dist/landing/access.d.ts +48 -0
  182. package/dist/landing/access.js +38 -0
  183. package/dist/landing/access.js.map +1 -0
  184. package/dist/landing/landing-actions.d.ts +138 -0
  185. package/dist/landing/landing-actions.js +39 -0
  186. package/dist/landing/landing-actions.js.map +1 -0
  187. package/dist/landing/landing-labels.d.ts +43 -0
  188. package/dist/landing/landing-labels.js +23 -0
  189. package/dist/landing/landing-labels.js.map +1 -0
  190. package/dist/landing/landing-sections.d.ts +218 -0
  191. package/dist/landing/landing-sections.js +167 -0
  192. package/dist/landing/landing-sections.js.map +1 -0
  193. package/dist/landing/page-seo.d.ts +99 -0
  194. package/dist/landing/page-seo.js +121 -0
  195. package/dist/landing/page-seo.js.map +1 -0
  196. package/dist/landing/public-header.d.ts +148 -0
  197. package/dist/landing/public-header.js +49 -0
  198. package/dist/landing/public-header.js.map +1 -0
  199. package/dist/landing/routing.d.ts +218 -0
  200. package/dist/landing/routing.js +81 -0
  201. package/dist/landing/routing.js.map +1 -0
  202. package/dist/rhf/fields.d.ts +21 -12
  203. package/dist/rhf/form.d.ts +21 -12
  204. package/dist/rhf.d.ts +21 -12
  205. package/dist/settings/settings-catalogue.d.ts +84 -0
  206. package/dist/settings/settings-catalogue.js +47 -0
  207. package/dist/settings/settings-catalogue.js.map +1 -0
  208. package/dist/settings/settings-context.d.ts +31 -0
  209. package/dist/settings/settings-context.js +11 -0
  210. package/dist/settings/settings-context.js.map +1 -0
  211. package/dist/settings/settings-heading.d.ts +50 -0
  212. package/dist/settings/settings-heading.js +27 -0
  213. package/dist/settings/settings-heading.js.map +1 -0
  214. package/dist/settings/settings-labels.d.ts +41 -0
  215. package/dist/settings/settings-labels.js +38 -0
  216. package/dist/settings/settings-labels.js.map +1 -0
  217. package/dist/settings/settings-layout.d.ts +77 -0
  218. package/dist/settings/settings-layout.js +242 -0
  219. package/dist/settings/settings-layout.js.map +1 -0
  220. package/dist/settings/settings-section.d.ts +133 -0
  221. package/dist/settings/settings-section.js +63 -0
  222. package/dist/settings/settings-section.js.map +1 -0
  223. package/dist/settings/use-account-language.d.ts +83 -0
  224. package/dist/settings/use-account-language.js +62 -0
  225. package/dist/settings/use-account-language.js.map +1 -0
  226. package/dist/settings/use-settings-focus.d.ts +34 -0
  227. package/dist/settings/use-settings-focus.js +77 -0
  228. package/dist/settings/use-settings-focus.js.map +1 -0
  229. package/dist/settings/use-settings-route.d.ts +118 -0
  230. package/dist/settings/use-settings-route.js +120 -0
  231. package/dist/settings/use-settings-route.js.map +1 -0
  232. package/dist/shell/app-shell.d.ts +27 -12
  233. package/dist/shell/app-shell.js +3 -2
  234. package/dist/shell/app-shell.js.map +1 -1
  235. package/dist/shell/top-bar-brand.d.ts +21 -12
  236. package/dist/shell.d.ts +25 -6
  237. package/dist/shell.js +56 -0
  238. package/dist/shell.js.map +1 -1
  239. package/dist/wizard/stepper-nav.d.ts +21 -12
  240. package/dist/wizard.d.ts +21 -12
  241. package/package.json +2 -1
  242. package/src/account/account-parts.ts +67 -0
  243. package/src/account/data-export-setting.tsx +219 -0
  244. package/src/account/delete-account-setting.tsx +363 -0
  245. package/src/account/email-change-setting.tsx +454 -0
  246. package/src/account/sessions-setting.tsx +284 -0
  247. package/src/admin/admin-action-confirm.tsx +495 -0
  248. package/src/admin/admin-action-log.tsx +297 -0
  249. package/src/admin/admin-parts.ts +82 -0
  250. package/src/admin/invitations-panel.tsx +523 -0
  251. package/src/admin/reviewer-scope-editor.tsx +234 -0
  252. package/src/admin/role-select.tsx +286 -0
  253. package/src/admin/transfer-ownership-dialog.tsx +172 -0
  254. package/src/admin/user-roster.tsx +573 -0
  255. package/src/auth/auth-errors.ts +196 -8
  256. package/src/auth/forgot-password-form.tsx +21 -5
  257. package/src/auth/register-form.tsx +24 -6
  258. package/src/auth/reset-password-form.tsx +19 -5
  259. package/src/auth/sign-in-form.tsx +241 -38
  260. package/src/auth/verify-email.tsx +59 -8
  261. package/src/components/account-chips.tsx +64 -7
  262. package/src/components/account-settings-labels.ts +28 -1
  263. package/src/components/account-settings.tsx +231 -34
  264. package/src/components/data-table.tsx +8 -0
  265. package/src/components/date-picker.tsx +9 -4
  266. package/src/components/passkeys-setting.tsx +2 -1
  267. package/src/components/settings-fields.tsx +50 -1
  268. package/src/demo/demo-banner.tsx +159 -0
  269. package/src/demo/demo-countdown.ts +141 -0
  270. package/src/demo/demo-ended.tsx +89 -0
  271. package/src/demo/demo-labels.ts +78 -0
  272. package/src/demo/demo-session.ts +32 -0
  273. package/src/demo/demo-start.tsx +150 -0
  274. package/src/feedback/feedback-inbox.tsx +21 -8
  275. package/src/feedback/feedback-labels.ts +2 -1
  276. package/src/feedback/feedback-record.ts +10 -7
  277. package/src/feedback/feedback-row-detail.tsx +28 -4
  278. package/src/feedback/feedback-swipe.tsx +1 -1
  279. package/src/hooks/use-anchored-panel.ts +14 -3
  280. package/src/i18n/defaults.ts +28 -0
  281. package/src/i18n/german.ts +327 -5
  282. package/src/i18n/kit-labels.tsx +42 -0
  283. package/src/i18n/locales/es.ts +313 -3
  284. package/src/i18n/locales/fr.ts +316 -0
  285. package/src/i18n/locales/hu.ts +308 -5
  286. package/src/i18n/locales/it.ts +313 -0
  287. package/src/i18n/locales/zh.ts +286 -0
  288. package/src/i18n/review.ts +49 -0
  289. package/src/index.ts +184 -0
  290. package/src/landing/access.ts +97 -0
  291. package/src/landing/landing-actions.tsx +92 -0
  292. package/src/landing/landing-labels.ts +59 -0
  293. package/src/landing/landing-sections.tsx +337 -0
  294. package/src/landing/page-seo.ts +251 -0
  295. package/src/landing/public-header.tsx +120 -0
  296. package/src/landing/routing.tsx +209 -0
  297. package/src/settings/settings-catalogue.ts +132 -0
  298. package/src/settings/settings-context.ts +33 -0
  299. package/src/settings/settings-heading.tsx +65 -0
  300. package/src/settings/settings-labels.ts +75 -0
  301. package/src/settings/settings-layout.tsx +379 -0
  302. package/src/settings/settings-section.tsx +108 -0
  303. package/src/settings/use-account-language.ts +146 -0
  304. package/src/settings/use-settings-focus.ts +120 -0
  305. package/src/settings/use-settings-route.ts +258 -0
  306. package/src/shell/app-shell.tsx +10 -2
  307. package/src/shell.ts +45 -0
@@ -10,8 +10,11 @@ import { CurrentPasswordInput } from "../components/danger-confirm";
10
10
  import { OneTimeCodeInput } from "../components/one-time-code-input";
11
11
  import { TextLink } from "../components/text-link";
12
12
  import { Button, Input } from "../components/ui";
13
- import { authErrorCode } from "./auth-errors";
14
- import type { AuthErrorCode } from "./auth-errors";
13
+ import { accessAction } from "../landing/access";
14
+ import type { AccessChoice } from "../landing/access";
15
+ import { useLandingLabels } from "../landing/landing-labels";
16
+ import { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from "./auth-errors";
17
+ import type { KitErrorCode } from "./auth-errors";
15
18
  import { taggedEmail } from "./email-tag";
16
19
  import { newPasswordProblem } from "./form-rules";
17
20
  import { NewPasswordFields } from "./new-password-fields";
@@ -72,8 +75,17 @@ export interface SignInCredentials {
72
75
 
73
76
  export interface SignInCodeValues {
74
77
  challengeToken: string;
75
- /** Digits only — {@link OneTimeCodeInput} drops the spaces of "123 456". */
78
+ /** Digits only — {@link OneTimeCodeInput} drops the spaces of "123 456". A backup code
79
+ * (`kind: "recovery"`) is trimmed and otherwise as typed: its dashes and case are the
80
+ * server's to normalise, as kastlan's `_normalise_backup_code` does. */
76
81
  code: string;
82
+ /**
83
+ * 0.30.0: `"recovery"` when the person typed one of their backup codes instead
84
+ * (`recoveryCode`); absent for the authenticator's code, as before. kastlan's
85
+ * `/auth/login/2fa` takes either in the same `code` and tells them apart itself; an
86
+ * app whose server takes a backup code elsewhere routes on this.
87
+ */
88
+ kind?: "recovery";
77
89
  }
78
90
 
79
91
  export interface SignInNewPasswordValues {
@@ -140,9 +152,35 @@ export interface SignInLabels {
140
152
  expired: string;
141
153
  /** From the second and third step back to the credentials. */
142
154
  backToSignIn: string;
155
+ /*
156
+ * 0.30.0 — OPTIONAL, like `TwoFactorSettingLabels.qrAlt` and for its reason: a new
157
+ * required key is a compile error at every call site that annotates this interface
158
+ * (CONTRIBUTING's additive-API rule). Each falls back to the provider's `signIn`, then
159
+ * English.
160
+ */
161
+ /** The second-factor step's switch to a backup code (`recoveryCode`). */
162
+ useRecoveryCode?: string;
163
+ /** The switch back, from the backup-code field to the authenticator's code. */
164
+ useAuthenticatorCode?: string;
165
+ /** The backup-code field's label. */
166
+ recoveryCode?: string;
167
+ /** The step's intro while a backup code is asked for, in place of `twoFactorIntro`. */
168
+ recoveryIntro?: string;
169
+ /** Under the backup-code field, given how many letters and digits a code has. */
170
+ recoveryCodeHint?: (length: number) => string;
171
+ /** A refused backup code — wrong, or spent already (each one works once). */
172
+ recoveryCodeInvalid?: string;
173
+ /** Any step's request was throttled — HTTP 429 ({@link isRateLimited}) — given the
174
+ * wait its `Retry-After` asks for, in seconds ({@link retryAfterSeconds}), or
175
+ * `undefined` (or `0`) without one. */
176
+ rateLimited?: (seconds?: number) => string;
177
+ /** 0.31.0: the link after `noAccount` for an invitation-only app (`access` of kind
178
+ * `request`) — a mail to support, in place of `register`. */
179
+ requestAccess?: string;
143
180
  }
144
181
 
145
- export const DEFAULT_SIGN_IN_LABELS: SignInLabels = {
182
+ /** `Required`: every key, the 0.30 ones included, has its English here. */
183
+ export const DEFAULT_SIGN_IN_LABELS: Required<SignInLabels> = {
146
184
  email: "Email",
147
185
  password: "Password",
148
186
  submit: "Sign in",
@@ -169,6 +207,14 @@ export const DEFAULT_SIGN_IN_LABELS: SignInLabels = {
169
207
  setPasswordFailed: "The password could not be set.",
170
208
  expired: "This sign-in has expired. Please sign in again.",
171
209
  backToSignIn: "Back to sign in",
210
+ useRecoveryCode: "Use a backup code",
211
+ useAuthenticatorCode: "Use your authenticator app",
212
+ recoveryCode: "Backup code",
213
+ recoveryIntro: "Enter one of the backup codes you saved when you turned on two-factor authentication.",
214
+ recoveryCodeHint: (length) => `${length} letters and digits. Dashes and spaces don’t matter.`,
215
+ recoveryCodeInvalid: "This backup code is not valid, or it has been used already.",
216
+ rateLimited: englishRateLimited,
217
+ requestAccess: "Request access",
172
218
  };
173
219
 
174
220
  /* ── Props ───────────────────────────────────────────────────────────────── */
@@ -213,6 +259,15 @@ export interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "
213
259
  forgotHref?: string;
214
260
  /** "No account yet? Create account" goes here — `/register`. Left out, no line. */
215
261
  registerHref?: string;
262
+ /**
263
+ * 0.31.0 (docs/landing-demo-harmonization.md §4.2): how a visitor gets an account —
264
+ * the same value the landing's `PublicHeader` and `Hero` take. `{kind: "request",
265
+ * email}` turns the line into "No account yet? Request access", a mail to support with
266
+ * the subject and body of `accessAction` (every app is invitation-only); `{kind:
267
+ * "register", href}` keeps "Create account", to `href`. Wins over `registerHref`; left
268
+ * out, `registerHref` works as before.
269
+ */
270
+ access?: AccessChoice;
216
271
  /** The email field's start value — the reset page ends on "Sign in" with the address
217
272
  * filled in (§6.3). */
218
273
  defaultEmail?: string;
@@ -230,10 +285,26 @@ export interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "
230
285
  deactivatedContact?: ReactNode;
231
286
  /** Digits of the second factor. Default 6 (TOTP). */
232
287
  codeLength?: number;
288
+ /**
289
+ * 0.30.0: offer a backup code on the second-factor step (kastlan's 0.29 adoption: its
290
+ * `/auth/login/2fa` takes one of the eight codes minted at 2FA setup, and the kit's
291
+ * step had no way to type one). A "Use a backup code" switch under the code field
292
+ * swaps the digits-only {@link OneTimeCodeInput} for a plain text field; the code goes
293
+ * to `onCode` with the same challenge and `kind: "recovery"`.
294
+ *
295
+ * `true` for a 16-character code (kastlan's `ABCD-EFGH-JKLM-NPQR`), or `{ length }`.
296
+ * The length counts letters and digits only — the dashes and spaces a printed code is
297
+ * grouped with don't count — and "Verify" waits for it. Left out, no switch.
298
+ */
299
+ recoveryCode?: boolean | { length?: number };
233
300
  /** App content at the top of the credentials step — a notice, a demo button. */
234
301
  credentialsContent?: ReactNode;
235
- /** App content on the second-factor step, under its intro. */
236
- twoFactorContent?: ReactNode;
302
+ /**
303
+ * App content on the second-factor step, under its intro. 0.30.0: pass a function and
304
+ * it gets the challenge token — kastlan kept its own copy of the token for a "lost
305
+ * your device?" link, which the step now hands over.
306
+ */
307
+ twoFactorContent?: ReactNode | ((challengeToken: string) => ReactNode);
237
308
  /**
238
309
  * App content on the set-a-new-password step, under its intro — keksdose's
239
310
  * private-mode note ("Your encrypted data is untouched …"), which depends on the
@@ -241,9 +312,10 @@ export interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "
241
312
  */
242
313
  passwordChangeContent?: ReactNode | ((extra: Readonly<Record<string, unknown>> | undefined) => ReactNode);
243
314
  /**
244
- * The app's own words for a failure, or `undefined` for the kit's: a throttled
245
- * attempt (`429`), the device being offline. Asked first, for every failure; what it
246
- * returns replaces the message, and the `invalid_credentials` hints still follow.
315
+ * The app's own words for a failure, or `undefined` for the kit's: the device being
316
+ * offline, a throttle with its wait ("try again in an hour"). Asked first, for every
317
+ * failure; what it returns replaces the message, and the `invalid_credentials` hints
318
+ * still follow. A bare `429` needs none since 0.30.0: the kit says `rateLimited`.
247
319
  */
248
320
  describeError?: (error: unknown, action: SignInAction) => ReactNode | undefined;
249
321
  /** The step changed — retitle the page (`AuthLayout`'s `title`) if the app wants. */
@@ -258,7 +330,7 @@ export interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "
258
330
 
259
331
  interface Failure {
260
332
  action: SignInAction;
261
- code: AuthErrorCode | undefined;
333
+ code: KitErrorCode | undefined;
262
334
  message: ReactNode;
263
335
  /** The address the failed credentials carried — the tag hint names its tagged form. */
264
336
  email?: string;
@@ -284,6 +356,16 @@ function withContact(template: string, contact: ReactNode): ReactNode[] {
284
356
  }
285
357
 
286
358
  const HINT_CLASS = "text-xs text-[var(--text-secondary)]";
359
+
360
+ /** A backup code's letters and digits — kastlan's: 16, printed `ABCD-EFGH-JKLM-NPQR`. */
361
+ const RECOVERY_CODE_LENGTH = 16;
362
+
363
+ /** How many letters and digits a typed backup code has: the dashes and spaces it is
364
+ * printed in groups with don't count, so "abcd-efgh ijkl mnop" is 16 — the count
365
+ * kastlan's server normalises the code to before it compares. */
366
+ function recoveryCharacters(value: string): number {
367
+ return value.replace(/[^\p{L}\p{N}]/gu, "").length;
368
+ }
287
369
  const INTRO_CLASS = "text-sm text-[var(--text-secondary)]";
288
370
 
289
371
  /* ── The form ────────────────────────────────────────────────────────────── */
@@ -319,7 +401,14 @@ const INTRO_CLASS = "text-sm text-[var(--text-secondary)]";
319
401
  * whole code. A refused code stays here with its error; `token_invalid` (the
320
402
  * challenge expired) goes back to the credentials and says why. Kurvenschmiede has
321
403
  * no 2FA yet, and the step is here from the start, so adding it is backend work only
322
- * (§10.5).
404
+ * (§10.5). With `recoveryCode` (0.30.0) a switch under the field takes a backup code
405
+ * instead, through the same `onCode` with `kind: "recovery"`.
406
+ *
407
+ * **A throttled request** (HTTP 429, {@link isRateLimited}) says "Too many attempts. Try
408
+ * again in 30 s." on any step (0.30.0) — the wait from its `Retry-After`, else "Wait a
409
+ * moment and try again." — after `describeError`, whose words still come first, and
410
+ * instead of "wrong password" or "invalid code", which would send the person to try
411
+ * another one.
323
412
  * - New password: new + confirm with the strength meter, the 8-character / 72-byte
324
413
  * rules and nothing else (§8). It reads as explained, not blocked (keksdose): the
325
414
  * person did nothing wrong.
@@ -335,10 +424,12 @@ export function SignInForm({
335
424
  passkeyAutofill,
336
425
  forgotHref,
337
426
  registerHref,
427
+ access,
338
428
  defaultEmail = "",
339
429
  emailTag,
340
430
  deactivatedContact,
341
431
  codeLength = 6,
432
+ recoveryCode,
342
433
  credentialsContent,
343
434
  twoFactorContent,
344
435
  passwordChangeContent,
@@ -350,10 +441,27 @@ export function SignInForm({
350
441
  ...rest
351
442
  }: SignInFormProps) {
352
443
  const labels = useKitLabels("signIn", DEFAULT_SIGN_IN_LABELS, labelsProp);
444
+ // The mail's subject and body are the landing's words (`landing` namespace), so the
445
+ // request reads the same from every page that offers it.
446
+ const landing = useLandingLabels();
447
+ const accountLink =
448
+ access === undefined
449
+ ? registerHref === undefined
450
+ ? null
451
+ : { href: registerHref, label: labels.register }
452
+ : access.kind === "register"
453
+ ? { href: access.href, label: labels.register }
454
+ : {
455
+ href: accessAction(access, landing).href,
456
+ label: labels.requestAccess ?? DEFAULT_SIGN_IN_LABELS.requestAccess,
457
+ };
353
458
  const [step, setStep] = useState<SignInStep>("credentials");
354
459
  const [email, setEmail] = useState(defaultEmail);
355
460
  const [password, setPassword] = useState("");
356
461
  const [code, setCode] = useState("");
462
+ // 0.30.0: the second factor as a backup code instead of the authenticator's digits.
463
+ const [useRecovery, setUseRecovery] = useState(false);
464
+ const [recovery, setRecovery] = useState("");
357
465
  const [newPassword, setNewPassword] = useState("");
358
466
  const [confirm, setConfirm] = useState("");
359
467
  const [challenge, setChallenge] = useState<{ token: string; extra?: Readonly<Record<string, unknown>> } | null>(
@@ -368,6 +476,7 @@ export function SignInForm({
368
476
  const emailRef = useRef<HTMLInputElement>(null);
369
477
  const passwordRef = useRef<HTMLInputElement>(null);
370
478
  const codeRef = useRef<HTMLInputElement>(null);
479
+ const recoveryRef = useRef<HTMLInputElement>(null);
371
480
  const newPasswordRef = useRef<HTMLInputElement>(null);
372
481
 
373
482
  // A late answer after unmount (the app navigated away) must not set state.
@@ -394,6 +503,8 @@ export function SignInForm({
394
503
  // The password has done its work; it does not stay in state for the next steps.
395
504
  setPassword("");
396
505
  setCode("");
506
+ setRecovery("");
507
+ setUseRecovery(false);
397
508
  setNewPassword("");
398
509
  setConfirm("");
399
510
  if (answer.kind === "2fa") {
@@ -405,25 +516,41 @@ export function SignInForm({
405
516
  }
406
517
  };
407
518
 
408
- const messageFor = (error: unknown, action: SignInAction, code: AuthErrorCode | undefined): ReactNode => {
519
+ const messageFor = (
520
+ error: unknown,
521
+ action: SignInAction,
522
+ code: KitErrorCode | undefined,
523
+ recoveryUsed: boolean,
524
+ ): ReactNode => {
409
525
  const own = describeError?.(error, action);
410
526
  if (hasMessage(own)) return own;
527
+ // After the app's words, before the kit's generic ones (0.30.0): a throttle is
528
+ // neither a wrong password nor a wrong code, and saying so sends the person to try
529
+ // another one — which the limiter counts too.
530
+ if (isRateLimited(error)) return (labels.rateLimited ?? DEFAULT_SIGN_IN_LABELS.rateLimited)(retryAfterSeconds(error));
411
531
  if (action === "password") return code === "invalid_credentials" ? labels.invalidCredentials : labels.failed;
412
532
  if (action === "passkey") return labels.passkeyFailed;
413
- if (action === "code") return code === "token_invalid" ? labels.expired : labels.codeInvalid;
533
+ if (action === "code") {
534
+ if (code === "token_invalid") return labels.expired;
535
+ return recoveryUsed
536
+ ? (labels.recoveryCodeInvalid ?? DEFAULT_SIGN_IN_LABELS.recoveryCodeInvalid)
537
+ : labels.codeInvalid;
538
+ }
414
539
  return code === "token_invalid" ? labels.expired : labels.setPasswordFailed;
415
540
  };
416
541
 
417
- const fail = (error: unknown, action: SignInAction, sentEmail?: string) => {
542
+ const fail = (error: unknown, action: SignInAction, sentEmail?: string, recoveryUsed = false) => {
418
543
  setBusy(null);
419
544
  // Our own abort (a passkey ceremony cancelled by the unmount) is no failure.
420
545
  if ((error as { name?: unknown } | null)?.name === "AbortError") return;
421
546
  const code = authErrorCode(error);
422
- const message = messageFor(error, action, code);
547
+ const message = messageFor(error, action, code, recoveryUsed);
423
548
  // The challenge expired between the steps: only a fresh sign-in helps.
424
549
  if ((action === "code" || action === "set-password") && code === "token_invalid") {
425
550
  setChallenge(null);
426
551
  setCode("");
552
+ setRecovery("");
553
+ setUseRecovery(false);
427
554
  setNewPassword("");
428
555
  setConfirm("");
429
556
  goTo("credentials");
@@ -431,7 +558,12 @@ export function SignInForm({
431
558
  setFailure({ action, code, message, email: sentEmail });
432
559
  };
433
560
 
434
- const attempt = (action: SignInAction, call: () => Promise<SignInAnswer>, sentEmail?: string) => {
561
+ const attempt = (
562
+ action: SignInAction,
563
+ call: () => Promise<SignInAnswer>,
564
+ sentEmail?: string,
565
+ recoveryUsed = false,
566
+ ) => {
435
567
  if (busy) return;
436
568
  setBusy(action);
437
569
  setFailure(null);
@@ -446,7 +578,7 @@ export function SignInForm({
446
578
  if (mounted.current) present(answer);
447
579
  },
448
580
  (error: unknown) => {
449
- if (mounted.current) fail(error, action, sentEmail);
581
+ if (mounted.current) fail(error, action, sentEmail, recoveryUsed);
450
582
  },
451
583
  );
452
584
  };
@@ -488,12 +620,24 @@ export function SignInForm({
488
620
  else if (emailRef.current?.value) passwordRef.current?.focus();
489
621
  else emailRef.current?.focus();
490
622
  }, [step]);
623
+ // The backup-code switch swaps one field for the other under the focus that pressed
624
+ // it; the field that came in is where the person is going. Only after a switch on
625
+ // this step — arriving on it is the effect above.
626
+ const shownRecovery = useRef(useRecovery);
627
+ useEffect(() => {
628
+ if (shownRecovery.current === useRecovery) return;
629
+ shownRecovery.current = useRecovery;
630
+ if (step !== "2fa") return;
631
+ (useRecovery ? recoveryRef : codeRef).current?.focus();
632
+ }, [useRecovery, step]);
491
633
 
492
634
  const backToCredentials = () => {
493
635
  if (busy) return;
494
636
  setChallenge(null);
495
637
  setFailure(null);
496
638
  setCode("");
639
+ setRecovery("");
640
+ setUseRecovery(false);
497
641
  setNewPassword("");
498
642
  setConfirm("");
499
643
  goTo("credentials");
@@ -504,35 +648,94 @@ export function SignInForm({
504
648
 
505
649
  /* ── Step 2: the second factor ── */
506
650
  if (step === "2fa" && challenge) {
651
+ const recoveryOn = recoveryCode !== undefined && recoveryCode !== false;
652
+ const recoveryLength = (typeof recoveryCode === "object" ? recoveryCode.length : undefined) ?? RECOVERY_CODE_LENGTH;
653
+ const asRecovery = recoveryOn && useRecovery;
654
+ const recoveryReady = recoveryCharacters(recovery) === recoveryLength;
655
+ const ready = asRecovery ? recoveryReady : code.length === codeLength;
507
656
  const submitCode = (e: FormEvent) => {
508
657
  e.preventDefault();
509
- if (code.length !== codeLength) return;
658
+ if (!ready) return;
659
+ if (asRecovery) {
660
+ attempt(
661
+ "code",
662
+ () => onCode({ challengeToken: challenge.token, code: recovery.trim(), kind: "recovery" }),
663
+ undefined,
664
+ true,
665
+ );
666
+ return;
667
+ }
510
668
  attempt("code", () => onCode({ challengeToken: challenge.token, code }));
511
669
  };
670
+ const switchCode = () => {
671
+ if (working) return;
672
+ setFailure(null);
673
+ setCode("");
674
+ setRecovery("");
675
+ setUseRecovery((on) => !on);
676
+ };
512
677
  const codeError = failure?.action === "code" ? failure.message : undefined;
678
+ const content =
679
+ typeof twoFactorContent === "function" ? twoFactorContent(challenge.token) : twoFactorContent;
513
680
  return (
514
681
  <div {...rest} data-step="2fa" className={rootClass}>
515
682
  <form className="space-y-3" aria-labelledby={headingId} onSubmit={submitCode}>
516
683
  <Heading id={headingId} className="text-base font-semibold text-[var(--text-primary)]">
517
684
  {labels.twoFactorTitle}
518
685
  </Heading>
519
- <p className={INTRO_CLASS}>{labels.twoFactorIntro}</p>
520
- {twoFactorContent}
521
- <OneTimeCodeInput
522
- ref={codeRef}
523
- label={labels.code}
524
- name="code"
525
- value={code}
526
- length={codeLength}
527
- onChange={(next) => {
528
- setCode(next);
529
- if (failure) setFailure(null);
530
- }}
531
- readOnly={working}
532
- error={codeError}
533
- required
534
- />
535
- <Button type="submit" className="w-full" disabled={!working && code.length !== codeLength} pending={working}>
686
+ <p className={INTRO_CLASS}>
687
+ {asRecovery ? (labels.recoveryIntro ?? DEFAULT_SIGN_IN_LABELS.recoveryIntro) : labels.twoFactorIntro}
688
+ </p>
689
+ {content}
690
+ {asRecovery ? (
691
+ // A plain text field, not the digits-only one: a backup code is letters and
692
+ // digits in printed groups. Nothing a phone keyboard would "correct", and no
693
+ // autofill — the browser's suggestion for an unknown field is the username.
694
+ <Input
695
+ ref={recoveryRef}
696
+ label={labels.recoveryCode ?? DEFAULT_SIGN_IN_LABELS.recoveryCode}
697
+ name="recovery-code"
698
+ value={recovery}
699
+ onChange={(e) => {
700
+ setRecovery(e.target.value);
701
+ if (failure) setFailure(null);
702
+ }}
703
+ autoComplete="off"
704
+ autoCapitalize="characters"
705
+ autoCorrect="off"
706
+ spellCheck={false}
707
+ inputClassName="font-mono"
708
+ hint={(labels.recoveryCodeHint ?? DEFAULT_SIGN_IN_LABELS.recoveryCodeHint)(recoveryLength)}
709
+ readOnly={working}
710
+ error={codeError}
711
+ required
712
+ />
713
+ ) : (
714
+ <OneTimeCodeInput
715
+ ref={codeRef}
716
+ label={labels.code}
717
+ name="code"
718
+ value={code}
719
+ length={codeLength}
720
+ onChange={(next) => {
721
+ setCode(next);
722
+ if (failure) setFailure(null);
723
+ }}
724
+ readOnly={working}
725
+ error={codeError}
726
+ required
727
+ />
728
+ )}
729
+ {recoveryOn && (
730
+ <p className="text-sm">
731
+ <Button type="button" variant="link" size="sm" disabled={working} onClick={switchCode}>
732
+ {asRecovery
733
+ ? (labels.useAuthenticatorCode ?? DEFAULT_SIGN_IN_LABELS.useAuthenticatorCode)
734
+ : (labels.useRecoveryCode ?? DEFAULT_SIGN_IN_LABELS.useRecoveryCode)}
735
+ </Button>
736
+ </p>
737
+ )}
738
+ <Button type="submit" className="w-full" disabled={!working && !ready} pending={working}>
536
739
  {labels.verify}
537
740
  </Button>
538
741
  </form>
@@ -662,11 +865,11 @@ export function SignInForm({
662
865
  </TextLink>
663
866
  </p>
664
867
  )}
665
- {registerHref !== undefined && (
868
+ {accountLink !== null && (
666
869
  <p className="text-center text-sm text-[var(--text-secondary)]">
667
870
  {labels.noAccount}{" "}
668
- <TextLink href={registerHref} tone="primary">
669
- {labels.register}
871
+ <TextLink href={accountLink.href} tone="primary">
872
+ {accountLink.label}
670
873
  </TextLink>
671
874
  </p>
672
875
  )}
@@ -8,6 +8,7 @@ import { AlertBanner } from "../components/alert-banner";
8
8
  import { LoadingState } from "../components/loading-state";
9
9
  import { Button, EmptyState } from "../components/ui";
10
10
  import { OUTCOME_CLASS, STATUS_HEADING_CLASS, isThenable, useOncePerKey } from "./status-parts";
11
+ import { authErrorCode } from "./auth-errors";
11
12
  import type { AuthHeadingLevel } from "./status-parts";
12
13
 
13
14
  /* ── Labels ──────────────────────────────────────────────────────────────── */
@@ -83,6 +84,9 @@ type ResendPhase = "idle" | "sending" | "sent" | "failed";
83
84
  */
84
85
  function useResend(onResend: (() => Promise<unknown> | void) | undefined, cooldown: number) {
85
86
  const [phase, setPhase] = useState<ResendPhase>("idle");
87
+ // The refusal itself, for `describeError` (0.29.1): the server's own sentence — keksdose's
88
+ // per-address throttle, "try again in N minutes" — beats a fixed "could not send".
89
+ const [error, setError] = useState<unknown>(undefined);
86
90
  const [remaining, setRemaining] = useState(0);
87
91
  const counting = remaining > 0;
88
92
  useEffect(() => {
@@ -100,7 +104,8 @@ function useResend(onResend: (() => Promise<unknown> | void) | undefined, cooldo
100
104
  let result: unknown;
101
105
  try {
102
106
  result = onResend();
103
- } catch {
107
+ } catch (e) {
108
+ setError(e);
104
109
  setPhase("failed");
105
110
  return;
106
111
  }
@@ -109,9 +114,12 @@ function useResend(onResend: (() => Promise<unknown> | void) | undefined, cooldo
109
114
  return;
110
115
  }
111
116
  setPhase("sending");
112
- result.then(sent, () => setPhase("failed"));
117
+ result.then(sent, (e: unknown) => {
118
+ setError(e);
119
+ setPhase("failed");
120
+ });
113
121
  };
114
- return { phase, remaining, send };
122
+ return { phase, remaining, send, error };
115
123
  }
116
124
 
117
125
  function ResendButton({
@@ -139,13 +147,26 @@ function ResendButton({
139
147
  );
140
148
  }
141
149
 
142
- function ResendStatus({ phase, labels, className }: { phase: ResendPhase; labels: VerifyEmailLabels; className?: string }) {
150
+ function ResendStatus({
151
+ phase,
152
+ error,
153
+ describeError,
154
+ labels,
155
+ className,
156
+ }: {
157
+ phase: ResendPhase;
158
+ error?: unknown;
159
+ describeError?: (error: unknown) => string | undefined;
160
+ labels: VerifyEmailLabels;
161
+ className?: string;
162
+ }) {
163
+ const failed = phase === "failed" ? (describeError?.(error) ?? labels.resendError) : "";
143
164
  return (
144
165
  <span
145
166
  role="status"
146
167
  className={cn(phase === "failed" ? "text-[var(--danger)]" : "text-[var(--success)]", className)}
147
168
  >
148
- {phase === "sent" ? labels.resent : phase === "failed" ? labels.resendError : ""}
169
+ {phase === "sent" ? labels.resent : failed}
149
170
  </span>
150
171
  );
151
172
  }
@@ -155,6 +176,11 @@ function ResendStatus({ phase, labels, className }: { phase: ResendPhase; labels
155
176
  /** What became of a confirmation link that did not verify. */
156
177
  export type VerifyEmailFailure = "invalid" | "expired";
157
178
 
179
+ /** The default when the app gives no `classifyError`: the coded refusal decides. */
180
+ function defaultVerifyFailure(error: unknown): VerifyEmailFailure {
181
+ return authErrorCode(error) === "token_expired" ? "expired" : "invalid";
182
+ }
183
+
158
184
  export interface VerifyEmailStatusProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "title"> {
159
185
  /** The link's token — the app's `?token=`. Empty: the link was cut short, and the
160
186
  * page says so without asking anyone. */
@@ -169,6 +195,9 @@ export interface VerifyEmailStatusProps extends Omit<ComponentPropsWithoutRef<"d
169
195
  /** Which dead end a rejection is. Default: `invalid` for everything — §5.2's servers
170
196
  * answer one `token_invalid`; an app whose server tells expiry apart maps it here. */
171
197
  classifyError?: (error: unknown) => VerifyEmailFailure;
198
+ /** 0.29.1: the words for a refused resend — the server's own sentence (a throttle's
199
+ * "try again in N minutes"), else the kit's `resendError`. */
200
+ describeError?: (error: unknown) => string | undefined;
172
201
  /** Someone is signed in on this device: "Continue" goes into the app, and a dead link
173
202
  * offers "Send again" (with `onResend`). */
174
203
  signedIn?: boolean;
@@ -211,6 +240,7 @@ export function VerifyEmailStatus({
211
240
  token,
212
241
  onVerify,
213
242
  classifyError,
243
+ describeError,
214
244
  signedIn = false,
215
245
  onResend,
216
246
  cooldown = DEFAULT_COOLDOWN,
@@ -230,7 +260,11 @@ export function VerifyEmailStatus({
230
260
  const resend = useResend(onResend, cooldown);
231
261
 
232
262
  useOncePerKey(token === "" ? null : token, onVerify, (key, result) => {
233
- const next: VerifyOutcome = result.ok ? "verified" : (classifyError?.(result.error) ?? "invalid");
263
+ // Without a classifier, the coded refusal decides (0.29.1): `token_expired` is
264
+ // "expired", anything else "invalid".
265
+ const next: VerifyOutcome = result.ok
266
+ ? "verified"
267
+ : (classifyError ?? defaultVerifyFailure)(result.error);
234
268
  setSettled({ token: key, outcome: next });
235
269
  announce(labels[next]);
236
270
  });
@@ -291,7 +325,15 @@ export function VerifyEmailStatus({
291
325
  >
292
326
  {title === null ? null : <Heading className={STATUS_HEADING_CLASS}>{title ?? labels.title}</Heading>}
293
327
  {body}
294
- {canResend && <ResendStatus phase={resend.phase} labels={labels} className="block text-center text-xs" />}
328
+ {canResend && (
329
+ <ResendStatus
330
+ phase={resend.phase}
331
+ error={resend.error}
332
+ describeError={describeError}
333
+ labels={labels}
334
+ className="block text-center text-xs"
335
+ />
336
+ )}
295
337
  {/* Rendered from the start, so the outcome is read when it arrives. */}
296
338
  <div {...regionProps} />
297
339
  </div>
@@ -304,6 +346,8 @@ export interface EmailVerificationBannerProps {
304
346
  /** Send a new confirmation mail (`POST /auth/verify-email/resend`). Resolve when it
305
347
  * went out; reject and the banner says it could not. */
306
348
  onResend: () => Promise<unknown> | void;
349
+ /** 0.29.1: the words for a refused resend — the server's own sentence, else the kit's. */
350
+ describeError?: (error: unknown) => string | undefined;
307
351
  /** Seconds "Send again" waits after a mail went out. Default 60; `0` never waits. */
308
352
  cooldown?: number;
309
353
  /** The ×, "Not now". The banner does not hide itself: the app stops rendering it —
@@ -331,6 +375,7 @@ export interface EmailVerificationBannerProps {
331
375
  */
332
376
  export function EmailVerificationBanner({
333
377
  onResend,
378
+ describeError,
334
379
  cooldown = DEFAULT_COOLDOWN,
335
380
  onDismiss,
336
381
  variant = "strip",
@@ -352,7 +397,13 @@ export function EmailVerificationBanner({
352
397
  >
353
398
  {labels.banner}
354
399
  {children != null && children !== false && <> {children}</>}{" "}
355
- <ResendStatus phase={resend.phase} labels={labels} className="font-medium" />
400
+ <ResendStatus
401
+ phase={resend.phase}
402
+ error={resend.error}
403
+ describeError={describeError}
404
+ labels={labels}
405
+ className="font-medium"
406
+ />
356
407
  </AlertBanner>
357
408
  );
358
409
  }