@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,41 @@
1
+ import 'server-only'
2
+
3
+ import { env, logger } from '@meith/core'
4
+ import { appliedPluginMigrations, getDb, readVersion } from '@meith/db'
5
+ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
6
+
7
+ import { activeDefinitions } from './plugin-host'
8
+
9
+ export const CODE_VERSION = '0.16.0'
10
+
11
+ export async function pendingUpgradeNotice(): Promise<string | null> {
12
+ if (env.DATA_SOURCE !== 'postgres') return null
13
+
14
+ try {
15
+ const db = getDb()
16
+ const plugins = activeDefinitions()
17
+
18
+ const applied: Record<string, readonly string[]> = {}
19
+ for (const plugin of plugins) {
20
+ applied[plugin.key] = await appliedPluginMigrations(db, plugin.key)
21
+ }
22
+
23
+ const state: UpgradeState = {
24
+ recordedVersion: (await readVersion(db, 'core')) ?? CODE_VERSION,
25
+ codeVersion: CODE_VERSION,
26
+ pendingCoreMigrations: [],
27
+ plugins: plugins.map((plugin) => ({
28
+ key: plugin.key,
29
+ version: plugin.version,
30
+ dependsOn: plugin.dependsOn ?? [],
31
+ migrationIds: (plugin.migrations ?? []).map((migration) => migration.id),
32
+ })),
33
+ appliedPluginMigrations: applied,
34
+ }
35
+
36
+ return upgradeNotice(planUpgrade(state), state)
37
+ } catch (error) {
38
+ logger().warn({ err: String(error) }, 'could not determine upgrade state')
39
+ return null
40
+ }
41
+ }
@@ -0,0 +1,572 @@
1
+ 'use server'
2
+
3
+ import { refresh, revalidatePath } from 'next/cache'
4
+
5
+ import { ValidationError } from '@meith/core'
6
+ import { currentRequestId } from '@meith/core/logger'
7
+ import { drivers } from '@meith/drivers'
8
+ import { msg } from '@meith/i18n'
9
+
10
+ import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
11
+ import { claimAdminUndo, issueAdminUndo } from './admin-undo'
12
+ import { recordStaffAuthEvent } from './auth-events'
13
+ import type { FormState } from './auth-form-state'
14
+ import { getContainer } from './container'
15
+ import { assertDemoAccountChangeable, assertDemoIdentityUnchanged } from './demo'
16
+ import { formStateReporter } from './form-state-reporter'
17
+ import { trimmedText } from './form-values'
18
+ import { getTranslator } from './i18n'
19
+ import { emitEvent } from './plugin-view'
20
+ import { clearMemberSecondFactor } from './two-factor'
21
+ import { banService, requireUserAdmin, requireUserBulk, requireUserMerge } from './user-admin'
22
+ import { sendEmailChangeNotice } from './usercp-mail'
23
+
24
+ const MERGE_CHUNK = 500
25
+
26
+ const PRUNE_CHUNK = 500
27
+
28
+ const MASS_MAIL_CHUNK = 500
29
+
30
+ const USER_BULK_LIMIT = 500
31
+
32
+ function userId(form: FormData): number {
33
+ const id = Number(trimmedText(form, 'userId'))
34
+ if (!Number.isSafeInteger(id) || id <= 0) throw new ValidationError(msg('error.app.such-member'))
35
+ return id
36
+ }
37
+
38
+ const toFormState = formStateReporter('user-admin', 'user administration write failed')
39
+
40
+ function refreshMemberScreens(): void {
41
+ revalidatePath('/admin/users')
42
+ revalidatePath('/admin/users/[id]', 'page')
43
+ refresh()
44
+ }
45
+
46
+ export async function saveMemberAccountAction(
47
+ _prev: FormState,
48
+ form: FormData,
49
+ ): Promise<FormState> {
50
+ try {
51
+ await requireAdmin()
52
+ const id = userId(form)
53
+
54
+ const primaryGroupId = Number(trimmedText(form, 'primaryGroupId'))
55
+ if (!Number.isSafeInteger(primaryGroupId) || primaryGroupId <= 0) {
56
+ throw new ValidationError(msg('error.app.such-group'))
57
+ }
58
+
59
+ const displayRaw = trimmedText(form, 'displayGroupId')
60
+ const displayGroupId = displayRaw === '' ? null : Number(displayRaw)
61
+ if (displayGroupId !== null && (!Number.isSafeInteger(displayGroupId) || displayGroupId <= 0)) {
62
+ throw new ValidationError(msg('error.app.such-display-group'))
63
+ }
64
+
65
+ const username = trimmedText(form, 'username')
66
+ const email = trimmedText(form, 'email')
67
+ await assertDemoIdentityUnchanged(id, { username, email })
68
+
69
+ const before = await getContainer().accountStore.accounts.findById(id)
70
+
71
+ await requireUserAdmin().updateAccount(id, {
72
+ username,
73
+ email,
74
+ primaryGroupId,
75
+ displayGroupId,
76
+ })
77
+
78
+ refreshMemberScreens()
79
+ await recordAdminAction({ action: 'user.account_changed', detail: { userId: id } })
80
+
81
+ if (before !== null && before.email !== email) {
82
+ await sendEmailChangeNotice({
83
+ stage: 'adopted',
84
+ previousEmail: before.email,
85
+ email,
86
+ t: await getTranslator(),
87
+ })
88
+ await recordStaffAuthEvent({ userId: id, kind: 'email_changed' })
89
+ }
90
+
91
+ return { notice: 'saved' }
92
+ } catch (err) {
93
+ return toFormState(err)
94
+ }
95
+ }
96
+
97
+ export async function clearSecondFactorAction(
98
+ _prev: FormState,
99
+ form: FormData,
100
+ ): Promise<FormState> {
101
+ try {
102
+ await requireFreshAdmin()
103
+ const id = userId(form)
104
+
105
+ await assertDemoAccountChangeable(id, 'sign-in method')
106
+
107
+ const cleared = await clearMemberSecondFactor(id)
108
+ if (!cleared) return { notice: 'cleared' }
109
+
110
+ await getContainer().accountStore.sessions.revokeAllForUser(id)
111
+
112
+ refreshMemberScreens()
113
+ await recordAdminAction({ action: 'user.second_factor_cleared', detail: { userId: id } })
114
+ await recordStaffAuthEvent({ userId: id, kind: 'second_factor_cleared' })
115
+
116
+ return { notice: 'cleared' }
117
+ } catch (err) {
118
+ return toFormState(err)
119
+ }
120
+ }
121
+
122
+ export async function setMemberStateAction(_prev: FormState, form: FormData): Promise<FormState> {
123
+ try {
124
+ const context = await requireAdmin()
125
+ const id = userId(form)
126
+
127
+ const state = trimmedText(form, 'state')
128
+ if (state !== 'active' && state !== 'awaiting_activation') {
129
+ throw new ValidationError(msg('error.app.choose-active-awaiting-activation'))
130
+ }
131
+
132
+ const repository = requireUserAdmin()
133
+ const before =
134
+ getContainer().dataSource === 'postgres' ? await repository.readDetail(id) : undefined
135
+ if (before === null) throw new ValidationError(msg('error.app.such-member'))
136
+ await repository.setState(id, state)
137
+
138
+ refreshMemberScreens()
139
+ await recordAdminAction({ action: 'user.state_changed', detail: { userId: id, state } })
140
+
141
+ return {
142
+ notice: 'saved',
143
+ undo:
144
+ before === undefined
145
+ ? undefined
146
+ : await issueAdminUndo({
147
+ actorUserId: context.session.userId,
148
+ operation: 'user.state',
149
+ snapshot: { userId: id, state: before.state },
150
+ }),
151
+ }
152
+ } catch (err) {
153
+ return toFormState(err)
154
+ }
155
+ }
156
+
157
+ export async function banMemberAction(_prev: FormState, form: FormData): Promise<FormState> {
158
+ try {
159
+ const context = await requireFreshAdmin()
160
+ const id = userId(form)
161
+
162
+ const days = trimmedText(form, 'days')
163
+ let expiresAt: Date | undefined
164
+ if (days !== '') {
165
+ const parsed = Number(days)
166
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
167
+ throw new ValidationError(msg('error.app.ban-lasts-whole-number-days'))
168
+ }
169
+ expiresAt = new Date(Date.now() + parsed * 86_400_000)
170
+ }
171
+
172
+ await banService().ban({
173
+ userId: id,
174
+ bannedByUserId: context.session.userId,
175
+ ...(trimmedText(form, 'reason') === '' ? {} : { reason: trimmedText(form, 'reason') }),
176
+ ...(trimmedText(form, 'publicReason') === ''
177
+ ? {}
178
+ : { publicReason: trimmedText(form, 'publicReason') }),
179
+ ...(expiresAt === undefined ? {} : { expiresAt }),
180
+ })
181
+
182
+ await emitEvent(
183
+ 'user.banned',
184
+ { userId: id, expiresAt: expiresAt?.toISOString() ?? null },
185
+ { moderatorId: context.session.userId, reason: trimmedText(form, 'reason') || null },
186
+ )
187
+
188
+ refreshMemberScreens()
189
+ await recordAdminAction({
190
+ action: 'user.banned',
191
+ detail: { userId: id, days: days === '' ? null : Number(days) },
192
+ })
193
+
194
+ return {
195
+ notice: 'banned',
196
+ undo: await issueAdminUndo({
197
+ actorUserId: context.session.userId,
198
+ operation: 'user.ban',
199
+ snapshot: { userId: id },
200
+ }),
201
+ }
202
+ } catch (err) {
203
+ return toFormState(err)
204
+ }
205
+ }
206
+
207
+ export async function saveSecondaryGroupsAction(
208
+ _prev: FormState,
209
+ form: FormData,
210
+ ): Promise<FormState> {
211
+ try {
212
+ const context = await requireAdmin()
213
+ const id = userId(form)
214
+
215
+ const groupIds: number[] = []
216
+ for (const value of form.getAll('groupIds')) {
217
+ const parsed = Number(typeof value === 'string' ? value : '')
218
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
219
+ throw new ValidationError(msg('error.app.such-group'))
220
+ }
221
+ groupIds.push(parsed)
222
+ }
223
+
224
+ const repository = requireUserAdmin()
225
+ const before =
226
+ getContainer().dataSource === 'postgres'
227
+ ? await repository.readSecondaryGroups(id)
228
+ : undefined
229
+ await repository.setSecondaryGroups(id, groupIds, context.session.userId)
230
+
231
+ await emitEvent(
232
+ 'user.groups.changed',
233
+ { userId: id, primaryGroupId: 0, secondaryGroupIds: groupIds },
234
+ { requestId: currentRequestId() ?? null },
235
+ )
236
+
237
+ refreshMemberScreens()
238
+ await recordAdminAction({
239
+ action: 'user.groups_changed',
240
+ detail: { userId: id, groups: groupIds.length },
241
+ })
242
+
243
+ return {
244
+ notice: 'saved',
245
+ undo:
246
+ before === undefined
247
+ ? undefined
248
+ : await issueAdminUndo({
249
+ actorUserId: context.session.userId,
250
+ operation: 'user.groups',
251
+ snapshot: { userId: id, memberships: before },
252
+ }),
253
+ }
254
+ } catch (err) {
255
+ return toFormState(err)
256
+ }
257
+ }
258
+
259
+ export async function mergeStepAction(_prev: FormState, form: FormData): Promise<FormState> {
260
+ try {
261
+ await requireFreshAdmin()
262
+
263
+ const fromUserId = userId(form)
264
+ const toUserId = Number(trimmedText(form, 'toUserId'))
265
+ if (!Number.isSafeInteger(toUserId) || toUserId <= 0) {
266
+ throw new ValidationError(msg('error.app.choose-account-merge-into'))
267
+ }
268
+ if (fromUserId === toUserId) {
269
+ throw new ValidationError(msg('error.app.choose-two-different-accounts'))
270
+ }
271
+
272
+ const merge = requireUserMerge()
273
+ const chunk = await merge.mergePostsChunk(fromUserId, toUserId, MERGE_CHUNK)
274
+
275
+ if (chunk.remaining > 0) {
276
+ await recordAdminAction({
277
+ action: 'user.merge_progress',
278
+ detail: { fromUserId, toUserId, moved: chunk.moved, remaining: chunk.remaining },
279
+ })
280
+ return {
281
+ notice: 'more',
282
+ values: { toUserId: String(toUserId), remaining: String(chunk.remaining) },
283
+ }
284
+ }
285
+
286
+ await merge.finish(fromUserId, toUserId)
287
+
288
+ await emitEvent(
289
+ 'user.merged',
290
+ { keptUserId: toUserId, mergedUserId: fromUserId },
291
+ { requestId: currentRequestId() ?? null },
292
+ )
293
+
294
+ refreshMemberScreens()
295
+ await recordAdminAction({
296
+ action: 'user.merged',
297
+ detail: { fromUserId, toUserId },
298
+ })
299
+
300
+ return { notice: 'merged', values: { toUserId: String(toUserId) } }
301
+ } catch (err) {
302
+ return toFormState(err)
303
+ }
304
+ }
305
+
306
+ export async function pruneMembersAction(_prev: FormState, form: FormData): Promise<FormState> {
307
+ try {
308
+ await requireFreshAdmin()
309
+
310
+ const before = new Date(trimmedText(form, 'before'))
311
+ if (Number.isNaN(before.getTime())) {
312
+ throw new ValidationError(msg('error.app.choose-date-members-must-registered'))
313
+ }
314
+
315
+ const inactiveRaw = trimmedText(form, 'inactive')
316
+ const inactive = inactiveRaw === '' ? undefined : new Date(inactiveRaw)
317
+ if (inactive !== undefined && Number.isNaN(inactive.getTime())) {
318
+ throw new ValidationError(msg('error.app.inactivity-date-date'))
319
+ }
320
+
321
+ const chunk = await requireUserBulk().pruneChunk(
322
+ {
323
+ registeredBefore: before,
324
+ ...(inactive === undefined ? {} : { inactiveSince: inactive }),
325
+ ...(form.get('awaiting') === null ? {} : { onlyAwaitingActivation: true }),
326
+ },
327
+ PRUNE_CHUNK,
328
+ )
329
+
330
+ for (const prunedUserId of chunk.prunedUserIds) {
331
+ await emitEvent(
332
+ 'user.deleted',
333
+ { userId: prunedUserId, reason: 'pruned' },
334
+ { requestId: currentRequestId() ?? null },
335
+ )
336
+ }
337
+
338
+ refreshMemberScreens()
339
+ await recordAdminAction({
340
+ action: 'user.pruned',
341
+ detail: { pruned: chunk.pruned, remaining: chunk.remaining },
342
+ })
343
+
344
+ return {
345
+ notice: chunk.remaining > 0 ? 'more' : 'finished',
346
+ values: { pruned: String(chunk.pruned), remaining: String(chunk.remaining) },
347
+ }
348
+ } catch (err) {
349
+ return toFormState(err)
350
+ }
351
+ }
352
+
353
+ export async function pruneSelectedMembersAction(
354
+ _prev: FormState,
355
+ form: FormData,
356
+ ): Promise<FormState> {
357
+ try {
358
+ const context = await requireFreshAdmin()
359
+ const selection = await claimAdminUndo({
360
+ token: trimmedText(form, 'selection'),
361
+ actorUserId: context.session.userId,
362
+ })
363
+ if (selection.operation !== 'user.prune_selection') {
364
+ throw new ValidationError(msg('admin.undoInvalid'))
365
+ }
366
+ const values = selection.snapshot.userIds
367
+ if (!Array.isArray(values)) throw new ValidationError(msg('admin.undoInvalid'))
368
+ const ids = values.map((value) => Number(value))
369
+ if (
370
+ ids.length === 0 ||
371
+ ids.length > USER_BULK_LIMIT ||
372
+ ids.some((id) => !Number.isSafeInteger(id) || id <= 0)
373
+ ) {
374
+ throw new ValidationError(msg('admin.undoInvalid'))
375
+ }
376
+
377
+ const prunedUserIds = await requireUserBulk().pruneSelected(ids)
378
+ for (const prunedUserId of prunedUserIds) {
379
+ await emitEvent(
380
+ 'user.deleted',
381
+ { userId: prunedUserId, reason: 'pruned' },
382
+ { requestId: currentRequestId() ?? null },
383
+ )
384
+ }
385
+ refreshMemberScreens()
386
+ await recordAdminAction({
387
+ action: 'user.pruned_selected',
388
+ detail: { selected: ids.length, pruned: prunedUserIds.length },
389
+ })
390
+ return { notice: 'finished', values: { pruned: String(prunedUserIds.length) } }
391
+ } catch (err) {
392
+ return toFormState(err)
393
+ }
394
+ }
395
+
396
+ export async function startMassMailAction(_prev: FormState, form: FormData): Promise<FormState> {
397
+ try {
398
+ const context = await requireFreshAdmin()
399
+
400
+ const groupRaw = trimmedText(form, 'targetGroupId')
401
+ const targetGroupId = groupRaw === '' ? null : Number(groupRaw)
402
+ if (targetGroupId !== null && (!Number.isSafeInteger(targetGroupId) || targetGroupId <= 0)) {
403
+ throw new ValidationError(msg('error.app.such-group'))
404
+ }
405
+
406
+ const bulk = requireUserBulk()
407
+ const massMailId = await bulk.createMassMail({
408
+ subject: trimmedText(form, 'subject'),
409
+ body: trimmedText(form, 'body'),
410
+ targetGroupId,
411
+ createdByUserId: context.session.userId,
412
+ })
413
+
414
+ await recordAdminAction({
415
+ action: 'user.mass_mail_started',
416
+ detail: { massMailId, targetGroupId },
417
+ })
418
+
419
+ return (await queueMassMailBatch(bulk, massMailId)).state
420
+ } catch (err) {
421
+ return toFormState(err)
422
+ }
423
+ }
424
+
425
+ export async function continueMassMailAction(_prev: FormState, form: FormData): Promise<FormState> {
426
+ try {
427
+ await requireFreshAdmin()
428
+
429
+ const massMailId = Number(trimmedText(form, 'massMailId'))
430
+ if (!Number.isSafeInteger(massMailId) || massMailId <= 0) {
431
+ throw new ValidationError(msg('error.app.such-message'))
432
+ }
433
+
434
+ const batch = await queueMassMailBatch(requireUserBulk(), massMailId)
435
+ await recordAdminAction({
436
+ action: 'user.mass_mail_continued',
437
+ detail: { massMailId, sent: batch.sent, queued: batch.queued },
438
+ })
439
+
440
+ return batch.state
441
+ } catch (err) {
442
+ return toFormState(err)
443
+ }
444
+ }
445
+
446
+ async function queueMassMailBatch(
447
+ bulk: ReturnType<typeof requireUserBulk>,
448
+ massMailId: number,
449
+ ): Promise<{ state: FormState; sent: number; queued: number }> {
450
+ const chunk = await bulk.claimMassMailChunk(massMailId, MASS_MAIL_CHUNK)
451
+
452
+ for (const recipient of chunk.recipients) {
453
+ await drivers().queue.enqueue(
454
+ 'admin.mass_mail',
455
+ { massMailId, userId: recipient.userId, email: recipient.email },
456
+ { dedupeKey: `mass-mail:${massMailId}:${recipient.userId}` },
457
+ )
458
+ }
459
+
460
+ const total = (await bulk.readMassMail(massMailId))?.queuedCount ?? 0
461
+
462
+ return {
463
+ state: {
464
+ notice: chunk.finished ? 'sent' : 'more',
465
+ values: { massMailId: String(massMailId), queued: String(total) },
466
+ },
467
+ sent: chunk.recipients.length,
468
+ queued: total,
469
+ }
470
+ }
471
+
472
+ export async function bulkUserAction(_prev: FormState, form: FormData): Promise<FormState> {
473
+ try {
474
+ const context = await requireFreshAdmin()
475
+ const ids = [...new Set(form.getAll('userIds').map((value) => Number(value)))]
476
+ if (
477
+ ids.length === 0 ||
478
+ ids.length > USER_BULK_LIMIT ||
479
+ ids.some((id) => !Number.isSafeInteger(id) || id <= 0)
480
+ ) {
481
+ throw new ValidationError(msg('adminUsers.bulkInvalid'))
482
+ }
483
+ if (ids.includes(context.session.userId)) throw new ValidationError(msg('adminUsers.bulkSelf'))
484
+
485
+ const repository = requireUserAdmin()
486
+ const members = await Promise.all(ids.map((id) => repository.readDetail(id)))
487
+ if (members.some((member) => member === null || member.deletedAt !== null)) {
488
+ throw new ValidationError(msg('adminUsers.bulkStale'))
489
+ }
490
+
491
+ const kind = trimmedText(form, 'bulkAction')
492
+ if (kind === 'ban') {
493
+ const reason = trimmedText(form, 'reason')
494
+ const banned: number[] = []
495
+ for (const id of ids) {
496
+ await banService().ban({
497
+ userId: id,
498
+ bannedByUserId: context.session.userId,
499
+ ...(reason === '' ? {} : { reason }),
500
+ })
501
+ banned.push(id)
502
+ }
503
+ refreshMemberScreens()
504
+ await recordAdminAction({ action: 'user.bulk_banned', detail: { count: banned.length } })
505
+ return {
506
+ notice: 'bulk-banned',
507
+ undo: await issueAdminUndo({
508
+ actorUserId: context.session.userId,
509
+ operation: 'user.bulk_ban',
510
+ snapshot: { userIds: banned },
511
+ }),
512
+ }
513
+ }
514
+
515
+ if (kind === 'group') {
516
+ const groupId = Number(trimmedText(form, 'groupId'))
517
+ if (!Number.isSafeInteger(groupId) || groupId <= 0) {
518
+ throw new ValidationError(msg('error.app.such-group'))
519
+ }
520
+ for (const id of ids) {
521
+ const before = await repository.readSecondaryGroups(id)
522
+ await repository.setSecondaryGroups(
523
+ id,
524
+ [...new Set([...before, groupId])],
525
+ context.session.userId,
526
+ )
527
+ }
528
+ refreshMemberScreens()
529
+ await recordAdminAction({ action: 'user.bulk_groups_changed', detail: { count: ids.length } })
530
+ return { notice: 'bulk-grouped' }
531
+ }
532
+
533
+ if (kind === 'prune') {
534
+ const selection = await issueAdminUndo({
535
+ actorUserId: context.session.userId,
536
+ operation: 'user.prune_selection',
537
+ snapshot: { userIds: ids },
538
+ })
539
+ if (selection === undefined) throw new ValidationError(msg('admin.undoUnavailable'))
540
+ return {
541
+ notice: 'prune-selection',
542
+ values: { selection: selection.token },
543
+ }
544
+ }
545
+
546
+ throw new ValidationError(msg('adminUsers.bulkChoose'))
547
+ } catch (err) {
548
+ return toFormState(err)
549
+ }
550
+ }
551
+
552
+ export async function liftBanAction(_prev: FormState, form: FormData): Promise<FormState> {
553
+ try {
554
+ const context = await requireAdmin()
555
+ const id = userId(form)
556
+
557
+ await banService().lift(id)
558
+
559
+ await emitEvent(
560
+ 'user.unbanned',
561
+ { userId: id, expired: false },
562
+ { moderatorId: context.session.userId, reason: null },
563
+ )
564
+
565
+ refreshMemberScreens()
566
+ await recordAdminAction({ action: 'user.ban_lifted', detail: { userId: id } })
567
+
568
+ return { notice: 'lifted' }
569
+ } catch (err) {
570
+ return toFormState(err)
571
+ }
572
+ }