@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.
- package/README.md +65 -6
- package/dist/account/account-parts.d.ts +21 -0
- package/dist/account/account-parts.js +40 -0
- package/dist/account/account-parts.js.map +1 -0
- package/dist/account/data-export-setting.d.ts +73 -0
- package/dist/account/data-export-setting.js +114 -0
- package/dist/account/data-export-setting.js.map +1 -0
- package/dist/account/delete-account-setting.d.ts +95 -0
- package/dist/account/delete-account-setting.js +200 -0
- package/dist/account/delete-account-setting.js.map +1 -0
- package/dist/account/email-change-setting.d.ts +136 -0
- package/dist/account/email-change-setting.js +278 -0
- package/dist/account/email-change-setting.js.map +1 -0
- package/dist/account/sessions-setting.d.ts +110 -0
- package/dist/account/sessions-setting.js +147 -0
- package/dist/account/sessions-setting.js.map +1 -0
- package/dist/admin/admin-action-confirm.d.ts +95 -0
- package/dist/admin/admin-action-confirm.js +276 -0
- package/dist/admin/admin-action-confirm.js.map +1 -0
- package/dist/admin/admin-action-log.d.ts +105 -0
- package/dist/admin/admin-action-log.js +153 -0
- package/dist/admin/admin-action-log.js.map +1 -0
- package/dist/admin/admin-parts.d.ts +40 -0
- package/dist/admin/admin-parts.js +45 -0
- package/dist/admin/admin-parts.js.map +1 -0
- package/dist/admin/invitations-panel.d.ts +95 -0
- package/dist/admin/invitations-panel.js +290 -0
- package/dist/admin/invitations-panel.js.map +1 -0
- package/dist/admin/reviewer-scope-editor.d.ts +95 -0
- package/dist/admin/reviewer-scope-editor.js +134 -0
- package/dist/admin/reviewer-scope-editor.js.map +1 -0
- package/dist/admin/role-select.d.ts +95 -0
- package/dist/admin/role-select.js +128 -0
- package/dist/admin/role-select.js.map +1 -0
- package/dist/admin/transfer-ownership-dialog.d.ts +95 -0
- package/dist/admin/transfer-ownership-dialog.js +83 -0
- package/dist/admin/transfer-ownership-dialog.js.map +1 -0
- package/dist/admin/user-roster.d.ts +95 -0
- package/dist/admin/user-roster.js +285 -0
- package/dist/admin/user-roster.js.map +1 -0
- package/dist/auth/auth-errors.d.ts +103 -6
- package/dist/auth/auth-errors.js +68 -2
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/auth/forgot-password-form.d.ts +10 -3
- package/dist/auth/forgot-password-form.js +6 -3
- package/dist/auth/forgot-password-form.js.map +1 -1
- package/dist/auth/register-form.d.ts +21 -12
- package/dist/auth/register-form.js +6 -4
- package/dist/auth/register-form.js.map +1 -1
- package/dist/auth/reset-password-form.d.ts +10 -3
- package/dist/auth/reset-password-form.js +6 -3
- package/dist/auth/reset-password-form.js.map +1 -1
- package/dist/auth/sign-in-form.d.ts +75 -9
- package/dist/auth/sign-in-form.js +105 -15
- package/dist/auth/sign-in-form.js.map +1 -1
- package/dist/auth/verify-email.d.ts +7 -2
- package/dist/auth/verify-email.js +44 -8
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/chart.d.ts +20 -11
- package/dist/components/account-chips.d.ts +47 -7
- package/dist/components/account-chips.js +19 -3
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/account-settings-labels.d.ts +24 -1
- package/dist/components/account-settings-labels.js +6 -2
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/account-settings.d.ts +69 -8
- package/dist/components/account-settings.js +133 -25
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +21 -12
- package/dist/components/button-group.d.ts +21 -12
- package/dist/components/calculator.d.ts +21 -12
- package/dist/components/confirm-dialog.d.ts +21 -12
- package/dist/components/copy-button.d.ts +18 -9
- package/dist/components/danger-confirm.d.ts +21 -12
- package/dist/components/data-table.js +8 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +9 -4
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/facing-pair.d.ts +21 -12
- package/dist/components/file-button.d.ts +21 -12
- package/dist/components/file-dropzone.d.ts +21 -12
- package/dist/components/form-actions.d.ts +21 -12
- package/dist/components/iban-input.d.ts +21 -12
- package/dist/components/language-select.d.ts +21 -12
- package/dist/components/legal-page.d.ts +21 -12
- package/dist/components/legal.d.ts +21 -12
- package/dist/components/money-field.d.ts +21 -12
- package/dist/components/number-field.d.ts +21 -12
- package/dist/components/number-input.d.ts +21 -12
- package/dist/components/numpad-sheet.d.ts +21 -12
- package/dist/components/passkeys-setting.js +2 -1
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/phone-input.d.ts +21 -12
- package/dist/components/series-chart.d.ts +21 -12
- package/dist/components/settings-fields.d.ts +55 -13
- package/dist/components/settings-fields.js +20 -1
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/share-card.d.ts +20 -11
- package/dist/components/text-link.d.ts +21 -12
- package/dist/components/time-input.d.ts +21 -12
- package/dist/components/ui.d.ts +17 -8
- package/dist/{register-form-V1OeNlMr.d.ts → delete-account-setting-DCFS5Vof.d.ts} +6612 -5612
- package/dist/demo/demo-banner.d.ts +51 -0
- package/dist/demo/demo-banner.js +97 -0
- package/dist/demo/demo-banner.js.map +1 -0
- package/dist/demo/demo-countdown.d.ts +58 -0
- package/dist/demo/demo-countdown.js +79 -0
- package/dist/demo/demo-countdown.js.map +1 -0
- package/dist/demo/demo-ended.d.ts +45 -0
- package/dist/demo/demo-ended.js +42 -0
- package/dist/demo/demo-ended.js.map +1 -0
- package/dist/demo/demo-labels.d.ts +53 -0
- package/dist/demo/demo-labels.js +29 -0
- package/dist/demo/demo-labels.js.map +1 -0
- package/dist/demo/demo-session.d.ts +30 -0
- package/dist/demo/demo-session.js +8 -0
- package/dist/demo/demo-session.js.map +1 -0
- package/dist/demo/demo-start.d.ts +54 -0
- package/dist/demo/demo-start.js +68 -0
- package/dist/demo/demo-start.js.map +1 -0
- package/dist/feedback/feedback-attachment.d.ts +21 -12
- package/dist/feedback/feedback-context.d.ts +21 -12
- package/dist/feedback/feedback-crash.d.ts +21 -12
- package/dist/feedback/feedback-dialog.d.ts +21 -12
- package/dist/feedback/feedback-inbox.d.ts +21 -12
- package/dist/feedback/feedback-inbox.js +12 -2
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-labels.d.ts +21 -12
- package/dist/feedback/feedback-labels.js +1 -0
- package/dist/feedback/feedback-labels.js.map +1 -1
- package/dist/feedback/feedback-menu.d.ts +21 -12
- package/dist/feedback/feedback-record.d.ts +21 -12
- package/dist/feedback/feedback-record.js +2 -0
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +21 -12
- package/dist/feedback/feedback-row-detail.js +7 -0
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-status-undo.d.ts +21 -12
- package/dist/feedback/feedback-submit.d.ts +21 -12
- package/dist/feedback/feedback-swipe.d.ts +22 -13
- package/dist/feedback/feedback-swipe.js.map +1 -1
- package/dist/feedback/feedback-table.d.ts +18 -9
- package/dist/feedback/feedback-thread.d.ts +21 -12
- package/dist/feedback.d.ts +21 -12
- package/dist/hooks/use-anchored-panel.d.ts +6 -2
- package/dist/hooks/use-anchored-panel.js +2 -1
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +21 -12
- package/dist/i18n/defaults.d.ts +21 -12
- package/dist/i18n/defaults.js +28 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +21 -12
- package/dist/i18n/german.js +298 -12
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +14 -5
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +15 -6
- package/dist/i18n/locales/de-CH.d.ts +21 -12
- package/dist/i18n/locales/en.d.ts +21 -12
- package/dist/i18n/locales/es.d.ts +21 -12
- package/dist/i18n/locales/es.js +287 -10
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +21 -12
- package/dist/i18n/locales/fr.js +290 -7
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +21 -12
- package/dist/i18n/locales/hu.js +288 -12
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +21 -12
- package/dist/i18n/locales/it.js +289 -7
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +21 -12
- package/dist/i18n/locales/zh.js +279 -7
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +21 -12
- package/dist/i18n/review.js +50 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +31 -4
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -1
- package/dist/landing/access.d.ts +48 -0
- package/dist/landing/access.js +38 -0
- package/dist/landing/access.js.map +1 -0
- package/dist/landing/landing-actions.d.ts +138 -0
- package/dist/landing/landing-actions.js +39 -0
- package/dist/landing/landing-actions.js.map +1 -0
- package/dist/landing/landing-labels.d.ts +43 -0
- package/dist/landing/landing-labels.js +23 -0
- package/dist/landing/landing-labels.js.map +1 -0
- package/dist/landing/landing-sections.d.ts +218 -0
- package/dist/landing/landing-sections.js +167 -0
- package/dist/landing/landing-sections.js.map +1 -0
- package/dist/landing/page-seo.d.ts +99 -0
- package/dist/landing/page-seo.js +121 -0
- package/dist/landing/page-seo.js.map +1 -0
- package/dist/landing/public-header.d.ts +148 -0
- package/dist/landing/public-header.js +49 -0
- package/dist/landing/public-header.js.map +1 -0
- package/dist/landing/routing.d.ts +218 -0
- package/dist/landing/routing.js +81 -0
- package/dist/landing/routing.js.map +1 -0
- package/dist/rhf/fields.d.ts +21 -12
- package/dist/rhf/form.d.ts +21 -12
- package/dist/rhf.d.ts +21 -12
- package/dist/settings/settings-catalogue.d.ts +84 -0
- package/dist/settings/settings-catalogue.js +47 -0
- package/dist/settings/settings-catalogue.js.map +1 -0
- package/dist/settings/settings-context.d.ts +31 -0
- package/dist/settings/settings-context.js +11 -0
- package/dist/settings/settings-context.js.map +1 -0
- package/dist/settings/settings-heading.d.ts +50 -0
- package/dist/settings/settings-heading.js +27 -0
- package/dist/settings/settings-heading.js.map +1 -0
- package/dist/settings/settings-labels.d.ts +41 -0
- package/dist/settings/settings-labels.js +38 -0
- package/dist/settings/settings-labels.js.map +1 -0
- package/dist/settings/settings-layout.d.ts +77 -0
- package/dist/settings/settings-layout.js +242 -0
- package/dist/settings/settings-layout.js.map +1 -0
- package/dist/settings/settings-section.d.ts +133 -0
- package/dist/settings/settings-section.js +63 -0
- package/dist/settings/settings-section.js.map +1 -0
- package/dist/settings/use-account-language.d.ts +83 -0
- package/dist/settings/use-account-language.js +62 -0
- package/dist/settings/use-account-language.js.map +1 -0
- package/dist/settings/use-settings-focus.d.ts +34 -0
- package/dist/settings/use-settings-focus.js +77 -0
- package/dist/settings/use-settings-focus.js.map +1 -0
- package/dist/settings/use-settings-route.d.ts +118 -0
- package/dist/settings/use-settings-route.js +120 -0
- package/dist/settings/use-settings-route.js.map +1 -0
- package/dist/shell/app-shell.d.ts +27 -12
- package/dist/shell/app-shell.js +3 -2
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +21 -12
- package/dist/shell.d.ts +25 -6
- package/dist/shell.js +56 -0
- package/dist/shell.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +21 -12
- package/dist/wizard.d.ts +21 -12
- package/package.json +2 -1
- package/src/account/account-parts.ts +67 -0
- package/src/account/data-export-setting.tsx +219 -0
- package/src/account/delete-account-setting.tsx +363 -0
- package/src/account/email-change-setting.tsx +454 -0
- package/src/account/sessions-setting.tsx +284 -0
- package/src/admin/admin-action-confirm.tsx +495 -0
- package/src/admin/admin-action-log.tsx +297 -0
- package/src/admin/admin-parts.ts +82 -0
- package/src/admin/invitations-panel.tsx +523 -0
- package/src/admin/reviewer-scope-editor.tsx +234 -0
- package/src/admin/role-select.tsx +286 -0
- package/src/admin/transfer-ownership-dialog.tsx +172 -0
- package/src/admin/user-roster.tsx +573 -0
- package/src/auth/auth-errors.ts +196 -8
- package/src/auth/forgot-password-form.tsx +21 -5
- package/src/auth/register-form.tsx +24 -6
- package/src/auth/reset-password-form.tsx +19 -5
- package/src/auth/sign-in-form.tsx +241 -38
- package/src/auth/verify-email.tsx +59 -8
- package/src/components/account-chips.tsx +64 -7
- package/src/components/account-settings-labels.ts +28 -1
- package/src/components/account-settings.tsx +231 -34
- package/src/components/data-table.tsx +8 -0
- package/src/components/date-picker.tsx +9 -4
- package/src/components/passkeys-setting.tsx +2 -1
- package/src/components/settings-fields.tsx +50 -1
- package/src/demo/demo-banner.tsx +159 -0
- package/src/demo/demo-countdown.ts +141 -0
- package/src/demo/demo-ended.tsx +89 -0
- package/src/demo/demo-labels.ts +78 -0
- package/src/demo/demo-session.ts +32 -0
- package/src/demo/demo-start.tsx +150 -0
- package/src/feedback/feedback-inbox.tsx +21 -8
- package/src/feedback/feedback-labels.ts +2 -1
- package/src/feedback/feedback-record.ts +10 -7
- package/src/feedback/feedback-row-detail.tsx +28 -4
- package/src/feedback/feedback-swipe.tsx +1 -1
- package/src/hooks/use-anchored-panel.ts +14 -3
- package/src/i18n/defaults.ts +28 -0
- package/src/i18n/german.ts +327 -5
- package/src/i18n/kit-labels.tsx +42 -0
- package/src/i18n/locales/es.ts +313 -3
- package/src/i18n/locales/fr.ts +316 -0
- package/src/i18n/locales/hu.ts +308 -5
- package/src/i18n/locales/it.ts +313 -0
- package/src/i18n/locales/zh.ts +286 -0
- package/src/i18n/review.ts +49 -0
- package/src/index.ts +184 -0
- package/src/landing/access.ts +97 -0
- package/src/landing/landing-actions.tsx +92 -0
- package/src/landing/landing-labels.ts +59 -0
- package/src/landing/landing-sections.tsx +337 -0
- package/src/landing/page-seo.ts +251 -0
- package/src/landing/public-header.tsx +120 -0
- package/src/landing/routing.tsx +209 -0
- package/src/settings/settings-catalogue.ts +132 -0
- package/src/settings/settings-context.ts +33 -0
- package/src/settings/settings-heading.tsx +65 -0
- package/src/settings/settings-labels.ts +75 -0
- package/src/settings/settings-layout.tsx +379 -0
- package/src/settings/settings-section.tsx +108 -0
- package/src/settings/use-account-language.ts +146 -0
- package/src/settings/use-settings-focus.ts +120 -0
- package/src/settings/use-settings-route.ts +258 -0
- package/src/shell/app-shell.tsx +10 -2
- 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 {
|
|
14
|
-
import type {
|
|
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
|
-
|
|
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
|
-
/**
|
|
236
|
-
|
|
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:
|
|
245
|
-
*
|
|
246
|
-
* returns replaces the message, and the `invalid_credentials` hints
|
|
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:
|
|
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 = (
|
|
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")
|
|
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 = (
|
|
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 (
|
|
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}>
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
{
|
|
868
|
+
{accountLink !== null && (
|
|
666
869
|
<p className="text-center text-sm text-[var(--text-secondary)]">
|
|
667
870
|
{labels.noAccount}{" "}
|
|
668
|
-
<TextLink href={
|
|
669
|
-
{
|
|
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, () =>
|
|
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({
|
|
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 :
|
|
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
|
-
|
|
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 &&
|
|
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
|
|
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
|
}
|