@meith/web 0.16.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 (511) hide show
  1. package/LICENSE.md +165 -0
  2. package/app/(auth)/layout.tsx +6 -0
  3. package/app/(auth)/login/page.tsx +106 -0
  4. package/app/(auth)/login/verify/page.tsx +55 -0
  5. package/app/(auth)/register/page.tsx +83 -0
  6. package/app/(auth)/reset/confirm/page.tsx +45 -0
  7. package/app/(auth)/reset/page.tsx +22 -0
  8. package/app/(auth)/verify/page.tsx +44 -0
  9. package/app/(auth)/verify/resend/page.tsx +51 -0
  10. package/app/(board)/[slug]/feed.xml/route.ts +48 -0
  11. package/app/(board)/[slug]/new/page.tsx +112 -0
  12. package/app/(board)/[slug]/page.tsx +403 -0
  13. package/app/(board)/discover/[view]/page.tsx +115 -0
  14. package/app/(board)/discover/page.tsx +5 -0
  15. package/app/(board)/layout.tsx +22 -0
  16. package/app/(board)/member/[id]/page.tsx +187 -0
  17. package/app/(board)/member/[id]/reputation/page.tsx +195 -0
  18. package/app/(board)/member/by-name/[name]/page.tsx +32 -0
  19. package/app/(board)/messages/[id]/page.tsx +103 -0
  20. package/app/(board)/messages/compose/page.tsx +60 -0
  21. package/app/(board)/messages/layout.tsx +5 -0
  22. package/app/(board)/messages/page.tsx +179 -0
  23. package/app/(board)/modcp/forums/page.tsx +59 -0
  24. package/app/(board)/modcp/ip/page.tsx +158 -0
  25. package/app/(board)/modcp/layout.tsx +5 -0
  26. package/app/(board)/modcp/log/page.tsx +98 -0
  27. package/app/(board)/modcp/page.tsx +112 -0
  28. package/app/(board)/moderation/layout.tsx +5 -0
  29. package/app/(board)/moderation/page.tsx +111 -0
  30. package/app/(board)/moderation/reports/page.tsx +193 -0
  31. package/app/(board)/moderation/warn/page.tsx +158 -0
  32. package/app/(board)/notifications/layout.tsx +5 -0
  33. package/app/(board)/notifications/page.tsx +144 -0
  34. package/app/(board)/notifications/preferences/page.tsx +65 -0
  35. package/app/(board)/online/page.tsx +119 -0
  36. package/app/(board)/page.tsx +195 -0
  37. package/app/(board)/plugins/[key]/[[...path]]/page.tsx +72 -0
  38. package/app/(board)/privacy/page.tsx +12 -0
  39. package/app/(board)/report/page.tsx +71 -0
  40. package/app/(board)/rules/page.tsx +12 -0
  41. package/app/(board)/search/[token]/page.tsx +130 -0
  42. package/app/(board)/search/page.tsx +289 -0
  43. package/app/(board)/stats/page.tsx +202 -0
  44. package/app/(board)/subscriptions/layout.tsx +5 -0
  45. package/app/(board)/subscriptions/page.tsx +142 -0
  46. package/app/(board)/terms/page.tsx +12 -0
  47. package/app/(board)/thread/[slug]/edit/page.tsx +107 -0
  48. package/app/(board)/thread/[slug]/feed.xml/route.ts +42 -0
  49. package/app/(board)/thread/[slug]/page.tsx +707 -0
  50. package/app/(board)/thread/[slug]/poll/page.tsx +41 -0
  51. package/app/(board)/thread/[slug]/post/[postId]/history/page.tsx +160 -0
  52. package/app/(board)/thread/[slug]/reply/page.tsx +136 -0
  53. package/app/(board)/unsubscribe/page.tsx +73 -0
  54. package/app/(board)/usercp/avatar/page.tsx +56 -0
  55. package/app/(board)/usercp/contacts/page.tsx +141 -0
  56. package/app/(board)/usercp/email/confirm/page.tsx +52 -0
  57. package/app/(board)/usercp/layout.tsx +23 -0
  58. package/app/(board)/usercp/options/page.tsx +56 -0
  59. package/app/(board)/usercp/page.tsx +71 -0
  60. package/app/(board)/usercp/profile/page.tsx +81 -0
  61. package/app/(board)/usercp/security/page.tsx +238 -0
  62. package/app/(board)/usercp/security/verify/page.tsx +92 -0
  63. package/app/(board)/usercp/signature/page.tsx +67 -0
  64. package/app/admin/antispam/page.tsx +155 -0
  65. package/app/admin/api/marketplace/screenshot/route.ts +68 -0
  66. package/app/admin/api/plugins/[key]/[...path]/route.ts +27 -0
  67. package/app/admin/api-tokens/page.tsx +129 -0
  68. package/app/admin/content/announcements/page.tsx +110 -0
  69. package/app/admin/content/attachments/page.tsx +199 -0
  70. package/app/admin/content/navigation/page.tsx +123 -0
  71. package/app/admin/content/page.tsx +170 -0
  72. package/app/admin/forums/[id]/page.tsx +96 -0
  73. package/app/admin/forums/[id]/permissions/page.tsx +108 -0
  74. package/app/admin/forums/page.tsx +45 -0
  75. package/app/admin/groups/[id]/page.tsx +133 -0
  76. package/app/admin/groups/memberships/page.tsx +56 -0
  77. package/app/admin/groups/page.tsx +104 -0
  78. package/app/admin/groups/promotions/page.tsx +143 -0
  79. package/app/admin/layout.tsx +86 -0
  80. package/app/admin/log/page.tsx +116 -0
  81. package/app/admin/page.tsx +206 -0
  82. package/app/admin/plugins/[key]/[[...path]]/page.tsx +276 -0
  83. package/app/admin/plugins/browse/page.tsx +70 -0
  84. package/app/admin/plugins/page.tsx +165 -0
  85. package/app/admin/security/page.tsx +124 -0
  86. package/app/admin/settings/page.tsx +190 -0
  87. package/app/admin/system/page.tsx +303 -0
  88. package/app/admin/themes/[key]/page.tsx +110 -0
  89. package/app/admin/themes/browse/page.tsx +70 -0
  90. package/app/admin/themes/page.tsx +163 -0
  91. package/app/admin/users/[id]/merge/page.tsx +136 -0
  92. package/app/admin/users/[id]/page.tsx +208 -0
  93. package/app/admin/users/mail/page.tsx +68 -0
  94. package/app/admin/users/page.tsx +260 -0
  95. package/app/admin/users/prune/page.tsx +186 -0
  96. package/app/api/health/route.ts +11 -0
  97. package/app/api/metrics/route.ts +66 -0
  98. package/app/api/plugins/[key]/[...path]/route.ts +27 -0
  99. package/app/api/read/all/route.ts +15 -0
  100. package/app/api/read/forum/[id]/route.ts +29 -0
  101. package/app/api/read/thread/[id]/route.ts +40 -0
  102. package/app/api/ready/route.ts +63 -0
  103. package/app/api/system/tick/route.ts +77 -0
  104. package/app/api/v1/[...path]/route.ts +208 -0
  105. package/app/api/v1/openapi.json/route.ts +12 -0
  106. package/app/apple-icon.ts +11 -0
  107. package/app/atom.xml/route.ts +7 -0
  108. package/app/attachment/[id]/route.ts +10 -0
  109. package/app/attachment/[id]/thumb/route.ts +10 -0
  110. package/app/auth/passkey/options/route.ts +145 -0
  111. package/app/auth/passkey/verify/route.ts +241 -0
  112. package/app/auth/resume/route.ts +55 -0
  113. package/app/auth/sso/[provider]/callback/route.ts +173 -0
  114. package/app/auth/sso/[provider]/go/route.ts +57 -0
  115. package/app/auth/sso/[provider]/route.ts +105 -0
  116. package/app/avatar/[id]/route.ts +31 -0
  117. package/app/brand/icon-192.png/route.ts +7 -0
  118. package/app/brand/icon-512.png/route.ts +7 -0
  119. package/app/brand/icon-maskable.png/route.ts +7 -0
  120. package/app/demo/stripe/[[...path]]/route.ts +27 -0
  121. package/app/error.tsx +15 -0
  122. package/app/feed.xml/route.ts +7 -0
  123. package/app/forumdisplay.php/page.tsx +11 -0
  124. package/app/group/[id]/badge/[scheme]/route.ts +26 -0
  125. package/app/icon.ts +13 -0
  126. package/app/install/page.tsx +221 -0
  127. package/app/jump/page.tsx +28 -0
  128. package/app/layout.tsx +124 -0
  129. package/app/logo/[scheme]/route.ts +24 -0
  130. package/app/manifest.ts +43 -0
  131. package/app/member.php/page.tsx +11 -0
  132. package/app/memberlist.php/page.tsx +11 -0
  133. package/app/not-found.tsx +14 -0
  134. package/app/opengraph-image.ts +11 -0
  135. package/app/redirect/page.tsx +44 -0
  136. package/app/robots.txt/route.ts +35 -0
  137. package/app/showthread.php/page.tsx +11 -0
  138. package/app/sitemap/[page]/route.ts +64 -0
  139. package/app/sitemap.xml/route.ts +31 -0
  140. package/app/twitter-image.ts +11 -0
  141. package/app/viewforum.php/page.tsx +11 -0
  142. package/app/viewtopic.php/page.tsx +11 -0
  143. package/bin/forum-web.mjs +241 -0
  144. package/components.json +21 -0
  145. package/instrumentation.ts +18 -0
  146. package/next.config.mjs +182 -0
  147. package/package.json +99 -0
  148. package/postcss.config.mjs +7 -0
  149. package/proxy.ts +160 -0
  150. package/src/components/account/active-sessions.tsx +87 -0
  151. package/src/components/account/avatar-form.tsx +119 -0
  152. package/src/components/account/credential-proof-form.tsx +76 -0
  153. package/src/components/account/logout-form.tsx +14 -0
  154. package/src/components/account/notification-forms.tsx +157 -0
  155. package/src/components/account/passkey-enrol.tsx +81 -0
  156. package/src/components/account/passkey-proof-button.tsx +47 -0
  157. package/src/components/account/push-device-form.tsx +193 -0
  158. package/src/components/account/relation-forms.tsx +69 -0
  159. package/src/components/account/reputation-forms.tsx +124 -0
  160. package/src/components/account/security-activity.tsx +46 -0
  161. package/src/components/account/sign-in-methods.tsx +166 -0
  162. package/src/components/account/subscription-forms.tsx +151 -0
  163. package/src/components/account/two-factor-forms.tsx +226 -0
  164. package/src/components/account/usercp-forms.tsx +387 -0
  165. package/src/components/account/usercp-nav.tsx +14 -0
  166. package/src/components/account/usercp-shell.tsx +34 -0
  167. package/src/components/admin/admin-forms.tsx +92 -0
  168. package/src/components/admin/admin-nav.tsx +52 -0
  169. package/src/components/admin/admin-undo.tsx +32 -0
  170. package/src/components/admin/api-token-forms.tsx +89 -0
  171. package/src/components/admin/badge-forms.tsx +97 -0
  172. package/src/components/admin/branding-forms.tsx +102 -0
  173. package/src/components/admin/content-forms.tsx +712 -0
  174. package/src/components/admin/form-bits.tsx +11 -0
  175. package/src/components/admin/forum-forms.tsx +524 -0
  176. package/src/components/admin/forum-tree.tsx +454 -0
  177. package/src/components/admin/group-forms.tsx +646 -0
  178. package/src/components/admin/mail-test-card.tsx +90 -0
  179. package/src/components/admin/marketplace-forms.tsx +30 -0
  180. package/src/components/admin/marketplace-listing.tsx +123 -0
  181. package/src/components/admin/navigation-forms.tsx +229 -0
  182. package/src/components/admin/navigation-tree.tsx +442 -0
  183. package/src/components/admin/oklch-picker.tsx +180 -0
  184. package/src/components/admin/plugin-forms.tsx +225 -0
  185. package/src/components/admin/settings-form.tsx +148 -0
  186. package/src/components/admin/system-forms.tsx +169 -0
  187. package/src/components/admin/theme-changes.tsx +354 -0
  188. package/src/components/admin/theme-forms.tsx +636 -0
  189. package/src/components/admin/theme-palette.tsx +119 -0
  190. package/src/components/admin/theme-preview.tsx +461 -0
  191. package/src/components/admin/user-bulk-toolbar.tsx +99 -0
  192. package/src/components/admin/user-forms.tsx +488 -0
  193. package/src/components/auth/auth-page.tsx +45 -0
  194. package/src/components/auth/form-controls.tsx +117 -0
  195. package/src/components/auth/login-form.tsx +47 -0
  196. package/src/components/auth/passkey-client.ts +171 -0
  197. package/src/components/auth/passkey-second-factor.tsx +69 -0
  198. package/src/components/auth/passkey-sign-in.tsx +61 -0
  199. package/src/components/auth/register-form.tsx +136 -0
  200. package/src/components/auth/resend-verification-form.tsx +34 -0
  201. package/src/components/auth/reset-confirm-form.tsx +43 -0
  202. package/src/components/auth/reset-request-form.tsx +35 -0
  203. package/src/components/auth/second-factor-form.tsx +51 -0
  204. package/src/components/auth/sso-buttons.tsx +47 -0
  205. package/src/components/board/live-region.tsx +74 -0
  206. package/src/components/board/search-off-notice.tsx +18 -0
  207. package/src/components/board/section-page.tsx +114 -0
  208. package/src/components/content/attachment-field.tsx +51 -0
  209. package/src/components/content/composer-field.ts +31 -0
  210. package/src/components/content/composer-ids.ts +6 -0
  211. package/src/components/content/composer-intents.tsx +27 -0
  212. package/src/components/content/composer-recovery.tsx +215 -0
  213. package/src/components/content/edit-post-form.tsx +121 -0
  214. package/src/components/content/markdown-editor.tsx +590 -0
  215. package/src/components/content/markdown-syntax.ts +53 -0
  216. package/src/components/content/mention-token.ts +23 -0
  217. package/src/components/content/multiquote-button.tsx +32 -0
  218. package/src/components/content/multiquote-selection.tsx +105 -0
  219. package/src/components/content/multiquote.ts +125 -0
  220. package/src/components/content/new-thread-form.tsx +169 -0
  221. package/src/components/content/poll-edit-form.tsx +96 -0
  222. package/src/components/content/poll.tsx +89 -0
  223. package/src/components/content/quote-in-place.tsx +51 -0
  224. package/src/components/content/reply-form.tsx +113 -0
  225. package/src/components/content/thanks-button.tsx +65 -0
  226. package/src/components/content/thread-rating.tsx +143 -0
  227. package/src/components/install/install-form.tsx +497 -0
  228. package/src/components/legal/legal-document.tsx +22 -0
  229. package/src/components/messages/message-forms.tsx +152 -0
  230. package/src/components/moderation/inline-moderation-form.tsx +158 -0
  231. package/src/components/moderation/modcp-nav.tsx +15 -0
  232. package/src/components/moderation/modcp-shell.tsx +41 -0
  233. package/src/components/moderation/queue-form.tsx +96 -0
  234. package/src/components/moderation/report-forms.tsx +120 -0
  235. package/src/components/moderation/signature-lock-form.tsx +83 -0
  236. package/src/components/moderation/thread-surgery-form.tsx +96 -0
  237. package/src/components/moderation/thread-tools-form.tsx +137 -0
  238. package/src/components/moderation/warning-forms.tsx +125 -0
  239. package/src/components/profile/custom-field.tsx +79 -0
  240. package/src/components/shell/appearance-icons.tsx +35 -0
  241. package/src/components/shell/board-notice.tsx +21 -0
  242. package/src/components/shell/copy-record.ts +20 -0
  243. package/src/components/shell/copy.tsx +17 -0
  244. package/src/components/shell/crash-notice.tsx +86 -0
  245. package/src/components/shell/demo-banner.tsx +49 -0
  246. package/src/components/shell/group-name-style.tsx +6 -0
  247. package/src/components/shell/measure.ts +1 -0
  248. package/src/components/shell/notification-menu.tsx +361 -0
  249. package/src/components/shell/offline-notice.tsx +29 -0
  250. package/src/components/shell/onboarding-banner.tsx +39 -0
  251. package/src/components/shell/page-shell.tsx +167 -0
  252. package/src/components/shell/panel-list.tsx +95 -0
  253. package/src/components/shell/panel-nav.tsx +40 -0
  254. package/src/components/shell/panel-overview.tsx +81 -0
  255. package/src/components/shell/panel-page.tsx +96 -0
  256. package/src/components/shell/panel-pagination.tsx +28 -0
  257. package/src/components/shell/panel-shell.tsx +31 -0
  258. package/src/components/shell/theme-runtime-style.tsx +6 -0
  259. package/src/components/shell/theme-switcher.tsx +97 -0
  260. package/src/components/shell/timezone-probe.tsx +45 -0
  261. package/src/components/shell/view-tabs.tsx +60 -0
  262. package/src/config.ts +13 -0
  263. package/src/server/absent-services.ts +29 -0
  264. package/src/server/admin-actions.ts +171 -0
  265. package/src/server/admin-settings-actions.ts +83 -0
  266. package/src/server/admin-undo-actions.ts +70 -0
  267. package/src/server/admin-undo.ts +75 -0
  268. package/src/server/admin.ts +112 -0
  269. package/src/server/announcements.ts +82 -0
  270. package/src/server/antispam.ts +310 -0
  271. package/src/server/api/content.ts +486 -0
  272. package/src/server/api/http.ts +119 -0
  273. package/src/server/api/members.ts +109 -0
  274. package/src/server/api/messages.ts +113 -0
  275. package/src/server/api/registry.ts +30 -0
  276. package/src/server/api/search.ts +98 -0
  277. package/src/server/api/subscriptions.ts +106 -0
  278. package/src/server/api-auth.ts +66 -0
  279. package/src/server/api-token-actions.ts +85 -0
  280. package/src/server/api-tokens-admin.ts +95 -0
  281. package/src/server/appearance-actions.ts +39 -0
  282. package/src/server/attachment-download.ts +43 -0
  283. package/src/server/attachment-embed.ts +63 -0
  284. package/src/server/attachment-upload-actions.ts +92 -0
  285. package/src/server/attachments.ts +257 -0
  286. package/src/server/auth-actions.ts +479 -0
  287. package/src/server/auth-config.ts +61 -0
  288. package/src/server/auth-events.ts +92 -0
  289. package/src/server/auth-form-state.ts +14 -0
  290. package/src/server/auth-mail.ts +104 -0
  291. package/src/server/avatars.ts +83 -0
  292. package/src/server/board-latest-actions.ts +7 -0
  293. package/src/server/board-latest.tsx +103 -0
  294. package/src/server/board-offline.ts +23 -0
  295. package/src/server/board-url.ts +14 -0
  296. package/src/server/brand-assets.ts +151 -0
  297. package/src/server/brand-mark.tsx +110 -0
  298. package/src/server/branding-actions.ts +53 -0
  299. package/src/server/branding.ts +101 -0
  300. package/src/server/cache-targets.ts +13 -0
  301. package/src/server/code-highlighting.ts +33 -0
  302. package/src/server/container.ts +402 -0
  303. package/src/server/content-actions.ts +459 -0
  304. package/src/server/content-admin-actions.ts +492 -0
  305. package/src/server/content-admin.ts +95 -0
  306. package/src/server/content-security-policy.ts +35 -0
  307. package/src/server/context.ts +32 -0
  308. package/src/server/cookies.ts +139 -0
  309. package/src/server/credential-proof-actions.ts +42 -0
  310. package/src/server/credential-proof.ts +35 -0
  311. package/src/server/current-location.ts +19 -0
  312. package/src/server/demo-stripe.ts +104 -0
  313. package/src/server/demo-uploads.ts +28 -0
  314. package/src/server/demo.ts +80 -0
  315. package/src/server/discovery.ts +104 -0
  316. package/src/server/embeds.ts +113 -0
  317. package/src/server/error-notice.tsx +41 -0
  318. package/src/server/federation-actions.ts +113 -0
  319. package/src/server/federation.ts +138 -0
  320. package/src/server/feed-builder.ts +72 -0
  321. package/src/server/feed-routes.ts +122 -0
  322. package/src/server/fixture-actor-source.ts +62 -0
  323. package/src/server/fixture-forum-repo.ts +59 -0
  324. package/src/server/fixture-member-profile-repo.ts +30 -0
  325. package/src/server/fixture-post-repo.ts +108 -0
  326. package/src/server/fixture-thread-repo.ts +98 -0
  327. package/src/server/form-state-reporter.ts +34 -0
  328. package/src/server/form-values.ts +23 -0
  329. package/src/server/forum-admin-actions.ts +353 -0
  330. package/src/server/forum-admin.ts +98 -0
  331. package/src/server/group-admin-actions.ts +341 -0
  332. package/src/server/group-admin.ts +98 -0
  333. package/src/server/group-badge-actions.ts +61 -0
  334. package/src/server/group-badge.ts +62 -0
  335. package/src/server/group-identity.ts +104 -0
  336. package/src/server/i18n-catalogs.ts +42 -0
  337. package/src/server/i18n.ts +69 -0
  338. package/src/server/image-upload.ts +109 -0
  339. package/src/server/inline-moderation-actions.ts +129 -0
  340. package/src/server/install-actions.ts +124 -0
  341. package/src/server/install.ts +281 -0
  342. package/src/server/legacy-redirect.ts +83 -0
  343. package/src/server/legal.ts +43 -0
  344. package/src/server/location-header.ts +37 -0
  345. package/src/server/mail-brand.ts +43 -0
  346. package/src/server/mail-health.ts +41 -0
  347. package/src/server/mail-send.ts +44 -0
  348. package/src/server/mail-test-actions.ts +67 -0
  349. package/src/server/mail-test.ts +56 -0
  350. package/src/server/markdown-pipeline.ts +31 -0
  351. package/src/server/marketplace-actions.ts +47 -0
  352. package/src/server/marketplace-admin.ts +260 -0
  353. package/src/server/mention-actions.ts +21 -0
  354. package/src/server/mention-search.ts +35 -0
  355. package/src/server/message-actions.ts +110 -0
  356. package/src/server/messages.ts +141 -0
  357. package/src/server/modcp.ts +125 -0
  358. package/src/server/moderation-actions.ts +165 -0
  359. package/src/server/navigation-admin-actions.ts +217 -0
  360. package/src/server/navigation.ts +112 -0
  361. package/src/server/nonce.ts +13 -0
  362. package/src/server/notification-actions.ts +84 -0
  363. package/src/server/notification-audience.ts +10 -0
  364. package/src/server/notification-menu-actions.ts +82 -0
  365. package/src/server/notification-menu.ts +96 -0
  366. package/src/server/notifications.ts +93 -0
  367. package/src/server/onboarding-actions.ts +27 -0
  368. package/src/server/onboarding.ts +89 -0
  369. package/src/server/page-metadata.ts +14 -0
  370. package/src/server/passkey-challenge.ts +42 -0
  371. package/src/server/plugin-admin-actions.ts +232 -0
  372. package/src/server/plugin-admin.ts +276 -0
  373. package/src/server/plugin-host.ts +143 -0
  374. package/src/server/plugin-pages.ts +192 -0
  375. package/src/server/plugin-panel.ts +49 -0
  376. package/src/server/plugin-routes.ts +298 -0
  377. package/src/server/plugin-view.tsx +58 -0
  378. package/src/server/poll-actions.ts +104 -0
  379. package/src/server/poll-scope.ts +52 -0
  380. package/src/server/post-notifications.ts +71 -0
  381. package/src/server/post-scope.ts +86 -0
  382. package/src/server/presence.ts +177 -0
  383. package/src/server/profile-fields.ts +112 -0
  384. package/src/server/push-actions.ts +113 -0
  385. package/src/server/push.ts +40 -0
  386. package/src/server/redirect-back.ts +15 -0
  387. package/src/server/registration.ts +7 -0
  388. package/src/server/relation-actions.ts +89 -0
  389. package/src/server/relations.ts +45 -0
  390. package/src/server/reply-core.ts +173 -0
  391. package/src/server/report-actions.ts +165 -0
  392. package/src/server/report-scope.ts +21 -0
  393. package/src/server/reputation-actions.ts +146 -0
  394. package/src/server/reputation-target.ts +49 -0
  395. package/src/server/reputation.ts +70 -0
  396. package/src/server/request-fingerprint.ts +65 -0
  397. package/src/server/safe-path.ts +16 -0
  398. package/src/server/same-origin.ts +45 -0
  399. package/src/server/search-page.ts +313 -0
  400. package/src/server/search.ts +45 -0
  401. package/src/server/secret-auth.ts +20 -0
  402. package/src/server/see-other.ts +11 -0
  403. package/src/server/seed-board.ts +417 -0
  404. package/src/server/session-actions.ts +78 -0
  405. package/src/server/session-cookies.ts +124 -0
  406. package/src/server/session-key.ts +17 -0
  407. package/src/server/settings.ts +47 -0
  408. package/src/server/signatures.ts +62 -0
  409. package/src/server/sso-handshake.ts +79 -0
  410. package/src/server/stats.ts +63 -0
  411. package/src/server/subscription-actions.ts +161 -0
  412. package/src/server/surgery-actions.ts +180 -0
  413. package/src/server/syndication.ts +77 -0
  414. package/src/server/system-admin-actions.ts +134 -0
  415. package/src/server/system-admin.ts +90 -0
  416. package/src/server/test-container.ts +114 -0
  417. package/src/server/theme-admin-actions.ts +242 -0
  418. package/src/server/theme-admin.ts +203 -0
  419. package/src/server/theme-runtime.ts +89 -0
  420. package/src/server/theme-style.ts +279 -0
  421. package/src/server/theme.ts +52 -0
  422. package/src/server/thread-core.ts +207 -0
  423. package/src/server/thread-rating-actions.ts +55 -0
  424. package/src/server/thread-tool-actions.ts +154 -0
  425. package/src/server/two-factor-actions.ts +178 -0
  426. package/src/server/two-factor.ts +86 -0
  427. package/src/server/upgrade-notice.ts +41 -0
  428. package/src/server/user-admin-actions.ts +572 -0
  429. package/src/server/user-admin.ts +165 -0
  430. package/src/server/usercp-actions.ts +263 -0
  431. package/src/server/usercp-mail.ts +120 -0
  432. package/src/server/viewer-preferences.ts +64 -0
  433. package/src/server/warning-actions.ts +122 -0
  434. package/src/styles/globals.css +969 -0
  435. package/src/theme/contract.fixture.ts +793 -0
  436. package/src/view/account-copy.ts +351 -0
  437. package/src/view/admin-content-copy.ts +66 -0
  438. package/src/view/admin-copy.ts +106 -0
  439. package/src/view/admin-forum-tree-copy.ts +40 -0
  440. package/src/view/admin-group-copy.ts +80 -0
  441. package/src/view/admin-log.ts +58 -0
  442. package/src/view/admin-nav.ts +164 -0
  443. package/src/view/admin-navigation-copy.ts +58 -0
  444. package/src/view/admin-panel-copy.ts +194 -0
  445. package/src/view/admin-settings.ts +173 -0
  446. package/src/view/admin-theme-copy.ts +229 -0
  447. package/src/view/admin-user-copy.ts +96 -0
  448. package/src/view/attachments.ts +51 -0
  449. package/src/view/auth-copy.ts +147 -0
  450. package/src/view/board-index.ts +167 -0
  451. package/src/view/board-latest.ts +101 -0
  452. package/src/view/breadcrumb.ts +50 -0
  453. package/src/view/contacts.ts +85 -0
  454. package/src/view/content-copy.ts +118 -0
  455. package/src/view/contrast.ts +286 -0
  456. package/src/view/copy.ts +101 -0
  457. package/src/view/count.ts +8 -0
  458. package/src/view/discovery-view.ts +74 -0
  459. package/src/view/editor-toolbar.ts +84 -0
  460. package/src/view/error-notice.ts +52 -0
  461. package/src/view/feed.ts +130 -0
  462. package/src/view/forum-cursor.ts +54 -0
  463. package/src/view/forum-display.ts +152 -0
  464. package/src/view/forum-jump.ts +60 -0
  465. package/src/view/forum-notice.ts +24 -0
  466. package/src/view/inline-moderation.ts +87 -0
  467. package/src/view/install-copy.ts +95 -0
  468. package/src/view/legal.ts +58 -0
  469. package/src/view/locale.ts +40 -0
  470. package/src/view/marketplace-panel.ts +51 -0
  471. package/src/view/member-identity.ts +23 -0
  472. package/src/view/member-profile.ts +65 -0
  473. package/src/view/messages.ts +222 -0
  474. package/src/view/metadata.ts +75 -0
  475. package/src/view/modcp-nav.ts +76 -0
  476. package/src/view/moderation-copy.ts +84 -0
  477. package/src/view/moderation-queue.ts +67 -0
  478. package/src/view/navigation-arrange.ts +188 -0
  479. package/src/view/navigation.ts +173 -0
  480. package/src/view/notification-menu.ts +202 -0
  481. package/src/view/notifications.ts +84 -0
  482. package/src/view/onboarding.ts +26 -0
  483. package/src/view/pager.ts +191 -0
  484. package/src/view/panel-nav.ts +203 -0
  485. package/src/view/plugin-panel.ts +42 -0
  486. package/src/view/post-form.ts +61 -0
  487. package/src/view/post-link.ts +29 -0
  488. package/src/view/presence.ts +99 -0
  489. package/src/view/promotion-rules.ts +52 -0
  490. package/src/view/redirect-notice.ts +21 -0
  491. package/src/view/reputation.ts +107 -0
  492. package/src/view/search-controls.ts +103 -0
  493. package/src/view/search-results.ts +291 -0
  494. package/src/view/security-activity.ts +73 -0
  495. package/src/view/setting-groups.ts +43 -0
  496. package/src/view/shell.ts +164 -0
  497. package/src/view/slug-id.ts +12 -0
  498. package/src/view/sso-hand-off.ts +44 -0
  499. package/src/view/sso-notices.ts +48 -0
  500. package/src/view/subscriptions.ts +76 -0
  501. package/src/view/theme-draft.ts +167 -0
  502. package/src/view/theme-preference.ts +30 -0
  503. package/src/view/theme-tokens.ts +333 -0
  504. package/src/view/thread-view.ts +280 -0
  505. package/src/view/time.ts +31 -0
  506. package/src/view/timezone.ts +35 -0
  507. package/src/view/two-factor.ts +6 -0
  508. package/src/view/usercp-nav.ts +103 -0
  509. package/src/view/usercp.ts +196 -0
  510. package/src/view/warnings.ts +128 -0
  511. package/src/view/word-filter.ts +5 -0
