@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.
- package/app/(board)/[slug]/feed.xml/route.ts +5 -3
- package/app/(board)/[slug]/new/page.tsx +5 -0
- package/app/(board)/[slug]/page.tsx +7 -1
- package/app/(board)/discover/[view]/page.tsx +6 -1
- package/app/(board)/member/[id]/page.tsx +3 -5
- package/app/(board)/member/[id]/reputation/page.tsx +8 -15
- package/app/(board)/members/page.tsx +3 -8
- package/app/(board)/messages/[id]/page.tsx +3 -7
- package/app/(board)/messages/page.tsx +3 -5
- package/app/(board)/modcp/ip/page.tsx +3 -5
- package/app/(board)/modcp/plugins/[key]/[[...path]]/page.tsx +84 -0
- package/app/(board)/moderation/reports/page.tsx +53 -15
- package/app/(board)/moderation/warn/page.tsx +3 -6
- package/app/(board)/notifications/page.tsx +5 -10
- package/app/(board)/notifications/preferences/page.tsx +8 -0
- package/app/(board)/online/page.tsx +12 -6
- package/app/(board)/staff/page.tsx +4 -5
- package/app/(board)/stats/page.tsx +10 -3
- package/app/(board)/subscriptions/page.tsx +5 -10
- package/app/(board)/thread/[slug]/edit/page.tsx +21 -0
- package/app/(board)/thread/[slug]/feed.xml/route.ts +6 -3
- package/app/(board)/thread/[slug]/page.tsx +49 -11
- package/app/(board)/thread/[slug]/reply/page.tsx +5 -0
- package/app/(board)/unsubscribe/page.tsx +8 -11
- package/app/(board)/usercp/contacts/page.tsx +3 -5
- package/app/(board)/usercp/drafts/page.tsx +70 -0
- package/app/(board)/usercp/options/page.tsx +8 -1
- package/app/(board)/usercp/security/page.tsx +16 -0
- package/app/admin/antispam/page.tsx +4 -12
- package/app/admin/content/attachments/page.tsx +3 -6
- package/app/admin/content/page.tsx +3 -13
- package/app/admin/forums/[id]/permissions/page.tsx +9 -12
- package/app/admin/groups/page.tsx +7 -9
- package/app/admin/layout.tsx +23 -8
- package/app/admin/page.tsx +12 -6
- package/app/admin/plugins/[key]/[[...path]]/page.tsx +6 -7
- package/app/admin/plugins/page.tsx +3 -6
- package/app/admin/users/[id]/page.tsx +5 -11
- package/app/admin/users/prune/page.tsx +4 -12
- package/app/admin/webhooks/page.tsx +144 -0
- package/app/api/subscribe/thread/[id]/route.ts +53 -0
- package/app/api/unsubscribe/thread/[id]/route.ts +42 -0
- package/app/atom.xml/route.ts +2 -2
- package/app/auth/sso/[provider]/callback/route.ts +10 -0
- package/app/feed.xml/route.ts +2 -2
- package/app/offline/route.ts +98 -0
- package/next.config.mjs +1 -0
- package/package.json +49 -48
- package/public/sw.js +42 -2
- package/src/components/account/draft-forms.tsx +39 -0
- package/src/components/account/feed-token.tsx +95 -0
- package/src/components/account/notification-forms.tsx +37 -2
- package/src/components/account/relation-forms.tsx +3 -2
- package/src/components/account/subscription-forms.tsx +20 -6
- package/src/components/account/usercp-forms.tsx +44 -0
- package/src/components/admin/admin-forms.tsx +57 -9
- package/src/components/admin/api-token-forms.tsx +11 -7
- package/src/components/admin/ban-filter-forms.tsx +15 -11
- package/src/components/admin/content-forms.tsx +31 -14
- package/src/components/admin/forum-forms.tsx +241 -129
- package/src/components/admin/forum-tree.tsx +5 -7
- package/src/components/admin/navigation-forms.tsx +43 -26
- package/src/components/admin/navigation-tree.tsx +8 -13
- package/src/components/admin/oklch-picker.tsx +2 -1
- package/src/components/admin/theme-changes.tsx +3 -2
- package/src/components/admin/theme-forms.tsx +3 -2
- package/src/components/admin/webhook-forms.tsx +137 -0
- package/src/components/auth/register-form.tsx +4 -7
- package/src/components/board/live-region.tsx +3 -1
- package/src/components/content/attachment-field.tsx +93 -22
- package/src/components/content/combobox-keys.ts +38 -0
- package/src/components/content/edit-post-form.tsx +75 -1
- package/src/components/content/markdown-editor.tsx +29 -86
- package/src/components/content/mention-combobox.tsx +137 -0
- package/src/components/content/multiquote-button.tsx +15 -12
- package/src/components/content/new-thread-form.tsx +95 -17
- package/src/components/content/poll-edit-form.tsx +61 -39
- package/src/components/content/poll-vote-form.tsx +91 -0
- package/src/components/content/poll.tsx +12 -48
- package/src/components/content/progressive-marker.tsx +13 -0
- package/src/components/content/recipient-segment.ts +39 -0
- package/src/components/content/reply-form.tsx +9 -1
- package/src/components/content/thanks-button.tsx +12 -7
- package/src/components/messages/message-forms.tsx +53 -43
- package/src/components/messages/recipient-field.tsx +88 -0
- package/src/components/moderation/inline-moderation-form.tsx +2 -0
- package/src/components/moderation/modcp-shell.tsx +17 -3
- package/src/components/moderation/queue-form.tsx +3 -6
- package/src/components/moderation/report-forms.tsx +24 -5
- package/src/components/moderation/thread-tools-form.tsx +2 -0
- package/src/components/shell/board-notice.tsx +22 -1
- package/src/components/shell/confirm-dialog.tsx +93 -0
- package/src/components/shell/nav-disclosure-enhancer.tsx +36 -0
- package/src/components/shell/notice-toast.tsx +40 -0
- package/src/components/shell/notification-menu.tsx +5 -3
- package/src/components/shell/page-shell.tsx +3 -0
- package/src/components/shell/scheme-toggle.tsx +72 -0
- package/src/components/shell/theme-switcher.tsx +16 -36
- package/src/components/shell/view-tabs.tsx +1 -1
- package/src/server/admin-actions.ts +118 -49
- package/src/server/admin-settings-actions.ts +5 -0
- package/src/server/admin.ts +70 -2
- package/src/server/api/search.ts +8 -2
- package/src/server/api-token-actions.ts +4 -0
- package/src/server/appearance-actions.ts +21 -0
- package/src/server/attachments.ts +35 -0
- package/src/server/auth-actions.ts +2 -0
- package/src/server/auth-form-state.ts +28 -0
- package/src/server/auto-watch.ts +31 -0
- package/src/server/ban-filter-admin-actions.ts +5 -0
- package/src/server/confirm.ts +19 -0
- package/src/server/content-actions.ts +129 -19
- package/src/server/content-admin-actions.ts +31 -0
- package/src/server/cookies.ts +18 -0
- package/src/server/discovery.ts +11 -1
- package/src/server/feed-routes.ts +13 -4
- package/src/server/feed-token-actions.ts +61 -0
- package/src/server/feed-token.ts +78 -0
- package/src/server/fixture-post-repo.ts +18 -0
- package/src/server/forum-admin-actions.ts +23 -16
- package/src/server/forum-admin.ts +10 -1
- package/src/server/inline-moderation-actions.ts +6 -0
- package/src/server/message-actions.ts +9 -0
- package/src/server/navigation-admin-actions.ts +6 -0
- package/src/server/plugin-admin.ts +7 -2
- package/src/server/plugin-pages.ts +50 -0
- package/src/server/plugin-panel.ts +34 -1
- package/src/server/plugin-routes.ts +8 -0
- package/src/server/plugin-view.tsx +46 -7
- package/src/server/poll-actions.ts +13 -1
- package/src/server/presence.ts +23 -0
- package/src/server/push-actions.ts +9 -0
- package/src/server/reply-core.ts +8 -1
- package/src/server/report-actions.ts +13 -2
- package/src/server/reputation-actions.ts +12 -1
- package/src/server/search-page.ts +8 -2
- package/src/server/search.ts +18 -5
- package/src/server/session-cookies.ts +18 -0
- package/src/server/subscription-actions.ts +9 -0
- package/src/server/thread-core.ts +10 -2
- package/src/server/thread-tool-actions.ts +6 -0
- package/src/server/unread-goto.ts +28 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/src/server/usercp-actions.ts +31 -1
- package/src/server/viewer-preferences.ts +5 -0
- package/src/server/webhook-actions.ts +98 -0
- package/src/server/webhooks-admin.ts +122 -0
- package/src/styles/globals.css +1 -1
- package/src/theme/contract.fixture.ts +36 -5
- package/src/view/account-copy.ts +39 -0
- package/src/view/admin-copy.ts +4 -9
- package/src/view/admin-nav.ts +6 -0
- package/src/view/admin-panel-copy.ts +29 -1
- package/src/view/attachments.ts +33 -1
- package/src/view/board-index.ts +31 -2
- package/src/view/board-title.ts +1 -0
- package/src/view/content-copy.ts +22 -12
- package/src/view/copy.ts +17 -0
- package/src/view/discovery-view.ts +2 -1
- package/src/view/drafts.ts +45 -0
- package/src/view/editor-toolbar.ts +14 -0
- package/src/view/forum-display.ts +39 -7
- package/src/view/modcp-nav.ts +32 -0
- package/src/view/moderation-copy.ts +20 -0
- package/src/view/notifications.ts +1 -0
- package/src/view/plugin-panel.ts +13 -0
- package/src/view/poll-vote.ts +46 -0
- package/src/view/post-link.ts +8 -1
- package/src/view/progressive-enhancement.ts +5 -0
- package/src/view/shell.ts +2 -1
- package/src/view/subscriptions.ts +1 -0
- package/src/view/thread-view.ts +41 -2
- package/src/view/usercp-nav.ts +6 -0
- 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
|
-
<
|
|
227
|
+
<TextLink
|
|
227
228
|
href={tab.allHref}
|
|
228
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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-
|
|
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 {
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
105
|
+
export async function adminVerifySecondFactorAction(
|
|
106
|
+
_prev: FormState,
|
|
107
|
+
form: FormData,
|
|
108
|
+
): Promise<FormState> {
|
|
109
|
+
let target: string
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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)
|
package/src/server/admin.ts
CHANGED
|
@@ -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 {
|
|
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
|
package/src/server/api/search.ts
CHANGED
|
@@ -13,7 +13,13 @@ import {
|
|
|
13
13
|
|
|
14
14
|
import { filterWords } from '../../view/word-filter'
|
|
15
15
|
import { activeWordFilter } from '../content-admin'
|
|
16
|
-
import {
|
|
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
|
+
}
|