@eifi1/ui-kit 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +26 -4
  2. package/dist/account/account-parts.d.ts +21 -0
  3. package/dist/account/account-parts.js +40 -0
  4. package/dist/account/account-parts.js.map +1 -0
  5. package/dist/account/data-export-setting.d.ts +73 -0
  6. package/dist/account/data-export-setting.js +113 -0
  7. package/dist/account/data-export-setting.js.map +1 -0
  8. package/dist/account/delete-account-setting.d.ts +91 -0
  9. package/dist/account/delete-account-setting.js +199 -0
  10. package/dist/account/delete-account-setting.js.map +1 -0
  11. package/dist/account/email-change-setting.d.ts +136 -0
  12. package/dist/account/email-change-setting.js +277 -0
  13. package/dist/account/email-change-setting.js.map +1 -0
  14. package/dist/account/sessions-setting.d.ts +110 -0
  15. package/dist/account/sessions-setting.js +146 -0
  16. package/dist/account/sessions-setting.js.map +1 -0
  17. package/dist/admin/admin-action-confirm.d.ts +91 -0
  18. package/dist/admin/admin-action-confirm.js +276 -0
  19. package/dist/admin/admin-action-confirm.js.map +1 -0
  20. package/dist/admin/admin-action-log.d.ts +105 -0
  21. package/dist/admin/admin-action-log.js +153 -0
  22. package/dist/admin/admin-action-log.js.map +1 -0
  23. package/dist/admin/admin-parts.d.ts +40 -0
  24. package/dist/admin/admin-parts.js +45 -0
  25. package/dist/admin/admin-parts.js.map +1 -0
  26. package/dist/admin/invitations-panel.d.ts +91 -0
  27. package/dist/admin/invitations-panel.js +289 -0
  28. package/dist/admin/invitations-panel.js.map +1 -0
  29. package/dist/admin/reviewer-scope-editor.d.ts +91 -0
  30. package/dist/admin/reviewer-scope-editor.js +134 -0
  31. package/dist/admin/reviewer-scope-editor.js.map +1 -0
  32. package/dist/admin/role-select.d.ts +91 -0
  33. package/dist/admin/role-select.js +128 -0
  34. package/dist/admin/role-select.js.map +1 -0
  35. package/dist/admin/transfer-ownership-dialog.d.ts +91 -0
  36. package/dist/admin/transfer-ownership-dialog.js +83 -0
  37. package/dist/admin/transfer-ownership-dialog.js.map +1 -0
  38. package/dist/admin/user-roster.d.ts +91 -0
  39. package/dist/admin/user-roster.js +285 -0
  40. package/dist/admin/user-roster.js.map +1 -0
  41. package/dist/auth/auth-errors.d.ts +85 -6
  42. package/dist/auth/auth-errors.js +62 -2
  43. package/dist/auth/auth-errors.js.map +1 -1
  44. package/dist/auth/forgot-password-form.d.ts +10 -3
  45. package/dist/auth/forgot-password-form.js +6 -3
  46. package/dist/auth/forgot-password-form.js.map +1 -1
  47. package/dist/auth/register-form.d.ts +17 -12
  48. package/dist/auth/register-form.js +6 -4
  49. package/dist/auth/register-form.js.map +1 -1
  50. package/dist/auth/reset-password-form.d.ts +10 -3
  51. package/dist/auth/reset-password-form.js +6 -3
  52. package/dist/auth/reset-password-form.js.map +1 -1
  53. package/dist/auth/sign-in-form.d.ts +61 -9
  54. package/dist/auth/sign-in-form.js +94 -13
  55. package/dist/auth/sign-in-form.js.map +1 -1
  56. package/dist/auth/verify-email.d.ts +7 -2
  57. package/dist/auth/verify-email.js +44 -8
  58. package/dist/auth/verify-email.js.map +1 -1
  59. package/dist/chart.d.ts +16 -11
  60. package/dist/components/account-chips.d.ts +47 -7
  61. package/dist/components/account-chips.js +19 -3
  62. package/dist/components/account-chips.js.map +1 -1
  63. package/dist/components/account-settings-labels.d.ts +12 -1
  64. package/dist/components/account-settings-labels.js +4 -1
  65. package/dist/components/account-settings-labels.js.map +1 -1
  66. package/dist/components/account-settings.d.ts +61 -6
  67. package/dist/components/account-settings.js +115 -15
  68. package/dist/components/account-settings.js.map +1 -1
  69. package/dist/components/amount-input.d.ts +17 -12
  70. package/dist/components/button-group.d.ts +17 -12
  71. package/dist/components/calculator.d.ts +17 -12
  72. package/dist/components/confirm-dialog.d.ts +17 -12
  73. package/dist/components/copy-button.d.ts +14 -9
  74. package/dist/components/danger-confirm.d.ts +17 -12
  75. package/dist/components/facing-pair.d.ts +17 -12
  76. package/dist/components/file-button.d.ts +17 -12
  77. package/dist/components/file-dropzone.d.ts +17 -12
  78. package/dist/components/form-actions.d.ts +17 -12
  79. package/dist/components/iban-input.d.ts +17 -12
  80. package/dist/components/language-select.d.ts +17 -12
  81. package/dist/components/legal-page.d.ts +17 -12
  82. package/dist/components/legal.d.ts +17 -12
  83. package/dist/components/money-field.d.ts +17 -12
  84. package/dist/components/number-field.d.ts +17 -12
  85. package/dist/components/number-input.d.ts +17 -12
  86. package/dist/components/numpad-sheet.d.ts +17 -12
  87. package/dist/components/phone-input.d.ts +17 -12
  88. package/dist/components/series-chart.d.ts +17 -12
  89. package/dist/components/settings-fields.d.ts +16 -11
  90. package/dist/components/share-card.d.ts +16 -11
  91. package/dist/components/text-link.d.ts +17 -12
  92. package/dist/components/time-input.d.ts +17 -12
  93. package/dist/components/ui.d.ts +13 -8
  94. package/dist/{register-form-V1OeNlMr.d.ts → delete-account-setting-CGVMckMw.d.ts} +6616 -5645
  95. package/dist/feedback/feedback-attachment.d.ts +17 -12
  96. package/dist/feedback/feedback-context.d.ts +17 -12
  97. package/dist/feedback/feedback-crash.d.ts +17 -12
  98. package/dist/feedback/feedback-dialog.d.ts +17 -12
  99. package/dist/feedback/feedback-inbox.d.ts +17 -12
  100. package/dist/feedback/feedback-labels.d.ts +17 -12
  101. package/dist/feedback/feedback-menu.d.ts +17 -12
  102. package/dist/feedback/feedback-record.d.ts +17 -12
  103. package/dist/feedback/feedback-row-detail.d.ts +17 -12
  104. package/dist/feedback/feedback-status-undo.d.ts +17 -12
  105. package/dist/feedback/feedback-submit.d.ts +17 -12
  106. package/dist/feedback/feedback-swipe.d.ts +17 -12
  107. package/dist/feedback/feedback-table.d.ts +14 -9
  108. package/dist/feedback/feedback-thread.d.ts +17 -12
  109. package/dist/feedback.d.ts +17 -12
  110. package/dist/hooks/use-anchored-panel.d.ts +6 -2
  111. package/dist/hooks/use-anchored-panel.js +2 -1
  112. package/dist/hooks/use-anchored-panel.js.map +1 -1
  113. package/dist/hooks/use-file-drop.d.ts +17 -12
  114. package/dist/i18n/defaults.d.ts +17 -12
  115. package/dist/i18n/defaults.js +22 -0
  116. package/dist/i18n/defaults.js.map +1 -1
  117. package/dist/i18n/german.d.ts +17 -12
  118. package/dist/i18n/german.js +215 -11
  119. package/dist/i18n/german.js.map +1 -1
  120. package/dist/i18n/kit-labels.d.ts +10 -5
  121. package/dist/i18n/kit-labels.js.map +1 -1
  122. package/dist/i18n/languages.d.ts +11 -6
  123. package/dist/i18n/locales/de-CH.d.ts +17 -12
  124. package/dist/i18n/locales/en.d.ts +17 -12
  125. package/dist/i18n/locales/es.d.ts +17 -12
  126. package/dist/i18n/locales/es.js +207 -9
  127. package/dist/i18n/locales/es.js.map +1 -1
  128. package/dist/i18n/locales/fr.d.ts +17 -12
  129. package/dist/i18n/locales/fr.js +209 -6
  130. package/dist/i18n/locales/fr.js.map +1 -1
  131. package/dist/i18n/locales/hu.d.ts +17 -12
  132. package/dist/i18n/locales/hu.js +207 -11
  133. package/dist/i18n/locales/hu.js.map +1 -1
  134. package/dist/i18n/locales/it.d.ts +17 -12
  135. package/dist/i18n/locales/it.js +209 -6
  136. package/dist/i18n/locales/it.js.map +1 -1
  137. package/dist/i18n/locales/zh.d.ts +17 -12
  138. package/dist/i18n/locales/zh.js +200 -6
  139. package/dist/i18n/locales/zh.js.map +1 -1
  140. package/dist/i18n/review.d.ts +17 -12
  141. package/dist/i18n/review.js +37 -1
  142. package/dist/i18n/review.js.map +1 -1
  143. package/dist/index.d.ts +8 -3
  144. package/dist/index.js +53 -0
  145. package/dist/index.js.map +1 -1
  146. package/dist/rhf/fields.d.ts +17 -12
  147. package/dist/rhf/form.d.ts +17 -12
  148. package/dist/rhf.d.ts +17 -12
  149. package/dist/shell/app-shell.d.ts +17 -12
  150. package/dist/shell/top-bar-brand.d.ts +17 -12
  151. package/dist/shell.d.ts +11 -6
  152. package/dist/wizard/stepper-nav.d.ts +17 -12
  153. package/dist/wizard.d.ts +17 -12
  154. package/package.json +1 -1
  155. package/src/account/account-parts.ts +67 -0
  156. package/src/account/data-export-setting.tsx +218 -0
  157. package/src/account/delete-account-setting.tsx +362 -0
  158. package/src/account/email-change-setting.tsx +457 -0
  159. package/src/account/sessions-setting.tsx +283 -0
  160. package/src/admin/admin-action-confirm.tsx +495 -0
  161. package/src/admin/admin-action-log.tsx +297 -0
  162. package/src/admin/admin-parts.ts +82 -0
  163. package/src/admin/invitations-panel.tsx +516 -0
  164. package/src/admin/reviewer-scope-editor.tsx +234 -0
  165. package/src/admin/role-select.tsx +286 -0
  166. package/src/admin/transfer-ownership-dialog.tsx +172 -0
  167. package/src/admin/user-roster.tsx +573 -0
  168. package/src/auth/auth-errors.ts +165 -8
  169. package/src/auth/forgot-password-form.tsx +21 -5
  170. package/src/auth/register-form.tsx +24 -6
  171. package/src/auth/reset-password-form.tsx +19 -5
  172. package/src/auth/sign-in-form.tsx +207 -35
  173. package/src/auth/verify-email.tsx +59 -8
  174. package/src/components/account-chips.tsx +64 -7
  175. package/src/components/account-settings-labels.ts +15 -1
  176. package/src/components/account-settings.tsx +199 -23
  177. package/src/hooks/use-anchored-panel.ts +14 -3
  178. package/src/i18n/defaults.ts +22 -0
  179. package/src/i18n/german.ts +241 -5
  180. package/src/i18n/kit-labels.tsx +33 -0
  181. package/src/i18n/locales/es.ts +230 -3
  182. package/src/i18n/locales/fr.ts +232 -0
  183. package/src/i18n/locales/hu.ts +224 -5
  184. package/src/i18n/locales/it.ts +230 -0
  185. package/src/i18n/locales/zh.ts +205 -0
  186. package/src/i18n/review.ts +36 -0
  187. package/src/index.ts +93 -0
