@eifi1/ui-kit 0.28.1 → 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 +21 -3
- 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 +18 -2
- package/dist/components/legal.d.ts +18 -2
- 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 +137 -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 +137 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +18 -2
- package/dist/i18n/locales/fr.js +139 -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 +139 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +18 -2
- package/dist/i18n/locales/it.js +137 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +18 -2
- package/dist/i18n/locales/zh.js +137 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +18 -2
- package/dist/i18n/review.js +9 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +13 -2
- package/dist/index.js +36 -0
- 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-Du0uHBq4.d.ts → register-form-V1OeNlMr.d.ts} +383 -2
- 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/user-avatar.tsx +22 -4
- package/src/i18n/defaults.ts +18 -0
- package/src/i18n/german.ts +143 -0
- package/src/i18n/kit-labels.tsx +27 -0
- package/src/i18n/locales/es.ts +144 -1
- package/src/i18n/locales/fr.ts +147 -0
- package/src/i18n/locales/hu.ts +147 -1
- package/src/i18n/locales/it.ts +143 -0
- package/src/i18n/locales/zh.ts +138 -0
- package/src/i18n/review.ts +8 -0
- package/src/index.ts +53 -0
- package/src/lib/person-name.ts +157 -0
- package/src/shell/company-switcher.tsx +140 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
2
|
+
import type { ComponentPropsWithoutRef, FormEvent, ReactNode } from "react";
|
|
3
|
+
import { CheckCircle2, XCircle } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { useKitLabels } from "../i18n/kit-labels";
|
|
6
|
+
import { AlertBanner } from "../components/alert-banner";
|
|
7
|
+
import { LoadingState } from "../components/loading-state";
|
|
8
|
+
import { Button, EmptyState } from "../components/ui";
|
|
9
|
+
import { authErrorCode } from "./auth-errors";
|
|
10
|
+
import { newPasswordProblem } from "./form-rules";
|
|
11
|
+
import { NewPasswordFields } from "./new-password-fields";
|
|
12
|
+
import {
|
|
13
|
+
FORM_HEADING_CLASS,
|
|
14
|
+
INTRO_CLASS,
|
|
15
|
+
OUTCOME_CLASS,
|
|
16
|
+
OutcomeMessage,
|
|
17
|
+
useFocusWhen,
|
|
18
|
+
useOncePerKey,
|
|
19
|
+
withQuery,
|
|
20
|
+
} from "./status-parts";
|
|
21
|
+
import type { AuthHeadingLevel } from "./status-parts";
|
|
22
|
+
|
|
23
|
+
/* ── Labels ──────────────────────────────────────────────────────────────── */
|
|
24
|
+
|
|
25
|
+
/** Every string the form renders — the `resetPassword` namespace. keksdose's words
|
|
26
|
+
* (`auth.reset.*`), which kastlan took over. */
|
|
27
|
+
export interface ResetPasswordLabels {
|
|
28
|
+
/** The heading, on every state. */
|
|
29
|
+
title: string;
|
|
30
|
+
/** While the link is checked. */
|
|
31
|
+
checking: string;
|
|
32
|
+
/** Over the fields, given the account's address (when the check returned one). */
|
|
33
|
+
intro: (email: string) => string;
|
|
34
|
+
newPassword: string;
|
|
35
|
+
confirmPassword: string;
|
|
36
|
+
/** Under the confirmation while it differs. */
|
|
37
|
+
mismatch: string;
|
|
38
|
+
submit: string;
|
|
39
|
+
/** The new password was not saved, for a reason that is not the link. */
|
|
40
|
+
failed: string;
|
|
41
|
+
/** The page was opened without a `token`. */
|
|
42
|
+
noToken: string;
|
|
43
|
+
/** The link is unknown, spent or expired — one sentence, as the server gives one
|
|
44
|
+
* code (`token_invalid`). */
|
|
45
|
+
invalid: string;
|
|
46
|
+
/** Under it. */
|
|
47
|
+
invalidHint: string;
|
|
48
|
+
/** The way forward from an invalid link: back to "Forgot password". */
|
|
49
|
+
requestNew: string;
|
|
50
|
+
/** Done. */
|
|
51
|
+
success: string;
|
|
52
|
+
/** Under it: a reset ends every session (§6.3). */
|
|
53
|
+
sessionsEnded: string;
|
|
54
|
+
/** The button out — to sign-in, with the address filled in. */
|
|
55
|
+
signIn: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const DEFAULT_RESET_PASSWORD_LABELS: ResetPasswordLabels = {
|
|
59
|
+
title: "Choose a new password",
|
|
60
|
+
checking: "Checking the link…",
|
|
61
|
+
intro: (email) => `You’re setting a new password for ${email}.`,
|
|
62
|
+
newPassword: "New password",
|
|
63
|
+
confirmPassword: "Repeat new password",
|
|
64
|
+
mismatch: "The passwords don’t match.",
|
|
65
|
+
submit: "Save password",
|
|
66
|
+
failed: "The password could not be changed.",
|
|
67
|
+
noToken: "This link is incomplete.",
|
|
68
|
+
invalid: "This link is invalid or has expired.",
|
|
69
|
+
invalidHint: "Links are valid for one hour and work only once. Just request a new one.",
|
|
70
|
+
requestNew: "Request a new link",
|
|
71
|
+
success: "Your password has been changed. You can sign in with it now.",
|
|
72
|
+
sessionsEnded: "Signed-in devices were signed out — you’ll need to sign in again there.",
|
|
73
|
+
signIn: "Go to sign in",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/* ── Types ───────────────────────────────────────────────────────────────── */
|
|
77
|
+
|
|
78
|
+
/** What `onCheck` may resolve with — keksdose's `/password-reset/check` answer. */
|
|
79
|
+
export interface ResetPasswordCheck {
|
|
80
|
+
/** The account's address: named over the fields ("You're setting a new password for
|
|
81
|
+
* …") and handed to the password manager, which files the new password under it. */
|
|
82
|
+
email?: string;
|
|
83
|
+
/** The app's notices over the fields, shown BEFORE the password is typed — keksdose's
|
|
84
|
+
* "your account is end-to-end encrypted: this restores your sign-in, not your data". */
|
|
85
|
+
notices?: ReactNode;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What `onSubmit` hands over. */
|
|
89
|
+
export interface ResetPasswordValues {
|
|
90
|
+
token: string;
|
|
91
|
+
password: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What `onSubmit` resolves with — keksdose's `/password-reset/confirm` answer, which
|
|
95
|
+
* carries no tokens (§6.3). */
|
|
96
|
+
export interface ResetPasswordResult {
|
|
97
|
+
/** The account's address — the sign-in it leads to is filled in with it. */
|
|
98
|
+
email: string;
|
|
99
|
+
/** The app's outcomes, shown on the done state: keksdose's recovery-code notice for an
|
|
100
|
+
* encrypted account, "Your 2 personal access tokens were revoked". */
|
|
101
|
+
notices?: ReactNode;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
type Phase =
|
|
105
|
+
| { kind: "form"; check: ResetPasswordCheck }
|
|
106
|
+
| { kind: "invalid" }
|
|
107
|
+
| { kind: "done"; result: ResetPasswordResult };
|
|
108
|
+
|
|
109
|
+
export interface ResetPasswordFormProps
|
|
110
|
+
extends Omit<ComponentPropsWithoutRef<"div">, "onSubmit" | "children" | "title"> {
|
|
111
|
+
/** The link's token — the app's `?token=` (keksdose, kastlan: `/reset-password?token=…`).
|
|
112
|
+
* Empty: the link was cut short, and the page says so without asking anyone. */
|
|
113
|
+
token: string;
|
|
114
|
+
/**
|
|
115
|
+
* Check the link before the form shows (`/auth/password-reset/check`) — once per
|
|
116
|
+
* token, on mount. Checking does not use it up, so a reload is free. Resolve (with
|
|
117
|
+
* the address and any notices) for a live link; reject for a dead one, and the page
|
|
118
|
+
* says "This link is invalid or has expired" with the way to a new one.
|
|
119
|
+
*/
|
|
120
|
+
onCheck: (token: string) => Promise<ResetPasswordCheck | void | undefined>;
|
|
121
|
+
/**
|
|
122
|
+
* The new password was submitted: send `/auth/password-reset/confirm`. Resolve with
|
|
123
|
+
* the account's address (and the app's notices) — no tokens: a reset is not a sign-in
|
|
124
|
+
* (§6.3). Reject with the client's error: `token_invalid` (the link expired or was
|
|
125
|
+
* used in another tab while the form was open) goes to the invalid state; anything
|
|
126
|
+
* else stays on the form with the password kept.
|
|
127
|
+
*/
|
|
128
|
+
onSubmit: (values: ResetPasswordValues) => Promise<ResetPasswordResult>;
|
|
129
|
+
/** "Go to sign in" calls this with the address, for an app that routes in code.
|
|
130
|
+
* Without it, the button is a link to `signInHref`. */
|
|
131
|
+
onSignIn?: (email: string) => void;
|
|
132
|
+
/** Where "Go to sign in" leads, with `?email=` added — read it into `SignInForm`'s
|
|
133
|
+
* `defaultEmail`. Default `/login`. */
|
|
134
|
+
signInHref?: string;
|
|
135
|
+
/** "Request a new link" — the app's "Forgot password" page. Default
|
|
136
|
+
* `/forgot-password`. */
|
|
137
|
+
forgotHref?: string;
|
|
138
|
+
/** The app's words for a failed save (a `429`, the device offline), or `undefined`
|
|
139
|
+
* for the kit's `failed`. Not asked for `token_invalid`, which is a state. */
|
|
140
|
+
describeError?: (error: unknown) => ReactNode | undefined;
|
|
141
|
+
/** The heading. Default `labels.title`; `null` draws none, for a page that puts it
|
|
142
|
+
* in `AuthLayout`'s `title` instead. */
|
|
143
|
+
title?: ReactNode;
|
|
144
|
+
/** The heading's level. Default `h2`, under the page's `h1`. */
|
|
145
|
+
headingAs?: AuthHeadingLevel;
|
|
146
|
+
labels?: Partial<ResetPasswordLabels>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Redeem a password-reset link (`/reset-password?token=…`, docs/auth-harmonization.md
|
|
151
|
+
* §6.3, §7) — keksdose's `ResetPasswordPage` (features/auth/reset-password-page.tsx),
|
|
152
|
+
* which kastlan built its own on.
|
|
153
|
+
*
|
|
154
|
+
* **The link is checked first** (`onCheck`), and only then does the form appear: an
|
|
155
|
+
* expired link should say so before someone types a new password twice, not after.
|
|
156
|
+
*
|
|
157
|
+
* **A reset is not a sign-in** (§6.3, §10.11). The confirm answers without tokens, so a
|
|
158
|
+
* 2FA account cannot skip its second factor by way of its inbox, and the page ends on
|
|
159
|
+
* "Go to sign in" with the address filled in — the person has just typed the new
|
|
160
|
+
* password twice; signing in is one step. What else the reset did is said there:
|
|
161
|
+
* every other session ended (the kit's line, true in all three apps), and the app's own
|
|
162
|
+
* outcomes in `notices` — keksdose's recovery-code notice and its revoked API tokens.
|
|
163
|
+
*
|
|
164
|
+
* **The password** is chosen with the same fields as `RegisterForm` and `SignInForm`'s
|
|
165
|
+
* set-password step ({@link NewPasswordFields}): the strength meter and its checklist as
|
|
166
|
+
* advice, and only the two hard rules — at least 8 characters, at most 72 bytes (§8).
|
|
167
|
+
* The submit waits for both and a matching confirmation.
|
|
168
|
+
*
|
|
169
|
+
* The form never sends a request itself (§8): the token and the password go to the
|
|
170
|
+
* callbacks, and the page renders what they resolve with.
|
|
171
|
+
*/
|
|
172
|
+
export function ResetPasswordForm({
|
|
173
|
+
token,
|
|
174
|
+
onCheck,
|
|
175
|
+
onSubmit,
|
|
176
|
+
onSignIn,
|
|
177
|
+
signInHref = "/login",
|
|
178
|
+
forgotHref = "/forgot-password",
|
|
179
|
+
describeError,
|
|
180
|
+
title,
|
|
181
|
+
headingAs: Heading = "h2",
|
|
182
|
+
labels: labelsProp,
|
|
183
|
+
className,
|
|
184
|
+
...rest
|
|
185
|
+
}: ResetPasswordFormProps) {
|
|
186
|
+
const labels = useKitLabels("resetPassword", DEFAULT_RESET_PASSWORD_LABELS, labelsProp);
|
|
187
|
+
// The phase belongs to the token it was reached with: a new token starts over at the
|
|
188
|
+
// check without a reset effect.
|
|
189
|
+
const [phase, setPhase] = useState<{ token: string; phase: Phase } | null>(null);
|
|
190
|
+
const current: Phase | "checking" =
|
|
191
|
+
token === "" ? { kind: "invalid" } : phase?.token === token ? phase.phase : "checking";
|
|
192
|
+
|
|
193
|
+
useOncePerKey(token === "" ? null : token, onCheck, (key, result) =>
|
|
194
|
+
setPhase({
|
|
195
|
+
token: key,
|
|
196
|
+
phase: result.ok ? { kind: "form", check: result.value ?? {} } : { kind: "invalid" },
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const [password, setPassword] = useState("");
|
|
201
|
+
const [confirm, setConfirm] = useState("");
|
|
202
|
+
const [pending, setPending] = useState(false);
|
|
203
|
+
const [failure, setFailure] = useState<ReactNode>(null);
|
|
204
|
+
const headingId = useId();
|
|
205
|
+
const passwordRef = useRef<HTMLInputElement>(null);
|
|
206
|
+
const doneRef = useRef<HTMLDivElement>(null);
|
|
207
|
+
const kind = current === "checking" ? "checking" : current.kind;
|
|
208
|
+
|
|
209
|
+
// The form arrives after the check: the field is where the person is going.
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (kind === "form") passwordRef.current?.focus();
|
|
212
|
+
}, [kind]);
|
|
213
|
+
useFocusWhen(kind === "done", doneRef);
|
|
214
|
+
|
|
215
|
+
const heading =
|
|
216
|
+
title === null ? null : (
|
|
217
|
+
<Heading id={headingId} className={FORM_HEADING_CLASS}>
|
|
218
|
+
{title ?? labels.title}
|
|
219
|
+
</Heading>
|
|
220
|
+
);
|
|
221
|
+
const rootClass = cn("space-y-3", className);
|
|
222
|
+
|
|
223
|
+
if (current === "checking") {
|
|
224
|
+
return (
|
|
225
|
+
<div {...rest} data-state="checking" aria-busy className={rootClass}>
|
|
226
|
+
{heading}
|
|
227
|
+
<LoadingState size="sm" label={labels.checking} />
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (current.kind === "invalid") {
|
|
233
|
+
return (
|
|
234
|
+
<div {...rest} data-state="invalid" className={rootClass}>
|
|
235
|
+
{heading}
|
|
236
|
+
<EmptyState
|
|
237
|
+
tone="danger"
|
|
238
|
+
icon={<XCircle />}
|
|
239
|
+
title={token === "" ? labels.noToken : labels.invalid}
|
|
240
|
+
hint={labels.invalidHint}
|
|
241
|
+
action={
|
|
242
|
+
<Button href={forgotHref} variant="brand">
|
|
243
|
+
{labels.requestNew}
|
|
244
|
+
</Button>
|
|
245
|
+
}
|
|
246
|
+
className={OUTCOME_CLASS}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (current.kind === "done") {
|
|
253
|
+
const { email, notices } = current.result;
|
|
254
|
+
return (
|
|
255
|
+
<div {...rest} data-state="done" className={rootClass}>
|
|
256
|
+
{heading}
|
|
257
|
+
<OutcomeMessage icon={<CheckCircle2 className="text-[var(--success)]" />} focusRef={doneRef}>
|
|
258
|
+
<p className={INTRO_CLASS}>{labels.success}</p>
|
|
259
|
+
</OutcomeMessage>
|
|
260
|
+
{notices}
|
|
261
|
+
<p className="text-xs text-[var(--text-muted)]">{labels.sessionsEnded}</p>
|
|
262
|
+
{onSignIn ? (
|
|
263
|
+
<Button type="button" variant="brand" className="w-full" onClick={() => onSignIn(email)}>
|
|
264
|
+
{labels.signIn}
|
|
265
|
+
</Button>
|
|
266
|
+
) : (
|
|
267
|
+
<Button href={email ? withQuery(signInHref, "email", email) : signInHref} variant="brand" className="w-full">
|
|
268
|
+
{labels.signIn}
|
|
269
|
+
</Button>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const { email, notices } = current.check;
|
|
276
|
+
const problem = newPasswordProblem(password, confirm);
|
|
277
|
+
|
|
278
|
+
const submit = (e: FormEvent) => {
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
if (pending || problem !== null) return;
|
|
281
|
+
setFailure(null);
|
|
282
|
+
setPending(true);
|
|
283
|
+
Promise.resolve()
|
|
284
|
+
.then(() => onSubmit({ token, password }))
|
|
285
|
+
.then(
|
|
286
|
+
(result) => {
|
|
287
|
+
setPending(false);
|
|
288
|
+
setPassword("");
|
|
289
|
+
setConfirm("");
|
|
290
|
+
setPhase({ token, phase: { kind: "done", result } });
|
|
291
|
+
},
|
|
292
|
+
(error: unknown) => {
|
|
293
|
+
setPending(false);
|
|
294
|
+
if (authErrorCode(error) === "token_invalid") {
|
|
295
|
+
setPassword("");
|
|
296
|
+
setConfirm("");
|
|
297
|
+
setPhase({ token, phase: { kind: "invalid" } });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
setFailure(describeError?.(error) ?? labels.failed);
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<div {...rest} data-state="form" className={rootClass}>
|
|
307
|
+
<form className="space-y-3" aria-labelledby={title === null ? undefined : headingId} onSubmit={submit}>
|
|
308
|
+
{heading}
|
|
309
|
+
{email ? <p className={INTRO_CLASS}>{labels.intro(email)}</p> : null}
|
|
310
|
+
{notices}
|
|
311
|
+
{/* Which account the new password is for, for the password manager: it files
|
|
312
|
+
the new password under this username instead of guessing (kastlan). */}
|
|
313
|
+
{email ? <input type="email" name="username" autoComplete="username" value={email} readOnly hidden /> : null}
|
|
314
|
+
<NewPasswordFields
|
|
315
|
+
password={password}
|
|
316
|
+
confirm={confirm}
|
|
317
|
+
onPasswordChange={(next) => {
|
|
318
|
+
setPassword(next);
|
|
319
|
+
if (failure) setFailure(null);
|
|
320
|
+
}}
|
|
321
|
+
onConfirmChange={setConfirm}
|
|
322
|
+
busy={pending}
|
|
323
|
+
passwordRef={passwordRef}
|
|
324
|
+
text={{ password: labels.newPassword, confirm: labels.confirmPassword, mismatch: labels.mismatch }}
|
|
325
|
+
/>
|
|
326
|
+
{failure != null && (
|
|
327
|
+
<AlertBanner tone="danger" size="sm" role="alert">
|
|
328
|
+
{failure}
|
|
329
|
+
</AlertBanner>
|
|
330
|
+
)}
|
|
331
|
+
<Button type="submit" className="w-full" disabled={!pending && problem !== null} pending={pending}>
|
|
332
|
+
{labels.submit}
|
|
333
|
+
</Button>
|
|
334
|
+
</form>
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
}
|