@meith/web 0.31.0 → 0.33.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 (174) hide show
  1. package/app/(board)/[slug]/feed.xml/route.ts +5 -3
  2. package/app/(board)/[slug]/new/page.tsx +5 -0
  3. package/app/(board)/[slug]/page.tsx +7 -1
  4. package/app/(board)/discover/[view]/page.tsx +6 -1
  5. package/app/(board)/member/[id]/page.tsx +3 -5
  6. package/app/(board)/member/[id]/reputation/page.tsx +8 -15
  7. package/app/(board)/members/page.tsx +3 -8
  8. package/app/(board)/messages/[id]/page.tsx +3 -7
  9. package/app/(board)/messages/page.tsx +3 -5
  10. package/app/(board)/modcp/ip/page.tsx +3 -5
  11. package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
  12. package/app/(board)/moderation/reports/page.tsx +53 -15
  13. package/app/(board)/moderation/warn/page.tsx +3 -6
  14. package/app/(board)/notifications/page.tsx +5 -10
  15. package/app/(board)/notifications/preferences/page.tsx +8 -0
  16. package/app/(board)/online/page.tsx +12 -6
  17. package/app/(board)/staff/page.tsx +4 -5
  18. package/app/(board)/stats/page.tsx +10 -3
  19. package/app/(board)/subscriptions/page.tsx +5 -10
  20. package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
  21. package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
  22. package/app/(board)/thread/[slug]/page.tsx +49 -11
  23. package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
  24. package/app/(board)/unsubscribe/page.tsx +8 -11
  25. package/app/(board)/usercp/contacts/page.tsx +3 -5
  26. package/app/(board)/usercp/drafts/page.tsx +70 -0
  27. package/app/(board)/usercp/options/page.tsx +8 -1
  28. package/app/(board)/usercp/security/page.tsx +16 -0
  29. package/app/admin/antispam/page.tsx +4 -12
  30. package/app/admin/content/attachments/page.tsx +3 -6
  31. package/app/admin/content/page.tsx +3 -13
  32. package/app/admin/forums/[id]/permissions/page.tsx +9 -12
  33. package/app/admin/groups/page.tsx +7 -9
  34. package/app/admin/layout.tsx +23 -8
  35. package/app/admin/page.tsx +12 -6
  36. package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
  37. package/app/admin/plugins/page.tsx +3 -6
  38. package/app/admin/users/[id]/page.tsx +5 -11
  39. package/app/admin/users/prune/page.tsx +4 -12
  40. package/app/admin/webhooks/page.tsx +144 -0
  41. package/app/api/subscribe/thread/[id]/route.ts +53 -0
  42. package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
  43. package/app/atom.xml/route.ts +2 -2
  44. package/app/auth/sso/[provider]/callback/route.ts +10 -0
  45. package/app/feed.xml/route.ts +2 -2
  46. package/app/offline/route.ts +98 -0
  47. package/next.config.mjs +1 -0
  48. package/package.json +49 -48
  49. package/public/sw.js +42 -2
  50. package/src/components/account/draft-forms.tsx +39 -0
  51. package/src/components/account/feed-token.tsx +95 -0
  52. package/src/components/account/notification-forms.tsx +37 -2
  53. package/src/components/account/relation-forms.tsx +3 -2
  54. package/src/components/account/subscription-forms.tsx +20 -6
  55. package/src/components/account/usercp-forms.tsx +44 -0
  56. package/src/components/admin/admin-forms.tsx +57 -9
  57. package/src/components/admin/api-token-forms.tsx +11 -7
  58. package/src/components/admin/ban-filter-forms.tsx +15 -11
  59. package/src/components/admin/content-forms.tsx +31 -14
  60. package/src/components/admin/forum-forms.tsx +241 -129
  61. package/src/components/admin/forum-tree.tsx +5 -7
  62. package/src/components/admin/navigation-forms.tsx +43 -26
  63. package/src/components/admin/navigation-tree.tsx +8 -13
  64. package/src/components/admin/oklch-picker.tsx +2 -1
  65. package/src/components/admin/theme-changes.tsx +3 -2
  66. package/src/components/admin/theme-forms.tsx +3 -2
  67. package/src/components/admin/webhook-forms.tsx +137 -0
  68. package/src/components/auth/register-form.tsx +4 -7
  69. package/src/components/board/live-region.tsx +3 -1
  70. package/src/components/content/attachment-field.tsx +93 -22
  71. package/src/components/content/combobox-keys.ts +38 -0
  72. package/src/components/content/edit-post-form.tsx +75 -1
  73. package/src/components/content/markdown-editor.tsx +29 -86
  74. package/src/components/content/mention-combobox.tsx +137 -0
  75. package/src/components/content/multiquote-button.tsx +15 -12
  76. package/src/components/content/new-thread-form.tsx +95 -17
  77. package/src/components/content/poll-edit-form.tsx +61 -39
  78. package/src/components/content/poll-vote-form.tsx +91 -0
  79. package/src/components/content/poll.tsx +12 -48
  80. package/src/components/content/progressive-marker.tsx +13 -0
  81. package/src/components/content/recipient-segment.ts +39 -0
  82. package/src/components/content/reply-form.tsx +9 -1
  83. package/src/components/content/thanks-button.tsx +12 -7
  84. package/src/components/messages/message-forms.tsx +53 -43
  85. package/src/components/messages/recipient-field.tsx +88 -0
  86. package/src/components/moderation/inline-moderation-form.tsx +2 -0
  87. package/src/components/moderation/modcp-shell.tsx +17 -3
  88. package/src/components/moderation/queue-form.tsx +3 -6
  89. package/src/components/moderation/report-forms.tsx +24 -5
  90. package/src/components/moderation/thread-tools-form.tsx +2 -0
  91. package/src/components/shell/board-notice.tsx +22 -1
  92. package/src/components/shell/confirm-dialog.tsx +93 -0
  93. package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
  94. package/src/components/shell/notice-toast.tsx +40 -0
  95. package/src/components/shell/notification-menu.tsx +5 -3
  96. package/src/components/shell/page-shell.tsx +3 -0
  97. package/src/components/shell/scheme-toggle.tsx +72 -0
  98. package/src/components/shell/theme-switcher.tsx +16 -36
  99. package/src/components/shell/view-tabs.tsx +1 -1
  100. package/src/server/admin-actions.ts +118 -49
  101. package/src/server/admin-settings-actions.ts +5 -0
  102. package/src/server/admin.ts +70 -2
  103. package/src/server/api/search.ts +8 -2
  104. package/src/server/api-token-actions.ts +4 -0
  105. package/src/server/appearance-actions.ts +21 -0
  106. package/src/server/attachments.ts +35 -0
  107. package/src/server/auth-actions.ts +2 -0
  108. package/src/server/auth-form-state.ts +28 -0
  109. package/src/server/auto-watch.ts +31 -0
  110. package/src/server/ban-filter-admin-actions.ts +5 -0
  111. package/src/server/confirm.ts +19 -0
  112. package/src/server/content-actions.ts +129 -19
  113. package/src/server/content-admin-actions.ts +31 -0
  114. package/src/server/cookies.ts +18 -0
  115. package/src/server/discovery.ts +11 -1
  116. package/src/server/feed-routes.ts +13 -4
  117. package/src/server/feed-token-actions.ts +61 -0
  118. package/src/server/feed-token.ts +78 -0
  119. package/src/server/fixture-post-repo.ts +18 -0
  120. package/src/server/forum-admin-actions.ts +23 -16
  121. package/src/server/forum-admin.ts +10 -1
  122. package/src/server/inline-moderation-actions.ts +6 -0
  123. package/src/server/message-actions.ts +9 -0
  124. package/src/server/navigation-admin-actions.ts +6 -0
  125. package/src/server/plugin-admin.ts +7 -2
  126. package/src/server/plugin-pages.ts +50 -0
  127. package/src/server/plugin-panel.ts +34 -1
  128. package/src/server/plugin-routes.ts +8 -0
  129. package/src/server/plugin-view.tsx +46 -7
  130. package/src/server/poll-actions.ts +13 -1
  131. package/src/server/presence.ts +23 -0
  132. package/src/server/push-actions.ts +9 -0
  133. package/src/server/reply-core.ts +8 -1
  134. package/src/server/report-actions.ts +13 -2
  135. package/src/server/reputation-actions.ts +12 -1
  136. package/src/server/search-page.ts +8 -2
  137. package/src/server/search.ts +18 -5
  138. package/src/server/session-cookies.ts +18 -0
  139. package/src/server/subscription-actions.ts +9 -0
  140. package/src/server/thread-core.ts +10 -2
  141. package/src/server/thread-tool-actions.ts +6 -0
  142. package/src/server/unread-goto.ts +28 -0
  143. package/src/server/upgrade-notice.ts +1 -1
  144. package/src/server/usercp-actions.ts +31 -1
  145. package/src/server/viewer-preferences.ts +5 -0
  146. package/src/server/webhook-actions.ts +98 -0
  147. package/src/server/webhooks-admin.ts +122 -0
  148. package/src/styles/globals.css +1 -1
  149. package/src/theme/contract.fixture.ts +36 -5
  150. package/src/view/account-copy.ts +39 -0
  151. package/src/view/admin-copy.ts +4 -9
  152. package/src/view/admin-nav.ts +6 -0
  153. package/src/view/admin-panel-copy.ts +29 -1
  154. package/src/view/attachments.ts +33 -1
  155. package/src/view/board-index.ts +31 -2
  156. package/src/view/board-title.ts +1 -0
  157. package/src/view/content-copy.ts +22 -12
  158. package/src/view/copy.ts +17 -0
  159. package/src/view/discovery-view.ts +2 -1
  160. package/src/view/drafts.ts +45 -0
  161. package/src/view/editor-toolbar.ts +14 -0
  162. package/src/view/forum-display.ts +39 -7
  163. package/src/view/modcp-nav.ts +32 -0
  164. package/src/view/moderation-copy.ts +20 -0
  165. package/src/view/notifications.ts +1 -0
  166. package/src/view/plugin-panel.ts +13 -0
  167. package/src/view/poll-vote.ts +46 -0
  168. package/src/view/post-link.ts +8 -1
  169. package/src/view/progressive-enhancement.ts +5 -0
  170. package/src/view/shell.ts +2 -1
  171. package/src/view/subscriptions.ts +1 -0
  172. package/src/view/thread-view.ts +41 -2
  173. package/src/view/usercp-nav.ts +6 -0
  174. package/src/view/usercp.ts +14 -0
