@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.
@@ -3,12 +3,17 @@ import { notFound } from 'next/navigation'
3
3
 
4
4
  import { ADMIN_IDLE_MINUTES } from '@meith/admin'
5
5
 
6
- import { AdminSignInForm, AdminSignOutForm } from '@/components/admin/admin-forms'
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
- <AdminSignInForm
32
- next="/admin"
33
- reason={resolved.denied === 'expired' ? 'expired' : null}
34
- idleMinutes={ADMIN_IDLE_MINUTES}
35
- copy={adminFormsCopy(await getTranslator())}
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')}
@@ -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; message?: string }>
18
+ searchParams: Promise<{ to?: string; m?: string }>
19
19
  }) {
20
- const { to, message } = await searchParams
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, message),
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', await getTranslator())}
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.32.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.32.0",
47
- "@meith/admin": "0.32.0",
48
- "@meith/antispam": "0.32.0",
49
- "@meith/api": "0.32.0",
50
- "@meith/attachments": "0.32.0",
51
- "@meith/authorization": "0.32.0",
52
- "@meith/avatars": "0.32.0",
53
- "@meith/board-digest": "0.32.0",
54
- "@meith/core": "0.32.0",
55
- "@meith/db": "0.32.0",
56
- "@meith/demo": "0.32.0",
57
- "@meith/drafts": "0.32.0",
58
- "@meith/drivers": "0.32.0",
59
- "@meith/events": "0.32.0",
60
- "@meith/forums": "0.32.0",
61
- "@meith/groups": "0.32.0",
62
- "@meith/i18n": "0.32.0",
63
- "@meith/import": "0.32.0",
64
- "@meith/install": "0.32.0",
65
- "@meith/mail": "0.32.0",
66
- "@meith/markdown": "0.32.0",
67
- "@meith/marketplace": "0.32.0",
68
- "@meith/messages": "0.32.0",
69
- "@meith/moderation": "0.32.0",
70
- "@meith/notifications": "0.32.0",
71
- "@meith/plugin-calendar": "0.32.0",
72
- "@meith/plugin-dues": "0.32.0",
73
- "@meith/plugin-kit": "0.32.0",
74
- "@meith/polls": "0.32.0",
75
- "@meith/posts": "0.32.0",
76
- "@meith/profile-fields": "0.32.0",
77
- "@meith/relations": "0.32.0",
78
- "@meith/reputation": "0.32.0",
79
- "@meith/runtime": "0.32.0",
80
- "@meith/search": "0.32.0",
81
- "@meith/settings": "0.32.0",
82
- "@meith/signatures": "0.32.0",
83
- "@meith/subscriptions": "0.32.0",
84
- "@meith/tasks": "0.32.0",
85
- "@meith/theme-clubhouse": "0.32.0",
86
- "@meith/theme-default": "0.32.0",
87
- "@meith/theme-kit": "0.32.0",
88
- "@meith/theme-midnight": "0.32.0",
89
- "@meith/theme-phasebook": "0.32.0",
90
- "@meith/theme-raidframe": "0.32.0",
91
- "@meith/threads": "0.32.0",
92
- "@meith/ui": "0.32.0",
93
- "@meith/upgrade": "0.32.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 { adminSignInAction, adminSignOutAction } from '@/server/admin-actions'
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 dangerouslySetInnerHTML={{ __html: state.preview }} />
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-foreground shadow-sm'
38
+ ? 'bg-card font-semibold text-primary shadow-sm'
39
39
  : 'font-medium text-muted-foreground hover:text-foreground',
40
40
  )}
41
41
  >
@@ -3,11 +3,20 @@
3
3
  import { redirect } from 'next/navigation'
4
4
 
5
5
  import { generateToken, hashToken, verifyPassword } from '@meith/accounts'
6
- import { ipAllowed } from '@meith/admin'
6
+ import { type AdminService, ipAllowed } from '@meith/admin'
7
7
  import { ForbiddenError, logger, ValidationError } from '@meith/core'
8
8
  import { msg } from '@meith/i18n'
9
9
 
10
- import { adminAllowlist, adminService, recordAdminAction, resolveAdmin } from './admin'
10
+ import {
11
+ abandonAdminSecondFactor,
12
+ adminAllowlist,
13
+ adminService,
14
+ holdAdminSecondFactor,
15
+ pendingAdminSecondFactor,
16
+ recordAdminAction,
17
+ redeemAdminSecondFactor,
18
+ resolveAdmin,
19
+ } from './admin'
11
20
  import { boardAuthConfig } from './auth-config'
