@eifi1/ui-kit 0.29.0 → 0.30.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 (187) hide show
  1. package/README.md +26 -4
  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 +113 -0
  7. package/dist/account/data-export-setting.js.map +1 -0
  8. package/dist/account/delete-account-setting.d.ts +91 -0
  9. package/dist/account/delete-account-setting.js +199 -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 +277 -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 +146 -0
  16. package/dist/account/sessions-setting.js.map +1 -0
  17. package/dist/admin/admin-action-confirm.d.ts +91 -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 +91 -0
  27. package/dist/admin/invitations-panel.js +289 -0
  28. package/dist/admin/invitations-panel.js.map +1 -0
  29. package/dist/admin/reviewer-scope-editor.d.ts +91 -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 +91 -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 +91 -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 +91 -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 +85 -6
  42. package/dist/auth/auth-errors.js +62 -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 +17 -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 +61 -9
  54. package/dist/auth/sign-in-form.js +94 -13
  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 +16 -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 +12 -1
  64. package/dist/components/account-settings-labels.js +4 -1
  65. package/dist/components/account-settings-labels.js.map +1 -1
  66. package/dist/components/account-settings.d.ts +61 -6
  67. package/dist/components/account-settings.js +115 -15
  68. package/dist/components/account-settings.js.map +1 -1
  69. package/dist/components/amount-input.d.ts +17 -12
  70. package/dist/components/button-group.d.ts +17 -12
  71. package/dist/components/calculator.d.ts +17 -12
  72. package/dist/components/confirm-dialog.d.ts +17 -12
  73. package/dist/components/copy-button.d.ts +14 -9
  74. package/dist/components/danger-confirm.d.ts +17 -12
  75. package/dist/components/facing-pair.d.ts +17 -12
  76. package/dist/components/file-button.d.ts +17 -12
  77. package/dist/components/file-dropzone.d.ts +17 -12
  78. package/dist/components/form-actions.d.ts +17 -12
  79. package/dist/components/iban-input.d.ts +17 -12
  80. package/dist/components/language-select.d.ts +17 -12
  81. package/dist/components/legal-page.d.ts +17 -12
  82. package/dist/components/legal.d.ts +17 -12
  83. package/dist/components/money-field.d.ts +17 -12
  84. package/dist/components/number-field.d.ts +17 -12
  85. package/dist/components/number-input.d.ts +17 -12
  86. package/dist/components/numpad-sheet.d.ts +17 -12
  87. package/dist/components/phone-input.d.ts +17 -12
  88. package/dist/components/series-chart.d.ts +17 -12
  89. package/dist/components/settings-fields.d.ts +16 -11
  90. package/dist/components/share-card.d.ts +16 -11
  91. package/dist/components/text-link.d.ts +17 -12
  92. package/dist/components/time-input.d.ts +17 -12
  93. package/dist/components/ui.d.ts +13 -8
  94. package/dist/{register-form-V1OeNlMr.d.ts → delete-account-setting-CGVMckMw.d.ts} +6616 -5645
  95. package/dist/feedback/feedback-attachment.d.ts +17 -12
  96. package/dist/feedback/feedback-context.d.ts +17 -12
  97. package/dist/feedback/feedback-crash.d.ts +17 -12
  98. package/dist/feedback/feedback-dialog.d.ts +17 -12
  99. package/dist/feedback/feedback-inbox.d.ts +17 -12
  100. package/dist/feedback/feedback-labels.d.ts +17 -12
  101. package/dist/feedback/feedback-menu.d.ts +17 -12
  102. package/dist/feedback/feedback-record.d.ts +17 -12
  103. package/dist/feedback/feedback-row-detail.d.ts +17 -12
  104. package/dist/feedback/feedback-status-undo.d.ts +17 -12
  105. package/dist/feedback/feedback-submit.d.ts +17 -12
  106. package/dist/feedback/feedback-swipe.d.ts +17 -12
  107. package/dist/feedback/feedback-table.d.ts +14 -9
  108. package/dist/feedback/feedback-thread.d.ts +17 -12
  109. package/dist/feedback.d.ts +17 -12
  110. package/dist/hooks/use-anchored-panel.d.ts +6 -2
  111. package/dist/hooks/use-anchored-panel.js +2 -1
  112. package/dist/hooks/use-anchored-panel.js.map +1 -1
  113. package/dist/hooks/use-file-drop.d.ts +17 -12
  114. package/dist/i18n/defaults.d.ts +17 -12
  115. package/dist/i18n/defaults.js +22 -0
  116. package/dist/i18n/defaults.js.map +1 -1
  117. package/dist/i18n/german.d.ts +17 -12
  118. package/dist/i18n/german.js +215 -11
  119. package/dist/i18n/german.js.map +1 -1
  120. package/dist/i18n/kit-labels.d.ts +10 -5
  121. package/dist/i18n/kit-labels.js.map +1 -1
  122. package/dist/i18n/languages.d.ts +11 -6
  123. package/dist/i18n/locales/de-CH.d.ts +17 -12
  124. package/dist/i18n/locales/en.d.ts +17 -12
  125. package/dist/i18n/locales/es.d.ts +17 -12
  126. package/dist/i18n/locales/es.js +207 -9
  127. package/dist/i18n/locales/es.js.map +1 -1
  128. package/dist/i18n/locales/fr.d.ts +17 -12
  129. package/dist/i18n/locales/fr.js +209 -6
  130. package/dist/i18n/locales/fr.js.map +1 -1
  131. package/dist/i18n/locales/hu.d.ts +17 -12
  132. package/dist/i18n/locales/hu.js +207 -11
  133. package/dist/i18n/locales/hu.js.map +1 -1
  134. package/dist/i18n/locales/it.d.ts +17 -12
  135. package/dist/i18n/locales/it.js +209 -6
  136. package/dist/i18n/locales/it.js.map +1 -1
  137. package/dist/i18n/locales/zh.d.ts +17 -12
  138. package/dist/i18n/locales/zh.js +200 -6
  139. package/dist/i18n/locales/zh.js.map +1 -1
  140. package/dist/i18n/review.d.ts +17 -12
  141. package/dist/i18n/review.js +37 -1
  142. package/dist/i18n/review.js.map +1 -1
  143. package/dist/index.d.ts +8 -3
  144. package/dist/index.js +53 -0
  145. package/dist/index.js.map +1 -1
  146. package/dist/rhf/fields.d.ts +17 -12
  147. package/dist/rhf/form.d.ts +17 -12
  148. package/dist/rhf.d.ts +17 -12
  149. package/dist/shell/app-shell.d.ts +17 -12
  150. package/dist/shell/top-bar-brand.d.ts +17 -12
  151. package/dist/shell.d.ts +11 -6
  152. package/dist/wizard/stepper-nav.d.ts +17 -12
  153. package/dist/wizard.d.ts +17 -12
  154. package/package.json +1 -1
  155. package/src/account/account-parts.ts +67 -0
  156. package/src/account/data-export-setting.tsx +218 -0
  157. package/src/account/delete-account-setting.tsx +362 -0
  158. package/src/account/email-change-setting.tsx +457 -0
  159. package/src/account/sessions-setting.tsx +283 -0
  160. package/src/admin/admin-action-confirm.tsx +495 -0
  161. package/src/admin/admin-action-log.tsx +297 -0
  162. package/src/admin/admin-parts.ts +82 -0
  163. package/src/admin/invitations-panel.tsx +516 -0
  164. package/src/admin/reviewer-scope-editor.tsx +234 -0
  165. package/src/admin/role-select.tsx +286 -0
  166. package/src/admin/transfer-ownership-dialog.tsx +172 -0
  167. package/src/admin/user-roster.tsx +573 -0
  168. package/src/auth/auth-errors.ts +165 -8
  169. package/src/auth/forgot-password-form.tsx +21 -5
  170. package/src/auth/register-form.tsx +24 -6
  171. package/src/auth/reset-password-form.tsx +19 -5
  172. package/src/auth/sign-in-form.tsx +207 -35
  173. package/src/auth/verify-email.tsx +59 -8
  174. package/src/components/account-chips.tsx +64 -7
  175. package/src/components/account-settings-labels.ts +15 -1
  176. package/src/components/account-settings.tsx +199 -23
  177. package/src/hooks/use-anchored-panel.ts +14 -3
  178. package/src/i18n/defaults.ts +22 -0
  179. package/src/i18n/german.ts +241 -5
  180. package/src/i18n/kit-labels.tsx +33 -0
  181. package/src/i18n/locales/es.ts +230 -3
  182. package/src/i18n/locales/fr.ts +232 -0
  183. package/src/i18n/locales/hu.ts +224 -5
  184. package/src/i18n/locales/it.ts +230 -0
  185. package/src/i18n/locales/zh.ts +205 -0
  186. package/src/i18n/review.ts +36 -0
  187. package/src/index.ts +93 -0