@@ -0,0 +1,207 @@
1
+ import 'server-only'
2
+
3
+ import type { Actor } from '@meith/authorization'
4
+ import { ForbiddenError, type ForumPermissions, ValidationError } from '@meith/core'
5
+ import { msg } from '@meith/i18n'
6
+ import { restrictsPosting } from '@meith/moderation'
7
+ import { type AuthorRestriction, type ForumPostingTarget, ThreadComposer } from '@meith/threads'
8
+
9
+ import {
10
+ dailyLimitMessage,
11
+ holdsNewMember,
12
+ limitMessage,
13
+ spendDailyLimit,
14
+ spendLimit,
15
+ } from './antispam'
16
+ import type { AttachmentScope } from './attachments'
17
+ import { getContainer } from './container'
18
+ import { emitEvent, filterView, viewerRef } from './plugin-view'
19
+ import { notifyPostAudience } from './post-notifications'
20
+ import { getSettings } from './settings'
21
+
22
+ export interface ResolvedThreadTarget {
23
+ readonly forum: ForumPostingTarget
24
+ readonly matrix: ForumPermissions
25
+ readonly scope: AttachmentScope
26
+ }
27
+
28
+ export async function resolveThreadTarget(
29
+ actor: Actor,
30
+ forumId: number,
31
+ ): Promise<ResolvedThreadTarget> {
32
+ const { authorizer, threadWrites } = getContainer()
33
+
34
+ if (threadWrites === null) {
35
+ throw new ValidationError(msg('error.app.board-running-in-memory-sample-data-8'))
36
+ }
37
+
38
+ const forum = await threadWrites.postingRules(forumId)
39
+ if (!forum) throw new ValidationError(msg('error.app.forum-exist'))
40
+
41
+ const matrix = await authorizer.forumMatrix(actor, forumId)
42
+ if (!authorizer.can(actor, 'thread.view', { forumId, forum: matrix })) {
43
+ throw new ValidationError(msg('error.app.forum-exist'))
44
+ }
45
+ authorizer.require(actor, 'thread.post', { forumId, forum: matrix })
46
+
47
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged-post'))
48
+
49
+ return {
50
+ forum,
51
+ matrix,
52
+ scope: { forumId, forum: matrix, allowsAttachments: forum.allowAttachments },
53
+ }
54
+ }
55
+
56
+ export interface SubmitThreadInput {
57
+ readonly title: string
58
+ readonly message: string
59
+ readonly prefixId?: number | null
60
+ readonly subscribe?: boolean
61
+ readonly poll?:
62
+ | {
63
+ readonly question: string
64
+ readonly options: readonly string[]
65
+ readonly maxOptions?: number
66
+ readonly allowRevote?: boolean
67
+ readonly publicVotes?: boolean
68
+ }
69
+ | undefined
70
+ }
71
+
72
+ export async function submitThread(
73
+ actor: Actor,
74
+ resolved: ResolvedThreadTarget,
75
+ input: SubmitThreadInput,
76
+ ): Promise<Awaited<ReturnType<ThreadComposer['create']>>> {
77
+ const { authorizer, threadWrites, memberProfiles, warnings } = getContainer()
78
+ if (threadWrites === null) {
79
+ throw new ValidationError(msg('error.app.board-running-in-memory-sample-data-8'))
80
+ }
81
+
82
+ const userId = actor.userId
83
+ if (userId === null) throw new ForbiddenError(msg('error.app.must-logged-post'))
84
+
85
+ const settings = await getSettings()
86
+ const { forum, matrix, scope } = resolved
87
+ const target = { forumId: forum.id, forum: matrix }
88
+
89
+ const limited = await spendLimit({ scope: 'post', actor, settings })
90
+ if (limited !== null && !limited.allowed) throw new ValidationError(limitMessage(limited))
91
+
92
+ const daily = await spendDailyLimit({ scope: 'post_day', actor })
93
+ if (daily !== null && !daily.allowed) {
94
+ throw new ValidationError(dailyLimitMessage('post_day', daily))
95
+ }
96
+
97
+ const profile = await memberProfiles.findPublicById(userId)
98
+ if (!profile) throw new ForbiddenError(msg('error.app.account-longer-post'))
99
+
100
+ const draft = await filterView(
101
+ 'thread.create.before',
102
+ {
103
+ subject: input.title,
104
+ body: input.message,
105
+ prefixId: input.prefixId ?? null,
106
+ forumId: forum.id,
107
+ authorId: userId,
108
+ },
109
+ viewerRef(actor),
110
+ )
111
+
112
+ const objections = await filterView('thread.create.validate', [], { draft })
113
+ if (objections.length > 0) throw new ValidationError(objections[0]!)
114
+
115
+ const composer = new ThreadComposer({
116
+ threads: threadWrites,
117
+ config: {
118
+ floodSeconds: settings.get('posting.flood_seconds'),
119
+ maxLength: settings.get('posting.max_length'),
120
+ },
121
+ })
122
+
123
+ const created = await composer.create(
124
+ {
125
+ title: draft.subject ?? input.title,
126
+ message: draft.body,
127
+ prefixId: draft.prefixId,
128
+ subscribe: input.subscribe ?? false,
129
+ heldAsNewMember: await holdsNewMember({ actor, postCount: profile.postCount, settings }),
130
+ requiresApproval: scope.forum.requiresThreadApproval === true,
131
+ ...(input.poll === undefined
132
+ ? {}
133
+ : {
134
+ poll: {
135
+ question: input.poll.question,
136
+ options: input.poll.options,
137
+ closesAt: null,
138
+ ...(input.poll.maxOptions === undefined ? {} : { maxOptions: input.poll.maxOptions }),
139
+ ...(input.poll.allowRevote === undefined
140
+ ? {}
141
+ : { allowRevote: input.poll.allowRevote }),
142
+ ...(input.poll.publicVotes === undefined
143
+ ? {}
144
+ : { publicVotes: input.poll.publicVotes }),
145
+ },
146
+ }),
147
+ mayPostPoll: authorizer.can(actor, 'poll.post', target),
148
+ bypassesModeration: authorizer.can(actor, 'content.viewUnapproved', target),
149
+ bypassesFlood: authorizer.can(actor, 'flood.bypass'),
150
+ restriction: await authorRestriction(warnings, userId),
151
+ },
152
+ { userId, username: profile.username },
153
+ forum,
154
+ )
155
+
156
+ if (created.visibility === 'unapproved') {
157
+ await emitEvent('approval.queued', { kind: 'thread', id: created.threadId }, viewerRef(actor))
158
+ }
159
+
160
+ if (input.poll !== undefined) {
161
+ const { polls } = getContainer()
162
+ const poll = polls === null ? null : await polls.find(created.threadId, null)
163
+ if (poll !== null) {
164
+ await emitEvent(
165
+ 'poll.created',
166
+ {
167
+ threadId: created.threadId,
168
+ forumId: forum.id,
169
+ pollId: poll.id,
170
+ optionCount: poll.options.length,
171
+ },
172
+ viewerRef(actor),
173
+ )
174
+ }
175
+ }
176
+
177
+ await emitEvent(
178
+ 'thread.created',
179
+ {
180
+ threadId: created.threadId,
181
+ forumId: forum.id,
182
+ authorId: userId,
183
+ subject: draft.subject ?? input.title,
184
+ },
185
+ viewerRef(actor),
186
+ )
187
+
188
+ await notifyPostAudience({
189
+ postId: created.postId,
190
+ threadId: created.threadId,
191
+ threadSlug: created.slug,
192
+ threadTitle: draft.subject ?? input.title,
193
+ message: draft.body,
194
+ authorUsername: profile.username,
195
+ visibility: created.visibility,
196
+ })
197
+
198
+ return created
199
+ }
200
+
201
+ async function authorRestriction(
202
+ warnings: ReturnType<typeof getContainer>['warnings'],
203
+ userId: number,
204
+ ): Promise<AuthorRestriction> {
205
+ if (warnings === null) return { suspended: false, moderated: false }
206
+ return restrictsPosting(await warnings.readRestriction(userId), new Date())
207
+ }
@@ -0,0 +1,55 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import { ForbiddenError, ValidationError } from '@meith/core'
6
+ import { canHoldThreads } from '@meith/forums'
7
+ import { msg } from '@meith/i18n'
8
+ import { ThreadRatingService } from '@meith/polls'
9
+
10
+ import { getContainer } from './container'
11
+ import { getActor } from './context'
12
+ import { emitEvent, viewerRef } from './plugin-view'
13
+ import { getSettings } from './settings'
14
+
15
+ const number = (form: FormData, name: string) => {
16
+ const value = Number(form.get(name))
17
+ if (!Number.isSafeInteger(value) || value <= 0)
18
+ throw new ValidationError(msg('error.app.thread-exist'))
19
+ return value
20
+ }
21
+
22
+ export async function rateThreadAction(form: FormData): Promise<void> {
23
+ const threadId = number(form, 'threadId')
24
+ const rating = Number(form.get('rating'))
25
+ const actor = await getActor()
26
+ const { authorizer, forums, polls, threads } = getContainer()
27
+ if (actor.userId === null || polls === null)
28
+ throw new ForbiddenError(msg('error.app.must-logged-rate-thread'))
29
+ const located = await threads.locate(threadId)
30
+ const forumId = located?.forumId ?? null
31
+ const forum = forumId === null ? null : await forums.findById(forumId)
32
+ if (forumId === null || forum === null || !canHoldThreads(forum.type))
33
+ throw new ValidationError(msg('error.app.thread-exist'))
34
+ const target = {
35
+ forumId,
36
+ forum: await authorizer.forumMatrix(actor, forumId),
37
+ threadAuthorId: located?.authorUserId ?? null,
38
+ }
39
+ if (!authorizer.can(actor, 'thread.view', target))
40
+ throw new ValidationError(msg('error.app.thread-exist'))
41
+ const recorded = await new ThreadRatingService(polls).rate({
42
+ threadId,
43
+ userId: actor.userId,
44
+ rating,
45
+ enabled:
46
+ (await getSettings()).get('posting.thread_ratings_enabled') &&
47
+ authorizer.can(actor, 'thread.rate', target),
48
+ })
49
+ await emitEvent(
50
+ 'rating.recorded',
51
+ { threadId, rating, average: recorded.average },
52
+ viewerRef(actor),
53
+ )
54
+ redirect(`/thread/${threadId}`)
55
+ }
@@ -0,0 +1,154 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import type { Action } from '@meith/authorization'
6
+ import { ForbiddenError, ValidationError } from '@meith/core'
7
+ import { msg } from '@meith/i18n'
8
+ import { parseThreadTool, type ThreadToolRights, ThreadTools } from '@meith/moderation'
9
+
10
+ import type { FormState } from './auth-form-state'
11
+ import { getContainer } from './container'
12
+ import { getActor } from './context'
13
+ import { formStateReporter } from './form-state-reporter'
14
+ import { positiveInt } from './form-values'
15
+ import { tr } from './i18n'
16
+ import { emitEvent } from './plugin-view'
17
+
18
+ const toFormState = formStateReporter('thread-tool-actions', 'unexpected error in thread tools')
19
+
20
+ const TOOL_ACTIONS: Readonly<Record<keyof ThreadToolRights, Action>> = {
21
+ lock: 'thread.lock',
22
+ stick: 'thread.stick',
23
+ move: 'thread.move',
24
+ delete: 'thread.delete',
25
+ restore: 'thread.restore',
26
+ }
27
+
28
+ export async function threadToolAction(_prev: FormState, form: FormData): Promise<FormState> {
29
+ const threadId = positiveInt(form, 'threadId')
30
+ const tool = parseThreadTool(
31
+ typeof form.get('tool') === 'string' ? (form.get('tool') as string) : undefined,
32
+ )
33
+ const toForumId = positiveInt(form, 'toForumId')
34
+
35
+ if (threadId === null || tool === null) {
36
+ return { error: await tr('notice.app.thread-exist') }
37
+ }
38
+
39
+ const { authorizer, threadTools, threads } = getContainer()
40
+ if (threadTools === null) {
41
+ return {
42
+ error: await tr('notice.app.board-running-in-memory-sample-data-3'),
43
+ }
44
+ }
45
+
46
+ let outcome: Awaited<ReturnType<ThreadTools['apply']>>
47
+ try {
48
+ const actor = await getActor()
49
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
50
+
51
+ const target = await threadTools.find(threadId)
52
+ if (target === null) throw new ValidationError(msg('error.app.thread-exist'))
53
+
54
+ const matrix = await authorizer.forumMatrix(actor, target.forumId)
55
+ if (!authorizer.can(actor, 'thread.view', { forumId: target.forumId, forum: matrix })) {
56
+ throw new ValidationError(msg('error.app.thread-exist'))
57
+ }
58
+
59
+ const threadAuthorId = (await threads.locate(threadId))?.authorUserId ?? null
60
+ const rights = await resolveRights(target.forumId, threadAuthorId)
61
+ const destinationRights =
62
+ (tool === 'move' || tool === 'copy') && toForumId !== null
63
+ ? await resolveRights(toForumId, threadAuthorId)
64
+ : undefined
65
+
66
+ outcome = await new ThreadTools({ threads: threadTools }).apply({
67
+ threadId,
68
+ tool,
69
+ ...(toForumId === null ? {} : { toForumId }),
70
+ actorUserId: actor.userId,
71
+ rights,
72
+ ...(destinationRights === undefined ? {} : { destinationRights }),
73
+ })
74
+
75
+ if (outcome.changed) {
76
+ await announceTool(outcome.tool, {
77
+ threadId,
78
+ fromForumId: target.forumId,
79
+ toForumId,
80
+ moderatorId: actor.userId,
81
+ })
82
+ }
83
+ } catch (err) {
84
+ return toFormState(err)
85
+ }
86
+
87
+ if (outcome.tool === 'copy') {
88
+ redirect(`/thread/${outcome.threadId}-${outcome.slug}?tool=copy`)
89
+ }
90
+ if (outcome.tool === 'delete') {
91
+ redirect(`/${(await threadTools.find(threadId))?.forumId ?? ''}?thread=deleted`)
92
+ }
93
+ redirect(`/thread/${outcome.threadId}-${outcome.slug}?tool=${outcome.tool}`)
94
+ }
95
+
96
+ async function announceTool(
97
+ tool: Awaited<ReturnType<ThreadTools['apply']>>['tool'],
98
+ input: {
99
+ readonly threadId: number
100
+ readonly fromForumId: number
101
+ readonly toForumId: number | null
102
+ readonly moderatorId: number
103
+ },
104
+ ): Promise<void> {
105
+ const moderation = { moderatorId: input.moderatorId, reason: null }
106
+ const thread = { threadId: input.threadId, forumId: input.fromForumId }
107
+
108
+ await emitEvent(
109
+ 'moderation.logged',
110
+ { action: `thread.${tool}`, targetId: input.threadId },
111
+ moderation,
112
+ )
113
+
114
+ if (tool === 'lock' || tool === 'unlock') {
115
+ await emitEvent('thread.locked', { ...thread, isLocked: tool === 'lock' }, moderation)
116
+ return
117
+ }
118
+ if (tool === 'stick' || tool === 'unstick') {
119
+ await emitEvent('thread.stickied', { ...thread, isSticky: tool === 'stick' }, moderation)
120
+ return
121
+ }
122
+ if (tool === 'move' && input.toForumId !== null) {
123
+ await emitEvent(
124
+ 'thread.moved',
125
+ {
126
+ threadId: input.threadId,
127
+ fromForumId: input.fromForumId,
128
+ toForumId: input.toForumId,
129
+ },
130
+ moderation,
131
+ )
132
+ }
133
+ }
134
+
135
+ async function resolveRights(
136
+ forumId: number,
137
+ threadAuthorId: number | null,
138
+ ): Promise<ThreadToolRights> {
139
+ const actor = await getActor()
140
+ const { authorizer } = getContainer()
141
+ const [forum, moderatorRights] = await Promise.all([
142
+ authorizer.forumMatrix(actor, forumId),
143
+ authorizer.moderatorRightsIn(actor, forumId),
144
+ ])
145
+ const target = { forumId, forum, moderatorRights, threadAuthorId }
146
+
147
+ return {
148
+ lock: authorizer.can(actor, TOOL_ACTIONS.lock, target),
149
+ stick: authorizer.can(actor, TOOL_ACTIONS.stick, target),
150
+ move: authorizer.can(actor, TOOL_ACTIONS.move, target),
151
+ delete: authorizer.can(actor, TOOL_ACTIONS.delete, target),
152
+ restore: authorizer.can(actor, TOOL_ACTIONS.restore, target),
153
+ }
154
+ }
@@ -0,0 +1,178 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import { verifyPassword } from '@meith/accounts'
6
+ import { ForbiddenError, ValidationError } from '@meith/core'
7
+ import { msg } from '@meith/i18n'
8
+
9
+ import { RECOVERY_CODES_FIELD } from '@/view/two-factor'
10
+
11
+ import { recordAuthEvent } from './auth-events'
12
+ import type { FormState } from './auth-form-state'
13
+ import { getContainer } from './container'
14
+ import { getActor } from './context'
15
+ import { assertDemoAccountChangeable } from './demo'
16
+ import { formStateReporter } from './form-state-reporter'
17
+ import { text } from './form-values'
18
+ import { getSettingsUncached } from './settings'
19
+ import { secondFactorPosture, twoFactorOffered, twoFactorService } from './two-factor'
20
+
21
+ const toFormState = formStateReporter(
22
+ 'two-factor-actions',
23
+ 'unexpected error while changing two-factor authentication',
24
+ )
25
+
26
+ interface Owner {
27
+ readonly userId: number
28
+ readonly hasPassword: boolean
29
+ }
30
+
31
+ async function ownAccount(): Promise<Owner> {
32
+ const actor = await getActor()
33
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
34
+
35
+ await assertDemoAccountChangeable(actor.userId, 'sign-in method')
36
+
37
+ const account = await getContainer().accountStore.accounts.findById(actor.userId)
38
+ if (account === null) throw new ForbiddenError(msg('error.app.account-longer-exists'))
39
+
40
+ return { userId: actor.userId, hasPassword: account.passwordHash !== null }
41
+ }
42
+
43
+ async function requireOwnAccount(): Promise<Owner> {
44
+ if (!(await twoFactorOffered())) {
45
+ throw new ForbiddenError(msg('error.app.board-offer-two-factor-authentication'))
46
+ }
47
+
48
+ return ownAccount()
49
+ }
50
+
51
+ async function requireOwnFactor(): Promise<Owner> {
52
+ const owner = await ownAccount()
53
+ if (await twoFactorOffered()) return owner
54
+
55
+ const service = twoFactorService()
56
+ if (service !== null && (await service.isEnrolled(owner.userId))) return owner
57
+
58
+ throw new ForbiddenError(msg('error.app.board-offer-two-factor-authentication'))
59
+ }
60
+
61
+ export async function beginTwoFactorAction(_prev: FormState, _form: FormData): Promise<FormState> {
62
+ try {
63
+ const { userId } = await requireOwnAccount()
64
+
65
+ const service = twoFactorService()
66
+ if (service === null) throw new ForbiddenError(msg('error.app.board-seal-secret'))
67
+
68
+ const name = (await getSettingsUncached()).get('board.name')
69
+ await service.beginEnrolment(userId, name.trim() === '' ? 'Meith' : name)
70
+ } catch (err) {
71
+ return toFormState(err)
72
+ }
73
+
74
+ redirect('/usercp/security?factor=setup')
75
+ }
76
+
77
+ export async function abandonTwoFactorAction(
78
+ _prev: FormState,
79
+ _form: FormData,
80
+ ): Promise<FormState> {
81
+ try {
82
+ const { userId } = await requireOwnAccount()
83
+
84
+ const service = twoFactorService()
85
+ if (service !== null) await service.abandonEnrolment(userId)
86
+ } catch (err) {
87
+ return toFormState(err)
88
+ }
89
+
90
+ redirect('/usercp/security')
91
+ }
92
+
93
+ export async function confirmTwoFactorAction(_prev: FormState, form: FormData): Promise<FormState> {
94
+ try {
95
+ const { userId } = await requireOwnAccount()
96
+
97
+ const service = twoFactorService()
98
+ if (service === null) throw new ForbiddenError(msg('error.app.board-seal-secret'))
99
+
100
+ const codes = await service.confirmEnrolment({ userId, code: text(form, 'code') })
101
+ await recordAuthEvent({ userId, kind: 'second_factor_enabled' })
102
+
103
+ return { notice: 'saved', values: { [RECOVERY_CODES_FIELD]: codes.join('\n') } }
104
+ } catch (err) {
105
+ return toFormState(err)
106
+ }
107
+ }
108
+
109
+ export async function replaceRecoveryCodesAction(
110
+ _prev: FormState,
111
+ form: FormData,
112
+ ): Promise<FormState> {
113
+ try {
114
+ const { userId, hasPassword } = await requireOwnFactor()
115
+
116
+ const service = twoFactorService()
117
+ if (service === null) throw new ForbiddenError(msg('error.app.board-seal-secret'))
118
+
119
+ await assertStillThem({ userId, hasPassword, form, service })
120
+
121
+ const codes = await service.replaceRecoveryCodes(userId)
122
+ await recordAuthEvent({ userId, kind: 'recovery_codes_replaced' })
123
+
124
+ return { notice: 'saved', values: { [RECOVERY_CODES_FIELD]: codes.join('\n') } }
125
+ } catch (err) {
126
+ return toFormState(err)
127
+ }
128
+ }
129
+
130
+ export async function disableTwoFactorAction(_prev: FormState, form: FormData): Promise<FormState> {
131
+ try {
132
+ const { userId, hasPassword } = await requireOwnFactor()
133
+
134
+ const service = twoFactorService()
135
+ if (service === null) throw new ForbiddenError(msg('error.app.board-seal-secret'))
136
+
137
+ await assertStillThem({ userId, hasPassword, form, service })
138
+
139
+ const posture = await secondFactorPosture(userId)
140
+ await service.disable({ userId, required: posture.required })
141
+ await recordAuthEvent({ userId, kind: 'second_factor_disabled' })
142
+ } catch (err) {
143
+ return toFormState(err)
144
+ }
145
+
146
+ redirect('/usercp/security?factor=off')
147
+ }
148
+
149
+ /**
150
+ * Weakening an account asks for the thing being weakened. An account with a
151
+ * password gives it; one without — signed in by a provider or a passkey —
152
+ * gives a current code instead, which proves the same possession.
153
+ */
154
+ async function assertStillThem(input: {
155
+ readonly userId: number
156
+ readonly hasPassword: boolean
157
+ readonly form: FormData
158
+ readonly service: NonNullable<ReturnType<typeof twoFactorService>>
159
+ }): Promise<void> {
160
+ if (input.hasPassword) {
161
+ const account = await getContainer().accountStore.accounts.findById(input.userId)
162
+ const password = text(input.form, 'password')
163
+
164
+ if (password === '' || !(await verifyPassword(password, account?.passwordHash))) {
165
+ throw new ValidationError(msg('error.app.current-password'))
166
+ }
167
+ return
168
+ }
169
+
170
+ const outcome = await input.service.verify({
171
+ userId: input.userId,
172
+ code: text(input.form, 'code'),
173
+ })
174
+
175
+ if (outcome.status !== 'ok') {
176
+ throw new ValidationError(msg('error.app.enter-current-code-from-authenticator'))
177
+ }
178
+ }
@@ -0,0 +1,86 @@
1
+ import 'server-only'
2
+
3
+ import {
4
+ clearSecondFactor,
5
+ type Enrolment,
6
+ holdsSecondFactor,
7
+ TwoFactorService,
8
+ type TwoFactorState,
9
+ } from '@meith/accounts'
10
+ import { env } from '@meith/core'
11
+
12
+ import { getContainer } from './container'
13
+ import { getActor } from './context'
14
+ import { getSettingsUncached } from './settings'
15
+
16
+ export interface SecondFactorPosture {
17
+ readonly offered: boolean
18
+ readonly required: boolean
19
+ }
20
+
21
+ export function twoFactorService(): TwoFactorService | null {
22
+ const key = env.AUTH_SECRET
23
+ if (key === undefined || key.trim() === '') return null
24
+
25
+ const { accountStore } = getContainer()
26
+
27
+ return new TwoFactorService({
28
+ accounts: accountStore.accounts,
29
+ twoFactor: accountStore.twoFactor,
30
+ recoveryCodes: accountStore.recoveryCodes,
31
+ sealingKey: key,
32
+ })
33
+ }
34
+
35
+ /**
36
+ * Offered when the board says so and the board has a key to seal a secret
37
+ * with. Without AUTH_SECRET there is nowhere safe to put the secret, so the
38
+ * surface is hidden rather than half-built.
39
+ */
40
+ export async function twoFactorOffered(): Promise<boolean> {
41
+ if (getContainer().dataSource !== 'postgres') return false
42
+ if (twoFactorService() === null) return false
43
+
44
+ return (await getSettingsUncached()).get('security.two_factor_enabled')
45
+ }
46
+
47
+ export async function twoFactorRequiredForStaff(): Promise<boolean> {
48
+ if (!(await twoFactorOffered())) return false
49
+ return (await getSettingsUncached()).get('security.two_factor_required_for_staff')
50
+ }
51
+
52
+ /** Whether this member must hold a second factor, and may not drop it. */
53
+ export async function secondFactorPosture(userId: number | null): Promise<SecondFactorPosture> {
54
+ const offered = await twoFactorOffered()
55
+ if (!offered || userId === null) return { offered, required: false }
56
+
57
+ if (!(await twoFactorRequiredForStaff())) return { offered, required: false }
58
+
59
+ const actor = await getActor()
60
+ if (actor.userId !== userId) return { offered, required: false }
61
+
62
+ return { offered, required: getContainer().authorizer.can(actor, 'admincp.access') }
63
+ }
64
+
65
+ export async function memberHoldsSecondFactor(userId: number): Promise<boolean> {
66
+ return holdsSecondFactor(getContainer().accountStore.twoFactor, userId)
67
+ }
68
+
69
+ export async function clearMemberSecondFactor(userId: number): Promise<boolean> {
70
+ return clearSecondFactor(getContainer().accountStore, userId)
71
+ }
72
+
73
+ export async function pendingEnrolment(userId: number): Promise<Enrolment | null> {
74
+ const service = twoFactorService()
75
+ if (service === null) return null
76
+
77
+ const name = (await getSettingsUncached()).get('board.name')
78
+ return service.pendingEnrolment(userId, name.trim() === '' ? 'Meith' : name)
79
+ }
80
+
81
+ export async function twoFactorState(userId: number): Promise<TwoFactorState> {
82
+ const service = twoFactorService()
83
+ if (service === null) return { enrolled: false, pending: false, recoveryCodesLeft: 0 }
84
+
85
+ return service.state(userId)
86
+ }