12
21
  import type { FormState } from './auth-form-state'
13
22
  import { getContainer } from './container'
@@ -71,31 +80,21 @@ export async function adminSignInAction(_prev: FormState, form: FormData): Promi
71
80
  throw new ForbiddenError(msg('error.app.password-right'))
72
81
  }
73
82
 
74
- try {
75
- await assertSecondFactorGiven({ userId: actor.userId, form })
76
- } catch (err) {
77
- await failAttempt()
78
- throw err
79
- }
83
+ const next = safeAdminReturn(text(form, 'next'))
84
+ const twoFactor = twoFactorService()
80
85
 
81
- await accountStore.loginAttempts.clear(attemptBucket)
82
-
83
- const existing = await resolveAdmin()
84
- if ('context' in existing) {
85
- await service.markReauthenticated(existing.context.session.id)
86
- await recordAdminAction({ action: 'admin.reauthenticated' })
86
+ if (twoFactor !== null && (await twoFactor.isEnrolled(actor.userId))) {
87
+ await holdAdminSecondFactor(actor.userId, next)
88
+ target = '/admin'
87
89
  } else {
88
- const token = generateToken()
89
- const session = await service.start({
90
- userId: actor.userId,
91
- tokenHash: await hashToken(token),
92
- ipPrefix: await retainedIpPrefix(),
93
- })
94
- await setAdminCookie(token, session.expiresAt)
95
- await recordAdminAction({ action: 'admin.signed_in' })
96
- }
90
+ if (twoFactor !== null && (await twoFactorRequiredForStaff())) {
91
+ await failAttempt()
92
+ throw new ForbiddenError(msg('adminAction.twoFactorRequired'))
93
+ }
97
94
 
98
- target = safeAdminReturn(text(form, 'next'))
95
+ await accountStore.loginAttempts.clear(attemptBucket)
96
+ target = await startAdminSession(service, actor.userId, next)
97
+ }
99
98
  } catch (err) {
100
99
  return toFormState(err)
101
100
  }
@@ -103,39 +102,109 @@ export async function adminSignInAction(_prev: FormState, form: FormData): Promi
103
102
  redirect(target)
104
103
  }
105
104
 
106
- async function adminReauthenticationBucket(userId: number): Promise<string> {
107
- const prefix = await retainedIpPrefix()
108
- return prefix === null ? `admin-reauth:${userId}` : `admin-reauth:${userId}@${prefix}`
109
- }
105
+ export async function adminVerifySecondFactorAction(
106
+ _prev: FormState,
107
+ form: FormData,
108
+ ): Promise<FormState> {
109
+ let target: string
110
110
 
111
- async function assertSecondFactorGiven(input: {
112
- readonly userId: number
113
- readonly form: FormData
114
- }): Promise<void> {
115
- const service = twoFactorService()
116
- if (service === null) return
111
+ try {
112
+ if (!ipAllowed(await remoteAddress(), await adminAllowlist())) {
113
+ throw new ForbiddenError(msg('error.app.control-panel-available-from-address'))
114
+ }
117
115
 
118
- const enrolled = await service.isEnrolled(input.userId)
116
+ const service = adminService()
117
+ if (service === null) {
118
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-25'))
119
+ }
119
120
 
120
- if (!enrolled) {
121
- if (await twoFactorRequiredForStaff()) {
122
- throw new ForbiddenError(msg('adminAction.twoFactorRequired'))
121
+ const actor = await getActor()
122
+ const { authorizer, accountStore } = getContainer()
123
+ if (actor.userId === null || !authorizer.can(actor, 'admincp.access')) {
124
+ throw new ForbiddenError(msg('error.app.reach-control-panel'))
123
125
  }
124
- return
125
- }
126
126
 
127
- const code = text(input.form, 'code')
128
- if (code === '') {
129
- throw new ValidationError(msg('error.app.enter-code-from-authenticator-app'))
130
- }
127
+ const twoFactor = twoFactorService()
128
+ const pending = await pendingAdminSecondFactor()
129
+ if (twoFactor === null || pending === null) {
130
+ throw new ForbiddenError(msg('adminAction.twoFactorExpired'))
131
+ }
132
+
133
+ const config = await boardAuthConfig()
134
+ const attemptBucket = await adminReauthenticationBucket(actor.userId)
135
+ const attemptSince = new Date(Date.now() - config.lockoutMinutes * 60_000)
136
+ if (
137
+ config.maxLoginAttempts > 0 &&
138
+ (await accountStore.loginAttempts.countFailuresSince(attemptBucket, attemptSince)) >=
139
+ config.maxLoginAttempts
140
+ ) {
141
+ throw new ForbiddenError(msg('error.accounts.too-many-failed-attempts-please'))
142
+ }
131
143
 
132
- const outcome = await service.verify({ userId: input.userId, code })
133
- if (outcome.status !== 'ok') {
134
- if (outcome.status === 'replayed') {
135
- throw new ForbiddenError(msg('adminAction.codeReplayed'))
144
+ const failAttempt = async (): Promise<void> => {
145
+ await accountStore.loginAttempts.record(attemptBucket, false, new Date())
146
+ await recordAdminAction({ action: 'admin.signin_failed' })
136
147
  }
137
- throw new ForbiddenError(msg('adminAction.codeWrong'))
148
+
149
+ const code = text(form, 'code')
150
+ if (code === '') {
151
+ await failAttempt()
152
+ throw new ValidationError(msg('error.app.enter-code-from-authenticator-app'))
153
+ }
154
+
155
+ const outcome = await twoFactor.verify({ userId: pending.userId, code })
156
+ if (outcome.status !== 'ok') {
157
+ await failAttempt()
158
+ throw new ForbiddenError(
159
+ msg(outcome.status === 'replayed' ? 'adminAction.codeReplayed' : 'adminAction.codeWrong'),
160
+ )
161
+ }
162
+
163
+ const redeemed = await redeemAdminSecondFactor()
164
+ if (redeemed === null) {
165
+ throw new ForbiddenError(msg('adminAction.twoFactorExpired'))
166
+ }
167
+
168
+ await accountStore.loginAttempts.clear(attemptBucket)
169
+ target = await startAdminSession(service, pending.userId, safeAdminReturn(redeemed.next))
170
+ } catch (err) {
171
+ return toFormState(err)
172
+ }
173
+
174
+ redirect(target)
175
+ }
176
+
177
+ export async function adminAbandonSecondFactorAction(): Promise<void> {
178
+ await abandonAdminSecondFactor()
179
+ redirect('/admin')
180
+ }
181
+
182
+ async function startAdminSession(
183
+ service: AdminService,
184
+ userId: number,
185
+ target: string,
186
+ ): Promise<string> {
187
+ const existing = await resolveAdmin()
188
+ if ('context' in existing) {
189
+ await service.markReauthenticated(existing.context.session.id)
190
+ await recordAdminAction({ action: 'admin.reauthenticated' })
191
+ } else {
192
+ const token = generateToken()
193
+ const session = await service.start({
194
+ userId,
195
+ tokenHash: await hashToken(token),
196
+ ipPrefix: await retainedIpPrefix(),
197
+ })
198
+ await setAdminCookie(token, session.expiresAt)
199
+ await recordAdminAction({ action: 'admin.signed_in' })
138
200
  }
201
+
202
+ return target
203
+ }
204
+
205
+ async function adminReauthenticationBucket(userId: number): Promise<string> {
206
+ const prefix = await retainedIpPrefix()
207
+ return prefix === null ? `admin-reauth:${userId}` : `admin-reauth:${userId}@${prefix}`
139
208
  }
140
209
 
141
210
  export async function adminSignOutAction(): Promise<void> {
@@ -4,14 +4,20 @@ import 'server-only'
4
4
  import { notFound } from 'next/navigation'
5
5
  import { cache } from 'react'
6
6
 
7
- import { hashToken } from '@meith/accounts'
7
+ import { generateToken, hashToken } from '@meith/accounts'
8
8
  import { type AdminContext, AdminService, ipAllowed, parseAllowlist } from '@meith/admin'
9
9
  import { env, ForbiddenError } from '@meith/core'
10
10
 
11
11
  import { getContainer } from './container'
12
12
  import { getActor } from './context'
13
+ import { ADMIN_SECOND_FACTOR_MINUTES } from './cookies'
13
14
  import { remoteAddress, retainedIpPrefix } from './request-fingerprint'
14
- import { readAdminToken } from './session-cookies'
15
+ import {
16
+ clearAdminSecondFactorCookie,
17
+ readAdminSecondFactorToken,
18
+ readAdminToken,
19
+ setAdminSecondFactorCookie,
20
+ } from './session-cookies'
15
21
 
16
22
  export type AdminDenial = 'address' | 'permission' | 'signin' | 'expired' | 'unavailable'
17
23
 
@@ -69,6 +75,68 @@ export function askForPassword(denied: AdminDenial): boolean {
69
75
  return denied === 'signin' || denied === 'expired'
70
76
  }
71
77
 
78
+ export interface AdminSecondFactorHold {
79
+ readonly userId: number
80
+ readonly next: string
81
+ }
82
+
83
+ export async function holdAdminSecondFactor(userId: number, next: string): Promise<void> {
84
+ const { tokens } = getContainer().accountStore
85
+ await tokens.revokeAllForUser(userId, 'admin_second_factor')
86
+
87
+ const token = generateToken()
88
+ const expiresAt = new Date(Date.now() + ADMIN_SECOND_FACTOR_MINUTES * 60_000)
89
+ await tokens.issue({
90
+ tokenHash: await hashToken(token),
91
+ userId,
92
+ purpose: 'admin_second_factor',
93
+ payload: next,
94
+ expiresAt,
95
+ })
96
+
97
+ await setAdminSecondFactorCookie(token, expiresAt)
98
+ }
99
+
100
+ export async function pendingAdminSecondFactor(): Promise<AdminSecondFactorHold | null> {
101
+ const token = await readAdminSecondFactorToken()
102
+ if (token === null || token === '') return null
103
+
104
+ const held = await getContainer().accountStore.tokens.peek(
105
+ await hashToken(token),
106
+ 'admin_second_factor',
107
+ new Date(),
108
+ )
109
+ if (held === null) return null
110
+
111
+ const actor = await getActor()
112
+ if (actor.userId === null || held.userId !== actor.userId) return null
113
+
114
+ return { userId: held.userId, next: held.payload ?? '/admin' }
115
+ }
116
+
117
+ export async function redeemAdminSecondFactor(): Promise<AdminSecondFactorHold | null> {
118
+ const token = await readAdminSecondFactorToken()
119
+ await clearAdminSecondFactorCookie()
120
+ if (token === null || token === '') return null
121
+
122
+ const redeemed = await getContainer().accountStore.tokens.consume(
123
+ await hashToken(token),
124
+ 'admin_second_factor',
125
+ new Date(),
126
+ )
127
+ if (redeemed === null) return null
128
+
129
+ return { userId: redeemed.userId, next: redeemed.payload ?? '/admin' }
130
+ }
131
+
132
+ export async function abandonAdminSecondFactor(): Promise<void> {
133
+ const actor = await getActor()
134
+ if (actor.userId !== null) {
135
+ await getContainer().accountStore.tokens.revokeAllForUser(actor.userId, 'admin_second_factor')
136
+ }
137
+ await clearAdminSecondFactorCookie()
138
+ }
139
+
72
140
  export async function adminPageContext(): Promise<AdminContext | null> {
73
141
  const resolved = await resolveAdmin()
74
142
  if ('context' in resolved) return resolved.context
@@ -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
- if (/<script|<foreignObject|\son\w+\s*=|javascript:/i.test(decoder.decode(bytes))) {
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] = await Promise.all([
61
- repository.taskHealth(),
62
- repository.recentRuns(20),
63
- repository.recountState(),
64
- repository.volumes(),
65
- maintenance.countPrunableSessions(now),
66
- new PostgresSearchRepository(getDb()).indexProgress(),
67
- assessMailReadiness(),
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.32.0'
19
+ export const CODE_VERSION = '0.33.1'
20
20
 
21
21
  export interface UpgradeApplied {
22
22
  readonly plugins: readonly string[]
@@ -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(--border);
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.twoFactorCodeHint',
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
- message: string | undefined,
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: message?.trim() || 'Continuing…',
35
+ message: t.t(key),
19
36
  delaySeconds: REDIRECT_DELAY_SECONDS,
20
37
  }
21
38
  }