@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
@@ -30,16 +30,59 @@ export type AuthErrorCode =
30
30
  | "invitation_invalid"
31
31
  | "invitation_expired"
32
32
  | "token_invalid"
33
+ /** 0.29.1: a link that WAS valid and ran out (verification, reset) — keksdose's step-3
34
+ * refusals tell it from `token_invalid`, so a page can say "expired, ask for a new one". */
35
+ | "token_expired"
33
36
  | "account_inactive";
34
37
 
35
- const CODES: ReadonlySet<string> = new Set<AuthErrorCode>([
38
+ /**
39
+ * 0.30.0: the coded refusals of user administration and the account's own settings
40
+ * (docs/user-admin-harmonization.md §4, §6) — server-kit 0.4.0's `AccountErrorCode`,
41
+ * value for value. The same family of answer as the sign-in codes (`{detail, code}`,
42
+ * plus `companies` on kastlan's `last_admin`), read by the same {@link authErrorCode}.
43
+ *
44
+ * - `last_admin` — the action would leave no active admin: deactivating, demoting or
45
+ * deleting the last one (in kastlan, of a company; the answer names the `companies`).
46
+ * - `self_action` — an admin acting on their own account where that is never allowed.
47
+ * - `other_companies` — kastlan: the account belongs to other companies too, so a
48
+ * company admin may not deactivate it; "Remove from company" instead (§9.6).
49
+ * - `household_has_members` — keksdose's guard on a deletion request (§6.4).
50
+ * - `confirmation_required`, `confirmation_mismatch` — the action's confirmation level
51
+ * asked for a typed address (or a tick), and the request carried none, or another
52
+ * address (§4.2).
53
+ * - `password_incorrect` — the CURRENT password is wrong on a signed-in route (the email
54
+ * change, the deletion request). `400`, not `401`: an app's client reads a 401 as an
55
+ * ended session and signs the user out, the wrong answer to a typo. Every other one
56
+ * is a `409`.
57
+ */
58
+ export type AccountErrorCode =
59
+ | "last_admin"
60
+ | "self_action"
61
+ | "other_companies"
62
+ | "household_has_members"
63
+ | "confirmation_required"
64
+ | "confirmation_mismatch"
65
+ | "password_incorrect";
66
+
67
+ /** Every code the kit reads: the sign-in refusals and the account ones (0.30.0). */
68
+ export type KitErrorCode = AuthErrorCode | AccountErrorCode;
69
+
70
+ const CODES: ReadonlySet<string> = new Set<KitErrorCode>([
36
71
  "invalid_credentials",
37
72
  "registration_closed",
38
73
  "email_taken",
39
74
  "invitation_invalid",
40
75
  "invitation_expired",
41
76
  "token_invalid",
77
+ "token_expired",
42
78
  "account_inactive",
79
+ "last_admin",
80
+ "self_action",
81
+ "other_companies",
82
+ "household_has_members",
83
+ "confirmation_required",
84
+ "confirmation_mismatch",
85
+ "password_incorrect",
43
86
  ]);
44
87
 
45
88
  type Bag = Record<string, unknown>;
@@ -47,19 +90,20 @@ type Bag = Record<string, unknown>;
47
90
  const isBag = (value: unknown): value is Bag => typeof value === "object" && value !== null;
48
91
 
49
92
  /** A known code, or undefined. */
50
- function known(value: unknown): AuthErrorCode | undefined {
51
- return typeof value === "string" && CODES.has(value) ? (value as AuthErrorCode) : undefined;
93
+ function known(value: unknown): KitErrorCode | undefined {
94
+ return typeof value === "string" && CODES.has(value) ? (value as KitErrorCode) : undefined;
52
95
  }
53
96
 
54
97
  /** `{code}` on a parsed body, or FastAPI's `{detail: {code}}` — what an
55
98
  * `HTTPException(detail={"code": …})` serialises to. */
56
- function codeOfBody(body: unknown): AuthErrorCode | undefined {
99
+ function codeOfBody(body: unknown): KitErrorCode | undefined {
57
100
  if (!isBag(body)) return undefined;
58
101
  return known(body.code) ?? (isBag(body.detail) ? known(body.detail.code) : undefined);
59
102
  }
60
103
 
61
104
  /**
62
- * The {@link AuthErrorCode} an error carries, or `undefined` when it carries none.
105
+ * The code an error carries — an {@link AuthErrorCode}, or since 0.30.0 an
106
+ * {@link AccountErrorCode} — or `undefined` when it carries none.
63
107
  *
64
108
  * Reads the parsed response body wherever the apps' HTTP clients put it, without
65
109
  * depending on any of them:
@@ -76,7 +120,7 @@ function codeOfBody(body: unknown): AuthErrorCode | undefined {
76
120
  * read as a refusal. The HTTP status is not checked: the code is the contract, and the
77
121
  * status differs per refusal (401, 403, 409, 410).
78
122
  */
79
- export function authErrorCode(err: unknown): AuthErrorCode | undefined {
123
+ export function authErrorCode(err: unknown): KitErrorCode | undefined {
80
124
  if (!isBag(err)) return undefined;
81
125
  const response = err.response;
82
126
  return (
@@ -97,9 +141,122 @@ export function authErrorCode(err: unknown): AuthErrorCode | undefined {
97
141
  *
98
142
  * The kit's `SignInForm` and `RegisterForm` call it on what their callbacks reject
99
143
  * with, so an app's `onSubmit` can simply let its client's error through. See
100
- * {@link authErrorCode} for the shapes it reads.
144
+ * {@link authErrorCode} for the shapes it reads. Since 0.30.0 it knows the account codes
145
+ * too: `isAuthError(err, "last_admin")`.
101
146
  */
102
- export function isAuthError(err: unknown, code?: AuthErrorCode): boolean {
147
+ export function isAuthError(err: unknown, code?: KitErrorCode): boolean {
103
148
  const found = authErrorCode(err);
104
149
  return found !== undefined && (code === undefined || found === code);
105
150
  }
151
+
152
+ /** An HTTP status as a number, or undefined. */
153
+ function statusOf(value: unknown): number | undefined {
154
+ return typeof value === "number" && Number.isInteger(value) ? value : undefined;
155
+ }
156
+
157
+ /**
158
+ * Whether `err` is a throttled request — HTTP `429 Too Many Requests` (0.30.0, from the
159
+ * apps' 0.29 adoption: every app answered a throttled sign-in, sign-up or reset through
160
+ * its own `describeError`, with three wordings of one sentence).
161
+ *
162
+ * A throttle carries no `code` in the contract — the STATUS is the answer, set by the
163
+ * apps' limiters (server-kit's `AuthLimiters`, keksdose's per-IP limit) — so this reads
164
+ * the status, wherever the apps' HTTP clients put it, without depending on any of them:
165
+ *
166
+ * 1. `err.response.status` — axios (all three apps' clients);
167
+ * 2. `err.status` — ofetch's `FetchError`, the generated OpenAPI clients' `ApiError`, and
168
+ * a `Response` thrown as it came;
169
+ * 3. `err.statusCode` — ofetch's alias, and Node-style errors.
170
+ *
171
+ * There is no `rate_limited` code: keksdose answers its 429s uncoded, with a
172
+ * `Retry-After` header, and the status is the one signal every limiter gives.
173
+ *
174
+ * The kit's `SignInForm`, `RegisterForm`, `ForgotPasswordForm` and `ResetPasswordForm`
175
+ * call it AFTER the app's `describeError` — an app with words of its own keeps them —
176
+ * and before their generic failure, so "Too many attempts. Try again in 30 s." (with
177
+ * {@link retryAfterSeconds}) or "… Wait a moment and try again." needs no app code.
178
+ */
179
+ export function isRateLimited(err: unknown): boolean {
180
+ if (!isBag(err)) return false;
181
+ const response = err.response;
182
+ const status =
183
+ (isBag(response) ? statusOf(response.status) : undefined) ?? statusOf(err.status) ?? statusOf(err.statusCode);
184
+ return status === 429;
185
+ }
186
+
187
+ /** A header's value from a `Headers` (fetch, ofetch's `response`), axios' `AxiosHeaders`
188
+ * or a plain record — whose keys may come in any case. */
189
+ function headerOf(headers: unknown, name: string): unknown {
190
+ if (!isBag(headers)) return undefined;
191
+ if (typeof headers.get === "function") {
192
+ try {
193
+ const value: unknown = (headers.get as (key: string) => unknown).call(headers, name);
194
+ if (value !== undefined && value !== null) return value;
195
+ } catch {
196
+ // A `get` that is not a header lookup; the record below is.
197
+ }
198
+ }
199
+ for (const [key, value] of Object.entries(headers)) {
200
+ if (key.toLowerCase() === name) return value;
201
+ }
202
+ return undefined;
203
+ }
204
+
205
+ /** `Retry-After` as seconds from `now`: delta-seconds ("30"), or an HTTP date
206
+ * ("Wed, 07 Oct 2026 10:00:00 GMT") counted from now, never below 0 (RFC 9110 §10.2.3). */
207
+ function parseRetryAfter(value: unknown, now: number): number | undefined {
208
+ const raw = Array.isArray(value) ? (value as unknown[])[0] : value;
209
+ if (typeof raw === "number") return Number.isFinite(raw) && raw >= 0 ? Math.ceil(raw) : undefined;
210
+ if (typeof raw !== "string") return undefined;
211
+ const text = raw.trim();
212
+ if (/^\d+$/.test(text)) return Number(text);
213
+ // An HTTP date names its day and month ("Wed, 07 Oct 2026 …"). Without a letter it is
214
+ // a malformed number ("-5", "1.5"), which `Date.parse` would happily read as a year.
215
+ if (!/[a-z]/i.test(text)) return undefined;
216
+ const at = Date.parse(text);
217
+ if (Number.isNaN(at)) return undefined;
218
+ return Math.max(0, Math.ceil((at - now) / 1000));
219
+ }
220
+
221
+ /**
222
+ * How long a throttled request asks to wait, in whole seconds — its `Retry-After`
223
+ * header (0.30.0, keksdose: its 429s carry no code, only the header) — or `undefined`
224
+ * when there is none, or none that parses.
225
+ *
226
+ * Read where the apps' clients keep the response headers, without depending on any of
227
+ * them: `err.response.headers` (axios' `AxiosHeaders` or a plain record; ofetch's
228
+ * `FetchError.response`, a fetch `Headers`), then `err.headers` (a `Response` thrown as
229
+ * it came). The value is delta-seconds, or an HTTP date turned into seconds from `now`
230
+ * (default: the present moment) — never below 0.
231
+ *
232
+ * The forms hand it to their `rateLimited` label when {@link isRateLimited} is true:
233
+ * "Too many attempts. Try again in 30 s." — and, without one, "… Wait a moment and try
234
+ * again." A browser only exposes `Retry-After` to a cross-origin page when the server
235
+ * lists it in `Access-Control-Expose-Headers`.
236
+ */
237
+ export function retryAfterSeconds(err: unknown, now: number | Date = Date.now()): number | undefined {
238
+ if (!isBag(err)) return undefined;
239
+ const at = typeof now === "number" ? now : now.getTime();
240
+ const response = err.response;
241
+ const value =
242
+ (isBag(response) ? headerOf(response.headers, "retry-after") : undefined) ?? headerOf(err.headers, "retry-after");
243
+ return value === undefined ? undefined : parseRetryAfter(value, at);
244
+ }
245
+
246
+ /**
247
+ * The English of every `rateLimited` label (0.30.0): "Too many attempts. Try again in
248
+ * 30 s." with a wait under a minute, in whole minutes from one minute up (keksdose's
249
+ * reset throttle answers in hours, and "3600 s" is a number nobody reads), and "Wait a
250
+ * moment and try again." without one — or with a `0`.
251
+ *
252
+ * @internal The namespaces' English; not part of the barrel.
253
+ */
254
+ export function englishRateLimited(seconds?: number): string {
255
+ if (!seconds || seconds <= 0) return "Too many attempts. Wait a moment and try again.";
256
+ return `Too many attempts. Try again in ${englishWait(seconds)}.`;
257
+ }
258
+
259
+ /** "30 s", "2 min" — a wait in English, rounded up. @internal */
260
+ export function englishWait(seconds: number): string {
261
+ return seconds < 60 ? `${Math.ceil(seconds)} s` : `${Math.ceil(seconds / 60)} min`;
262
+ }
@@ -6,6 +6,7 @@ import { useKitLabels } from "../i18n/kit-labels";
6
6
  import { AlertBanner } from "../components/alert-banner";
7
7
  import { TextLink } from "../components/text-link";
8
8
  import { Button, Input } from "../components/ui";
9
+ import { englishRateLimited, isRateLimited, retryAfterSeconds } from "./auth-errors";
9
10
  import { FORM_HEADING_CLASS, INTRO_CLASS, OutcomeMessage, isThenable, useFocusWhen } from "./status-parts";
10
11
  import type { AuthHeadingLevel } from "./status-parts";
11
12
 
@@ -30,9 +31,15 @@ export interface ForgotPasswordLabels {
30
31
  /** A request that did not go through: a throttle, an outage. Never "unknown
31
32
  * address" — the endpoint does not say. */
32
33
  error: string;
34
+ /** 0.30.0: the request was throttled — HTTP 429 ({@link isRateLimited}) — given the
35
+ * `Retry-After` wait in seconds, or `undefined` without one. OPTIONAL, like every key
36
+ * added to a shipped interface; the provider's `forgotPassword`, then English, fill
37
+ * it. */
38
+ rateLimited?: (seconds?: number) => string;
33
39
  }
34
40
 
35
- export const DEFAULT_FORGOT_PASSWORD_LABELS: ForgotPasswordLabels = {
41
+ /** `Required`: every key, the 0.30 one included, has its English here. */
42
+ export const DEFAULT_FORGOT_PASSWORD_LABELS: Required<ForgotPasswordLabels> = {
36
43
  title: "Forgot password",
37
44
  intro: "Enter your account’s email address. We’ll send you a link to choose a new password.",
38
45
  email: "Email",
@@ -41,6 +48,7 @@ export const DEFAULT_FORGOT_PASSWORD_LABELS: ForgotPasswordLabels = {
41
48
  sentHint: "The link is valid for one hour and works exactly once. Check your spam folder too.",
42
49
  backToSignIn: "Back to sign in",
43
50
  error: "The request failed. Please try again later.",
51
+ rateLimited: englishRateLimited,
44
52
  };
45
53
 
46
54
  /* ── The form ────────────────────────────────────────────────────────────── */
@@ -61,8 +69,9 @@ export interface ForgotPasswordFormProps
61
69
  * whose password just failed need not type it twice. */
62
70
  defaultEmail?: string;
63
71
  /**
64
- * The app's words for a failed request (a `429`: "Too many requests — try again in
65
- * an hour"), or `undefined` for the kit's `error`. The same hook as `SignInForm`'s.
72
+ * The app's words for a failed request ("Too many requests — try again in an hour"),
73
+ * or `undefined` for the kit's: `rateLimited` for a bare `429` (0.30.0), else `error`.
74
+ * The same hook as `SignInForm`'s.
66
75
  */
67
76
  describeError?: (error: unknown) => ReactNode | undefined;
68
77
  /** App content under the intro — a notice, a support address. */
@@ -146,6 +155,13 @@ export function ForgotPasswordForm({
146
155
  );
147
156
  }
148
157
 
158
+ // The app's words first, then the throttle's, then the kit's catch-all.
159
+ const failureFor = (error: unknown): ReactNode =>
160
+ describeError?.(error) ??
161
+ (isRateLimited(error)
162
+ ? (labels.rateLimited ?? DEFAULT_FORGOT_PASSWORD_LABELS.rateLimited)(retryAfterSeconds(error))
163
+ : labels.error);
164
+
149
165
  const submit = (e: FormEvent) => {
150
166
  e.preventDefault();
151
167
  const address = email.trim();
@@ -155,7 +171,7 @@ export function ForgotPasswordForm({
155
171
  try {
156
172
  result = onSubmit(address);
157
173
  } catch (error) {
158
- setFailure(describeError?.(error) ?? labels.error);
174
+ setFailure(failureFor(error));
159
175
  return;
160
176
  }
161
177
  if (!isThenable(result)) {
@@ -170,7 +186,7 @@ export function ForgotPasswordForm({
170
186
  },
171
187
  (error: unknown) => {
172
188
  setPending(false);
173
- setFailure(describeError?.(error) ?? labels.error);
189
+ setFailure(failureFor(error));
174
190
  },
175
191
  );
176
192
  };
@@ -11,7 +11,7 @@ import { LanguageSelect } from "../components/language-select";
11
11
  import { LegalAcceptCheckbox } from "../components/legal-page";
12
12
  import { TextLink } from "../components/text-link";
13
13
  import { Button, Input } from "../components/ui";
14
- import { authErrorCode } from "./auth-errors";
14
+ import { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from "./auth-errors";
15
15
  import { emailParts, taggedEmail } from "./email-tag";
16
16
  import { newPasswordProblem, personNameOk, PERSON_NAME_MAX_LENGTH } from "./form-rules";
17
17
  import { NewPasswordFields } from "./new-password-fields";
@@ -71,9 +71,15 @@ export interface RegisterLabels {
71
71
  invitationExpired: string;
72
72
  /** Any other failure. */
73
73
  failed: string;
74
+ /** 0.30.0: the request was throttled — HTTP 429 ({@link isRateLimited}) — given the
75
+ * `Retry-After` wait in seconds, or `undefined` without one. OPTIONAL, like every key
76
+ * added to a shipped interface (an annotated call site must keep compiling); the
77
+ * provider's `register`, then English, fill it. */
78
+ rateLimited?: (seconds?: number) => string;
74
79
  }
75
80
 
76
- export const DEFAULT_REGISTER_LABELS: RegisterLabels = {
81
+ /** `Required`: every key, the 0.30 one included, has its English here. */
82
+ export const DEFAULT_REGISTER_LABELS: Required<RegisterLabels> = {
77
83
  firstName: "First name",
78
84
  lastName: "Last name",
79
85
  email: "Email",
@@ -94,6 +100,7 @@ export const DEFAULT_REGISTER_LABELS: RegisterLabels = {
94
100
  invitationInvalid: "This invitation link is not valid.",
95
101
  invitationExpired: "This invitation has expired. Ask for a new one.",
96
102
  failed: "Registration failed. Please try again.",
103
+ rateLimited: englishRateLimited,
97
104
  };
98
105
 
99
106
  export interface RegisterFormProps extends Omit<ComponentPropsWithoutRef<"div">, "onSubmit" | "children"> {
@@ -119,6 +126,13 @@ export interface RegisterFormProps extends Omit<ComponentPropsWithoutRef<"div">,
119
126
  * it — the server accepts exactly that variant — and go back.
120
127
  */
121
128
  invitedEmail?: string;
129
+ /**
130
+ * 0.29.1: an address to start with that stays EDITABLE, with the `+tag` suggestion still
131
+ * offered — for a link anyone can build (keksdose's token-free `/register?email=…`
132
+ * beta link, kept while an app moves to invitations). Unlike `invitedEmail` it proves
133
+ * nothing, so it locks nothing. `invitedEmail` wins when both are given.
134
+ */
135
+ defaultEmail?: string;
122
136
  /**
123
137
  * The language field's value — controlled. Pass the app's UI language
124
138
  * (`i18n.resolvedLanguage`) with `onLocaleChange` switching it, and the field IS the
@@ -158,8 +172,9 @@ export interface RegisterFormProps extends Omit<ComponentPropsWithoutRef<"div">,
158
172
  privacyHref?: string;
159
173
  /** "Already have an account? Sign in" goes here — `/login`. Left out, no line. */
160
174
  signInHref?: string;
161
- /** The app's own words for a failure the kit cannot know (a throttled attempt), or
162
- * `undefined` for the kit's. Asked first, for every failure. */
175
+ /** The app's own words for a failure the kit cannot know (the device offline, a
176
+ * throttle with its wait), or `undefined` for the kit's. Asked first, for every
177
+ * failure; a bare `429` needs none since 0.30.0 — the kit says `rateLimited`. */
163
178
  describeError?: (error: unknown) => ReactNode | undefined;
164
179
  labels?: Partial<RegisterLabels>;
165
180
  }
@@ -211,6 +226,7 @@ export function RegisterForm({
211
226
  onSubmit,
212
227
  emailTag,
213
228
  invitedEmail,
229
+ defaultEmail,
214
230
  locale: localeProp,
215
231
  defaultLocale,
216
232
  onLocaleChange,
@@ -234,7 +250,7 @@ export function RegisterForm({
234
250
  const invited = invitedEmail !== undefined;
235
251
  const [firstName, setFirstName] = useState("");
236
252
  const [lastName, setLastName] = useState("");
237
- const [email, setEmail] = useState(invitedEmail ?? "");
253
+ const [email, setEmail] = useState(invitedEmail ?? defaultEmail ?? "");
238
254
  const [password, setPassword] = useState("");
239
255
  const [confirm, setConfirm] = useState("");
240
256
  const [ownLocale, setOwnLocale] = useState<string>(
@@ -312,7 +328,9 @@ export function RegisterForm({
312
328
  setFailure(
313
329
  hasMessage(own)
314
330
  ? own
315
- : code === "registration_closed"
331
+ : isRateLimited(error)
332
+ ? (labels.rateLimited ?? DEFAULT_REGISTER_LABELS.rateLimited)(retryAfterSeconds(error))
333
+ : code === "registration_closed"
316
334
  ? labels.registrationClosed
317
335
  : code === "invitation_invalid"
318
336
  ? labels.invitationInvalid
@@ -6,7 +6,7 @@ import { useKitLabels } from "../i18n/kit-labels";
6
6
  import { AlertBanner } from "../components/alert-banner";
7
7
  import { LoadingState } from "../components/loading-state";
8
8
  import { Button, EmptyState } from "../components/ui";
9
- import { authErrorCode } from "./auth-errors";
9
+ import { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from "./auth-errors";
10
10
  import { newPasswordProblem } from "./form-rules";
11
11
  import { NewPasswordFields } from "./new-password-fields";
12
12
  import {
@@ -53,9 +53,15 @@ export interface ResetPasswordLabels {
53
53
  sessionsEnded: string;
54
54
  /** The button out — to sign-in, with the address filled in. */
55
55
  signIn: string;
56
+ /** 0.30.0: the save was throttled — HTTP 429 ({@link isRateLimited}) — given the
57
+ * `Retry-After` wait in seconds, or `undefined` without one. OPTIONAL, like every key
58
+ * added to a shipped interface; the provider's `resetPassword`, then English, fill
59
+ * it. */
60
+ rateLimited?: (seconds?: number) => string;
56
61
  }
57
62
 
58
- export const DEFAULT_RESET_PASSWORD_LABELS: ResetPasswordLabels = {
63
+ /** `Required`: every key, the 0.30 one included, has its English here. */
64
+ export const DEFAULT_RESET_PASSWORD_LABELS: Required<ResetPasswordLabels> = {
59
65
  title: "Choose a new password",
60
66
  checking: "Checking the link…",
61
67
  intro: (email) => `You’re setting a new password for ${email}.`,
@@ -71,6 +77,7 @@ export const DEFAULT_RESET_PASSWORD_LABELS: ResetPasswordLabels = {
71
77
  success: "Your password has been changed. You can sign in with it now.",
72
78
  sessionsEnded: "Signed-in devices were signed out — you’ll need to sign in again there.",
73
79
  signIn: "Go to sign in",
80
+ rateLimited: englishRateLimited,
74
81
  };
75
82
 
76
83
  /* ── Types ───────────────────────────────────────────────────────────────── */
@@ -135,8 +142,9 @@ export interface ResetPasswordFormProps
135
142
  /** "Request a new link" — the app's "Forgot password" page. Default
136
143
  * `/forgot-password`. */
137
144
  forgotHref?: string;
138
- /** The app's words for a failed save (a `429`, the device offline), or `undefined`
139
- * for the kit's `failed`. Not asked for `token_invalid`, which is a state. */
145
+ /** The app's words for a failed save (the device offline), or `undefined` for the
146
+ * kit's: `rateLimited` for a bare `429` (0.30.0), else `failed`. Not asked for
147
+ * `token_invalid`, which is a state. */
140
148
  describeError?: (error: unknown) => ReactNode | undefined;
141
149
  /** The heading. Default `labels.title`; `null` draws none, for a page that puts it
142
150
  * in `AuthLayout`'s `title` instead. */
@@ -297,7 +305,13 @@ export function ResetPasswordForm({
297
305
  setPhase({ token, phase: { kind: "invalid" } });
298
306
  return;
299
307
  }
300
- setFailure(describeError?.(error) ?? labels.failed);
308
+ // The app's words first, then the throttle's, then the kit's catch-all.
309
+ setFailure(
310
+ describeError?.(error) ??
311
+ (isRateLimited(error)
312
+ ? (labels.rateLimited ?? DEFAULT_RESET_PASSWORD_LABELS.rateLimited)(retryAfterSeconds(error))
313
+ : labels.failed),
314
+ );
301
315
  },
302
316
  );
303
317
  };