@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.
@@ -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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.32.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.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.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 { 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}>
@@ -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
  }
@@ -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.32.0'
19
+ export const CODE_VERSION = '0.33.0'
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
  ],