@meith/web 0.23.2 → 0.24.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.
@@ -47,6 +47,9 @@ export default async function VerifySecurityPage({
47
47
  codeSubmit: tr.t('credentialProof.codeSubmit'),
48
48
  passwordLabel: tr.t('credentialProof.passwordLabel'),
49
49
  passwordSubmit: tr.t('credentialProof.passwordSubmit'),
50
+ recoveryLabel: tr.t('otp.recoveryLabel'),
51
+ useRecovery: tr.t('otp.useRecovery'),
52
+ useApp: tr.t('otp.useApp'),
50
53
  }}
51
54
  hasPassword={account?.passwordHash !== null && account !== null}
52
55
  hasSecondFactor={factor.enrolled}
@@ -26,7 +26,7 @@ export default async function AdminLayout({ children }: { children: React.ReactN
26
26
  <main
27
27
  id="board-content"
28
28
  tabIndex={-1}
29
- className="flex min-h-screen items-center justify-center px-6 py-12"
29
+ className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 py-12"
30
30
  >
31
31
  <AdminSignInForm
32
32
  next="/admin"
@@ -34,6 +34,9 @@ export default async function AdminLayout({ children }: { children: React.ReactN
34
34
  idleMinutes={ADMIN_IDLE_MINUTES}
35
35
  copy={adminFormsCopy(await getTranslator())}
36
36
  />
37
+ <a href="/" className="text-sm text-muted-foreground hover:text-foreground">
38
+ {await tr('adminPanel.cancel')}
39
+ </a>
37
40
  </main>
38
41
  )
39
42
  }
@@ -64,9 +67,6 @@ export default async function AdminLayout({ children }: { children: React.ReactN
64
67
  {await tr('page.control-panel')}
65
68
  </a>
66
69
  <div className="ml-auto flex shrink-0 items-center gap-3 text-sm whitespace-nowrap sm:gap-4">
67
- <a href="/" className="text-muted-foreground hover:text-foreground">
68
- {await tr('page.board')}
69
- </a>
70
70
  <AdminSignOutForm copy={adminFormsCopy(t)} />
71
71
  </div>
72
72
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.23.2",
3
+ "version": "0.24.0",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,53 +43,53 @@
43
43
  "react-dom": "19.2.8",
44
44
  "tailwindcss": "^4.3.3",
45
45
  "typescript": "7.0.2",
46
- "@meith/accounts": "0.23.2",
47
- "@meith/admin": "0.23.2",
48
- "@meith/antispam": "0.23.2",
49
- "@meith/api": "0.23.2",
50
- "@meith/attachments": "0.23.2",
51
- "@meith/authorization": "0.23.2",
52
- "@meith/avatars": "0.23.2",
53
- "@meith/core": "0.23.2",
54
- "@meith/db": "0.23.2",
55
- "@meith/demo": "0.23.2",
56
- "@meith/drafts": "0.23.2",
57
- "@meith/drivers": "0.23.2",
58
- "@meith/events": "0.23.2",
59
- "@meith/forums": "0.23.2",
60
- "@meith/groups": "0.23.2",
61
- "@meith/i18n": "0.23.2",
62
- "@meith/import": "0.23.2",
63
- "@meith/install": "0.23.2",
64
- "@meith/mail": "0.23.2",
65
- "@meith/markdown": "0.23.2",
66
- "@meith/marketplace": "0.23.2",
67
- "@meith/messages": "0.23.2",
68
- "@meith/moderation": "0.23.2",
69
- "@meith/notifications": "0.23.2",
70
- "@meith/plugin-calendar": "0.23.2",
71
- "@meith/plugin-dues": "0.23.2",
72
- "@meith/plugin-kit": "0.23.2",
73
- "@meith/polls": "0.23.2",
74
- "@meith/posts": "0.23.2",
75
- "@meith/profile-fields": "0.23.2",
76
- "@meith/relations": "0.23.2",
77
- "@meith/reputation": "0.23.2",
78
- "@meith/runtime": "0.23.2",
79
- "@meith/settings": "0.23.2",
80
- "@meith/search": "0.23.2",
81
- "@meith/signatures": "0.23.2",
82
- "@meith/subscriptions": "0.23.2",
83
- "@meith/tasks": "0.23.2",
84
- "@meith/theme-clubhouse": "0.23.2",
85
- "@meith/theme-kit": "0.23.2",
86
- "@meith/theme-default": "0.23.2",
87
- "@meith/theme-midnight": "0.23.2",
88
- "@meith/theme-phasebook": "0.23.2",
89
- "@meith/theme-raidframe": "0.23.2",
90
- "@meith/threads": "0.23.2",
91
- "@meith/ui": "0.23.2",
92
- "@meith/upgrade": "0.23.2"
46
+ "@meith/accounts": "0.24.0",
47
+ "@meith/admin": "0.24.0",
48
+ "@meith/antispam": "0.24.0",
49
+ "@meith/api": "0.24.0",
50
+ "@meith/attachments": "0.24.0",
51
+ "@meith/authorization": "0.24.0",
52
+ "@meith/avatars": "0.24.0",
53
+ "@meith/core": "0.24.0",
54
+ "@meith/db": "0.24.0",
55
+ "@meith/demo": "0.24.0",
56
+ "@meith/drafts": "0.24.0",
57
+ "@meith/drivers": "0.24.0",
58
+ "@meith/events": "0.24.0",
59
+ "@meith/forums": "0.24.0",
60
+ "@meith/groups": "0.24.0",
61
+ "@meith/i18n": "0.24.0",
62
+ "@meith/import": "0.24.0",
63
+ "@meith/install": "0.24.0",
64
+ "@meith/mail": "0.24.0",
65
+ "@meith/markdown": "0.24.0",
66
+ "@meith/marketplace": "0.24.0",
67
+ "@meith/messages": "0.24.0",
68
+ "@meith/moderation": "0.24.0",
69
+ "@meith/notifications": "0.24.0",
70
+ "@meith/plugin-calendar": "0.24.0",
71
+ "@meith/plugin-dues": "0.24.0",
72
+ "@meith/plugin-kit": "0.24.0",
73
+ "@meith/polls": "0.24.0",
74
+ "@meith/posts": "0.24.0",
75
+ "@meith/profile-fields": "0.24.0",
76
+ "@meith/relations": "0.24.0",
77
+ "@meith/reputation": "0.24.0",
78
+ "@meith/runtime": "0.24.0",
79
+ "@meith/search": "0.24.0",
80
+ "@meith/settings": "0.24.0",
81
+ "@meith/signatures": "0.24.0",
82
+ "@meith/subscriptions": "0.24.0",
83
+ "@meith/tasks": "0.24.0",
84
+ "@meith/theme-clubhouse": "0.24.0",
85
+ "@meith/theme-default": "0.24.0",
86
+ "@meith/theme-kit": "0.24.0",
87
+ "@meith/theme-midnight": "0.24.0",
88
+ "@meith/theme-phasebook": "0.24.0",
89
+ "@meith/theme-raidframe": "0.24.0",
90
+ "@meith/threads": "0.24.0",
91
+ "@meith/ui": "0.24.0",
92
+ "@meith/upgrade": "0.24.0"
93
93
  },
94
94
  "scripts": {
95
95
  "dev": "next dev",
@@ -9,6 +9,7 @@ import { EMPTY_STATE } from '@/server/auth-form-state'
9
9
  import { proveCredentialAction } from '@/server/credential-proof-actions'
10
10
 
11
11
  import { FormError } from '../auth/form-controls'
12
+ import { OtpField } from '../auth/otp-field'
12
13
 
13
14
  export interface CredentialProofCopy {
14
15
  readonly codeConsumed: string
@@ -16,6 +17,9 @@ export interface CredentialProofCopy {
16
17
  readonly codeSubmit: string
17
18
  readonly passwordLabel: string
18
19
  readonly passwordSubmit: string
20
+ readonly recoveryLabel: string
21
+ readonly useRecovery: string
22
+ readonly useApp: string
19
23
  }
20
24
 
21
25
  export function CredentialProofForm({
@@ -59,10 +63,16 @@ export function CredentialProofForm({
59
63
  >
60
64
  <input type="hidden" name="method" value="code" />
61
65
  <input type="hidden" name="next" value={next} />
62
- <label className="flex flex-col gap-1 text-sm">
63
- <span className="font-medium">{copy.codeLabel}</span>
64
- <Input name="code" inputMode="numeric" autoComplete="one-time-code" required />
65
- </label>
66
+ <OtpField
67
+ label={copy.codeLabel}
68
+ name="code"
69
+ recovery={{
70
+ label: copy.recoveryLabel,
71
+ toRecovery: copy.useRecovery,
72
+ toApp: copy.useApp,
73
+ hint: copy.codeConsumed,
74
+ }}
75
+ />
66
76
  <p className="text-sm text-muted-foreground">{copy.codeConsumed}</p>
67
77
  <div>
68
78
  <Button type="submit" variant="primary">
@@ -15,6 +15,7 @@ import {
15
15
  import { RECOVERY_CODES_FIELD } from '@/view/two-factor'
16
16
 
17
17
  import { Field, FormError } from '../auth/form-controls'
18
+ import { OtpField, otpRecoveryFromCopy } from '../auth/otp-field'
18
19
  import { type Copy, fromCopy } from '../shell/copy'
19
20
 
20
21
  const CARD = 'flex flex-col gap-4 rounded-lg border border-border bg-card p-5'
@@ -96,10 +97,9 @@ export function TwoFactorSetup({
96
97
 
97
98
  <form action={action} className="flex flex-col gap-4">
98
99
  <FormError message={state.error} />
99
- <Field
100
+ <OtpField
100
101
  label={fromCopy(copy, 'accountForm.twoFactor.codeLabel')}
101
102
  name="code"
102
- autoComplete="one-time-code"
103
103
  hint={fromCopy(copy, 'accountForm.twoFactor.codeHint')}
104
104
  />
105
105
  <div className="flex flex-wrap gap-2">
@@ -166,12 +166,12 @@ export function TwoFactorPanel({
166
166
  autoComplete="current-password"
167
167
  />
168
168
  ) : (
169
- <Field
169
+ <OtpField
170
170
  id={`${which}-code`}
171
171
  label={fromCopy(copy, 'accountForm.twoFactor.codeProofLabel')}
172
172
  name="code"
173
- autoComplete="one-time-code"
174
173
  hint={fromCopy(copy, 'accountForm.twoFactor.codeProofHint')}
174
+ recovery={otpRecoveryFromCopy(copy)}
175
175
  />
176
176
  )
177
177
 
@@ -6,6 +6,7 @@ import { adminSignInAction, adminSignOutAction } from '@/server/admin-actions'
6
6
  import { EMPTY_STATE } from '@/server/auth-form-state'
7
7
 
8
8
  import { FormError } from '../auth/form-controls'
9
+ import { OtpField, otpRecoveryFromCopy } from '../auth/otp-field'
9
10
  import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
10
11
 
11
12
  export function AdminSignInForm({
@@ -50,19 +51,13 @@ export function AdminSignInForm({
50
51
  />
51
52
  </label>
52
53
 
53
- <label className="flex flex-col gap-1 text-sm">
54
- <span className="font-medium">{fromCopy(copy, 'adminPanel.twoFactorCode')}</span>
55
- <input
56
- type="text"
57
- name="code"
58
- inputMode="numeric"
59
- autoComplete="one-time-code"
60
- className="h-10 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40"
61
- />
62
- <span className="text-xs text-muted-foreground">
63
- {fromCopy(copy, 'adminPanel.twoFactorCodeHint')}
64
- </span>
65
- </label>
54
+ <OtpField
55
+ label={fromCopy(copy, 'adminPanel.twoFactorCode')}
56
+ name="code"
57
+ required={false}
58
+ hint={fromCopy(copy, 'adminPanel.twoFactorCodeHint')}
59
+ recovery={otpRecoveryFromCopy(copy)}
60
+ />
66
61
 
67
62
  <button
68
63
  type="submit"
@@ -10,6 +10,7 @@ import { removeBadgeAction, saveBadgeAction } from '@/server/group-badge-actions
10
10
 
11
11
  import { FormError, SubmitButton } from '../auth/form-controls'
12
12
  import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
13
+ import { Saved } from './form-bits'
13
14
 
14
15
  const GHOST =
15
16
  'inline-flex h-8 items-center justify-center rounded-md border border-border px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
@@ -39,13 +40,11 @@ export function BadgeUploadForm({
39
40
  <h3 className="text-sm font-medium">{label}</h3>
40
41
 
41
42
  <FormError message={saved.error ?? removed.error} />
42
- {(saved.notice === 'saved' || removed.notice === 'removed') && (
43
- <p role="status" className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
44
- {saved.notice === 'saved'
45
- ? fromCopy(copy, 'admin.saved')
46
- : fromCopy(copy, 'adminPanel.badge.removed')}
47
- </p>
48
- )}
43
+ <Saved when={saved.notice === 'saved' || removed.notice === 'removed'}>
44
+ {saved.notice === 'saved'
45
+ ? fromCopy(copy, 'admin.saved')
46
+ : fromCopy(copy, 'adminPanel.badge.removed')}
47
+ </Saved>
49
48
 
50
49
  <div
51
50
  className={`flex min-h-12 items-center justify-center rounded-md border border-border bg-card p-2 ${
@@ -8,6 +8,7 @@ import { removeLogoAction, saveLogoAction } from '@/server/branding-actions'
8
8
 
9
9
  import { FormError, SubmitButton } from '../auth/form-controls'
10
10
  import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
11
+ import { Saved } from './form-bits'
11
12
 
12
13
  export interface LogoSlot {
13
14
  readonly scheme: 'light' | 'dark'
@@ -41,16 +42,10 @@ export function LogoUploadForm({
41
42
  </div>
42
43
 
43
44
  <FormError message={saved.error ?? removed.error} />
44
- {saved.notice === 'saved' && (
45
- <p role="status" className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
46
- {fromCopy(copy, 'adminPanel.branding.saved')}
47
- </p>
48
- )}
49
- {removed.notice === 'removed' && (
50
- <p role="status" className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
51
- {fromCopy(copy, 'adminPanel.branding.removed')}
52
- </p>
53
- )}
45
+ <Saved when={saved.notice === 'saved'}>{fromCopy(copy, 'adminPanel.branding.saved')}</Saved>
46
+ <Saved when={removed.notice === 'removed'}>
47
+ {fromCopy(copy, 'adminPanel.branding.removed')}
48
+ </Saved>
54
49
 
55
50
  <div
56
51
  className={`flex min-h-16 items-center justify-center rounded-md border border-border bg-card p-3 ${
@@ -1,11 +1,13 @@
1
+ import { Alert, AlertDescription } from '@meith/ui'
2
+
1
3
  export const INPUT =
2
4
  'w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
3
5
 
4
- export function Saved({ when, children }: { when: boolean; children: React.ReactNode }) {
6
+ export function Saved({ when = true, children }: { when?: boolean; children: React.ReactNode }) {
5
7
  if (!when) return null
6
8
  return (
7
- <p role="status" className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
8
- {children}
9
- </p>
9
+ <Alert tone="success">
10
+ <AlertDescription>{children}</AlertDescription>
11
+ </Alert>
10
12
  )
11
13
  }
@@ -19,7 +19,7 @@ import {
19
19
 
20
20
  import { FormError, SubmitButton } from '../auth/form-controls'
21
21
  import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
22
- import { INPUT } from './form-bits'
22
+ import { INPUT, Saved } from './form-bits'
23
23
 
24
24
  const TOGGLES = [
25
25
  { name: 'isOpen', labelKey: 'adminForum.toggle.isOpen' },
@@ -49,11 +49,7 @@ export function ForumOptionsForm({ forum, copy }: { forum: ForumOptionsValues; c
49
49
  return (
50
50
  <form action={action} className="flex flex-col gap-4" noValidate>
51
51
  <FormError message={state.error} />
52
- {state.notice === 'saved' && (
53
- <p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
54
- {fromCopy(copy, 'admin.saved')}
55
- </p>
56
- )}
52
+ <Saved when={state.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
57
53
  <input type="hidden" name="forumId" value={forum.id} />
58
54
 
59
55
  <label className="flex flex-col gap-1 text-sm">
@@ -342,7 +338,9 @@ export function ForumPermissionRowForm({
342
338
  {formatFromCopy(copy, 'adminForum.saveGroup', { group: row.groupTitle })}
343
339
  </SubmitButton>
344
340
  {state.notice === 'saved' && (
345
- <span className="text-xs text-muted-foreground">{fromCopy(copy, 'admin.saved')}</span>
341
+ <span className="text-xs font-medium text-moderation-approved">
342
+ {fromCopy(copy, 'admin.saved')}
343
+ </span>
346
344
  )}
347
345
  </div>
348
346
  </form>
@@ -366,11 +364,7 @@ export function CopyPermissionsForm({
366
364
  return (
367
365
  <form action={action} className="flex flex-col gap-3">
368
366
  <FormError message={state.error} />
369
- {state.notice === 'copied' && (
370
- <p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
371
- {fromCopy(copy, 'adminForum.copied')}
372
- </p>
373
- )}
367
+ <Saved when={state.notice === 'copied'}>{fromCopy(copy, 'adminForum.copied')}</Saved>
374
368
  <input type="hidden" name="forumId" value={forumId} />
375
369
  <div>
376
370
  <SubmitButton>
@@ -418,11 +412,7 @@ export function CreateForumForm({
418
412
  return (
419
413
  <form action={action} className="flex flex-col gap-3" noValidate>
420
414
  <FormError message={state.error} />
421
- {state.notice === 'created' && (
422
- <p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
423
- {fromCopy(copy, 'admin.created')}
424
- </p>
425
- )}
415
+ <Saved when={state.notice === 'created'}>{fromCopy(copy, 'admin.created')}</Saved>
426
416
 
427
417
  <div className="grid gap-3 sm:grid-cols-2">
428
418
  <label className="flex flex-col gap-1 text-sm">
@@ -486,11 +476,7 @@ export function MoveForumForm({
486
476
  return (
487
477
  <form action={action} className="flex flex-col gap-3" noValidate>
488
478
  <FormError message={state.error} />
489
- {state.notice === 'moved' && (
490
- <p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
491
- {fromCopy(copy, 'adminForum.moved')}
492
- </p>
493
- )}
479
+ <Saved when={state.notice === 'moved'}>{fromCopy(copy, 'adminForum.moved')}</Saved>
494
480
  <input type="hidden" name="forumId" value={forumId} />
495
481
 
496
482
  <label className="flex flex-col gap-1 text-sm">
@@ -589,9 +575,7 @@ export function ModeratorsPanel({
589
575
 
590
576
  <form action={appoint} className={PANEL_CARD}>
591
577
  <FormError message={appointState.error} />
592
- {appointState.notice === 'saved' && (
593
- <p className="text-sm text-muted-foreground">{fromCopy(copy, 'admin.saved')}</p>
594
- )}
578
+ <Saved when={appointState.notice === 'saved'}>{fromCopy(copy, 'admin.saved')}</Saved>
595
579
  <h3 className="text-sm font-medium">{fromCopy(copy, 'adminForum.appointTitle')}</h3>
596
580
  <input type="hidden" name="forumId" value={forumId} />
597
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"
@@ -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}
@@ -6,7 +6,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
6
6
 
7
7
  import { activeDefinitions } from './plugin-host'
8
8
 
9
- export const CODE_VERSION = '0.23.2'
9
+ export const CODE_VERSION = '0.24.0'
10
10
 
11
11
  export async function pendingUpgradeNotice(): Promise<string | null> {
12
12
  if (env.DATA_SOURCE !== 'postgres') return null
@@ -1,5 +1,6 @@
1
1
  import type { Translator } from '@meith/i18n'
2
2
 
3
+ import { otpFieldCopy } from './auth-copy'
3
4
  import { copyFor, splitAround } from './copy'
4
5
  import { untranslated } from './time'
5
6
 
@@ -201,6 +202,7 @@ export function twoFactorFormsCopy(
201
202
  'accountForm.twoFactor.codes': t.t('accountForm.twoFactor.codes', {
202
203
  count: recoveryCodesLeft,
203
204
  }),
205
+ ...otpFieldCopy(t),
204
206
  }
205
207
  }
206
208
 
@@ -1,6 +1,7 @@
1
1
  import type { Translator } from '@meith/i18n'
2
2
 
3
3
  import { adminSharedCopy } from './admin-copy'
4
+ import { otpFieldCopy } from './auth-copy'
4
5
  import { copyFor, patternCopy, splitAround } from './copy'
5
6
  import { untranslated } from './time'
6
7
 
@@ -21,6 +22,7 @@ export function adminFormsCopy(t: Translator = untranslated()): Readonly<Record<
21
22
  t,
22
23
  ),
23
24
  ...patternCopy(['adminPanel.idleNote'], t),
25
+ ...otpFieldCopy(t),
24
26
  }
25
27
  }
26
28
 
@@ -79,19 +79,26 @@ export function resendVerificationFormCopy(
79
79
  return copyFor(['authForm.email', 'authForm.resend.submit'], t)
80
80
  }
81
81
 
82
+ export function otpFieldCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
83
+ return copyFor(['otp.recoveryLabel', 'otp.useApp', 'otp.useRecovery'], t)
84
+ }
85
+
82
86
  export function secondFactorFormCopy(
83
87
  t: Translator = untranslated(),
84
88
  ): Readonly<Record<string, string>> {
85
- return copyFor(
86
- [
87
- 'authForm.secondFactor.code',
88
- 'authForm.secondFactor.recoveryHint',
89
- 'authForm.secondFactor.recoveryHintExhausted',
90
- 'authForm.secondFactor.submit',
91
- 'authForm.secondFactor.cancel',
92
- ],
93
- t,
94
- )
89
+ return {
90
+ ...copyFor(
91
+ [
92
+ 'authForm.secondFactor.code',
93
+ 'authForm.secondFactor.recoveryHint',
94
+ 'authForm.secondFactor.recoveryHintExhausted',
95
+ 'authForm.secondFactor.submit',
96
+ 'authForm.secondFactor.cancel',
97
+ ],
98
+ t,
99
+ ),
100
+ ...otpFieldCopy(t),
101
+ }
95
102
  }
96
103
 
97
104
  export function passkeyCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {