@eifi1/ui-kit 0.28.1 → 0.29.0

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