create-nextblock 0.12.9 → 0.12.12
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/docker-template/.dockerignore +23 -23
- package/docker-template/.env.docker.example +69 -69
- package/docker-template/docker/db/init/99-jwt.sql +6 -6
- package/docker-template/docker/db/init/99-roles.sql +25 -25
- package/docker-template/docker/kong/kong.yml +112 -112
- package/docker-template/scripts/docker-setup.mjs +310 -310
- package/libs/db/tsconfig.lib.json +3 -3
- package/libs/editor/tsconfig.lib.json +3 -3
- package/libs/ui/tsconfig.lib.json +3 -3
- package/libs/utils/tsconfig.json +3 -3
- package/package.json +1 -1
- package/project.json +19 -19
- package/templates/nextblock-template/.browserslistrc +11 -11
- package/templates/nextblock-template/.dockerignore +23 -23
- package/templates/nextblock-template/README.md +34 -34
- package/templates/nextblock-template/app/(auth-pages)/layout.tsx +9 -9
- package/templates/nextblock-template/app/(auth-pages)/post-sign-in/page.tsx +27 -27
- package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +141 -141
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +46 -46
- package/templates/nextblock-template/app/(auth-pages)/two-factor/actions.ts +91 -91
- package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +123 -123
- package/templates/nextblock-template/app/(auth-pages)/two-factor/page.tsx +51 -51
- package/templates/nextblock-template/app/ToasterProvider.tsx +17 -17
- package/templates/nextblock-template/app/[slug]/pageClientActions.ts +7 -7
- package/templates/nextblock-template/app/actions/consent.ts +57 -57
- package/templates/nextblock-template/app/actions/interactions.test.ts +301 -301
- package/templates/nextblock-template/app/actions/interactions.ts +373 -373
- package/templates/nextblock-template/app/actions/postActions.ts +146 -146
- package/templates/nextblock-template/app/actions/twoFactorEmail.ts +22 -22
- package/templates/nextblock-template/app/api/ai/cortex/build-widget/route.ts +153 -153
- package/templates/nextblock-template/app/api/ai/generate-blocks/route.ts +96 -96
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +964 -964
- package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -48
- package/templates/nextblock-template/app/api/cms/check-updates/route.ts +44 -44
- package/templates/nextblock-template/app/api/cms/full-backup/export/route.ts +33 -33
- package/templates/nextblock-template/app/api/cms/full-backup/restore/route.ts +63 -63
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +3490 -3490
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +5597 -5597
- package/templates/nextblock-template/app/api/cron/sync-currencies/route.ts +39 -39
- package/templates/nextblock-template/app/api/custom-blocks/db-relations/route.ts +92 -92
- package/templates/nextblock-template/app/api/custom-blocks/editor-definitions/route.ts +43 -43
- package/templates/nextblock-template/app/api/media/library/route.ts +69 -69
- package/templates/nextblock-template/app/api/media/r2-presigned/route.ts +53 -53
- package/templates/nextblock-template/app/api/media/record/route.ts +160 -160
- package/templates/nextblock-template/app/api/search/route.ts +43 -43
- package/templates/nextblock-template/app/article/[slug]/PostClientContent.tsx +441 -441
- package/templates/nextblock-template/app/auth/callback/route.ts +31 -31
- package/templates/nextblock-template/app/cart/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
- package/templates/nextblock-template/app/cms/blocks/components/MediaLibraryModal.tsx +149 -149
- package/templates/nextblock-template/app/cms/blocks/editors/DynamicCustomBlockEditor.tsx +167 -167
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +29 -29
- package/templates/nextblock-template/app/cms/blocks/editors/TextBlockEditor.tsx +90 -90
- package/templates/nextblock-template/app/cms/components/ConnectGitHubButton.tsx +122 -122
- package/templates/nextblock-template/app/cms/components/CortexAiPageContext.tsx +58 -58
- package/templates/nextblock-template/app/cms/components/FeatureImageField.tsx +244 -244
- package/templates/nextblock-template/app/cms/components/FeedbackModal.tsx +36 -36
- package/templates/nextblock-template/app/cms/components/SystemAlertsBanner.tsx +112 -112
- package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -45
- package/templates/nextblock-template/app/cms/components/github-connect-actions.ts +102 -102
- package/templates/nextblock-template/app/cms/components/system-alerts-actions.ts +31 -31
- package/templates/nextblock-template/app/cms/custom-blocks/[id]/edit/page.tsx +66 -66
- package/templates/nextblock-template/app/cms/custom-blocks/actions.ts +519 -519
- package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +1522 -1522
- package/templates/nextblock-template/app/cms/custom-blocks/components/BlocksLibraryTransferControls.tsx +256 -256
- package/templates/nextblock-template/app/cms/custom-blocks/components/DBRelationSelect.tsx +384 -384
- package/templates/nextblock-template/app/cms/custom-blocks/components/ImageR2Picker.tsx +221 -221
- package/templates/nextblock-template/app/cms/custom-blocks/new/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/custom-blocks/page.tsx +438 -438
- package/templates/nextblock-template/app/cms/dashboard/actions.ts +228 -228
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +130 -130
- package/templates/nextblock-template/app/cms/import-export/ContentTransferControls.tsx +431 -391
- package/templates/nextblock-template/app/cms/import-export/actions.ts +226 -226
- package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +408 -408
- package/templates/nextblock-template/app/cms/interactions/page.tsx +51 -51
- package/templates/nextblock-template/app/cms/layout.tsx +73 -73
- package/templates/nextblock-template/app/cms/media/components/FolderNavigator.tsx +273 -273
- package/templates/nextblock-template/app/cms/media/components/FolderTree.tsx +122 -122
- package/templates/nextblock-template/app/cms/media/components/MediaEditForm.tsx +26 -26
- package/templates/nextblock-template/app/cms/media/components/MediaGridClient.tsx +69 -69
- package/templates/nextblock-template/app/cms/media/components/MediaPickerDialog.tsx +149 -158
- package/templates/nextblock-template/app/cms/media/components/MediaUploadForm.tsx +101 -101
- package/templates/nextblock-template/app/cms/navigation/components/NavigationMenuDnd.tsx +3 -3
- package/templates/nextblock-template/app/cms/pages/page.tsx +18 -17
- package/templates/nextblock-template/app/cms/posts/page.tsx +19 -18
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +12 -0
- package/templates/nextblock-template/app/cms/products/attributes/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/categories/page.tsx +12 -12
- package/templates/nextblock-template/app/cms/products/inventory/page.tsx +13 -13
- package/templates/nextblock-template/app/cms/products/new/page.tsx +135 -135
- package/templates/nextblock-template/app/cms/products/page.tsx +39 -27
- package/templates/nextblock-template/app/cms/products/productFormData.ts +133 -133
- package/templates/nextblock-template/app/cms/products/settings/page.tsx +5 -5
- package/templates/nextblock-template/app/cms/promotions/PromotionsWorkspace.tsx +456 -456
- package/templates/nextblock-template/app/cms/promotions/actions.ts +115 -115
- package/templates/nextblock-template/app/cms/promotions/page.tsx +31 -31
- package/templates/nextblock-template/app/cms/revisions/service.ts +19 -19
- package/templates/nextblock-template/app/cms/revisions/utils.ts +132 -132
- package/templates/nextblock-template/app/cms/settings/backup-restore/BackupRestoreWorkspace.tsx +1004 -1004
- package/templates/nextblock-template/app/cms/settings/backup-restore/page.tsx +29 -29
- package/templates/nextblock-template/app/cms/settings/bot-protection/actions.ts +93 -93
- package/templates/nextblock-template/app/cms/settings/bot-protection/components/BotProtectionForm.tsx +129 -129
- package/templates/nextblock-template/app/cms/settings/bot-protection/page.tsx +24 -24
- package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +497 -497
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +410 -410
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +246 -246
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +80 -80
- package/templates/nextblock-template/app/cms/settings/currencies/actions.ts +331 -331
- package/templates/nextblock-template/app/cms/settings/currencies/page.tsx +494 -494
- package/templates/nextblock-template/app/cms/settings/email/actions.ts +64 -64
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +181 -181
- package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -28
- package/templates/nextblock-template/app/cms/settings/extra-translations/ExtraTranslationsWorkspace.tsx +767 -767
- package/templates/nextblock-template/app/cms/settings/extra-translations/page.tsx +93 -93
- package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +60 -60
- package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +129 -129
- package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
- package/templates/nextblock-template/app/cms/settings/languages/actions.ts +16 -16
- package/templates/nextblock-template/app/cms/settings/logos/[id]/edit/page.tsx +7 -7
- package/templates/nextblock-template/app/cms/settings/logos/components/BrandingSettingsForm.tsx +339 -339
- package/templates/nextblock-template/app/cms/settings/logos/components/DeleteLogoButton.tsx +21 -21
- package/templates/nextblock-template/app/cms/settings/logos/components/LogoForm.tsx +20 -20
- package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -42
- package/templates/nextblock-template/app/cms/settings/logos/components/SiteSeoSettingsForm.tsx +133 -133
- package/templates/nextblock-template/app/cms/settings/logos/new/page.tsx +8 -8
- package/templates/nextblock-template/app/cms/settings/packages/package-card.tsx +122 -122
- package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +52 -52
- package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +148 -148
- package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +27 -27
- package/templates/nextblock-template/app/cms/settings/registration/actions.ts +44 -44
- package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +65 -65
- package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
- package/templates/nextblock-template/app/cms/settings/security/actions.ts +284 -284
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +522 -522
- package/templates/nextblock-template/app/cms/settings/security/page.tsx +33 -33
- package/templates/nextblock-template/app/cms/settings/taxes/page.tsx +21 -21
- package/templates/nextblock-template/app/cms/shipping/page.tsx +20 -20
- package/templates/nextblock-template/app/cms/users/actions.ts +222 -222
- package/templates/nextblock-template/app/cms/users/components/DeleteUserButton.tsx +12 -12
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +44 -44
- package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +253 -253
- package/templates/nextblock-template/app/profile/ProfileAccountSidebar.tsx +73 -73
- package/templates/nextblock-template/app/profile/ProfilePageHeader.tsx +16 -16
- package/templates/nextblock-template/app/profile/ProfilePageMissingState.tsx +9 -9
- package/templates/nextblock-template/app/profile/account-data.ts +37 -37
- package/templates/nextblock-template/app/profile/account-links.ts +22 -22
- package/templates/nextblock-template/app/profile/account-types.ts +11 -11
- package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
- package/templates/nextblock-template/app/profile/orders/[id]/CustomerOrderDetailPageClient.tsx +79 -79
- package/templates/nextblock-template/app/profile/orders/[id]/page.tsx +32 -32
- package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
- package/templates/nextblock-template/app/profile/page.tsx +42 -42
- package/templates/nextblock-template/app/profile/password/PasswordSettingsPageClient.tsx +128 -128
- package/templates/nextblock-template/app/profile/password/actions.ts +59 -59
- package/templates/nextblock-template/app/profile/password/page.tsx +27 -27
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +678 -678
- package/templates/nextblock-template/app/setup/layout.tsx +13 -13
- package/templates/nextblock-template/app/setup/page.tsx +111 -111
- package/templates/nextblock-template/app/sitemap.ts +130 -130
- package/templates/nextblock-template/components/CartDrawerLoader.tsx +7 -7
- package/templates/nextblock-template/components/DeferredCartDrawer.tsx +23 -23
- package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -70
- package/templates/nextblock-template/components/DeferredGoogleTagManager.tsx +70 -70
- package/templates/nextblock-template/components/DeferredSpeedInsights.tsx +69 -69
- package/templates/nextblock-template/components/FeatureImageHero.tsx +47 -47
- package/templates/nextblock-template/components/FooterNavigation.tsx +32 -32
- package/templates/nextblock-template/components/GitHubLoginButton.tsx +15 -15
- package/templates/nextblock-template/components/HtmlScriptExecutor.tsx +47 -47
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
- package/templates/nextblock-template/components/PostCommentsSection.tsx +369 -369
- package/templates/nextblock-template/components/ProductReviewsSection.tsx +419 -419
- package/templates/nextblock-template/components/PublicEnvBootstrap.tsx +30 -30
- package/templates/nextblock-template/components/ResponsiveNav.tsx +14 -14
- package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -182
- package/templates/nextblock-template/components/blocks/PostCardSkeleton.tsx +12 -12
- package/templates/nextblock-template/components/blocks/PostsGridBlock.tsx +12 -12
- package/templates/nextblock-template/components/blocks/ecommerceRendererLoaders.ts +23 -23
- package/templates/nextblock-template/components/blocks/renderers/FormBlockRenderer.tsx +249 -249
- package/templates/nextblock-template/components/blocks/types.ts +7 -7
- package/templates/nextblock-template/components/env-var-warning.tsx +3 -3
- package/templates/nextblock-template/components/form-message.tsx +32 -32
- package/templates/nextblock-template/components/privacy/ConsentBanner.tsx +170 -170
- package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +70 -70
- package/templates/nextblock-template/components/renderers/CachedDynamicLayoutEngine.tsx +28 -28
- package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.test.tsx +166 -166
- package/templates/nextblock-template/components/renderers/DynamicLayoutEngine.tsx +464 -464
- package/templates/nextblock-template/components/submit-button.tsx +23 -23
- package/templates/nextblock-template/components/theme-switcher.tsx +8 -8
- package/templates/nextblock-template/components/visual-editing/DeferredVisualEditing.tsx +21 -21
- package/templates/nextblock-template/context/AuthContext.tsx +23 -23
- package/templates/nextblock-template/context/LanguageContext.tsx +16 -16
- package/templates/nextblock-template/context/language-rest-client.ts +32 -32
- package/templates/nextblock-template/docker/db/init/99-jwt.sql +6 -6
- package/templates/nextblock-template/docker/db/init/99-roles.sql +25 -25
- package/templates/nextblock-template/docker/kong/kong.yml +112 -112
- package/templates/nextblock-template/docs/01-PROJECT-OVERVIEW.md +94 -94
- package/templates/nextblock-template/docs/02-ECOMMERCE-CAPABILITIES.md +364 -364
- package/templates/nextblock-template/docs/03-CMS-AND-EDITOR.md +202 -202
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +246 -246
- package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +176 -176
- package/templates/nextblock-template/docs/07-BLOCK-SDK-AND-EXTENSIBILITY.md +146 -146
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +1319 -1319
- package/templates/nextblock-template/docs/10-CUSTOM-BLOCKS.md +222 -222
- package/templates/nextblock-template/docs/11-SELF-HOSTED-DOCKER.md +173 -173
- package/templates/nextblock-template/docs/12-VERCEL-DEPLOYMENT.md +170 -170
- package/templates/nextblock-template/docs/13-STAYING-UP-TO-DATE.md +151 -151
- package/templates/nextblock-template/docs/README.md +39 -39
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +12505 -12505
- package/templates/nextblock-template/hooks/use-hotkeys.ts +21 -21
- package/templates/nextblock-template/hooks/useGlobalSearch.ts +101 -101
- package/templates/nextblock-template/index.d.ts +7 -7
- package/templates/nextblock-template/lib/app-secrets.ts +39 -39
- package/templates/nextblock-template/lib/auth/cookies.ts +47 -47
- package/templates/nextblock-template/lib/auth/crypto.ts +45 -45
- package/templates/nextblock-template/lib/auth/trustedDevices.ts +92 -92
- package/templates/nextblock-template/lib/auth/twoFactor.ts +208 -208
- package/templates/nextblock-template/lib/auth-redirects.ts +46 -46
- package/templates/nextblock-template/lib/blocks/README.md +13 -13
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +6 -6
- package/templates/nextblock-template/lib/blocks/blockTypes.ts +18 -18
- package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
- package/templates/nextblock-template/lib/cms-transfer/csv.test.ts +100 -77
- package/templates/nextblock-template/lib/cms-transfer/csv.ts +403 -399
- package/templates/nextblock-template/lib/cms-transfer/server.ts +2243 -2243
- package/templates/nextblock-template/lib/cms-transfer/types.ts +145 -145
- package/templates/nextblock-template/lib/config/email-settings.ts +217 -217
- package/templates/nextblock-template/lib/custom-block-definitions.ts +87 -87
- package/templates/nextblock-template/lib/custom-block-r2-upload-shared.ts +178 -178
- package/templates/nextblock-template/lib/custom-block-r2-upload.test.ts +140 -140
- package/templates/nextblock-template/lib/custom-block-r2-upload.ts +88 -88
- package/templates/nextblock-template/lib/custom-block-relation-registry.ts +256 -256
- package/templates/nextblock-template/lib/custom-block-relations.test.ts +227 -227
- package/templates/nextblock-template/lib/custom-block-relations.ts +279 -279
- package/templates/nextblock-template/lib/custom-block-safelist.ts +14 -14
- package/templates/nextblock-template/lib/editor/dynamic-extension-core.test.ts +172 -172
- package/templates/nextblock-template/lib/editor/dynamic-extension-core.ts +213 -213
- package/templates/nextblock-template/lib/editor/dynamic-extension-loader.ts +22 -22
- package/templates/nextblock-template/lib/editor/dynamic-extensions.tsx +193 -193
- package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -133
- package/templates/nextblock-template/lib/email/branding-format.ts +123 -123
- package/templates/nextblock-template/lib/email/branding.ts +76 -76
- package/templates/nextblock-template/lib/full-backup/manifest.test.ts +121 -121
- package/templates/nextblock-template/lib/full-backup/manifest.ts +206 -206
- package/templates/nextblock-template/lib/full-backup/server.ts +743 -743
- package/templates/nextblock-template/lib/logos/active-logo.ts +53 -53
- package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +54 -54
- package/templates/nextblock-template/lib/onboarding/actions.ts +31 -31
- package/templates/nextblock-template/lib/onboarding/status.ts +222 -222
- package/templates/nextblock-template/lib/posts/readTime.ts +60 -60
- package/templates/nextblock-template/lib/privacy/consent-client.ts +57 -57
- package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -64
- package/templates/nextblock-template/lib/privacy/settings.ts +115 -115
- package/templates/nextblock-template/lib/privacy/types.ts +69 -69
- package/templates/nextblock-template/lib/promotions/server.test.ts +74 -74
- package/templates/nextblock-template/lib/promotions/server.ts +741 -741
- package/templates/nextblock-template/lib/resolve-block-relations.test.ts +142 -142
- package/templates/nextblock-template/lib/resolve-block-relations.ts +255 -255
- package/templates/nextblock-template/lib/search/server.ts +585 -585
- package/templates/nextblock-template/lib/search/types.ts +27 -27
- package/templates/nextblock-template/lib/setup/actions.ts +460 -460
- package/templates/nextblock-template/lib/setup/env-status.ts +125 -125
- package/templates/nextblock-template/lib/setup/env-write.ts +111 -111
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +37 -37
- package/templates/nextblock-template/lib/setup/provisioning.ts +59 -59
- package/templates/nextblock-template/lib/setup/schema-apply.ts +408 -408
- package/templates/nextblock-template/lib/setup/system-config.ts +105 -105
- package/templates/nextblock-template/lib/setup/types.ts +18 -18
- package/templates/nextblock-template/lib/site-url.ts +48 -48
- package/templates/nextblock-template/lib/storage/provider.ts +66 -66
- package/templates/nextblock-template/lib/storage/supabase-storage.ts +103 -103
- package/templates/nextblock-template/lib/updates/check-upstream.ts +441 -441
- package/templates/nextblock-template/lib/updates/github-device.ts +206 -206
- package/templates/nextblock-template/lib/updates/repo-identity.ts +56 -56
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/postcss.config.js +6 -6
- package/templates/nextblock-template/scripts/backup.js +115 -115
- package/templates/nextblock-template/scripts/docker-setup.mjs +310 -310
- package/templates/nextblock-template/scripts/restore.js +385 -385
- package/templates/nextblock-template/scripts/verify-cortex-ai-build-widget.tsx +98 -98
- package/templates/nextblock-template/scripts/verify-cortex-ai-generate-blocks.ts +62 -62
- package/templates/nextblock-template/scripts/verify-cortex-ai-global-tools.ts +537 -537
- package/templates/nextblock-template/scripts/verify-cortex-ai-routing.ts +58 -58
- package/templates/nextblock-template/scripts/verify-custom-block-definitions.ts +188 -188
- package/templates/nextblock-template/scripts/verify-dynamic-custom-block-extensions.ts +123 -123
- package/templates/nextblock-template/scripts/verify-dynamic-layout-engine.tsx +133 -133
- package/templates/nextblock-template/scripts/verify-milestone-2-custom-blocks.ts +65 -65
- package/templates/nextblock-template/tailwind.config.js +25 -25
- package/templates/nextblock-template/tools/build-migrate.mjs +209 -209
- package/templates/nextblock-template/tools/configure-supabase-auth.js +282 -282
- package/templates/nextblock-template/types/jsdom.d.ts +6 -6
- package/tsconfig.base.json +3 -3
- package/templates/nextblock-template/tsconfig.tsbuildinfo +0 -1
|
@@ -1,92 +1,92 @@
|
|
|
1
|
-
// Server-only trusted-device ("Remember this device") helpers.
|
|
2
|
-
//
|
|
3
|
-
// Trust is server-validated: the cookie holds only a random token, and a bypass
|
|
4
|
-
// is honoured ONLY when a non-expired user_trusted_devices row matches its
|
|
5
|
-
// SHA-256. Deleting the row instantly revokes trust, regardless of the cookie.
|
|
6
|
-
import { createClient, getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
7
|
-
import { getSecuritySettings } from '../privacy/settings';
|
|
8
|
-
import { randomToken, sha256Hex } from './crypto';
|
|
9
|
-
import {
|
|
10
|
-
TRUSTED_DEVICE_COOKIE,
|
|
11
|
-
clearCookie,
|
|
12
|
-
getCookieValue,
|
|
13
|
-
setSecureCookie,
|
|
14
|
-
} from './cookies';
|
|
15
|
-
|
|
16
|
-
export interface TrustedDeviceRow {
|
|
17
|
-
id: string;
|
|
18
|
-
browser_metadata: string | null;
|
|
19
|
-
created_at: string;
|
|
20
|
-
expires_at: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Mint a trusted-device token, persist its hash, and set the cookie. */
|
|
24
|
-
export async function issueTrustedDevice(
|
|
25
|
-
userId: string,
|
|
26
|
-
browserMetadata: string | null,
|
|
27
|
-
): Promise<void> {
|
|
28
|
-
const { trusted_device_days } = await getSecuritySettings();
|
|
29
|
-
const maxAgeSeconds = trusted_device_days * 24 * 60 * 60;
|
|
30
|
-
const token = randomToken(32);
|
|
31
|
-
const expiresAt = new Date(Date.now() + maxAgeSeconds * 1000).toISOString();
|
|
32
|
-
|
|
33
|
-
const svc = getServiceRoleSupabaseClient();
|
|
34
|
-
const { error } = await svc.from('user_trusted_devices').insert({
|
|
35
|
-
user_id: userId,
|
|
36
|
-
device_hash: sha256Hex(token),
|
|
37
|
-
browser_metadata: browserMetadata ? browserMetadata.slice(0, 500) : null,
|
|
38
|
-
expires_at: expiresAt,
|
|
39
|
-
});
|
|
40
|
-
if (error) {
|
|
41
|
-
console.error('Failed to persist trusted device:', error.message);
|
|
42
|
-
return; // Non-fatal: the user simply isn't remembered.
|
|
43
|
-
}
|
|
44
|
-
await setSecureCookie(TRUSTED_DEVICE_COOKIE, token, maxAgeSeconds);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** True when the current request carries a cookie matching a live trusted row. */
|
|
48
|
-
export async function hasValidTrustedDevice(userId: string): Promise<boolean> {
|
|
49
|
-
const token = await getCookieValue(TRUSTED_DEVICE_COOKIE);
|
|
50
|
-
if (!token) return false;
|
|
51
|
-
const svc = getServiceRoleSupabaseClient();
|
|
52
|
-
const { data } = await svc
|
|
53
|
-
.from('user_trusted_devices')
|
|
54
|
-
.select('id')
|
|
55
|
-
.eq('user_id', userId)
|
|
56
|
-
.eq('device_hash', sha256Hex(token))
|
|
57
|
-
.gt('expires_at', new Date().toISOString())
|
|
58
|
-
.maybeSingle();
|
|
59
|
-
return Boolean(data);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** List the signed-in user's trusted devices (RLS scopes to their own rows). */
|
|
63
|
-
export async function listTrustedDevices(userId: string): Promise<TrustedDeviceRow[]> {
|
|
64
|
-
const supabase = createClient();
|
|
65
|
-
const { data } = await supabase
|
|
66
|
-
.from('user_trusted_devices')
|
|
67
|
-
.select('id, browser_metadata, created_at, expires_at')
|
|
68
|
-
.eq('user_id', userId)
|
|
69
|
-
.order('created_at', { ascending: false });
|
|
70
|
-
return data ?? [];
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Revoke one trusted device the user owns (RLS prevents touching others'). */
|
|
74
|
-
export async function revokeTrustedDevice(userId: string, id: string): Promise<void> {
|
|
75
|
-
const supabase = createClient();
|
|
76
|
-
const { error } = await supabase
|
|
77
|
-
.from('user_trusted_devices')
|
|
78
|
-
.delete()
|
|
79
|
-
.eq('id', id)
|
|
80
|
-
.eq('user_id', userId);
|
|
81
|
-
if (error) {
|
|
82
|
-
console.error('Failed to revoke trusted device:', error.message);
|
|
83
|
-
throw new Error('Failed to revoke device.');
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Drop all of a user's trusted devices and the local cookie (e.g. on MFA disable). */
|
|
88
|
-
export async function revokeAllTrustedDevices(userId: string): Promise<void> {
|
|
89
|
-
const svc = getServiceRoleSupabaseClient();
|
|
90
|
-
await svc.from('user_trusted_devices').delete().eq('user_id', userId);
|
|
91
|
-
await clearCookie(TRUSTED_DEVICE_COOKIE);
|
|
92
|
-
}
|
|
1
|
+
// Server-only trusted-device ("Remember this device") helpers.
|
|
2
|
+
//
|
|
3
|
+
// Trust is server-validated: the cookie holds only a random token, and a bypass
|
|
4
|
+
// is honoured ONLY when a non-expired user_trusted_devices row matches its
|
|
5
|
+
// SHA-256. Deleting the row instantly revokes trust, regardless of the cookie.
|
|
6
|
+
import { createClient, getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
7
|
+
import { getSecuritySettings } from '../privacy/settings';
|
|
8
|
+
import { randomToken, sha256Hex } from './crypto';
|
|
9
|
+
import {
|
|
10
|
+
TRUSTED_DEVICE_COOKIE,
|
|
11
|
+
clearCookie,
|
|
12
|
+
getCookieValue,
|
|
13
|
+
setSecureCookie,
|
|
14
|
+
} from './cookies';
|
|
15
|
+
|
|
16
|
+
export interface TrustedDeviceRow {
|
|
17
|
+
id: string;
|
|
18
|
+
browser_metadata: string | null;
|
|
19
|
+
created_at: string;
|
|
20
|
+
expires_at: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Mint a trusted-device token, persist its hash, and set the cookie. */
|
|
24
|
+
export async function issueTrustedDevice(
|
|
25
|
+
userId: string,
|
|
26
|
+
browserMetadata: string | null,
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const { trusted_device_days } = await getSecuritySettings();
|
|
29
|
+
const maxAgeSeconds = trusted_device_days * 24 * 60 * 60;
|
|
30
|
+
const token = randomToken(32);
|
|
31
|
+
const expiresAt = new Date(Date.now() + maxAgeSeconds * 1000).toISOString();
|
|
32
|
+
|
|
33
|
+
const svc = getServiceRoleSupabaseClient();
|
|
34
|
+
const { error } = await svc.from('user_trusted_devices').insert({
|
|
35
|
+
user_id: userId,
|
|
36
|
+
device_hash: sha256Hex(token),
|
|
37
|
+
browser_metadata: browserMetadata ? browserMetadata.slice(0, 500) : null,
|
|
38
|
+
expires_at: expiresAt,
|
|
39
|
+
});
|
|
40
|
+
if (error) {
|
|
41
|
+
console.error('Failed to persist trusted device:', error.message);
|
|
42
|
+
return; // Non-fatal: the user simply isn't remembered.
|
|
43
|
+
}
|
|
44
|
+
await setSecureCookie(TRUSTED_DEVICE_COOKIE, token, maxAgeSeconds);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when the current request carries a cookie matching a live trusted row. */
|
|
48
|
+
export async function hasValidTrustedDevice(userId: string): Promise<boolean> {
|
|
49
|
+
const token = await getCookieValue(TRUSTED_DEVICE_COOKIE);
|
|
50
|
+
if (!token) return false;
|
|
51
|
+
const svc = getServiceRoleSupabaseClient();
|
|
52
|
+
const { data } = await svc
|
|
53
|
+
.from('user_trusted_devices')
|
|
54
|
+
.select('id')
|
|
55
|
+
.eq('user_id', userId)
|
|
56
|
+
.eq('device_hash', sha256Hex(token))
|
|
57
|
+
.gt('expires_at', new Date().toISOString())
|
|
58
|
+
.maybeSingle();
|
|
59
|
+
return Boolean(data);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** List the signed-in user's trusted devices (RLS scopes to their own rows). */
|
|
63
|
+
export async function listTrustedDevices(userId: string): Promise<TrustedDeviceRow[]> {
|
|
64
|
+
const supabase = createClient();
|
|
65
|
+
const { data } = await supabase
|
|
66
|
+
.from('user_trusted_devices')
|
|
67
|
+
.select('id, browser_metadata, created_at, expires_at')
|
|
68
|
+
.eq('user_id', userId)
|
|
69
|
+
.order('created_at', { ascending: false });
|
|
70
|
+
return data ?? [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Revoke one trusted device the user owns (RLS prevents touching others'). */
|
|
74
|
+
export async function revokeTrustedDevice(userId: string, id: string): Promise<void> {
|
|
75
|
+
const supabase = createClient();
|
|
76
|
+
const { error } = await supabase
|
|
77
|
+
.from('user_trusted_devices')
|
|
78
|
+
.delete()
|
|
79
|
+
.eq('id', id)
|
|
80
|
+
.eq('user_id', userId);
|
|
81
|
+
if (error) {
|
|
82
|
+
console.error('Failed to revoke trusted device:', error.message);
|
|
83
|
+
throw new Error('Failed to revoke device.');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Drop all of a user's trusted devices and the local cookie (e.g. on MFA disable). */
|
|
88
|
+
export async function revokeAllTrustedDevices(userId: string): Promise<void> {
|
|
89
|
+
const svc = getServiceRoleSupabaseClient();
|
|
90
|
+
await svc.from('user_trusted_devices').delete().eq('user_id', userId);
|
|
91
|
+
await clearCookie(TRUSTED_DEVICE_COOKIE);
|
|
92
|
+
}
|
|
@@ -1,208 +1,208 @@
|
|
|
1
|
-
// Server-only two-factor orchestration: email-code challenges, the signed
|
|
2
|
-
// "email second factor satisfied" session cookie, and the gate that decides
|
|
3
|
-
// whether a signed-in user still owes a second factor before reaching the CMS.
|
|
4
|
-
import { createClient, getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
5
|
-
import { generateNumericCode, hmacSign, safeEqual, sha256Hex } from './crypto';
|
|
6
|
-
import {
|
|
7
|
-
TWO_FACTOR_COOKIE,
|
|
8
|
-
clearCookie,
|
|
9
|
-
getCookieValue,
|
|
10
|
-
setSecureCookie,
|
|
11
|
-
} from './cookies';
|
|
12
|
-
import { hasValidTrustedDevice } from './trustedDevices';
|
|
13
|
-
import { getSecuritySettings } from '../privacy/settings';
|
|
14
|
-
|
|
15
|
-
const EMAIL_CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
16
|
-
const TWO_FACTOR_SESSION_TTL_SECONDS = 12 * 60 * 60; // 12 hours
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Create and persist a hashed 6-digit email code and return the RAW code so the
|
|
20
|
-
* caller can email it. Prior unconsumed codes for the user are invalidated.
|
|
21
|
-
*/
|
|
22
|
-
export async function createEmailChallenge(userId: string): Promise<string> {
|
|
23
|
-
const code = generateNumericCode(6);
|
|
24
|
-
const svc = getServiceRoleSupabaseClient();
|
|
25
|
-
const nowIso = new Date().toISOString();
|
|
26
|
-
|
|
27
|
-
// Invalidate any earlier pending codes so only the newest one works.
|
|
28
|
-
await svc
|
|
29
|
-
.from('email_2fa_challenges')
|
|
30
|
-
.update({ consumed_at: nowIso })
|
|
31
|
-
.eq('user_id', userId)
|
|
32
|
-
.is('consumed_at', null);
|
|
33
|
-
|
|
34
|
-
await svc.from('email_2fa_challenges').insert({
|
|
35
|
-
user_id: userId,
|
|
36
|
-
token_hash: sha256Hex(code),
|
|
37
|
-
expires_at: new Date(Date.now() + EMAIL_CODE_TTL_MS).toISOString(),
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
return code;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** True when the user has an unconsumed, unexpired email code awaiting entry. */
|
|
44
|
-
export async function hasPendingEmailChallenge(userId: string): Promise<boolean> {
|
|
45
|
-
const svc = getServiceRoleSupabaseClient();
|
|
46
|
-
const { data } = await svc
|
|
47
|
-
.from('email_2fa_challenges')
|
|
48
|
-
.select('id')
|
|
49
|
-
.eq('user_id', userId)
|
|
50
|
-
.is('consumed_at', null)
|
|
51
|
-
.gt('expires_at', new Date().toISOString())
|
|
52
|
-
.limit(1);
|
|
53
|
-
return Boolean(data && data.length > 0);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Verify a submitted email code against the newest live challenge. */
|
|
57
|
-
export async function verifyEmailChallenge(userId: string, code: string): Promise<boolean> {
|
|
58
|
-
const trimmed = (code || '').trim();
|
|
59
|
-
if (!/^\d{6}$/.test(trimmed)) return false;
|
|
60
|
-
|
|
61
|
-
const svc = getServiceRoleSupabaseClient();
|
|
62
|
-
const nowIso = new Date().toISOString();
|
|
63
|
-
const { data } = await svc
|
|
64
|
-
.from('email_2fa_challenges')
|
|
65
|
-
.select('id, token_hash, expires_at')
|
|
66
|
-
.eq('user_id', userId)
|
|
67
|
-
.is('consumed_at', null)
|
|
68
|
-
.gt('expires_at', nowIso)
|
|
69
|
-
.order('created_at', { ascending: false })
|
|
70
|
-
.limit(5);
|
|
71
|
-
|
|
72
|
-
if (!data || data.length === 0) return false;
|
|
73
|
-
const candidateHash = sha256Hex(trimmed);
|
|
74
|
-
const match = data.find((row) => safeEqual(row.token_hash, candidateHash));
|
|
75
|
-
if (!match) return false;
|
|
76
|
-
|
|
77
|
-
await svc.from('email_2fa_challenges').update({ consumed_at: nowIso }).eq('id', match.id);
|
|
78
|
-
return true;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Issue the signed cookie that marks the email second factor satisfied. */
|
|
82
|
-
export async function issueTwoFactorVerifiedCookie(userId: string): Promise<void> {
|
|
83
|
-
const exp = Date.now() + TWO_FACTOR_SESSION_TTL_SECONDS * 1000;
|
|
84
|
-
const payload = `${userId}.${exp}`;
|
|
85
|
-
await setSecureCookie(
|
|
86
|
-
TWO_FACTOR_COOKIE,
|
|
87
|
-
`${payload}.${hmacSign(payload)}`,
|
|
88
|
-
TWO_FACTOR_SESSION_TTL_SECONDS,
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
export async function clearTwoFactorVerifiedCookie(): Promise<void> {
|
|
93
|
-
await clearCookie(TWO_FACTOR_COOKIE);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Validate the signed email-2FA cookie for a specific user. */
|
|
97
|
-
export async function hasValidTwoFactorCookie(userId: string): Promise<boolean> {
|
|
98
|
-
const raw = await getCookieValue(TWO_FACTOR_COOKIE);
|
|
99
|
-
if (!raw) return false;
|
|
100
|
-
const parts = raw.split('.');
|
|
101
|
-
if (parts.length !== 3) return false;
|
|
102
|
-
const [uid, expStr, sig] = parts;
|
|
103
|
-
if (uid !== userId) return false;
|
|
104
|
-
const exp = Number(expStr);
|
|
105
|
-
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
|
106
|
-
return safeEqual(hmacSign(`${uid}.${expStr}`), sig);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export type TwoFactorStatus =
|
|
110
|
-
| 'not_required'
|
|
111
|
-
| 'satisfied'
|
|
112
|
-
| 'totp_required'
|
|
113
|
-
| 'email_required';
|
|
114
|
-
|
|
115
|
-
export interface TwoFactorEvaluation {
|
|
116
|
-
status: TwoFactorStatus;
|
|
117
|
-
userId: string | null;
|
|
118
|
-
mfaType: 'totp' | 'email' | null;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Whether to surface the "set up two-factor authentication" reminder banner to the
|
|
123
|
-
* currently signed-in staff user. True only when: not in sandbox, the user is an
|
|
124
|
-
* ADMIN/WRITER, the global "encourage staff to enable 2FA" policy is on, and the user
|
|
125
|
-
* has not enrolled a second factor. Best-effort — any failure resolves to no banner.
|
|
126
|
-
*/
|
|
127
|
-
export async function getStaffTwoFactorReminder(): Promise<boolean> {
|
|
128
|
-
// Sandbox runs on a shared demo account; never nag it (the page is disabled there too).
|
|
129
|
-
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') return false;
|
|
130
|
-
|
|
131
|
-
try {
|
|
132
|
-
const supabase = createClient();
|
|
133
|
-
const {
|
|
134
|
-
data: { user },
|
|
135
|
-
} = await supabase.auth.getUser();
|
|
136
|
-
if (!user) return false;
|
|
137
|
-
|
|
138
|
-
const { data: profile } = await supabase
|
|
139
|
-
.from('profiles')
|
|
140
|
-
.select('role')
|
|
141
|
-
.eq('id', user.id)
|
|
142
|
-
.maybeSingle();
|
|
143
|
-
const role = profile?.role;
|
|
144
|
-
if (role !== 'ADMIN' && role !== 'WRITER') return false;
|
|
145
|
-
|
|
146
|
-
const { enforce_staff_2fa } = await getSecuritySettings();
|
|
147
|
-
if (!enforce_staff_2fa) return false;
|
|
148
|
-
|
|
149
|
-
const { data: settings } = await supabase
|
|
150
|
-
.from('user_security_settings')
|
|
151
|
-
.select('mfa_enabled, mfa_type')
|
|
152
|
-
.eq('user_id', user.id)
|
|
153
|
-
.maybeSingle();
|
|
154
|
-
|
|
155
|
-
return !(settings?.mfa_enabled && settings?.mfa_type);
|
|
156
|
-
} catch {
|
|
157
|
-
return false;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Decide whether the currently signed-in user still owes a second factor.
|
|
163
|
-
* Used both to route sign-in and to guard /cms server-side.
|
|
164
|
-
*/
|
|
165
|
-
export async function evaluateTwoFactor(): Promise<TwoFactorEvaluation> {
|
|
166
|
-
const supabase = createClient();
|
|
167
|
-
const {
|
|
168
|
-
data: { user },
|
|
169
|
-
} = await supabase.auth.getUser();
|
|
170
|
-
if (!user) return { status: 'not_required', userId: null, mfaType: null };
|
|
171
|
-
|
|
172
|
-
// Sandbox/demo mode runs on a single shared public account. Never enforce a
|
|
173
|
-
// second factor there — otherwise anyone enrolling the demo account in 2FA
|
|
174
|
-
// would lock out every other visitor until the next sandbox reset.
|
|
175
|
-
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
176
|
-
return { status: 'satisfied', userId: user.id, mfaType: null };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const { data: settings } = await supabase
|
|
180
|
-
.from('user_security_settings')
|
|
181
|
-
.select('mfa_enabled, mfa_type')
|
|
182
|
-
.eq('user_id', user.id)
|
|
183
|
-
.maybeSingle();
|
|
184
|
-
|
|
185
|
-
const mfaType = (settings?.mfa_type as 'totp' | 'email' | null) ?? null;
|
|
186
|
-
if (!settings || !settings.mfa_enabled || !mfaType) {
|
|
187
|
-
return { status: 'satisfied', userId: user.id, mfaType: null };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// A live trusted device bypasses the second factor entirely.
|
|
191
|
-
if (await hasValidTrustedDevice(user.id)) {
|
|
192
|
-
return { status: 'satisfied', userId: user.id, mfaType };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
if (mfaType === 'totp') {
|
|
196
|
-
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
|
197
|
-
if (aal?.currentLevel === 'aal2') {
|
|
198
|
-
return { status: 'satisfied', userId: user.id, mfaType };
|
|
199
|
-
}
|
|
200
|
-
return { status: 'totp_required', userId: user.id, mfaType };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Email path.
|
|
204
|
-
if (await hasValidTwoFactorCookie(user.id)) {
|
|
205
|
-
return { status: 'satisfied', userId: user.id, mfaType };
|
|
206
|
-
}
|
|
207
|
-
return { status: 'email_required', userId: user.id, mfaType };
|
|
208
|
-
}
|
|
1
|
+
// Server-only two-factor orchestration: email-code challenges, the signed
|
|
2
|
+
// "email second factor satisfied" session cookie, and the gate that decides
|
|
3
|
+
// whether a signed-in user still owes a second factor before reaching the CMS.
|
|
4
|
+
import { createClient, getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
5
|
+
import { generateNumericCode, hmacSign, safeEqual, sha256Hex } from './crypto';
|
|
6
|
+
import {
|
|
7
|
+
TWO_FACTOR_COOKIE,
|
|
8
|
+
clearCookie,
|
|
9
|
+
getCookieValue,
|
|
10
|
+
setSecureCookie,
|
|
11
|
+
} from './cookies';
|
|
12
|
+
import { hasValidTrustedDevice } from './trustedDevices';
|
|
13
|
+
import { getSecuritySettings } from '../privacy/settings';
|
|
14
|
+
|
|
15
|
+
const EMAIL_CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
16
|
+
const TWO_FACTOR_SESSION_TTL_SECONDS = 12 * 60 * 60; // 12 hours
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create and persist a hashed 6-digit email code and return the RAW code so the
|
|
20
|
+
* caller can email it. Prior unconsumed codes for the user are invalidated.
|
|
21
|
+
*/
|
|
22
|
+
export async function createEmailChallenge(userId: string): Promise<string> {
|
|
23
|
+
const code = generateNumericCode(6);
|
|
24
|
+
const svc = getServiceRoleSupabaseClient();
|
|
25
|
+
const nowIso = new Date().toISOString();
|
|
26
|
+
|
|
27
|
+
// Invalidate any earlier pending codes so only the newest one works.
|
|
28
|
+
await svc
|
|
29
|
+
.from('email_2fa_challenges')
|
|
30
|
+
.update({ consumed_at: nowIso })
|
|
31
|
+
.eq('user_id', userId)
|
|
32
|
+
.is('consumed_at', null);
|
|
33
|
+
|
|
34
|
+
await svc.from('email_2fa_challenges').insert({
|
|
35
|
+
user_id: userId,
|
|
36
|
+
token_hash: sha256Hex(code),
|
|
37
|
+
expires_at: new Date(Date.now() + EMAIL_CODE_TTL_MS).toISOString(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return code;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True when the user has an unconsumed, unexpired email code awaiting entry. */
|
|
44
|
+
export async function hasPendingEmailChallenge(userId: string): Promise<boolean> {
|
|
45
|
+
const svc = getServiceRoleSupabaseClient();
|
|
46
|
+
const { data } = await svc
|
|
47
|
+
.from('email_2fa_challenges')
|
|
48
|
+
.select('id')
|
|
49
|
+
.eq('user_id', userId)
|
|
50
|
+
.is('consumed_at', null)
|
|
51
|
+
.gt('expires_at', new Date().toISOString())
|
|
52
|
+
.limit(1);
|
|
53
|
+
return Boolean(data && data.length > 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Verify a submitted email code against the newest live challenge. */
|
|
57
|
+
export async function verifyEmailChallenge(userId: string, code: string): Promise<boolean> {
|
|
58
|
+
const trimmed = (code || '').trim();
|
|
59
|
+
if (!/^\d{6}$/.test(trimmed)) return false;
|
|
60
|
+
|
|
61
|
+
const svc = getServiceRoleSupabaseClient();
|
|
62
|
+
const nowIso = new Date().toISOString();
|
|
63
|
+
const { data } = await svc
|
|
64
|
+
.from('email_2fa_challenges')
|
|
65
|
+
.select('id, token_hash, expires_at')
|
|
66
|
+
.eq('user_id', userId)
|
|
67
|
+
.is('consumed_at', null)
|
|
68
|
+
.gt('expires_at', nowIso)
|
|
69
|
+
.order('created_at', { ascending: false })
|
|
70
|
+
.limit(5);
|
|
71
|
+
|
|
72
|
+
if (!data || data.length === 0) return false;
|
|
73
|
+
const candidateHash = sha256Hex(trimmed);
|
|
74
|
+
const match = data.find((row) => safeEqual(row.token_hash, candidateHash));
|
|
75
|
+
if (!match) return false;
|
|
76
|
+
|
|
77
|
+
await svc.from('email_2fa_challenges').update({ consumed_at: nowIso }).eq('id', match.id);
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Issue the signed cookie that marks the email second factor satisfied. */
|
|
82
|
+
export async function issueTwoFactorVerifiedCookie(userId: string): Promise<void> {
|
|
83
|
+
const exp = Date.now() + TWO_FACTOR_SESSION_TTL_SECONDS * 1000;
|
|
84
|
+
const payload = `${userId}.${exp}`;
|
|
85
|
+
await setSecureCookie(
|
|
86
|
+
TWO_FACTOR_COOKIE,
|
|
87
|
+
`${payload}.${hmacSign(payload)}`,
|
|
88
|
+
TWO_FACTOR_SESSION_TTL_SECONDS,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function clearTwoFactorVerifiedCookie(): Promise<void> {
|
|
93
|
+
await clearCookie(TWO_FACTOR_COOKIE);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Validate the signed email-2FA cookie for a specific user. */
|
|
97
|
+
export async function hasValidTwoFactorCookie(userId: string): Promise<boolean> {
|
|
98
|
+
const raw = await getCookieValue(TWO_FACTOR_COOKIE);
|
|
99
|
+
if (!raw) return false;
|
|
100
|
+
const parts = raw.split('.');
|
|
101
|
+
if (parts.length !== 3) return false;
|
|
102
|
+
const [uid, expStr, sig] = parts;
|
|
103
|
+
if (uid !== userId) return false;
|
|
104
|
+
const exp = Number(expStr);
|
|
105
|
+
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
|
106
|
+
return safeEqual(hmacSign(`${uid}.${expStr}`), sig);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type TwoFactorStatus =
|
|
110
|
+
| 'not_required'
|
|
111
|
+
| 'satisfied'
|
|
112
|
+
| 'totp_required'
|
|
113
|
+
| 'email_required';
|
|
114
|
+
|
|
115
|
+
export interface TwoFactorEvaluation {
|
|
116
|
+
status: TwoFactorStatus;
|
|
117
|
+
userId: string | null;
|
|
118
|
+
mfaType: 'totp' | 'email' | null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether to surface the "set up two-factor authentication" reminder banner to the
|
|
123
|
+
* currently signed-in staff user. True only when: not in sandbox, the user is an
|
|
124
|
+
* ADMIN/WRITER, the global "encourage staff to enable 2FA" policy is on, and the user
|
|
125
|
+
* has not enrolled a second factor. Best-effort — any failure resolves to no banner.
|
|
126
|
+
*/
|
|
127
|
+
export async function getStaffTwoFactorReminder(): Promise<boolean> {
|
|
128
|
+
// Sandbox runs on a shared demo account; never nag it (the page is disabled there too).
|
|
129
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') return false;
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const supabase = createClient();
|
|
133
|
+
const {
|
|
134
|
+
data: { user },
|
|
135
|
+
} = await supabase.auth.getUser();
|
|
136
|
+
if (!user) return false;
|
|
137
|
+
|
|
138
|
+
const { data: profile } = await supabase
|
|
139
|
+
.from('profiles')
|
|
140
|
+
.select('role')
|
|
141
|
+
.eq('id', user.id)
|
|
142
|
+
.maybeSingle();
|
|
143
|
+
const role = profile?.role;
|
|
144
|
+
if (role !== 'ADMIN' && role !== 'WRITER') return false;
|
|
145
|
+
|
|
146
|
+
const { enforce_staff_2fa } = await getSecuritySettings();
|
|
147
|
+
if (!enforce_staff_2fa) return false;
|
|
148
|
+
|
|
149
|
+
const { data: settings } = await supabase
|
|
150
|
+
.from('user_security_settings')
|
|
151
|
+
.select('mfa_enabled, mfa_type')
|
|
152
|
+
.eq('user_id', user.id)
|
|
153
|
+
.maybeSingle();
|
|
154
|
+
|
|
155
|
+
return !(settings?.mfa_enabled && settings?.mfa_type);
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Decide whether the currently signed-in user still owes a second factor.
|
|
163
|
+
* Used both to route sign-in and to guard /cms server-side.
|
|
164
|
+
*/
|
|
165
|
+
export async function evaluateTwoFactor(): Promise<TwoFactorEvaluation> {
|
|
166
|
+
const supabase = createClient();
|
|
167
|
+
const {
|
|
168
|
+
data: { user },
|
|
169
|
+
} = await supabase.auth.getUser();
|
|
170
|
+
if (!user) return { status: 'not_required', userId: null, mfaType: null };
|
|
171
|
+
|
|
172
|
+
// Sandbox/demo mode runs on a single shared public account. Never enforce a
|
|
173
|
+
// second factor there — otherwise anyone enrolling the demo account in 2FA
|
|
174
|
+
// would lock out every other visitor until the next sandbox reset.
|
|
175
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
176
|
+
return { status: 'satisfied', userId: user.id, mfaType: null };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { data: settings } = await supabase
|
|
180
|
+
.from('user_security_settings')
|
|
181
|
+
.select('mfa_enabled, mfa_type')
|
|
182
|
+
.eq('user_id', user.id)
|
|
183
|
+
.maybeSingle();
|
|
184
|
+
|
|
185
|
+
const mfaType = (settings?.mfa_type as 'totp' | 'email' | null) ?? null;
|
|
186
|
+
if (!settings || !settings.mfa_enabled || !mfaType) {
|
|
187
|
+
return { status: 'satisfied', userId: user.id, mfaType: null };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A live trusted device bypasses the second factor entirely.
|
|
191
|
+
if (await hasValidTrustedDevice(user.id)) {
|
|
192
|
+
return { status: 'satisfied', userId: user.id, mfaType };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (mfaType === 'totp') {
|
|
196
|
+
const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
|
|
197
|
+
if (aal?.currentLevel === 'aal2') {
|
|
198
|
+
return { status: 'satisfied', userId: user.id, mfaType };
|
|
199
|
+
}
|
|
200
|
+
return { status: 'totp_required', userId: user.id, mfaType };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Email path.
|
|
204
|
+
if (await hasValidTwoFactorCookie(user.id)) {
|
|
205
|
+
return { status: 'satisfied', userId: user.id, mfaType };
|
|
206
|
+
}
|
|
207
|
+
return { status: 'email_required', userId: user.id, mfaType };
|
|
208
|
+
}
|