@meith/web 0.23.1 → 0.23.3

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 (38) hide show
  1. package/app/(board)/[slug]/new/page.tsx +7 -5
  2. package/app/(board)/thread/[slug]/edit/page.tsx +7 -5
  3. package/app/(board)/thread/[slug]/page.tsx +19 -1
  4. package/app/(board)/thread/[slug]/reply/page.tsx +7 -5
  5. package/app/(board)/usercp/security/verify/page.tsx +3 -0
  6. package/app/admin/layout.tsx +4 -4
  7. package/package.json +48 -48
  8. package/src/components/account/credential-proof-form.tsx +14 -4
  9. package/src/components/account/two-factor-forms.tsx +4 -4
  10. package/src/components/admin/admin-forms.tsx +8 -13
  11. package/src/components/admin/badge-forms.tsx +6 -7
  12. package/src/components/admin/branding-forms.tsx +5 -10
  13. package/src/components/admin/form-bits.tsx +6 -4
  14. package/src/components/admin/forum-forms.tsx +180 -78
  15. package/src/components/admin/mail-test-card.tsx +2 -5
  16. package/src/components/admin/marketplace-forms.tsx +4 -5
  17. package/src/components/admin/marketplace-listing.tsx +1 -1
  18. package/src/components/admin/plugin-forms.tsx +2 -6
  19. package/src/components/admin/settings-form.tsx +2 -6
  20. package/src/components/admin/system-forms.tsx +2 -5
  21. package/src/components/auth/otp-field.tsx +142 -0
  22. package/src/components/auth/second-factor-form.tsx +4 -3
  23. package/src/components/content/composer-recovery.tsx +19 -1
  24. package/src/components/content/edit-post-form.tsx +3 -2
  25. package/src/components/content/markdown-editor.tsx +123 -104
  26. package/src/components/content/new-thread-form.tsx +3 -2
  27. package/src/components/content/reply-form.tsx +3 -2
  28. package/src/components/moderation/thread-surgery-form.tsx +51 -47
  29. package/src/components/moderation/thread-tools-form.tsx +67 -87
  30. package/src/server/marketplace-admin.ts +2 -2
  31. package/src/server/upgrade-notice.ts +1 -1
  32. package/src/theme/contract.fixture.ts +2 -2
  33. package/src/view/account-copy.ts +2 -0
  34. package/src/view/admin-copy.ts +9 -0
  35. package/src/view/admin-panel-copy.ts +2 -0
  36. package/src/view/auth-copy.ts +17 -10
  37. package/src/view/content-copy.ts +11 -6
  38. package/src/view/forum-display.ts +1 -0
@@ -3,6 +3,7 @@
3
3
  import { useActionState } from 'react'
4
4
 
5
5
  import type { MatrixCell, MatrixRow } from '@meith/authorization'
6
+ import { cn, Disclosure } from '@meith/ui'
6
7
 
7
8
  import { PANEL_CARD, PANEL_LIST, PANEL_ROW } from '@/components/shell/panel-list'
8
9
  import { EMPTY_STATE } from '@/server/auth-form-state'