@@ -0,0 +1,36 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ const OPEN_DISCLOSURES = '[data-nav-disclosure][open]'
6
+
7
+ export function NavDisclosureEnhancer() {
8
+ useEffect(() => {
9
+ function onPointerDown(event: PointerEvent) {
10
+ const target = event.target
11
+ if (!(target instanceof Node)) return
12
+
13
+ for (const details of document.querySelectorAll<HTMLDetailsElement>(OPEN_DISCLOSURES)) {
14
+ if (!details.contains(target)) details.open = false
15
+ }
16
+ }
17
+
18
+ function onKeyDown(event: KeyboardEvent) {
19
+ if (event.key !== 'Escape') return
20
+
21
+ for (const details of document.querySelectorAll<HTMLDetailsElement>(OPEN_DISCLOSURES)) {
22
+ details.open = false
23
+ }
24
+ }
25
+
26
+ document.addEventListener('pointerdown', onPointerDown)
27
+ document.addEventListener('keydown', onKeyDown)
28
+
29
+ return () => {
30
+ document.removeEventListener('pointerdown', onPointerDown)
31
+ document.removeEventListener('keydown', onKeyDown)
32
+ }
33
+ }, [])
34
+
35
+ return null
36
+ }
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useState } from 'react'
4
+
5
+ import { Toast, type ToastTone } from '@meith/ui/toast'
6
+
7
+ import { fromCopy, useCopy } from './copy'
8
+
9
+ export function NoticeToast({
10
+ kind,
11
+ message,
12
+ dismissHref,
13
+ }: {
14
+ kind: ToastTone
15
+ message: string
16
+ dismissHref: string | null
17
+ }) {
18
+ const copy = useCopy()
19
+ const [mounted, setMounted] = useState(false)
20
+
21
+ useEffect(() => setMounted(true), [])
22
+
23
+ const clearNotice = useCallback(() => {
24
+ if (dismissHref === null || typeof window === 'undefined') return
25
+ window.history.replaceState(window.history.state, '', dismissHref)
26
+ }, [dismissHref])
27
+
28
+ if (!mounted) return null
29
+
30
+ return (
31
+ <div
32
+ data-notice="toast"
33
+ className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex justify-center px-4"
34
+ >
35
+ <Toast tone={kind} dismissLabel={fromCopy(copy, 'notice.dismiss')} onDismiss={clearNotice}>
36
+ {message}
37
+ </Toast>
38
+ </div>
39
+ )
40
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useActionState } from 'react'
4
4
 
