@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.
- package/README.md +26 -4
- 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 +113 -0
- package/dist/account/data-export-setting.js.map +1 -0
- package/dist/account/delete-account-setting.d.ts +91 -0
- package/dist/account/delete-account-setting.js +199 -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 +277 -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 +146 -0
- package/dist/account/sessions-setting.js.map +1 -0
- package/dist/admin/admin-action-confirm.d.ts +91 -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 +91 -0
- package/dist/admin/invitations-panel.js +289 -0
- package/dist/admin/invitations-panel.js.map +1 -0
- package/dist/admin/reviewer-scope-editor.d.ts +91 -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 +91 -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 +91 -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 +91 -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 +85 -6
- package/dist/auth/auth-errors.js +62 -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 +17 -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 +61 -9
- package/dist/auth/sign-in-form.js +94 -13
- 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 +16 -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 +12 -1
- package/dist/components/account-settings-labels.js +4 -1
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/account-settings.d.ts +61 -6
- package/dist/components/account-settings.js +115 -15
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +17 -12
- package/dist/components/button-group.d.ts +17 -12
- package/dist/components/calculator.d.ts +17 -12
- package/dist/components/confirm-dialog.d.ts +17 -12
- package/dist/components/copy-button.d.ts +14 -9
- package/dist/components/danger-confirm.d.ts +17 -12
- package/dist/components/facing-pair.d.ts +17 -12
- package/dist/components/file-button.d.ts +17 -12
- package/dist/components/file-dropzone.d.ts +17 -12
- package/dist/components/form-actions.d.ts +17 -12
- package/dist/components/iban-input.d.ts +17 -12
- package/dist/components/language-select.d.ts +17 -12
- package/dist/components/legal-page.d.ts +17 -12
- package/dist/components/legal.d.ts +17 -12
- package/dist/components/money-field.d.ts +17 -12
- package/dist/components/number-field.d.ts +17 -12
- package/dist/components/number-input.d.ts +17 -12
- package/dist/components/numpad-sheet.d.ts +17 -12
- package/dist/components/phone-input.d.ts +17 -12
- package/dist/components/series-chart.d.ts +17 -12
- package/dist/components/settings-fields.d.ts +16 -11
- package/dist/components/share-card.d.ts +16 -11
- package/dist/components/text-link.d.ts +17 -12
- package/dist/components/time-input.d.ts +17 -12
- package/dist/components/ui.d.ts +13 -8
- package/dist/{register-form-V1OeNlMr.d.ts → delete-account-setting-CGVMckMw.d.ts} +6616 -5645
- package/dist/feedback/feedback-attachment.d.ts +17 -12
- package/dist/feedback/feedback-context.d.ts +17 -12
- package/dist/feedback/feedback-crash.d.ts +17 -12
- package/dist/feedback/feedback-dialog.d.ts +17 -12
- package/dist/feedback/feedback-inbox.d.ts +17 -12
- package/dist/feedback/feedback-labels.d.ts +17 -12
- package/dist/feedback/feedback-menu.d.ts +17 -12
- package/dist/feedback/feedback-record.d.ts +17 -12
- package/dist/feedback/feedback-row-detail.d.ts +17 -12
- package/dist/feedback/feedback-status-undo.d.ts +17 -12
- package/dist/feedback/feedback-submit.d.ts +17 -12
- package/dist/feedback/feedback-swipe.d.ts +17 -12
- package/dist/feedback/feedback-table.d.ts +14 -9
- package/dist/feedback/feedback-thread.d.ts +17 -12
- package/dist/feedback.d.ts +17 -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 +17 -12
- package/dist/i18n/defaults.d.ts +17 -12
- package/dist/i18n/defaults.js +22 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +17 -12
- package/dist/i18n/german.js +215 -11
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +10 -5
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +11 -6
- package/dist/i18n/locales/de-CH.d.ts +17 -12
- package/dist/i18n/locales/en.d.ts +17 -12
- package/dist/i18n/locales/es.d.ts +17 -12
- package/dist/i18n/locales/es.js +207 -9
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +17 -12
- package/dist/i18n/locales/fr.js +209 -6
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +17 -12
- package/dist/i18n/locales/hu.js +207 -11
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +17 -12
- package/dist/i18n/locales/it.js +209 -6
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +17 -12
- package/dist/i18n/locales/zh.js +200 -6
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +17 -12
- package/dist/i18n/review.js +37 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.js +53 -0
- package/dist/index.js.map +1 -1
- package/dist/rhf/fields.d.ts +17 -12
- package/dist/rhf/form.d.ts +17 -12
- package/dist/rhf.d.ts +17 -12
- package/dist/shell/app-shell.d.ts +17 -12
- package/dist/shell/top-bar-brand.d.ts +17 -12
- package/dist/shell.d.ts +11 -6
- package/dist/wizard/stepper-nav.d.ts +17 -12
- package/dist/wizard.d.ts +17 -12
- package/package.json +1 -1
- package/src/account/account-parts.ts +67 -0
- package/src/account/data-export-setting.tsx +218 -0
- package/src/account/delete-account-setting.tsx +362 -0
- package/src/account/email-change-setting.tsx +457 -0
- package/src/account/sessions-setting.tsx +283 -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 +516 -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 +165 -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 +207 -35
- package/src/auth/verify-email.tsx +59 -8
- package/src/components/account-chips.tsx +64 -7
- package/src/components/account-settings-labels.ts +15 -1
- package/src/components/account-settings.tsx +199 -23
- package/src/hooks/use-anchored-panel.ts +14 -3
- package/src/i18n/defaults.ts +22 -0
- package/src/i18n/german.ts +241 -5
- package/src/i18n/kit-labels.tsx +33 -0
- package/src/i18n/locales/es.ts +230 -3
- package/src/i18n/locales/fr.ts +232 -0
- package/src/i18n/locales/hu.ts +224 -5
- package/src/i18n/locales/it.ts +230 -0
- package/src/i18n/locales/zh.ts +205 -0
- package/src/i18n/review.ts +36 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
236
|
-
|
|
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:
|
|
245
|
-
*
|
|
246
|
-
* returns replaces the message, and the `invalid_credentials` hints
|
|
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:
|
|
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 = (
|
|
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")
|
|
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 = (
|
|
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 (
|
|
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}>
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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, () =>
|
|
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
|
}
|