@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.
Files changed (166) hide show
  1. package/README.md +22 -4
  2. package/dist/auth/accept-invitation.d.ts +90 -0
  3. package/dist/auth/accept-invitation.js +117 -0
  4. package/dist/auth/accept-invitation.js.map +1 -0
  5. package/dist/auth/auth-errors.d.ts +60 -0
  6. package/dist/auth/auth-errors.js +32 -0
  7. package/dist/auth/auth-errors.js.map +1 -0
  8. package/dist/auth/complete-name-dialog.d.ts +71 -0
  9. package/dist/auth/complete-name-dialog.js +168 -0
  10. package/dist/auth/complete-name-dialog.js.map +1 -0
  11. package/dist/auth/email-tag.d.ts +60 -0
  12. package/dist/auth/email-tag.js +23 -0
  13. package/dist/auth/email-tag.js.map +1 -0
  14. package/dist/auth/forgot-password-form.d.ts +79 -0
  15. package/dist/auth/forgot-password-form.js +120 -0
  16. package/dist/auth/forgot-password-form.js.map +1 -0
  17. package/dist/auth/form-rules.d.ts +27 -0
  18. package/dist/auth/form-rules.js +23 -0
  19. package/dist/auth/form-rules.js.map +1 -0
  20. package/dist/auth/new-password-fields.d.ts +38 -0
  21. package/dist/auth/new-password-fields.js +54 -0
  22. package/dist/auth/new-password-fields.js.map +1 -0
  23. package/dist/auth/not-found-page.d.ts +57 -0
  24. package/dist/auth/not-found-page.js +40 -0
  25. package/dist/auth/not-found-page.js.map +1 -0
  26. package/dist/auth/register-form.d.ts +86 -0
  27. package/dist/auth/register-form.js +268 -0
  28. package/dist/auth/register-form.js.map +1 -0
  29. package/dist/auth/reset-password-form.d.ts +124 -0
  30. package/dist/auth/reset-password-form.js +164 -0
  31. package/dist/auth/reset-password-form.js.map +1 -0
  32. package/dist/auth/sign-in-form.d.ts +243 -0
  33. package/dist/auth/sign-in-form.js +345 -0
  34. package/dist/auth/sign-in-form.js.map +1 -0
  35. package/dist/auth/status-parts.d.ts +78 -0
  36. package/dist/auth/status-parts.js +83 -0
  37. package/dist/auth/status-parts.js.map +1 -0
  38. package/dist/auth/verify-email.d.ts +121 -0
  39. package/dist/auth/verify-email.js +207 -0
  40. package/dist/auth/verify-email.js.map +1 -0
  41. package/dist/chart.d.ts +17 -2
  42. package/dist/components/amount-input.d.ts +18 -2
  43. package/dist/components/button-group.d.ts +18 -2
  44. package/dist/components/calculator.d.ts +18 -2
  45. package/dist/components/confirm-dialog.d.ts +18 -2
  46. package/dist/components/copy-button.d.ts +18 -2
  47. package/dist/components/danger-confirm.d.ts +18 -2
  48. package/dist/components/facing-pair.d.ts +18 -2
  49. package/dist/components/file-button.d.ts +18 -2
  50. package/dist/components/file-dropzone.d.ts +18 -2
  51. package/dist/components/form-actions.d.ts +18 -2
  52. package/dist/components/iban-input.d.ts +18 -2
  53. package/dist/components/language-select.d.ts +18 -9
  54. package/dist/components/legal-page.d.ts +26 -4
  55. package/dist/components/legal-page.js +24 -5
  56. package/dist/components/legal-page.js.map +1 -1
  57. package/dist/components/legal.d.ts +18 -2
  58. package/dist/components/legal.js +49 -5
  59. package/dist/components/legal.js.map +1 -1
  60. package/dist/components/money-field.d.ts +18 -2
  61. package/dist/components/number-field.d.ts +18 -2
  62. package/dist/components/number-input.d.ts +18 -2
  63. package/dist/components/numpad-sheet.d.ts +18 -2
  64. package/dist/components/phone-input.d.ts +18 -2
  65. package/dist/components/series-chart.d.ts +18 -2
  66. package/dist/components/settings-fields.d.ts +15 -2
  67. package/dist/components/share-card.d.ts +18 -2
  68. package/dist/components/text-link.d.ts +18 -2
  69. package/dist/components/time-input.d.ts +18 -2
  70. package/dist/components/ui.d.ts +17 -1
  71. package/dist/components/user-avatar.d.ts +16 -2
  72. package/dist/components/user-avatar.js +7 -3
  73. package/dist/components/user-avatar.js.map +1 -1
  74. package/dist/feedback/feedback-attachment.d.ts +18 -2
  75. package/dist/feedback/feedback-context.d.ts +18 -2
  76. package/dist/feedback/feedback-crash.d.ts +18 -2
  77. package/dist/feedback/feedback-dialog.d.ts +18 -2
  78. package/dist/feedback/feedback-inbox.d.ts +18 -2
  79. package/dist/feedback/feedback-labels.d.ts +18 -2
  80. package/dist/feedback/feedback-menu.d.ts +18 -2
  81. package/dist/feedback/feedback-record.d.ts +18 -2
  82. package/dist/feedback/feedback-row-detail.d.ts +18 -2
  83. package/dist/feedback/feedback-status-undo.d.ts +18 -2
  84. package/dist/feedback/feedback-submit.d.ts +18 -2
  85. package/dist/feedback/feedback-swipe.d.ts +18 -2
  86. package/dist/feedback/feedback-table.d.ts +18 -2
  87. package/dist/feedback/feedback-thread.d.ts +18 -2
  88. package/dist/feedback.d.ts +18 -2
  89. package/dist/hooks/use-file-drop.d.ts +18 -2
  90. package/dist/i18n/defaults.d.ts +18 -2
  91. package/dist/i18n/defaults.js +18 -0
  92. package/dist/i18n/defaults.js.map +1 -1
  93. package/dist/i18n/german.d.ts +18 -2
  94. package/dist/i18n/german.js +145 -0
  95. package/dist/i18n/german.js.map +1 -1
  96. package/dist/i18n/kit-labels.d.ts +18 -2
  97. package/dist/i18n/kit-labels.js.map +1 -1
  98. package/dist/i18n/languages.d.ts +13 -201
  99. package/dist/i18n/locales/de-CH.d.ts +18 -2
  100. package/dist/i18n/locales/en.d.ts +18 -2
  101. package/dist/i18n/locales/es.d.ts +18 -2
  102. package/dist/i18n/locales/es.js +147 -3
  103. package/dist/i18n/locales/es.js.map +1 -1
  104. package/dist/i18n/locales/fr.d.ts +18 -2
  105. package/dist/i18n/locales/fr.js +147 -0
  106. package/dist/i18n/locales/fr.js.map +1 -1
  107. package/dist/i18n/locales/hu.d.ts +18 -2
  108. package/dist/i18n/locales/hu.js +149 -3
  109. package/dist/i18n/locales/hu.js.map +1 -1
  110. package/dist/i18n/locales/it.d.ts +18 -2
  111. package/dist/i18n/locales/it.js +146 -1
  112. package/dist/i18n/locales/it.js.map +1 -1
  113. package/dist/i18n/locales/zh.d.ts +18 -2
  114. package/dist/i18n/locales/zh.js +146 -1
  115. package/dist/i18n/locales/zh.js.map +1 -1
  116. package/dist/i18n/review.d.ts +18 -2
  117. package/dist/i18n/review.js +17 -7
  118. package/dist/i18n/review.js.map +1 -1
  119. package/dist/index.d.ts +13 -2
  120. package/dist/index.js +49 -1
  121. package/dist/index.js.map +1 -1
  122. package/dist/lib/person-name.d.ts +69 -0
  123. package/dist/lib/person-name.js +65 -0
  124. package/dist/lib/person-name.js.map +1 -0
  125. package/dist/{kit-labels-SBN0biPl.d.ts → register-form-V1OeNlMr.d.ts} +423 -6
  126. package/dist/rhf/fields.d.ts +18 -2
  127. package/dist/rhf/form.d.ts +18 -2
  128. package/dist/rhf.d.ts +18 -2
  129. package/dist/shell/app-shell.d.ts +18 -2
  130. package/dist/shell/company-switcher.d.ts +59 -0
  131. package/dist/shell/company-switcher.js +85 -0
  132. package/dist/shell/company-switcher.js.map +1 -0
  133. package/dist/shell/top-bar-brand.d.ts +18 -2
  134. package/dist/shell/topbar-action-menu.d.ts +1 -0
  135. package/dist/shell.d.ts +12 -2
  136. package/dist/wizard/stepper-nav.d.ts +18 -2
  137. package/dist/wizard.d.ts +18 -2
  138. package/package.json +1 -1
  139. package/src/auth/accept-invitation.tsx +227 -0
  140. package/src/auth/auth-errors.ts +105 -0
  141. package/src/auth/complete-name-dialog.tsx +253 -0
  142. package/src/auth/email-tag.ts +75 -0
  143. package/src/auth/forgot-password-form.tsx +213 -0
  144. package/src/auth/form-rules.ts +40 -0
  145. package/src/auth/new-password-fields.tsx +85 -0
  146. package/src/auth/not-found-page.tsx +98 -0
  147. package/src/auth/register-form.tsx +459 -0
  148. package/src/auth/reset-password-form.tsx +337 -0
  149. package/src/auth/sign-in-form.tsx +687 -0
  150. package/src/auth/status-parts.tsx +138 -0
  151. package/src/auth/verify-email.tsx +358 -0
  152. package/src/components/legal-page.tsx +35 -0
  153. package/src/components/legal.tsx +93 -7
  154. package/src/components/user-avatar.tsx +22 -4
  155. package/src/i18n/defaults.ts +18 -0
  156. package/src/i18n/german.ts +153 -0
  157. package/src/i18n/kit-labels.tsx +27 -0
  158. package/src/i18n/locales/es.ts +156 -3
  159. package/src/i18n/locales/fr.ts +157 -0
  160. package/src/i18n/locales/hu.ts +159 -3
  161. package/src/i18n/locales/it.ts +154 -1
  162. package/src/i18n/locales/zh.ts +149 -1
  163. package/src/i18n/review.ts +15 -6
  164. package/src/index.ts +63 -1
  165. package/src/lib/person-name.ts +157 -0
  166. package/src/shell/company-switcher.tsx +140 -0
@@ -0,0 +1,687 @@
1
+ import { useEffect, useEffectEvent, useId, useRef, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, FormEvent, ReactNode } from "react";
3
+ import { KeyRound } from "lucide-react";
4
+
5
+ import { useKitLabels } from "../i18n/kit-labels";
6
+ import { cn } from "../lib/cn";
7
+ import { AlertBanner } from "../components/alert-banner";
8
+ import { hasMessage } from "../components/choice-parts";
9
+ import { CurrentPasswordInput } from "../components/danger-confirm";
10
+ import { OneTimeCodeInput } from "../components/one-time-code-input";
11
+ import { TextLink } from "../components/text-link";
12
+ import { Button, Input } from "../components/ui";
13
+ import { authErrorCode } from "./auth-errors";
14
+ import type { AuthErrorCode } from "./auth-errors";
15
+ import { taggedEmail } from "./email-tag";
16
+ import { newPasswordProblem } from "./form-rules";
17
+ import { NewPasswordFields } from "./new-password-fields";
18
+
19
+ /* ── The answers (§5.1) ──────────────────────────────────────────────────── */
20
+
21
+ /**
22
+ * What a sign-in call answers, in the kit's words — docs/auth-harmonization.md §5.1,
23
+ * keksdose's three shapes. The app maps its own HTTP answer to one of these and resolves
24
+ * with it; a refusal REJECTS (with whatever its client throws — {@link authErrorCode}
25
+ * reads the code out of it).
26
+ *
27
+ * | the server answers (keksdose) | resolve with |
28
+ * |---|---|
29
+ * | `TokenResponse` `{access_token, refresh_token, token_type, user}` | `{ kind: "signed-in" }`, after the app has stored the session |
30
+ * | `{requires_2fa: true, challenge_token}` | `{ kind: "2fa", challengeToken: challenge_token }` |
31
+ * | `{requires_password_change: true, challenge_token, encrypted}` | `{ kind: "password-change", challengeToken: challenge_token, extra: { encrypted } }` |
32
+ * | `401 {code: "invalid_credentials"}` | reject — let the client's error through |
33
+ *
34
+ * ```ts
35
+ * async function toAnswer(res: TokenResponse | TwoFactorChallenge | PasswordChangeChallenge): Promise<SignInAnswer> {
36
+ * if ("requires_2fa" in res) return { kind: "2fa", challengeToken: res.challenge_token };
37
+ * if ("requires_password_change" in res)
38
+ * return { kind: "password-change", challengeToken: res.challenge_token, extra: { encrypted: res.encrypted } };
39
+ * await onLoginSuccess(res); // store tokens, load /auth/me, switch language
40
+ * return { kind: "signed-in" };
41
+ * }
42
+ * <SignInForm onSubmit={async (v) => toAnswer(await authApi.login(v))} … />
43
+ * ```
44
+ *
45
+ * The same mapping serves `/auth/login/2fa`, `/auth/login/set-password` and the passkey
46
+ * finish, which answer the same union.
47
+ */
48
+ export type SignInAnswer =
49
+ | { kind: "signed-in" }
50
+ | { kind: "2fa"; challengeToken: string }
51
+ | {
52
+ kind: "password-change";
53
+ challengeToken: string;
54
+ /** The challenge's app fields, passed through untouched — keksdose's `encrypted`,
55
+ * which its note on this step reads (`passwordChangeContent`). */
56
+ extra?: Readonly<Record<string, unknown>>;
57
+ };
58
+
59
+ /** The step on screen: the credentials, the second factor, or a new password. */
60
+ export type SignInStep = "credentials" | "2fa" | "password-change";
61
+
62
+ /** What the person did when a call failed — {@link SignInFormProps.describeError}'s
63
+ * second argument. */
64
+ export type SignInAction = "password" | "passkey" | "code" | "set-password";
65
+
66
+ export interface SignInCredentials {
67
+ /** Trimmed. Lower-casing is the server's (§3.1). */
68
+ email: string;
69
+ /** As typed, never trimmed. */
70
+ password: string;
71
+ }
72
+
73
+ export interface SignInCodeValues {
74
+ challengeToken: string;
75
+ /** Digits only — {@link OneTimeCodeInput} drops the spaces of "123 456". */
76
+ code: string;
77
+ }
78
+
79
+ export interface SignInNewPasswordValues {
80
+ challengeToken: string;
81
+ password: string;
82
+ }
83
+
84
+ /* ── Labels ──────────────────────────────────────────────────────────────── */
85
+
86
+ /**
87
+ * Every string `SignInForm` renders — the `signIn` namespace of `<UiKitProvider
88
+ * labels>`, overridable per instance through `labels`. keksdose's words where it has
89
+ * them (`auth.*` in its catalogue).
90
+ */
91
+ export interface SignInLabels {
92
+ email: string;
93
+ password: string;
94
+ /** The submit button. */
95
+ submit: string;
96
+ /** The passkey button (`onPasskey`). */
97
+ passkey: string;
98
+ /** The link to `forgotHref`. */
99
+ forgotPassword: string;
100
+ /** Before the link to `registerHref`. */
101
+ noAccount: string;
102
+ /** The link to `registerHref`. */
103
+ register: string;
104
+ /** The ONE answer to an unknown address, a wrong password and a deactivated account
105
+ * (`invalid_credentials`, §5.2). */
106
+ invalidCredentials: string;
107
+ /** Any other failed sign-in (the network, a 500). */
108
+ failed: string;
109
+ /** A failed or refused passkey sign-in. */
110
+ passkeyFailed: string;
111
+ /**
112
+ * The hint under `invalidCredentials` (§2.10), shown when `deactivatedContact` is
113
+ * given. A TEMPLATE: `{contact}` is replaced by the contact — a `mailto:` link for an
114
+ * address — so a language puts it where its grammar needs it.
115
+ */
116
+ deactivatedHint: string;
117
+ /** The hint under `invalidCredentials` when `emailTag` is given (§4.5), given the
118
+ * typed address with the app's tag: "Signed up with ada+kastlan@example.com?". */
119
+ tagHint: (taggedAddress: string) => string;
120
+ /** The second-factor step's heading. */
121
+ twoFactorTitle: string;
122
+ twoFactorIntro: string;
123
+ /** The code field's label. */
124
+ code: string;
125
+ /** The code step's submit button. */
126
+ verify: string;
127
+ /** The code was refused. */
128
+ codeInvalid: string;
129
+ /** The set-a-new-password step's heading. */
130
+ setPasswordTitle: string;
131
+ setPasswordIntro: string;
132
+ newPassword: string;
133
+ confirmPassword: string;
134
+ passwordMismatch: string;
135
+ /** The set-a-new-password step's submit button. */
136
+ setPasswordSubmit: string;
137
+ setPasswordFailed: string;
138
+ /** A challenge was refused as `token_invalid` — it expired between the steps; the
139
+ * form is back on the credentials. */
140
+ expired: string;
141
+ /** From the second and third step back to the credentials. */
142
+ backToSignIn: string;
143
+ }
144
+
145
+ export const DEFAULT_SIGN_IN_LABELS: SignInLabels = {
146
+ email: "Email",
147
+ password: "Password",
148
+ submit: "Sign in",
149
+ passkey: "Sign in with a passkey",
150
+ forgotPassword: "Forgot your password?",
151
+ noAccount: "No account yet?",
152
+ register: "Create account",
153
+ invalidCredentials: "The email or password is incorrect.",
154
+ failed: "Sign-in failed. Please try again.",
155
+ passkeyFailed: "Passkey sign-in failed.",
156
+ deactivatedHint: "Account deactivated? Write to {contact}.",
157
+ tagHint: (taggedAddress) => `Signed up with ${taggedAddress}? Use that address.`,
158
+ twoFactorTitle: "Two-factor authentication",
159
+ twoFactorIntro: "Enter the code your authenticator app shows.",
160
+ code: "2FA code",
161
+ verify: "Verify",
162
+ codeInvalid: "Invalid 2FA code.",
163
+ setPasswordTitle: "Choose a new password",
164
+ setPasswordIntro: "A new password was requested for this account. Choose one and you are straight back in.",
165
+ newPassword: "New password",
166
+ confirmPassword: "Repeat new password",
167
+ passwordMismatch: "The passwords don't match.",
168
+ setPasswordSubmit: "Set password and sign in",
169
+ setPasswordFailed: "The password could not be set.",
170
+ expired: "This sign-in has expired. Please sign in again.",
171
+ backToSignIn: "Back to sign in",
172
+ };
173
+
174
+ /* ── Props ───────────────────────────────────────────────────────────────── */
175
+
176
+ export interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "onSubmit" | "children"> {
177
+ /**
178
+ * The credentials step was submitted. Send them, and resolve with the answer
179
+ * ({@link SignInAnswer}) or reject with the client's error. The form never sends a
180
+ * request itself (§8): keksdose must see the password on its way, to refuse the same
181
+ * string as an encryption passphrase later.
182
+ *
183
+ * On `signed-in` the form's job is over — it stays busy while the app navigates.
184
+ */
185
+ onSubmit: (credentials: SignInCredentials) => Promise<SignInAnswer>;
186
+ /** The second factor was submitted (`/auth/login/2fa`). Resolves with the next answer
187
+ * — keksdose may still require a new password after the code. */
188
+ onCode: (values: SignInCodeValues) => Promise<SignInAnswer>;
189
+ /** The new password was submitted (`/auth/login/set-password`). Resolves with the
190
+ * answer, `signed-in` in practice; keksdose's `key_wrap_unchanged` note is the app's
191
+ * to show from here. */
192
+ onSetPassword: (values: SignInNewPasswordValues) => Promise<SignInAnswer>;
193
+ /**
194
+ * The passkey button was pressed: run the ceremony (`/auth/passkey/login/begin|finish`)
195
+ * and resolve with its answer. It asks for NO email — a passkey is bound to the site,
196
+ * and the browser offers every one it holds (keksdose #126). Left out, no button: pass
197
+ * it only where `PublicKeyCredential` exists.
198
+ */
199
+ onPasskey?: () => Promise<SignInAnswer>;
200
+ /**
201
+ * Passkey autofill (conditional mediation). The email field is
202
+ * `autoComplete="username webauthn"`, so a pending `navigator.credentials.get({
203
+ * mediation: "conditional" })` lists the site's passkeys in its dropdown. The form
204
+ * calls this ONCE on mount; arm the request there and resolve with its answer when the
205
+ * person picks a passkey, or with `null` when nothing happens. The signal aborts on
206
+ * unmount — cancel the pending request then (keksdose: `armPasskeyAutofill(onDone,
207
+ * signal)` + `cancelPasskeyAutofill()`). A rejection is silent: the button stays the
208
+ * reliable path.
209
+ */
210
+ passkeyAutofill?: (signal: AbortSignal) => Promise<SignInAnswer | null | undefined>;
211
+ /** "Forgot your password?" goes here — the app's `/forgot-password`. Left out, no
212
+ * link. */
213
+ forgotHref?: string;
214
+ /** "No account yet? Create account" goes here — `/register`. Left out, no line. */
215
+ registerHref?: string;
216
+ /** The email field's start value — the reset page ends on "Sign in" with the address
217
+ * filled in (§6.3). */
218
+ defaultEmail?: string;
219
+ /**
220
+ * The app's address tag (`"kastlan"`, §4.5). After `invalid_credentials` the form also
221
+ * says "Signed up with you+kastlan@example.com? Use that address." — sign-in stays
222
+ * exact, and the server looks nothing up.
223
+ */
224
+ emailTag?: string;
225
+ /**
226
+ * Who a deactivated user writes to (§2.10): an address (drawn as a `mailto:` link) or
227
+ * the app's own node. Shown after `invalid_credentials` as "Account deactivated? Write
228
+ * to …". Left out, no hint.
229
+ */
230
+ deactivatedContact?: ReactNode;
231
+ /** Digits of the second factor. Default 6 (TOTP). */
232
+ codeLength?: number;
233
+ /** App content at the top of the credentials step — a notice, a demo button. */
234
+ credentialsContent?: ReactNode;
235
+ /** App content on the second-factor step, under its intro. */
236
+ twoFactorContent?: ReactNode;
237
+ /**
238
+ * App content on the set-a-new-password step, under its intro — keksdose's
239
+ * private-mode note ("Your encrypted data is untouched …"), which depends on the
240
+ * challenge: pass a function and it gets the challenge's `extra`.
241
+ */
242
+ passwordChangeContent?: ReactNode | ((extra: Readonly<Record<string, unknown>> | undefined) => ReactNode);
243
+ /**
244
+ * The app's own words for a failure, or `undefined` for the kit's: a throttled
245
+ * attempt (`429`), the device being offline. Asked first, for every failure; what it
246
+ * returns replaces the message, and the `invalid_credentials` hints still follow.
247
+ */
248
+ describeError?: (error: unknown, action: SignInAction) => ReactNode | undefined;
249
+ /** The step changed — retitle the page (`AuthLayout`'s `title`) if the app wants. */
250
+ onStepChange?: (step: SignInStep) => void;
251
+ /** The level of the second and third step's heading. Default `h2`, under the page's
252
+ * `h1`; one level under `AuthLayout`'s `headingAs` where that is embedded lower. */
253
+ headingAs?: "h2" | "h3" | "h4" | "h5" | "h6";
254
+ labels?: Partial<SignInLabels>;
255
+ }
256
+
257
+ /* ── Parts ───────────────────────────────────────────────────────────────── */
258
+
259
+ interface Failure {
260
+ action: SignInAction;
261
+ code: AuthErrorCode | undefined;
262
+ message: ReactNode;
263
+ /** The address the failed credentials carried — the tag hint names its tagged form. */
264
+ email?: string;
265
+ }
266
+
267
+ /** `{contact}` in the template, replaced by the contact (a `mailto:` link for an
268
+ * address). Split, not interpolated, so the link is an element. A template that lost
269
+ * its placeholder still shows the contact, after it. */
270
+ function withContact(template: string, contact: ReactNode): ReactNode[] {
271
+ const node =
272
+ typeof contact === "string" && contact.includes("@") ? (
273
+ <TextLink key="contact" href={`mailto:${contact}`} tone="secondary">
274
+ {contact}
275
+ </TextLink>
276
+ ) : (
277
+ <span key="contact">{contact}</span>
278
+ );
279
+ if (!template.includes("{contact}")) return [template, " ", node];
280
+ return template
281
+ .split(/(\{contact\})/)
282
+ .filter((part) => part !== "")
283
+ .map((part) => (part === "{contact}" ? node : part));
284
+ }
285
+
286
+ const HINT_CLASS = "text-xs text-[var(--text-secondary)]";
287
+ const INTRO_CLASS = "text-sm text-[var(--text-secondary)]";
288
+
289
+ /* ── The form ────────────────────────────────────────────────────────────── */
290
+
291
+ /**
292
+ * Sign-in for every app (docs/auth-harmonization.md §5, §8): email and password, or a
293
+ * passkey; then the second factor when it is on; then a new password when an admin
294
+ * required one. keksdose's `LoginPage` and `ForcedPasswordChangeStep`
295
+ * (features/auth/login-page.tsx, forced-password-change.tsx), as one component.
296
+ *
297
+ * **It never sends a request.** Each step hands its input to a callback and renders the
298
+ * {@link SignInAnswer} it resolves with; a rejection is shown in the step it came from.
299
+ * The challenge token lives in this component's state only, as in keksdose, so a reload
300
+ * costs a fresh sign-in — no route, no storage.
301
+ *
302
+ * **One error for every wrong credential** (§2.10, §5.2): `invalid_credentials` shows one
303
+ * sentence, whatever was wrong, plus two hints UNDER it — "Account deactivated? Write to
304
+ * …" (`deactivatedContact`) and "Signed up with you+kastlan@…? Use that address."
305
+ * (`emailTag`). Any other failure says "Sign-in failed" and no hint: the hints answer a
306
+ * refused credential, not a network that is down. `describeError` puts the app's words
307
+ * on a failure the kit cannot know (a throttled attempt).
308
+ *
309
+ * **The steps.**
310
+ * - Credentials: the email field is `username webauthn` (passkey autofill, see
311
+ * `passkeyAutofill`), the password a `CurrentPasswordInput` (a password manager fills
312
+ * it and never offers a new one). The submit is not disabled while the fields are
313
+ * empty: a browser that autofills on load withholds the values until the first
314
+ * interaction, and a disabled button would swallow that first click. The fields'
315
+ * own `required` answers an empty submit instead. "Forgot your password?" sits under
316
+ * the buttons, where someone whose password just failed is already looking
317
+ * (keksdose #198).
318
+ * - Second factor: {@link OneTimeCodeInput}, focused on arrival. "Verify" waits for the
319
+ * whole code. A refused code stays here with its error; `token_invalid` (the
320
+ * challenge expired) goes back to the credentials and says why. Kurvenschmiede has
321
+ * no 2FA yet, and the step is here from the start, so adding it is backend work only
322
+ * (§10.5).
323
+ * - New password: new + confirm with the strength meter, the 8-character / 72-byte
324
+ * rules and nothing else (§8). It reads as explained, not blocked (keksdose): the
325
+ * person did nothing wrong.
326
+ *
327
+ * The password is dropped from state once the credentials are answered; "Back to sign in"
328
+ * returns to an empty password field.
329
+ */
330
+ export function SignInForm({
331
+ onSubmit,
332
+ onCode,
333
+ onSetPassword,
334
+ onPasskey,
335
+ passkeyAutofill,
336
+ forgotHref,
337
+ registerHref,
338
+ defaultEmail = "",
339
+ emailTag,
340
+ deactivatedContact,
341
+ codeLength = 6,
342
+ credentialsContent,
343
+ twoFactorContent,
344
+ passwordChangeContent,
345
+ describeError,
346
+ onStepChange,
347
+ headingAs: Heading = "h2",
348
+ labels: labelsProp,
349
+ className,
350
+ ...rest
351
+ }: SignInFormProps) {
352
+ const labels = useKitLabels("signIn", DEFAULT_SIGN_IN_LABELS, labelsProp);
353
+ const [step, setStep] = useState<SignInStep>("credentials");
354
+ const [email, setEmail] = useState(defaultEmail);
355
+ const [password, setPassword] = useState("");
356
+ const [code, setCode] = useState("");
357
+ const [newPassword, setNewPassword] = useState("");
358
+ const [confirm, setConfirm] = useState("");
359
+ const [challenge, setChallenge] = useState<{ token: string; extra?: Readonly<Record<string, unknown>> } | null>(
360
+ null,
361
+ );
362
+ // What is running: one call at a time. "done" after `signed-in` — the app has the
363
+ // session and is leaving; the buttons stay busy so nothing is sent twice meanwhile.
364
+ const [busy, setBusy] = useState<SignInAction | "done" | null>(null);
365
+ const [failure, setFailure] = useState<Failure | null>(null);
366
+ const headingId = useId();
367
+
368
+ const emailRef = useRef<HTMLInputElement>(null);
369
+ const passwordRef = useRef<HTMLInputElement>(null);
370
+ const codeRef = useRef<HTMLInputElement>(null);
371
+ const newPasswordRef = useRef<HTMLInputElement>(null);
372
+
373
+ // A late answer after unmount (the app navigated away) must not set state.
374
+ const mounted = useRef(true);
375
+ useEffect(() => {
376
+ mounted.current = true;
377
+ return () => {
378
+ mounted.current = false;
379
+ };
380
+ }, []);
381
+
382
+ const goTo = (next: SignInStep) => {
383
+ setStep(next);
384
+ onStepChange?.(next);
385
+ };
386
+
387
+ const present = (answer: SignInAnswer) => {
388
+ setFailure(null);
389
+ if (answer.kind === "signed-in") {
390
+ setBusy("done");
391
+ return;
392
+ }
393
+ setBusy(null);
394
+ // The password has done its work; it does not stay in state for the next steps.
395
+ setPassword("");
396
+ setCode("");
397
+ setNewPassword("");
398
+ setConfirm("");
399
+ if (answer.kind === "2fa") {
400
+ setChallenge({ token: answer.challengeToken });
401
+ goTo("2fa");
402
+ } else if (answer.kind === "password-change") {
403
+ setChallenge({ token: answer.challengeToken, extra: answer.extra });
404
+ goTo("password-change");
405
+ }
406
+ };
407
+
408
+ const messageFor = (error: unknown, action: SignInAction, code: AuthErrorCode | undefined): ReactNode => {
409
+ const own = describeError?.(error, action);
410
+ if (hasMessage(own)) return own;
411
+ if (action === "password") return code === "invalid_credentials" ? labels.invalidCredentials : labels.failed;
412
+ if (action === "passkey") return labels.passkeyFailed;
413
+ if (action === "code") return code === "token_invalid" ? labels.expired : labels.codeInvalid;
414
+ return code === "token_invalid" ? labels.expired : labels.setPasswordFailed;
415
+ };
416
+
417
+ const fail = (error: unknown, action: SignInAction, sentEmail?: string) => {
418
+ setBusy(null);
419
+ // Our own abort (a passkey ceremony cancelled by the unmount) is no failure.
420
+ if ((error as { name?: unknown } | null)?.name === "AbortError") return;
421
+ const code = authErrorCode(error);
422
+ const message = messageFor(error, action, code);
423
+ // The challenge expired between the steps: only a fresh sign-in helps.
424
+ if ((action === "code" || action === "set-password") && code === "token_invalid") {
425
+ setChallenge(null);
426
+ setCode("");
427
+ setNewPassword("");
428
+ setConfirm("");
429
+ goTo("credentials");
430
+ }
431
+ setFailure({ action, code, message, email: sentEmail });
432
+ };
433
+
434
+ const attempt = (action: SignInAction, call: () => Promise<SignInAnswer>, sentEmail?: string) => {
435
+ if (busy) return;
436
+ setBusy(action);
437
+ setFailure(null);
438
+ let result: Promise<SignInAnswer>;
439
+ try {
440
+ result = Promise.resolve(call());
441
+ } catch (error) {
442
+ result = Promise.reject(error);
443
+ }
444
+ result.then(
445
+ (answer) => {
446
+ if (mounted.current) present(answer);
447
+ },
448
+ (error: unknown) => {
449
+ if (mounted.current) fail(error, action, sentEmail);
450
+ },
451
+ );
452
+ };
453
+
454
+ // Passkey autofill: armed once, on mount; the signal aborts on unmount. Its answer
455
+ // arrives whenever the person picks a passkey from the email field's dropdown — and
456
+ // is dropped if they have meanwhile started a sign-in of their own.
457
+ const armAutofill = useEffectEvent((signal: AbortSignal) => passkeyAutofill?.(signal));
458
+ const autofillAnswered = useEffectEvent((answer: SignInAnswer) => {
459
+ if (busy === null) present(answer);
460
+ });
461
+ useEffect(() => {
462
+ const controller = new AbortController();
463
+ let pending: Promise<SignInAnswer | null | undefined>;
464
+ try {
465
+ pending = Promise.resolve(armAutofill(controller.signal));
466
+ } catch (error) {
467
+ pending = Promise.reject(error);
468
+ }
469
+ pending.then(
470
+ (answer) => {
471
+ if (answer && !controller.signal.aborted && mounted.current) autofillAnswered(answer);
472
+ },
473
+ () => {
474
+ /* silent — unsupported, declined or aborted; the button stays the way in */
475
+ },
476
+ );
477
+ return () => controller.abort();
478
+ }, []);
479
+
480
+ // Focus follows the step — not on mount (where it would pop a phone's keyboard over
481
+ // the page), only once the person moved to another step.
482
+ const shownStep = useRef(step);
483
+ useEffect(() => {
484
+ if (shownStep.current === step) return;
485
+ shownStep.current = step;
486
+ if (step === "2fa") codeRef.current?.focus();
487
+ else if (step === "password-change") newPasswordRef.current?.focus();
488
+ else if (emailRef.current?.value) passwordRef.current?.focus();
489
+ else emailRef.current?.focus();
490
+ }, [step]);
491
+
492
+ const backToCredentials = () => {
493
+ if (busy) return;
494
+ setChallenge(null);
495
+ setFailure(null);
496
+ setCode("");
497
+ setNewPassword("");
498
+ setConfirm("");
499
+ goTo("credentials");
500
+ };
501
+
502
+ const working = busy !== null;
503
+ const rootClass = cn("space-y-3", className);
504
+
505
+ /* ── Step 2: the second factor ── */
506
+ if (step === "2fa" && challenge) {
507
+ const submitCode = (e: FormEvent) => {
508
+ e.preventDefault();
509
+ if (code.length !== codeLength) return;
510
+ attempt("code", () => onCode({ challengeToken: challenge.token, code }));
511
+ };
512
+ const codeError = failure?.action === "code" ? failure.message : undefined;
513
+ return (
514
+ <div {...rest} data-step="2fa" className={rootClass}>
515
+ <form className="space-y-3" aria-labelledby={headingId} onSubmit={submitCode}>
516
+ <Heading id={headingId} className="text-base font-semibold text-[var(--text-primary)]">
517
+ {labels.twoFactorTitle}
518
+ </Heading>
519
+ <p className={INTRO_CLASS}>{labels.twoFactorIntro}</p>
520
+ {twoFactorContent}
521
+ <OneTimeCodeInput
522
+ ref={codeRef}
523
+ label={labels.code}
524
+ name="code"
525
+ value={code}
526
+ length={codeLength}
527
+ onChange={(next) => {
528
+ setCode(next);
529
+ if (failure) setFailure(null);
530
+ }}
531
+ readOnly={working}
532
+ error={codeError}
533
+ required
534
+ />
535
+ <Button type="submit" className="w-full" disabled={!working && code.length !== codeLength} pending={working}>
536
+ {labels.verify}
537
+ </Button>
538
+ </form>
539
+ <BackLink label={labels.backToSignIn} onClick={backToCredentials} disabled={working} />
540
+ </div>
541
+ );
542
+ }
543
+
544
+ /* ── Step 3: a new password ── */
545
+ if (step === "password-change" && challenge) {
546
+ const problem = newPasswordProblem(newPassword, confirm);
547
+ const submitPassword = (e: FormEvent) => {
548
+ e.preventDefault();
549
+ if (problem) return;
550
+ attempt("set-password", () => onSetPassword({ challengeToken: challenge.token, password: newPassword }));
551
+ };
552
+ const content =
553
+ typeof passwordChangeContent === "function" ? passwordChangeContent(challenge.extra) : passwordChangeContent;
554
+ return (
555
+ <div {...rest} data-step="password-change" className={rootClass}>
556
+ <form className="space-y-3" aria-labelledby={headingId} onSubmit={submitPassword}>
557
+ <Heading id={headingId} className="text-base font-semibold text-[var(--text-primary)]">
558
+ {labels.setPasswordTitle}
559
+ </Heading>
560
+ <p className={INTRO_CLASS}>{labels.setPasswordIntro}</p>
561
+ {content}
562
+ {/* Which account the new password is for, for the password manager: it files
563
+ the new password under this username instead of guessing. */}
564
+ {email !== "" && (
565
+ <input type="email" name="username" autoComplete="username" value={email} readOnly hidden />
566
+ )}
567
+ <NewPasswordFields
568
+ password={newPassword}
569
+ confirm={confirm}
570
+ onPasswordChange={setNewPassword}
571
+ onConfirmChange={setConfirm}
572
+ busy={working}
573
+ passwordRef={newPasswordRef}
574
+ text={{ password: labels.newPassword, confirm: labels.confirmPassword, mismatch: labels.passwordMismatch }}
575
+ />
576
+ {failure?.action === "set-password" && (
577
+ <AlertBanner tone="danger" size="sm" role="alert">
578
+ {failure.message}
579
+ </AlertBanner>
580
+ )}
581
+ <Button type="submit" className="w-full" disabled={!working && problem !== null} pending={working}>
582
+ {labels.setPasswordSubmit}
583
+ </Button>
584
+ </form>
585
+ <BackLink label={labels.backToSignIn} onClick={backToCredentials} disabled={working} />
586
+ </div>
587
+ );
588
+ }
589
+
590
+ /* ── Step 1: the credentials ── */
591
+ const submitCredentials = (e: FormEvent) => {
592
+ e.preventDefault();
593
+ const sent = email.trim();
594
+ if (sent === "" || password === "") return;
595
+ attempt("password", () => onSubmit({ email: sent, password }), sent);
596
+ };
597
+
598
+ const refused = failure?.code === "invalid_credentials";
599
+ const tagged =
600
+ refused && failure?.action === "password" && emailTag && failure.email ? taggedEmail(failure.email, emailTag) : null;
601
+ const showDeactivated = refused && hasMessage(deactivatedContact);
602
+
603
+ return (
604
+ <div {...rest} data-step="credentials" className={rootClass}>
605
+ <form className="space-y-3" onSubmit={submitCredentials}>
606
+ {credentialsContent}
607
+ <Input
608
+ ref={emailRef}
609
+ type="email"
610
+ name="email"
611
+ // "webauthn" lets the browser list passkeys in this field's dropdown, paired
612
+ // with the conditional request `passkeyAutofill` arms.
613
+ autoComplete="username webauthn"
614
+ label={labels.email}
615
+ value={email}
616
+ onChange={(e) => setEmail(e.target.value)}
617
+ readOnly={working}
618
+ required
619
+ />
620
+ <CurrentPasswordInput
621
+ ref={passwordRef}
622
+ name="password"
623
+ label={labels.password}
624
+ value={password}
625
+ onValueChange={setPassword}
626
+ busy={working}
627
+ required
628
+ />
629
+ {failure && (
630
+ // One live region for the sentence and its hints: they are one answer.
631
+ <div role="alert" className="space-y-1.5">
632
+ <AlertBanner tone="danger" size="sm">
633
+ {failure.message}
634
+ </AlertBanner>
635
+ {showDeactivated && (
636
+ <p className={HINT_CLASS}>{withContact(labels.deactivatedHint, deactivatedContact)}</p>
637
+ )}
638
+ {tagged && <p className={HINT_CLASS}>{labels.tagHint(tagged)}</p>}
639
+ </div>
640
+ )}
641
+ <Button type="submit" className="w-full" pending={busy === "password" || busy === "done"} disabled={busy === "passkey"}>
642
+ {labels.submit}
643
+ </Button>
644
+ {onPasskey && (
645
+ <Button
646
+ type="button"
647
+ variant="secondary"
648
+ className="w-full"
649
+ pending={busy === "passkey"}
650
+ disabled={busy === "password" || busy === "done"}
651
+ onClick={() => attempt("passkey", onPasskey)}
652
+ >
653
+ <KeyRound aria-hidden className="size-4" />
654
+ {labels.passkey}
655
+ </Button>
656
+ )}
657
+ </form>
658
+ {forgotHref !== undefined && (
659
+ <p className="text-center text-sm">
660
+ <TextLink href={forgotHref} tone="secondary">
661
+ {labels.forgotPassword}
662
+ </TextLink>
663
+ </p>
664
+ )}
665
+ {registerHref !== undefined && (
666
+ <p className="text-center text-sm text-[var(--text-secondary)]">
667
+ {labels.noAccount}{" "}
668
+ <TextLink href={registerHref} tone="primary">
669
+ {labels.register}
670
+ </TextLink>
671
+ </p>
672
+ )}
673
+ </div>
674
+ );
675
+ }
676
+
677
+ /** "Back to sign in" under the second and third step: a button, since it changes the
678
+ * step in place rather than going anywhere. */
679
+ function BackLink({ label, onClick, disabled }: { label: string; onClick: () => void; disabled: boolean }) {
680
+ return (
681
+ <p className="text-center text-sm">
682
+ <Button type="button" variant="link" tone="muted" size="sm" disabled={disabled} onClick={onClick}>
683
+ {label}
684
+ </Button>
685
+ </p>
686
+ );
687
+ }