5
+ import { TextLink } from '@meith/ui'
5
6
  import { Popover } from '@meith/ui/popover'
6
7
  import { Tabs } from '@meith/ui/tabs'
7
8
 
@@ -223,12 +224,13 @@ function Panel({ tab, copy }: { tab: NotificationMenuTab; copy: Copy }) {
223
224
  )}
224
225
 
225
226
  <div className="flex items-center justify-between gap-2 border-t border-border px-2 pt-2">
226
- <a
227
+ <TextLink
227
228
  href={tab.allHref}
228
- className="rounded-md px-2 py-1 text-xs font-medium text-foreground underline decoration-border underline-offset-2 hover:decoration-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
229
+ size="xs"
230
+ className="rounded-md px-2 py-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
229
231
  >
230
232
  {fromCopy(copy, 'board.notificationMenu.viewAll')}
231
- </a>
233
+ </TextLink>
232
234
 
233
235
  {unreadRows.length > 0 && (
234
236
  <SeenForm
@@ -4,6 +4,7 @@ import { requireSlot, slotCopy } from '@meith/theme-kit'
4
4
 
5
5
  import { LogoutForm } from '@/components/account/logout-form'
6
6
  import { InstallBanner } from '@/components/shell/install-banner'
7
+ import { NavDisclosureEnhancer } from '@/components/shell/nav-disclosure-enhancer'
7
8
  import { NotificationMenu } from '@/components/shell/notification-menu'
8
9
  import { OnboardingBanner } from '@/components/shell/onboarding-banner'
9
10
  import { ThemeSwitcher } from '@/components/shell/theme-switcher'
@@ -152,6 +153,8 @@ export async function PageShell({ actor, children }: { actor: Actor; children: R
152
153
  </UserPanel>
153
154
  </Header>
154
155
 
156
+ <NavDisclosureEnhancer />
157
+
155
158
  {await boardRegion('header.notice', actor)}
156
159
 
157
160
  {install !== null && <InstallBanner {...install} />}
@@ -0,0 +1,72 @@
1
+ 'use client'
2
+
3
+ import { useActionState } from 'react'
4
+
5
+ import { setSchemeAction } from '@/server/appearance-actions'
6
+ import {
7
+ COLOUR_SCHEMES,
8
+ type ColourSchemePreference,
9
+ colorSchemeProperty,
10
+ schemeClass,
11
+ } from '@/view/theme-preference'
12
+
13
+ import { PendingButton } from '../auth/form-controls'
14
+ import { ProgressiveMarker } from '../content/progressive-marker'
15
+
16
+ const EMPTY: { scheme?: ColourSchemePreference } = {}
17
+
18
+ function applySchemeImmediately(scheme: ColourSchemePreference): void {
19
+ const root = document.documentElement
20
+ root.classList.remove('light', 'dark')
21
+ const applied = schemeClass(scheme)
22
+ if (applied !== '') root.classList.add(applied)
23
+ root.style.colorScheme = colorSchemeProperty(scheme)
24
+ }
25
+
26
+ export function SchemeToggle({
27
+ scheme,
28
+ groupLabel,
29
+ labels,
30
+ icons,
31
+ }: {
32
+ scheme: ColourSchemePreference
33
+ groupLabel: string
34
+ labels: Readonly<Record<ColourSchemePreference, string>>
35
+ icons: Readonly<Record<ColourSchemePreference, React.ReactNode>>
36
+ }) {
37
+ const [state, action] = useActionState(setSchemeAction, EMPTY)
38
+ const current = state.scheme ?? scheme
39
+
40
+ return (
41
+ <form action={action}>
42
+ <ProgressiveMarker />
43
+ <span
44
+ role="group"
45
+ aria-label={groupLabel}
46
+ className="inline-flex items-center overflow-hidden rounded-md border border-border text-muted-foreground"
47
+ >
48
+ {COLOUR_SCHEMES.map((option) => {
49
+ const selected = current === option
50
+ return (
51
+ <PendingButton
52
+ key={option}
53
+ name="scheme"
54
+ value={option}
55
+ title={labels[option]}
56
+ aria-pressed={selected}
57
+ onClick={() => applySchemeImmediately(option)}
58
+ className={`inline-flex h-8 items-center justify-center border-border px-2.5 transition-colors [&:not(:first-child)]:border-l focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${
59
+ selected
60
+ ? 'bg-primary text-primary-foreground'
61
+ : 'bg-background hover:bg-accent hover:text-accent-foreground'
62
+ }`}
63
+ >
64
+ {icons[option]}
65
+ <span className="sr-only">{labels[option]}</span>
66
+ </PendingButton>
67
+ )
68
+ })}
69
+ </span>
70
+ </form>
71
+ )
72
+ }
@@ -1,17 +1,12 @@
1
1
  import { PendingButton } from '@/components/auth/form-controls'
2
2
  import { MonitorIcon, MoonIcon, SunIcon } from '@/components/shell/appearance-icons'
3
+ import { SchemeToggle } from '@/components/shell/scheme-toggle'
3
4
  import { setAppearanceAction } from '@/server/appearance-actions'
4
5
  import { getTranslator } from '@/server/i18n'
5
6
  import { currentColourScheme, currentThemeKey } from '@/server/theme'
6
7
  import { getBoardThemeStyle } from '@/server/theme-runtime'
7
8
  import { COLOUR_SCHEMES, type ColourSchemePreference } from '@/view/theme-preference'
8
9
 
9
- const SCHEME_ICON: Record<ColourSchemePreference, () => React.ReactNode> = {
10
- light: SunIcon,
11
- system: MonitorIcon,
12
- dark: MoonIcon,
13
- }
14
-
15
10
  const SCHEME_LABEL_KEY: Record<ColourSchemePreference, string> = {
16
11
  system: 'appearance.scheme.system',
17
12
  light: 'appearance.scheme.light',
@@ -29,6 +24,15 @@ export async function ThemeSwitcher() {
29
24
  getTranslator(),
30
25
  ])
31
26
 
27
+ const icons: Record<ColourSchemePreference, React.ReactNode> = {
28
+ light: <SunIcon />,
29
+ system: <MonitorIcon />,
30
+ dark: <MoonIcon />,
31
+ }
32
+ const labels = Object.fromEntries(
33
+ COLOUR_SCHEMES.map((option) => [option, t.t(SCHEME_LABEL_KEY[option])]),
34
+ ) as Record<ColourSchemePreference, string>
35
+
32
36
  return (
33
37
  <section
34
38
  aria-label={t.t('appearance.section')}
@@ -55,36 +59,12 @@ export async function ThemeSwitcher() {
55
59
  </form>
56
60
  )}
57
61
 
58
- <form action={setAppearanceAction}>
59
- <span
60
- role="group"
61
- aria-label={t.t('appearance.schemeGroup')}
62
- className="inline-flex items-center overflow-hidden rounded-md border border-border text-muted-foreground"
63
- >
64
- {COLOUR_SCHEMES.map((option) => {
65
- const Icon = SCHEME_ICON[option]
66
- const label = t.t(SCHEME_LABEL_KEY[option])
67
- const selected = scheme === option
68
- return (
69
- <PendingButton
70
- key={option}
71
- name="scheme"
72
- value={option}
73
- title={label}
74
- aria-pressed={selected}
75
- className={`inline-flex h-8 items-center justify-center border-border px-2.5 transition-colors [&:not(:first-child)]:border-l focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${
76
- selected
77
- ? 'bg-primary text-primary-foreground'
78
- : 'bg-background hover:bg-accent hover:text-accent-foreground'
79
- }`}
80
- >
81
- <Icon />
82
- <span className="sr-only">{label}</span>
83
- </PendingButton>
84
- )
85
- })}
86
- </span>
87
- </form>
62
+ <SchemeToggle
63
+ scheme={scheme}
64
+ groupLabel={t.t('appearance.schemeGroup')}
65
+ labels={labels}
66
+ icons={icons}
67
+ />
88
68
  </section>
89
69
  )
90
70
  }
@@ -35,7 +35,7 @@ export function ViewTabs({
35
35
  'inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-sm whitespace-nowrap transition-colors',
36
36
  'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
37
37
  tab.isCurrent
38
- ? 'bg-card font-semibold text-foreground shadow-sm'
38
+ ? 'bg-card font-semibold text-primary shadow-sm'
39
39
  : 'font-medium text-muted-foreground hover:text-foreground',
40
40
  )}
41
41
  >
@@ -3,11 +3,20 @@
3
3
  import { redirect } from 'next/navigation'
4
4
 
5
5
  import { generateToken, hashToken, verifyPassword } from '@meith/accounts'
6
- import { ipAllowed } from '@meith/admin'
6
+ import { type AdminService, ipAllowed } from '@meith/admin'
7
7
  import { ForbiddenError, logger, ValidationError } from '@meith/core'
8
8
  import { msg } from '@meith/i18n'
9
9
 
10
- import { adminAllowlist, adminService, recordAdminAction, resolveAdmin } from './admin'
10
+ import {
11
+ abandonAdminSecondFactor,
12
+ adminAllowlist,
13
+ adminService,
14
+ holdAdminSecondFactor,
15
+ pendingAdminSecondFactor,
16
+ recordAdminAction,
17
+ redeemAdminSecondFactor,
18
+ resolveAdmin,
19
+ } from './admin'
11
20
  import { boardAuthConfig } from './auth-config'
12
21
  import type { FormState } from './auth-form-state'
13
22
  import { getContainer } from './container'
@@ -71,31 +80,21 @@ export async function adminSignInAction(_prev: FormState, form: FormData): Promi
71
80
  throw new ForbiddenError(msg('error.app.password-right'))
72
81
  }
73
82
 
74
- try {
75
- await assertSecondFactorGiven({ userId: actor.userId, form })
76
- } catch (err) {
77
- await failAttempt()
78
- throw err
79
- }
83
+ const next = safeAdminReturn(text(form, 'next'))
84
+ const twoFactor = twoFactorService()
80
85
 
81
- await accountStore.loginAttempts.clear(attemptBucket)
82
-
83
- const existing = await resolveAdmin()
84
- if ('context' in existing) {
85
- await service.markReauthenticated(existing.context.session.id)
86
- await recordAdminAction({ action: 'admin.reauthenticated' })
86
+ if (twoFactor !== null && (await twoFactor.isEnrolled(actor.userId))) {
87
+ await holdAdminSecondFactor(actor.userId, next)
88
+ target = '/admin'
87
89
  } else {
88
- const token = generateToken()
89
- const session = await service.start({
90
- userId: actor.userId,
91
- tokenHash: await hashToken(token),
92
- ipPrefix: await retainedIpPrefix(),
93
- })
94
- await setAdminCookie(token, session.expiresAt)
95
- await recordAdminAction({ action: 'admin.signed_in' })
96
- }
90
+ if (twoFactor !== null && (await twoFactorRequiredForStaff())) {
91
+ await failAttempt()
92
+ throw new ForbiddenError(msg('adminAction.twoFactorRequired'))
93
+ }
97
94
 
98
- target = safeAdminReturn(text(form, 'next'))
95
+ await accountStore.loginAttempts.clear(attemptBucket)
96
+ target = await startAdminSession(service, actor.userId, next)
97
+ }
99
98
  } catch (err) {
100
99
  return toFormState(err)
101
100
  }
