@meith/web 0.33.4 → 0.35.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.
Files changed (46) hide show
  1. package/app/(board)/subscriptions/page.tsx +2 -2
  2. package/app/(board)/thread/[slug]/reply/page.tsx +5 -1
  3. package/app/admin/settings/page.tsx +9 -0
  4. package/app/admin/system/backups/[name]/route.ts +11 -0
  5. package/app/admin/system/backups/page.tsx +298 -0
  6. package/app/admin/system/page.tsx +4 -1
  7. package/app/auth/passkey/verify/route.ts +32 -2
  8. package/app/install/page.tsx +45 -2
  9. package/next.config.mjs +1 -0
  10. package/package.json +50 -49
  11. package/src/components/admin/backup-forms.tsx +87 -0
  12. package/src/components/install/restore-form.tsx +154 -0
  13. package/src/components/install/unlock-form.tsx +65 -0
  14. package/src/server/antispam.ts +18 -0
  15. package/src/server/api/content.ts +4 -1
  16. package/src/server/api/subscriptions.ts +2 -2
  17. package/src/server/attachment-upload-actions.ts +4 -0
  18. package/src/server/auth-actions.ts +3 -1
  19. package/src/server/backup-admin-actions.ts +115 -0
  20. package/src/server/backup-admin.ts +157 -0
  21. package/src/server/backup-download.ts +66 -0
  22. package/src/server/content-actions.ts +45 -18
  23. package/src/server/credential-proof-actions.ts +4 -0
  24. package/src/server/credential-proof.ts +11 -0
  25. package/src/server/federation-actions.ts +5 -0
  26. package/src/server/fixture-thread-repo.ts +3 -1
  27. package/src/server/install-actions.ts +25 -1
  28. package/src/server/install-restore-actions.ts +42 -0
  29. package/src/server/install-restore.ts +186 -0
  30. package/src/server/install-unlock.ts +55 -0
  31. package/src/server/message-actions.ts +1 -1
  32. package/src/server/passkey-challenge.ts +36 -3
  33. package/src/server/poll-scope.ts +6 -0
  34. package/src/server/post-notifications.ts +10 -2
  35. package/src/server/post-scope.ts +4 -0
  36. package/src/server/reply-core.ts +2 -0
  37. package/src/server/thread-core.ts +2 -0
  38. package/src/server/thread-rating-actions.ts +6 -0
  39. package/src/server/two-factor-actions.ts +5 -0
  40. package/src/server/upgrade-notice.ts +1 -1
  41. package/src/server/user-admin-actions.ts +9 -9
  42. package/src/server/usercp-actions.ts +2 -0
  43. package/src/view/admin-nav.ts +1 -0
  44. package/src/view/admin-panel-copy.ts +16 -0
  45. package/src/view/install-copy.ts +35 -0
  46. package/src/view/setting-groups.ts +2 -0