@@ -18,7 +19,7 @@ import {
18
19
 
19
20
  import { FormError, SubmitButton } from '../auth/form-controls'
20
21
  import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
21
- import { INPUT } from './form-bits'
22
+ import { INPUT, Saved } from './form-bits'
22
23
 
23
24
  const TOGGLES = [
24
25
  { name: 'isOpen', labelKey: 'adminForum.toggle.isOpen' },
@@ -48,11 +49,7 @@ export function ForumOptionsForm({ forum, copy }: { forum: ForumOptionsValues; c
48
49
  return (
49
50
  <form action={action} className="flex flex-col gap-4" noValidate>
50
51
  <FormError message={state.error} />
51
- {state.notice === 'saved' && (
52
- <p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
53
- {fromCopy(copy, 'admin.saved')}
54
- </p>
55
- )}
52
+ <Saved when={state.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
56
53
  <input type="hidden" name="forumId" value={forum.id} />
57
54
 
58
55
  <label className="flex flex-col gap-1 text-sm">
@@ -153,38 +150,145 @@ function effectiveLabel(
153
150
  })
154
151
  }
155
152
 
156
- function CellControl({ cell, copy }: { cell: MatrixCell; copy: Copy }) {
157
- if (cell.kind === 'numeric') {
158
- return (
159
- <input
160
- type="number"
161
- name={cell.key}
162
- min={0}
163
- placeholder={fromCopy(copy, 'adminForum.perm.inherit')}
164
- defaultValue={cell.control}
165
- className={INPUT}
166
- />
167
- )
168
- }
153
+ const PERM_GROUPS: readonly { readonly labelKey: string; readonly fields: readonly string[] }[] = [
154
+ {
155
+ labelKey: 'adminForum.permGroup.viewing',
156
+ fields: ['canView', 'canViewThreads', 'canViewOthersThreads', 'canSearch'],
157
+ },
158
+ {
159
+ labelKey: 'adminForum.permGroup.posting',
160
+ fields: [
161
+ 'canPostThreads',
162
+ 'canPostReplies',
163
+ 'canPostPolls',
164
+ 'canVotePolls',
165
+ 'canRateThreads',
166
+ 'canSubscribe',
167
+ ],
168
+ },
169
+ {
170
+ labelKey: 'adminForum.permGroup.ownContent',
171
+ fields: ['canEditOwnPosts', 'canDeleteOwnPosts', 'canDeleteOwnThreads', 'editTimeLimitMinutes'],
172
+ },
173
+ {
174
+ labelKey: 'adminForum.permGroup.moderation',
175
+ fields: [
176
+ 'canEditOthersPosts',
177
+ 'canSoftDeletePosts',
178
+ 'canViewUnapproved',
179
+ 'canViewDeleted',
180
+ 'canApproveContent',
181
+ ],
182
+ },
183
+ {
184
+ labelKey: 'adminForum.permGroup.attachments',
185
+ fields: [
186
+ 'canUploadAttachments',
187
+ 'canDownloadAttachments',
188
+ 'maxAttachmentsPerPost',
189
+ 'maxAttachmentSizeKb',
190
+ ],
191
+ },
192
+ {
193
+ labelKey: 'adminForum.permGroup.approvals',
194
+ fields: ['requiresThreadApproval', 'requiresPostApproval', 'requiresApprovalOnEdit'],
195
+ },
196
+ ]
197
+
198
+ function firstSentence(text: string): string {
199
+ const at = text.indexOf('. ')
200
+ return at === -1 ? text : text.slice(0, at + 1)
201
+ }
202
+
203
+ function groupCells(
204
+ cells: readonly MatrixCell[],
205
+ ): readonly { readonly labelKey: string; readonly cells: readonly MatrixCell[] }[] {
206
+ const byKey = new Map(cells.map((cell) => [cell.key, cell]))
207
+ const taken = new Set<string>()
208
+ const sections = PERM_GROUPS.map((group) => {
209
+ const found = group.fields.flatMap((key) => {
210
+ const cell = byKey.get(key)
211
+ if (cell === undefined) return []
212
+ taken.add(key)
213
+ return [cell]
214
+ })
215
+ return { labelKey: group.labelKey, cells: found }
216
+ }).filter((section) => section.cells.length > 0)
217
+
218
+ const leftover = cells.filter((cell) => !taken.has(cell.key))
219
+ return leftover.length === 0
220
+ ? sections
221
+ : [...sections, { labelKey: 'adminForum.permGroup.other', cells: leftover }]
222
+ }
169
223
 
224
+ const SEGMENT =
225
+ 'flex-1 cursor-pointer px-2.5 py-1 text-center text-xs font-medium text-muted-foreground transition-colors hover:text-foreground has-[:checked]:font-semibold has-[:focus-visible]:outline-2 has-[:focus-visible]:-outline-offset-2 has-[:focus-visible]:outline-ring'
226
+
227
+ function TriState({ cell, copy }: { cell: MatrixCell; copy: Copy }) {
170
228
  const negative = cell.kind === 'negative'
229
+ const options = [
230
+ {
231
+ value: 'inherit',
232
+ label: fromCopy(copy, 'adminForum.perm.inherit'),
233
+ on: 'has-[:checked]:bg-secondary has-[:checked]:text-secondary-foreground',
234
+ },
235
+ {
236
+ value: 'grant',
237
+ label: negative
238
+ ? fromCopy(copy, 'adminForum.perm.requiredOption')
239
+ : fromCopy(copy, 'adminForum.perm.grant'),
240
+ on: 'has-[:checked]:bg-primary has-[:checked]:text-primary-foreground',
241
+ },
242
+ {
243
+ value: 'deny',
244
+ label: negative
245
+ ? fromCopy(copy, 'adminForum.perm.notRequiredOption')
246
+ : fromCopy(copy, 'adminForum.perm.deny'),
247
+ on: 'has-[:checked]:bg-destructive has-[:checked]:text-destructive-foreground',
248
+ },
249
+ ]
250
+
171
251
  return (
172
- <select name={cell.key} defaultValue={cell.control} className={INPUT}>
173
- <option value="inherit">{fromCopy(copy, 'adminForum.perm.inherit')}</option>
174
- <option value="grant">
175
- {negative
176
- ? fromCopy(copy, 'adminForum.perm.requiredOption')
177
- : fromCopy(copy, 'adminForum.perm.grant')}
178
- </option>
179
- <option value="deny">
180
- {negative
181
- ? fromCopy(copy, 'adminForum.perm.notRequiredOption')
182
- : fromCopy(copy, 'adminForum.perm.deny')}
183
- </option>
184
- </select>
252
+ <fieldset className="flex w-full shrink-0 divide-x divide-border overflow-hidden rounded-md border border-border sm:w-56">
253
+ <legend className="sr-only">{firstSentence(cell.description)}</legend>
254
+ {options.map((option) => (
255
+ <label key={option.value} className={cn(SEGMENT, option.on)}>
256
+ <input
257
+ type="radio"
258
+ name={cell.key}
259
+ value={option.value}
260
+ defaultChecked={cell.control === option.value}
261
+ className="sr-only"
262
+ />
263
+ {option.label}
264
+ </label>
265
+ ))}
266
+ </fieldset>
185
267
  )
186
268
  }
187
269
 
270
+ function CellControl({ cell, copy }: { cell: MatrixCell; copy: Copy }) {
271
+ if (cell.kind !== 'numeric') return <TriState cell={cell} copy={copy} />
272
+
273
+ return (
274
+ <input
275
+ type="number"
276
+ name={cell.key}
277
+ min={0}
278
+ placeholder={fromCopy(copy, 'adminForum.perm.inherit')}
279
+ defaultValue={cell.control}
280
+ className={cn(INPUT, 'w-full shrink-0 sm:w-56')}
281
+ />
282
+ )
283
+ }
284
+
285
+ function overridesAside(row: MatrixRow, copy: Copy): string {
286
+ const count = row.cells.filter((cell) => cell.stored !== null).length
287
+ return count === 0
288
+ ? fromCopy(copy, 'adminForum.inheritsAll')
289
+ : formatFromCopy(copy, 'adminForum.overridesSet', { count })
290
+ }
291
+
188
292
  export function ForumPermissionRowForm({
189
293
  forumId,
190
294
  row,
@@ -199,36 +303,48 @@ export function ForumPermissionRowForm({
199
303
  const [state, action] = useActionState(saveForumPermissionsAction, EMPTY_STATE)
200
304
 
201
305
  return (
202
- <form action={action} className={PANEL_CARD}>
203
- <FormError message={state.error} />
204
- <div className="flex items-center justify-between gap-3">
205
- <h3 className="text-base font-semibold tracking-tight">{row.groupTitle}</h3>
206
- {state.notice === 'saved' && (
207
- <span className="text-xs text-muted-foreground">{fromCopy(copy, 'admin.saved')}</span>
208
- )}
209
- </div>
210
-
211
- <input type="hidden" name="forumId" value={forumId} />
212
- <input type="hidden" name="groupId" value={row.groupId} />
213
-
214
- <div className="grid gap-3 sm:grid-cols-2">
215
- {row.cells.map((cell) => (
216
- <label key={cell.key} className="flex flex-col gap-1 text-sm">
217
- <span className="font-medium">{cell.description}</span>
218
- <CellControl cell={cell} copy={copy} />
219
- <span className="text-xs text-muted-foreground">
220
- {effectiveLabel(cell, forumTitles, copy)}
221
- </span>
222
- </label>
306
+ <Disclosure summary={row.groupTitle} aside={overridesAside(row, copy)}>
307
+ <form action={action} className="flex flex-col gap-5">
308
+ <FormError message={state.error} />
309
+ <input type="hidden" name="forumId" value={forumId} />
310
+ <input type="hidden" name="groupId" value={row.groupId} />
311
+
312
+ {groupCells(row.cells).map((section) => (
313
+ <section key={section.labelKey} className="flex flex-col gap-2.5">
314
+ <h4 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
315
+ {fromCopy(copy, section.labelKey)}
316
+ </h4>
317
+ {section.cells.map((cell) => (
318
+ <div
319
+ key={cell.key}
320
+ className="flex flex-col gap-1.5 border-b border-border/60 pb-2.5 last:border-b-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between sm:gap-4"
321
+ >
322
+ <div className="min-w-0">
323
+ <p className="text-sm font-medium" title={cell.description}>
324
+ {firstSentence(cell.description)}
325
+ </p>
326
+ <p className="mt-0.5 text-xs text-muted-foreground">
327
+ {effectiveLabel(cell, forumTitles, copy)}
328
+ </p>
329
+ </div>
330
+ <CellControl cell={cell} copy={copy} />
331
+ </div>
332
+ ))}
333
+ </section>
223
334
  ))}
224
- </div>
225
335
 
226
- <div>
227
- <SubmitButton>
228
- {formatFromCopy(copy, 'adminForum.saveGroup', { group: row.groupTitle })}
229
- </SubmitButton>
230
- </div>
231
- </form>
336
+ <div className="flex items-center gap-3">
337
+ <SubmitButton>
338
+ {formatFromCopy(copy, 'adminForum.saveGroup', { group: row.groupTitle })}
339
+ </SubmitButton>
340
+ {state.notice === 'saved' && (
341
+ <span className="text-xs font-medium text-moderation-approved">
342
+ {fromCopy(copy, 'admin.saved')}
343
+ </span>
344
+ )}
345
+ </div>
346
+ </form>
347
+ </Disclosure>
232
348
  )
233
349
  }
234
350
 
@@ -248,11 +364,7 @@ export function CopyPermissionsForm({
248
364
  return (
249
365
  <form action={action} className="flex flex-col gap-3">
250
366
  <FormError message={state.error} />
251
- {state.notice === 'copied' && (
252
- <p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
253
- {fromCopy(copy, 'adminForum.copied')}
254
- </p>
255
- )}
367
+ <Saved when={state.notice === 'copied'}>{fromCopy(copy, 'adminForum.copied')}</Saved>
256
368
  <input type="hidden" name="forumId" value={forumId} />
257
369
  <div>
258
370
  <SubmitButton>
@@ -300,11 +412,7 @@ export function CreateForumForm({
300
412
  return (
301
413
  <form action={action} className="flex flex-col gap-3" noValidate>
302
414
  <FormError message={state.error} />
303
- {state.notice === 'created' && (
304
- <p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
305
- {fromCopy(copy, 'admin.created')}
306
- </p>
307
- )}
415
+ <Saved when={state.notice === 'created'}>{fromCopy(copy, 'admin.created')}</Saved>
308
416
 
309
417
  <div className="grid gap-3 sm:grid-cols-2">
310
418
  <label className="flex flex-col gap-1 text-sm">
@@ -368,11 +476,7 @@ export function MoveForumForm({
368
476
  return (
369
477
  <form action={action} className="flex flex-col gap-3" noValidate>
370
478
  <FormError message={state.error} />
371
- {state.notice === 'moved' && (
372
- <p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
373
- {fromCopy(copy, 'adminForum.moved')}
374
- </p>
375
- )}
479
+ <Saved when={state.notice === 'moved'}>{fromCopy(copy, 'adminForum.moved')}</Saved>
376
480
  <input type="hidden" name="forumId" value={forumId} />
377
481
 
378
482
  <label className="flex flex-col gap-1 text-sm">
@@ -471,9 +575,7 @@ export function ModeratorsPanel({
471
575
 
472
576
  <form action={appoint} className={PANEL_CARD}>
473
577
  <FormError message={appointState.error} />
474
- {appointState.notice === 'saved' && (
475
- <p className="text-sm text-muted-foreground">{fromCopy(copy, 'admin.saved')}</p>
476
- )}
578
+ <Saved when={appointState.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
477
579
  <h3 className="text-sm font-medium">{fromCopy(copy, 'adminForum.appointTitle')}</h3>
478
580
  <input type="hidden" name="forumId" value={forumId} />
479
581
 
@@ -7,6 +7,7 @@ import { EMPTY_STATE, type FormState } from '@/server/auth-form-state'
7
7
  import { sendTestMailAction } from '@/server/mail-test-actions'
8
8
 
9
9
  import { type Copy, fromCopy } from '../shell/copy'
10
+ import { Saved } from './form-bits'
10
11
 
11
12
  export function MailTestCard({
12
13
  summary,
@@ -61,11 +62,7 @@ export function MailTestCard({
61
62
  </p>
62
63
  )}
63
64
 
64
- {state.notice !== undefined && (
65
- <p role="status" className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
66
- {state.notice}
67
- </p>
68
- )}
65
+ <Saved when={state.notice !== undefined}>{state.notice}</Saved>
69
66
 
70
67
  <form action={submit} className="flex flex-col gap-2">
71
68
  <div>
@@ -7,6 +7,7 @@ import { refreshMarketplaceAction } from '@/server/marketplace-actions'
7
7
 
8
8
  import { FormError } from '../auth/form-controls'
9
9
  import { type Copy, fromCopy } from '../shell/copy'
10
+ import { Saved } from './form-bits'
10
11
 
11
12
  export function MarketplaceRefreshForm({ copy }: { copy: Copy }) {
12
13
  const [state, action] = useActionState(refreshMarketplaceAction, EMPTY_STATE)
@@ -14,11 +15,9 @@ export function MarketplaceRefreshForm({ copy }: { copy: Copy }) {
14
15
  return (
15
16
  <form action={action} className="flex flex-col gap-2">
16
17
  <FormError message={state.error} />
17
- {state.notice === 'refreshed' && (
18
- <p role="status" className="text-sm text-muted-foreground">
19
- {fromCopy(copy, 'adminPanel.marketplace.refreshed')}
20
- </p>
21
- )}
18
+ <Saved when={state.notice === 'refreshed'}>
19
+ {fromCopy(copy, 'adminPanel.marketplace.refreshed')}
20
+ </Saved>
22
21
  <button
23
22
  type="submit"
24
23
  className="inline-flex h-9 w-fit items-center justify-center rounded-md border border-border px-3 text-sm font-medium transition-colors hover:border-primary hover:bg-accent"
@@ -70,7 +70,7 @@ export function MarketplaceListingCard({
70
70
  <ol className="flex flex-col gap-1">
71
71
  {listing.installSteps.map((step) => (
72
72
  <li key={step} className="text-xs">
73
- {step.startsWith('pnpm ') || step.startsWith('community ') ? (
73
+ {step.startsWith('npm ') || step.startsWith('community ') ? (
74
74
  <code className="rounded bg-muted px-1.5 py-0.5">{step}</code>
75
75
  ) : (
76
76
  <span className="text-muted-foreground">{step}</span>
@@ -11,7 +11,7 @@ import {
11
11
 
12
12
  import { FormError, SubmitButton } from '../auth/form-controls'
13
13
  import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
14
- import { INPUT } from './form-bits'
14
+ import { INPUT, Saved } from './form-bits'
15
15
 
16
16
  export interface PluginSettingField {
17
17
  readonly key: string
@@ -87,11 +87,7 @@ export function PluginSettingsForm({
87
87
  return (
88
88
  <form action={action} className="flex flex-col gap-4" noValidate>
89
89
  <FormError message={state.error} />
90
- {state.notice === 'saved' && (
91
- <p role="status" className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
92
- {fromCopy(copy, 'adminPanel.plugin.saved')}
93
- </p>
94
- )}
90
+ <Saved when={state.notice === 'saved'}>{fromCopy(copy, 'adminPanel.plugin.saved')}</Saved>
95
91
 
96
92
  <input type="hidden" name="key" value={pluginKey} />
97
93
 
@@ -8,7 +8,7 @@ import type { SettingFieldModel, SettingGroupModel } from '@/view/admin-settings
8
8
 
9
9
  import { FormError, SubmitButton } from '../auth/form-controls'
10
10
  import { type Copy, fromCopy } from '../shell/copy'
11
- import { INPUT } from './form-bits'
11
+ import { INPUT, Saved } from './form-bits'
12
12
 
13
13
  function Control({ setting, copy }: { setting: SettingFieldModel; copy: Copy }) {
14
14
  const { field, key, value } = setting
@@ -95,11 +95,7 @@ export function AdminSettingsForm({
95
95
  return (
96
96
  <form action={action} className="flex flex-col gap-8" noValidate>
97
97
  <FormError message={state.error} />
98
- {state.notice === 'saved' && (
99
- <p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
100
- {fromCopy(copy, 'adminPanel.setting.saved')}
101
- </p>
102
- )}
98
+ <Saved when={state.notice === 'saved'}>{fromCopy(copy, 'adminPanel.setting.saved')}</Saved>
103
99
  {state.notice === 'unchanged' && (
104
100
  <p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
105
101
  {fromCopy(copy, 'adminPanel.setting.unchanged')}
@@ -14,13 +14,10 @@ import {
14
14
 
15
15
  import { FormError, SubmitButton } from '../auth/form-controls'
16
16
  import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
17
+ import { Saved } from './form-bits'
17
18
 
18
19
  function Result({ children }: { children: React.ReactNode }) {
19
- return (
20
- <p role="status" className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
21
- {children}
22
- </p>
23
- )
20
+ return <Saved>{children}</Saved>
24
21
  }
25
22
 
26
23
  export function PruneSessionsForm({ prunable, copy }: { prunable: number; copy: Copy }) {
@@ -0,0 +1,142 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ import { Input, Field as UiField } from '@meith/ui'
6
+ import { InputOTP, InputOTPGroup, InputOTPSlot, REGEXP_ONLY_DIGITS } from '@meith/ui/input-otp'
7
+
8
+ import { type Copy, fromCopy } from '../shell/copy'
9
+
10
+ const OTP_LENGTH = 6
11
+ const OTP_SLOTS = Array.from({ length: OTP_LENGTH }, (_, index) => index)
12
+
13
+ export interface OtpRecoveryCopy {
14
+ readonly label: string
15
+ readonly hint?: string | undefined
16
+ readonly toRecovery: string
17
+ readonly toApp: string
18
+ }
19
+
20
+ export function otpRecoveryFromCopy(copy: Copy, hint?: string): OtpRecoveryCopy {
21
+ return {
22
+ label: fromCopy(copy, 'otp.recoveryLabel'),
23
+ toRecovery: fromCopy(copy, 'otp.useRecovery'),
24
+ toApp: fromCopy(copy, 'otp.useApp'),
25
+ ...(hint === undefined ? {} : { hint }),
26
+ }
27
+ }
28
+
29
+ export interface OtpFieldProps {
30
+ readonly label: string
31
+ readonly name?: string
32
+ readonly id?: string
33
+ readonly hint?: string | undefined
34
+ readonly error?: string | undefined
35
+ readonly required?: boolean
36
+ readonly recovery?: OtpRecoveryCopy | undefined
37
+ }
38
+
39
+ function Toggle({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
40
+ return (
41
+ <button
42
+ type="button"
43
+ onClick={onClick}
44
+ className="self-start text-xs text-muted-foreground underline underline-offset-4 transition-colors hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
45
+ >
46
+ {children}
47
+ </button>
48
+ )
49
+ }
50
+
51
+ export function OtpField({
52
+ label,
53
+ name = 'code',
54
+ id,
55
+ hint,
56
+ error,
57
+ required = true,
58
+ recovery,
59
+ }: OtpFieldProps) {
60
+ const [enhanced, setEnhanced] = useState(false)
61
+ const [value, setValue] = useState('')
62
+ const [useRecovery, setUseRecovery] = useState(false)
63
+
64
+ useEffect(() => {
65
+ setEnhanced(true)
66
+ }, [])
67
+
68
+ if (!enhanced) {
69
+ return (
70
+ <UiField
71
+ name={name}
72
+ label={label}
73
+ error={error ?? null}
74
+ {...(id === undefined ? {} : { id })}
75
+ {...(hint === undefined ? {} : { description: hint })}
76
+ >
77
+ {(control) => (
78
+ <Input
79
+ {...control}
80
+ type="text"
81
+ autoComplete="one-time-code"
82
+ required={required}
83
+ {...(recovery === undefined ? { inputMode: 'numeric' as const } : {})}
84
+ />
85
+ )}
86
+ </UiField>
87
+ )
88
+ }
89
+
90
+ if (recovery !== undefined && useRecovery) {
91
+ return (
92
+ <div className="flex flex-col gap-2">
93
+ <UiField
94
+ name={name}
95
+ label={recovery.label}
96
+ error={error ?? null}
97
+ {...(id === undefined ? {} : { id })}
98
+ {...(recovery.hint === undefined ? {} : { description: recovery.hint })}
99
+ >
100
+ {(control) => (
101
+ <Input {...control} type="text" autoComplete="one-time-code" required={required} />
102
+ )}
103
+ </UiField>
104
+ <Toggle onClick={() => setUseRecovery(false)}>{recovery.toApp}</Toggle>
105
+ </div>
106
+ )
107
+ }
108
+
109
+ return (
110
+ <div className="flex flex-col gap-2">
111
+ <UiField
112
+ name={name}
113
+ label={label}
114
+ error={error ?? null}
115
+ {...(id === undefined ? {} : { id })}
116
+ {...(hint === undefined ? {} : { description: hint })}
117
+ >
118
+ {(control) => (
119
+ <InputOTP
120
+ {...control}
121
+ maxLength={OTP_LENGTH}
122
+ pattern={REGEXP_ONLY_DIGITS}
123
+ value={value}
124
+ onChange={setValue}
125
+ inputMode="numeric"
126
+ autoComplete="one-time-code"
127
+ required={required}
128
+ >
129
+ <InputOTPGroup>
130
+ {OTP_SLOTS.map((slot) => (
131
+ <InputOTPSlot key={slot} index={slot} />
132
+ ))}
133
+ </InputOTPGroup>
134
+ </InputOTP>
135
+ )}
136
+ </UiField>
137
+ {recovery !== undefined ? (
138
+ <Toggle onClick={() => setUseRecovery(true)}>{recovery.toRecovery}</Toggle>
139
+ ) : null}
140
+ </div>
141
+ )
142
+ }
@@ -8,7 +8,8 @@ import { abandonSecondFactorAction, verifySecondFactorAction } from '@/server/au
8
8
  import { EMPTY_STATE } from '@/server/auth-form-state'
9
9
 
10
10
  import { type Copy, fromCopy } from '../shell/copy'
11
- import { Field, FormError, SubmitButton } from './form-controls'
11
+ import { FormError, SubmitButton } from './form-controls'
12
+ import { OtpField, otpRecoveryFromCopy } from './otp-field'
12
13
 
13
14
  export function SecondFactorForm({
14
15
  next,
@@ -26,15 +27,15 @@ export function SecondFactorForm({
26
27
  <form action={action} className="flex flex-col gap-4" noValidate>
27
28
  <FormError message={state.error} />
28
29
 
29
- <Field
30
+ <OtpField
30
31
  label={fromCopy(copy, 'authForm.secondFactor.code')}
31
32
  name="code"
32
- autoComplete="one-time-code"
33
33
  hint={
34
34
  recoveryCodesLeft > 0
35
35
  ? fromCopy(copy, 'authForm.secondFactor.recoveryHint')
36
36
  : fromCopy(copy, 'authForm.secondFactor.recoveryHintExhausted')
37
37
  }
38
+ recovery={otpRecoveryFromCopy(copy)}
38
39
  />
39
40
 
40
41
  {next ? <input type="hidden" name="next" value={next} /> : null}