@@ -103,39 +102,109 @@ export async function adminSignInAction(_prev: FormState, form: FormData): Promi
103
102
  redirect(target)
104
103
  }
105
104
 
106
- async function adminReauthenticationBucket(userId: number): Promise<string> {
107
- const prefix = await retainedIpPrefix()
108
- return prefix === null ? `admin-reauth:${userId}` : `admin-reauth:${userId}@${prefix}`
109
- }
105
+ export async function adminVerifySecondFactorAction(
106
+ _prev: FormState,
107
+ form: FormData,
108
+ ): Promise<FormState> {
109
+ let target: string
110
110
 
111
- async function assertSecondFactorGiven(input: {
112
- readonly userId: number
113
- readonly form: FormData
114
- }): Promise<void> {
115
- const service = twoFactorService()
116
- if (service === null) return
111
+ try {
112
+ if (!ipAllowed(await remoteAddress(), await adminAllowlist())) {
113
+ throw new ForbiddenError(msg('error.app.control-panel-available-from-address'))
114
+ }
117
115
 
118
- const enrolled = await service.isEnrolled(input.userId)
116
+ const service = adminService()
117
+ if (service === null) {
118
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-25'))
119
+ }
119
120
 
120
- if (!enrolled) {
121
- if (await twoFactorRequiredForStaff()) {
122
- throw new ForbiddenError(msg('adminAction.twoFactorRequired'))
121
+ const actor = await getActor()
122
+ const { authorizer, accountStore } = getContainer()
123
+ if (actor.userId === null || !authorizer.can(actor, 'admincp.access')) {
124
+ throw new ForbiddenError(msg('error.app.reach-control-panel'))
123
125
  }
124
- return
125
- }
126
126
 
127
- const code = text(input.form, 'code')
128
- if (code === '') {
129
- throw new ValidationError(msg('error.app.enter-code-from-authenticator-app'))
130
- }
127
+ const twoFactor = twoFactorService()
128
+ const pending = await pendingAdminSecondFactor()
129
+ if (twoFactor === null || pending === null) {
130
+ throw new ForbiddenError(msg('adminAction.twoFactorExpired'))
131
+ }
132
+
133
+ const config = await boardAuthConfig()
134
+ const attemptBucket = await adminReauthenticationBucket(actor.userId)
135
+ const attemptSince = new Date(Date.now() - config.lockoutMinutes * 60_000)
136
+ if (
137
+ config.maxLoginAttempts > 0 &&
138
+ (await accountStore.loginAttempts.countFailuresSince(attemptBucket, attemptSince)) >=
139
+ config.maxLoginAttempts
140
+ ) {
141
+ throw new ForbiddenError(msg('error.accounts.too-many-failed-attempts-please'))
142
+ }
131
143
 
132
- const outcome = await service.verify({ userId: input.userId, code })
133
- if (outcome.status !== 'ok') {
134
- if (outcome.status === 'replayed') {
135
- throw new ForbiddenError(msg('adminAction.codeReplayed'))
144
+ const failAttempt = async (): Promise<void> => {
145
+ await accountStore.loginAttempts.record(attemptBucket, false, new Date())
146
+ await recordAdminAction({ action: 'admin.signin_failed' })
136
147
  }
137
- throw new ForbiddenError(msg('adminAction.codeWrong'))
148
+
149
+ const code = text(form, 'code')
150
+ if (code === '') {
151
+ await failAttempt()
152
+ throw new ValidationError(msg('error.app.enter-code-from-authenticator-app'))
153
+ }
154
+
155
+ const outcome = await twoFactor.verify({ userId: pending.userId, code })
156
+ if (outcome.status !== 'ok') {
157
+ await failAttempt()
158
+ throw new ForbiddenError(
159
+ msg(outcome.status === 'replayed' ? 'adminAction.codeReplayed' : 'adminAction.codeWrong'),
160
+ )
161
+ }
162
+
163
+ const redeemed = await redeemAdminSecondFactor()
164
+ if (redeemed === null) {
165
+ throw new ForbiddenError(msg('adminAction.twoFactorExpired'))
166
+ }
167
+
168
+ await accountStore.loginAttempts.clear(attemptBucket)
169
+ target = await startAdminSession(service, pending.userId, safeAdminReturn(redeemed.next))
170
+ } catch (err) {
171
+ return toFormState(err)
172
+ }
173
+
174
+ redirect(target)
175
+ }
176
+
177
+ export async function adminAbandonSecondFactorAction(): Promise<void> {
178
+ await abandonAdminSecondFactor()
179
+ redirect('/admin')
180
+ }
181
+
182
+ async function startAdminSession(
183
+ service: AdminService,
184
+ userId: number,
185
+ target: string,
186
+ ): Promise<string> {
187
+ const existing = await resolveAdmin()
188
+ if ('context' in existing) {
189
+ await service.markReauthenticated(existing.context.session.id)
190
+ await recordAdminAction({ action: 'admin.reauthenticated' })
191
+ } else {
192
+ const token = generateToken()
193
+ const session = await service.start({
194
+ userId,
195
+ tokenHash: await hashToken(token),
196
+ ipPrefix: await retainedIpPrefix(),
197
+ })
198
+ await setAdminCookie(token, session.expiresAt)
199
+ await recordAdminAction({ action: 'admin.signed_in' })
138
200
  }
201
+
202
+ return target
203
+ }
204
+
205
+ async function adminReauthenticationBucket(userId: number): Promise<string> {
206
+ const prefix = await retainedIpPrefix()
207
+ return prefix === null ? `admin-reauth:${userId}` : `admin-reauth:${userId}@${prefix}`
139
208
  }
140
209
 
141
210
  export async function adminSignOutAction(): Promise<void> {
@@ -18,6 +18,7 @@ import { recordAdminAction, requireAdmin } from './admin'
18
18
  import type { FormState } from './auth-form-state'
19
19
  import { tr } from './i18n'
20
20
  import { emitEvent } from './plugin-view'
21
+ import { searchProvider } from './search'
21
22
  import { getSettings } from './settings'
22
23
 
23
24
  function submittedKeys(form: FormData): readonly SettingDefinition[] {
@@ -59,6 +60,10 @@ export async function saveAdminSettingsAction(
59
60
  await getSettings(),
60
61
  )
61
62
 
63
+ if (result.changed.includes('search.language')) {
64
+ await searchProvider()?.markForReindex()
65
+ }
66
+
62
67
  if (result.changed.length > 0) {
63
68
  const tags = [CacheTags.settings(), ...result.invalidates]
64
69
  await drivers().cache.invalidateTags(tags)
@@ -4,14 +4,20 @@ import 'server-only'
4
4
  import { notFound } from 'next/navigation'
5
5
  import { cache } from 'react'
6
6
 
7
- import { hashToken } from '@meith/accounts'
7
+ import { generateToken, hashToken } from '@meith/accounts'
8
8
  import { type AdminContext, AdminService, ipAllowed, parseAllowlist } from '@meith/admin'
9
9
  import { env, ForbiddenError } from '@meith/core'
10
10
 
11
11
  import { getContainer } from './container'
12
12
  import { getActor } from './context'
13
+ import { ADMIN_SECOND_FACTOR_MINUTES } from './cookies'
13
14
  import { remoteAddress, retainedIpPrefix } from './request-fingerprint'
14
- import { readAdminToken } from './session-cookies'
15
+ import {
16
+ clearAdminSecondFactorCookie,
17
+ readAdminSecondFactorToken,
18
+ readAdminToken,
19
+ setAdminSecondFactorCookie,
20
+ } from './session-cookies'
15
21
 
16
22
  export type AdminDenial = 'address' | 'permission' | 'signin' | 'expired' | 'unavailable'
17
23
 
@@ -69,6 +75,68 @@ export function askForPassword(denied: AdminDenial): boolean {
69
75
  return denied === 'signin' || denied === 'expired'
70
76
  }
71
77
 
78
+ export interface AdminSecondFactorHold {
79
+ readonly userId: number
80
+ readonly next: string
81
+ }
82
+
83
+ export async function holdAdminSecondFactor(userId: number, next: string): Promise<void> {
84
+ const { tokens } = getContainer().accountStore
85
+ await tokens.revokeAllForUser(userId, 'admin_second_factor')
86
+
87
+ const token = generateToken()
88
+ const expiresAt = new Date(Date.now() + ADMIN_SECOND_FACTOR_MINUTES * 60_000)
89
+ await tokens.issue({
90
+ tokenHash: await hashToken(token),
91
+ userId,
92
+ purpose: 'admin_second_factor',
93
+ payload: next,
94
+ expiresAt,
95
+ })
96
+
97
+ await setAdminSecondFactorCookie(token, expiresAt)
98
+ }
99
+
100
+ export async function pendingAdminSecondFactor(): Promise<AdminSecondFactorHold | null> {
101
+ const token = await readAdminSecondFactorToken()
102
+ if (token === null || token === '') return null
103
+
104
+ const held = await getContainer().accountStore.tokens.peek(
105
+ await hashToken(token),
106
+ 'admin_second_factor',
107
+ new Date(),
108
+ )
109
+ if (held === null) return null
110
+
111
+ const actor = await getActor()
112
+ if (actor.userId === null || held.userId !== actor.userId) return null
113
+
114
+ return { userId: held.userId, next: held.payload ?? '/admin' }
115
+ }
116
+
117
+ export async function redeemAdminSecondFactor(): Promise<AdminSecondFactorHold | null> {
118
+ const token = await readAdminSecondFactorToken()
119
+ await clearAdminSecondFactorCookie()
120
+ if (token === null || token === '') return null
121
+
122
+ const redeemed = await getContainer().accountStore.tokens.consume(
123
+ await hashToken(token),
124
+ 'admin_second_factor',
125
+ new Date(),
126
+ )
127
+ if (redeemed === null) return null
128
+
129
+ return { userId: redeemed.userId, next: redeemed.payload ?? '/admin' }
130
+ }
131
+
132
+ export async function abandonAdminSecondFactor(): Promise<void> {
133
+ const actor = await getActor()
134
+ if (actor.userId !== null) {
135
+ await getContainer().accountStore.tokens.revokeAllForUser(actor.userId, 'admin_second_factor')
136
+ }
137
+ await clearAdminSecondFactorCookie()
138
+ }
139
+
72
140
  export async function adminPageContext(): Promise<AdminContext | null> {
73
141
  const resolved = await resolveAdmin()
74
142
  if ('context' in resolved) return resolved.context
@@ -13,7 +13,13 @@ import {
13
13
 
14
14
  import { filterWords } from '../../view/word-filter'
15
15
  import { activeWordFilter } from '../content-admin'
16
- import { requireSearch, requireSearchEnabled, searchMinWordLength, searchScopeFor } from '../search'
16
+ import {
17
+ requireSearch,
18
+ requireSearchEnabled,
19
+ searchLanguage,
20
+ searchMinWordLength,
21
+ searchScopeFor,
22
+ } from '../search'
17
23
  import {
18
24
  type ApiResult,
19
25
  type ApiRoutes,
@@ -55,7 +61,7 @@ export const SEARCH_HANDLERS: ApiRoutes = [
55
61
  const forumIds = ids('forum')
56
62
  const authorUserIds = ids('by')
57
63
 
58
- const results = await requireSearch().search(
64
+ const results = await requireSearch(await searchLanguage()).search(
59
65
  searchQueryFrom(
60
66
  parsed.terms,
61
67
  {
@@ -8,6 +8,7 @@ import { msg } from '@meith/i18n'
8
8
  import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
9
9
  import { apiTokenStore, issueApiToken } from './api-tokens-admin'
10
10
  import type { FormState } from './auth-form-state'
11
+ import { requireConfirmation } from './confirm'
11
12
  import { getMessageResolver, tr } from './i18n'
12
13
 
13
14
  function field(form: FormData, name: string): string {
@@ -69,6 +70,9 @@ export async function revokeApiTokenAction(_prev: FormState, form: FormData): Pr
69
70
  const id = Number(field(form, 'tokenId'))
70
71
  if (!Number.isSafeInteger(id) || id <= 0) return { error: await tr('notice.app.such-token') }
71
72
 
73
+ const confirm = requireConfirmation(form, await tr('adminPanel.token.confirmRevoke'))
74
+ if (confirm !== null) return confirm
75
+
72
76
  const store = apiTokenStore()
73
77
  if (store === null) return { error: await tr('notice.app.board-database-api') }
74
78
 
@@ -2,7 +2,9 @@
2
2
 
3
3
  import { cookies } from 'next/headers'
4
4
 
5
+ import { isEnhancedSubmit } from '@/view/progressive-enhancement'
5
6
  import {
7
+ type ColourSchemePreference,
6
8
  isColourScheme,
7
9
  PREFERENCE_COOKIE_MAX_AGE,
8
10
  SCHEME_COOKIE,
@@ -37,3 +39,22 @@ export async function setAppearanceAction(form: FormData): Promise<void> {
37
39
 
38
40
  await redirectToCurrentPath()
39
41
  }
42
+
43
+ export interface SchemeState {
44
+ readonly scheme?: ColourSchemePreference | undefined
45
+ }
46
+
47
+ export async function setSchemeAction(_prev: SchemeState, form: FormData): Promise<SchemeState> {
48
+ const raw = form.get('scheme')
49
+ const scheme = typeof raw === 'string' && isColourScheme(raw) ? raw : null
50
+
51
+ if (scheme !== null) {
52
+ const jar = await cookies()
53
+ jar.set(SCHEME_COOKIE, scheme, COOKIE_OPTIONS)
54
+ }
55
+
56
+ if (isEnhancedSubmit(form)) return { scheme: scheme ?? undefined }
57
+
58
+ await redirectToCurrentPath()
59
+ return {}
60
+ }