@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,165 @@
1
+ import { msg } from '@meith/i18n'
2
+ import 'server-only'
3
+
4
+ import type { BanRecord } from '@meith/accounts'
5
+ import { BanService } from '@meith/accounts'
6
+ import { ForbiddenError, optional } from '@meith/core'
7
+ import {
8
+ type AccountState,
9
+ getDb,
10
+ PostgresBanRepository,
11
+ PostgresUserAdminRepository,
12
+ PostgresUserBulkRepository,
13
+ PostgresUserMergeRepository,
14
+ type PruneCriteria,
15
+ type UserDetail,
16
+ type UserSearchFilter,
17
+ type UserSearchRow,
18
+ } from '@meith/db'
19
+ import { SEED_GROUP } from '@meith/runtime'
20
+
21
+ import { offsetOf, readPage } from '@/view/pager'
22
+
23
+ import { getContainer } from './container'
24
+
25
+ export const USER_PAGE = 50
26
+
27
+ export function userAdminRepository(): PostgresUserAdminRepository | null {
28
+ return getContainer().dataSource === 'postgres' ? new PostgresUserAdminRepository(getDb()) : null
29
+ }
30
+
31
+ export function requireUserAdmin(): PostgresUserAdminRepository {
32
+ const repository = userAdminRepository()
33
+ if (repository === null) {
34
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-2'))
35
+ }
36
+ return repository
37
+ }
38
+
39
+ export function banService(): BanService {
40
+ if (getContainer().dataSource !== 'postgres') {
41
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-3'))
42
+ }
43
+ return new BanService({
44
+ bans: new PostgresBanRepository(getDb()),
45
+ bannedGroupId: SEED_GROUP.banned,
46
+ })
47
+ }
48
+
49
+ export function requireUserMerge(): PostgresUserMergeRepository {
50
+ if (getContainer().dataSource !== 'postgres') {
51
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-4'))
52
+ }
53
+ return new PostgresUserMergeRepository(getDb())
54
+ }
55
+
56
+ export function banRepository(): PostgresBanRepository | null {
57
+ return getContainer().dataSource === 'postgres' ? new PostgresBanRepository(getDb()) : null
58
+ }
59
+
60
+ export function parseUserFilter(
61
+ params: Record<string, string | string[] | undefined>,
62
+ ): UserSearchFilter {
63
+ const one = (key: string): string | undefined => {
64
+ const value = params[key]
65
+ const text = Array.isArray(value) ? value[0] : value
66
+ return text === undefined || text.trim() === '' ? undefined : text.trim()
67
+ }
68
+
69
+ const number = (key: string): number | undefined => {
70
+ const raw = one(key)
71
+ if (raw === undefined) return undefined
72
+ const parsed = Number(raw)
73
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined
74
+ }
75
+
76
+ const date = (key: string): Date | undefined => {
77
+ const raw = one(key)
78
+ if (raw === undefined) return undefined
79
+ const parsed = new Date(raw)
80
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed
81
+ }
82
+
83
+ const state = one('state')
84
+ const stateFilter: AccountState | undefined =
85
+ state === 'active' || state === 'awaiting_activation' || state === 'banned' ? state : undefined
86
+
87
+ return {
88
+ ...optional(one('username'), (username) => ({ username })),
89
+ ...optional(one('email'), (email) => ({ email })),
90
+ ...optional(one('ip'), (ipPrefix) => ({ ipPrefix })),
91
+ ...optional(number('group'), (primaryGroupId) => ({ primaryGroupId })),
92
+ ...optional(stateFilter, (state) => ({ state })),
93
+ ...optional(date('after'), (registeredAfter) => ({ registeredAfter })),
94
+ ...optional(date('before'), (registeredBefore) => ({ registeredBefore })),
95
+ ...optional(number('minPosts'), (minPostCount) => ({ minPostCount })),
96
+ ...optional(number('maxPosts'), (maxPostCount) => ({ maxPostCount })),
97
+ ...optional(one('deleted'), () => ({ includeDeleted: true })),
98
+ offset: offsetOf(readPage(params), USER_PAGE),
99
+ limit: USER_PAGE,
100
+ }
101
+ }
102
+
103
+ export interface MemberView {
104
+ readonly member: UserDetail
105
+ readonly secondaryGroupIds: readonly number[]
106
+ readonly groups: readonly { readonly id: number; readonly title: string }[]
107
+ readonly activeBan: BanRecord | null
108
+ readonly sharedNetwork: readonly UserSearchRow[]
109
+ }
110
+
111
+ export async function buildMemberView(userId: number): Promise<MemberView | null> {
112
+ const repository = userAdminRepository()
113
+ const bans = banRepository()
114
+ if (repository === null || bans === null) return null
115
+
116
+ const member = await repository.readDetail(userId)
117
+ if (member === null) return null
118
+
119
+ const prefix = member.lastIpPrefix ?? member.registrationIpPrefix ?? ''
120
+
121
+ return {
122
+ member,
123
+ secondaryGroupIds: await repository.readSecondaryGroups(userId),
124
+ groups: await repository.listGroups(),
125
+ activeBan: await bans.findActive(userId),
126
+ sharedNetwork: await repository.sharingIpPrefix(prefix, userId),
127
+ }
128
+ }
129
+
130
+ export function requireUserBulk(): PostgresUserBulkRepository {
131
+ if (getContainer().dataSource !== 'postgres') {
132
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-5'))
133
+ }
134
+ return new PostgresUserBulkRepository(getDb())
135
+ }
136
+
137
+ export function userBulkRepository(): PostgresUserBulkRepository | null {
138
+ return getContainer().dataSource === 'postgres' ? new PostgresUserBulkRepository(getDb()) : null
139
+ }
140
+
141
+ export function parsePruneCriteria(
142
+ params: Record<string, string | string[] | undefined>,
143
+ ): PruneCriteria | null {
144
+ const one = (key: string): string | undefined => {
145
+ const value = params[key]
146
+ const text = Array.isArray(value) ? value[0] : value
147
+ return text === undefined || text.trim() === '' ? undefined : text.trim()
148
+ }
149
+
150
+ const date = (key: string): Date | undefined => {
151
+ const raw = one(key)
152
+ if (raw === undefined) return undefined
153
+ const parsed = new Date(raw)
154
+ return Number.isNaN(parsed.getTime()) ? undefined : parsed
155
+ }
156
+
157
+ const registeredBefore = date('before')
158
+ if (registeredBefore === undefined) return null
159
+
160
+ return {
161
+ registeredBefore,
162
+ ...optional(date('inactive'), (inactiveSince) => ({ inactiveSince })),
163
+ ...optional(one('awaiting'), () => ({ onlyAwaitingActivation: true })),
164
+ }
165
+ }
@@ -0,0 +1,263 @@
1
+ 'use server'
2
+
3
+ import { redirect } from 'next/navigation'
4
+
5
+ import { MemberSettingsService } from '@meith/accounts'
6
+ import { ForbiddenError, ValidationError } from '@meith/core'
7
+ import { currentRequestId } from '@meith/core/logger'
8
+ import { drivers } from '@meith/drivers'
9
+ import { msg } from '@meith/i18n'
10
+ import { prepareSignature } from '@meith/signatures'
11
+
12
+ import { adminService } from './admin'
13
+ import { limitMessage, spendLimit } from './antispam'
14
+ import { boardAuthConfig } from './auth-config'
15
+ import { recordAuthEvent } from './auth-events'
16
+ import type { FormState } from './auth-form-state'
17
+ import { AVATAR_FIELD, canUploadAvatar, requireAvatarService } from './avatars'
18
+ import { configuredSessions, getContainer } from './container'
19
+ import { getActor } from './context'
20
+ import { assertDemoAccountChangeable } from './demo'
21
+ import { formStateReporter } from './form-state-reporter'
22
+ import { text } from './form-values'
23
+ import { getTranslator, tr } from './i18n'
24
+ import { boardRendering } from './markdown-pipeline'
25
+ import { emitEvent } from './plugin-view'
26
+ import { profileFieldService, submittedFields, viewerFieldContext } from './profile-fields'
27
+ import { setSessionCookie } from './session-cookies'
28
+ import { signatureStore, viewerSignatureLimits } from './signatures'
29
+ import { sendEmailChangeConfirmation, sendEmailChangeNotice } from './usercp-mail'
30
+
31
+ const toFormState = formStateReporter('usercp-actions', 'unexpected error in the UserCP')
32
+
33
+ async function requireOwnSettings(): Promise<{
34
+ service: MemberSettingsService
35
+ userId: number
36
+ }> {
37
+ const actor = await getActor()
38
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
39
+
40
+ const { memberSettings, accountStore } = getContainer()
41
+ if (memberSettings === null || accountStore === null) {
42
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-9'))
43
+ }
44
+
45
+ return {
46
+ service: new MemberSettingsService({
47
+ settings: memberSettings,
48
+ accounts: accountStore.accounts,
49
+ sessions: accountStore.sessions,
50
+ remember: accountStore.remember,
51
+ tokens: accountStore.tokens,
52
+ }),
53
+ userId: actor.userId,
54
+ }
55
+ }
56
+
57
+ export async function saveProfileAction(_prev: FormState, form: FormData): Promise<FormState> {
58
+ try {
59
+ const { service, userId } = await requireOwnSettings()
60
+ await service.saveProfile({
61
+ userId,
62
+ location: text(form, 'location'),
63
+ website: text(form, 'website'),
64
+ bio: text(form, 'bio'),
65
+ })
66
+
67
+ const fields = profileFieldService()
68
+ const context = await viewerFieldContext()
69
+ if (fields !== null && context !== null) {
70
+ await fields.save({ userId, submitted: submittedFields(form), context })
71
+ }
72
+
73
+ await emitEvent(
74
+ 'user.profile.updated',
75
+ { userId, fields: ['location', 'website', 'bio'] },
76
+ { requestId: currentRequestId() ?? null },
77
+ )
78
+ } catch (err) {
79
+ return toFormState(err)
80
+ }
81
+
82
+ redirect('/usercp/profile?saved=1')
83
+ }
84
+
85
+ export async function saveDisplayGroupAction(_prev: FormState, form: FormData): Promise<FormState> {
86
+ try {
87
+ const { service, userId } = await requireOwnSettings()
88
+ await service.saveDisplayGroup({ userId, displayGroupId: text(form, 'displayGroupId') })
89
+ } catch (err) {
90
+ return toFormState(err)
91
+ }
92
+
93
+ redirect('/usercp/profile?saved=1')
94
+ }
95
+
96
+ export async function saveOptionsAction(_prev: FormState, form: FormData): Promise<FormState> {
97
+ try {
98
+ const { service, userId } = await requireOwnSettings()
99
+ await service.saveOptions({
100
+ userId,
101
+ timezone: text(form, 'timezone'),
102
+ locale: text(form, 'locale'),
103
+ postsPerPage: text(form, 'postsPerPage'),
104
+ threadsPerPage: text(form, 'threadsPerPage'),
105
+ invisible: form.get('invisible') !== null,
106
+ })
107
+
108
+ await emitEvent(
109
+ 'user.profile.updated',
110
+ { userId, fields: ['timezone', 'locale', 'postsPerPage', 'threadsPerPage', 'invisible'] },
111
+ { requestId: currentRequestId() ?? null },
112
+ )
113
+ } catch (err) {
114
+ return toFormState(err)
115
+ }
116
+
117
+ redirect('/usercp/options?saved=1')
118
+ }
119
+
120
+ export async function changePasswordAction(_prev: FormState, form: FormData): Promise<FormState> {
121
+ try {
122
+ const { service, userId } = await requireOwnSettings()
123
+
124
+ await assertDemoAccountChangeable(userId, 'password')
125
+
126
+ const next = text(form, 'newPassword')
127
+ if (next !== text(form, 'confirmPassword')) {
128
+ return { error: await tr('notice.app.two-new-passwords-match') }
129
+ }
130
+
131
+ await service.changePassword({
132
+ userId,
133
+ currentPassword: text(form, 'currentPassword'),
134
+ newPassword: next,
135
+ minLength: (await boardAuthConfig()).minPasswordLength,
136
+ })
137
+
138
+ const session = await (await configuredSessions()).start(userId)
139
+ await setSessionCookie(session.token, session.expiresAt)
140
+
141
+ const admin = adminService()
142
+ if (admin !== null) await admin.endAllFor(userId)
143
+
144
+ await recordAuthEvent({ userId, kind: 'password_changed' })
145
+ } catch (err) {
146
+ return toFormState(err)
147
+ }
148
+
149
+ redirect('/usercp/security?changed=password')
150
+ }
151
+
152
+ export async function requestEmailChangeAction(
153
+ _prev: FormState,
154
+ form: FormData,
155
+ ): Promise<FormState> {
156
+ let pending: { token: string; email: string; previousEmail: string; userId: number }
157
+ try {
158
+ const { service, userId } = await requireOwnSettings()
159
+
160
+ await assertDemoAccountChangeable(userId, 'email')
161
+
162
+ pending = {
163
+ userId,
164
+ ...(await service.requestEmailChange({
165
+ userId,
166
+ currentPassword: text(form, 'currentPassword'),
167
+ newEmail: text(form, 'newEmail'),
168
+ })),
169
+ }
170
+ } catch (err) {
171
+ return toFormState(err)
172
+ }
173
+
174
+ const t = await getTranslator()
175
+ await sendEmailChangeConfirmation({ ...pending, t }).catch(() => undefined)
176
+ await sendEmailChangeNotice({
177
+ stage: 'requested',
178
+ previousEmail: pending.previousEmail,
179
+ email: pending.email,
180
+ t,
181
+ })
182
+ await recordAuthEvent({ userId: pending.userId, kind: 'email_change_requested' })
183
+
184
+ redirect('/usercp/security?sent=1')
185
+ }
186
+
187
+ export async function saveSignatureAction(_prev: FormState, form: FormData): Promise<FormState> {
188
+ const values = { signature: text(form, 'signature') }
189
+
190
+ try {
191
+ const actor = await getActor()
192
+ if (actor.userId === null) throw new ForbiddenError(msg('error.app.must-logged'))
193
+
194
+ const store = signatureStore()
195
+ if (store === null) {
196
+ throw new ForbiddenError(msg('error.app.board-running-in-memory-sample-data-10'))
197
+ }
198
+
199
+ const limits = await viewerSignatureLimits()
200
+ const { source, rendered } = await prepareSignature(values.signature, limits, {
201
+ authorId: actor.userId,
202
+ rendering: boardRendering,
203
+ })
204
+
205
+ const wrote = await store.save({
206
+ userId: actor.userId,
207
+ signature: source,
208
+ signatureHtml: rendered.html,
209
+ renderVersion: rendered.version,
210
+ })
211
+
212
+ if (!wrote) {
213
+ throw new ForbiddenError(msg('error.app.signature-locked-by-moderator-so'))
214
+ }
215
+ } catch (err) {
216
+ return toFormState(err)
217
+ }
218
+
219
+ redirect('/usercp/signature?saved=1')
220
+ }
221
+
222
+ export async function saveAvatarAction(_prev: FormState, form: FormData): Promise<FormState> {
223
+ try {
224
+ const actor = await getActor()
225
+ const service = requireAvatarService(actor)
226
+
227
+ const file = form.get(AVATAR_FIELD)
228
+ if (!(file instanceof File) || file.size === 0) {
229
+ throw new ValidationError(msg('error.app.choose-image-first'))
230
+ }
231
+
232
+ const limited = await spendLimit({ scope: 'upload', actor })
233
+ if (limited !== null && !limited.allowed) throw new ValidationError(limitMessage(limited))
234
+
235
+ await service.upload({
236
+ userId: actor.userId as number,
237
+ file: { filename: file.name, bytes: new Uint8Array(await file.arrayBuffer()) },
238
+ mayUpload: canUploadAvatar(actor),
239
+ })
240
+
241
+ await drivers().queue.enqueue(
242
+ 'avatars.process',
243
+ { userId: actor.userId },
244
+ { dedupeKey: `avatar:${actor.userId}:${Date.now()}` },
245
+ )
246
+ } catch (err) {
247
+ return toFormState(err)
248
+ }
249
+
250
+ redirect('/usercp/avatar?saved=processing')
251
+ }
252
+
253
+ export async function removeAvatarAction(_prev: FormState): Promise<FormState> {
254
+ try {
255
+ const actor = await getActor()
256
+ const service = requireAvatarService(actor)
257
+ await service.remove(actor.userId as number)
258
+ } catch (err) {
259
+ return toFormState(err)
260
+ }
261
+
262
+ redirect('/usercp/avatar?saved=removed')
263
+ }
@@ -0,0 +1,120 @@
1
+ import 'server-only'
2
+
3
+ import { logger } from '@meith/core'
4
+ import type { Translator } from '@meith/i18n'
5
+ import { absoluteUrl, renderMail } from '@meith/mail'
6
+
7
+ import { brandedFor } from './mail-brand'
8
+ import { sendBoardMail } from './mail-send'
9
+
10
+ const CONFIRM_PATH = '/usercp/email/confirm'
11
+ const RESET_PATH = '/reset'
12
+
13
+ export type EmailChangeStage = 'requested' | 'adopted'
14
+
15
+ export async function sendEmailChangeConfirmation(input: {
16
+ readonly token: string
17
+ readonly email: string
18
+ readonly t: Translator
19
+ }): Promise<void> {
20
+ const { t } = input
21
+ const { brand, boardName } = await brandedFor(t, 'usercpMail.boardFallback')
22
+
23
+ const link = absoluteUrl(
24
+ brand.boardUrl,
25
+ `${CONFIRM_PATH}?token=${encodeURIComponent(input.token)}`,
26
+ )
27
+
28
+ const mail = renderMail({
29
+ brand,
30
+ t,
31
+ body: {
32
+ title: t.t('usercpMail.subject'),
33
+ greeting: t.t('usercpMail.greeting'),
34
+ paragraphs: [
35
+ t.t('usercpMail.intro', { board: boardName }),
36
+ ...(link === null ? [t.t('usercpMail.noLink')] : []),
37
+ t.t('usercpMail.ignore'),
38
+ ],
39
+ ...(link === null ? {} : { action: { label: t.t('usercpMail.action'), href: link } }),
40
+ note: t.t('usercpMail.ttl'),
41
+ footer: [{ text: t.t('mail.footer.sentBy', { board: boardName }) }],
42
+ },
43
+ })
44
+
45
+ const fromName = brand.fromName ?? ''
46
+
47
+ try {
48
+ await sendBoardMail('usercp.email-change', {
49
+ to: input.email,
50
+ subject: mail.subject,
51
+ text: mail.text,
52
+ html: mail.html,
53
+ ...(fromName === '' ? {} : { fromName }),
54
+ })
55
+ } catch (err) {
56
+ logger({ module: 'usercp' }).error({ err }, 'could not send an e-mail change confirmation')
57
+ throw err
58
+ }
59
+ }
60
+
61
+ export async function sendEmailChangeNotice(input: {
62
+ readonly stage: EmailChangeStage
63
+ readonly previousEmail: string
64
+ readonly email: string
65
+ readonly t: Translator
66
+ }): Promise<void> {
67
+ const { t } = input
68
+ const { brand, boardName } = await brandedFor(t, 'usercpMail.boardFallback')
69
+
70
+ const requested = input.stage === 'requested'
71
+ const link = absoluteUrl(brand.boardUrl, requested ? RESET_PATH : '/')
72
+
73
+ const secure = ((): string => {
74
+ if (!requested) return t.t('usercpMail.notice.adoptedSecure')
75
+ return t.t(
76
+ link === null
77
+ ? 'usercpMail.notice.requestedSecureNoLink'
78
+ : 'usercpMail.notice.requestedSecure',
79
+ )
80
+ })()
81
+
82
+ const action = requested
83
+ ? t.t('usercpMail.notice.requestedAction')
84
+ : t.t('usercpMail.notice.adoptedAction', { board: boardName })
85
+
86
+ const mail = renderMail({
87
+ brand,
88
+ t,
89
+ body: {
90
+ title: t.t(
91
+ requested ? 'usercpMail.notice.requestedSubject' : 'usercpMail.notice.adoptedSubject',
92
+ ),
93
+ greeting: t.t('usercpMail.greeting'),
94
+ paragraphs: [
95
+ t.t(requested ? 'usercpMail.notice.requested' : 'usercpMail.notice.adopted', {
96
+ board: boardName,
97
+ email: input.email,
98
+ }),
99
+ secure,
100
+ ],
101
+ ...(link === null ? {} : { action: { label: action, href: link } }),
102
+ note: t.t('usercpMail.notice.nothingToDo'),
103
+ footer: [{ text: t.t('mail.footer.sentBy', { board: boardName }) }],
104
+ },
105
+ })
106
+
107
+ const fromName = brand.fromName ?? ''
108
+
109
+ try {
110
+ await sendBoardMail('usercp.email-change', {
111
+ to: input.previousEmail,
112
+ subject: mail.subject,
113
+ text: mail.text,
114
+ html: mail.html,
115
+ ...(fromName === '' ? {} : { fromName }),
116
+ })
117
+ } catch (err) {
118
+ logger({ module: 'usercp' }).error({ err }, 'could not send an e-mail change notice')
119
+ }
120
+ }
@@ -0,0 +1,64 @@
1
+ import 'server-only'
2
+
3
+ import { cookies } from 'next/headers'
4
+ import { cache } from 'react'
5
+
6
+ import { resolveTimezone, TIMEZONE_COOKIE } from '@/view/timezone'
7
+
8
+ import { getContainer } from './container'
9
+ import { getActor } from './context'
10
+ import { getSettings } from './settings'
11
+
12
+ export interface ViewerPreferences {
13
+ readonly timezone: string
14
+ readonly timezoneIsAutomatic: boolean
15
+ readonly locale: string | null
16
+ readonly postsPerPage: number
17
+ readonly threadsPerPage: number
18
+ }
19
+
20
+ const detectedZone = cache(async (): Promise<string | null> => {
21
+ try {
22
+ return (await cookies()).get(TIMEZONE_COOKIE)?.value ?? null
23
+ } catch {
24
+ return null
25
+ }
26
+ })
27
+
28
+ export const getViewerPreferences = cache(async (): Promise<ViewerPreferences> => {
29
+ const detected = await detectedZone()
30
+
31
+ const settings = await getSettings()
32
+ const defaults = {
33
+ postsPerPage: settings.get('display.posts_per_page'),
34
+ threadsPerPage: settings.get('display.threads_per_page'),
35
+ }
36
+
37
+ const stored = await storedSettings()
38
+ const timezone = resolveTimezone({ stored: stored?.timezone ?? null, detected })
39
+
40
+ return {
41
+ timezone: timezone.zone,
42
+ timezoneIsAutomatic: timezone.isAutomatic,
43
+ locale: stored?.locale ?? null,
44
+ postsPerPage: stored?.postsPerPage ?? defaults.postsPerPage,
45
+ threadsPerPage: stored?.threadsPerPage ?? defaults.threadsPerPage,
46
+ }
47
+ })
48
+
49
+ async function storedSettings(): Promise<{
50
+ readonly timezone: string
51
+ readonly locale: string
52
+ readonly postsPerPage: number | null
53
+ readonly threadsPerPage: number | null
54
+ } | null> {
55
+ const actor = await getActor()
56
+ const { memberSettings } = getContainer()
57
+ if (actor.userId === null || memberSettings === null) return null
58
+
59
+ try {
60
+ return await memberSettings.read(actor.userId)
61
+ } catch {
62
+ return null
63
+ }
64
+ }