@@ -34,8 +34,8 @@ export default async function SubscriptionsPage({
34
34
 
35
35
  if (actor.userId === null || subscriptions === null) notFound()
36
36
 
37
- const visibleForumIds = await authorizer.visibleForumIds(actor)
38
- const rows = await new SubscriptionService({ subscriptions }).list(actor.userId, visibleForumIds)
37
+ const audience = await authorizer.threadAudience(actor)
38
+ const rows = await new SubscriptionService({ subscriptions }).list(actor.userId, audience)
39
39
 
40
40
  const translator = await getTranslator()
41
41
 
@@ -55,7 +55,11 @@ export default async function ReplyPage({
55
55
  forum: await authorizer.forumMatrix(actor, target.forum.id),
56
56
  allowsAttachments: target.forum.allowAttachments,
57
57
  }
58
- if (!authorizer.can(actor, 'thread.view', scope)) notFound()
58
+ const threadViewTarget = {
59
+ ...(await authorizer.moderatorTargetIn(actor, target.forum.id, scope.forum)),
60
+ threadAuthorId: target.authorUserId,
61
+ }
62
+ if (!authorizer.can(actor, 'thread.view', threadViewTarget)) notFound()
59
63
  if (!authorizer.can(actor, 'reply.post', scope)) notFound()
60
64
 
61
65
  const moderates = authorizer.can(actor, 'content.viewUnapproved', scope)
@@ -8,6 +8,7 @@ import { MailTestCard } from '@/components/admin/mail-test-card'
8
8
  import { AdminSettingsForm } from '@/components/admin/settings-form'
9
9
  import { PanelPage } from '@/components/shell/panel-page'
10
10
  import { adminPageContext } from '@/server/admin'
11
+ import { destinationIsFromEnvironment } from '@/server/backup-admin'
11
12
  import { boardUrlResolution } from '@/server/board-url'
12
13
  import { faviconKey, faviconSrc } from '@/server/branding'
13
14
  import { getTranslator, tr } from '@/server/i18n'
@@ -54,6 +55,8 @@ export default async function AdminSettingsPage({
54
55
 
55
56
  const favicon = model.activeGroup === 'board' ? await faviconKey() : null
56
57
 
58
+ const backupFromEnvironment = model.activeGroup === 'backup' && destinationIsFromEnvironment()
59
+
57
60
  return (
58
61
  <PanelPage title={await tr('page.board-settings')} lede={t.t('adminSettings.lede')}>
59
62
  <Card>
@@ -189,6 +192,12 @@ export default async function AdminSettingsPage({
189
192
  </section>
190
193
  )}
191
194
 
195
+ {backupFromEnvironment && (
196
+ <section className="rounded-lg border border-border bg-muted/40 p-4 text-sm">
197
+ <p>{t.t('adminSettings.backupFromEnvironment')}</p>
198
+ </section>
199
+ )}
200
+
192
201
  {model.activeGroup === 'board' && (
193
202
  <section className="flex flex-col gap-3">
194
203
  <div className="flex flex-col gap-1">
@@ -0,0 +1,11 @@
1
+ import { serveBackupDownload } from '@/server/backup-download'
2
+
3
+ export const dynamic = 'force-dynamic'
4
+
5
+ export async function GET(
6
+ _request: Request,
7
+ context: { params: Promise<{ name: string }> },
8
+ ): Promise<Response> {
9
+ const { name } = await context.params
10
+ return serveBackupDownload(name)
11
+ }
@@ -0,0 +1,298 @@
1
+ import type { Metadata } from 'next'
2
+
3
+ import type { BackupRunRecord } from '@meith/backup'
4
+ import { formatScheduleTime } from '@meith/backup'
5
+ import { cn } from '@meith/ui'
6
+
7
+ import {
8
+ DeleteBackupForm,
9
+ RequestBackupForm,
10
+ TestDestinationForm,
11
+ } from '@/components/admin/backup-forms'
12
+ import { PANEL_CARD } from '@/components/shell/panel-list'
13
+ import { PanelPage } from '@/components/shell/panel-page'
14
+ import { adminPageContext } from '@/server/admin'
15
+ import { buildBackupAdminView } from '@/server/backup-admin'
16
+ import { getTranslator, tr } from '@/server/i18n'
17
+ import { backupFormsCopy } from '@/view/admin-panel-copy'
18
+ import { settingsHref } from '@/view/admin-settings'
19
+ import { formatBytes } from '@/view/attachments'
20
+ import { formatTime } from '@/view/time'
21
+
22
+ export async function generateMetadata(): Promise<Metadata> {
23
+ return { title: await tr('page.backups') }
24
+ }
25
+
26
+ const RUN_STATUS_KEYS = {
27
+ queued: 'adminBackups.run.queued',
28
+ running: 'adminBackups.run.running',
29
+ done: 'adminBackups.run.done',
30
+ incomplete: 'adminBackups.run.incomplete',
31
+ failed: 'adminBackups.run.failed',
32
+ } as const satisfies Record<BackupRunRecord['status'], string>
33
+
34
+ const RUN_TRIGGER_KEYS = {
35
+ manual: 'adminBackups.trigger.manual',
36
+ schedule: 'adminBackups.trigger.schedule',
37
+ upgrade: 'adminBackups.trigger.upgrade',
38
+ cli: 'adminBackups.trigger.cli',
39
+ } as const satisfies Record<BackupRunRecord['trigger'], string>
40
+
41
+ const WEEKDAY_KEYS: Readonly<Record<number, string>> = {
42
+ 0: 'setting.backup.weekday.option.0',
43
+ 1: 'setting.backup.weekday.option.1',
44
+ 2: 'setting.backup.weekday.option.2',
45
+ 3: 'setting.backup.weekday.option.3',
46
+ 4: 'setting.backup.weekday.option.4',
47
+ 5: 'setting.backup.weekday.option.5',
48
+ 6: 'setting.backup.weekday.option.6',
49
+ }
50
+
51
+ const DOWNLOAD_LINK =
52
+ 'inline-flex h-8 items-center justify-center rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
53
+
54
+ export default async function AdminBackupsPage({
55
+ searchParams,
56
+ }: {
57
+ searchParams: Promise<{ notice?: string }>
58
+ }) {
59
+ if ((await adminPageContext()) === null) return null
60
+
61
+ const now = new Date()
62
+ const [translator, view, query] = await Promise.all([
63
+ getTranslator(),
64
+ buildBackupAdminView(now),
65
+ searchParams,
66
+ ])
67
+ const copy = backupFormsCopy(translator)
68
+ const t = translator.t.bind(translator)
69
+
70
+ if (view === null) {
71
+ return (
72
+ <PanelPage
73
+ title={await tr('page.backups')}
74
+ back={{ href: '/admin/system', label: t('adminBackups.backToSystem') }}
75
+ >
76
+ <p className="mt-2 text-sm text-muted-foreground">{t('adminBackups.sample')}</p>
77
+ </PanelPage>
78
+ )
79
+ }
80
+
81
+ const { settings } = view
82
+ const available = view.capability === 'available'
83
+ const time = formatScheduleTime(settings.schedule)
84
+ const scheduleSummary =
85
+ settings.schedule.frequency === 'off'
86
+ ? t('adminBackups.schedule.off')
87
+ : settings.schedule.frequency === 'daily'
88
+ ? t('adminBackups.schedule.daily', { time })
89
+ : t('adminBackups.schedule.weekly', {
90
+ time,
91
+ weekday: t(WEEKDAY_KEYS[settings.schedule.weekday] ?? WEEKDAY_KEYS[1] ?? ''),
92
+ })
93
+ const retentionSummary =
94
+ (settings.retention.keepDays ?? 0) > 0
95
+ ? t('adminBackups.retention.countAndDays', {
96
+ count: settings.retention.keep,
97
+ days: settings.retention.keepDays ?? 0,
98
+ })
99
+ : t('adminBackups.retention.count', { count: settings.retention.keep })
100
+
101
+ return (
102
+ <PanelPage
103
+ title={await tr('page.backups')}
104
+ back={{ href: '/admin/system', label: t('adminBackups.backToSystem') }}
105
+ lede={t('adminBackups.lede')}
106
+ gap="loose"
107
+ >
108
+ {query.notice === 'reauth' && (
109
+ <section role="alert" className="rounded-lg border border-border bg-muted/40 p-4 text-sm">
110
+ {t('adminBackups.reauth')}
111
+ </section>
112
+ )}
113
+
114
+ {view.capability === 'serverless' && (
115
+ <section
116
+ role="alert"
117
+ className="flex flex-col gap-2 rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm"
118
+ >
119
+ <p>{t('adminBackups.serverless')}</p>
120
+ </section>
121
+ )}
122
+
123
+ <section className={cn(PANEL_CARD, 'gap-4')}>
124
+ <h2 className="font-heading text-lg font-semibold">{t('adminBackups.now.title')}</h2>
125
+ <p className="text-sm text-muted-foreground">{t('adminBackups.now.hint')}</p>
126
+ {view.active !== null && (
127
+ <p className="text-sm">
128
+ {view.active.status === 'running'
129
+ ? t('adminBackups.now.running', {
130
+ since: formatTime(
131
+ view.active.startedAt ?? view.active.requestedAt,
132
+ now,
133
+ translator,
134
+ ).label,
135
+ })
136
+ : t('adminBackups.now.queued')}
137
+ </p>
138
+ )}
139
+ <RequestBackupForm disabled={!available || view.active !== null} copy={copy} />
140
+ </section>
141
+
142
+ <section className={PANEL_CARD}>
143
+ <h2 className="font-heading text-lg font-semibold">{t('adminBackups.plan.title')}</h2>
144
+ <ul className="flex flex-col gap-1 text-sm">
145
+ <li>
146
+ <span className="text-muted-foreground">{t('adminBackups.plan.schedule')}</span>{' '}
147
+ {scheduleSummary}
148
+ {view.nextScheduled !== null && available && (
149
+ <span className="text-muted-foreground">
150
+ {' · '}
151
+ {t('adminBackups.plan.next', {
152
+ time: formatTime(view.nextScheduled, now, translator).label,
153
+ })}
154
+ </span>
155
+ )}
156
+ </li>
157
+ <li>
158
+ <span className="text-muted-foreground">{t('adminBackups.plan.retention')}</span>{' '}
159
+ {retentionSummary}
160
+ </li>
161
+ <li>
162
+ <span className="text-muted-foreground">{t('adminBackups.plan.uploads')}</span>{' '}
163
+ {settings.uploads === 'include'
164
+ ? t('adminBackups.plan.uploadsIncluded')
165
+ : t('adminBackups.plan.uploadsSkipped')}
166
+ </li>
167
+ <li>
168
+ <span className="text-muted-foreground">{t('adminBackups.plan.beforeUpgrade')}</span>{' '}
169
+ {settings.beforeUpgrade ? t('adminBackups.plan.on') : t('adminBackups.plan.off')}
170
+ </li>
171
+ <li>
172
+ <span className="text-muted-foreground">{t('adminBackups.plan.ring')}</span>{' '}
173
+ <code className="text-xs">{view.ring}</code>
174
+ </li>
175
+ </ul>
176
+ <p className="text-sm text-muted-foreground">
177
+ <a href={settingsHref({ group: 'backup' })} className="underline">
178
+ {t('adminBackups.plan.change')}
179
+ </a>
180
+ </p>
181
+ </section>
182
+
183
+ <section className={PANEL_CARD}>
184
+ <h2 className="font-heading text-lg font-semibold">
185
+ {t('adminBackups.destination.title')}
186
+ </h2>
187
+ {view.destination.source === 'none' ? (
188
+ <p className="text-sm text-muted-foreground">{t('adminBackups.destination.none')}</p>
189
+ ) : (
190
+ <p className="text-sm">
191
+ {view.destination.description === null
192
+ ? t('adminBackups.destination.unusable')
193
+ : t('adminBackups.destination.shipsTo', {
194
+ destination: view.destination.description,
195
+ })}{' '}
196
+ <span className="text-muted-foreground">
197
+ {view.destination.source === 'environment'
198
+ ? t('adminBackups.destination.fromEnvironment')
199
+ : t('adminBackups.destination.fromBoard')}
200
+ </span>
201
+ </p>
202
+ )}
203
+ {view.destination.problem !== null && (
204
+ <p className="text-sm text-destructive">{view.destination.problem}</p>
205
+ )}
206
+ {view.destination.listError !== null && (
207
+ <p className="text-sm text-destructive">
208
+ {t('adminBackups.destination.listFailed', { error: view.destination.listError })}
209
+ </p>
210
+ )}
211
+ {view.destination.description !== null && <TestDestinationForm copy={copy} />}
212
+ </section>
213
+
214
+ <section className={PANEL_CARD}>
215
+ <h2 className="font-heading text-lg font-semibold">{t('adminBackups.bundles.title')}</h2>
216
+ {view.bundles.length === 0 ? (
217
+ <p className="text-sm text-muted-foreground">{t('adminBackups.bundles.empty')}</p>
218
+ ) : (
219
+ <ul className="flex flex-col divide-y divide-border">
220
+ {view.bundles.map((bundle) => (
221
+ <li
222
+ key={bundle.name}
223
+ className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 py-3 first:pt-0 last:pb-0"
224
+ >
225
+ <span className="flex min-w-0 flex-col">
226
+ <span className="text-sm font-medium">
227
+ {bundle.takenAt === null
228
+ ? bundle.name
229
+ : formatTime(bundle.takenAt, now, translator).label}
230
+ </span>
231
+ <span className="truncate text-xs text-muted-foreground">
232
+ <code>{bundle.name}</code>
233
+ {bundle.localSize !== null &&
234
+ ` · ${t('adminBackups.bundles.local', { size: formatBytes(bundle.localSize) })}`}
235
+ {bundle.remoteSize !== null &&
236
+ ` · ${t('adminBackups.bundles.offSite', { size: formatBytes(bundle.remoteSize) })}`}
237
+ </span>
238
+ </span>
239
+ <span className="flex shrink-0 flex-wrap items-center gap-2">
240
+ <a
241
+ href={`/admin/system/backups/${encodeURIComponent(bundle.name)}`}
242
+ className={DOWNLOAD_LINK}
243
+ >
244
+ {t('adminBackups.bundles.download')}
245
+ </a>
246
+ <DeleteBackupForm name={bundle.name} copy={copy} />
247
+ </span>
248
+ </li>
249
+ ))}
250
+ </ul>
251
+ )}
252
+ <p className="text-xs text-muted-foreground">{t('adminBackups.bundles.restoreHint')}</p>
253
+ </section>
254
+
255
+ <section className={PANEL_CARD}>
256
+ <h2 className="font-heading text-lg font-semibold">{t('adminBackups.runs.title')}</h2>
257
+ {view.runs.length === 0 ? (
258
+ <p className="text-sm text-muted-foreground">{t('adminBackups.runs.empty')}</p>
259
+ ) : (
260
+ <ul className="flex flex-col gap-1 text-sm">
261
+ {view.runs.map((run) => (
262
+ <li key={run.id}>
263
+ <span
264
+ className={
265
+ run.status === 'failed' || run.status === 'incomplete'
266
+ ? 'font-medium text-destructive'
267
+ : 'font-medium'
268
+ }
269
+ >
270
+ {t(RUN_STATUS_KEYS[run.status])}
271
+ </span>{' '}
272
+ <span className="text-muted-foreground">
273
+ {t(RUN_TRIGGER_KEYS[run.trigger])} ·{' '}
274
+ <time dateTime={run.requestedAt.toISOString()}>
275
+ {formatTime(run.requestedAt, now, translator).label}
276
+ </time>
277
+ {run.bundleName !== null && (
278
+ <>
279
+ {' · '}
280
+ <code className="text-xs">{run.bundleName}</code>
281
+ </>
282
+ )}
283
+ {run.sizeBytes !== null && ` · ${formatBytes(run.sizeBytes)}`}
284
+ {run.shipped && ` · ${t('adminBackups.runs.shipped')}`}
285
+ {run.skippedKeys > 0 &&
286
+ ` · ${t('adminBackups.runs.skipped', { count: run.skippedKeys })}`}
287
+ </span>
288
+ {run.error !== null && (
289
+ <span className="block text-xs text-destructive">{run.error}</span>
290
+ )}
291
+ </li>
292
+ ))}
293
+ </ul>
294
+ )}
295
+ </section>
296
+ </PanelPage>
297
+ )
298
+ }
@@ -23,6 +23,7 @@ import { formatTime } from '@/view/time'
23
23
 
24
24
  const TASK_STATUS_KEYS = {
25
25
  healthy: 'adminSystem.taskStatus.healthy',
26
+ running: 'adminSystem.taskStatus.running',
26
27
  late: 'adminSystem.taskStatus.late',
27
28
  stale: 'adminSystem.taskStatus.stale',
28
29
  failing: 'adminSystem.taskStatus.failing',
@@ -194,7 +195,9 @@ export default async function AdminSystemPage() {
194
195
  </span>
195
196
  <span
196
197
  className={
197
- task.status === 'healthy' || task.status === 'disabled'
198
+ task.status === 'healthy' ||
199
+ task.status === 'running' ||
200
+ task.status === 'disabled'
198
201
  ? 'shrink-0 text-xs text-muted-foreground'
199
202
  : 'shrink-0 text-xs font-medium text-destructive'
200
203
  }
@@ -1,7 +1,9 @@
1
1
  import type { NextRequest } from 'next/server'
2
2
 
3
+ import { RateLimiter } from '@meith/antispam'
3
4
  import { logger, statusForError, toPublicError } from '@meith/core'
4
5
 
6
+ import { rateLimitStore } from '@/server/antispam'
5
7
  import { recordAuthEvent } from '@/server/auth-events'
6
8
  import { configuredIdentity, configuredSessions } from '@/server/container'
7
9
  import { getActor } from '@/server/context'
@@ -16,7 +18,11 @@ import {
16
18
  relyingParty,
17
19
  } from '@/server/federation'
18
20
  import { tr } from '@/server/i18n'
19
- import { type PasskeyPurpose, unpackChallenge } from '@/server/passkey-challenge'
21
+ import {
22
+ PASSKEY_CHALLENGE_TTL_SECONDS,
23
+ type PasskeyPurpose,
24
+ unpackChallenge,
25
+ } from '@/server/passkey-challenge'
20
26
  import { retainedIpPrefix } from '@/server/request-fingerprint'
21
27
  import { isSafeLocalPath } from '@/server/safe-path'
22
28
  import { crossOriginRefusal, isSameOrigin } from '@/server/same-origin'
@@ -59,6 +65,26 @@ function text(value: unknown): string | null {
59
65
  return typeof value === 'string' && value !== '' ? value : null
60
66
  }
61
67
 
68
+ async function consumeChallengeOnce(challenge: string): Promise<boolean> {
69
+ const store = rateLimitStore()
70
+ if (store === null) return true
71
+
72
+ try {
73
+ const outcome = await new RateLimiter(store).consume({
74
+ scope: 'passkey',
75
+ subject: challenge,
76
+ rule: { max: 1, windowSeconds: PASSKEY_CHALLENGE_TTL_SECONDS },
77
+ })
78
+ return outcome.allowed
79
+ } catch (error) {
80
+ logger({ module: 'passkeys' }).warn(
81
+ { err: String(error) },
82
+ 'could not enforce single-use on a passkey challenge',
83
+ )
84
+ return true
85
+ }
86
+ }
87
+
62
88
  export async function POST(request: NextRequest): Promise<Response> {
63
89
  if (!isSameOrigin(request)) return crossOriginRefusal()
64
90
 
@@ -86,6 +112,10 @@ export async function POST(request: NextRequest): Promise<Response> {
86
112
  return problem(await tr('authRoute.passkey.attemptExpired'), 400)
87
113
  }
88
114
 
115
+ if (!(await consumeChallengeOnce(challengeState.challenge))) {
116
+ return problem(await tr('authRoute.passkey.attemptExpired'), 400)
117
+ }
118
+
89
119
  const clientDataJSON = text(body.clientDataJSON)
90
120
  if (clientDataJSON === null) {
91
121
  return problem(await tr('authRoute.passkey.responseIncomplete'), 400)
@@ -209,7 +239,7 @@ export async function POST(request: NextRequest): Promise<Response> {
209
239
  await setSessionCookie(login.sessionToken, login.expiresAt)
210
240
 
211
241
  if (pending.remember) {
212
- const remembered = await (await configuredSessions()).startRemembered(pending.userId)
242
+ const remembered = await (await configuredSessions()).issueRemember(pending.userId)
213
243
  await setRememberCookie(remembered.rememberToken, remembered.rememberExpiresAt)
214
244
  }
215
245
 
@@ -16,9 +16,15 @@ import { isUsableOrigin, MAIL_PRESETS, normaliseOrigin } from '@meith/settings'
16
16
  import { Alert, AlertDescription, AlertTitle, Disclosure } from '@meith/ui'
17
17
 
18
18
  import { InstallForm } from '@/components/install/install-form'
19
+ import { InstallRestoreForm } from '@/components/install/restore-form'
20
+ import { InstallUnlockForm } from '@/components/install/unlock-form'
19
21
  import { getTranslator, tr } from '@/server/i18n'
20
22
  import { gatherPreflight, installerIsSealed, probeMail } from '@/server/install'
21
- import { installFormCopy } from '@/view/install-copy'
23
+ import { installRestoreView } from '@/server/install-restore'
24
+ import { installUnlocked } from '@/server/install-unlock'
25
+ import { formatBytes } from '@/view/attachments'
26
+ import { installFormCopy, installRestoreCopy } from '@/view/install-copy'
27
+ import { formatTime } from '@/view/time'
22
28
 
23
29
  export async function generateMetadata(): Promise<Metadata> {
24
30
  return { title: await tr('page.install') }
@@ -73,8 +79,11 @@ export default async function InstallPage() {
73
79
 
74
80
  const checks = await gatherPreflight()
75
81
  const ready = canProceed(checks)
82
+ const unlocked = ready && (await installUnlocked())
76
83
  const mail = await probeMail()
77
84
  const suggestedBoardUrl = await suggestBoardUrl()
85
+ const restore = unlocked ? await installRestoreView() : null
86
+ const now = new Date()
78
87
 
79
88
  return (
80
89
  <main className="mx-auto flex w-full max-w-2xl flex-col gap-8 px-6 py-12">
@@ -87,7 +96,41 @@ export default async function InstallPage() {
87
96
 
88
97
  <Preflight checks={checks} />
89
98
 
90
- {ready && (
99
+ {ready && !unlocked && (
100
+ <InstallUnlockForm
101
+ copy={{
102
+ title: t.t('installUnlock.title'),
103
+ lede: t.t('installUnlock.lede'),
104
+ label: t.t('installUnlock.label'),
105
+ button: t.t('installUnlock.button'),
106
+ pending: t.t('installUnlock.pending'),
107
+ }}
108
+ />
109
+ )}
110
+
111
+ {restore?.possible && (
112
+ <InstallRestoreForm
113
+ candidates={restore.candidates.map((candidate) => ({
114
+ name: candidate.name,
115
+ label:
116
+ candidate.takenAt === null
117
+ ? candidate.name
118
+ : t.t('install.restore.takenAt', {
119
+ time: formatTime(candidate.takenAt, now, t).label,
120
+ }),
121
+ size: formatBytes(candidate.size),
122
+ location:
123
+ candidate.location === 'server'
124
+ ? t.t('install.restore.onServer')
125
+ : t.t('install.restore.offSite'),
126
+ }))}
127
+ destination={restore.destination}
128
+ problem={restore.problem}
129
+ copy={installRestoreCopy(t)}
130
+ />
131
+ )}
132
+
133
+ {unlocked && (
91
134
  <InstallForm
92
135
  presets={MAIL_PRESETS.map((preset) => ({
93
136
  ...preset,
package/next.config.mjs CHANGED
@@ -91,6 +91,7 @@ const nextConfig = {
91
91
  '@meith/settings',
92
92
  '@meith/signatures',
93
93
  '@meith/subscriptions',
94
+ '@meith/backup',
94
95
  '@meith/tasks',
95
96
  '@meith/theme-clubhouse',
96
97
  '@meith/theme-default',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.33.4",
3
+ "version": "0.35.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,55 @@
43
43
  "react-dom": "19.2.8",
44
44
  "tailwindcss": "^4.3.3",
45
45
  "typescript": "7.0.2",
46
- "@meith/accounts": "0.33.4",
47
- "@meith/admin": "0.33.4",
48
- "@meith/antispam": "0.33.4",
49
- "@meith/api": "0.33.4",
50
- "@meith/attachments": "0.33.4",
51
- "@meith/authorization": "0.33.4",
52
- "@meith/avatars": "0.33.4",
53
- "@meith/board-digest": "0.33.4",
54
- "@meith/core": "0.33.4",
55
- "@meith/db": "0.33.4",
56
- "@meith/demo": "0.33.4",
57
- "@meith/drivers": "0.33.4",
58
- "@meith/drafts": "0.33.4",
59
- "@meith/events": "0.33.4",
60
- "@meith/forums": "0.33.4",
61
- "@meith/groups": "0.33.4",
62
- "@meith/i18n": "0.33.4",
63
- "@meith/import": "0.33.4",
64
- "@meith/install": "0.33.4",
65
- "@meith/mail": "0.33.4",
66
- "@meith/markdown": "0.33.4",
67
- "@meith/marketplace": "0.33.4",
68
- "@meith/messages": "0.33.4",
69
- "@meith/moderation": "0.33.4",
70
- "@meith/notifications": "0.33.4",
71
- "@meith/plugin-calendar": "0.33.4",
72
- "@meith/plugin-dues": "0.33.4",
73
- "@meith/plugin-kit": "0.33.4",
74
- "@meith/polls": "0.33.4",
75
- "@meith/posts": "0.33.4",
76
- "@meith/profile-fields": "0.33.4",
77
- "@meith/relations": "0.33.4",
78
- "@meith/reputation": "0.33.4",
79
- "@meith/runtime": "0.33.4",
80
- "@meith/search": "0.33.4",
81
- "@meith/settings": "0.33.4",
82
- "@meith/signatures": "0.33.4",
83
- "@meith/subscriptions": "0.33.4",
84
- "@meith/tasks": "0.33.4",
85
- "@meith/theme-clubhouse": "0.33.4",
86
- "@meith/theme-default": "0.33.4",
87
- "@meith/theme-kit": "0.33.4",
88
- "@meith/theme-midnight": "0.33.4",
89
- "@meith/theme-phasebook": "0.33.4",
90
- "@meith/theme-raidframe": "0.33.4",
91
- "@meith/threads": "0.33.4",
92
- "@meith/ui": "0.33.4",
93
- "@meith/upgrade": "0.33.4"
46
+ "@meith/accounts": "0.35.0",
47
+ "@meith/admin": "0.35.0",
48
+ "@meith/antispam": "0.35.0",
49
+ "@meith/api": "0.35.0",
50
+ "@meith/attachments": "0.35.0",
51
+ "@meith/authorization": "0.35.0",
52
+ "@meith/avatars": "0.35.0",
53
+ "@meith/backup": "0.35.0",
54
+ "@meith/board-digest": "0.35.0",
55
+ "@meith/core": "0.35.0",
56
+ "@meith/db": "0.35.0",
57
+ "@meith/demo": "0.35.0",
58
+ "@meith/drafts": "0.35.0",
59
+ "@meith/drivers": "0.35.0",
60
+ "@meith/events": "0.35.0",
61
+ "@meith/forums": "0.35.0",
62
+ "@meith/groups": "0.35.0",
63
+ "@meith/i18n": "0.35.0",
64
+ "@meith/import": "0.35.0",
65
+ "@meith/install": "0.35.0",
66
+ "@meith/mail": "0.35.0",
67
+ "@meith/markdown": "0.35.0",
68
+ "@meith/marketplace": "0.35.0",
69
+ "@meith/messages": "0.35.0",
70
+ "@meith/moderation": "0.35.0",
71
+ "@meith/notifications": "0.35.0",
72
+ "@meith/plugin-calendar": "0.35.0",
73
+ "@meith/plugin-dues": "0.35.0",
74
+ "@meith/plugin-kit": "0.35.0",
75
+ "@meith/polls": "0.35.0",
76
+ "@meith/posts": "0.35.0",
77
+ "@meith/profile-fields": "0.35.0",
78
+ "@meith/relations": "0.35.0",
79
+ "@meith/reputation": "0.35.0",
80
+ "@meith/runtime": "0.35.0",
81
+ "@meith/search": "0.35.0",
82
+ "@meith/settings": "0.35.0",
83
+ "@meith/signatures": "0.35.0",
84
+ "@meith/subscriptions": "0.35.0",
85
+ "@meith/tasks": "0.35.0",
86
+ "@meith/theme-clubhouse": "0.35.0",
87
+ "@meith/theme-default": "0.35.0",
88
+ "@meith/theme-kit": "0.35.0",
89
+ "@meith/theme-midnight": "0.35.0",
90
+ "@meith/theme-phasebook": "0.35.0",
91
+ "@meith/theme-raidframe": "0.35.0",
92
+ "@meith/threads": "0.35.0",
93
+ "@meith/ui": "0.35.0",
94
+ "@meith/upgrade": "0.35.0"
94
95
  },
95
96
  "scripts": {
96
97
  "dev": "next dev",