@@ -10,8 +10,8 @@ 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 { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from "./auth-errors";
14
+ import type { KitErrorCode } from "./auth-errors";
15
15
  import { taggedEmail } from "./email-tag";
16
16
  import { newPasswordProblem } from "./form-rules";
17
17
  import { NewPasswordFields } from "./new-password-fields";
@@ -72,8 +72,17 @@ export interface SignInCredentials {
72
72
 
73
73
  export interface SignInCodeValues {
74
74
  challengeToken: string;
75
- /** Digits only — {@link OneTimeCodeInput} drops the spaces of "123 456". */
75
+ /** Digits only — {@link OneTimeCodeInput} drops the spaces of "123 456". A backup code
76
+ * (`kind: "recovery"`) is trimmed and otherwise as typed: its dashes and case are the
77
+ * server's to normalise, as kastlan's `_normalise_backup_code` does. */
76
78
  code: string;
79
+ /**
80
+ * 0.30.0: `"recovery"` when the person typed one of their backup codes instead
81
+ * (`recoveryCode`); absent for the authenticator's code, as before. kastlan's
82
+ * `/auth/login/2fa` takes either in the same `code` and tells them apart itself; an
83
+ * app whose server takes a backup code elsewhere routes on this.
84
+ */
85
+ kind?: "recovery";
77
86
  }
78
87
 
79
88
  export interface SignInNewPasswordValues {
@@ -140,9 +149,32 @@ export interface SignInLabels {
140
149
  expired: string;
141
150
  /** From the second and third step back to the credentials. */
142
151
  backToSignIn: string;
152
+ /*
153
+ * 0.30.0 — OPTIONAL, like `TwoFactorSettingLabels.qrAlt` and for its reason: a new
154
+ * required key is a compile error at every call site that annotates this interface
155
+ * (CONTRIBUTING's additive-API rule). Each falls back to the provider's `signIn`, then
156
+ * English.
157
+ */
158
+ /** The second-factor step's switch to a backup code (`recoveryCode`). */
159
+ useRecoveryCode?: string;
160
+ /** The switch back, from the backup-code field to the authenticator's code. */
161
+ useAuthenticatorCode?: string;
162
+ /** The backup-code field's label. */
163
+ recoveryCode?: string;
164
+ /** The step's intro while a backup code is asked for, in place of `twoFactorIntro`. */
165
+ recoveryIntro?: string;
166
+ /** Under the backup-code field, given how many letters and digits a code has. */
167
+ recoveryCodeHint?: (length: number) => string;
168
+ /** A refused backup code — wrong, or spent already (each one works once). */
169
+ recoveryCodeInvalid?: string;
170
+ /** Any step's request was throttled — HTTP 429 ({@link isRateLimited}) — given the
171
+ * wait its `Retry-After` asks for, in seconds ({@link retryAfterSeconds}), or
172
+ * `undefined` (or `0`) without one. */
173
+ rateLimited?: (seconds?: number) => string;
143
174
  }
144
175
 
145
- export const DEFAULT_SIGN_IN_LABELS: SignInLabels = {
176
+ /** `Required`: every key, the 0.30 ones included, has its English here. */
177
+ export const DEFAULT_SIGN_IN_LABELS: Required<SignInLabels> = {
146
178
  email: "Email",
147
179
  password: "Password",
148
180
  submit: "Sign in",
@@ -169,6 +201,13 @@ export const DEFAULT_SIGN_IN_LABELS: SignInLabels = {
169
201
  setPasswordFailed: "The password could not be set.",
170
202
  expired: "This sign-in has expired. Please sign in again.",
171
203
  backToSignIn: "Back to sign in",
204
+ useRecoveryCode: "Use a backup code",
205
+ useAuthenticatorCode: "Use your authenticator app",
206
+ recoveryCode: "Backup code",
207
+ recoveryIntro: "Enter one of the backup codes you saved when you turned on two-factor authentication.",
208
+ recoveryCodeHint: (length) => `${length} letters and digits. Dashes and spaces don’t matter.`,
209
+ recoveryCodeInvalid: "This backup code is not valid, or it has been used already.",
210
+ rateLimited: englishRateLimited,
172
211
  };
173
212
 
174
213
  /* ── Props ───────────────────────────────────────────────────────────────── */
@@ -230,10 +269,26 @@ export interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "
230
269
  deactivatedContact?: ReactNode;
231
270
  /** Digits of the second factor. Default 6 (TOTP). */
232
271
  codeLength?: number;
272
+ /**
273
+ * 0.30.0: offer a backup code on the second-factor step (kastlan's 0.29 adoption: its
274
+ * `/auth/login/2fa` takes one of the eight codes minted at 2FA setup, and the kit's
275
+ * step had no way to type one). A "Use a backup code" switch under the code field
276
+ * swaps the digits-only {@link OneTimeCodeInput} for a plain text field; the code goes
277
+ * to `onCode` with the same challenge and `kind: "recovery"`.
278
+ *
279
+ * `true` for a 16-character code (kastlan's `ABCD-EFGH-JKLM-NPQR`), or `{ length }`.
280
+ * The length counts letters and digits only — the dashes and spaces a printed code is
281
+ * grouped with don't count — and "Verify" waits for it. Left out, no switch.
282
+ */
283
+ recoveryCode?: boolean | { length?: number };
233
284
  /** App content at the top of the credentials step — a notice, a demo button. */
234
285
  credentialsContent?: ReactNode;
235
- /** App content on the second-factor step, under its intro. */
236
- twoFactorContent?: ReactNode;
286
+ /**
287
+ * App content on the second-factor step, under its intro. 0.30.0: pass a function and
288
+ * it gets the challenge token — kastlan kept its own copy of the token for a "lost
289
+ * your device?" link, which the step now hands over.
290
+ */
291
+ twoFactorContent?: ReactNode | ((challengeToken: string) => ReactNode);
237
292
  /**
238
293
  * App content on the set-a-new-password step, under its intro — keksdose's
239
294
  * private-mode note ("Your encrypted data is untouched …"), which depends on the
@@ -241,9 +296,10 @@ export interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "
241
296
  */
242
297
  passwordChangeContent?: ReactNode | ((extra: Readonly<Record<string, unknown>> | undefined) => ReactNode);
243
298
  /**
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.
299
+ * The app's own words for a failure, or `undefined` for the kit's: the device being
300
+ * offline, a throttle with its wait ("try again in an hour"). Asked first, for every
301
+ * failure; what it returns replaces the message, and the `invalid_credentials` hints
302
+ * still follow. A bare `429` needs none since 0.30.0: the kit says `rateLimited`.
247
303
  */
248
304
  describeError?: (error: unknown, action: SignInAction) => ReactNode | undefined;
249
305
  /** The step changed — retitle the page (`AuthLayout`'s `title`) if the app wants. */
@@ -258,7 +314,7 @@ export interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "
258
314
 
259
315
  interface Failure {
260
316
  action: SignInAction;
261
- code: AuthErrorCode | undefined;
317
+ code: KitErrorCode | undefined;
262
318
  message: ReactNode;
263
319
  /** The address the failed credentials carried — the tag hint names its tagged form. */
264
320
  email?: string;
@@ -284,6 +340,16 @@ function withContact(template: string, contact: ReactNode): ReactNode[] {
284
340
  }
285
341
 
286
342
  const HINT_CLASS = "text-xs text-[var(--text-secondary)]";
343
+
344
+ /** A backup code's letters and digits — kastlan's: 16, printed `ABCD-EFGH-JKLM-NPQR`. */
345
+ const RECOVERY_CODE_LENGTH = 16;
346
+
347
+ /** How many letters and digits a typed backup code has: the dashes and spaces it is
348
+ * printed in groups with don't count, so "abcd-efgh ijkl mnop" is 16 — the count
349
+ * kastlan's server normalises the code to before it compares. */
350
+ function recoveryCharacters(value: string): number {
351
+ return value.replace(/[^\p{L}\p{N}]/gu, "").length;
352
+ }
287
353
  const INTRO_CLASS = "text-sm text-[var(--text-secondary)]";
288
354
 
289
355
  /* ── The form ────────────────────────────────────────────────────────────── */
@@ -319,7 +385,14 @@ const INTRO_CLASS = "text-sm text-[var(--text-secondary)]";
319
385
  * whole code. A refused code stays here with its error; `token_invalid` (the
320
386
  * challenge expired) goes back to the credentials and says why. Kurvenschmiede has
321
387
  * no 2FA yet, and the step is here from the start, so adding it is backend work only
322
- * (§10.5).
388
+ * (§10.5). With `recoveryCode` (0.30.0) a switch under the field takes a backup code
389
+ * instead, through the same `onCode` with `kind: "recovery"`.
390
+ *
391
+ * **A throttled request** (HTTP 429, {@link isRateLimited}) says "Too many attempts. Try
392
+ * again in 30 s." on any step (0.30.0) — the wait from its `Retry-After`, else "Wait a
393
+ * moment and try again." — after `describeError`, whose words still come first, and
394
+ * instead of "wrong password" or "invalid code", which would send the person to try
395
+ * another one.
323
396
  * - New password: new + confirm with the strength meter, the 8-character / 72-byte
324
397
  * rules and nothing else (§8). It reads as explained, not blocked (keksdose): the
325
398
  * person did nothing wrong.
@@ -339,6 +412,7 @@ export function SignInForm({
339
412
  emailTag,
340
413
  deactivatedContact,
341
414
  codeLength = 6,
415
+ recoveryCode,
342
416
  credentialsContent,
343
417
  twoFactorContent,
344
418
  passwordChangeContent,
@@ -354,6 +428,9 @@ export function SignInForm({
354
428
  const [email, setEmail] = useState(defaultEmail);
355
429
  const [password, setPassword] = useState("");
356
430
  const [code, setCode] = useState("");
431
+ // 0.30.0: the second factor as a backup code instead of the authenticator's digits.
432
+ const [useRecovery, setUseRecovery] = useState(false);
433
+ const [recovery, setRecovery] = useState("");
357
434
  const [newPassword, setNewPassword] = useState("");
358
435
  const [confirm, setConfirm] = useState("");
359
436
  const [challenge, setChallenge] = useState<{ token: string; extra?: Readonly<Record<string, unknown>> } | null>(
@@ -368,6 +445,7 @@ export function SignInForm({
368
445
  const emailRef = useRef<HTMLInputElement>(null);
369
446
  const passwordRef = useRef<HTMLInputElement>(null);
370
447
  const codeRef = useRef<HTMLInputElement>(null);
448
+ const recoveryRef = useRef<HTMLInputElement>(null);
371
449
  const newPasswordRef = useRef<HTMLInputElement>(null);
372
450
 
373
451
  // A late answer after unmount (the app navigated away) must not set state.
@@ -394,6 +472,8 @@ export function SignInForm({
394
472
  // The password has done its work; it does not stay in state for the next steps.
395
473
  setPassword("");
396
474
  setCode("");
475
+ setRecovery("");
476
+ setUseRecovery(false);
397
477
  setNewPassword("");
398
478
  setConfirm("");
399
479
  if (answer.kind === "2fa") {
@@ -405,25 +485,41 @@ export function SignInForm({
405
485
  }
406
486
  };
407
487
 
408
- const messageFor = (error: unknown, action: SignInAction, code: AuthErrorCode | undefined): ReactNode => {
488
+ const messageFor = (
489
+ error: unknown,
490
+ action: SignInAction,
491
+ code: KitErrorCode | undefined,
492
+ recoveryUsed: boolean,
493
+ ): ReactNode => {
409
494
  const own = describeError?.(error, action);
410
495
  if (hasMessage(own)) return own;
496
+ // After the app's words, before the kit's generic ones (0.30.0): a throttle is
497
+ // neither a wrong password nor a wrong code, and saying so sends the person to try
498
+ // another one — which the limiter counts too.
499
+ if (isRateLimited(error)) return (labels.rateLimited ?? DEFAULT_SIGN_IN_LABELS.rateLimited)(retryAfterSeconds(error));
411
500
  if (action === "password") return code === "invalid_credentials" ? labels.invalidCredentials : labels.failed;
412
501
  if (action === "passkey") return labels.passkeyFailed;
413
- if (action === "code") return code === "token_invalid" ? labels.expired : labels.codeInvalid;
502
+ if (action === "code") {
503
+ if (code === "token_invalid") return labels.expired;
504
+ return recoveryUsed
505
+ ? (labels.recoveryCodeInvalid ?? DEFAULT_SIGN_IN_LABELS.recoveryCodeInvalid)
506
+ : labels.codeInvalid;
507
+ }
414
508
  return code === "token_invalid" ? labels.expired : labels.setPasswordFailed;
415
509
  };
416
510
 
417
- const fail = (error: unknown, action: SignInAction, sentEmail?: string) => {
511
+ const fail = (error: unknown, action: SignInAction, sentEmail?: string, recoveryUsed = false) => {
418
512
  setBusy(null);
419
513
  // Our own abort (a passkey ceremony cancelled by the unmount) is no failure.
420
514
  if ((error as { name?: unknown } | null)?.name === "AbortError") return;
421
515
  const code = authErrorCode(error);
422
- const message = messageFor(error, action, code);
516
+ const message = messageFor(error, action, code, recoveryUsed);
423
517
  // The challenge expired between the steps: only a fresh sign-in helps.
424
518
  if ((action === "code" || action === "set-password") && code === "token_invalid") {
425
519
  setChallenge(null);
426
520
  setCode("");
521
+ setRecovery("");
522
+ setUseRecovery(false);
427
523
  setNewPassword("");
428
524
  setConfirm("");
429
525
  goTo("credentials");
@@ -431,7 +527,12 @@ export function SignInForm({
431
527
  setFailure({ action, code, message, email: sentEmail });
432
528
  };
433
529
 
434
- const attempt = (action: SignInAction, call: () => Promise<SignInAnswer>, sentEmail?: string) => {
530
+ const attempt = (
531
+ action: SignInAction,
532
+ call: () => Promise<SignInAnswer>,
533
+ sentEmail?: string,
534
+ recoveryUsed = false,
535
+ ) => {
435
536
  if (busy) return;
436
537
  setBusy(action);
437
538
  setFailure(null);
@@ -446,7 +547,7 @@ export function SignInForm({
446
547
  if (mounted.current) present(answer);
447
548
  },
448
549
  (error: unknown) => {
449
- if (mounted.current) fail(error, action, sentEmail);
550
+ if (mounted.current) fail(error, action, sentEmail, recoveryUsed);
450
551
  },
451
552
  );
452
553
  };
@@ -488,12 +589,24 @@ export function SignInForm({
488
589
  else if (emailRef.current?.value) passwordRef.current?.focus();
489
590
  else emailRef.current?.focus();
490
591
  }, [step]);
592
+ // The backup-code switch swaps one field for the other under the focus that pressed
593
+ // it; the field that came in is where the person is going. Only after a switch on
594
+ // this step — arriving on it is the effect above.
595
+ const shownRecovery = useRef(useRecovery);
596
+ useEffect(() => {
597
+ if (shownRecovery.current === useRecovery) return;
598
+ shownRecovery.current = useRecovery;
599
+ if (step !== "2fa") return;
600
+ (useRecovery ? recoveryRef : codeRef).current?.focus();
601
+ }, [useRecovery, step]);
491
602
 
492
603
  const backToCredentials = () => {
493
604
  if (busy) return;
494
605
  setChallenge(null);
495
606
  setFailure(null);
496
607
  setCode("");
608
+ setRecovery("");
609
+ setUseRecovery(false);
497
610
  setNewPassword("");
498
611
  setConfirm("");
499
612
  goTo("credentials");
@@ -504,35 +617,94 @@ export function SignInForm({
504
617
 
505
618
  /* ── Step 2: the second factor ── */
506
619
  if (step === "2fa" && challenge) {
620
+ const recoveryOn = recoveryCode !== undefined && recoveryCode !== false;
621
+ const recoveryLength = (typeof recoveryCode === "object" ? recoveryCode.length : undefined) ?? RECOVERY_CODE_LENGTH;
622
+ const asRecovery = recoveryOn && useRecovery;
623
+ const recoveryReady = recoveryCharacters(recovery) === recoveryLength;
624
+ const ready = asRecovery ? recoveryReady : code.length === codeLength;
507
625
  const submitCode = (e: FormEvent) => {
508
626
  e.preventDefault();
509
- if (code.length !== codeLength) return;
627
+ if (!ready) return;
628
+ if (asRecovery) {
629
+ attempt(
630
+ "code",
631
+ () => onCode({ challengeToken: challenge.token, code: recovery.trim(), kind: "recovery" }),
632
+ undefined,
633
+ true,
634
+ );
635
+ return;
636
+ }
510
637
  attempt("code", () => onCode({ challengeToken: challenge.token, code }));
511
638
  };
639
+ const switchCode = () => {
640
+ if (working) return;
641
+ setFailure(null);
642
+ setCode("");
643
+ setRecovery("");
644
+ setUseRecovery((on) => !on);
645
+ };
512
646
  const codeError = failure?.action === "code" ? failure.message : undefined;
647
+ const content =
648
+ typeof twoFactorContent === "function" ? twoFactorContent(challenge.token) : twoFactorContent;
513
649
  return (
514
650
  <div {...rest} data-step="2fa" className={rootClass}>
515
651
  <form className="space-y-3" aria-labelledby={headingId} onSubmit={submitCode}>
516
652
  <Heading id={headingId} className="text-base font-semibold text-[var(--text-primary)]">
517
653
  {labels.twoFactorTitle}
518
654
  </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}>
655
+ <p className={INTRO_CLASS}>
656
+ {asRecovery ? (labels.recoveryIntro ?? DEFAULT_SIGN_IN_LABELS.recoveryIntro) : labels.twoFactorIntro}
657
+ </p>
658
+ {content}
659
+ {asRecovery ? (
660
+ // A plain text field, not the digits-only one: a backup code is letters and
661
+ // digits in printed groups. Nothing a phone keyboard would "correct", and no
662
+ // autofill — the browser's suggestion for an unknown field is the username.
663
+ <Input
664
+ ref={recoveryRef}
665
+ label={labels.recoveryCode ?? DEFAULT_SIGN_IN_LABELS.recoveryCode}
666
+ name="recovery-code"
667
+ value={recovery}
668
+ onChange={(e) => {
669
+ setRecovery(e.target.value);
670
+ if (failure) setFailure(null);
671
+ }}
672
+ autoComplete="off"
673
+ autoCapitalize="characters"
674
+ autoCorrect="off"
675
+ spellCheck={false}
676
+ inputClassName="font-mono"
677
+ hint={(labels.recoveryCodeHint ?? DEFAULT_SIGN_IN_LABELS.recoveryCodeHint)(recoveryLength)}
678
+ readOnly={working}
679
+ error={codeError}
680
+ required
681
+ />
682
+ ) : (
683
+ <OneTimeCodeInput
684
+ ref={codeRef}
685
+ label={labels.code}
686
+ name="code"
687
+ value={code}
688
+ length={codeLength}
689
+ onChange={(next) => {
690
+ setCode(next);
691
+ if (failure) setFailure(null);
692
+ }}
693
+ readOnly={working}
694
+ error={codeError}
695
+ required
696
+ />
697
+ )}
698
+ {recoveryOn && (
699
+ <p className="text-sm">
700
+ <Button type="button" variant="link" size="sm" disabled={working} onClick={switchCode}>
701
+ {asRecovery
702
+ ? (labels.useAuthenticatorCode ?? DEFAULT_SIGN_IN_LABELS.useAuthenticatorCode)
703
+ : (labels.useRecoveryCode ?? DEFAULT_SIGN_IN_LABELS.useRecoveryCode)}
704
+ </Button>
705
+ </p>
706
+ )}
707
+ <Button type="submit" className="w-full" disabled={!working && !ready} pending={working}>
536
708
  {labels.verify}
537
709
  </Button>
538
710
  </form>
@@ -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
  }