@eifi1/ui-kit 0.28.0 → 0.29.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 +22 -4
- package/dist/auth/accept-invitation.d.ts +90 -0
- package/dist/auth/accept-invitation.js +117 -0
- package/dist/auth/accept-invitation.js.map +1 -0
- package/dist/auth/auth-errors.d.ts +60 -0
- package/dist/auth/auth-errors.js +32 -0
- package/dist/auth/auth-errors.js.map +1 -0
- package/dist/auth/complete-name-dialog.d.ts +71 -0
- package/dist/auth/complete-name-dialog.js +168 -0
- package/dist/auth/complete-name-dialog.js.map +1 -0
- package/dist/auth/email-tag.d.ts +60 -0
- package/dist/auth/email-tag.js +23 -0
- package/dist/auth/email-tag.js.map +1 -0
- package/dist/auth/forgot-password-form.d.ts +79 -0
- package/dist/auth/forgot-password-form.js +120 -0
- package/dist/auth/forgot-password-form.js.map +1 -0
- package/dist/auth/form-rules.d.ts +27 -0
- package/dist/auth/form-rules.js +23 -0
- package/dist/auth/form-rules.js.map +1 -0
- package/dist/auth/new-password-fields.d.ts +38 -0
- package/dist/auth/new-password-fields.js +54 -0
- package/dist/auth/new-password-fields.js.map +1 -0
- package/dist/auth/not-found-page.d.ts +57 -0
- package/dist/auth/not-found-page.js +40 -0
- package/dist/auth/not-found-page.js.map +1 -0
- package/dist/auth/register-form.d.ts +86 -0
- package/dist/auth/register-form.js +268 -0
- package/dist/auth/register-form.js.map +1 -0
- package/dist/auth/reset-password-form.d.ts +124 -0
- package/dist/auth/reset-password-form.js +164 -0
- package/dist/auth/reset-password-form.js.map +1 -0
- package/dist/auth/sign-in-form.d.ts +243 -0
- package/dist/auth/sign-in-form.js +345 -0
- package/dist/auth/sign-in-form.js.map +1 -0
- package/dist/auth/status-parts.d.ts +78 -0
- package/dist/auth/status-parts.js +83 -0
- package/dist/auth/status-parts.js.map +1 -0
- package/dist/auth/verify-email.d.ts +121 -0
- package/dist/auth/verify-email.js +207 -0
- package/dist/auth/verify-email.js.map +1 -0
- package/dist/chart.d.ts +17 -2
- package/dist/components/amount-input.d.ts +18 -2
- package/dist/components/button-group.d.ts +18 -2
- package/dist/components/calculator.d.ts +18 -2
- package/dist/components/confirm-dialog.d.ts +18 -2
- package/dist/components/copy-button.d.ts +18 -2
- package/dist/components/danger-confirm.d.ts +18 -2
- package/dist/components/facing-pair.d.ts +18 -2
- package/dist/components/file-button.d.ts +18 -2
- package/dist/components/file-dropzone.d.ts +18 -2
- package/dist/components/form-actions.d.ts +18 -2
- package/dist/components/iban-input.d.ts +18 -2
- package/dist/components/language-select.d.ts +18 -9
- package/dist/components/legal-page.d.ts +26 -4
- package/dist/components/legal-page.js +24 -5
- package/dist/components/legal-page.js.map +1 -1
- package/dist/components/legal.d.ts +18 -2
- package/dist/components/legal.js +49 -5
- package/dist/components/legal.js.map +1 -1
- package/dist/components/money-field.d.ts +18 -2
- package/dist/components/number-field.d.ts +18 -2
- package/dist/components/number-input.d.ts +18 -2
- package/dist/components/numpad-sheet.d.ts +18 -2
- package/dist/components/phone-input.d.ts +18 -2
- package/dist/components/series-chart.d.ts +18 -2
- package/dist/components/settings-fields.d.ts +15 -2
- package/dist/components/share-card.d.ts +18 -2
- package/dist/components/text-link.d.ts +18 -2
- package/dist/components/time-input.d.ts +18 -2
- package/dist/components/ui.d.ts +17 -1
- package/dist/components/user-avatar.d.ts +16 -2
- package/dist/components/user-avatar.js +7 -3
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/feedback/feedback-attachment.d.ts +18 -2
- package/dist/feedback/feedback-context.d.ts +18 -2
- package/dist/feedback/feedback-crash.d.ts +18 -2
- package/dist/feedback/feedback-dialog.d.ts +18 -2
- package/dist/feedback/feedback-inbox.d.ts +18 -2
- package/dist/feedback/feedback-labels.d.ts +18 -2
- package/dist/feedback/feedback-menu.d.ts +18 -2
- package/dist/feedback/feedback-record.d.ts +18 -2
- package/dist/feedback/feedback-row-detail.d.ts +18 -2
- package/dist/feedback/feedback-status-undo.d.ts +18 -2
- package/dist/feedback/feedback-submit.d.ts +18 -2
- package/dist/feedback/feedback-swipe.d.ts +18 -2
- package/dist/feedback/feedback-table.d.ts +18 -2
- package/dist/feedback/feedback-thread.d.ts +18 -2
- package/dist/feedback.d.ts +18 -2
- package/dist/hooks/use-file-drop.d.ts +18 -2
- package/dist/i18n/defaults.d.ts +18 -2
- package/dist/i18n/defaults.js +18 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +18 -2
- package/dist/i18n/german.js +145 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +18 -2
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +13 -201
- package/dist/i18n/locales/de-CH.d.ts +18 -2
- package/dist/i18n/locales/en.d.ts +18 -2
- package/dist/i18n/locales/es.d.ts +18 -2
- package/dist/i18n/locales/es.js +147 -3
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +18 -2
- package/dist/i18n/locales/fr.js +147 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +18 -2
- package/dist/i18n/locales/hu.js +149 -3
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +18 -2
- package/dist/i18n/locales/it.js +146 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +18 -2
- package/dist/i18n/locales/zh.js +146 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +18 -2
- package/dist/i18n/review.js +17 -7
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +13 -2
- package/dist/index.js +49 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/person-name.d.ts +69 -0
- package/dist/lib/person-name.js +65 -0
- package/dist/lib/person-name.js.map +1 -0
- package/dist/{kit-labels-SBN0biPl.d.ts → register-form-V1OeNlMr.d.ts} +423 -6
- package/dist/rhf/fields.d.ts +18 -2
- package/dist/rhf/form.d.ts +18 -2
- package/dist/rhf.d.ts +18 -2
- package/dist/shell/app-shell.d.ts +18 -2
- package/dist/shell/company-switcher.d.ts +59 -0
- package/dist/shell/company-switcher.js +85 -0
- package/dist/shell/company-switcher.js.map +1 -0
- package/dist/shell/top-bar-brand.d.ts +18 -2
- package/dist/shell/topbar-action-menu.d.ts +1 -0
- package/dist/shell.d.ts +12 -2
- package/dist/wizard/stepper-nav.d.ts +18 -2
- package/dist/wizard.d.ts +18 -2
- package/package.json +1 -1
- package/src/auth/accept-invitation.tsx +227 -0
- package/src/auth/auth-errors.ts +105 -0
- package/src/auth/complete-name-dialog.tsx +253 -0
- package/src/auth/email-tag.ts +75 -0
- package/src/auth/forgot-password-form.tsx +213 -0
- package/src/auth/form-rules.ts +40 -0
- package/src/auth/new-password-fields.tsx +85 -0
- package/src/auth/not-found-page.tsx +98 -0
- package/src/auth/register-form.tsx +459 -0
- package/src/auth/reset-password-form.tsx +337 -0
- package/src/auth/sign-in-form.tsx +687 -0
- package/src/auth/status-parts.tsx +138 -0
- package/src/auth/verify-email.tsx +358 -0
- package/src/components/legal-page.tsx +35 -0
- package/src/components/legal.tsx +93 -7
- package/src/components/user-avatar.tsx +22 -4
- package/src/i18n/defaults.ts +18 -0
- package/src/i18n/german.ts +153 -0
- package/src/i18n/kit-labels.tsx +27 -0
- package/src/i18n/locales/es.ts +156 -3
- package/src/i18n/locales/fr.ts +157 -0
- package/src/i18n/locales/hu.ts +159 -3
- package/src/i18n/locales/it.ts +154 -1
- package/src/i18n/locales/zh.ts +149 -1
- package/src/i18n/review.ts +15 -6
- package/src/index.ts +63 -1
- package/src/lib/person-name.ts +157 -0
- package/src/shell/company-switcher.tsx +140 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ReactNode, ComponentPropsWithoutRef } from 'react';
|
|
3
|
+
import { AuthHeadingLevel } from './status-parts.js';
|
|
4
|
+
|
|
5
|
+
/** Every string the form renders — the `resetPassword` namespace. keksdose's words
|
|
6
|
+
* (`auth.reset.*`), which kastlan took over. */
|
|
7
|
+
interface ResetPasswordLabels {
|
|
8
|
+
/** The heading, on every state. */
|
|
9
|
+
title: string;
|
|
10
|
+
/** While the link is checked. */
|
|
11
|
+
checking: string;
|
|
12
|
+
/** Over the fields, given the account's address (when the check returned one). */
|
|
13
|
+
intro: (email: string) => string;
|
|
14
|
+
newPassword: string;
|
|
15
|
+
confirmPassword: string;
|
|
16
|
+
/** Under the confirmation while it differs. */
|
|
17
|
+
mismatch: string;
|
|
18
|
+
submit: string;
|
|
19
|
+
/** The new password was not saved, for a reason that is not the link. */
|
|
20
|
+
failed: string;
|
|
21
|
+
/** The page was opened without a `token`. */
|
|
22
|
+
noToken: string;
|
|
23
|
+
/** The link is unknown, spent or expired — one sentence, as the server gives one
|
|
24
|
+
* code (`token_invalid`). */
|
|
25
|
+
invalid: string;
|
|
26
|
+
/** Under it. */
|
|
27
|
+
invalidHint: string;
|
|
28
|
+
/** The way forward from an invalid link: back to "Forgot password". */
|
|
29
|
+
requestNew: string;
|
|
30
|
+
/** Done. */
|
|
31
|
+
success: string;
|
|
32
|
+
/** Under it: a reset ends every session (§6.3). */
|
|
33
|
+
sessionsEnded: string;
|
|
34
|
+
/** The button out — to sign-in, with the address filled in. */
|
|
35
|
+
signIn: string;
|
|
36
|
+
}
|
|
37
|
+
declare const DEFAULT_RESET_PASSWORD_LABELS: ResetPasswordLabels;
|
|
38
|
+
/** What `onCheck` may resolve with — keksdose's `/password-reset/check` answer. */
|
|
39
|
+
interface ResetPasswordCheck {
|
|
40
|
+
/** The account's address: named over the fields ("You're setting a new password for
|
|
41
|
+
* …") and handed to the password manager, which files the new password under it. */
|
|
42
|
+
email?: string;
|
|
43
|
+
/** The app's notices over the fields, shown BEFORE the password is typed — keksdose's
|
|
44
|
+
* "your account is end-to-end encrypted: this restores your sign-in, not your data". */
|
|
45
|
+
notices?: ReactNode;
|
|
46
|
+
}
|
|
47
|
+
/** What `onSubmit` hands over. */
|
|
48
|
+
interface ResetPasswordValues {
|
|
49
|
+
token: string;
|
|
50
|
+
password: string;
|
|
51
|
+
}
|
|
52
|
+
/** What `onSubmit` resolves with — keksdose's `/password-reset/confirm` answer, which
|
|
53
|
+
* carries no tokens (§6.3). */
|
|
54
|
+
interface ResetPasswordResult {
|
|
55
|
+
/** The account's address — the sign-in it leads to is filled in with it. */
|
|
56
|
+
email: string;
|
|
57
|
+
/** The app's outcomes, shown on the done state: keksdose's recovery-code notice for an
|
|
58
|
+
* encrypted account, "Your 2 personal access tokens were revoked". */
|
|
59
|
+
notices?: ReactNode;
|
|
60
|
+
}
|
|
61
|
+
interface ResetPasswordFormProps extends Omit<ComponentPropsWithoutRef<"div">, "onSubmit" | "children" | "title"> {
|
|
62
|
+
/** The link's token — the app's `?token=` (keksdose, kastlan: `/reset-password?token=…`).
|
|
63
|
+
* Empty: the link was cut short, and the page says so without asking anyone. */
|
|
64
|
+
token: string;
|
|
65
|
+
/**
|
|
66
|
+
* Check the link before the form shows (`/auth/password-reset/check`) — once per
|
|
67
|
+
* token, on mount. Checking does not use it up, so a reload is free. Resolve (with
|
|
68
|
+
* the address and any notices) for a live link; reject for a dead one, and the page
|
|
69
|
+
* says "This link is invalid or has expired" with the way to a new one.
|
|
70
|
+
*/
|
|
71
|
+
onCheck: (token: string) => Promise<ResetPasswordCheck | void | undefined>;
|
|
72
|
+
/**
|
|
73
|
+
* The new password was submitted: send `/auth/password-reset/confirm`. Resolve with
|
|
74
|
+
* the account's address (and the app's notices) — no tokens: a reset is not a sign-in
|
|
75
|
+
* (§6.3). Reject with the client's error: `token_invalid` (the link expired or was
|
|
76
|
+
* used in another tab while the form was open) goes to the invalid state; anything
|
|
77
|
+
* else stays on the form with the password kept.
|
|
78
|
+
*/
|
|
79
|
+
onSubmit: (values: ResetPasswordValues) => Promise<ResetPasswordResult>;
|
|
80
|
+
/** "Go to sign in" calls this with the address, for an app that routes in code.
|
|
81
|
+
* Without it, the button is a link to `signInHref`. */
|
|
82
|
+
onSignIn?: (email: string) => void;
|
|
83
|
+
/** Where "Go to sign in" leads, with `?email=` added — read it into `SignInForm`'s
|
|
84
|
+
* `defaultEmail`. Default `/login`. */
|
|
85
|
+
signInHref?: string;
|
|
86
|
+
/** "Request a new link" — the app's "Forgot password" page. Default
|
|
87
|
+
* `/forgot-password`. */
|
|
88
|
+
forgotHref?: string;
|
|
89
|
+
/** The app's words for a failed save (a `429`, the device offline), or `undefined`
|
|
90
|
+
* for the kit's `failed`. Not asked for `token_invalid`, which is a state. */
|
|
91
|
+
describeError?: (error: unknown) => ReactNode | undefined;
|
|
92
|
+
/** The heading. Default `labels.title`; `null` draws none, for a page that puts it
|
|
93
|
+
* in `AuthLayout`'s `title` instead. */
|
|
94
|
+
title?: ReactNode;
|
|
95
|
+
/** The heading's level. Default `h2`, under the page's `h1`. */
|
|
96
|
+
headingAs?: AuthHeadingLevel;
|
|
97
|
+
labels?: Partial<ResetPasswordLabels>;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Redeem a password-reset link (`/reset-password?token=…`, docs/auth-harmonization.md
|
|
101
|
+
* §6.3, §7) — keksdose's `ResetPasswordPage` (features/auth/reset-password-page.tsx),
|
|
102
|
+
* which kastlan built its own on.
|
|
103
|
+
*
|
|
104
|
+
* **The link is checked first** (`onCheck`), and only then does the form appear: an
|
|
105
|
+
* expired link should say so before someone types a new password twice, not after.
|
|
106
|
+
*
|
|
107
|
+
* **A reset is not a sign-in** (§6.3, §10.11). The confirm answers without tokens, so a
|
|
108
|
+
* 2FA account cannot skip its second factor by way of its inbox, and the page ends on
|
|
109
|
+
* "Go to sign in" with the address filled in — the person has just typed the new
|
|
110
|
+
* password twice; signing in is one step. What else the reset did is said there:
|
|
111
|
+
* every other session ended (the kit's line, true in all three apps), and the app's own
|
|
112
|
+
* outcomes in `notices` — keksdose's recovery-code notice and its revoked API tokens.
|
|
113
|
+
*
|
|
114
|
+
* **The password** is chosen with the same fields as `RegisterForm` and `SignInForm`'s
|
|
115
|
+
* set-password step ({@link NewPasswordFields}): the strength meter and its checklist as
|
|
116
|
+
* advice, and only the two hard rules — at least 8 characters, at most 72 bytes (§8).
|
|
117
|
+
* The submit waits for both and a matching confirmation.
|
|
118
|
+
*
|
|
119
|
+
* The form never sends a request itself (§8): the token and the password go to the
|
|
120
|
+
* callbacks, and the page renders what they resolve with.
|
|
121
|
+
*/
|
|
122
|
+
declare function ResetPasswordForm({ token, onCheck, onSubmit, onSignIn, signInHref, forgotHref, describeError, title, headingAs: Heading, labels: labelsProp, className, ...rest }: ResetPasswordFormProps): react.JSX.Element;
|
|
123
|
+
|
|
124
|
+
export { DEFAULT_RESET_PASSWORD_LABELS, type ResetPasswordCheck, ResetPasswordForm, type ResetPasswordFormProps, type ResetPasswordLabels, type ResetPasswordResult, type ResetPasswordValues };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { CheckCircle2, XCircle } from "lucide-react";
|
|
5
|
+
import { cn } from "../lib/cn.js";
|
|
6
|
+
import { useKitLabels } from "../i18n/kit-labels.js";
|
|
7
|
+
import { AlertBanner } from "../components/alert-banner.js";
|
|
8
|
+
import { LoadingState } from "../components/loading-state.js";
|
|
9
|
+
import { Button, EmptyState } from "../components/ui.js";
|
|
10
|
+
import { authErrorCode } from "./auth-errors.js";
|
|
11
|
+
import { newPasswordProblem } from "./form-rules.js";
|
|
12
|
+
import { NewPasswordFields } from "./new-password-fields.js";
|
|
13
|
+
import {
|
|
14
|
+
FORM_HEADING_CLASS,
|
|
15
|
+
INTRO_CLASS,
|
|
16
|
+
OUTCOME_CLASS,
|
|
17
|
+
OutcomeMessage,
|
|
18
|
+
useFocusWhen,
|
|
19
|
+
useOncePerKey,
|
|
20
|
+
withQuery
|
|
21
|
+
} from "./status-parts.js";
|
|
22
|
+
const DEFAULT_RESET_PASSWORD_LABELS = {
|
|
23
|
+
title: "Choose a new password",
|
|
24
|
+
checking: "Checking the link\u2026",
|
|
25
|
+
intro: (email) => `You\u2019re setting a new password for ${email}.`,
|
|
26
|
+
newPassword: "New password",
|
|
27
|
+
confirmPassword: "Repeat new password",
|
|
28
|
+
mismatch: "The passwords don\u2019t match.",
|
|
29
|
+
submit: "Save password",
|
|
30
|
+
failed: "The password could not be changed.",
|
|
31
|
+
noToken: "This link is incomplete.",
|
|
32
|
+
invalid: "This link is invalid or has expired.",
|
|
33
|
+
invalidHint: "Links are valid for one hour and work only once. Just request a new one.",
|
|
34
|
+
requestNew: "Request a new link",
|
|
35
|
+
success: "Your password has been changed. You can sign in with it now.",
|
|
36
|
+
sessionsEnded: "Signed-in devices were signed out \u2014 you\u2019ll need to sign in again there.",
|
|
37
|
+
signIn: "Go to sign in"
|
|
38
|
+
};
|
|
39
|
+
function ResetPasswordForm({
|
|
40
|
+
token,
|
|
41
|
+
onCheck,
|
|
42
|
+
onSubmit,
|
|
43
|
+
onSignIn,
|
|
44
|
+
signInHref = "/login",
|
|
45
|
+
forgotHref = "/forgot-password",
|
|
46
|
+
describeError,
|
|
47
|
+
title,
|
|
48
|
+
headingAs: Heading = "h2",
|
|
49
|
+
labels: labelsProp,
|
|
50
|
+
className,
|
|
51
|
+
...rest
|
|
52
|
+
}) {
|
|
53
|
+
const labels = useKitLabels("resetPassword", DEFAULT_RESET_PASSWORD_LABELS, labelsProp);
|
|
54
|
+
const [phase, setPhase] = useState(null);
|
|
55
|
+
const current = token === "" ? { kind: "invalid" } : phase?.token === token ? phase.phase : "checking";
|
|
56
|
+
useOncePerKey(
|
|
57
|
+
token === "" ? null : token,
|
|
58
|
+
onCheck,
|
|
59
|
+
(key, result) => setPhase({
|
|
60
|
+
token: key,
|
|
61
|
+
phase: result.ok ? { kind: "form", check: result.value ?? {} } : { kind: "invalid" }
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
const [password, setPassword] = useState("");
|
|
65
|
+
const [confirm, setConfirm] = useState("");
|
|
66
|
+
const [pending, setPending] = useState(false);
|
|
67
|
+
const [failure, setFailure] = useState(null);
|
|
68
|
+
const headingId = useId();
|
|
69
|
+
const passwordRef = useRef(null);
|
|
70
|
+
const doneRef = useRef(null);
|
|
71
|
+
const kind = current === "checking" ? "checking" : current.kind;
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (kind === "form") passwordRef.current?.focus();
|
|
74
|
+
}, [kind]);
|
|
75
|
+
useFocusWhen(kind === "done", doneRef);
|
|
76
|
+
const heading = title === null ? null : /* @__PURE__ */ jsx(Heading, { id: headingId, className: FORM_HEADING_CLASS, children: title ?? labels.title });
|
|
77
|
+
const rootClass = cn("space-y-3", className);
|
|
78
|
+
if (current === "checking") {
|
|
79
|
+
return /* @__PURE__ */ jsxs("div", { ...rest, "data-state": "checking", "aria-busy": true, className: rootClass, children: [
|
|
80
|
+
heading,
|
|
81
|
+
/* @__PURE__ */ jsx(LoadingState, { size: "sm", label: labels.checking })
|
|
82
|
+
] });
|
|
83
|
+
}
|
|
84
|
+
if (current.kind === "invalid") {
|
|
85
|
+
return /* @__PURE__ */ jsxs("div", { ...rest, "data-state": "invalid", className: rootClass, children: [
|
|
86
|
+
heading,
|
|
87
|
+
/* @__PURE__ */ jsx(
|
|
88
|
+
EmptyState,
|
|
89
|
+
{
|
|
90
|
+
tone: "danger",
|
|
91
|
+
icon: /* @__PURE__ */ jsx(XCircle, {}),
|
|
92
|
+
title: token === "" ? labels.noToken : labels.invalid,
|
|
93
|
+
hint: labels.invalidHint,
|
|
94
|
+
action: /* @__PURE__ */ jsx(Button, { href: forgotHref, variant: "brand", children: labels.requestNew }),
|
|
95
|
+
className: OUTCOME_CLASS
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
] });
|
|
99
|
+
}
|
|
100
|
+
if (current.kind === "done") {
|
|
101
|
+
const { email: email2, notices: notices2 } = current.result;
|
|
102
|
+
return /* @__PURE__ */ jsxs("div", { ...rest, "data-state": "done", className: rootClass, children: [
|
|
103
|
+
heading,
|
|
104
|
+
/* @__PURE__ */ jsx(OutcomeMessage, { icon: /* @__PURE__ */ jsx(CheckCircle2, { className: "text-[var(--success)]" }), focusRef: doneRef, children: /* @__PURE__ */ jsx("p", { className: INTRO_CLASS, children: labels.success }) }),
|
|
105
|
+
notices2,
|
|
106
|
+
/* @__PURE__ */ jsx("p", { className: "text-xs text-[var(--text-muted)]", children: labels.sessionsEnded }),
|
|
107
|
+
onSignIn ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "brand", className: "w-full", onClick: () => onSignIn(email2), children: labels.signIn }) : /* @__PURE__ */ jsx(Button, { href: email2 ? withQuery(signInHref, "email", email2) : signInHref, variant: "brand", className: "w-full", children: labels.signIn })
|
|
108
|
+
] });
|
|
109
|
+
}
|
|
110
|
+
const { email, notices } = current.check;
|
|
111
|
+
const problem = newPasswordProblem(password, confirm);
|
|
112
|
+
const submit = (e) => {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
if (pending || problem !== null) return;
|
|
115
|
+
setFailure(null);
|
|
116
|
+
setPending(true);
|
|
117
|
+
Promise.resolve().then(() => onSubmit({ token, password })).then(
|
|
118
|
+
(result) => {
|
|
119
|
+
setPending(false);
|
|
120
|
+
setPassword("");
|
|
121
|
+
setConfirm("");
|
|
122
|
+
setPhase({ token, phase: { kind: "done", result } });
|
|
123
|
+
},
|
|
124
|
+
(error) => {
|
|
125
|
+
setPending(false);
|
|
126
|
+
if (authErrorCode(error) === "token_invalid") {
|
|
127
|
+
setPassword("");
|
|
128
|
+
setConfirm("");
|
|
129
|
+
setPhase({ token, phase: { kind: "invalid" } });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
setFailure(describeError?.(error) ?? labels.failed);
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
return /* @__PURE__ */ jsx("div", { ...rest, "data-state": "form", className: rootClass, children: /* @__PURE__ */ jsxs("form", { className: "space-y-3", "aria-labelledby": title === null ? void 0 : headingId, onSubmit: submit, children: [
|
|
137
|
+
heading,
|
|
138
|
+
email ? /* @__PURE__ */ jsx("p", { className: INTRO_CLASS, children: labels.intro(email) }) : null,
|
|
139
|
+
notices,
|
|
140
|
+
email ? /* @__PURE__ */ jsx("input", { type: "email", name: "username", autoComplete: "username", value: email, readOnly: true, hidden: true }) : null,
|
|
141
|
+
/* @__PURE__ */ jsx(
|
|
142
|
+
NewPasswordFields,
|
|
143
|
+
{
|
|
144
|
+
password,
|
|
145
|
+
confirm,
|
|
146
|
+
onPasswordChange: (next) => {
|
|
147
|
+
setPassword(next);
|
|
148
|
+
if (failure) setFailure(null);
|
|
149
|
+
},
|
|
150
|
+
onConfirmChange: setConfirm,
|
|
151
|
+
busy: pending,
|
|
152
|
+
passwordRef,
|
|
153
|
+
text: { password: labels.newPassword, confirm: labels.confirmPassword, mismatch: labels.mismatch }
|
|
154
|
+
}
|
|
155
|
+
),
|
|
156
|
+
failure != null && /* @__PURE__ */ jsx(AlertBanner, { tone: "danger", size: "sm", role: "alert", children: failure }),
|
|
157
|
+
/* @__PURE__ */ jsx(Button, { type: "submit", className: "w-full", disabled: !pending && problem !== null, pending, children: labels.submit })
|
|
158
|
+
] }) });
|
|
159
|
+
}
|
|
160
|
+
export {
|
|
161
|
+
DEFAULT_RESET_PASSWORD_LABELS,
|
|
162
|
+
ResetPasswordForm
|
|
163
|
+
};
|
|
164
|
+
//# sourceMappingURL=reset-password-form.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/auth/reset-password-form.tsx"],"sourcesContent":["import { useEffect, useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, FormEvent, ReactNode } from \"react\";\nimport { CheckCircle2, XCircle } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { LoadingState } from \"../components/loading-state\";\nimport { Button, EmptyState } from \"../components/ui\";\nimport { authErrorCode } from \"./auth-errors\";\nimport { newPasswordProblem } from \"./form-rules\";\nimport { NewPasswordFields } from \"./new-password-fields\";\nimport {\n FORM_HEADING_CLASS,\n INTRO_CLASS,\n OUTCOME_CLASS,\n OutcomeMessage,\n useFocusWhen,\n useOncePerKey,\n withQuery,\n} from \"./status-parts\";\nimport type { AuthHeadingLevel } from \"./status-parts\";\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the form renders — the `resetPassword` namespace. keksdose's words\n * (`auth.reset.*`), which kastlan took over. */\nexport interface ResetPasswordLabels {\n /** The heading, on every state. */\n title: string;\n /** While the link is checked. */\n checking: string;\n /** Over the fields, given the account's address (when the check returned one). */\n intro: (email: string) => string;\n newPassword: string;\n confirmPassword: string;\n /** Under the confirmation while it differs. */\n mismatch: string;\n submit: string;\n /** The new password was not saved, for a reason that is not the link. */\n failed: string;\n /** The page was opened without a `token`. */\n noToken: string;\n /** The link is unknown, spent or expired — one sentence, as the server gives one\n * code (`token_invalid`). */\n invalid: string;\n /** Under it. */\n invalidHint: string;\n /** The way forward from an invalid link: back to \"Forgot password\". */\n requestNew: string;\n /** Done. */\n success: string;\n /** Under it: a reset ends every session (§6.3). */\n sessionsEnded: string;\n /** The button out — to sign-in, with the address filled in. */\n signIn: string;\n}\n\nexport const DEFAULT_RESET_PASSWORD_LABELS: ResetPasswordLabels = {\n title: \"Choose a new password\",\n checking: \"Checking the link…\",\n intro: (email) => `You’re setting a new password for ${email}.`,\n newPassword: \"New password\",\n confirmPassword: \"Repeat new password\",\n mismatch: \"The passwords don’t match.\",\n submit: \"Save password\",\n failed: \"The password could not be changed.\",\n noToken: \"This link is incomplete.\",\n invalid: \"This link is invalid or has expired.\",\n invalidHint: \"Links are valid for one hour and work only once. Just request a new one.\",\n requestNew: \"Request a new link\",\n success: \"Your password has been changed. You can sign in with it now.\",\n sessionsEnded: \"Signed-in devices were signed out — you’ll need to sign in again there.\",\n signIn: \"Go to sign in\",\n};\n\n/* ── Types ───────────────────────────────────────────────────────────────── */\n\n/** What `onCheck` may resolve with — keksdose's `/password-reset/check` answer. */\nexport interface ResetPasswordCheck {\n /** The account's address: named over the fields (\"You're setting a new password for\n * …\") and handed to the password manager, which files the new password under it. */\n email?: string;\n /** The app's notices over the fields, shown BEFORE the password is typed — keksdose's\n * \"your account is end-to-end encrypted: this restores your sign-in, not your data\". */\n notices?: ReactNode;\n}\n\n/** What `onSubmit` hands over. */\nexport interface ResetPasswordValues {\n token: string;\n password: string;\n}\n\n/** What `onSubmit` resolves with — keksdose's `/password-reset/confirm` answer, which\n * carries no tokens (§6.3). */\nexport interface ResetPasswordResult {\n /** The account's address — the sign-in it leads to is filled in with it. */\n email: string;\n /** The app's outcomes, shown on the done state: keksdose's recovery-code notice for an\n * encrypted account, \"Your 2 personal access tokens were revoked\". */\n notices?: ReactNode;\n}\n\ntype Phase =\n | { kind: \"form\"; check: ResetPasswordCheck }\n | { kind: \"invalid\" }\n | { kind: \"done\"; result: ResetPasswordResult };\n\nexport interface ResetPasswordFormProps\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onSubmit\" | \"children\" | \"title\"> {\n /** The link's token — the app's `?token=` (keksdose, kastlan: `/reset-password?token=…`).\n * Empty: the link was cut short, and the page says so without asking anyone. */\n token: string;\n /**\n * Check the link before the form shows (`/auth/password-reset/check`) — once per\n * token, on mount. Checking does not use it up, so a reload is free. Resolve (with\n * the address and any notices) for a live link; reject for a dead one, and the page\n * says \"This link is invalid or has expired\" with the way to a new one.\n */\n onCheck: (token: string) => Promise<ResetPasswordCheck | void | undefined>;\n /**\n * The new password was submitted: send `/auth/password-reset/confirm`. Resolve with\n * the account's address (and the app's notices) — no tokens: a reset is not a sign-in\n * (§6.3). Reject with the client's error: `token_invalid` (the link expired or was\n * used in another tab while the form was open) goes to the invalid state; anything\n * else stays on the form with the password kept.\n */\n onSubmit: (values: ResetPasswordValues) => Promise<ResetPasswordResult>;\n /** \"Go to sign in\" calls this with the address, for an app that routes in code.\n * Without it, the button is a link to `signInHref`. */\n onSignIn?: (email: string) => void;\n /** Where \"Go to sign in\" leads, with `?email=` added — read it into `SignInForm`'s\n * `defaultEmail`. Default `/login`. */\n signInHref?: string;\n /** \"Request a new link\" — the app's \"Forgot password\" page. Default\n * `/forgot-password`. */\n forgotHref?: string;\n /** The app's words for a failed save (a `429`, the device offline), or `undefined`\n * for the kit's `failed`. Not asked for `token_invalid`, which is a state. */\n describeError?: (error: unknown) => ReactNode | undefined;\n /** The heading. Default `labels.title`; `null` draws none, for a page that puts it\n * in `AuthLayout`'s `title` instead. */\n title?: ReactNode;\n /** The heading's level. Default `h2`, under the page's `h1`. */\n headingAs?: AuthHeadingLevel;\n labels?: Partial<ResetPasswordLabels>;\n}\n\n/**\n * Redeem a password-reset link (`/reset-password?token=…`, docs/auth-harmonization.md\n * §6.3, §7) — keksdose's `ResetPasswordPage` (features/auth/reset-password-page.tsx),\n * which kastlan built its own on.\n *\n * **The link is checked first** (`onCheck`), and only then does the form appear: an\n * expired link should say so before someone types a new password twice, not after.\n *\n * **A reset is not a sign-in** (§6.3, §10.11). The confirm answers without tokens, so a\n * 2FA account cannot skip its second factor by way of its inbox, and the page ends on\n * \"Go to sign in\" with the address filled in — the person has just typed the new\n * password twice; signing in is one step. What else the reset did is said there:\n * every other session ended (the kit's line, true in all three apps), and the app's own\n * outcomes in `notices` — keksdose's recovery-code notice and its revoked API tokens.\n *\n * **The password** is chosen with the same fields as `RegisterForm` and `SignInForm`'s\n * set-password step ({@link NewPasswordFields}): the strength meter and its checklist as\n * advice, and only the two hard rules — at least 8 characters, at most 72 bytes (§8).\n * The submit waits for both and a matching confirmation.\n *\n * The form never sends a request itself (§8): the token and the password go to the\n * callbacks, and the page renders what they resolve with.\n */\nexport function ResetPasswordForm({\n token,\n onCheck,\n onSubmit,\n onSignIn,\n signInHref = \"/login\",\n forgotHref = \"/forgot-password\",\n describeError,\n title,\n headingAs: Heading = \"h2\",\n labels: labelsProp,\n className,\n ...rest\n}: ResetPasswordFormProps) {\n const labels = useKitLabels(\"resetPassword\", DEFAULT_RESET_PASSWORD_LABELS, labelsProp);\n // The phase belongs to the token it was reached with: a new token starts over at the\n // check without a reset effect.\n const [phase, setPhase] = useState<{ token: string; phase: Phase } | null>(null);\n const current: Phase | \"checking\" =\n token === \"\" ? { kind: \"invalid\" } : phase?.token === token ? phase.phase : \"checking\";\n\n useOncePerKey(token === \"\" ? null : token, onCheck, (key, result) =>\n setPhase({\n token: key,\n phase: result.ok ? { kind: \"form\", check: result.value ?? {} } : { kind: \"invalid\" },\n }),\n );\n\n const [password, setPassword] = useState(\"\");\n const [confirm, setConfirm] = useState(\"\");\n const [pending, setPending] = useState(false);\n const [failure, setFailure] = useState<ReactNode>(null);\n const headingId = useId();\n const passwordRef = useRef<HTMLInputElement>(null);\n const doneRef = useRef<HTMLDivElement>(null);\n const kind = current === \"checking\" ? \"checking\" : current.kind;\n\n // The form arrives after the check: the field is where the person is going.\n useEffect(() => {\n if (kind === \"form\") passwordRef.current?.focus();\n }, [kind]);\n useFocusWhen(kind === \"done\", doneRef);\n\n const heading =\n title === null ? null : (\n <Heading id={headingId} className={FORM_HEADING_CLASS}>\n {title ?? labels.title}\n </Heading>\n );\n const rootClass = cn(\"space-y-3\", className);\n\n if (current === \"checking\") {\n return (\n <div {...rest} data-state=\"checking\" aria-busy className={rootClass}>\n {heading}\n <LoadingState size=\"sm\" label={labels.checking} />\n </div>\n );\n }\n\n if (current.kind === \"invalid\") {\n return (\n <div {...rest} data-state=\"invalid\" className={rootClass}>\n {heading}\n <EmptyState\n tone=\"danger\"\n icon={<XCircle />}\n title={token === \"\" ? labels.noToken : labels.invalid}\n hint={labels.invalidHint}\n action={\n <Button href={forgotHref} variant=\"brand\">\n {labels.requestNew}\n </Button>\n }\n className={OUTCOME_CLASS}\n />\n </div>\n );\n }\n\n if (current.kind === \"done\") {\n const { email, notices } = current.result;\n return (\n <div {...rest} data-state=\"done\" className={rootClass}>\n {heading}\n <OutcomeMessage icon={<CheckCircle2 className=\"text-[var(--success)]\" />} focusRef={doneRef}>\n <p className={INTRO_CLASS}>{labels.success}</p>\n </OutcomeMessage>\n {notices}\n <p className=\"text-xs text-[var(--text-muted)]\">{labels.sessionsEnded}</p>\n {onSignIn ? (\n <Button type=\"button\" variant=\"brand\" className=\"w-full\" onClick={() => onSignIn(email)}>\n {labels.signIn}\n </Button>\n ) : (\n <Button href={email ? withQuery(signInHref, \"email\", email) : signInHref} variant=\"brand\" className=\"w-full\">\n {labels.signIn}\n </Button>\n )}\n </div>\n );\n }\n\n const { email, notices } = current.check;\n const problem = newPasswordProblem(password, confirm);\n\n const submit = (e: FormEvent) => {\n e.preventDefault();\n if (pending || problem !== null) return;\n setFailure(null);\n setPending(true);\n Promise.resolve()\n .then(() => onSubmit({ token, password }))\n .then(\n (result) => {\n setPending(false);\n setPassword(\"\");\n setConfirm(\"\");\n setPhase({ token, phase: { kind: \"done\", result } });\n },\n (error: unknown) => {\n setPending(false);\n if (authErrorCode(error) === \"token_invalid\") {\n setPassword(\"\");\n setConfirm(\"\");\n setPhase({ token, phase: { kind: \"invalid\" } });\n return;\n }\n setFailure(describeError?.(error) ?? labels.failed);\n },\n );\n };\n\n return (\n <div {...rest} data-state=\"form\" className={rootClass}>\n <form className=\"space-y-3\" aria-labelledby={title === null ? undefined : headingId} onSubmit={submit}>\n {heading}\n {email ? <p className={INTRO_CLASS}>{labels.intro(email)}</p> : null}\n {notices}\n {/* Which account the new password is for, for the password manager: it files\n the new password under this username instead of guessing (kastlan). */}\n {email ? <input type=\"email\" name=\"username\" autoComplete=\"username\" value={email} readOnly hidden /> : null}\n <NewPasswordFields\n password={password}\n confirm={confirm}\n onPasswordChange={(next) => {\n setPassword(next);\n if (failure) setFailure(null);\n }}\n onConfirmChange={setConfirm}\n busy={pending}\n passwordRef={passwordRef}\n text={{ password: labels.newPassword, confirm: labels.confirmPassword, mismatch: labels.mismatch }}\n />\n {failure != null && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n <Button type=\"submit\" className=\"w-full\" disabled={!pending && problem !== null} pending={pending}>\n {labels.submit}\n </Button>\n </form>\n </div>\n );\n}\n"],"mappings":"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","names":["email","notices"]}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
import { ComponentPropsWithoutRef, ReactNode } from 'react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a sign-in call answers, in the kit's words — docs/auth-harmonization.md §5.1,
|
|
6
|
+
* keksdose's three shapes. The app maps its own HTTP answer to one of these and resolves
|
|
7
|
+
* with it; a refusal REJECTS (with whatever its client throws — {@link authErrorCode}
|
|
8
|
+
* reads the code out of it).
|
|
9
|
+
*
|
|
10
|
+
* | the server answers (keksdose) | resolve with |
|
|
11
|
+
* |---|---|
|
|
12
|
+
* | `TokenResponse` `{access_token, refresh_token, token_type, user}` | `{ kind: "signed-in" }`, after the app has stored the session |
|
|
13
|
+
* | `{requires_2fa: true, challenge_token}` | `{ kind: "2fa", challengeToken: challenge_token }` |
|
|
14
|
+
* | `{requires_password_change: true, challenge_token, encrypted}` | `{ kind: "password-change", challengeToken: challenge_token, extra: { encrypted } }` |
|
|
15
|
+
* | `401 {code: "invalid_credentials"}` | reject — let the client's error through |
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* async function toAnswer(res: TokenResponse | TwoFactorChallenge | PasswordChangeChallenge): Promise<SignInAnswer> {
|
|
19
|
+
* if ("requires_2fa" in res) return { kind: "2fa", challengeToken: res.challenge_token };
|
|
20
|
+
* if ("requires_password_change" in res)
|
|
21
|
+
* return { kind: "password-change", challengeToken: res.challenge_token, extra: { encrypted: res.encrypted } };
|
|
22
|
+
* await onLoginSuccess(res); // store tokens, load /auth/me, switch language
|
|
23
|
+
* return { kind: "signed-in" };
|
|
24
|
+
* }
|
|
25
|
+
* <SignInForm onSubmit={async (v) => toAnswer(await authApi.login(v))} … />
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* The same mapping serves `/auth/login/2fa`, `/auth/login/set-password` and the passkey
|
|
29
|
+
* finish, which answer the same union.
|
|
30
|
+
*/
|
|
31
|
+
type SignInAnswer = {
|
|
32
|
+
kind: "signed-in";
|
|
33
|
+
} | {
|
|
34
|
+
kind: "2fa";
|
|
35
|
+
challengeToken: string;
|
|
36
|
+
} | {
|
|
37
|
+
kind: "password-change";
|
|
38
|
+
challengeToken: string;
|
|
39
|
+
/** The challenge's app fields, passed through untouched — keksdose's `encrypted`,
|
|
40
|
+
* which its note on this step reads (`passwordChangeContent`). */
|
|
41
|
+
extra?: Readonly<Record<string, unknown>>;
|
|
42
|
+
};
|
|
43
|
+
/** The step on screen: the credentials, the second factor, or a new password. */
|
|
44
|
+
type SignInStep = "credentials" | "2fa" | "password-change";
|
|
45
|
+
/** What the person did when a call failed — {@link SignInFormProps.describeError}'s
|
|
46
|
+
* second argument. */
|
|
47
|
+
type SignInAction = "password" | "passkey" | "code" | "set-password";
|
|
48
|
+
interface SignInCredentials {
|
|
49
|
+
/** Trimmed. Lower-casing is the server's (§3.1). */
|
|
50
|
+
email: string;
|
|
51
|
+
/** As typed, never trimmed. */
|
|
52
|
+
password: string;
|
|
53
|
+
}
|
|
54
|
+
interface SignInCodeValues {
|
|
55
|
+
challengeToken: string;
|
|
56
|
+
/** Digits only — {@link OneTimeCodeInput} drops the spaces of "123 456". */
|
|
57
|
+
code: string;
|
|
58
|
+
}
|
|
59
|
+
interface SignInNewPasswordValues {
|
|
60
|
+
challengeToken: string;
|
|
61
|
+
password: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Every string `SignInForm` renders — the `signIn` namespace of `<UiKitProvider
|
|
65
|
+
* labels>`, overridable per instance through `labels`. keksdose's words where it has
|
|
66
|
+
* them (`auth.*` in its catalogue).
|
|
67
|
+
*/
|
|
68
|
+
interface SignInLabels {
|
|
69
|
+
email: string;
|
|
70
|
+
password: string;
|
|
71
|
+
/** The submit button. */
|
|
72
|
+
submit: string;
|
|
73
|
+
/** The passkey button (`onPasskey`). */
|
|
74
|
+
passkey: string;
|
|
75
|
+
/** The link to `forgotHref`. */
|
|
76
|
+
forgotPassword: string;
|
|
77
|
+
/** Before the link to `registerHref`. */
|
|
78
|
+
noAccount: string;
|
|
79
|
+
/** The link to `registerHref`. */
|
|
80
|
+
register: string;
|
|
81
|
+
/** The ONE answer to an unknown address, a wrong password and a deactivated account
|
|
82
|
+
* (`invalid_credentials`, §5.2). */
|
|
83
|
+
invalidCredentials: string;
|
|
84
|
+
/** Any other failed sign-in (the network, a 500). */
|
|
85
|
+
failed: string;
|
|
86
|
+
/** A failed or refused passkey sign-in. */
|
|
87
|
+
passkeyFailed: string;
|
|
88
|
+
/**
|
|
89
|
+
* The hint under `invalidCredentials` (§2.10), shown when `deactivatedContact` is
|
|
90
|
+
* given. A TEMPLATE: `{contact}` is replaced by the contact — a `mailto:` link for an
|
|
91
|
+
* address — so a language puts it where its grammar needs it.
|
|
92
|
+
*/
|
|
93
|
+
deactivatedHint: string;
|
|
94
|
+
/** The hint under `invalidCredentials` when `emailTag` is given (§4.5), given the
|
|
95
|
+
* typed address with the app's tag: "Signed up with ada+kastlan@example.com?". */
|
|
96
|
+
tagHint: (taggedAddress: string) => string;
|
|
97
|
+
/** The second-factor step's heading. */
|
|
98
|
+
twoFactorTitle: string;
|
|
99
|
+
twoFactorIntro: string;
|
|
100
|
+
/** The code field's label. */
|
|
101
|
+
code: string;
|
|
102
|
+
/** The code step's submit button. */
|
|
103
|
+
verify: string;
|
|
104
|
+
/** The code was refused. */
|
|
105
|
+
codeInvalid: string;
|
|
106
|
+
/** The set-a-new-password step's heading. */
|
|
107
|
+
setPasswordTitle: string;
|
|
108
|
+
setPasswordIntro: string;
|
|
109
|
+
newPassword: string;
|
|
110
|
+
confirmPassword: string;
|
|
111
|
+
passwordMismatch: string;
|
|
112
|
+
/** The set-a-new-password step's submit button. */
|
|
113
|
+
setPasswordSubmit: string;
|
|
114
|
+
setPasswordFailed: string;
|
|
115
|
+
/** A challenge was refused as `token_invalid` — it expired between the steps; the
|
|
116
|
+
* form is back on the credentials. */
|
|
117
|
+
expired: string;
|
|
118
|
+
/** From the second and third step back to the credentials. */
|
|
119
|
+
backToSignIn: string;
|
|
120
|
+
}
|
|
121
|
+
declare const DEFAULT_SIGN_IN_LABELS: SignInLabels;
|
|
122
|
+
interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "onSubmit" | "children"> {
|
|
123
|
+
/**
|
|
124
|
+
* The credentials step was submitted. Send them, and resolve with the answer
|
|
125
|
+
* ({@link SignInAnswer}) or reject with the client's error. The form never sends a
|
|
126
|
+
* request itself (§8): keksdose must see the password on its way, to refuse the same
|
|
127
|
+
* string as an encryption passphrase later.
|
|
128
|
+
*
|
|
129
|
+
* On `signed-in` the form's job is over — it stays busy while the app navigates.
|
|
130
|
+
*/
|
|
131
|
+
onSubmit: (credentials: SignInCredentials) => Promise<SignInAnswer>;
|
|
132
|
+
/** The second factor was submitted (`/auth/login/2fa`). Resolves with the next answer
|
|
133
|
+
* — keksdose may still require a new password after the code. */
|
|
134
|
+
onCode: (values: SignInCodeValues) => Promise<SignInAnswer>;
|
|
135
|
+
/** The new password was submitted (`/auth/login/set-password`). Resolves with the
|
|
136
|
+
* answer, `signed-in` in practice; keksdose's `key_wrap_unchanged` note is the app's
|
|
137
|
+
* to show from here. */
|
|
138
|
+
onSetPassword: (values: SignInNewPasswordValues) => Promise<SignInAnswer>;
|
|
139
|
+
/**
|
|
140
|
+
* The passkey button was pressed: run the ceremony (`/auth/passkey/login/begin|finish`)
|
|
141
|
+
* and resolve with its answer. It asks for NO email — a passkey is bound to the site,
|
|
142
|
+
* and the browser offers every one it holds (keksdose #126). Left out, no button: pass
|
|
143
|
+
* it only where `PublicKeyCredential` exists.
|
|
144
|
+
*/
|
|
145
|
+
onPasskey?: () => Promise<SignInAnswer>;
|
|
146
|
+
/**
|
|
147
|
+
* Passkey autofill (conditional mediation). The email field is
|
|
148
|
+
* `autoComplete="username webauthn"`, so a pending `navigator.credentials.get({
|
|
149
|
+
* mediation: "conditional" })` lists the site's passkeys in its dropdown. The form
|
|
150
|
+
* calls this ONCE on mount; arm the request there and resolve with its answer when the
|
|
151
|
+
* person picks a passkey, or with `null` when nothing happens. The signal aborts on
|
|
152
|
+
* unmount — cancel the pending request then (keksdose: `armPasskeyAutofill(onDone,
|
|
153
|
+
* signal)` + `cancelPasskeyAutofill()`). A rejection is silent: the button stays the
|
|
154
|
+
* reliable path.
|
|
155
|
+
*/
|
|
156
|
+
passkeyAutofill?: (signal: AbortSignal) => Promise<SignInAnswer | null | undefined>;
|
|
157
|
+
/** "Forgot your password?" goes here — the app's `/forgot-password`. Left out, no
|
|
158
|
+
* link. */
|
|
159
|
+
forgotHref?: string;
|
|
160
|
+
/** "No account yet? Create account" goes here — `/register`. Left out, no line. */
|
|
161
|
+
registerHref?: string;
|
|
162
|
+
/** The email field's start value — the reset page ends on "Sign in" with the address
|
|
163
|
+
* filled in (§6.3). */
|
|
164
|
+
defaultEmail?: string;
|
|
165
|
+
/**
|
|
166
|
+
* The app's address tag (`"kastlan"`, §4.5). After `invalid_credentials` the form also
|
|
167
|
+
* says "Signed up with you+kastlan@example.com? Use that address." — sign-in stays
|
|
168
|
+
* exact, and the server looks nothing up.
|
|
169
|
+
*/
|
|
170
|
+
emailTag?: string;
|
|
171
|
+
/**
|
|
172
|
+
* Who a deactivated user writes to (§2.10): an address (drawn as a `mailto:` link) or
|
|
173
|
+
* the app's own node. Shown after `invalid_credentials` as "Account deactivated? Write
|
|
174
|
+
* to …". Left out, no hint.
|
|
175
|
+
*/
|
|
176
|
+
deactivatedContact?: ReactNode;
|
|
177
|
+
/** Digits of the second factor. Default 6 (TOTP). */
|
|
178
|
+
codeLength?: number;
|
|
179
|
+
/** App content at the top of the credentials step — a notice, a demo button. */
|
|
180
|
+
credentialsContent?: ReactNode;
|
|
181
|
+
/** App content on the second-factor step, under its intro. */
|
|
182
|
+
twoFactorContent?: ReactNode;
|
|
183
|
+
/**
|
|
184
|
+
* App content on the set-a-new-password step, under its intro — keksdose's
|
|
185
|
+
* private-mode note ("Your encrypted data is untouched …"), which depends on the
|
|
186
|
+
* challenge: pass a function and it gets the challenge's `extra`.
|
|
187
|
+
*/
|
|
188
|
+
passwordChangeContent?: ReactNode | ((extra: Readonly<Record<string, unknown>> | undefined) => ReactNode);
|
|
189
|
+
/**
|
|
190
|
+
* The app's own words for a failure, or `undefined` for the kit's: a throttled
|
|
191
|
+
* attempt (`429`), the device being offline. Asked first, for every failure; what it
|
|
192
|
+
* returns replaces the message, and the `invalid_credentials` hints still follow.
|
|
193
|
+
*/
|
|
194
|
+
describeError?: (error: unknown, action: SignInAction) => ReactNode | undefined;
|
|
195
|
+
/** The step changed — retitle the page (`AuthLayout`'s `title`) if the app wants. */
|
|
196
|
+
onStepChange?: (step: SignInStep) => void;
|
|
197
|
+
/** The level of the second and third step's heading. Default `h2`, under the page's
|
|
198
|
+
* `h1`; one level under `AuthLayout`'s `headingAs` where that is embedded lower. */
|
|
199
|
+
headingAs?: "h2" | "h3" | "h4" | "h5" | "h6";
|
|
200
|
+
labels?: Partial<SignInLabels>;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Sign-in for every app (docs/auth-harmonization.md §5, §8): email and password, or a
|
|
204
|
+
* passkey; then the second factor when it is on; then a new password when an admin
|
|
205
|
+
* required one. keksdose's `LoginPage` and `ForcedPasswordChangeStep`
|
|
206
|
+
* (features/auth/login-page.tsx, forced-password-change.tsx), as one component.
|
|
207
|
+
*
|
|
208
|
+
* **It never sends a request.** Each step hands its input to a callback and renders the
|
|
209
|
+
* {@link SignInAnswer} it resolves with; a rejection is shown in the step it came from.
|
|
210
|
+
* The challenge token lives in this component's state only, as in keksdose, so a reload
|
|
211
|
+
* costs a fresh sign-in — no route, no storage.
|
|
212
|
+
*
|
|
213
|
+
* **One error for every wrong credential** (§2.10, §5.2): `invalid_credentials` shows one
|
|
214
|
+
* sentence, whatever was wrong, plus two hints UNDER it — "Account deactivated? Write to
|
|
215
|
+
* …" (`deactivatedContact`) and "Signed up with you+kastlan@…? Use that address."
|
|
216
|
+
* (`emailTag`). Any other failure says "Sign-in failed" and no hint: the hints answer a
|
|
217
|
+
* refused credential, not a network that is down. `describeError` puts the app's words
|
|
218
|
+
* on a failure the kit cannot know (a throttled attempt).
|
|
219
|
+
*
|
|
220
|
+
* **The steps.**
|
|
221
|
+
* - Credentials: the email field is `username webauthn` (passkey autofill, see
|
|
222
|
+
* `passkeyAutofill`), the password a `CurrentPasswordInput` (a password manager fills
|
|
223
|
+
* it and never offers a new one). The submit is not disabled while the fields are
|
|
224
|
+
* empty: a browser that autofills on load withholds the values until the first
|
|
225
|
+
* interaction, and a disabled button would swallow that first click. The fields'
|
|
226
|
+
* own `required` answers an empty submit instead. "Forgot your password?" sits under
|
|
227
|
+
* the buttons, where someone whose password just failed is already looking
|
|
228
|
+
* (keksdose #198).
|
|
229
|
+
* - Second factor: {@link OneTimeCodeInput}, focused on arrival. "Verify" waits for the
|
|
230
|
+
* whole code. A refused code stays here with its error; `token_invalid` (the
|
|
231
|
+
* challenge expired) goes back to the credentials and says why. Kurvenschmiede has
|
|
232
|
+
* no 2FA yet, and the step is here from the start, so adding it is backend work only
|
|
233
|
+
* (§10.5).
|
|
234
|
+
* - New password: new + confirm with the strength meter, the 8-character / 72-byte
|
|
235
|
+
* rules and nothing else (§8). It reads as explained, not blocked (keksdose): the
|
|
236
|
+
* person did nothing wrong.
|
|
237
|
+
*
|
|
238
|
+
* The password is dropped from state once the credentials are answered; "Back to sign in"
|
|
239
|
+
* returns to an empty password field.
|
|
240
|
+
*/
|
|
241
|
+
declare function SignInForm({ onSubmit, onCode, onSetPassword, onPasskey, passkeyAutofill, forgotHref, registerHref, defaultEmail, emailTag, deactivatedContact, codeLength, credentialsContent, twoFactorContent, passwordChangeContent, describeError, onStepChange, headingAs: Heading, labels: labelsProp, className, ...rest }: SignInFormProps): react.JSX.Element;
|
|
242
|
+
|
|
243
|
+
export { DEFAULT_SIGN_IN_LABELS, type SignInAction, type SignInAnswer, type SignInCodeValues, type SignInCredentials, SignInForm, type SignInFormProps, type SignInLabels, type SignInNewPasswordValues, type SignInStep };
|