@meith/web 0.32.0 → 0.33.1
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/admin/layout.tsx +23 -8
- package/app/admin/system/page.tsx +15 -1
- package/app/redirect/page.tsx +5 -4
- package/package.json +49 -49
- package/src/components/admin/admin-forms.tsx +57 -9
- package/src/components/admin/theme-forms.tsx +1 -1
- package/src/components/shell/view-tabs.tsx +1 -1
- package/src/server/admin-actions.ts +118 -49
- package/src/server/admin.ts +70 -2
- package/src/server/cookies.ts +18 -0
- package/src/server/image-upload.ts +31 -1
- package/src/server/push-actions.ts +9 -0
- package/src/server/session-cookies.ts +18 -0
- package/src/server/system-admin.ts +13 -9
- package/src/server/upgrade-notice.ts +1 -1
- package/src/styles/globals.css +1 -1
- package/src/view/admin-panel-copy.ts +6 -1
- package/src/view/redirect-notice.ts +19 -2
package/app/admin/layout.tsx
CHANGED
|
@@ -3,12 +3,17 @@ import { notFound } from 'next/navigation'
|
|
|
3
3
|
|
|
4
4
|
import { ADMIN_IDLE_MINUTES } from '@meith/admin'
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
AdminSecondFactorForm,
|
|
8
|
+
AdminSignInForm,
|
|
9
|
+
AdminSignOutForm,
|
|
10
|
+
} from '@/components/admin/admin-forms'
|
|
7
11
|
import { AdminNav } from '@/components/admin/admin-nav'
|
|
8
12
|
import { PanelShell } from '@/components/shell/panel-shell'
|
|
9
|
-
import { askForPassword, resolveAdmin } from '@/server/admin'
|
|
13
|
+
import { askForPassword, pendingAdminSecondFactor, resolveAdmin } from '@/server/admin'
|
|
10
14
|
import { getActor } from '@/server/context'
|
|
11
15
|
import { getTranslator, tr } from '@/server/i18n'
|
|
16
|
+
import { twoFactorState } from '@/server/two-factor'
|
|
12
17
|
import { adminFormsCopy } from '@/view/admin-panel-copy'
|
|
13
18
|
import { buildPanelLinks } from '@/view/shell'
|
|
14
19
|
|
|
@@ -22,18 +27,28 @@ export default async function AdminLayout({ children }: { children: React.ReactN
|
|
|
22
27
|
if ('denied' in resolved) {
|
|
23
28
|
if (!askForPassword(resolved.denied)) notFound()
|
|
24
29
|
|
|
30
|
+
const copy = adminFormsCopy(await getTranslator())
|
|
31
|
+
const pending = await pendingAdminSecondFactor()
|
|
32
|
+
|
|
25
33
|
return (
|
|
26
34
|
<main
|
|
27
35
|
id="board-content"
|
|
28
36
|
tabIndex={-1}
|
|
29
37
|
className="flex min-h-screen flex-col items-center justify-center gap-4 px-6 py-12"
|
|
30
38
|
>
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
{pending === null ? (
|
|
40
|
+
<AdminSignInForm
|
|
41
|
+
next="/admin"
|
|
42
|
+
reason={resolved.denied === 'expired' ? 'expired' : null}
|
|
43
|
+
idleMinutes={ADMIN_IDLE_MINUTES}
|
|
44
|
+
copy={copy}
|
|
45
|
+
/>
|
|
46
|
+
) : (
|
|
47
|
+
<AdminSecondFactorForm
|
|
48
|
+
recoveryCodesLeft={(await twoFactorState(pending.userId)).recoveryCodesLeft}
|
|
49
|
+
copy={copy}
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
37
52
|
<a href="/" className="text-sm text-muted-foreground hover:text-foreground">
|
|
38
53
|
{await tr('adminPanel.cancel')}
|
|
39
54
|
</a>
|
|
@@ -53,7 +53,7 @@ export default async function AdminSystemPage() {
|
|
|
53
53
|
)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
const { mail, scheduler, volumes } = view
|
|
56
|
+
const { mail, scheduler, volumes, legacyPasswordHashes } = view
|
|
57
57
|
|
|
58
58
|
return (
|
|
59
59
|
<PanelPage
|
|
@@ -252,6 +252,20 @@ export default async function AdminSystemPage() {
|
|
|
252
252
|
</ul>
|
|
253
253
|
</section>
|
|
254
254
|
|
|
255
|
+
{legacyPasswordHashes > 0 && (
|
|
256
|
+
<section className={PANEL_CARD}>
|
|
257
|
+
<h2 className="font-heading text-lg font-semibold">
|
|
258
|
+
{translator.t('adminSystem.legacyPasswords')}
|
|
259
|
+
</h2>
|
|
260
|
+
<p className="text-sm">
|
|
261
|
+
{translator.t('adminSystem.legacyPasswordsCount', { count: legacyPasswordHashes })}
|
|
262
|
+
</p>
|
|
263
|
+
<p className="text-sm text-muted-foreground">
|
|
264
|
+
{translator.t('adminSystem.legacyPasswordsHint')}
|
|
265
|
+
</p>
|
|
266
|
+
</section>
|
|
267
|
+
)}
|
|
268
|
+
|
|
255
269
|
<section className={PANEL_CARD}>
|
|
256
270
|
<h2 className="font-heading text-lg font-semibold">
|
|
257
271
|
{translator.t('adminSystem.migrations')}
|
package/app/redirect/page.tsx
CHANGED
|
@@ -15,12 +15,13 @@ export async function generateMetadata(): Promise<Metadata> {
|
|
|
15
15
|
export default async function RedirectPage({
|
|
16
16
|
searchParams,
|
|
17
17
|
}: {
|
|
18
|
-
searchParams: Promise<{ to?: string;
|
|
18
|
+
searchParams: Promise<{ to?: string; m?: string }>
|
|
19
19
|
}) {
|
|
20
|
-
const { to,
|
|
20
|
+
const { to, m } = await searchParams
|
|
21
|
+
const translator = await getTranslator()
|
|
21
22
|
const filtered = await filterView(
|
|
22
23
|
'view.redirect-notice',
|
|
23
|
-
buildRedirectNotice(to,
|
|
24
|
+
buildRedirectNotice(to, m, translator),
|
|
24
25
|
viewerRef(await getActor()),
|
|
25
26
|
)
|
|
26
27
|
const notice = { ...filtered, targetHref: localHref(filtered.targetHref) }
|
|
@@ -36,7 +37,7 @@ export default async function RedirectPage({
|
|
|
36
37
|
>
|
|
37
38
|
<RedirectNotice
|
|
38
39
|
{...notice}
|
|
39
|
-
copy={slotCopy(await currentTheme(), 'RedirectNotice',
|
|
40
|
+
copy={slotCopy(await currentTheme(), 'RedirectNotice', translator)}
|
|
40
41
|
/>
|
|
41
42
|
</main>
|
|
42
43
|
</>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.1",
|
|
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,54 +43,54 @@
|
|
|
43
43
|
"react-dom": "19.2.8",
|
|
44
44
|
"tailwindcss": "^4.3.3",
|
|
45
45
|
"typescript": "7.0.2",
|
|
46
|
-
"@meith/accounts": "0.
|
|
47
|
-
"@meith/admin": "0.
|
|
48
|
-
"@meith/antispam": "0.
|
|
49
|
-
"@meith/api": "0.
|
|
50
|
-
"@meith/attachments": "0.
|
|
51
|
-
"@meith/authorization": "0.
|
|
52
|
-
"@meith/avatars": "0.
|
|
53
|
-
"@meith/board-digest": "0.
|
|
54
|
-
"@meith/core": "0.
|
|
55
|
-
"@meith/db": "0.
|
|
56
|
-
"@meith/demo": "0.
|
|
57
|
-
"@meith/drafts": "0.
|
|
58
|
-
"@meith/drivers": "0.
|
|
59
|
-
"@meith/events": "0.
|
|
60
|
-
"@meith/forums": "0.
|
|
61
|
-
"@meith/groups": "0.
|
|
62
|
-
"@meith/i18n": "0.
|
|
63
|
-
"@meith/import": "0.
|
|
64
|
-
"@meith/install": "0.
|
|
65
|
-
"@meith/mail": "0.
|
|
66
|
-
"@meith/markdown": "0.
|
|
67
|
-
"@meith/marketplace": "0.
|
|
68
|
-
"@meith/messages": "0.
|
|
69
|
-
"@meith/moderation": "0.
|
|
70
|
-
"@meith/notifications": "0.
|
|
71
|
-
"@meith/plugin-calendar": "0.
|
|
72
|
-
"@meith/plugin-dues": "0.
|
|
73
|
-
"@meith/plugin-kit": "0.
|
|
74
|
-
"@meith/polls": "0.
|
|
75
|
-
"@meith/posts": "0.
|
|
76
|
-
"@meith/profile-fields": "0.
|
|
77
|
-
"@meith/relations": "0.
|
|
78
|
-
"@meith/reputation": "0.
|
|
79
|
-
"@meith/runtime": "0.
|
|
80
|
-
"@meith/search": "0.
|
|
81
|
-
"@meith/settings": "0.
|
|
82
|
-
"@meith/signatures": "0.
|
|
83
|
-
"@meith/subscriptions": "0.
|
|
84
|
-
"@meith/tasks": "0.
|
|
85
|
-
"@meith/theme-clubhouse": "0.
|
|
86
|
-
"@meith/theme-default": "0.
|
|
87
|
-
"@meith/theme-kit": "0.
|
|
88
|
-
"@meith/theme-midnight": "0.
|
|
89
|
-
"@meith/theme-phasebook": "0.
|
|
90
|
-
"@meith/theme-raidframe": "0.
|
|
91
|
-
"@meith/threads": "0.
|
|
92
|
-
"@meith/ui": "0.
|
|
93
|
-
"@meith/upgrade": "0.
|
|
46
|
+
"@meith/accounts": "0.33.1",
|
|
47
|
+
"@meith/admin": "0.33.1",
|
|
48
|
+
"@meith/antispam": "0.33.1",
|
|
49
|
+
"@meith/api": "0.33.1",
|
|
50
|
+
"@meith/attachments": "0.33.1",
|
|
51
|
+
"@meith/authorization": "0.33.1",
|
|
52
|
+
"@meith/avatars": "0.33.1",
|
|
53
|
+
"@meith/board-digest": "0.33.1",
|
|
54
|
+
"@meith/core": "0.33.1",
|
|
55
|
+
"@meith/db": "0.33.1",
|
|
56
|
+
"@meith/demo": "0.33.1",
|
|
57
|
+
"@meith/drafts": "0.33.1",
|
|
58
|
+
"@meith/drivers": "0.33.1",
|
|
59
|
+
"@meith/events": "0.33.1",
|
|
60
|
+
"@meith/forums": "0.33.1",
|
|
61
|
+
"@meith/groups": "0.33.1",
|
|
62
|
+
"@meith/i18n": "0.33.1",
|
|
63
|
+
"@meith/import": "0.33.1",
|
|
64
|
+
"@meith/install": "0.33.1",
|
|
65
|
+
"@meith/mail": "0.33.1",
|
|
66
|
+
"@meith/markdown": "0.33.1",
|
|
67
|
+
"@meith/marketplace": "0.33.1",
|
|
68
|
+
"@meith/messages": "0.33.1",
|
|
69
|
+
"@meith/moderation": "0.33.1",
|
|
70
|
+
"@meith/notifications": "0.33.1",
|
|
71
|
+
"@meith/plugin-calendar": "0.33.1",
|
|
72
|
+
"@meith/plugin-dues": "0.33.1",
|
|
73
|
+
"@meith/plugin-kit": "0.33.1",
|
|
74
|
+
"@meith/polls": "0.33.1",
|
|
75
|
+
"@meith/posts": "0.33.1",
|
|
76
|
+
"@meith/profile-fields": "0.33.1",
|
|
77
|
+
"@meith/relations": "0.33.1",
|
|
78
|
+
"@meith/reputation": "0.33.1",
|
|
79
|
+
"@meith/runtime": "0.33.1",
|
|
80
|
+
"@meith/search": "0.33.1",
|
|
81
|
+
"@meith/settings": "0.33.1",
|
|
82
|
+
"@meith/signatures": "0.33.1",
|
|
83
|
+
"@meith/subscriptions": "0.33.1",
|
|
84
|
+
"@meith/tasks": "0.33.1",
|
|
85
|
+
"@meith/theme-clubhouse": "0.33.1",
|
|
86
|
+
"@meith/theme-default": "0.33.1",
|
|
87
|
+
"@meith/theme-kit": "0.33.1",
|
|
88
|
+
"@meith/theme-midnight": "0.33.1",
|
|
89
|
+
"@meith/theme-phasebook": "0.33.1",
|
|
90
|
+
"@meith/theme-raidframe": "0.33.1",
|
|
91
|
+
"@meith/threads": "0.33.1",
|
|
92
|
+
"@meith/ui": "0.33.1",
|
|
93
|
+
"@meith/upgrade": "0.33.1"
|
|
94
94
|
},
|
|
95
95
|
"scripts": {
|
|
96
96
|
"dev": "next dev",
|
|
@@ -2,7 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { useActionState } from 'react'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
adminAbandonSecondFactorAction,
|
|
7
|
+
adminSignInAction,
|
|
8
|
+
adminSignOutAction,
|
|
9
|
+
adminVerifySecondFactorAction,
|
|
10
|
+
} from '@/server/admin-actions'
|
|
6
11
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
7
12
|
|
|
8
13
|
import { FormError, PendingButton } from '../auth/form-controls'
|
|
@@ -51,14 +56,6 @@ export function AdminSignInForm({
|
|
|
51
56
|
/>
|
|
52
57
|
</label>
|
|
53
58
|
|
|
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
|
-
/>
|
|
61
|
-
|
|
62
59
|
<PendingButton
|
|
63
60
|
showWorking
|
|
64
61
|
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
@@ -73,6 +70,57 @@ export function AdminSignInForm({
|
|
|
73
70
|
)
|
|
74
71
|
}
|
|
75
72
|
|
|
73
|
+
export function AdminSecondFactorForm({
|
|
74
|
+
recoveryCodesLeft,
|
|
75
|
+
copy,
|
|
76
|
+
}: {
|
|
77
|
+
recoveryCodesLeft: number
|
|
78
|
+
copy: Copy
|
|
79
|
+
}) {
|
|
80
|
+
const [state, action] = useActionState(adminVerifySecondFactorAction, EMPTY_STATE)
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex w-full max-w-sm flex-col gap-4 rounded-lg border border-border bg-card p-6">
|
|
84
|
+
<h1 className="text-xl font-semibold tracking-tight">
|
|
85
|
+
{fromCopy(copy, 'adminPanel.twoFactor.title')}
|
|
86
|
+
</h1>
|
|
87
|
+
|
|
88
|
+
<p className="text-sm text-muted-foreground">{fromCopy(copy, 'adminPanel.twoFactor.lede')}</p>
|
|
89
|
+
|
|
90
|
+
<form action={action} className="flex flex-col gap-4" noValidate>
|
|
91
|
+
<FormError message={state.error} />
|
|
92
|
+
|
|
93
|
+
<OtpField
|
|
94
|
+
label={fromCopy(copy, 'adminPanel.twoFactorCode')}
|
|
95
|
+
name="code"
|
|
96
|
+
hint={
|
|
97
|
+
recoveryCodesLeft > 0
|
|
98
|
+
? fromCopy(copy, 'adminPanel.twoFactor.recoveryHint')
|
|
99
|
+
: fromCopy(copy, 'adminPanel.twoFactor.recoveryHintExhausted')
|
|
100
|
+
}
|
|
101
|
+
recovery={otpRecoveryFromCopy(copy)}
|
|
102
|
+
/>
|
|
103
|
+
|
|
104
|
+
<PendingButton
|
|
105
|
+
showWorking
|
|
106
|
+
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
107
|
+
>
|
|
108
|
+
{fromCopy(copy, 'adminPanel.twoFactor.submit')}
|
|
109
|
+
</PendingButton>
|
|
110
|
+
</form>
|
|
111
|
+
|
|
112
|
+
<form action={adminAbandonSecondFactorAction}>
|
|
113
|
+
<PendingButton
|
|
114
|
+
showWorking
|
|
115
|
+
className="text-sm text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
|
116
|
+
>
|
|
117
|
+
{fromCopy(copy, 'adminPanel.twoFactor.cancel')}
|
|
118
|
+
</PendingButton>
|
|
119
|
+
</form>
|
|
120
|
+
</div>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
76
124
|
export function AdminSignOutForm({ copy }: { copy: Copy }) {
|
|
77
125
|
return (
|
|
78
126
|
<form action={adminSignOutAction}>
|
|
@@ -507,7 +507,7 @@ export function ThemeEditorForm({
|
|
|
507
507
|
<p className="text-xs text-muted-foreground">
|
|
508
508
|
{fromCopy(copy, 'adminTheme.validated.blurb')}
|
|
509
509
|
</p>
|
|
510
|
-
<style
|
|
510
|
+
<style>{state.preview}</style>
|
|
511
511
|
<ValidatedSample copy={copy} />
|
|
512
512
|
</section>
|
|
513
513
|
)}
|
|
@@ -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> {
|
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/cookies.ts
CHANGED
|
@@ -11,6 +11,9 @@ export const GUEST_COOKIE_DAYS = 1
|
|
|
11
11
|
export const ADMIN_COOKIE = 'fs_admin'
|
|
12
12
|
export const ADMIN_COOKIE_PATH = '/admin'
|
|
13
13
|
|
|
14
|
+
export const ADMIN_SECOND_FACTOR_COOKIE = 'fs_admin_2fa'
|
|
15
|
+
export const ADMIN_SECOND_FACTOR_MINUTES = 10
|
|
16
|
+
|
|
14
17
|
export const SSO_COOKIE = '__Host-fs_sso'
|
|
15
18
|
export const DEV_SSO_COOKIE = 'fs_sso'
|
|
16
19
|
|
|
@@ -80,6 +83,21 @@ export function clearedAdminCookie(secure: boolean): CookieAttrs {
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
export function adminSecondFactorCookie(expires: Date, secure: boolean): CookieAttrs {
|
|
87
|
+
return { httpOnly: true, secure, sameSite: 'strict', path: ADMIN_COOKIE_PATH, expires }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function clearedAdminSecondFactorCookie(secure: boolean): CookieAttrs {
|
|
91
|
+
return {
|
|
92
|
+
httpOnly: true,
|
|
93
|
+
secure,
|
|
94
|
+
sameSite: 'strict',
|
|
95
|
+
path: ADMIN_COOKIE_PATH,
|
|
96
|
+
expires: new Date(0),
|
|
97
|
+
maxAge: 0,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
83
101
|
export function clearedCookie(secure: boolean): CookieAttrs {
|
|
84
102
|
return { ...base(secure), expires: new Date(0), maxAge: 0 }
|
|
85
103
|
}
|
|
@@ -46,6 +46,31 @@ export function sniff(bytes: Uint8Array): ImageFormat | null {
|
|
|
46
46
|
return null
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
const DANGEROUS_TAG = /<\s*(script|foreignobject|iframe|embed|object|set|animate\w*)\b/i
|
|
50
|
+
const EVENT_HANDLER_OR_SCRIPT_SCHEME = /\son\w+\s*=|javascript:|vbscript:/i
|
|
51
|
+
const REFERENCING_TAG = /<\s*(use|image|feimage)\b([^>]*)>/gi
|
|
52
|
+
const HREF_ATTRIBUTE = /(?:xlink:href|href)\s*=\s*(\x22|\x27)([\s\S]*?)\1/i
|
|
53
|
+
|
|
54
|
+
function decodeEntities(value: string): string {
|
|
55
|
+
return value.replace(/&(#\d+|#x[0-9a-f]+|amp|lt|gt|quot|apos);/gi, (match, entity: string) => {
|
|
56
|
+
if (entity.startsWith('#')) {
|
|
57
|
+
const isHex = entity[1] === 'x' || entity[1] === 'X'
|
|
58
|
+
const code = Number.parseInt(entity.slice(isHex ? 2 : 1), isHex ? 16 : 10)
|
|
59
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
|
60
|
+
}
|
|
61
|
+
const named: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }
|
|
62
|
+
return named[entity.toLowerCase()] ?? match
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasExternalReference(decoded: string): boolean {
|
|
67
|
+
for (const match of decoded.matchAll(REFERENCING_TAG)) {
|
|
68
|
+
const href = HREF_ATTRIBUTE.exec(match[2] ?? '')?.[2]?.trim()
|
|
69
|
+
if (href !== undefined && href !== '' && !href.startsWith('#')) return true
|
|
70
|
+
}
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
|
|
49
74
|
function isSvg(bytes: Uint8Array): boolean {
|
|
50
75
|
const decoder = new TextDecoder('utf-8', { fatal: false })
|
|
51
76
|
const head = decoder
|
|
@@ -56,9 +81,14 @@ function isSvg(bytes: Uint8Array): boolean {
|
|
|
56
81
|
const looksSvg = head.startsWith('<svg') || /^<\?xml[\s\S]{0,512}?<svg/i.test(head)
|
|
57
82
|
if (!looksSvg) return false
|
|
58
83
|
|
|
59
|
-
|
|
84
|
+
const decoded = decodeEntities(decoder.decode(bytes))
|
|
85
|
+
|
|
86
|
+
if (DANGEROUS_TAG.test(decoded) || EVENT_HANDLER_OR_SCRIPT_SCHEME.test(decoded)) {
|
|
60
87
|
throw new ValidationError(msg('error.app.svg-contains-script-event-handler'))
|
|
61
88
|
}
|
|
89
|
+
if (hasExternalReference(decoded)) {
|
|
90
|
+
throw new ValidationError(msg('error.app.svg-contains-external-reference'))
|
|
91
|
+
}
|
|
62
92
|
return true
|
|
63
93
|
}
|
|
64
94
|
|
|
@@ -15,6 +15,8 @@ const toFormState = formStateReporter('push-actions', 'unexpected error in web p
|
|
|
15
15
|
|
|
16
16
|
const ENDPOINT_MAX = 1000
|
|
17
17
|
|
|
18
|
+
const SUBSCRIPTIONS_PER_USER_MAX = 20
|
|
19
|
+
|
|
18
20
|
const BASE64URL = /^[A-Za-z0-9_-]+$/
|
|
19
21
|
|
|
20
22
|
const IP_LITERAL = /^\[|^\d+\.\d+\.\d+\.\d+$/
|
|
@@ -77,6 +79,13 @@ export async function subscribeToPushAction(input: PushSubscriptionInput): Promi
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
const { service, userId } = await requirePushCentre()
|
|
82
|
+
|
|
83
|
+
const existing = await service.pushSubscriptions(userId)
|
|
84
|
+
const known = existing.some((subscription) => subscription.endpoint === input.endpoint)
|
|
85
|
+
if (!known && existing.length >= SUBSCRIPTIONS_PER_USER_MAX) {
|
|
86
|
+
return { error: await tr('notice.app.push-subscription-limit') }
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
await service.subscribeToPush({
|
|
81
90
|
userId,
|
|
82
91
|
endpoint: input.endpoint,
|
|
@@ -6,8 +6,11 @@ import { env } from '@meith/core'
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
ADMIN_COOKIE,
|
|
9
|
+
ADMIN_SECOND_FACTOR_COOKIE,
|
|
9
10
|
adminCookie,
|
|
11
|
+
adminSecondFactorCookie,
|
|
10
12
|
clearedAdminCookie,
|
|
13
|
+
clearedAdminSecondFactorCookie,
|
|
11
14
|
clearedCookie,
|
|
12
15
|
passkeyCookie,
|
|
13
16
|
passkeyCookieName,
|
|
@@ -55,6 +58,21 @@ export async function readAdminToken(): Promise<string | null> {
|
|
|
55
58
|
return jar.get(ADMIN_COOKIE)?.value ?? null
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
export async function setAdminSecondFactorCookie(token: string, expiresAt: Date): Promise<void> {
|
|
62
|
+
const jar = await cookies()
|
|
63
|
+
jar.set(ADMIN_SECOND_FACTOR_COOKIE, token, adminSecondFactorCookie(expiresAt, secure()))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function readAdminSecondFactorToken(): Promise<string | null> {
|
|
67
|
+
const jar = await cookies()
|
|
68
|
+
return jar.get(ADMIN_SECOND_FACTOR_COOKIE)?.value ?? null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function clearAdminSecondFactorCookie(): Promise<void> {
|
|
72
|
+
const jar = await cookies()
|
|
73
|
+
jar.set(ADMIN_SECOND_FACTOR_COOKIE, '', clearedAdminSecondFactorCookie(secure()))
|
|
74
|
+
}
|
|
75
|
+
|
|
58
76
|
export async function clearSessionCookies(): Promise<void> {
|
|
59
77
|
const jar = await cookies()
|
|
60
78
|
const isSecure = secure()
|
|
@@ -50,6 +50,7 @@ export interface SystemHealthView {
|
|
|
50
50
|
readonly recount: readonly RecountStateRow[]
|
|
51
51
|
readonly volumes: BoardVolumes
|
|
52
52
|
readonly prunableSessions: number
|
|
53
|
+
readonly legacyPasswordHashes: number
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export async function buildSystemHealthView(now: Date): Promise<SystemHealthView | null> {
|
|
@@ -57,15 +58,17 @@ export async function buildSystemHealthView(now: Date): Promise<SystemHealthView
|
|
|
57
58
|
if (repository === null) return null
|
|
58
59
|
|
|
59
60
|
const maintenance = new PostgresMaintenanceRepository(getDb())
|
|
60
|
-
const [tasks, runs, recount, volumes, prunableSessions, searchIndex, mail] =
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
61
|
+
const [tasks, runs, recount, volumes, prunableSessions, searchIndex, mail, legacyPasswordHashes] =
|
|
62
|
+
await Promise.all([
|
|
63
|
+
repository.taskHealth(),
|
|
64
|
+
repository.recentRuns(20),
|
|
65
|
+
repository.recountState(),
|
|
66
|
+
repository.volumes(),
|
|
67
|
+
maintenance.countPrunableSessions(now),
|
|
68
|
+
new PostgresSearchRepository(getDb()).indexProgress(),
|
|
69
|
+
assessMailReadiness(),
|
|
70
|
+
repository.legacyPasswordHashes(),
|
|
71
|
+
])
|
|
69
72
|
|
|
70
73
|
const scheduler = assessScheduler(tasks, now)
|
|
71
74
|
const taskMessages = new Map(
|
|
@@ -86,5 +89,6 @@ export async function buildSystemHealthView(now: Date): Promise<SystemHealthView
|
|
|
86
89
|
recount,
|
|
87
90
|
volumes,
|
|
88
91
|
prunableSessions,
|
|
92
|
+
legacyPasswordHashes,
|
|
89
93
|
}
|
|
90
94
|
}
|
|
@@ -16,7 +16,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
|
|
|
16
16
|
|
|
17
17
|
import { activeDefinitions } from './plugin-host'
|
|
18
18
|
|
|
19
|
-
export const CODE_VERSION = '0.
|
|
19
|
+
export const CODE_VERSION = '0.33.1'
|
|
20
20
|
|
|
21
21
|
export interface UpgradeApplied {
|
|
22
22
|
readonly plugins: readonly string[]
|
package/src/styles/globals.css
CHANGED
|
@@ -666,7 +666,7 @@
|
|
|
666
666
|
/* `em`, so a quote inside a heading scales with the text it is in. */
|
|
667
667
|
margin-block: 0.85em;
|
|
668
668
|
padding: 0.55em 0.85em;
|
|
669
|
-
border-inline-start: 3px solid var(--
|
|
669
|
+
border-inline-start: 3px solid color-mix(in srgb, var(--primary) 55%, transparent);
|
|
670
670
|
background-color: var(--muted);
|
|
671
671
|
border-start-end-radius: var(--radius);
|
|
672
672
|
border-end-end-radius: var(--radius);
|
|
@@ -15,7 +15,12 @@ export function adminFormsCopy(t: Translator = untranslated()): Readonly<Record<
|
|
|
15
15
|
'adminPanel.reason.default',
|
|
16
16
|
'adminPanel.password',
|
|
17
17
|
'adminPanel.twoFactorCode',
|
|
18
|
-
'adminPanel.
|
|
18
|
+
'adminPanel.twoFactor.title',
|
|
19
|
+
'adminPanel.twoFactor.lede',
|
|
20
|
+
'adminPanel.twoFactor.recoveryHint',
|
|
21
|
+
'adminPanel.twoFactor.recoveryHintExhausted',
|
|
22
|
+
'adminPanel.twoFactor.submit',
|
|
23
|
+
'adminPanel.twoFactor.cancel',
|
|
19
24
|
'adminPanel.enter',
|
|
20
25
|
'adminPanel.leave',
|
|
21
26
|
],
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
+
import type { Translator } from '@meith/i18n'
|
|
1
2
|
import type { RedirectNoticeModel } from '@meith/theme-kit'
|
|
2
3
|
|
|
4
|
+
import { untranslated } from './time'
|
|
5
|
+
|
|
3
6
|
export const REDIRECT_DELAY_SECONDS = 2
|
|
4
7
|
|
|
8
|
+
const DEFAULT_MESSAGE_KEY = 'redirectNotice.continuing'
|
|
9
|
+
|
|
10
|
+
const REDIRECT_MESSAGE_KEYS: Readonly<Record<string, string>> = {
|
|
11
|
+
continuing: DEFAULT_MESSAGE_KEY,
|
|
12
|
+
'signed-out': 'redirectNotice.signedOut',
|
|
13
|
+
saved: 'redirectNotice.saved',
|
|
14
|
+
posted: 'redirectNotice.posted',
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
export function localHref(value: string | undefined): string {
|
|
6
18
|
if (value === undefined || !value.startsWith('/')) return '/'
|
|
7
19
|
const origin = 'https://forum.invalid'
|
|
@@ -11,11 +23,16 @@ export function localHref(value: string | undefined): string {
|
|
|
11
23
|
|
|
12
24
|
export function buildRedirectNotice(
|
|
13
25
|
target: string | undefined,
|
|
14
|
-
|
|
26
|
+
messageKey: string | undefined,
|
|
27
|
+
t: Translator = untranslated(),
|
|
15
28
|
): RedirectNoticeModel {
|
|
29
|
+
const key =
|
|
30
|
+
(messageKey !== undefined ? REDIRECT_MESSAGE_KEYS[messageKey] : undefined) ??
|
|
31
|
+
DEFAULT_MESSAGE_KEY
|
|
32
|
+
|
|
16
33
|
return {
|
|
17
34
|
targetHref: localHref(target),
|
|
18
|
-
message:
|
|
35
|
+
message: t.t(key),
|
|
19
36
|
delaySeconds: REDIRECT_DELAY_SECONDS,
|
|
20
37
|
}
|
|
21
38
|
}
|