@meith/web 0.32.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/admin/layout.tsx +23 -8
- package/package.json +49 -49
- package/src/components/admin/admin-forms.tsx +57 -9
- 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/push-actions.ts +9 -0
- package/src/server/session-cookies.ts +18 -0
- 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/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>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -43,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.0",
|
|
47
|
+
"@meith/admin": "0.33.0",
|
|
48
|
+
"@meith/antispam": "0.33.0",
|
|
49
|
+
"@meith/api": "0.33.0",
|
|
50
|
+
"@meith/attachments": "0.33.0",
|
|
51
|
+
"@meith/authorization": "0.33.0",
|
|
52
|
+
"@meith/avatars": "0.33.0",
|
|
53
|
+
"@meith/board-digest": "0.33.0",
|
|
54
|
+
"@meith/core": "0.33.0",
|
|
55
|
+
"@meith/db": "0.33.0",
|
|
56
|
+
"@meith/demo": "0.33.0",
|
|
57
|
+
"@meith/drafts": "0.33.0",
|
|
58
|
+
"@meith/drivers": "0.33.0",
|
|
59
|
+
"@meith/events": "0.33.0",
|
|
60
|
+
"@meith/forums": "0.33.0",
|
|
61
|
+
"@meith/groups": "0.33.0",
|
|
62
|
+
"@meith/i18n": "0.33.0",
|
|
63
|
+
"@meith/import": "0.33.0",
|
|
64
|
+
"@meith/install": "0.33.0",
|
|
65
|
+
"@meith/mail": "0.33.0",
|
|
66
|
+
"@meith/markdown": "0.33.0",
|
|
67
|
+
"@meith/marketplace": "0.33.0",
|
|
68
|
+
"@meith/messages": "0.33.0",
|
|
69
|
+
"@meith/moderation": "0.33.0",
|
|
70
|
+
"@meith/notifications": "0.33.0",
|
|
71
|
+
"@meith/plugin-calendar": "0.33.0",
|
|
72
|
+
"@meith/plugin-dues": "0.33.0",
|
|
73
|
+
"@meith/plugin-kit": "0.33.0",
|
|
74
|
+
"@meith/polls": "0.33.0",
|
|
75
|
+
"@meith/posts": "0.33.0",
|
|
76
|
+
"@meith/profile-fields": "0.33.0",
|
|
77
|
+
"@meith/relations": "0.33.0",
|
|
78
|
+
"@meith/reputation": "0.33.0",
|
|
79
|
+
"@meith/runtime": "0.33.0",
|
|
80
|
+
"@meith/search": "0.33.0",
|
|
81
|
+
"@meith/settings": "0.33.0",
|
|
82
|
+
"@meith/signatures": "0.33.0",
|
|
83
|
+
"@meith/subscriptions": "0.33.0",
|
|
84
|
+
"@meith/tasks": "0.33.0",
|
|
85
|
+
"@meith/theme-clubhouse": "0.33.0",
|
|
86
|
+
"@meith/theme-default": "0.33.0",
|
|
87
|
+
"@meith/theme-kit": "0.33.0",
|
|
88
|
+
"@meith/theme-midnight": "0.33.0",
|
|
89
|
+
"@meith/theme-phasebook": "0.33.0",
|
|
90
|
+
"@meith/theme-raidframe": "0.33.0",
|
|
91
|
+
"@meith/threads": "0.33.0",
|
|
92
|
+
"@meith/ui": "0.33.0",
|
|
93
|
+
"@meith/upgrade": "0.33.0"
|
|
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}>
|
|
@@ -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
|
}
|
|
@@ -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()
|
|
@@ -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.0'
|
|
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
|
],
|