@@ -7,6 +7,7 @@ import { useKitLabels } from "../i18n/kit-labels.js";
7
7
  import { AlertBanner } from "../components/alert-banner.js";
8
8
  import { TextLink } from "../components/text-link.js";
9
9
  import { Button, Input } from "../components/ui.js";
10
+ import { englishRateLimited, isRateLimited, retryAfterSeconds } from "./auth-errors.js";
10
11
  import { FORM_HEADING_CLASS, INTRO_CLASS, OutcomeMessage, isThenable, useFocusWhen } from "./status-parts.js";
11
12
  const DEFAULT_FORGOT_PASSWORD_LABELS = {
12
13
  title: "Forgot password",
@@ -16,7 +17,8 @@ const DEFAULT_FORGOT_PASSWORD_LABELS = {
16
17
  sent: (email) => `If an account exists for ${email}, the link is on its way.`,
17
18
  sentHint: "The link is valid for one hour and works exactly once. Check your spam folder too.",
18
19
  backToSignIn: "Back to sign in",
19
- error: "The request failed. Please try again later."
20
+ error: "The request failed. Please try again later.",
21
+ rateLimited: englishRateLimited
20
22
  };
21
23
  function ForgotPasswordForm({
22
24
  onSubmit,
@@ -50,6 +52,7 @@ function ForgotPasswordForm({
50
52
  back
51
53
  ] });
52
54
  }
55
+ const failureFor = (error) => describeError?.(error) ?? (isRateLimited(error) ? (labels.rateLimited ?? DEFAULT_FORGOT_PASSWORD_LABELS.rateLimited)(retryAfterSeconds(error)) : labels.error);
53
56
  const submit = (e) => {
54
57
  e.preventDefault();
55
58
  const address = email.trim();
@@ -59,7 +62,7 @@ function ForgotPasswordForm({
59
62
  try {
60
63
  result = onSubmit(address);
61
64
  } catch (error) {
62
- setFailure(describeError?.(error) ?? labels.error);
65
+ setFailure(failureFor(error));
63
66
  return;
64
67
  }
65
68
  if (!isThenable(result)) {
@@ -74,7 +77,7 @@ function ForgotPasswordForm({
74
77
  },
75
78
  (error) => {
76
79
  setPending(false);
77
- setFailure(describeError?.(error) ?? labels.error);
80
+ setFailure(failureFor(error));
78
81
  }
79
82
  );
80
83
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/forgot-password-form.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, FormEvent, ReactNode } from \"react\";\nimport { MailCheck } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { TextLink } from \"../components/text-link\";\nimport { Button, Input } from \"../components/ui\";\nimport { FORM_HEADING_CLASS, INTRO_CLASS, OutcomeMessage, isThenable, useFocusWhen } from \"./status-parts\";\nimport type { AuthHeadingLevel } from \"./status-parts\";\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the form renders — the `forgotPassword` namespace. keksdose's words\n * (`auth.forgot.*`), which kastlan took over unchanged. */\nexport interface ForgotPasswordLabels {\n /** The heading. */\n title: string;\n /** Under the heading, before the field. */\n intro: string;\n /** The email field's label. */\n email: string;\n submit: string;\n /** The one answer, whatever the server knows — given the address as typed. */\n sent: (email: string) => string;\n /** Under it: how long the link lives, and where else to look. */\n sentHint: string;\n /** The link back to the sign-in page, on both states. */\n backToSignIn: string;\n /** A request that did not go through: a throttle, an outage. Never \"unknown\n * address\" — the endpoint does not say. */\n error: string;\n}\n\nexport const DEFAULT_FORGOT_PASSWORD_LABELS: ForgotPasswordLabels = {\n title: \"Forgot password\",\n intro: \"Enter your account’s email address. We’ll send you a link to choose a new password.\",\n email: \"Email\",\n submit: \"Send the link\",\n sent: (email) => `If an account exists for ${email}, the link is on its way.`,\n sentHint: \"The link is valid for one hour and works exactly once. Check your spam folder too.\",\n backToSignIn: \"Back to sign in\",\n error: \"The request failed. Please try again later.\",\n};\n\n/* ── The form ────────────────────────────────────────────────────────────── */\n\nexport interface ForgotPasswordFormProps\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onSubmit\" | \"children\" | \"title\"> {\n /**\n * The address was submitted, trimmed: send `POST /auth/password-reset/request`.\n * Resolve when the server answered — whatever it answered: it is `204` whether or not\n * an account exists. Reject only when the request did not go through (a throttle, an\n * outage); the form then stays, with the address, and says so. The form never sends a\n * request itself (§8).\n */\n onSubmit: (email: string) => Promise<unknown> | void;\n /** \"Back to sign in\" — the app's `/login`. Default `/login`. */\n signInHref?: string;\n /** The field's start value — the address the sign-in form already had, so someone\n * whose password just failed need not type it twice. */\n defaultEmail?: string;\n /**\n * The app's words for a failed request (a `429`: \"Too many requests — try again in\n * an hour\"), or `undefined` for the kit's `error`. The same hook as `SignInForm`'s.\n */\n describeError?: (error: unknown) => ReactNode | undefined;\n /** App content under the intro — a notice, a support address. */\n formContent?: ReactNode;\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<ForgotPasswordLabels>;\n}\n\n/**\n * \"I forgot my password\" for every app (docs/auth-harmonization.md §7): one field, then\n * the same sentence whatever the answer — keksdose's `ForgotPasswordPage`\n * (features/auth/forgot-password-page.tsx), kastlan's copy of it, as one component.\n *\n * **It always ends on the same message**: \"If an account exists for …, the link is on\n * its way.\" The server answers `204` whether or not the address has an account — so the\n * page cannot be used to find out who has one — and the page must not undo that by\n * saying more than the server knows. Worded as a promise (\"we sent you a link\"), a typo\n * would look like a mail-delivery problem and send the person waiting instead of trying\n * their other address (keksdose #198). The address shown is the one they typed, so a\n * typo is visible.\n *\n * The only failure it can show is a request that did not go through (throttled, the\n * server down), never \"unknown address\". §4.5's fallback to a tagged address\n * (`you+kastlan@…` when `you@…` has no account) is the server's and changes nothing here.\n *\n * The submit is not disabled while the field is empty — a browser that autofills\n * withholds the value until the first interaction, and a disabled button would swallow\n * that first click; the field's own `required` answers an empty submit instead. After a\n * submit the confirmation takes focus, because the button that had it is gone with the\n * form.\n */\nexport function ForgotPasswordForm({\n onSubmit,\n signInHref = \"/login\",\n defaultEmail = \"\",\n describeError,\n formContent,\n title,\n headingAs: Heading = \"h2\",\n labels: labelsProp,\n className,\n ...rest\n}: ForgotPasswordFormProps) {\n const labels = useKitLabels(\"forgotPassword\", DEFAULT_FORGOT_PASSWORD_LABELS, labelsProp);\n const [email, setEmail] = useState(defaultEmail);\n const [pending, setPending] = useState(false);\n const [failure, setFailure] = useState<ReactNode>(null);\n const [sentTo, setSentTo] = useState<string | null>(null);\n const headingId = useId();\n const sentRef = useRef<HTMLDivElement>(null);\n useFocusWhen(sentTo !== null, sentRef);\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 back = (\n <p className=\"text-center text-sm\">\n <TextLink href={signInHref} tone=\"primary\">\n {labels.backToSignIn}\n </TextLink>\n </p>\n );\n\n if (sentTo !== null) {\n return (\n <div {...rest} data-state=\"sent\" className={cn(\"space-y-3\", className)}>\n {heading}\n <OutcomeMessage icon={<MailCheck className=\"text-[var(--success)]\" />} focusRef={sentRef}>\n <p className={INTRO_CLASS}>{labels.sent(sentTo)}</p>\n <p className=\"text-xs text-[var(--text-muted)]\">{labels.sentHint}</p>\n </OutcomeMessage>\n {back}\n </div>\n );\n }\n\n const submit = (e: FormEvent) => {\n e.preventDefault();\n const address = email.trim();\n if (pending || address === \"\") return;\n setFailure(null);\n let result: unknown;\n try {\n result = onSubmit(address);\n } catch (error) {\n setFailure(describeError?.(error) ?? labels.error);\n return;\n }\n if (!isThenable(result)) {\n setSentTo(address);\n return;\n }\n setPending(true);\n result.then(\n () => {\n setPending(false);\n setSentTo(address);\n },\n (error: unknown) => {\n setPending(false);\n setFailure(describeError?.(error) ?? labels.error);\n },\n );\n };\n\n return (\n <div {...rest} data-state=\"form\" className={cn(\"space-y-3\", className)}>\n <form\n className=\"space-y-3\"\n aria-labelledby={title === null ? undefined : headingId}\n onSubmit={submit}\n >\n {heading}\n <p className={INTRO_CLASS}>{labels.intro}</p>\n {formContent}\n <Input\n type=\"email\"\n name=\"email\"\n autoComplete=\"email\"\n label={labels.email}\n value={email}\n onChange={(e) => {\n setEmail(e.target.value);\n if (failure) setFailure(null);\n }}\n readOnly={pending}\n required\n />\n {failure != null && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n <Button type=\"submit\" className=\"w-full\" pending={pending}>\n {labels.submit}\n </Button>\n </form>\n {back}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/auth/forgot-password-form.tsx"],"sourcesContent":["import { useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, FormEvent, ReactNode } from \"react\";\nimport { MailCheck } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels } from \"../i18n/kit-labels\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { TextLink } from \"../components/text-link\";\nimport { Button, Input } from \"../components/ui\";\nimport { englishRateLimited, isRateLimited, retryAfterSeconds } from \"./auth-errors\";\nimport { FORM_HEADING_CLASS, INTRO_CLASS, OutcomeMessage, isThenable, useFocusWhen } from \"./status-parts\";\nimport type { AuthHeadingLevel } from \"./status-parts\";\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the form renders — the `forgotPassword` namespace. keksdose's words\n * (`auth.forgot.*`), which kastlan took over unchanged. */\nexport interface ForgotPasswordLabels {\n /** The heading. */\n title: string;\n /** Under the heading, before the field. */\n intro: string;\n /** The email field's label. */\n email: string;\n submit: string;\n /** The one answer, whatever the server knows — given the address as typed. */\n sent: (email: string) => string;\n /** Under it: how long the link lives, and where else to look. */\n sentHint: string;\n /** The link back to the sign-in page, on both states. */\n backToSignIn: string;\n /** A request that did not go through: a throttle, an outage. Never \"unknown\n * address\" — the endpoint does not say. */\n error: string;\n /** 0.30.0: the request was throttled — HTTP 429 ({@link isRateLimited}) — given the\n * `Retry-After` wait in seconds, or `undefined` without one. OPTIONAL, like every key\n * added to a shipped interface; the provider's `forgotPassword`, then English, fill\n * it. */\n rateLimited?: (seconds?: number) => string;\n}\n\n/** `Required`: every key, the 0.30 one included, has its English here. */\nexport const DEFAULT_FORGOT_PASSWORD_LABELS: Required<ForgotPasswordLabels> = {\n title: \"Forgot password\",\n intro: \"Enter your account’s email address. We’ll send you a link to choose a new password.\",\n email: \"Email\",\n submit: \"Send the link\",\n sent: (email) => `If an account exists for ${email}, the link is on its way.`,\n sentHint: \"The link is valid for one hour and works exactly once. Check your spam folder too.\",\n backToSignIn: \"Back to sign in\",\n error: \"The request failed. Please try again later.\",\n rateLimited: englishRateLimited,\n};\n\n/* ── The form ────────────────────────────────────────────────────────────── */\n\nexport interface ForgotPasswordFormProps\n extends Omit<ComponentPropsWithoutRef<\"div\">, \"onSubmit\" | \"children\" | \"title\"> {\n /**\n * The address was submitted, trimmed: send `POST /auth/password-reset/request`.\n * Resolve when the server answered — whatever it answered: it is `204` whether or not\n * an account exists. Reject only when the request did not go through (a throttle, an\n * outage); the form then stays, with the address, and says so. The form never sends a\n * request itself (§8).\n */\n onSubmit: (email: string) => Promise<unknown> | void;\n /** \"Back to sign in\" — the app's `/login`. Default `/login`. */\n signInHref?: string;\n /** The field's start value — the address the sign-in form already had, so someone\n * whose password just failed need not type it twice. */\n defaultEmail?: string;\n /**\n * The app's words for a failed request (\"Too many requests — try again in an hour\"),\n * or `undefined` for the kit's: `rateLimited` for a bare `429` (0.30.0), else `error`.\n * The same hook as `SignInForm`'s.\n */\n describeError?: (error: unknown) => ReactNode | undefined;\n /** App content under the intro — a notice, a support address. */\n formContent?: ReactNode;\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<ForgotPasswordLabels>;\n}\n\n/**\n * \"I forgot my password\" for every app (docs/auth-harmonization.md §7): one field, then\n * the same sentence whatever the answer — keksdose's `ForgotPasswordPage`\n * (features/auth/forgot-password-page.tsx), kastlan's copy of it, as one component.\n *\n * **It always ends on the same message**: \"If an account exists for …, the link is on\n * its way.\" The server answers `204` whether or not the address has an account — so the\n * page cannot be used to find out who has one — and the page must not undo that by\n * saying more than the server knows. Worded as a promise (\"we sent you a link\"), a typo\n * would look like a mail-delivery problem and send the person waiting instead of trying\n * their other address (keksdose #198). The address shown is the one they typed, so a\n * typo is visible.\n *\n * The only failure it can show is a request that did not go through (throttled, the\n * server down), never \"unknown address\". §4.5's fallback to a tagged address\n * (`you+kastlan@…` when `you@…` has no account) is the server's and changes nothing here.\n *\n * The submit is not disabled while the field is empty — a browser that autofills\n * withholds the value until the first interaction, and a disabled button would swallow\n * that first click; the field's own `required` answers an empty submit instead. After a\n * submit the confirmation takes focus, because the button that had it is gone with the\n * form.\n */\nexport function ForgotPasswordForm({\n onSubmit,\n signInHref = \"/login\",\n defaultEmail = \"\",\n describeError,\n formContent,\n title,\n headingAs: Heading = \"h2\",\n labels: labelsProp,\n className,\n ...rest\n}: ForgotPasswordFormProps) {\n const labels = useKitLabels(\"forgotPassword\", DEFAULT_FORGOT_PASSWORD_LABELS, labelsProp);\n const [email, setEmail] = useState(defaultEmail);\n const [pending, setPending] = useState(false);\n const [failure, setFailure] = useState<ReactNode>(null);\n const [sentTo, setSentTo] = useState<string | null>(null);\n const headingId = useId();\n const sentRef = useRef<HTMLDivElement>(null);\n useFocusWhen(sentTo !== null, sentRef);\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 back = (\n <p className=\"text-center text-sm\">\n <TextLink href={signInHref} tone=\"primary\">\n {labels.backToSignIn}\n </TextLink>\n </p>\n );\n\n if (sentTo !== null) {\n return (\n <div {...rest} data-state=\"sent\" className={cn(\"space-y-3\", className)}>\n {heading}\n <OutcomeMessage icon={<MailCheck className=\"text-[var(--success)]\" />} focusRef={sentRef}>\n <p className={INTRO_CLASS}>{labels.sent(sentTo)}</p>\n <p className=\"text-xs text-[var(--text-muted)]\">{labels.sentHint}</p>\n </OutcomeMessage>\n {back}\n </div>\n );\n }\n\n // The app's words first, then the throttle's, then the kit's catch-all.\n const failureFor = (error: unknown): ReactNode =>\n describeError?.(error) ??\n (isRateLimited(error)\n ? (labels.rateLimited ?? DEFAULT_FORGOT_PASSWORD_LABELS.rateLimited)(retryAfterSeconds(error))\n : labels.error);\n\n const submit = (e: FormEvent) => {\n e.preventDefault();\n const address = email.trim();\n if (pending || address === \"\") return;\n setFailure(null);\n let result: unknown;\n try {\n result = onSubmit(address);\n } catch (error) {\n setFailure(failureFor(error));\n return;\n }\n if (!isThenable(result)) {\n setSentTo(address);\n return;\n }\n setPending(true);\n result.then(\n () => {\n setPending(false);\n setSentTo(address);\n },\n (error: unknown) => {\n setPending(false);\n setFailure(failureFor(error));\n },\n );\n };\n\n return (\n <div {...rest} data-state=\"form\" className={cn(\"space-y-3\", className)}>\n <form\n className=\"space-y-3\"\n aria-labelledby={title === null ? undefined : headingId}\n onSubmit={submit}\n >\n {heading}\n <p className={INTRO_CLASS}>{labels.intro}</p>\n {formContent}\n <Input\n type=\"email\"\n name=\"email\"\n autoComplete=\"email\"\n label={labels.email}\n value={email}\n onChange={(e) => {\n setEmail(e.target.value);\n if (failure) setFailure(null);\n }}\n readOnly={pending}\n required\n />\n {failure != null && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n <Button type=\"submit\" className=\"w-full\" pending={pending}>\n {labels.submit}\n </Button>\n </form>\n {back}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,5 +1,8 @@
1
1
  import 'react';
2
- export { cr as DEFAULT_REGISTER_LABELS, dS as RegisterForm, dT as RegisterFormProps, dU as RegisterLabels, dV as RegisterValues } from '../register-form-V1OeNlMr.js';
2
+ export { cB as DEFAULT_REGISTER_LABELS, ei as RegisterForm, ej as RegisterFormProps, ek as RegisterLabels, el as RegisterValues } from '../delete-account-setting-CGVMckMw.js';
3
+ import '../components/tooltip.js';
4
+ import '../hooks/use-anchored-rect.js';
5
+ import '../lib/clipping.js';
3
6
  import '../data-table-labels-DUELeXpa.js';
4
7
  import '../components/data-table-sort.js';
5
8
  import '../components/swipeable-row.js';
@@ -9,10 +12,6 @@ import '../components/popover.js';
9
12
  import '../components/chip.js';
10
13
  import 'lucide-react';
11
14
  import '../components/field-sync.js';
12
- import '../components/tooltip.js';
13
- import '../hooks/use-anchored-rect.js';
14
- import '../lib/clipping.js';
15
- import '../components/field-parts.js';
16
15
  import '../wizard/types.js';
17
16
  import '../tour/tour.js';
18
17
  import '../search/command-palette.js';
@@ -55,6 +54,12 @@ import '../components/image-grid.js';
55
54
  import '../components/lightbox.js';
56
55
  import '../components/write-lock.js';
57
56
  import './sign-in-form.js';
57
+ import '../shell/topbar-controls.js';
58
+ import '../theme/palette-presets.js';
59
+ import '../theme/chart-palette.js';
60
+ import '../theme/theme-store.js';
61
+ import 'zustand/middleware';
62
+ import 'zustand';
58
63
  import './complete-name-dialog.js';
59
64
  import './forgot-password-form.js';
60
65
  import './status-parts.js';
@@ -63,8 +68,13 @@ import './verify-email.js';
63
68
  import './not-found-page.js';
64
69
  import './accept-invitation.js';
65
70
  import '../shell/company-switcher.js';
66
- import '../components/account-chips.js';
71
+ import '../account/email-change-setting.js';
72
+ import '../account/sessions-setting.js';
67
73
  import '../lib/format.js';
74
+ import '../account/data-export-setting.js';
75
+ import '../components/account-chips.js';
76
+ import '../admin/admin-parts.js';
77
+ import '../admin/admin-action-log.js';
68
78
  import '../components/reauth-dialog.js';
69
79
  import '../components/server-wake.js';
70
80
  import '../lib/server-wake.js';
@@ -78,9 +88,4 @@ import '../components/sign-chip.js';
78
88
  import '../components/column-mapper.js';
79
89
  import '../lib/column-mapping.js';
80
90
  import '../lib/table-text.js';
81
- import '../shell/topbar-controls.js';
82
- import '../theme/palette-presets.js';
83
- import '../theme/chart-palette.js';
84
- import '../theme/theme-store.js';
85
- import 'zustand/middleware';
86
- import 'zustand';
91
+ import '../components/field-parts.js';
@@ -10,7 +10,7 @@ import { LanguageSelect } from "../components/language-select.js";
10
10
  import { LegalAcceptCheckbox } from "../components/legal-page.js";
11
11
  import { TextLink } from "../components/text-link.js";
12
12
  import { Button, Input } from "../components/ui.js";
13
- import { authErrorCode } from "./auth-errors.js";
13
+ import { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from "./auth-errors.js";
14
14
  import { emailParts, taggedEmail } from "./email-tag.js";
15
15
  import { newPasswordProblem, personNameOk, PERSON_NAME_MAX_LENGTH } from "./form-rules.js";
16
16
  import { NewPasswordFields } from "./new-password-fields.js";
@@ -33,7 +33,8 @@ const DEFAULT_REGISTER_LABELS = {
33
33
  registrationClosed: "New accounts are by invitation only. Ask the operator to invite your email address.",
34
34
  invitationInvalid: "This invitation link is not valid.",
35
35
  invitationExpired: "This invitation has expired. Ask for a new one.",
36
- failed: "Registration failed. Please try again."
36
+ failed: "Registration failed. Please try again.",
37
+ rateLimited: englishRateLimited
37
38
  };
38
39
  const ALL_CODES = KIT_LANGUAGES.map((language) => language.code);
39
40
  function browserLanguages() {
@@ -44,6 +45,7 @@ function RegisterForm({
44
45
  onSubmit,
45
46
  emailTag,
46
47
  invitedEmail,
48
+ defaultEmail,
47
49
  locale: localeProp,
48
50
  defaultLocale,
49
51
  onLocaleChange,
@@ -67,7 +69,7 @@ function RegisterForm({
67
69
  const invited = invitedEmail !== void 0;
68
70
  const [firstName, setFirstName] = useState("");
69
71
  const [lastName, setLastName] = useState("");
70
- const [email, setEmail] = useState(invitedEmail ?? "");
72
+ const [email, setEmail] = useState(invitedEmail ?? defaultEmail ?? "");
71
73
  const [password, setPassword] = useState("");
72
74
  const [confirm, setConfirm] = useState("");
73
75
  const [ownLocale, setOwnLocale] = useState(
@@ -129,7 +131,7 @@ function RegisterForm({
129
131
  return;
130
132
  }
131
133
  setFailure(
132
- hasMessage(own) ? own : code === "registration_closed" ? labels.registrationClosed : code === "invitation_invalid" ? labels.invitationInvalid : code === "invitation_expired" ? labels.invitationExpired : labels.failed
134
+ hasMessage(own) ? own : isRateLimited(error) ? (labels.rateLimited ?? DEFAULT_REGISTER_LABELS.rateLimited)(retryAfterSeconds(error)) : code === "registration_closed" ? labels.registrationClosed : code === "invitation_invalid" ? labels.invitationInvalid : code === "invitation_expired" ? labels.invitationExpired : labels.failed
133
135
  );
134
136
  }
135
137
  );
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/auth/register-form.tsx"],"sourcesContent":["import { useEffect, useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, FormEvent, ReactNode } from \"react\";\n\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { KIT_LANGUAGES, resolveLanguage } from \"../i18n/languages\";\nimport type { KitLanguageCode } from \"../i18n/languages\";\nimport { cn } from \"../lib/cn\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { hasMessage } from \"../components/choice-parts\";\nimport { LanguageSelect } from \"../components/language-select\";\nimport { LegalAcceptCheckbox } from \"../components/legal-page\";\nimport { TextLink } from \"../components/text-link\";\nimport { Button, Input } from \"../components/ui\";\nimport { authErrorCode } from \"./auth-errors\";\nimport { emailParts, taggedEmail } from \"./email-tag\";\nimport { newPasswordProblem, personNameOk, PERSON_NAME_MAX_LENGTH } from \"./form-rules\";\nimport { NewPasswordFields } from \"./new-password-fields\";\n\n/** What `RegisterForm` hands `onSubmit` — the core of `POST /auth/register` (§4.2). The\n * app adds its own fields (kastlan's company, keksdose's currency) and the\n * `invite_token` it read from the URL. */\nexport interface RegisterValues {\n /** Trimmed, 1–120 characters. */\n firstName: string;\n /** Trimmed, 1–120 characters. */\n lastName: string;\n /** Trimmed. Lower-casing is the server's (§3.1); a tag is never stripped (§4.5). */\n email: string;\n /** As typed: at least 8 characters, at most 72 bytes. */\n password: string;\n /** The language picked — one of `languages` — for every mail to the user (§3.1). */\n locale: KitLanguageCode;\n}\n\n/**\n * Every string `RegisterForm` renders — the `register` namespace of `<UiKitProvider\n * labels>`, overridable per instance through `labels`. keksdose's words where it has\n * them. The terms checkbox speaks `legal.accept`, the strength meter `passwordStrength`.\n */\nexport interface RegisterLabels {\n firstName: string;\n lastName: string;\n email: string;\n /** The address-tag button (§4.5), given the exact address it would use — and, with an\n * invitation, the button back to the invited address. */\n emailTagUse: (address: string) => string;\n /** The line after the address-tag button. */\n emailTagHint: string;\n /** Under an invited (read-only) email field. */\n invitedEmailHint: string;\n /** Once the invitee took the tagged address: it is not the invited one, so it is\n * confirmed by a mail of its own (§4.3). */\n invitedTagNote: string;\n password: string;\n confirmPassword: string;\n passwordMismatch: string;\n language: string;\n /** The submit button. */\n submit: string;\n /** Before the link to `signInHref`. */\n haveAccount: string;\n /** The link to `signInHref`. */\n signIn: string;\n /** `email_taken`, under the email field. */\n emailTaken: string;\n /** `registration_closed` — the gate refused the address (§4.3). */\n registrationClosed: string;\n /** `invitation_invalid` — the invitation link is unknown or spent. */\n invitationInvalid: string;\n /** `invitation_expired`. */\n invitationExpired: string;\n /** Any other failure. */\n failed: string;\n}\n\nexport const DEFAULT_REGISTER_LABELS: RegisterLabels = {\n firstName: \"First name\",\n lastName: \"Last name\",\n email: \"Email\",\n emailTagUse: (address) => `Use ${address}`,\n emailTagHint:\n \"Many providers deliver name+tag@… to the same inbox, so mail from this app is easy to filter and trace. Check yours does before you rely on it: you would sign in with the tagged address.\",\n invitedEmailHint: \"The address your invitation was sent to.\",\n invitedTagNote: \"The tagged address gets a confirmation mail of its own.\",\n password: \"Password\",\n confirmPassword: \"Repeat password\",\n passwordMismatch: \"The passwords don't match.\",\n language: \"Language\",\n submit: \"Create account\",\n haveAccount: \"Already have an account?\",\n signIn: \"Sign in\",\n emailTaken: \"An account with this email address already exists.\",\n registrationClosed: \"New accounts are by invitation only. Ask the operator to invite your email address.\",\n invitationInvalid: \"This invitation link is not valid.\",\n invitationExpired: \"This invitation has expired. Ask for a new one.\",\n failed: \"Registration failed. Please try again.\",\n};\n\nexport interface RegisterFormProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onSubmit\" | \"children\"> {\n /**\n * The form was submitted with everything filled in. Send it (`POST /auth/register`,\n * §4.2) and resolve once the user is signed in, or reject with the client's error:\n * `email_taken` shows under the email field, `registration_closed` and the\n * invitation codes above the button, anything else as \"Registration failed\". The form\n * never sends a request itself (§8): keksdose must see the password on its way.\n *\n * On resolve the form stays busy while the app navigates.\n */\n onSubmit: (values: RegisterValues) => Promise<unknown>;\n /**\n * The app's address tag (`\"kastlan\"`, §4.5). Once the email is plausible, a button\n * under it offers the tagged address — \"Use ada+kastlan@example.com\" — with a line on\n * why; one click puts it in the field. Never applied on its own. Left out, no offer.\n */\n emailTag?: string;\n /**\n * The address of the invitation this sign-up comes from (§4.4): the field starts with\n * it and is read-only. With `emailTag`, the invitee may still take the tagged form of\n * it — the server accepts exactly that variant — and go back.\n */\n invitedEmail?: string;\n /**\n * The language field's value — controlled. Pass the app's UI language\n * (`i18n.resolvedLanguage`) with `onLocaleChange` switching it, and the field IS the\n * app's language switch, with no copy of its own to drift from a second switcher on\n * the page (keksdose's register page has one in its header). Any tag; it shows (and\n * submits) the offered code it resolves to. Left out, the form keeps its own, starting\n * from `defaultLocale`, else the provider's language, else the browser's.\n */\n locale?: string;\n /** The uncontrolled language field's start value. */\n defaultLocale?: string;\n /** A language was picked. */\n onLocaleChange?: (code: KitLanguageCode) => void;\n /** The languages to offer, in order. Default all seven. */\n languages?: readonly KitLanguageCode[];\n /**\n * The app's own language field in place of the kit's select — keksdose's\n * `LanguageSetting` bound to its i18n. `null` draws none (an app with one language);\n * the submitted `locale` is then `locale`'s.\n */\n languageField?: ReactNode;\n /** Above the form — keksdose's closed-beta banner. */\n aboveForm?: ReactNode;\n /** Under the email field and its tag offer — an app's own hint. */\n underEmail?: ReactNode;\n /** The app's sign-up fields (§4.1 item 7): kastlan's Company, keksdose's reporting\n * currency. Their values are the app's; say whether they are complete with\n * `appFieldsComplete`. */\n appFields?: ReactNode;\n /** After the app fields, before the terms — keksdose's privacy-mode note. */\n afterFields?: ReactNode;\n /** Whether the app fields are filled in as required — kastlan's Company without an\n * invitation. The submit waits for it. Default `true`. */\n appFieldsComplete?: boolean;\n /** The terms checkbox's links. Default `/terms` and `/privacy`. */\n termsHref?: string;\n privacyHref?: string;\n /** \"Already have an account? Sign in\" goes here — `/login`. Left out, no line. */\n signInHref?: string;\n /** The app's own words for a failure the kit cannot know (a throttled attempt), or\n * `undefined` for the kit's. Asked first, for every failure. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: Partial<RegisterLabels>;\n}\n\nconst ALL_CODES: readonly KitLanguageCode[] = KIT_LANGUAGES.map((language) => language.code);\n\n/** The browser's languages, in its order — absent outside a browser. */\nfunction browserLanguages(): readonly string[] {\n if (typeof navigator === \"undefined\") return [];\n return navigator.languages?.length ? navigator.languages : [navigator.language];\n}\n\ntype Busy = \"sending\" | \"done\" | null;\n\n/**\n * Sign-up for every app (docs/auth-harmonization.md §4.1, §8): first and last name,\n * email with the address-tag offer, password with the strength meter and its\n * confirmation, language, the app's own fields in a slot, and the terms. keksdose's\n * `RegisterPage` (features/auth/register-page.tsx) with its slots made props.\n *\n * **It never sends a request.** `onSubmit` gets the values; what the request carries\n * besides them (kastlan's company, keksdose's currency, the `invite_token`) is the\n * app's, as are the role and the associations, which never come from the form (§4.3).\n *\n * **The submit waits for a complete form**: both names (1–120 characters after\n * trimming), a plausible address, a password of at least 8 characters and at most 72\n * bytes, the same password again, the terms ticked, and `appFieldsComplete`. Nothing\n * else is a rule — the strength checklist is advice (keksdose #124), and the kit adds\n * none of its own (§8). So there is never a failed attempt for a missing field, and no\n * field error to show for one.\n *\n * **The address tag** (§4.5) is offered under the email field as one click with the\n * exact result on the button, never applied: sub-addressing is a convention the\n * receiving server may or may not implement, and a silent rewrite would send the\n * verification mail down a path nobody checked. With an invitation the field is fixed\n * to the invited address; the invitee may still take its tagged form, and is told it\n * will be confirmed by a mail of its own (§4.3).\n *\n * **Refusals** are read by code ({@link authErrorCode}): `email_taken` under the email\n * field, which takes the focus; `registration_closed`, `invitation_invalid` and\n * `invitation_expired` above the button; anything else as \"Registration failed\", unless\n * `describeError` has the app's words.\n *\n * Laid out for a 390 px phone: the names sit side by side only where the form is at\n * least 20rem wide (a container query), which the narrow `AuthLayout` card is on a\n * desktop and a phone is not.\n */\nexport function RegisterForm({\n onSubmit,\n emailTag,\n invitedEmail,\n locale: localeProp,\n defaultLocale,\n onLocaleChange,\n languages = ALL_CODES,\n languageField,\n aboveForm,\n underEmail,\n appFields,\n afterFields,\n appFieldsComplete = true,\n termsHref,\n privacyHref,\n signInHref,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: RegisterFormProps) {\n const labels = useKitLabels(\"register\", DEFAULT_REGISTER_LABELS, labelsProp);\n const kitLocale = useKitLocale();\n const invited = invitedEmail !== undefined;\n const [firstName, setFirstName] = useState(\"\");\n const [lastName, setLastName] = useState(\"\");\n const [email, setEmail] = useState(invitedEmail ?? \"\");\n const [password, setPassword] = useState(\"\");\n const [confirm, setConfirm] = useState(\"\");\n const [ownLocale, setOwnLocale] = useState<string>(\n () => defaultLocale ?? resolveLanguage([kitLocale, ...browserLanguages()], languages),\n );\n const [accepted, setAccepted] = useState(false);\n const [busy, setBusy] = useState<Busy>(null);\n // `email_taken` belongs to the address it was answered for; another one clears it.\n const [taken, setTaken] = useState<string | null>(null);\n const [failure, setFailure] = useState<ReactNode>(null);\n const emailRef = useRef<HTMLInputElement>(null);\n const tagHintId = useId();\n\n const mounted = useRef(true);\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n // What the field shows is what is submitted: the offered code the value resolves to.\n const locale = resolveLanguage([localeProp ?? ownLocale], languages);\n const changeLocale = (code: KitLanguageCode) => {\n if (localeProp === undefined) setOwnLocale(code);\n onLocaleChange?.(code);\n };\n\n const working = busy !== null;\n const suggestion = emailTag ? taggedEmail(email, emailTag) : null;\n // The invitee took the tagged address: offer the way back to the invited one.\n const backToInvited =\n invitedEmail !== undefined && email.trim() !== invitedEmail.trim() ? invitedEmail.trim() : null;\n const canSubmit =\n personNameOk(firstName) &&\n personNameOk(lastName) &&\n emailParts(email) !== null &&\n newPasswordProblem(password, confirm) === null &&\n accepted &&\n appFieldsComplete;\n\n const submit = (e: FormEvent) => {\n e.preventDefault();\n if (!canSubmit || working) return;\n const values: RegisterValues = {\n firstName: firstName.trim(),\n lastName: lastName.trim(),\n email: email.trim(),\n password,\n locale,\n };\n setBusy(\"sending\");\n setFailure(null);\n setTaken(null);\n let result: Promise<unknown>;\n try {\n result = Promise.resolve(onSubmit(values));\n } catch (error) {\n result = Promise.reject(error);\n }\n result.then(\n () => {\n if (mounted.current) setBusy(\"done\");\n },\n (error: unknown) => {\n if (!mounted.current) return;\n setBusy(null);\n const own = describeError?.(error);\n const code = authErrorCode(error);\n if (code === \"email_taken\" && !hasMessage(own)) {\n setTaken(values.email);\n emailRef.current?.focus();\n return;\n }\n setFailure(\n hasMessage(own)\n ? own\n : code === \"registration_closed\"\n ? labels.registrationClosed\n : code === \"invitation_invalid\"\n ? labels.invitationInvalid\n : code === \"invitation_expired\"\n ? labels.invitationExpired\n : labels.failed,\n );\n },\n );\n };\n\n return (\n <div {...rest} className={cn(\"@container space-y-4\", className)}>\n {aboveForm}\n <form className=\"space-y-3\" noValidate onSubmit={submit}>\n <div className=\"grid gap-3 @xs:grid-cols-2\">\n <Input\n name=\"given-name\"\n autoComplete=\"given-name\"\n label={labels.firstName}\n value={firstName}\n onChange={(e) => setFirstName(e.target.value)}\n maxLength={PERSON_NAME_MAX_LENGTH}\n readOnly={working}\n required\n />\n <Input\n name=\"family-name\"\n autoComplete=\"family-name\"\n label={labels.lastName}\n value={lastName}\n onChange={(e) => setLastName(e.target.value)}\n maxLength={PERSON_NAME_MAX_LENGTH}\n readOnly={working}\n required\n />\n </div>\n\n <div>\n <Input\n ref={emailRef}\n type=\"email\"\n name=\"email\"\n autoComplete=\"email\"\n label={labels.email}\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n readOnly={invited || working}\n hint={invited ? labels.invitedEmailHint : undefined}\n error={taken !== null && taken === email.trim() ? labels.emailTaken : undefined}\n required\n />\n {/* The tag, offered rather than applied (§4.5): one click, the exact address\n on the button, reversible by typing — or, invited, by the button back. */}\n {suggestion && (\n <p className=\"mt-1 text-xs text-[var(--text-muted)]\">\n <Button\n type=\"button\"\n variant=\"link\"\n size=\"sm\"\n className=\"h-auto p-0 align-baseline\"\n disabled={working}\n aria-describedby={tagHintId}\n onClick={() => setEmail(suggestion)}\n >\n {labels.emailTagUse(suggestion)}\n </Button>{\" \"}\n <span id={tagHintId}>{labels.emailTagHint}</span>\n </p>\n )}\n {backToInvited !== null && (\n <p className=\"mt-1 text-xs text-[var(--text-muted)]\">\n <span>{labels.invitedTagNote}</span>{\" \"}\n <Button\n type=\"button\"\n variant=\"link\"\n size=\"sm\"\n className=\"h-auto p-0 align-baseline\"\n disabled={working}\n onClick={() => setEmail(backToInvited)}\n >\n {labels.emailTagUse(backToInvited)}\n </Button>\n </p>\n )}\n {underEmail}\n </div>\n\n <NewPasswordFields\n password={password}\n confirm={confirm}\n onPasswordChange={setPassword}\n onConfirmChange={setConfirm}\n busy={working}\n text={{ password: labels.password, confirm: labels.confirmPassword, mismatch: labels.passwordMismatch }}\n />\n\n {languageField === undefined ? (\n <LanguageSelect\n name=\"locale\"\n label={labels.language}\n value={locale}\n codes={languages}\n onChange={changeLocale}\n disabled={working}\n />\n ) : (\n languageField\n )}\n\n {appFields}\n {afterFields}\n\n {/* The kit's terms checkbox (legal contract §3.4). Never an error: an unticked\n box keeps the submit disabled, so there is no failed attempt to explain. */}\n <LegalAcceptCheckbox\n checked={accepted}\n onCheckedChange={setAccepted}\n termsHref={termsHref}\n privacyHref={privacyHref}\n disabled={working}\n />\n\n {hasMessage(failure) && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n\n <Button type=\"submit\" className=\"w-full\" disabled={!working && !canSubmit} pending={working}>\n {labels.submit}\n </Button>\n </form>\n {signInHref !== undefined && (\n <p className=\"text-center text-sm text-[var(--text-secondary)]\">\n {labels.haveAccount}{\" \"}\n <TextLink href={signInHref} tone=\"primary\">\n {labels.signIn}\n </TextLink>\n </p>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/auth/register-form.tsx"],"sourcesContent":["import { useEffect, useId, useRef, useState } from \"react\";\nimport type { ComponentPropsWithoutRef, FormEvent, ReactNode } from \"react\";\n\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport { KIT_LANGUAGES, resolveLanguage } from \"../i18n/languages\";\nimport type { KitLanguageCode } from \"../i18n/languages\";\nimport { cn } from \"../lib/cn\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { hasMessage } from \"../components/choice-parts\";\nimport { LanguageSelect } from \"../components/language-select\";\nimport { LegalAcceptCheckbox } from \"../components/legal-page\";\nimport { TextLink } from \"../components/text-link\";\nimport { Button, Input } from \"../components/ui\";\nimport { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from \"./auth-errors\";\nimport { emailParts, taggedEmail } from \"./email-tag\";\nimport { newPasswordProblem, personNameOk, PERSON_NAME_MAX_LENGTH } from \"./form-rules\";\nimport { NewPasswordFields } from \"./new-password-fields\";\n\n/** What `RegisterForm` hands `onSubmit` — the core of `POST /auth/register` (§4.2). The\n * app adds its own fields (kastlan's company, keksdose's currency) and the\n * `invite_token` it read from the URL. */\nexport interface RegisterValues {\n /** Trimmed, 1–120 characters. */\n firstName: string;\n /** Trimmed, 1–120 characters. */\n lastName: string;\n /** Trimmed. Lower-casing is the server's (§3.1); a tag is never stripped (§4.5). */\n email: string;\n /** As typed: at least 8 characters, at most 72 bytes. */\n password: string;\n /** The language picked — one of `languages` — for every mail to the user (§3.1). */\n locale: KitLanguageCode;\n}\n\n/**\n * Every string `RegisterForm` renders — the `register` namespace of `<UiKitProvider\n * labels>`, overridable per instance through `labels`. keksdose's words where it has\n * them. The terms checkbox speaks `legal.accept`, the strength meter `passwordStrength`.\n */\nexport interface RegisterLabels {\n firstName: string;\n lastName: string;\n email: string;\n /** The address-tag button (§4.5), given the exact address it would use — and, with an\n * invitation, the button back to the invited address. */\n emailTagUse: (address: string) => string;\n /** The line after the address-tag button. */\n emailTagHint: string;\n /** Under an invited (read-only) email field. */\n invitedEmailHint: string;\n /** Once the invitee took the tagged address: it is not the invited one, so it is\n * confirmed by a mail of its own (§4.3). */\n invitedTagNote: string;\n password: string;\n confirmPassword: string;\n passwordMismatch: string;\n language: string;\n /** The submit button. */\n submit: string;\n /** Before the link to `signInHref`. */\n haveAccount: string;\n /** The link to `signInHref`. */\n signIn: string;\n /** `email_taken`, under the email field. */\n emailTaken: string;\n /** `registration_closed` — the gate refused the address (§4.3). */\n registrationClosed: string;\n /** `invitation_invalid` — the invitation link is unknown or spent. */\n invitationInvalid: string;\n /** `invitation_expired`. */\n invitationExpired: string;\n /** Any other failure. */\n failed: string;\n /** 0.30.0: the request was throttled — HTTP 429 ({@link isRateLimited}) — given the\n * `Retry-After` wait in seconds, or `undefined` without one. OPTIONAL, like every key\n * added to a shipped interface (an annotated call site must keep compiling); the\n * provider's `register`, then English, fill it. */\n rateLimited?: (seconds?: number) => string;\n}\n\n/** `Required`: every key, the 0.30 one included, has its English here. */\nexport const DEFAULT_REGISTER_LABELS: Required<RegisterLabels> = {\n firstName: \"First name\",\n lastName: \"Last name\",\n email: \"Email\",\n emailTagUse: (address) => `Use ${address}`,\n emailTagHint:\n \"Many providers deliver name+tag@… to the same inbox, so mail from this app is easy to filter and trace. Check yours does before you rely on it: you would sign in with the tagged address.\",\n invitedEmailHint: \"The address your invitation was sent to.\",\n invitedTagNote: \"The tagged address gets a confirmation mail of its own.\",\n password: \"Password\",\n confirmPassword: \"Repeat password\",\n passwordMismatch: \"The passwords don't match.\",\n language: \"Language\",\n submit: \"Create account\",\n haveAccount: \"Already have an account?\",\n signIn: \"Sign in\",\n emailTaken: \"An account with this email address already exists.\",\n registrationClosed: \"New accounts are by invitation only. Ask the operator to invite your email address.\",\n invitationInvalid: \"This invitation link is not valid.\",\n invitationExpired: \"This invitation has expired. Ask for a new one.\",\n failed: \"Registration failed. Please try again.\",\n rateLimited: englishRateLimited,\n};\n\nexport interface RegisterFormProps extends Omit<ComponentPropsWithoutRef<\"div\">, \"onSubmit\" | \"children\"> {\n /**\n * The form was submitted with everything filled in. Send it (`POST /auth/register`,\n * §4.2) and resolve once the user is signed in, or reject with the client's error:\n * `email_taken` shows under the email field, `registration_closed` and the\n * invitation codes above the button, anything else as \"Registration failed\". The form\n * never sends a request itself (§8): keksdose must see the password on its way.\n *\n * On resolve the form stays busy while the app navigates.\n */\n onSubmit: (values: RegisterValues) => Promise<unknown>;\n /**\n * The app's address tag (`\"kastlan\"`, §4.5). Once the email is plausible, a button\n * under it offers the tagged address — \"Use ada+kastlan@example.com\" — with a line on\n * why; one click puts it in the field. Never applied on its own. Left out, no offer.\n */\n emailTag?: string;\n /**\n * The address of the invitation this sign-up comes from (§4.4): the field starts with\n * it and is read-only. With `emailTag`, the invitee may still take the tagged form of\n * it — the server accepts exactly that variant — and go back.\n */\n invitedEmail?: string;\n /**\n * 0.29.1: an address to start with that stays EDITABLE, with the `+tag` suggestion still\n * offered — for a link anyone can build (keksdose's token-free `/register?email=…`\n * beta link, kept while an app moves to invitations). Unlike `invitedEmail` it proves\n * nothing, so it locks nothing. `invitedEmail` wins when both are given.\n */\n defaultEmail?: string;\n /**\n * The language field's value — controlled. Pass the app's UI language\n * (`i18n.resolvedLanguage`) with `onLocaleChange` switching it, and the field IS the\n * app's language switch, with no copy of its own to drift from a second switcher on\n * the page (keksdose's register page has one in its header). Any tag; it shows (and\n * submits) the offered code it resolves to. Left out, the form keeps its own, starting\n * from `defaultLocale`, else the provider's language, else the browser's.\n */\n locale?: string;\n /** The uncontrolled language field's start value. */\n defaultLocale?: string;\n /** A language was picked. */\n onLocaleChange?: (code: KitLanguageCode) => void;\n /** The languages to offer, in order. Default all seven. */\n languages?: readonly KitLanguageCode[];\n /**\n * The app's own language field in place of the kit's select — keksdose's\n * `LanguageSetting` bound to its i18n. `null` draws none (an app with one language);\n * the submitted `locale` is then `locale`'s.\n */\n languageField?: ReactNode;\n /** Above the form — keksdose's closed-beta banner. */\n aboveForm?: ReactNode;\n /** Under the email field and its tag offer — an app's own hint. */\n underEmail?: ReactNode;\n /** The app's sign-up fields (§4.1 item 7): kastlan's Company, keksdose's reporting\n * currency. Their values are the app's; say whether they are complete with\n * `appFieldsComplete`. */\n appFields?: ReactNode;\n /** After the app fields, before the terms — keksdose's privacy-mode note. */\n afterFields?: ReactNode;\n /** Whether the app fields are filled in as required — kastlan's Company without an\n * invitation. The submit waits for it. Default `true`. */\n appFieldsComplete?: boolean;\n /** The terms checkbox's links. Default `/terms` and `/privacy`. */\n termsHref?: string;\n privacyHref?: string;\n /** \"Already have an account? Sign in\" goes here — `/login`. Left out, no line. */\n signInHref?: string;\n /** The app's own words for a failure the kit cannot know (the device offline, a\n * throttle with its wait), or `undefined` for the kit's. Asked first, for every\n * failure; a bare `429` needs none since 0.30.0 — the kit says `rateLimited`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: Partial<RegisterLabels>;\n}\n\nconst ALL_CODES: readonly KitLanguageCode[] = KIT_LANGUAGES.map((language) => language.code);\n\n/** The browser's languages, in its order — absent outside a browser. */\nfunction browserLanguages(): readonly string[] {\n if (typeof navigator === \"undefined\") return [];\n return navigator.languages?.length ? navigator.languages : [navigator.language];\n}\n\ntype Busy = \"sending\" | \"done\" | null;\n\n/**\n * Sign-up for every app (docs/auth-harmonization.md §4.1, §8): first and last name,\n * email with the address-tag offer, password with the strength meter and its\n * confirmation, language, the app's own fields in a slot, and the terms. keksdose's\n * `RegisterPage` (features/auth/register-page.tsx) with its slots made props.\n *\n * **It never sends a request.** `onSubmit` gets the values; what the request carries\n * besides them (kastlan's company, keksdose's currency, the `invite_token`) is the\n * app's, as are the role and the associations, which never come from the form (§4.3).\n *\n * **The submit waits for a complete form**: both names (1–120 characters after\n * trimming), a plausible address, a password of at least 8 characters and at most 72\n * bytes, the same password again, the terms ticked, and `appFieldsComplete`. Nothing\n * else is a rule — the strength checklist is advice (keksdose #124), and the kit adds\n * none of its own (§8). So there is never a failed attempt for a missing field, and no\n * field error to show for one.\n *\n * **The address tag** (§4.5) is offered under the email field as one click with the\n * exact result on the button, never applied: sub-addressing is a convention the\n * receiving server may or may not implement, and a silent rewrite would send the\n * verification mail down a path nobody checked. With an invitation the field is fixed\n * to the invited address; the invitee may still take its tagged form, and is told it\n * will be confirmed by a mail of its own (§4.3).\n *\n * **Refusals** are read by code ({@link authErrorCode}): `email_taken` under the email\n * field, which takes the focus; `registration_closed`, `invitation_invalid` and\n * `invitation_expired` above the button; anything else as \"Registration failed\", unless\n * `describeError` has the app's words.\n *\n * Laid out for a 390 px phone: the names sit side by side only where the form is at\n * least 20rem wide (a container query), which the narrow `AuthLayout` card is on a\n * desktop and a phone is not.\n */\nexport function RegisterForm({\n onSubmit,\n emailTag,\n invitedEmail,\n defaultEmail,\n locale: localeProp,\n defaultLocale,\n onLocaleChange,\n languages = ALL_CODES,\n languageField,\n aboveForm,\n underEmail,\n appFields,\n afterFields,\n appFieldsComplete = true,\n termsHref,\n privacyHref,\n signInHref,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: RegisterFormProps) {\n const labels = useKitLabels(\"register\", DEFAULT_REGISTER_LABELS, labelsProp);\n const kitLocale = useKitLocale();\n const invited = invitedEmail !== undefined;\n const [firstName, setFirstName] = useState(\"\");\n const [lastName, setLastName] = useState(\"\");\n const [email, setEmail] = useState(invitedEmail ?? defaultEmail ?? \"\");\n const [password, setPassword] = useState(\"\");\n const [confirm, setConfirm] = useState(\"\");\n const [ownLocale, setOwnLocale] = useState<string>(\n () => defaultLocale ?? resolveLanguage([kitLocale, ...browserLanguages()], languages),\n );\n const [accepted, setAccepted] = useState(false);\n const [busy, setBusy] = useState<Busy>(null);\n // `email_taken` belongs to the address it was answered for; another one clears it.\n const [taken, setTaken] = useState<string | null>(null);\n const [failure, setFailure] = useState<ReactNode>(null);\n const emailRef = useRef<HTMLInputElement>(null);\n const tagHintId = useId();\n\n const mounted = useRef(true);\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n // What the field shows is what is submitted: the offered code the value resolves to.\n const locale = resolveLanguage([localeProp ?? ownLocale], languages);\n const changeLocale = (code: KitLanguageCode) => {\n if (localeProp === undefined) setOwnLocale(code);\n onLocaleChange?.(code);\n };\n\n const working = busy !== null;\n const suggestion = emailTag ? taggedEmail(email, emailTag) : null;\n // The invitee took the tagged address: offer the way back to the invited one.\n const backToInvited =\n invitedEmail !== undefined && email.trim() !== invitedEmail.trim() ? invitedEmail.trim() : null;\n const canSubmit =\n personNameOk(firstName) &&\n personNameOk(lastName) &&\n emailParts(email) !== null &&\n newPasswordProblem(password, confirm) === null &&\n accepted &&\n appFieldsComplete;\n\n const submit = (e: FormEvent) => {\n e.preventDefault();\n if (!canSubmit || working) return;\n const values: RegisterValues = {\n firstName: firstName.trim(),\n lastName: lastName.trim(),\n email: email.trim(),\n password,\n locale,\n };\n setBusy(\"sending\");\n setFailure(null);\n setTaken(null);\n let result: Promise<unknown>;\n try {\n result = Promise.resolve(onSubmit(values));\n } catch (error) {\n result = Promise.reject(error);\n }\n result.then(\n () => {\n if (mounted.current) setBusy(\"done\");\n },\n (error: unknown) => {\n if (!mounted.current) return;\n setBusy(null);\n const own = describeError?.(error);\n const code = authErrorCode(error);\n if (code === \"email_taken\" && !hasMessage(own)) {\n setTaken(values.email);\n emailRef.current?.focus();\n return;\n }\n setFailure(\n hasMessage(own)\n ? own\n : isRateLimited(error)\n ? (labels.rateLimited ?? DEFAULT_REGISTER_LABELS.rateLimited)(retryAfterSeconds(error))\n : code === \"registration_closed\"\n ? labels.registrationClosed\n : code === \"invitation_invalid\"\n ? labels.invitationInvalid\n : code === \"invitation_expired\"\n ? labels.invitationExpired\n : labels.failed,\n );\n },\n );\n };\n\n return (\n <div {...rest} className={cn(\"@container space-y-4\", className)}>\n {aboveForm}\n <form className=\"space-y-3\" noValidate onSubmit={submit}>\n <div className=\"grid gap-3 @xs:grid-cols-2\">\n <Input\n name=\"given-name\"\n autoComplete=\"given-name\"\n label={labels.firstName}\n value={firstName}\n onChange={(e) => setFirstName(e.target.value)}\n maxLength={PERSON_NAME_MAX_LENGTH}\n readOnly={working}\n required\n />\n <Input\n name=\"family-name\"\n autoComplete=\"family-name\"\n label={labels.lastName}\n value={lastName}\n onChange={(e) => setLastName(e.target.value)}\n maxLength={PERSON_NAME_MAX_LENGTH}\n readOnly={working}\n required\n />\n </div>\n\n <div>\n <Input\n ref={emailRef}\n type=\"email\"\n name=\"email\"\n autoComplete=\"email\"\n label={labels.email}\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n readOnly={invited || working}\n hint={invited ? labels.invitedEmailHint : undefined}\n error={taken !== null && taken === email.trim() ? labels.emailTaken : undefined}\n required\n />\n {/* The tag, offered rather than applied (§4.5): one click, the exact address\n on the button, reversible by typing — or, invited, by the button back. */}\n {suggestion && (\n <p className=\"mt-1 text-xs text-[var(--text-muted)]\">\n <Button\n type=\"button\"\n variant=\"link\"\n size=\"sm\"\n className=\"h-auto p-0 align-baseline\"\n disabled={working}\n aria-describedby={tagHintId}\n onClick={() => setEmail(suggestion)}\n >\n {labels.emailTagUse(suggestion)}\n </Button>{\" \"}\n <span id={tagHintId}>{labels.emailTagHint}</span>\n </p>\n )}\n {backToInvited !== null && (\n <p className=\"mt-1 text-xs text-[var(--text-muted)]\">\n <span>{labels.invitedTagNote}</span>{\" \"}\n <Button\n type=\"button\"\n variant=\"link\"\n size=\"sm\"\n className=\"h-auto p-0 align-baseline\"\n disabled={working}\n onClick={() => setEmail(backToInvited)}\n >\n {labels.emailTagUse(backToInvited)}\n </Button>\n </p>\n )}\n {underEmail}\n </div>\n\n <NewPasswordFields\n password={password}\n confirm={confirm}\n onPasswordChange={setPassword}\n onConfirmChange={setConfirm}\n busy={working}\n text={{ password: labels.password, confirm: labels.confirmPassword, mismatch: labels.passwordMismatch }}\n />\n\n {languageField === undefined ? (\n <LanguageSelect\n name=\"locale\"\n label={labels.language}\n value={locale}\n codes={languages}\n onChange={changeLocale}\n disabled={working}\n />\n ) : (\n languageField\n )}\n\n {appFields}\n {afterFields}\n\n {/* The kit's terms checkbox (legal contract §3.4). Never an error: an unticked\n box keeps the submit disabled, so there is no failed attempt to explain. */}\n <LegalAcceptCheckbox\n checked={accepted}\n onCheckedChange={setAccepted}\n termsHref={termsHref}\n privacyHref={privacyHref}\n disabled={working}\n />\n\n {hasMessage(failure) && (\n <AlertBanner tone=\"danger\" size=\"sm\" role=\"alert\">\n {failure}\n </AlertBanner>\n )}\n\n <Button type=\"submit\" className=\"w-full\" disabled={!working && !canSubmit} pending={working}>\n {labels.submit}\n </Button>\n </form>\n {signInHref !== undefined && (\n <p className=\"text-center text-sm text-[var(--text-secondary)]\">\n {labels.haveAccount}{\" \"}\n <TextLink href={signInHref} tone=\"primary\">\n {labels.signIn}\n </TextLink>\n </p>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -33,8 +33,14 @@ interface ResetPasswordLabels {
33
33
  sessionsEnded: string;
34
34
  /** The button out — to sign-in, with the address filled in. */
35
35
  signIn: string;
36
+ /** 0.30.0: the save was throttled — HTTP 429 ({@link isRateLimited}) — given the
37
+ * `Retry-After` wait in seconds, or `undefined` without one. OPTIONAL, like every key
38
+ * added to a shipped interface; the provider's `resetPassword`, then English, fill
39
+ * it. */
40
+ rateLimited?: (seconds?: number) => string;
36
41
  }
37
- declare const DEFAULT_RESET_PASSWORD_LABELS: ResetPasswordLabels;
42
+ /** `Required`: every key, the 0.30 one included, has its English here. */
43
+ declare const DEFAULT_RESET_PASSWORD_LABELS: Required<ResetPasswordLabels>;
38
44
  /** What `onCheck` may resolve with — keksdose's `/password-reset/check` answer. */
39
45
  interface ResetPasswordCheck {
40
46
  /** The account's address: named over the fields ("You're setting a new password for
@@ -86,8 +92,9 @@ interface ResetPasswordFormProps extends Omit<ComponentPropsWithoutRef<"div">, "
86
92
  /** "Request a new link" — the app's "Forgot password" page. Default
87
93
  * `/forgot-password`. */
88
94
  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. */
95
+ /** The app's words for a failed save (the device offline), or `undefined` for the
96
+ * kit's: `rateLimited` for a bare `429` (0.30.0), else `failed`. Not asked for
97
+ * `token_invalid`, which is a state. */
91
98
  describeError?: (error: unknown) => ReactNode | undefined;
92
99
  /** The heading. Default `labels.title`; `null` draws none, for a page that puts it
93
100
  * in `AuthLayout`'s `title` instead. */
@@ -7,7 +7,7 @@ import { useKitLabels } from "../i18n/kit-labels.js";
7
7
  import { AlertBanner } from "../components/alert-banner.js";
8
8
  import { LoadingState } from "../components/loading-state.js";
9
9
  import { Button, EmptyState } from "../components/ui.js";
10
- import { authErrorCode } from "./auth-errors.js";
10
+ import { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from "./auth-errors.js";
11
11
  import { newPasswordProblem } from "./form-rules.js";
12
12
  import { NewPasswordFields } from "./new-password-fields.js";
13
13
  import {
@@ -34,7 +34,8 @@ const DEFAULT_RESET_PASSWORD_LABELS = {
34
34
  requestNew: "Request a new link",
35
35
  success: "Your password has been changed. You can sign in with it now.",
36
36
  sessionsEnded: "Signed-in devices were signed out \u2014 you\u2019ll need to sign in again there.",
37
- signIn: "Go to sign in"
37
+ signIn: "Go to sign in",
38
+ rateLimited: englishRateLimited
38
39
  };
39
40
  function ResetPasswordForm({
40
41
  token,
@@ -129,7 +130,9 @@ function ResetPasswordForm({
129
130
  setPhase({ token, phase: { kind: "invalid" } });
130
131
  return;
131
132
  }
132
- setFailure(describeError?.(error) ?? labels.failed);
133
+ setFailure(
134
+ describeError?.(error) ?? (isRateLimited(error) ? (labels.rateLimited ?? DEFAULT_RESET_PASSWORD_LABELS.rateLimited)(retryAfterSeconds(error)) : labels.failed)
135
+ );
133
136
  }
134
137
  );
135
138
  };
@@ -1 +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"]}
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, englishRateLimited, isRateLimited, retryAfterSeconds } 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 /** 0.30.0: the save was throttled — HTTP 429 ({@link isRateLimited}) — given the\n * `Retry-After` wait in seconds, or `undefined` without one. OPTIONAL, like every key\n * added to a shipped interface; the provider's `resetPassword`, then English, fill\n * it. */\n rateLimited?: (seconds?: number) => string;\n}\n\n/** `Required`: every key, the 0.30 one included, has its English here. */\nexport const DEFAULT_RESET_PASSWORD_LABELS: Required<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 rateLimited: englishRateLimited,\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 (the device offline), or `undefined` for the\n * kit's: `rateLimited` for a bare `429` (0.30.0), else `failed`. Not asked for\n * `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 // The app's words first, then the throttle's, then the kit's catch-all.\n setFailure(\n describeError?.(error) ??\n (isRateLimited(error)\n ? (labels.rateLimited ?? DEFAULT_RESET_PASSWORD_LABELS.rateLimited)(retryAfterSeconds(error))\n : labels.failed),\n );\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"]}
@@ -53,8 +53,17 @@ interface SignInCredentials {
53
53
  }
54
54
  interface SignInCodeValues {
55
55
  challengeToken: string;
56
- /** Digits only — {@link OneTimeCodeInput} drops the spaces of "123 456". */
56
+ /** Digits only — {@link OneTimeCodeInput} drops the spaces of "123 456". A backup code
57
+ * (`kind: "recovery"`) is trimmed and otherwise as typed: its dashes and case are the
58
+ * server's to normalise, as kastlan's `_normalise_backup_code` does. */
57
59
  code: string;
60
+ /**
61
+ * 0.30.0: `"recovery"` when the person typed one of their backup codes instead
62
+ * (`recoveryCode`); absent for the authenticator's code, as before. kastlan's
63
+ * `/auth/login/2fa` takes either in the same `code` and tells them apart itself; an
64
+ * app whose server takes a backup code elsewhere routes on this.
65
+ */
66
+ kind?: "recovery";
58
67
  }
59
68
  interface SignInNewPasswordValues {
60
69
  challengeToken: string;
@@ -117,8 +126,25 @@ interface SignInLabels {
117
126
  expired: string;
118
127
  /** From the second and third step back to the credentials. */
119
128
  backToSignIn: string;
129
+ /** The second-factor step's switch to a backup code (`recoveryCode`). */
130
+ useRecoveryCode?: string;
131
+ /** The switch back, from the backup-code field to the authenticator's code. */
132
+ useAuthenticatorCode?: string;
133
+ /** The backup-code field's label. */
134
+ recoveryCode?: string;
135
+ /** The step's intro while a backup code is asked for, in place of `twoFactorIntro`. */
136
+ recoveryIntro?: string;
137
+ /** Under the backup-code field, given how many letters and digits a code has. */
138
+ recoveryCodeHint?: (length: number) => string;
139
+ /** A refused backup code — wrong, or spent already (each one works once). */
140
+ recoveryCodeInvalid?: string;
141
+ /** Any step's request was throttled — HTTP 429 ({@link isRateLimited}) — given the
142
+ * wait its `Retry-After` asks for, in seconds ({@link retryAfterSeconds}), or
143
+ * `undefined` (or `0`) without one. */
144
+ rateLimited?: (seconds?: number) => string;
120
145
  }
121
- declare const DEFAULT_SIGN_IN_LABELS: SignInLabels;
146
+ /** `Required`: every key, the 0.30 ones included, has its English here. */
147
+ declare const DEFAULT_SIGN_IN_LABELS: Required<SignInLabels>;
122
148
  interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "onSubmit" | "children"> {
123
149
  /**
124
150
  * The credentials step was submitted. Send them, and resolve with the answer
@@ -176,10 +202,28 @@ interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "onSubmi
176
202
  deactivatedContact?: ReactNode;
177
203
  /** Digits of the second factor. Default 6 (TOTP). */
178
204
  codeLength?: number;
205
+ /**
206
+ * 0.30.0: offer a backup code on the second-factor step (kastlan's 0.29 adoption: its
207
+ * `/auth/login/2fa` takes one of the eight codes minted at 2FA setup, and the kit's
208
+ * step had no way to type one). A "Use a backup code" switch under the code field
209
+ * swaps the digits-only {@link OneTimeCodeInput} for a plain text field; the code goes
210
+ * to `onCode` with the same challenge and `kind: "recovery"`.
211
+ *
212
+ * `true` for a 16-character code (kastlan's `ABCD-EFGH-JKLM-NPQR`), or `{ length }`.
213
+ * The length counts letters and digits only — the dashes and spaces a printed code is
214
+ * grouped with don't count — and "Verify" waits for it. Left out, no switch.
215
+ */
216
+ recoveryCode?: boolean | {
217
+ length?: number;
218
+ };
179
219
  /** App content at the top of the credentials step — a notice, a demo button. */
180
220
  credentialsContent?: ReactNode;
181
- /** App content on the second-factor step, under its intro. */
182
- twoFactorContent?: ReactNode;
221
+ /**
222
+ * App content on the second-factor step, under its intro. 0.30.0: pass a function and
223
+ * it gets the challenge token — kastlan kept its own copy of the token for a "lost
224
+ * your device?" link, which the step now hands over.
225
+ */
226
+ twoFactorContent?: ReactNode | ((challengeToken: string) => ReactNode);
183
227
  /**
184
228
  * App content on the set-a-new-password step, under its intro — keksdose's
185
229
  * private-mode note ("Your encrypted data is untouched …"), which depends on the
@@ -187,9 +231,10 @@ interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "onSubmi
187
231
  */
188
232
  passwordChangeContent?: ReactNode | ((extra: Readonly<Record<string, unknown>> | undefined) => ReactNode);
189
233
  /**
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.
234
+ * The app's own words for a failure, or `undefined` for the kit's: the device being
235
+ * offline, a throttle with its wait ("try again in an hour"). Asked first, for every
236
+ * failure; what it returns replaces the message, and the `invalid_credentials` hints
237
+ * still follow. A bare `429` needs none since 0.30.0: the kit says `rateLimited`.
193
238
  */
194
239
  describeError?: (error: unknown, action: SignInAction) => ReactNode | undefined;
195
240
  /** The step changed — retitle the page (`AuthLayout`'s `title`) if the app wants. */
@@ -230,7 +275,14 @@ interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "onSubmi
230
275
  * whole code. A refused code stays here with its error; `token_invalid` (the
231
276
  * challenge expired) goes back to the credentials and says why. Kurvenschmiede has
232
277
  * no 2FA yet, and the step is here from the start, so adding it is backend work only
233
- * (§10.5).
278
+ * (§10.5). With `recoveryCode` (0.30.0) a switch under the field takes a backup code
279
+ * instead, through the same `onCode` with `kind: "recovery"`.
280
+ *
281
+ * **A throttled request** (HTTP 429, {@link isRateLimited}) says "Too many attempts. Try
282
+ * again in 30 s." on any step (0.30.0) — the wait from its `Retry-After`, else "Wait a
283
+ * moment and try again." — after `describeError`, whose words still come first, and
284
+ * instead of "wrong password" or "invalid code", which would send the person to try
285
+ * another one.
234
286
  * - New password: new + confirm with the strength meter, the 8-character / 72-byte
235
287
  * rules and nothing else (§8). It reads as explained, not blocked (keksdose): the
236
288
  * person did nothing wrong.
@@ -238,6 +290,6 @@ interface SignInFormProps extends Omit<ComponentPropsWithoutRef<"div">, "onSubmi
238
290
  * The password is dropped from state once the credentials are answered; "Back to sign in"
239
291
  * returns to an empty password field.
240
292
  */
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;
293
+ declare function SignInForm({ onSubmit, onCode, onSetPassword, onPasskey, passkeyAutofill, forgotHref, registerHref, defaultEmail, emailTag, deactivatedContact, codeLength, recoveryCode, credentialsContent, twoFactorContent, passwordChangeContent, describeError, onStepChange, headingAs: Heading, labels: labelsProp, className, ...rest }: SignInFormProps): react.JSX.Element;
242
294
 
243
295
  export { DEFAULT_SIGN_IN_LABELS, type SignInAction, type SignInAnswer, type SignInCodeValues, type SignInCredentials, SignInForm, type SignInFormProps, type SignInLabels, type SignInNewPasswordValues, type SignInStep };