@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.
- package/LICENSE.md +165 -0
- package/app/(auth)/layout.tsx +6 -0
- package/app/(auth)/login/page.tsx +106 -0
- package/app/(auth)/login/verify/page.tsx +55 -0
- package/app/(auth)/register/page.tsx +83 -0
- package/app/(auth)/reset/confirm/page.tsx +45 -0
- package/app/(auth)/reset/page.tsx +22 -0
- package/app/(auth)/verify/page.tsx +44 -0
- package/app/(auth)/verify/resend/page.tsx +51 -0
- package/app/(board)/[slug]/feed.xml/route.ts +48 -0
- package/app/(board)/[slug]/new/page.tsx +112 -0
- package/app/(board)/[slug]/page.tsx +403 -0
- package/app/(board)/discover/[view]/page.tsx +115 -0
- package/app/(board)/discover/page.tsx +5 -0
- package/app/(board)/layout.tsx +22 -0
- package/app/(board)/member/[id]/page.tsx +187 -0
- package/app/(board)/member/[id]/reputation/page.tsx +195 -0
- package/app/(board)/member/by-name/[name]/page.tsx +32 -0
- package/app/(board)/messages/[id]/page.tsx +103 -0
- package/app/(board)/messages/compose/page.tsx +60 -0
- package/app/(board)/messages/layout.tsx +5 -0
- package/app/(board)/messages/page.tsx +179 -0
- package/app/(board)/modcp/forums/page.tsx +59 -0
- package/app/(board)/modcp/ip/page.tsx +158 -0
- package/app/(board)/modcp/layout.tsx +5 -0
- package/app/(board)/modcp/log/page.tsx +98 -0
- package/app/(board)/modcp/page.tsx +112 -0
- package/app/(board)/moderation/layout.tsx +5 -0
- package/app/(board)/moderation/page.tsx +111 -0
- package/app/(board)/moderation/reports/page.tsx +193 -0
- package/app/(board)/moderation/warn/page.tsx +158 -0
- package/app/(board)/notifications/layout.tsx +5 -0
- package/app/(board)/notifications/page.tsx +144 -0
- package/app/(board)/notifications/preferences/page.tsx +65 -0
- package/app/(board)/online/page.tsx +119 -0
- package/app/(board)/page.tsx +195 -0
- package/app/(board)/plugins/[key]/[[...path]]/page.tsx +72 -0
- package/app/(board)/privacy/page.tsx +12 -0
- package/app/(board)/report/page.tsx +71 -0
- package/app/(board)/rules/page.tsx +12 -0
- package/app/(board)/search/[token]/page.tsx +130 -0
- package/app/(board)/search/page.tsx +289 -0
- package/app/(board)/stats/page.tsx +202 -0
- package/app/(board)/subscriptions/layout.tsx +5 -0
- package/app/(board)/subscriptions/page.tsx +142 -0
- package/app/(board)/terms/page.tsx +12 -0
- package/app/(board)/thread/[slug]/edit/page.tsx +107 -0
- package/app/(board)/thread/[slug]/feed.xml/route.ts +42 -0
- package/app/(board)/thread/[slug]/page.tsx +707 -0
- package/app/(board)/thread/[slug]/poll/page.tsx +41 -0
- package/app/(board)/thread/[slug]/post/[postId]/history/page.tsx +160 -0
- package/app/(board)/thread/[slug]/reply/page.tsx +136 -0
- package/app/(board)/unsubscribe/page.tsx +73 -0
- package/app/(board)/usercp/avatar/page.tsx +56 -0
- package/app/(board)/usercp/contacts/page.tsx +141 -0
- package/app/(board)/usercp/email/confirm/page.tsx +52 -0
- package/app/(board)/usercp/layout.tsx +23 -0
- package/app/(board)/usercp/options/page.tsx +56 -0
- package/app/(board)/usercp/page.tsx +71 -0
- package/app/(board)/usercp/profile/page.tsx +81 -0
- package/app/(board)/usercp/security/page.tsx +238 -0
- package/app/(board)/usercp/security/verify/page.tsx +92 -0
- package/app/(board)/usercp/signature/page.tsx +67 -0
- package/app/admin/antispam/page.tsx +155 -0
- package/app/admin/api/marketplace/screenshot/route.ts +68 -0
- package/app/admin/api/plugins/[key]/[...path]/route.ts +27 -0
- package/app/admin/api-tokens/page.tsx +129 -0
- package/app/admin/content/announcements/page.tsx +110 -0
- package/app/admin/content/attachments/page.tsx +199 -0
- package/app/admin/content/navigation/page.tsx +123 -0
- package/app/admin/content/page.tsx +170 -0
- package/app/admin/forums/[id]/page.tsx +96 -0
- package/app/admin/forums/[id]/permissions/page.tsx +108 -0
- package/app/admin/forums/page.tsx +45 -0
- package/app/admin/groups/[id]/page.tsx +133 -0
- package/app/admin/groups/memberships/page.tsx +56 -0
- package/app/admin/groups/page.tsx +104 -0
- package/app/admin/groups/promotions/page.tsx +143 -0
- package/app/admin/layout.tsx +86 -0
- package/app/admin/log/page.tsx +116 -0
- package/app/admin/page.tsx +206 -0
- package/app/admin/plugins/[key]/[[...path]]/page.tsx +276 -0
- package/app/admin/plugins/browse/page.tsx +70 -0
- package/app/admin/plugins/page.tsx +165 -0
- package/app/admin/security/page.tsx +124 -0
- package/app/admin/settings/page.tsx +190 -0
- package/app/admin/system/page.tsx +303 -0
- package/app/admin/themes/[key]/page.tsx +110 -0
- package/app/admin/themes/browse/page.tsx +70 -0
- package/app/admin/themes/page.tsx +163 -0
- package/app/admin/users/[id]/merge/page.tsx +136 -0
- package/app/admin/users/[id]/page.tsx +208 -0
- package/app/admin/users/mail/page.tsx +68 -0
- package/app/admin/users/page.tsx +260 -0
- package/app/admin/users/prune/page.tsx +186 -0
- package/app/api/health/route.ts +11 -0
- package/app/api/metrics/route.ts +66 -0
- package/app/api/plugins/[key]/[...path]/route.ts +27 -0
- package/app/api/read/all/route.ts +15 -0
- package/app/api/read/forum/[id]/route.ts +29 -0
- package/app/api/read/thread/[id]/route.ts +40 -0
- package/app/api/ready/route.ts +63 -0
- package/app/api/system/tick/route.ts +77 -0
- package/app/api/v1/[...path]/route.ts +208 -0
- package/app/api/v1/openapi.json/route.ts +12 -0
- package/app/apple-icon.ts +11 -0
- package/app/atom.xml/route.ts +7 -0
- package/app/attachment/[id]/route.ts +10 -0
- package/app/attachment/[id]/thumb/route.ts +10 -0
- package/app/auth/passkey/options/route.ts +145 -0
- package/app/auth/passkey/verify/route.ts +241 -0
- package/app/auth/resume/route.ts +55 -0
- package/app/auth/sso/[provider]/callback/route.ts +173 -0
- package/app/auth/sso/[provider]/go/route.ts +57 -0
- package/app/auth/sso/[provider]/route.ts +105 -0
- package/app/avatar/[id]/route.ts +31 -0
- package/app/brand/icon-192.png/route.ts +7 -0
- package/app/brand/icon-512.png/route.ts +7 -0
- package/app/brand/icon-maskable.png/route.ts +7 -0
- package/app/demo/stripe/[[...path]]/route.ts +27 -0
- package/app/error.tsx +15 -0
- package/app/feed.xml/route.ts +7 -0
- package/app/forumdisplay.php/page.tsx +11 -0
- package/app/group/[id]/badge/[scheme]/route.ts +26 -0
- package/app/icon.ts +13 -0
- package/app/install/page.tsx +221 -0
- package/app/jump/page.tsx +28 -0
- package/app/layout.tsx +124 -0
- package/app/logo/[scheme]/route.ts +24 -0
- package/app/manifest.ts +43 -0
- package/app/member.php/page.tsx +11 -0
- package/app/memberlist.php/page.tsx +11 -0
- package/app/not-found.tsx +14 -0
- package/app/opengraph-image.ts +11 -0
- package/app/redirect/page.tsx +44 -0
- package/app/robots.txt/route.ts +35 -0
- package/app/showthread.php/page.tsx +11 -0
- package/app/sitemap/[page]/route.ts +64 -0
- package/app/sitemap.xml/route.ts +31 -0
- package/app/twitter-image.ts +11 -0
- package/app/viewforum.php/page.tsx +11 -0
- package/app/viewtopic.php/page.tsx +11 -0
- package/bin/forum-web.mjs +241 -0
- package/components.json +21 -0
- package/instrumentation.ts +18 -0
- package/next.config.mjs +182 -0
- package/package.json +99 -0
- package/postcss.config.mjs +7 -0
- package/proxy.ts +160 -0
- package/src/components/account/active-sessions.tsx +87 -0
- package/src/components/account/avatar-form.tsx +119 -0
- package/src/components/account/credential-proof-form.tsx +76 -0
- package/src/components/account/logout-form.tsx +14 -0
- package/src/components/account/notification-forms.tsx +157 -0
- package/src/components/account/passkey-enrol.tsx +81 -0
- package/src/components/account/passkey-proof-button.tsx +47 -0
- package/src/components/account/push-device-form.tsx +193 -0
- package/src/components/account/relation-forms.tsx +69 -0
- package/src/components/account/reputation-forms.tsx +124 -0
- package/src/components/account/security-activity.tsx +46 -0
- package/src/components/account/sign-in-methods.tsx +166 -0
- package/src/components/account/subscription-forms.tsx +151 -0
- package/src/components/account/two-factor-forms.tsx +226 -0
- package/src/components/account/usercp-forms.tsx +387 -0
- package/src/components/account/usercp-nav.tsx +14 -0
- package/src/components/account/usercp-shell.tsx +34 -0
- package/src/components/admin/admin-forms.tsx +92 -0
- package/src/components/admin/admin-nav.tsx +52 -0
- package/src/components/admin/admin-undo.tsx +32 -0
- package/src/components/admin/api-token-forms.tsx +89 -0
- package/src/components/admin/badge-forms.tsx +97 -0
- package/src/components/admin/branding-forms.tsx +102 -0
- package/src/components/admin/content-forms.tsx +712 -0
- package/src/components/admin/form-bits.tsx +11 -0
- package/src/components/admin/forum-forms.tsx +524 -0
- package/src/components/admin/forum-tree.tsx +454 -0
- package/src/components/admin/group-forms.tsx +646 -0
- package/src/components/admin/mail-test-card.tsx +90 -0
- package/src/components/admin/marketplace-forms.tsx +30 -0
- package/src/components/admin/marketplace-listing.tsx +123 -0
- package/src/components/admin/navigation-forms.tsx +229 -0
- package/src/components/admin/navigation-tree.tsx +442 -0
- package/src/components/admin/oklch-picker.tsx +180 -0
- package/src/components/admin/plugin-forms.tsx +225 -0
- package/src/components/admin/settings-form.tsx +148 -0
- package/src/components/admin/system-forms.tsx +169 -0
- package/src/components/admin/theme-changes.tsx +354 -0
- package/src/components/admin/theme-forms.tsx +636 -0
- package/src/components/admin/theme-palette.tsx +119 -0
- package/src/components/admin/theme-preview.tsx +461 -0
- package/src/components/admin/user-bulk-toolbar.tsx +99 -0
- package/src/components/admin/user-forms.tsx +488 -0
- package/src/components/auth/auth-page.tsx +45 -0
- package/src/components/auth/form-controls.tsx +117 -0
- package/src/components/auth/login-form.tsx +47 -0
- package/src/components/auth/passkey-client.ts +171 -0
- package/src/components/auth/passkey-second-factor.tsx +69 -0
- package/src/components/auth/passkey-sign-in.tsx +61 -0
- package/src/components/auth/register-form.tsx +136 -0
- package/src/components/auth/resend-verification-form.tsx +34 -0
- package/src/components/auth/reset-confirm-form.tsx +43 -0
- package/src/components/auth/reset-request-form.tsx +35 -0
- package/src/components/auth/second-factor-form.tsx +51 -0
- package/src/components/auth/sso-buttons.tsx +47 -0
- package/src/components/board/live-region.tsx +74 -0
- package/src/components/board/search-off-notice.tsx +18 -0
- package/src/components/board/section-page.tsx +114 -0
- package/src/components/content/attachment-field.tsx +51 -0
- package/src/components/content/composer-field.ts +31 -0
- package/src/components/content/composer-ids.ts +6 -0
- package/src/components/content/composer-intents.tsx +27 -0
- package/src/components/content/composer-recovery.tsx +215 -0
- package/src/components/content/edit-post-form.tsx +121 -0
- package/src/components/content/markdown-editor.tsx +590 -0
- package/src/components/content/markdown-syntax.ts +53 -0
- package/src/components/content/mention-token.ts +23 -0
- package/src/components/content/multiquote-button.tsx +32 -0
- package/src/components/content/multiquote-selection.tsx +105 -0
- package/src/components/content/multiquote.ts +125 -0
- package/src/components/content/new-thread-form.tsx +169 -0
- package/src/components/content/poll-edit-form.tsx +96 -0
- package/src/components/content/poll.tsx +89 -0
- package/src/components/content/quote-in-place.tsx +51 -0
- package/src/components/content/reply-form.tsx +113 -0
- package/src/components/content/thanks-button.tsx +65 -0
- package/src/components/content/thread-rating.tsx +143 -0
- package/src/components/install/install-form.tsx +497 -0
- package/src/components/legal/legal-document.tsx +22 -0
- package/src/components/messages/message-forms.tsx +152 -0
- package/src/components/moderation/inline-moderation-form.tsx +158 -0
- package/src/components/moderation/modcp-nav.tsx +15 -0
- package/src/components/moderation/modcp-shell.tsx +41 -0
- package/src/components/moderation/queue-form.tsx +96 -0
- package/src/components/moderation/report-forms.tsx +120 -0
- package/src/components/moderation/signature-lock-form.tsx +83 -0
- package/src/components/moderation/thread-surgery-form.tsx +96 -0
- package/src/components/moderation/thread-tools-form.tsx +137 -0
- package/src/components/moderation/warning-forms.tsx +125 -0
- package/src/components/profile/custom-field.tsx +79 -0
- package/src/components/shell/appearance-icons.tsx +35 -0
- package/src/components/shell/board-notice.tsx +21 -0
- package/src/components/shell/copy-record.ts +20 -0
- package/src/components/shell/copy.tsx +17 -0
- package/src/components/shell/crash-notice.tsx +86 -0
- package/src/components/shell/demo-banner.tsx +49 -0
- package/src/components/shell/group-name-style.tsx +6 -0
- package/src/components/shell/measure.ts +1 -0
- package/src/components/shell/notification-menu.tsx +361 -0
- package/src/components/shell/offline-notice.tsx +29 -0
- package/src/components/shell/onboarding-banner.tsx +39 -0
- package/src/components/shell/page-shell.tsx +167 -0
- package/src/components/shell/panel-list.tsx +95 -0
- package/src/components/shell/panel-nav.tsx +40 -0
- package/src/components/shell/panel-overview.tsx +81 -0
- package/src/components/shell/panel-page.tsx +96 -0
- package/src/components/shell/panel-pagination.tsx +28 -0
- package/src/components/shell/panel-shell.tsx +31 -0
- package/src/components/shell/theme-runtime-style.tsx +6 -0
- package/src/components/shell/theme-switcher.tsx +97 -0
- package/src/components/shell/timezone-probe.tsx +45 -0
- package/src/components/shell/view-tabs.tsx +60 -0
- package/src/config.ts +13 -0
- package/src/server/absent-services.ts +29 -0
- package/src/server/admin-actions.ts +171 -0
- package/src/server/admin-settings-actions.ts +83 -0
- package/src/server/admin-undo-actions.ts +70 -0
- package/src/server/admin-undo.ts +75 -0
- package/src/server/admin.ts +112 -0
- package/src/server/announcements.ts +82 -0
- package/src/server/antispam.ts +310 -0
- package/src/server/api/content.ts +486 -0
- package/src/server/api/http.ts +119 -0
- package/src/server/api/members.ts +109 -0
- package/src/server/api/messages.ts +113 -0
- package/src/server/api/registry.ts +30 -0
- package/src/server/api/search.ts +98 -0
- package/src/server/api/subscriptions.ts +106 -0
- package/src/server/api-auth.ts +66 -0
- package/src/server/api-token-actions.ts +85 -0
- package/src/server/api-tokens-admin.ts +95 -0
- package/src/server/appearance-actions.ts +39 -0
- package/src/server/attachment-download.ts +43 -0
- package/src/server/attachment-embed.ts +63 -0
- package/src/server/attachment-upload-actions.ts +92 -0
- package/src/server/attachments.ts +257 -0
- package/src/server/auth-actions.ts +479 -0
- package/src/server/auth-config.ts +61 -0
- package/src/server/auth-events.ts +92 -0
- package/src/server/auth-form-state.ts +14 -0
- package/src/server/auth-mail.ts +104 -0
- package/src/server/avatars.ts +83 -0
- package/src/server/board-latest-actions.ts +7 -0
- package/src/server/board-latest.tsx +103 -0
- package/src/server/board-offline.ts +23 -0
- package/src/server/board-url.ts +14 -0
- package/src/server/brand-assets.ts +151 -0
- package/src/server/brand-mark.tsx +110 -0
- package/src/server/branding-actions.ts +53 -0
- package/src/server/branding.ts +101 -0
- package/src/server/cache-targets.ts +13 -0
- package/src/server/code-highlighting.ts +33 -0
- package/src/server/container.ts +402 -0
- package/src/server/content-actions.ts +459 -0
- package/src/server/content-admin-actions.ts +492 -0
- package/src/server/content-admin.ts +95 -0
- package/src/server/content-security-policy.ts +35 -0
- package/src/server/context.ts +32 -0
- package/src/server/cookies.ts +139 -0
- package/src/server/credential-proof-actions.ts +42 -0
- package/src/server/credential-proof.ts +35 -0
- package/src/server/current-location.ts +19 -0
- package/src/server/demo-stripe.ts +104 -0
- package/src/server/demo-uploads.ts +28 -0
- package/src/server/demo.ts +80 -0
- package/src/server/discovery.ts +104 -0
- package/src/server/embeds.ts +113 -0
- package/src/server/error-notice.tsx +41 -0
- package/src/server/federation-actions.ts +113 -0
- package/src/server/federation.ts +138 -0
- package/src/server/feed-builder.ts +72 -0
- package/src/server/feed-routes.ts +122 -0
- package/src/server/fixture-actor-source.ts +62 -0
- package/src/server/fixture-forum-repo.ts +59 -0
- package/src/server/fixture-member-profile-repo.ts +30 -0
- package/src/server/fixture-post-repo.ts +108 -0
- package/src/server/fixture-thread-repo.ts +98 -0
- package/src/server/form-state-reporter.ts +34 -0
- package/src/server/form-values.ts +23 -0
- package/src/server/forum-admin-actions.ts +353 -0
- package/src/server/forum-admin.ts +98 -0
- package/src/server/group-admin-actions.ts +341 -0
- package/src/server/group-admin.ts +98 -0
- package/src/server/group-badge-actions.ts +61 -0
- package/src/server/group-badge.ts +62 -0
- package/src/server/group-identity.ts +104 -0
- package/src/server/i18n-catalogs.ts +42 -0
- package/src/server/i18n.ts +69 -0
- package/src/server/image-upload.ts +109 -0
- package/src/server/inline-moderation-actions.ts +129 -0
- package/src/server/install-actions.ts +124 -0
- package/src/server/install.ts +281 -0
- package/src/server/legacy-redirect.ts +83 -0
- package/src/server/legal.ts +43 -0
- package/src/server/location-header.ts +37 -0
- package/src/server/mail-brand.ts +43 -0
- package/src/server/mail-health.ts +41 -0
- package/src/server/mail-send.ts +44 -0
- package/src/server/mail-test-actions.ts +67 -0
- package/src/server/mail-test.ts +56 -0
- package/src/server/markdown-pipeline.ts +31 -0
- package/src/server/marketplace-actions.ts +47 -0
- package/src/server/marketplace-admin.ts +260 -0
- package/src/server/mention-actions.ts +21 -0
- package/src/server/mention-search.ts +35 -0
- package/src/server/message-actions.ts +110 -0
- package/src/server/messages.ts +141 -0
- package/src/server/modcp.ts +125 -0
- package/src/server/moderation-actions.ts +165 -0
- package/src/server/navigation-admin-actions.ts +217 -0
- package/src/server/navigation.ts +112 -0
- package/src/server/nonce.ts +13 -0
- package/src/server/notification-actions.ts +84 -0
- package/src/server/notification-audience.ts +10 -0
- package/src/server/notification-menu-actions.ts +82 -0
- package/src/server/notification-menu.ts +96 -0
- package/src/server/notifications.ts +93 -0
- package/src/server/onboarding-actions.ts +27 -0
- package/src/server/onboarding.ts +89 -0
- package/src/server/page-metadata.ts +14 -0
- package/src/server/passkey-challenge.ts +42 -0
- package/src/server/plugin-admin-actions.ts +232 -0
- package/src/server/plugin-admin.ts +276 -0
- package/src/server/plugin-host.ts +143 -0
- package/src/server/plugin-pages.ts +192 -0
- package/src/server/plugin-panel.ts +49 -0
- package/src/server/plugin-routes.ts +298 -0
- package/src/server/plugin-view.tsx +58 -0
- package/src/server/poll-actions.ts +104 -0
- package/src/server/poll-scope.ts +52 -0
- package/src/server/post-notifications.ts +71 -0
- package/src/server/post-scope.ts +86 -0
- package/src/server/presence.ts +177 -0
- package/src/server/profile-fields.ts +112 -0
- package/src/server/push-actions.ts +113 -0
- package/src/server/push.ts +40 -0
- package/src/server/redirect-back.ts +15 -0
- package/src/server/registration.ts +7 -0
- package/src/server/relation-actions.ts +89 -0
- package/src/server/relations.ts +45 -0
- package/src/server/reply-core.ts +173 -0
- package/src/server/report-actions.ts +165 -0
- package/src/server/report-scope.ts +21 -0
- package/src/server/reputation-actions.ts +146 -0
- package/src/server/reputation-target.ts +49 -0
- package/src/server/reputation.ts +70 -0
- package/src/server/request-fingerprint.ts +65 -0
- package/src/server/safe-path.ts +16 -0
- package/src/server/same-origin.ts +45 -0
- package/src/server/search-page.ts +313 -0
- package/src/server/search.ts +45 -0
- package/src/server/secret-auth.ts +20 -0
- package/src/server/see-other.ts +11 -0
- package/src/server/seed-board.ts +417 -0
- package/src/server/session-actions.ts +78 -0
- package/src/server/session-cookies.ts +124 -0
- package/src/server/session-key.ts +17 -0
- package/src/server/settings.ts +47 -0
- package/src/server/signatures.ts +62 -0
- package/src/server/sso-handshake.ts +79 -0
- package/src/server/stats.ts +63 -0
- package/src/server/subscription-actions.ts +161 -0
- package/src/server/surgery-actions.ts +180 -0
- package/src/server/syndication.ts +77 -0
- package/src/server/system-admin-actions.ts +134 -0
- package/src/server/system-admin.ts +90 -0
- package/src/server/test-container.ts +114 -0
- package/src/server/theme-admin-actions.ts +242 -0
- package/src/server/theme-admin.ts +203 -0
- package/src/server/theme-runtime.ts +89 -0
- package/src/server/theme-style.ts +279 -0
- package/src/server/theme.ts +52 -0
- package/src/server/thread-core.ts +207 -0
- package/src/server/thread-rating-actions.ts +55 -0
- package/src/server/thread-tool-actions.ts +154 -0
- package/src/server/two-factor-actions.ts +178 -0
- package/src/server/two-factor.ts +86 -0
- package/src/server/upgrade-notice.ts +41 -0
- package/src/server/user-admin-actions.ts +572 -0
- package/src/server/user-admin.ts +165 -0
- package/src/server/usercp-actions.ts +263 -0
- package/src/server/usercp-mail.ts +120 -0
- package/src/server/viewer-preferences.ts +64 -0
- package/src/server/warning-actions.ts +122 -0
- package/src/styles/globals.css +969 -0
- package/src/theme/contract.fixture.ts +793 -0
- package/src/view/account-copy.ts +351 -0
- package/src/view/admin-content-copy.ts +66 -0
- package/src/view/admin-copy.ts +106 -0
- package/src/view/admin-forum-tree-copy.ts +40 -0
- package/src/view/admin-group-copy.ts +80 -0
- package/src/view/admin-log.ts +58 -0
- package/src/view/admin-nav.ts +164 -0
- package/src/view/admin-navigation-copy.ts +58 -0
- package/src/view/admin-panel-copy.ts +194 -0
- package/src/view/admin-settings.ts +173 -0
- package/src/view/admin-theme-copy.ts +229 -0
- package/src/view/admin-user-copy.ts +96 -0
- package/src/view/attachments.ts +51 -0
- package/src/view/auth-copy.ts +147 -0
- package/src/view/board-index.ts +167 -0
- package/src/view/board-latest.ts +101 -0
- package/src/view/breadcrumb.ts +50 -0
- package/src/view/contacts.ts +85 -0
- package/src/view/content-copy.ts +118 -0
- package/src/view/contrast.ts +286 -0
- package/src/view/copy.ts +101 -0
- package/src/view/count.ts +8 -0
- package/src/view/discovery-view.ts +74 -0
- package/src/view/editor-toolbar.ts +84 -0
- package/src/view/error-notice.ts +52 -0
- package/src/view/feed.ts +130 -0
- package/src/view/forum-cursor.ts +54 -0
- package/src/view/forum-display.ts +152 -0
- package/src/view/forum-jump.ts +60 -0
- package/src/view/forum-notice.ts +24 -0
- package/src/view/inline-moderation.ts +87 -0
- package/src/view/install-copy.ts +95 -0
- package/src/view/legal.ts +58 -0
- package/src/view/locale.ts +40 -0
- package/src/view/marketplace-panel.ts +51 -0
- package/src/view/member-identity.ts +23 -0
- package/src/view/member-profile.ts +65 -0
- package/src/view/messages.ts +222 -0
- package/src/view/metadata.ts +75 -0
- package/src/view/modcp-nav.ts +76 -0
- package/src/view/moderation-copy.ts +84 -0
- package/src/view/moderation-queue.ts +67 -0
- package/src/view/navigation-arrange.ts +188 -0
- package/src/view/navigation.ts +173 -0
- package/src/view/notification-menu.ts +202 -0
- package/src/view/notifications.ts +84 -0
- package/src/view/onboarding.ts +26 -0
- package/src/view/pager.ts +191 -0
- package/src/view/panel-nav.ts +203 -0
- package/src/view/plugin-panel.ts +42 -0
- package/src/view/post-form.ts +61 -0
- package/src/view/post-link.ts +29 -0
- package/src/view/presence.ts +99 -0
- package/src/view/promotion-rules.ts +52 -0
- package/src/view/redirect-notice.ts +21 -0
- package/src/view/reputation.ts +107 -0
- package/src/view/search-controls.ts +103 -0
- package/src/view/search-results.ts +291 -0
- package/src/view/security-activity.ts +73 -0
- package/src/view/setting-groups.ts +43 -0
- package/src/view/shell.ts +164 -0
- package/src/view/slug-id.ts +12 -0
- package/src/view/sso-hand-off.ts +44 -0
- package/src/view/sso-notices.ts +48 -0
- package/src/view/subscriptions.ts +76 -0
- package/src/view/theme-draft.ts +167 -0
- package/src/view/theme-preference.ts +30 -0
- package/src/view/theme-tokens.ts +333 -0
- package/src/view/thread-view.ts +280 -0
- package/src/view/time.ts +31 -0
- package/src/view/timezone.ts +35 -0
- package/src/view/two-factor.ts +6 -0
- package/src/view/usercp-nav.ts +103 -0
- package/src/view/usercp.ts +196 -0
- package/src/view/warnings.ts +128 -0
- package/src/view/word-filter.ts +5 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
|
|
5
|
+
import { type AuthConfig, foldIdentifier, hashToken, type LoginBucket } from '@meith/accounts'
|
|
6
|
+
import { env, logger } from '@meith/core'
|
|
7
|
+
import { currentRequestId } from '@meith/core/logger'
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
limitMessage,
|
|
11
|
+
loginAddressAttempts,
|
|
12
|
+
refused,
|
|
13
|
+
spendRegisterLimit,
|
|
14
|
+
spendResendLimit,
|
|
15
|
+
spendResetLimits,
|
|
16
|
+
verifyChallenge,
|
|
17
|
+
} from './antispam'
|
|
18
|
+
import { boardAuthConfig } from './auth-config'
|
|
19
|
+
import { recordAuthEvent } from './auth-events'
|
|
20
|
+
import type { FormState } from './auth-form-state'
|
|
21
|
+
import { sendPasswordResetEmail, sendVerificationEmail } from './auth-mail'
|
|
22
|
+
import { configuredIdentity, configuredSessions, getContainer } from './container'
|
|
23
|
+
import { formStateReporter } from './form-state-reporter'
|
|
24
|
+
import { getTranslator, tr } from './i18n'
|
|
25
|
+
import { termsAcceptance } from './legal'
|
|
26
|
+
import { emitEvent, filterView } from './plugin-view'
|
|
27
|
+
import { profileFieldService, registrationFieldContext, submittedFields } from './profile-fields'
|
|
28
|
+
import {
|
|
29
|
+
countingAddress,
|
|
30
|
+
countingPrefix,
|
|
31
|
+
requestFingerprint,
|
|
32
|
+
retainedIpPrefix,
|
|
33
|
+
} from './request-fingerprint'
|
|
34
|
+
import { isSafeLocalPath } from './safe-path'
|
|
35
|
+
import {
|
|
36
|
+
clearSecondFactorCookie,
|
|
37
|
+
clearSessionCookies,
|
|
38
|
+
readRememberToken,
|
|
39
|
+
readSecondFactorToken,
|
|
40
|
+
readSessionToken,
|
|
41
|
+
setRememberCookie,
|
|
42
|
+
setSecondFactorCookie,
|
|
43
|
+
setSessionCookie,
|
|
44
|
+
} from './session-cookies'
|
|
45
|
+
import { twoFactorService } from './two-factor'
|
|
46
|
+
|
|
47
|
+
function field(form: FormData, name: string): string {
|
|
48
|
+
const v = form.get(name)
|
|
49
|
+
return typeof v === 'string' ? v.trim() : ''
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const toFormState = formStateReporter('auth-actions', 'unexpected error in auth action')
|
|
53
|
+
|
|
54
|
+
async function addressContext(): Promise<{ readonly ipPrefix: string | null }> {
|
|
55
|
+
return { ipPrefix: await retainedIpPrefix() }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function deviceContext(): Promise<{
|
|
59
|
+
readonly ipPrefix: string | null
|
|
60
|
+
readonly userAgent: string | null
|
|
61
|
+
}> {
|
|
62
|
+
return requestFingerprint()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function loginBuckets(
|
|
66
|
+
identifier: string,
|
|
67
|
+
config: AuthConfig,
|
|
68
|
+
): Promise<readonly LoginBucket[]> {
|
|
69
|
+
const account = foldIdentifier(identifier)
|
|
70
|
+
const wide: LoginBucket = {
|
|
71
|
+
key: `login:${account}`,
|
|
72
|
+
max: config.maxAccountLoginAttempts,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const address = await countingAddress()
|
|
76
|
+
if (address === null) return [wide]
|
|
77
|
+
|
|
78
|
+
const perAddress = await loginAddressAttempts()
|
|
79
|
+
const prefix = await countingPrefix()
|
|
80
|
+
if (perAddress <= 0 || prefix === null) {
|
|
81
|
+
return [{ key: `login:${account}@${address}` }, wide]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return [
|
|
85
|
+
{ key: `login:${account}@${address}` },
|
|
86
|
+
wide,
|
|
87
|
+
{ key: `login@${prefix}`, max: perAddress, clearOnSuccess: false },
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function registerAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
92
|
+
const username = field(form, 'username')
|
|
93
|
+
const email = field(form, 'email')
|
|
94
|
+
const password = field(form, 'password')
|
|
95
|
+
const accepted = field(form, 'terms') !== ''
|
|
96
|
+
const values = { username, email, ...(accepted ? { terms: '1' } : {}) }
|
|
97
|
+
|
|
98
|
+
const identity = await configuredIdentity()
|
|
99
|
+
|
|
100
|
+
let verification: { token: string; email: string; username: string } | null = null
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const terms = await termsAcceptance()
|
|
104
|
+
if (terms !== null && !accepted) {
|
|
105
|
+
return {
|
|
106
|
+
error: await tr('authAction.acceptTerms', { terms: terms.label }),
|
|
107
|
+
values,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const challenge = await verifyChallenge(form)
|
|
112
|
+
if (!challenge.ok) return { error: challenge.reason, values }
|
|
113
|
+
|
|
114
|
+
const limited = await spendRegisterLimit()
|
|
115
|
+
if (refused(limited)) return { error: limitMessage(limited), values }
|
|
116
|
+
|
|
117
|
+
const fields = profileFieldService()
|
|
118
|
+
const context = fields === null ? null : await registrationFieldContext()
|
|
119
|
+
const fieldValues =
|
|
120
|
+
fields === null || context === null
|
|
121
|
+
? []
|
|
122
|
+
: await fields.validateRegistration({ submitted: submittedFields(form), context })
|
|
123
|
+
|
|
124
|
+
const objections = await filterView('user.register.validate', [], {
|
|
125
|
+
username,
|
|
126
|
+
email,
|
|
127
|
+
ipPrefix: (await requestFingerprint()).ipPrefix,
|
|
128
|
+
})
|
|
129
|
+
if (objections.length > 0) return { error: objections[0]!, values }
|
|
130
|
+
|
|
131
|
+
const result = await identity.register({ username, email, password }, await addressContext())
|
|
132
|
+
|
|
133
|
+
await emitEvent(
|
|
134
|
+
'user.registered',
|
|
135
|
+
{
|
|
136
|
+
userId: result.account.id,
|
|
137
|
+
username: result.account.username,
|
|
138
|
+
requiresActivation: result.verificationToken !== undefined,
|
|
139
|
+
},
|
|
140
|
+
{ requestId: currentRequestId() ?? null },
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if (fields !== null) await fields.applyRegistration(result.account.id, fieldValues)
|
|
144
|
+
|
|
145
|
+
if (result.verificationToken !== undefined) {
|
|
146
|
+
verification = {
|
|
147
|
+
token: result.verificationToken,
|
|
148
|
+
email: result.account.email,
|
|
149
|
+
username: result.account.username,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (err) {
|
|
153
|
+
return toFormState(err, values)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (verification !== null) {
|
|
157
|
+
try {
|
|
158
|
+
await sendVerificationEmail({ ...verification, t: await getTranslator() })
|
|
159
|
+
} catch (err) {
|
|
160
|
+
logger({ module: 'auth-actions' }).error(
|
|
161
|
+
{ err },
|
|
162
|
+
'could not send a verification e-mail; the account exists and can ask for another',
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
redirect(`/verify/resend?email=${encodeURIComponent(verification.email)}&sent=1`)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
redirect('/login?registered=1')
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function resendVerificationAction(
|
|
172
|
+
_prev: FormState,
|
|
173
|
+
form: FormData,
|
|
174
|
+
): Promise<FormState> {
|
|
175
|
+
const email = field(form, 'email')
|
|
176
|
+
const values = { email }
|
|
177
|
+
|
|
178
|
+
const notice = await tr('authAction.verificationResent')
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
const limit = await spendResendLimit(foldIdentifier(email))
|
|
182
|
+
if (limit !== null && !limit.allowed) return { notice, values }
|
|
183
|
+
|
|
184
|
+
const identity = await configuredIdentity()
|
|
185
|
+
const resent = await identity.resendVerification(email)
|
|
186
|
+
|
|
187
|
+
if (resent.token !== null && resent.account !== null) {
|
|
188
|
+
try {
|
|
189
|
+
await sendVerificationEmail({
|
|
190
|
+
token: resent.token,
|
|
191
|
+
email: resent.account.email,
|
|
192
|
+
username: resent.account.username,
|
|
193
|
+
t: await getTranslator(),
|
|
194
|
+
})
|
|
195
|
+
} catch (err) {
|
|
196
|
+
logger({ module: 'auth-actions' }).error({ err }, 'could not resend a verification e-mail')
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { notice, values }
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return toFormState(err, values)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function loginAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
207
|
+
const identifier = field(form, 'identifier')
|
|
208
|
+
const password = field(form, 'password')
|
|
209
|
+
const remember = form.get('remember') === 'on'
|
|
210
|
+
const next = sanitizeNext(field(form, 'next'))
|
|
211
|
+
const values = { identifier }
|
|
212
|
+
|
|
213
|
+
let destination = next
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const config = await boardAuthConfig()
|
|
217
|
+
const identity = await configuredIdentity()
|
|
218
|
+
|
|
219
|
+
const outcome = await identity.login(
|
|
220
|
+
identifier,
|
|
221
|
+
password,
|
|
222
|
+
await loginBuckets(identifier, config),
|
|
223
|
+
await deviceContext(),
|
|
224
|
+
{ remember },
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
if (outcome.status === 'second-factor') {
|
|
228
|
+
await setSecondFactorCookie(outcome.token, outcome.expiresAt)
|
|
229
|
+
destination = verifyPath(next)
|
|
230
|
+
} else {
|
|
231
|
+
await completeSignIn(outcome.login, remember)
|
|
232
|
+
}
|
|
233
|
+
} catch (err) {
|
|
234
|
+
await recordAuthEvent({
|
|
235
|
+
userId: await accountBehind(identifier),
|
|
236
|
+
kind: 'login_failed',
|
|
237
|
+
detail: { identifier },
|
|
238
|
+
})
|
|
239
|
+
await emitEvent(
|
|
240
|
+
'user.login.attempted',
|
|
241
|
+
{
|
|
242
|
+
username: identifier,
|
|
243
|
+
outcome: loginOutcomeOf(err),
|
|
244
|
+
ipPrefix: (await requestFingerprint()).ipPrefix,
|
|
245
|
+
},
|
|
246
|
+
{ requestId: currentRequestId() ?? null },
|
|
247
|
+
)
|
|
248
|
+
return toFormState(err, values)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await emitEvent(
|
|
252
|
+
'user.login.attempted',
|
|
253
|
+
{
|
|
254
|
+
username: identifier,
|
|
255
|
+
outcome: 'ok',
|
|
256
|
+
ipPrefix: (await requestFingerprint()).ipPrefix,
|
|
257
|
+
},
|
|
258
|
+
{ requestId: currentRequestId() ?? null },
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
redirect(destination)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function accountBehind(identifier: string): Promise<number | null> {
|
|
265
|
+
const lower = foldIdentifier(identifier)
|
|
266
|
+
if (lower === '') return null
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
const { accounts } = getContainer().accountStore
|
|
270
|
+
const account =
|
|
271
|
+
(await accounts.findByUsernameLower(lower)) ?? (await accounts.findByEmailLower(lower))
|
|
272
|
+
|
|
273
|
+
return account?.id ?? null
|
|
274
|
+
} catch (err) {
|
|
275
|
+
logger({ module: 'auth-actions' }).warn({ err }, 'could not attribute a refused sign-in')
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function completeSignIn(
|
|
281
|
+
login: { account: { id: number }; sessionToken: string; expiresAt: Date },
|
|
282
|
+
remember: boolean,
|
|
283
|
+
): Promise<void> {
|
|
284
|
+
await setSessionCookie(login.sessionToken, login.expiresAt)
|
|
285
|
+
|
|
286
|
+
if (remember) {
|
|
287
|
+
const sessions = await configuredSessions()
|
|
288
|
+
const remembered = await sessions.startRemembered(login.account.id, await deviceContext())
|
|
289
|
+
await setRememberCookie(remembered.rememberToken, remembered.rememberExpiresAt)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await recordAuthEvent({ userId: login.account.id, kind: 'login' })
|
|
293
|
+
await emitEvent(
|
|
294
|
+
'user.logged-in',
|
|
295
|
+
{ userId: login.account.id },
|
|
296
|
+
{ requestId: currentRequestId() ?? null },
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function loginOutcomeOf(error: unknown): 'bad-credentials' | 'locked-out' | 'banned' {
|
|
301
|
+
const message = error instanceof Error ? error.message.toLowerCase() : ''
|
|
302
|
+
if (message.includes('banned') || message.includes('suspended')) return 'banned'
|
|
303
|
+
if (message.includes('too many') || message.includes('locked')) return 'locked-out'
|
|
304
|
+
return 'bad-credentials'
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function verifyPath(next: string): string {
|
|
308
|
+
return next === '/' ? '/login/verify' : `/login/verify?next=${encodeURIComponent(next)}`
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function verifySecondFactorAction(
|
|
312
|
+
_prev: FormState,
|
|
313
|
+
form: FormData,
|
|
314
|
+
): Promise<FormState> {
|
|
315
|
+
const code = field(form, 'code')
|
|
316
|
+
const next = sanitizeNext(field(form, 'next'))
|
|
317
|
+
|
|
318
|
+
const token = await readSecondFactorToken()
|
|
319
|
+
if (token === undefined || token === '') {
|
|
320
|
+
return { error: await tr('authAction.secondFactorExpired') }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const identity = await configuredIdentity()
|
|
324
|
+
const pending = await identity.pendingSecondFactor(token)
|
|
325
|
+
if (pending === null) {
|
|
326
|
+
await clearSecondFactorCookie()
|
|
327
|
+
return { error: await tr('authAction.secondFactorExpired') }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const service = twoFactorService()
|
|
331
|
+
if (service === null) return { error: await tr('authAction.secondFactorExpired') }
|
|
332
|
+
|
|
333
|
+
try {
|
|
334
|
+
await identity.assertSecondFactorAttemptsLeft(pending.userId)
|
|
335
|
+
|
|
336
|
+
const outcome = await service.verify({ userId: pending.userId, code })
|
|
337
|
+
|
|
338
|
+
if (outcome.status !== 'ok') {
|
|
339
|
+
await identity.recordSecondFactorFailure(pending.userId)
|
|
340
|
+
await recordAuthEvent({ userId: pending.userId, kind: 'second_factor_failed' })
|
|
341
|
+
return {
|
|
342
|
+
error: await tr(
|
|
343
|
+
outcome.status === 'replayed' ? 'authAction.codeReplayed' : 'authAction.codeWrong',
|
|
344
|
+
),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
await identity.clearSecondFactorFailures(pending.userId)
|
|
349
|
+
|
|
350
|
+
const login = await identity.redeemSecondFactor(token, await deviceContext())
|
|
351
|
+
await clearSecondFactorCookie()
|
|
352
|
+
await completeSignIn(login, pending.remember)
|
|
353
|
+
|
|
354
|
+
if (outcome.usedRecoveryCode) {
|
|
355
|
+
await recordAuthEvent({ userId: pending.userId, kind: 'recovery_code_used' })
|
|
356
|
+
}
|
|
357
|
+
} catch (err) {
|
|
358
|
+
return toFormState(err)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
redirect(next)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function abandonSecondFactorAction(): Promise<void> {
|
|
365
|
+
const token = await readSecondFactorToken()
|
|
366
|
+
|
|
367
|
+
if (token !== undefined && token !== '') {
|
|
368
|
+
const identity = await configuredIdentity()
|
|
369
|
+
const pending = await identity.pendingSecondFactor(token)
|
|
370
|
+
if (pending !== null) await identity.abandonSecondFactor(pending.userId)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
await clearSecondFactorCookie()
|
|
374
|
+
redirect('/login')
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export async function logoutAction(): Promise<void> {
|
|
378
|
+
const token = await readSessionToken()
|
|
379
|
+
const rememberToken = await readRememberToken()
|
|
380
|
+
const { accountStore, identity } = getContainer()
|
|
381
|
+
if (rememberToken) {
|
|
382
|
+
try {
|
|
383
|
+
const held = await accountStore.remember.findByTokenHash(await hashToken(rememberToken))
|
|
384
|
+
if (held !== null) {
|
|
385
|
+
await accountStore.remember.revokeFamily(held.familyId, 'logout', new Date())
|
|
386
|
+
}
|
|
387
|
+
} catch (err) {
|
|
388
|
+
logger({ module: 'auth-actions' }).warn(
|
|
389
|
+
{ err },
|
|
390
|
+
['logout remember-token revoke failed', 'clearing cookies anyway'].join('; '),
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (token) {
|
|
395
|
+
try {
|
|
396
|
+
const resolved = await identity.resolveSession(token)
|
|
397
|
+
await identity.logout(token)
|
|
398
|
+
if (resolved !== null) {
|
|
399
|
+
await recordAuthEvent({ userId: resolved.userId, kind: 'logout' })
|
|
400
|
+
await emitEvent(
|
|
401
|
+
'user.logged-out',
|
|
402
|
+
{ userId: resolved.userId, reason: 'requested' },
|
|
403
|
+
{ requestId: currentRequestId() ?? null },
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
} catch (err) {
|
|
407
|
+
logger({ module: 'auth-actions' }).warn(
|
|
408
|
+
{ err },
|
|
409
|
+
'logout revoke failed; clearing cookies anyway',
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
await clearSessionCookies()
|
|
414
|
+
redirect('/login')
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function requestResetAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
418
|
+
const email = field(form, 'email')
|
|
419
|
+
const { identity } = getContainer()
|
|
420
|
+
|
|
421
|
+
const notice = await tr('authAction.resetRequested')
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const limits = await spendResetLimits(foldIdentifier(email))
|
|
425
|
+
if (limits.some(refused)) return { notice }
|
|
426
|
+
|
|
427
|
+
const { token, userId } = await identity.requestPasswordReset(email)
|
|
428
|
+
|
|
429
|
+
if (token !== null && userId !== null) {
|
|
430
|
+
const account = await getContainer().accountStore.accounts.findById(userId)
|
|
431
|
+
if (account !== null) {
|
|
432
|
+
try {
|
|
433
|
+
await sendPasswordResetEmail({
|
|
434
|
+
token,
|
|
435
|
+
email: account.email,
|
|
436
|
+
username: account.username,
|
|
437
|
+
t: await getTranslator(),
|
|
438
|
+
})
|
|
439
|
+
} catch (err) {
|
|
440
|
+
logger({ module: 'auth-actions' }).error(
|
|
441
|
+
{ err },
|
|
442
|
+
'could not send a password reset e-mail',
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (token && env.NODE_ENV === 'development') {
|
|
449
|
+
return { notice, values: { devToken: token } }
|
|
450
|
+
}
|
|
451
|
+
return { notice }
|
|
452
|
+
} catch (err) {
|
|
453
|
+
return toFormState(err, { email })
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export async function confirmResetAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
458
|
+
const token = field(form, 'token')
|
|
459
|
+
const password = field(form, 'password')
|
|
460
|
+
const confirm = field(form, 'confirm')
|
|
461
|
+
|
|
462
|
+
if (password !== confirm) {
|
|
463
|
+
return { error: await tr('notice.app.two-passwords-match'), values: { token } }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const identity = await configuredIdentity()
|
|
467
|
+
try {
|
|
468
|
+
const { userId } = await identity.redeemPasswordReset(token, password)
|
|
469
|
+
await recordAuthEvent({ userId, kind: 'password_reset' })
|
|
470
|
+
} catch (err) {
|
|
471
|
+
return toFormState(err, { token })
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
redirect('/login?reset=1')
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function sanitizeNext(next: string): string {
|
|
478
|
+
return isSafeLocalPath(next) ? next : '/'
|
|
479
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import { type AuthConfig, DEFAULT_AUTH_POLICY, resolveAuthPolicy } from '@meith/accounts'
|
|
4
|
+
import { env } from '@meith/core'
|
|
5
|
+
|
|
6
|
+
import { SEED_GROUP } from './seed-board'
|
|
7
|
+
import { getSettings } from './settings'
|
|
8
|
+
|
|
9
|
+
export const REMEMBER_DAYS = 30
|
|
10
|
+
export const SESSION_LIFETIME_DAYS = 14
|
|
11
|
+
|
|
12
|
+
export const AUTH_CONFIG: AuthConfig = {
|
|
13
|
+
...DEFAULT_AUTH_POLICY,
|
|
14
|
+
sessionLifetimeDays: SESSION_LIFETIME_DAYS,
|
|
15
|
+
activationMethod: 'none',
|
|
16
|
+
defaultMemberGroupId: SEED_GROUP.registered,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BoardSessionConfig {
|
|
20
|
+
readonly rememberDays: number
|
|
21
|
+
readonly sessionLifetimeDays: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function boardAuthConfig(): Promise<AuthConfig> {
|
|
25
|
+
if (env.DATA_SOURCE !== 'postgres') return AUTH_CONFIG
|
|
26
|
+
|
|
27
|
+
const settings = await getSettings()
|
|
28
|
+
return demoOverrides({
|
|
29
|
+
...AUTH_CONFIG,
|
|
30
|
+
...resolveAuthPolicy((key) => settings.get(key as never), AUTH_CONFIG),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function boardSessionConfig(): Promise<BoardSessionConfig> {
|
|
35
|
+
return {
|
|
36
|
+
rememberDays: REMEMBER_DAYS,
|
|
37
|
+
sessionLifetimeDays: (await boardAuthConfig()).sessionLifetimeDays,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Applied last, over the stored settings, because on a demo the stored settings
|
|
43
|
+
* are whatever the last visitor left behind.
|
|
44
|
+
*
|
|
45
|
+
* The lockout exists to make guessing a password expensive. A demo's passwords
|
|
46
|
+
* are printed on the page, so there is nothing left to guess — and the counter
|
|
47
|
+
* is per account, which means one visitor mistyping `admin` five times locks the
|
|
48
|
+
* published login for everyone else for a quarter of an hour. Not disabled
|
|
49
|
+
* outright: this is still a login form on the open internet, and something
|
|
50
|
+
* hammering it should still meet a wall.
|
|
51
|
+
*/
|
|
52
|
+
function demoOverrides(config: AuthConfig): AuthConfig {
|
|
53
|
+
if (!env.DEMO_MODE) return config
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
...config,
|
|
57
|
+
maxLoginAttempts: 50,
|
|
58
|
+
maxAccountLoginAttempts: 500,
|
|
59
|
+
lockoutMinutes: 1,
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import 'server-only'
|
|
2
|
+
|
|
3
|
+
import type { AuthEventKind, AuthEventRecord } from '@meith/accounts'
|
|
4
|
+
import { logger } from '@meith/core'
|
|
5
|
+
|
|
6
|
+
import { getContainer } from './container'
|
|
7
|
+
import { requestFingerprint } from './request-fingerprint'
|
|
8
|
+
|
|
9
|
+
export const MEMBER_ACTIVITY_LIMIT = 20
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Never lets the log stop the thing it is recording. A sign-in that worked and
|
|
13
|
+
* went unrecorded is a gap in an audit trail; a sign-in refused because the
|
|
14
|
+
* audit trail was unwritable is an outage.
|
|
15
|
+
*/
|
|
16
|
+
export async function recordAuthEvent(input: {
|
|
17
|
+
readonly userId: number | null
|
|
18
|
+
readonly kind: AuthEventKind
|
|
19
|
+
readonly detail?: Readonly<Record<string, unknown>>
|
|
20
|
+
}): Promise<void> {
|
|
21
|
+
const { accountStore, dataSource } = getContainer()
|
|
22
|
+
if (dataSource !== 'postgres') return
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const fingerprint = await requestFingerprint()
|
|
26
|
+
|
|
27
|
+
await accountStore.authEvents.record({
|
|
28
|
+
userId: input.userId,
|
|
29
|
+
kind: input.kind,
|
|
30
|
+
ipPrefix: fingerprint.ipPrefix,
|
|
31
|
+
userAgent: fingerprint.userAgent,
|
|
32
|
+
detail: input.detail ?? {},
|
|
33
|
+
at: new Date(),
|
|
34
|
+
})
|
|
35
|
+
} catch (err) {
|
|
36
|
+
logger({ module: 'auth-events' }).warn(
|
|
37
|
+
{ err, kind: input.kind },
|
|
38
|
+
'could not record an authentication event',
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function recordStaffAuthEvent(input: {
|
|
44
|
+
readonly userId: number
|
|
45
|
+
readonly kind: AuthEventKind
|
|
46
|
+
readonly detail?: Readonly<Record<string, unknown>>
|
|
47
|
+
}): Promise<void> {
|
|
48
|
+
const { accountStore, dataSource } = getContainer()
|
|
49
|
+
if (dataSource !== 'postgres') return
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
await accountStore.authEvents.record({
|
|
53
|
+
userId: input.userId,
|
|
54
|
+
kind: input.kind,
|
|
55
|
+
ipPrefix: null,
|
|
56
|
+
userAgent: null,
|
|
57
|
+
detail: { ...(input.detail ?? {}), by: 'staff' },
|
|
58
|
+
at: new Date(),
|
|
59
|
+
})
|
|
60
|
+
} catch (err) {
|
|
61
|
+
logger({ module: 'auth-events' }).warn(
|
|
62
|
+
{ err, kind: input.kind },
|
|
63
|
+
'could not record an authentication event',
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function memberSecurityActivity(
|
|
69
|
+
userId: number,
|
|
70
|
+
limit = MEMBER_ACTIVITY_LIMIT,
|
|
71
|
+
): Promise<readonly AuthEventRecord[]> {
|
|
72
|
+
const { accountStore, dataSource } = getContainer()
|
|
73
|
+
if (dataSource !== 'postgres') return []
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
return await accountStore.authEvents.listForUser(userId, limit)
|
|
77
|
+
} catch (err) {
|
|
78
|
+
logger({ module: 'auth-events' }).warn({ err }, 'could not read security activity')
|
|
79
|
+
return []
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function boardSecurityActivity(input: {
|
|
84
|
+
readonly limit: number
|
|
85
|
+
readonly before?: number | undefined
|
|
86
|
+
readonly kind?: AuthEventKind | undefined
|
|
87
|
+
}): Promise<readonly AuthEventRecord[]> {
|
|
88
|
+
const { accountStore, dataSource } = getContainer()
|
|
89
|
+
if (dataSource !== 'postgres') return []
|
|
90
|
+
|
|
91
|
+
return accountStore.authEvents.listRecent(input)
|
|
92
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface UndoState {
|
|
2
|
+
readonly token: string
|
|
3
|
+
readonly expiresAt: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface FormState {
|
|
7
|
+
readonly error?: string | undefined
|
|
8
|
+
readonly notice?: string | undefined
|
|
9
|
+
readonly values?: Record<string, string> | undefined
|
|
10
|
+
readonly preview?: string | undefined
|
|
11
|
+
readonly undo?: UndoState | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const EMPTY_STATE: FormState = {}
|