@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,479 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import { type AuthConfig, foldIdentifier, hashToken, type LoginBucket } from '@meith/accounts'
6
+ import { env, logger } from '@meith/core'
7
+ import { currentRequestId } from '@meith/core/logger'
8
+
9
+ import {
10
+ limitMessage,
11
+ loginAddressAttempts,
12
+ refused,
13
+ spendRegisterLimit,
14
+ spendResendLimit,
15
+ spendResetLimits,
16
+ verifyChallenge,
17
+ } from './antispam'
18
+ import { boardAuthConfig } from './auth-config'
19
+ import { recordAuthEvent } from './auth-events'
20
+ import type { FormState } from './auth-form-state'
21
+ import { sendPasswordResetEmail, sendVerificationEmail } from './auth-mail'
22
+ import { configuredIdentity, configuredSessions, getContainer } from './container'
23
+ import { formStateReporter } from './form-state-reporter'
24
+ import { getTranslator, tr } from './i18n'
25
+ import { termsAcceptance } from './legal'
26
+ import { emitEvent, filterView } from './plugin-view'
27
+ import { profileFieldService, registrationFieldContext, submittedFields } from './profile-fields'
28
+ import {
29
+ countingAddress,
30
+ countingPrefix,
31
+ requestFingerprint,
32
+ retainedIpPrefix,
33
+ } from './request-fingerprint'
34
+ import { isSafeLocalPath } from './safe-path'
35
+ import {
36
+ clearSecondFactorCookie,
37
+ clearSessionCookies,
38
+ readRememberToken,
39
+ readSecondFactorToken,
40
+ readSessionToken,
41
+ setRememberCookie,
42
+ setSecondFactorCookie,
43
+ setSessionCookie,
44
+ } from './session-cookies'
45
+ import { twoFactorService } from './two-factor'
46
+
47
+ function field(form: FormData, name: string): string {
48
+ const v = form.get(name)
49
+ return typeof v === 'string' ? v.trim() : ''
50
+ }
51
+
52
+ const toFormState = formStateReporter('auth-actions', 'unexpected error in auth action')
53
+
54
+ async function addressContext(): Promise<{ readonly ipPrefix: string | null }> {
55
+ return { ipPrefix: await retainedIpPrefix() }
56
+ }
57
+
58
+ async function deviceContext(): Promise<{
59
+ readonly ipPrefix: string | null
60
+ readonly userAgent: string | null
61
+ }> {
62
+ return requestFingerprint()
63
+ }
64
+
65
+ async function loginBuckets(
66
+ identifier: string,
67
+ config: AuthConfig,
68
+ ): Promise<readonly LoginBucket[]> {
69
+ const account = foldIdentifier(identifier)
70
+ const wide: LoginBucket = {
71
+ key: `login:${account}`,
72
+ max: config.maxAccountLoginAttempts,
73
+ }
74
+
75
+ const address = await countingAddress()
76
+ if (address === null) return [wide]
77
+
78
+ const perAddress = await loginAddressAttempts()
79
+ const prefix = await countingPrefix()
80
+ if (perAddress <= 0 || prefix === null) {
81
+ return [{ key: `login:${account}@${address}` }, wide]
82
+ }
83
+
84
+ return [
85
+ { key: `login:${account}@${address}` },
86
+ wide,
87
+ { key: `login@${prefix}`, max: perAddress, clearOnSuccess: false },
88
+ ]
89
+ }
90
+
91
+ export async function registerAction(_prev: FormState, form: FormData): Promise<FormState> {
92
+ const username = field(form, 'username')
93
+ const email = field(form, 'email')
94
+ const password = field(form, 'password')
95
+ const accepted = field(form, 'terms') !== ''
96
+ const values = { username, email, ...(accepted ? { terms: '1' } : {}) }
97
+
98
+ const identity = await configuredIdentity()
99
+
100
+ let verification: { token: string; email: string; username: string } | null = null
101
+
102
+ try {
103
+ const terms = await termsAcceptance()
104
+ if (terms !== null && !accepted) {
105
+ return {
106
+ error: await tr('authAction.acceptTerms', { terms: terms.label }),
107
+ values,
108
+ }
109
+ }
110
+
111
+ const challenge = await verifyChallenge(form)
112
+ if (!challenge.ok) return { error: challenge.reason, values }
113
+
114
+ const limited = await spendRegisterLimit()
115
+ if (refused(limited)) return { error: limitMessage(limited), values }
116
+
117
+ const fields = profileFieldService()
118
+ const context = fields === null ? null : await registrationFieldContext()
119
+ const fieldValues =
120
+ fields === null || context === null
121
+ ? []
122
+ : await fields.validateRegistration({ submitted: submittedFields(form), context })
123
+
124
+ const objections = await filterView('user.register.validate', [], {
125
+ username,
126
+ email,
127
+ ipPrefix: (await requestFingerprint()).ipPrefix,
128
+ })
129
+ if (objections.length > 0) return { error: objections[0]!, values }
130
+
131
+ const result = await identity.register({ username, email, password }, await addressContext())
132
+
133
+ await emitEvent(
134
+ 'user.registered',
135
+ {
136
+ userId: result.account.id,
137
+ username: result.account.username,
138
+ requiresActivation: result.verificationToken !== undefined,
139
+ },
140
+ { requestId: currentRequestId() ?? null },
141
+ )
142
+
143
+ if (fields !== null) await fields.applyRegistration(result.account.id, fieldValues)
144
+
145
+ if (result.verificationToken !== undefined) {
146
+ verification = {
147
+ token: result.verificationToken,
148
+ email: result.account.email,
149
+ username: result.account.username,
150
+ }
151
+ }
152
+ } catch (err) {
153
+ return toFormState(err, values)
154
+ }
155
+
156
+ if (verification !== null) {
157
+ try {
158
+ await sendVerificationEmail({ ...verification, t: await getTranslator() })
159
+ } catch (err) {
160
+ logger({ module: 'auth-actions' }).error(
161
+ { err },
162
+ 'could not send a verification e-mail; the account exists and can ask for another',
163
+ )
164
+ }
165
+ redirect(`/verify/resend?email=${encodeURIComponent(verification.email)}&sent=1`)
166
+ }
167
+
168
+ redirect('/login?registered=1')
169
+ }
170
+
171
+ export async function resendVerificationAction(
172
+ _prev: FormState,
173
+ form: FormData,
174
+ ): Promise<FormState> {
175
+ const email = field(form, 'email')
176
+ const values = { email }
177
+
178
+ const notice = await tr('authAction.verificationResent')
179
+
180
+ try {
181
+ const limit = await spendResendLimit(foldIdentifier(email))
182
+ if (limit !== null && !limit.allowed) return { notice, values }
183
+
184
+ const identity = await configuredIdentity()
185
+ const resent = await identity.resendVerification(email)
186
+
187
+ if (resent.token !== null && resent.account !== null) {
188
+ try {
189
+ await sendVerificationEmail({
190
+ token: resent.token,
191
+ email: resent.account.email,
192
+ username: resent.account.username,
193
+ t: await getTranslator(),
194
+ })
195
+ } catch (err) {
196
+ logger({ module: 'auth-actions' }).error({ err }, 'could not resend a verification e-mail')
197
+ }
198
+ }
199
+
200
+ return { notice, values }
201
+ } catch (err) {
202
+ return toFormState(err, values)
203
+ }
204
+ }
205
+
206
+ export async function loginAction(_prev: FormState, form: FormData): Promise<FormState> {
207
+ const identifier = field(form, 'identifier')
208
+ const password = field(form, 'password')
209
+ const remember = form.get('remember') === 'on'
210
+ const next = sanitizeNext(field(form, 'next'))
211
+ const values = { identifier }
212
+
213
+ let destination = next
214
+
215
+ try {
216
+ const config = await boardAuthConfig()
217
+ const identity = await configuredIdentity()
218
+
219
+ const outcome = await identity.login(
220
+ identifier,
221
+ password,
222
+ await loginBuckets(identifier, config),
223
+ await deviceContext(),
224
+ { remember },
225
+ )
226
+
227
+ if (outcome.status === 'second-factor') {
228
+ await setSecondFactorCookie(outcome.token, outcome.expiresAt)
229
+ destination = verifyPath(next)
230
+ } else {
231
+ await completeSignIn(outcome.login, remember)
232
+ }
233
+ } catch (err) {
234
+ await recordAuthEvent({
235
+ userId: await accountBehind(identifier),
236
+ kind: 'login_failed',
237
+ detail: { identifier },
238
+ })
239
+ await emitEvent(
240
+ 'user.login.attempted',
241
+ {
242
+ username: identifier,
243
+ outcome: loginOutcomeOf(err),
244
+ ipPrefix: (await requestFingerprint()).ipPrefix,
245
+ },
246
+ { requestId: currentRequestId() ?? null },
247
+ )
248
+ return toFormState(err, values)
249
+ }
250
+
251
+ await emitEvent(
252
+ 'user.login.attempted',
253
+ {
254
+ username: identifier,
255
+ outcome: 'ok',
256
+ ipPrefix: (await requestFingerprint()).ipPrefix,
257
+ },
258
+ { requestId: currentRequestId() ?? null },
259
+ )
260
+
261
+ redirect(destination)
262
+ }
263
+
264
+ async function accountBehind(identifier: string): Promise<number | null> {
265
+ const lower = foldIdentifier(identifier)
266
+ if (lower === '') return null
267
+
268
+ try {
269
+ const { accounts } = getContainer().accountStore
270
+ const account =
271
+ (await accounts.findByUsernameLower(lower)) ?? (await accounts.findByEmailLower(lower))
272
+
273
+ return account?.id ?? null
274
+ } catch (err) {
275
+ logger({ module: 'auth-actions' }).warn({ err }, 'could not attribute a refused sign-in')
276
+ return null
277
+ }
278
+ }
279
+
280
+ async function completeSignIn(
281
+ login: { account: { id: number }; sessionToken: string; expiresAt: Date },
282
+ remember: boolean,
283
+ ): Promise<void> {
284
+ await setSessionCookie(login.sessionToken, login.expiresAt)
285
+
286
+ if (remember) {
287
+ const sessions = await configuredSessions()
288
+ const remembered = await sessions.startRemembered(login.account.id, await deviceContext())
289
+ await setRememberCookie(remembered.rememberToken, remembered.rememberExpiresAt)
290
+ }
291
+
292
+ await recordAuthEvent({ userId: login.account.id, kind: 'login' })
293
+ await emitEvent(
294
+ 'user.logged-in',
295
+ { userId: login.account.id },
296
+ { requestId: currentRequestId() ?? null },
297
+ )
298
+ }
299
+
300
+ function loginOutcomeOf(error: unknown): 'bad-credentials' | 'locked-out' | 'banned' {
301
+ const message = error instanceof Error ? error.message.toLowerCase() : ''
302
+ if (message.includes('banned') || message.includes('suspended')) return 'banned'
303
+ if (message.includes('too many') || message.includes('locked')) return 'locked-out'
304
+ return 'bad-credentials'
305
+ }
306
+
307
+ function verifyPath(next: string): string {
308
+ return next === '/' ? '/login/verify' : `/login/verify?next=${encodeURIComponent(next)}`
309
+ }
310
+
311
+ export async function verifySecondFactorAction(
312
+ _prev: FormState,
313
+ form: FormData,
314
+ ): Promise<FormState> {
315
+ const code = field(form, 'code')
316
+ const next = sanitizeNext(field(form, 'next'))
317
+
318
+ const token = await readSecondFactorToken()
319
+ if (token === undefined || token === '') {
320
+ return { error: await tr('authAction.secondFactorExpired') }
321
+ }
322
+
323
+ const identity = await configuredIdentity()
324
+ const pending = await identity.pendingSecondFactor(token)
325
+ if (pending === null) {
326
+ await clearSecondFactorCookie()
327
+ return { error: await tr('authAction.secondFactorExpired') }
328
+ }
329
+
330
+ const service = twoFactorService()
331
+ if (service === null) return { error: await tr('authAction.secondFactorExpired') }
332
+
333
+ try {
334
+ await identity.assertSecondFactorAttemptsLeft(pending.userId)
335
+
336
+ const outcome = await service.verify({ userId: pending.userId, code })
337
+
338
+ if (outcome.status !== 'ok') {
339
+ await identity.recordSecondFactorFailure(pending.userId)
340
+ await recordAuthEvent({ userId: pending.userId, kind: 'second_factor_failed' })
341
+ return {
342
+ error: await tr(
343
+ outcome.status === 'replayed' ? 'authAction.codeReplayed' : 'authAction.codeWrong',
344
+ ),
345
+ }
346
+ }
347
+
348
+ await identity.clearSecondFactorFailures(pending.userId)
349
+
350
+ const login = await identity.redeemSecondFactor(token, await deviceContext())
351
+ await clearSecondFactorCookie()
352
+ await completeSignIn(login, pending.remember)
353
+
354
+ if (outcome.usedRecoveryCode) {
355
+ await recordAuthEvent({ userId: pending.userId, kind: 'recovery_code_used' })
356
+ }
357
+ } catch (err) {
358
+ return toFormState(err)
359
+ }
360
+
361
+ redirect(next)
362
+ }
363
+
364
+ export async function abandonSecondFactorAction(): Promise<void> {
365
+ const token = await readSecondFactorToken()
366
+
367
+ if (token !== undefined && token !== '') {
368
+ const identity = await configuredIdentity()
369
+ const pending = await identity.pendingSecondFactor(token)
370
+ if (pending !== null) await identity.abandonSecondFactor(pending.userId)
371
+ }
372
+
373
+ await clearSecondFactorCookie()
374
+ redirect('/login')
375
+ }
376
+
377
+ export async function logoutAction(): Promise<void> {
378
+ const token = await readSessionToken()
379
+ const rememberToken = await readRememberToken()
380
+ const { accountStore, identity } = getContainer()
381
+ if (rememberToken) {
382
+ try {
383
+ const held = await accountStore.remember.findByTokenHash(await hashToken(rememberToken))
384
+ if (held !== null) {
385
+ await accountStore.remember.revokeFamily(held.familyId, 'logout', new Date())
386
+ }
387
+ } catch (err) {
388
+ logger({ module: 'auth-actions' }).warn(
389
+ { err },
390
+ ['logout remember-token revoke failed', 'clearing cookies anyway'].join('; '),
391
+ )
392
+ }
393
+ }
394
+ if (token) {
395
+ try {
396
+ const resolved = await identity.resolveSession(token)
397
+ await identity.logout(token)
398
+ if (resolved !== null) {
399
+ await recordAuthEvent({ userId: resolved.userId, kind: 'logout' })
400
+ await emitEvent(
401
+ 'user.logged-out',
402
+ { userId: resolved.userId, reason: 'requested' },
403
+ { requestId: currentRequestId() ?? null },
404
+ )
405
+ }
406
+ } catch (err) {
407
+ logger({ module: 'auth-actions' }).warn(
408
+ { err },
409
+ 'logout revoke failed; clearing cookies anyway',
410
+ )
411
+ }
412
+ }
413
+ await clearSessionCookies()
414
+ redirect('/login')
415
+ }
416
+
417
+ export async function requestResetAction(_prev: FormState, form: FormData): Promise<FormState> {
418
+ const email = field(form, 'email')
419
+ const { identity } = getContainer()
420
+
421
+ const notice = await tr('authAction.resetRequested')
422
+
423
+ try {
424
+ const limits = await spendResetLimits(foldIdentifier(email))
425
+ if (limits.some(refused)) return { notice }
426
+
427
+ const { token, userId } = await identity.requestPasswordReset(email)
428
+
429
+ if (token !== null && userId !== null) {
430
+ const account = await getContainer().accountStore.accounts.findById(userId)
431
+ if (account !== null) {
432
+ try {
433
+ await sendPasswordResetEmail({
434
+ token,
435
+ email: account.email,
436
+ username: account.username,
437
+ t: await getTranslator(),
438
+ })
439
+ } catch (err) {
440
+ logger({ module: 'auth-actions' }).error(
441
+ { err },
442
+ 'could not send a password reset e-mail',
443
+ )
444
+ }
445
+ }
446
+ }
447
+
448
+ if (token && env.NODE_ENV === 'development') {
449
+ return { notice, values: { devToken: token } }
450
+ }
451
+ return { notice }
452
+ } catch (err) {
453
+ return toFormState(err, { email })
454
+ }
455
+ }
456
+
457
+ export async function confirmResetAction(_prev: FormState, form: FormData): Promise<FormState> {
458
+ const token = field(form, 'token')
459
+ const password = field(form, 'password')
460
+ const confirm = field(form, 'confirm')
461
+
462
+ if (password !== confirm) {
463
+ return { error: await tr('notice.app.two-passwords-match'), values: { token } }
464
+ }
465
+
466
+ const identity = await configuredIdentity()
467
+ try {
468
+ const { userId } = await identity.redeemPasswordReset(token, password)
469
+ await recordAuthEvent({ userId, kind: 'password_reset' })
470
+ } catch (err) {
471
+ return toFormState(err, { token })
472
+ }
473
+
474
+ redirect('/login?reset=1')
475
+ }
476
+
477
+ function sanitizeNext(next: string): string {
478
+ return isSafeLocalPath(next) ? next : '/'
479
+ }
@@ -0,0 +1,61 @@
1
+ import 'server-only'
2
+
3
+ import { type AuthConfig, DEFAULT_AUTH_POLICY, resolveAuthPolicy } from '@meith/accounts'
4
+ import { env } from '@meith/core'
5
+
6
+ import { SEED_GROUP } from './seed-board'
7
+ import { getSettings } from './settings'
8
+
9
+ export const REMEMBER_DAYS = 30
10
+ export const SESSION_LIFETIME_DAYS = 14
11
+
12
+ export const AUTH_CONFIG: AuthConfig = {
13
+ ...DEFAULT_AUTH_POLICY,
14
+ sessionLifetimeDays: SESSION_LIFETIME_DAYS,
15
+ activationMethod: 'none',
16
+ defaultMemberGroupId: SEED_GROUP.registered,
17
+ }
18
+
19
+ export interface BoardSessionConfig {
20
+ readonly rememberDays: number
21
+ readonly sessionLifetimeDays: number
22
+ }
23
+
24
+ export async function boardAuthConfig(): Promise<AuthConfig> {
25
+ if (env.DATA_SOURCE !== 'postgres') return AUTH_CONFIG
26
+
27
+ const settings = await getSettings()
28
+ return demoOverrides({
29
+ ...AUTH_CONFIG,
30
+ ...resolveAuthPolicy((key) => settings.get(key as never), AUTH_CONFIG),
31
+ })
32
+ }
33
+
34
+ export async function boardSessionConfig(): Promise<BoardSessionConfig> {
35
+ return {
36
+ rememberDays: REMEMBER_DAYS,
37
+ sessionLifetimeDays: (await boardAuthConfig()).sessionLifetimeDays,
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Applied last, over the stored settings, because on a demo the stored settings
43
+ * are whatever the last visitor left behind.
44
+ *
45
+ * The lockout exists to make guessing a password expensive. A demo's passwords
46
+ * are printed on the page, so there is nothing left to guess — and the counter
47
+ * is per account, which means one visitor mistyping `admin` five times locks the
48
+ * published login for everyone else for a quarter of an hour. Not disabled
49
+ * outright: this is still a login form on the open internet, and something
50
+ * hammering it should still meet a wall.
51
+ */
52
+ function demoOverrides(config: AuthConfig): AuthConfig {
53
+ if (!env.DEMO_MODE) return config
54
+
55
+ return {
56
+ ...config,
57
+ maxLoginAttempts: 50,
58
+ maxAccountLoginAttempts: 500,
59
+ lockoutMinutes: 1,
60
+ }
61
+ }
@@ -0,0 +1,92 @@
1
+ import 'server-only'
2
+
3
+ import type { AuthEventKind, AuthEventRecord } from '@meith/accounts'
4
+ import { logger } from '@meith/core'
5
+
6
+ import { getContainer } from './container'
7
+ import { requestFingerprint } from './request-fingerprint'
8
+
9
+ export const MEMBER_ACTIVITY_LIMIT = 20
10
+
11
+ /**
12
+ * Never lets the log stop the thing it is recording. A sign-in that worked and
13
+ * went unrecorded is a gap in an audit trail; a sign-in refused because the
14
+ * audit trail was unwritable is an outage.
15
+ */
16
+ export async function recordAuthEvent(input: {
17
+ readonly userId: number | null
18
+ readonly kind: AuthEventKind
19
+ readonly detail?: Readonly<Record<string, unknown>>
20
+ }): Promise<void> {
21
+ const { accountStore, dataSource } = getContainer()
22
+ if (dataSource !== 'postgres') return
23
+
24
+ try {
25
+ const fingerprint = await requestFingerprint()
26
+
27
+ await accountStore.authEvents.record({
28
+ userId: input.userId,
29
+ kind: input.kind,
30
+ ipPrefix: fingerprint.ipPrefix,
31
+ userAgent: fingerprint.userAgent,
32
+ detail: input.detail ?? {},
33
+ at: new Date(),
34
+ })
35
+ } catch (err) {
36
+ logger({ module: 'auth-events' }).warn(
37
+ { err, kind: input.kind },
38
+ 'could not record an authentication event',
39
+ )
40
+ }
41
+ }
42
+
43
+ export async function recordStaffAuthEvent(input: {
44
+ readonly userId: number
45
+ readonly kind: AuthEventKind
46
+ readonly detail?: Readonly<Record<string, unknown>>
47
+ }): Promise<void> {
48
+ const { accountStore, dataSource } = getContainer()
49
+ if (dataSource !== 'postgres') return
50
+
51
+ try {
52
+ await accountStore.authEvents.record({
53
+ userId: input.userId,
54
+ kind: input.kind,
55
+ ipPrefix: null,
56
+ userAgent: null,
57
+ detail: { ...(input.detail ?? {}), by: 'staff' },
58
+ at: new Date(),
59
+ })
60
+ } catch (err) {
61
+ logger({ module: 'auth-events' }).warn(
62
+ { err, kind: input.kind },
63
+ 'could not record an authentication event',
64
+ )
65
+ }
66
+ }
67
+
68
+ export async function memberSecurityActivity(
69
+ userId: number,
70
+ limit = MEMBER_ACTIVITY_LIMIT,
71
+ ): Promise<readonly AuthEventRecord[]> {
72
+ const { accountStore, dataSource } = getContainer()
73
+ if (dataSource !== 'postgres') return []
74
+
75
+ try {
76
+ return await accountStore.authEvents.listForUser(userId, limit)
77
+ } catch (err) {
78
+ logger({ module: 'auth-events' }).warn({ err }, 'could not read security activity')
79
+ return []
80
+ }
81
+ }
82
+
83
+ export async function boardSecurityActivity(input: {
84
+ readonly limit: number
85
+ readonly before?: number | undefined
86
+ readonly kind?: AuthEventKind | undefined
87
+ }): Promise<readonly AuthEventRecord[]> {
88
+ const { accountStore, dataSource } = getContainer()
89
+ if (dataSource !== 'postgres') return []
90
+
91
+ return accountStore.authEvents.listRecent(input)
92
+ }
@@ -0,0 +1,14 @@
1
+ export interface UndoState {
2
+ readonly token: string
3
+ readonly expiresAt: string
4
+ }
5
+
6
+ export interface FormState {
7
+ readonly error?: string | undefined
8
+ readonly notice?: string | undefined
9
+ readonly values?: Record<string, string> | undefined
10
+ readonly preview?: string | undefined
11
+ readonly undo?: UndoState | undefined
12
+ }
13
+
14
+ export const EMPTY_STATE: FormState = {}