create-nextblock 0.14.1 → 0.14.4
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/page.tsx +46 -46
- 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/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/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 +5881 -5623
- 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/CmsClientLayout.tsx +2 -2
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +17 -22
- package/templates/nextblock-template/app/cms/blocks/components/CustomBlockEditorPreview.tsx +160 -160
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +13 -19
- 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/HeadingBlockEditor.tsx +45 -34
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +4 -2
- 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/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/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/actions.ts +226 -226
- 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/navigation/components/NavigationMenuDnd.tsx +3 -3
- 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/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/page.tsx +24 -24
- package/templates/nextblock-template/app/cms/settings/cortex-ai/SandboxCortexAiSettingsClient.tsx +688 -688
- 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/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/global-css/components/ThemeEditor.tsx +382 -0
- package/templates/nextblock-template/app/cms/settings/global-css/components/ThemeManager.tsx +267 -0
- package/templates/nextblock-template/app/cms/settings/global-css/page.tsx +40 -24
- package/templates/nextblock-template/app/cms/settings/global-css/theme-actions.ts +259 -0
- package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -26
- 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 +23 -21
- 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/page.tsx +27 -27
- package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -27
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +9 -7
- 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/components/DeleteUserButton.tsx +12 -12
- package/templates/nextblock-template/app/layout.tsx +49 -0
- package/templates/nextblock-template/app/lib/site-settings.ts +105 -105
- 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-links.ts +22 -22
- package/templates/nextblock-template/app/profile/orders/CustomerOrdersPageClient.tsx +124 -124
- package/templates/nextblock-template/app/profile/orders/page.tsx +19 -19
- 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/providers.tsx +16 -4
- 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/HtmlScriptExecutor.tsx +47 -47
- package/templates/nextblock-template/components/LanguageSwitcher.tsx +2 -2
- 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/renderers/HeadingBlockRenderer.tsx +7 -10
- 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/media/YouTubeFacade.tsx +105 -105
- package/templates/nextblock-template/components/media/youtube-embed-replace.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 +471 -471
- package/templates/nextblock-template/components/submit-button.tsx +23 -23
- package/templates/nextblock-template/components/theme-icon.tsx +78 -0
- package/templates/nextblock-template/components/theme-switcher.tsx +77 -82
- 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/ThemeCatalogContext.tsx +44 -0
- 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 +279 -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/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/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-redirects.ts +46 -46
- package/templates/nextblock-template/lib/blocks/README.md +13 -13
- package/templates/nextblock-template/lib/blocks/blockColors.test.ts +114 -0
- package/templates/nextblock-template/lib/blocks/blockColors.ts +132 -0
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +17 -1
- package/templates/nextblock-template/lib/botProtection/verify.ts +134 -134
- 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/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-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/media/youtube.ts +99 -99
- 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/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 +15 -10
- 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/themes/buildThemeCss.test.ts +163 -0
- package/templates/nextblock-template/lib/themes/buildThemeCss.ts +124 -0
- package/templates/nextblock-template/lib/themes/tokenColor.ts +31 -0
- package/templates/nextblock-template/lib/themes/tokens.ts +143 -0
- 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/next-env.d.ts +2 -2
- 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/scripts/verify-site-themes.ts +63 -0
- 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/tsconfig.tsbuildinfo +1 -1
- package/templates/nextblock-template/types/jsdom.d.ts +6 -6
- package/tsconfig.base.json +3 -3
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
TEXT_COLOR_CLASSES,
|
|
4
|
+
TEXT_COLOR_TOKENS,
|
|
5
|
+
TEXT_COLOR_TOKEN_OPTIONS,
|
|
6
|
+
isCustomCssColor,
|
|
7
|
+
isTextColorToken,
|
|
8
|
+
resolveTextAlign,
|
|
9
|
+
resolveTextColor,
|
|
10
|
+
} from './blockColors';
|
|
11
|
+
import { HeadingBlockSchema } from './blockRegistry';
|
|
12
|
+
|
|
13
|
+
describe('resolveTextColor', () => {
|
|
14
|
+
it('maps theme tokens to a static class and no inline style', () => {
|
|
15
|
+
expect(resolveTextColor('primary')).toEqual({ className: 'text-primary' });
|
|
16
|
+
expect(resolveTextColor('foreground')).toEqual({ className: 'text-foreground' });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('maps "muted" to the readable foreground pair, not the surface colour', () => {
|
|
20
|
+
// Regression: the renderer used to emit `text-muted` (hsl(var(--muted)),
|
|
21
|
+
// a near-white surface) while both CMS previews used muted-foreground.
|
|
22
|
+
expect(resolveTextColor('muted')).toEqual({ className: 'text-muted-foreground' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('renders custom colours as an inline style', () => {
|
|
26
|
+
expect(resolveTextColor('#FF8800')).toEqual({ style: { color: '#FF8800' } });
|
|
27
|
+
expect(resolveTextColor('rgba(255, 136, 0, 0.8)')).toEqual({ style: { color: 'rgba(255, 136, 0, 0.8)' } });
|
|
28
|
+
expect(resolveTextColor('hsl(28, 100%, 50%)')).toEqual({ style: { color: 'hsl(28, 100%, 50%)' } });
|
|
29
|
+
expect(resolveTextColor('#f80')).toEqual({ style: { color: '#f80' } });
|
|
30
|
+
expect(resolveTextColor('#FF880080')).toEqual({ style: { color: '#FF880080' } });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('trims incidental whitespace', () => {
|
|
34
|
+
expect(resolveTextColor(' primary ')).toEqual({ className: 'text-primary' });
|
|
35
|
+
expect(resolveTextColor(' #FF8800 ')).toEqual({ style: { color: '#FF8800' } });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('degrades to inherited colour rather than emitting a bogus class', () => {
|
|
39
|
+
expect(resolveTextColor(undefined)).toEqual({});
|
|
40
|
+
expect(resolveTextColor(null)).toEqual({});
|
|
41
|
+
expect(resolveTextColor('')).toEqual({});
|
|
42
|
+
expect(resolveTextColor('chartreuse')).toEqual({});
|
|
43
|
+
expect(resolveTextColor('javascript:alert(1)')).toEqual({});
|
|
44
|
+
expect(resolveTextColor('url(https://evil.test/x.png)')).toEqual({});
|
|
45
|
+
expect(resolveTextColor('red; background: url(x)')).toEqual({});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('never emits an interpolated class name', () => {
|
|
49
|
+
for (const token of TEXT_COLOR_TOKENS) {
|
|
50
|
+
const { className } = resolveTextColor(token);
|
|
51
|
+
expect(className).toBe(TEXT_COLOR_CLASSES[token]);
|
|
52
|
+
expect(className?.startsWith('text-')).toBe(true);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('resolveTextAlign', () => {
|
|
58
|
+
it('covers every alignment the schema allows', () => {
|
|
59
|
+
expect(resolveTextAlign('left')).toBe('text-left');
|
|
60
|
+
expect(resolveTextAlign('center')).toBe('text-center');
|
|
61
|
+
expect(resolveTextAlign('right')).toBe('text-right');
|
|
62
|
+
expect(resolveTextAlign('justify')).toBe('text-justify');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('ignores unknown values', () => {
|
|
66
|
+
expect(resolveTextAlign(undefined)).toBeUndefined();
|
|
67
|
+
expect(resolveTextAlign('sideways')).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('guards', () => {
|
|
72
|
+
it('distinguishes tokens from custom colours', () => {
|
|
73
|
+
expect(isTextColorToken('accent')).toBe(true);
|
|
74
|
+
expect(isTextColorToken('#FFF')).toBe(false);
|
|
75
|
+
expect(isCustomCssColor('#FFF')).toBe(true);
|
|
76
|
+
expect(isCustomCssColor('accent')).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('token options', () => {
|
|
81
|
+
it('exposes a label and swatch for every token', () => {
|
|
82
|
+
expect(TEXT_COLOR_TOKEN_OPTIONS).toHaveLength(TEXT_COLOR_TOKENS.length);
|
|
83
|
+
for (const option of TEXT_COLOR_TOKEN_OPTIONS) {
|
|
84
|
+
expect(option.label).toBeTruthy();
|
|
85
|
+
expect(option.cssColor).toMatch(/^hsl\(var\(--[a-z-]+\)\)$/);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('HeadingBlockSchema.textColor', () => {
|
|
91
|
+
const base = { level: 2 as const, text_content: 'Hello' };
|
|
92
|
+
|
|
93
|
+
it('still accepts every previously stored token', () => {
|
|
94
|
+
for (const token of ['primary', 'secondary', 'accent', 'muted', 'destructive', 'background']) {
|
|
95
|
+
expect(HeadingBlockSchema.safeParse({ ...base, textColor: token }).success).toBe(true);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('accepts custom colours from the picker', () => {
|
|
100
|
+
for (const color of ['#FF8800', '#f80', '#FF880080', 'rgb(1,2,3)', 'rgba(1,2,3,0.5)', 'hsl(28, 100%, 50%)']) {
|
|
101
|
+
expect(HeadingBlockSchema.safeParse({ ...base, textColor: color }).success).toBe(true);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects values that are neither a token nor a colour', () => {
|
|
106
|
+
for (const bad of ['nonsense', 'url(x)', 'red', '']) {
|
|
107
|
+
expect(HeadingBlockSchema.safeParse({ ...base, textColor: bad }).success).toBe(false);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('stays optional', () => {
|
|
112
|
+
expect(HeadingBlockSchema.safeParse(base).success).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Text-colour model shared by the block renderers and the CMS previews.
|
|
5
|
+
*
|
|
6
|
+
* A colour is stored as EITHER a theme token keyword (`"primary"`) — which keeps
|
|
7
|
+
* following the active theme and dark mode — OR a literal CSS colour emitted by
|
|
8
|
+
* the colour picker (`"#FF8800"`, `"rgba(...)"`, `"hsl(...)"`). Tokens render as
|
|
9
|
+
* a static Tailwind class; literals render as an inline style.
|
|
10
|
+
*
|
|
11
|
+
* Keep this the single source of truth: HeadingBlockRenderer, EditableBlock and
|
|
12
|
+
* ColumnEditor previously each carried their own map and had drifted apart
|
|
13
|
+
* (`muted` rendered as the near-invisible `text-muted` on the live site while
|
|
14
|
+
* both previews used `text-muted-foreground`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const TEXT_COLOR_TOKENS = [
|
|
18
|
+
'foreground',
|
|
19
|
+
'primary',
|
|
20
|
+
'secondary',
|
|
21
|
+
'accent',
|
|
22
|
+
'muted',
|
|
23
|
+
'destructive',
|
|
24
|
+
'background',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type TextColorToken = (typeof TEXT_COLOR_TOKENS)[number];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Token -> Tailwind class.
|
|
31
|
+
*
|
|
32
|
+
* These must be written as complete literals, never interpolated. Tailwind v4
|
|
33
|
+
* ignores the `safelist` key in the legacy JS config (libs/ui/tailwind.config.js
|
|
34
|
+
* still carries one, but v4 never reads it) — a class exists only if the scanner
|
|
35
|
+
* finds the whole string in a source file. Listing them here is what guarantees
|
|
36
|
+
* they are emitted.
|
|
37
|
+
*/
|
|
38
|
+
export const TEXT_COLOR_CLASSES: Record<TextColorToken, string> = {
|
|
39
|
+
foreground: 'text-foreground',
|
|
40
|
+
primary: 'text-primary',
|
|
41
|
+
secondary: 'text-secondary',
|
|
42
|
+
accent: 'text-accent',
|
|
43
|
+
// `--muted` is a near-white surface colour; the readable pair is its foreground.
|
|
44
|
+
muted: 'text-muted-foreground',
|
|
45
|
+
destructive: 'text-destructive',
|
|
46
|
+
background: 'text-background',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Token -> CSS colour, for painting swatches in the editor. */
|
|
50
|
+
export const TEXT_COLOR_SWATCHES: Record<TextColorToken, string> = {
|
|
51
|
+
foreground: 'hsl(var(--foreground))',
|
|
52
|
+
primary: 'hsl(var(--primary))',
|
|
53
|
+
secondary: 'hsl(var(--secondary))',
|
|
54
|
+
accent: 'hsl(var(--accent))',
|
|
55
|
+
muted: 'hsl(var(--muted-foreground))',
|
|
56
|
+
destructive: 'hsl(var(--destructive))',
|
|
57
|
+
background: 'hsl(var(--background))',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const TEXT_COLOR_LABELS: Record<TextColorToken, string> = {
|
|
61
|
+
foreground: 'Default text',
|
|
62
|
+
primary: 'Primary',
|
|
63
|
+
secondary: 'Secondary',
|
|
64
|
+
accent: 'Accent',
|
|
65
|
+
muted: 'Muted',
|
|
66
|
+
destructive: 'Destructive',
|
|
67
|
+
background: 'Background',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Token options in the shape `ColorField` expects. */
|
|
71
|
+
export const TEXT_COLOR_TOKEN_OPTIONS = TEXT_COLOR_TOKENS.map((token) => ({
|
|
72
|
+
value: token,
|
|
73
|
+
label: TEXT_COLOR_LABELS[token],
|
|
74
|
+
cssColor: TEXT_COLOR_SWATCHES[token],
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Accepts what the colour picker can emit. Deliberately permissive on the inner
|
|
79
|
+
* grammar — the picker only produces well-formed values, and being strict here
|
|
80
|
+
* would reject legitimate colours authored by hand or by Cortex AI.
|
|
81
|
+
*/
|
|
82
|
+
export const CSS_COLOR_PATTERN =
|
|
83
|
+
/^(#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba?\([^)]+\)|hsla?\([^)]+\))$/i;
|
|
84
|
+
|
|
85
|
+
export function isTextColorToken(value: unknown): value is TextColorToken {
|
|
86
|
+
return typeof value === 'string' && (TEXT_COLOR_TOKENS as readonly string[]).includes(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isCustomCssColor(value: unknown): value is string {
|
|
90
|
+
return typeof value === 'string' && CSS_COLOR_PATTERN.test(value.trim());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ResolvedTextColor {
|
|
94
|
+
className?: string;
|
|
95
|
+
style?: CSSProperties;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Turn a stored colour into render props. Unknown values resolve to `{}` so a
|
|
100
|
+
* stale or hand-edited value degrades to the inherited colour rather than
|
|
101
|
+
* emitting a bogus Tailwind class.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveTextColor(value: string | null | undefined): ResolvedTextColor {
|
|
104
|
+
if (!value) return {};
|
|
105
|
+
const trimmed = value.trim();
|
|
106
|
+
if (isTextColorToken(trimmed)) {
|
|
107
|
+
return { className: TEXT_COLOR_CLASSES[trimmed] };
|
|
108
|
+
}
|
|
109
|
+
if (isCustomCssColor(trimmed)) {
|
|
110
|
+
return { style: { color: trimmed } };
|
|
111
|
+
}
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Text alignment, also as literals. `text-justify` currently only survives
|
|
117
|
+
* because the string happens to appear in a regex in libs/editor/src/lib/kit.ts;
|
|
118
|
+
* pinning it here removes that accidental dependency.
|
|
119
|
+
*/
|
|
120
|
+
export const TEXT_ALIGN_CLASSES = {
|
|
121
|
+
left: 'text-left',
|
|
122
|
+
center: 'text-center',
|
|
123
|
+
right: 'text-right',
|
|
124
|
+
justify: 'text-justify',
|
|
125
|
+
} as const;
|
|
126
|
+
|
|
127
|
+
export type TextAlign = keyof typeof TEXT_ALIGN_CLASSES;
|
|
128
|
+
|
|
129
|
+
export function resolveTextAlign(value: string | null | undefined): string | undefined {
|
|
130
|
+
if (!value) return undefined;
|
|
131
|
+
return TEXT_ALIGN_CLASSES[value as TextAlign];
|
|
132
|
+
}
|
|
@@ -2,6 +2,7 @@ import { z } from '../zod-config';
|
|
|
2
2
|
import { TestimonialBlockConfig, TestimonialBlockContent } from '../../components/blocks/TestimonialBlock';
|
|
3
3
|
import { ProductGridBlockSchema, ProductGridBlockContent, FeaturedProductBlockSchema, FeaturedProductBlockContent, CartBlockSchema, CartBlockContent, CheckoutBlockSchema, CheckoutBlockContent, ProductDetailsBlockSchema, ProductDetailsBlockContent } from './ecommerce-block-schemas';
|
|
4
4
|
import { availableBlockTypes, type BlockType } from './blockTypes';
|
|
5
|
+
import { CSS_COLOR_PATTERN, TEXT_COLOR_TOKENS } from './blockColors';
|
|
5
6
|
export { availableBlockTypes, type BlockType } from './blockTypes';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -19,11 +20,26 @@ export const TextBlockSchema = z.object({
|
|
|
19
20
|
});
|
|
20
21
|
export type TextBlockContent = z.infer<typeof TextBlockSchema>;
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* A text colour is either a theme token (keeps following the theme and dark
|
|
25
|
+
* mode) or a literal CSS colour from the colour picker. Existing rows only ever
|
|
26
|
+
* hold tokens, so widening the union is backward compatible.
|
|
27
|
+
* See `./blockColors` for the token -> class mapping used at render time.
|
|
28
|
+
*/
|
|
29
|
+
export const TextColorSchema = z
|
|
30
|
+
.union([
|
|
31
|
+
z.enum(TEXT_COLOR_TOKENS),
|
|
32
|
+
z.string().regex(CSS_COLOR_PATTERN, 'Must be a hex, rgb(a) or hsl(a) colour'),
|
|
33
|
+
])
|
|
34
|
+
.describe(
|
|
35
|
+
`A theme token (${TEXT_COLOR_TOKENS.join(', ')}) or a custom CSS colour such as "#FF8800", "rgba(255,136,0,0.8)" or "hsl(28, 100%, 50%)"`,
|
|
36
|
+
);
|
|
37
|
+
|
|
22
38
|
export const HeadingBlockSchema = z.object({
|
|
23
39
|
level: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5), z.literal(6)]).describe('Heading level (1-6)'),
|
|
24
40
|
text_content: z.string().describe('The text content of the heading'),
|
|
25
41
|
textAlign: z.enum(['left', 'center', 'right', 'justify']).optional().describe('Text alignment'),
|
|
26
|
-
textColor:
|
|
42
|
+
textColor: TextColorSchema.optional().describe('Color of the heading text'),
|
|
27
43
|
});
|
|
28
44
|
export type HeadingBlockContent = z.infer<typeof HeadingBlockSchema>;
|
|
29
45
|
|
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
// lib/botProtection/verify.ts
|
|
2
|
-
//
|
|
3
|
-
// Shared server-side bot-protection verification: the honeypot check plus
|
|
4
|
-
// Cloudflare Turnstile / Google reCAPTCHA token verification. This is the single
|
|
5
|
-
// source of truth used by BOTH the page contact-form handler
|
|
6
|
-
// (app/actions/formActions.ts) and the account-signup action (app/actions.ts).
|
|
7
|
-
//
|
|
8
|
-
// Server-only: it reads the RLS-bypassing service-role client, which throws if
|
|
9
|
-
// imported into a Client Component.
|
|
10
|
-
|
|
11
|
-
import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
12
|
-
|
|
13
|
-
export type BotProtectionProvider = 'none' | 'turnstile' | 'recaptcha';
|
|
14
|
-
|
|
15
|
-
// Shared field names — the client widgets emit these, the verifier reads them.
|
|
16
|
-
export const HONEYPOT_FIELD = 'verification_secondary_email';
|
|
17
|
-
export const TURNSTILE_TOKEN_FIELD = 'cf-turnstile-response';
|
|
18
|
-
export const RECAPTCHA_TOKEN_FIELD = 'g-recaptcha-response';
|
|
19
|
-
|
|
20
|
-
export type BotProtectionResult =
|
|
21
|
-
// Passed (or nothing configured beyond the honeypot).
|
|
22
|
-
| { ok: true }
|
|
23
|
-
// The honeypot was filled — almost certainly a bot. Callers should silently
|
|
24
|
-
// discard the submission and fake a success so the bot learns nothing.
|
|
25
|
-
| { ok: false; reason: 'honeypot' }
|
|
26
|
-
// The captcha was missing, failed, or could not be checked. `message` is a
|
|
27
|
-
// human-readable explanation safe to surface to the user.
|
|
28
|
-
| { ok: false; reason: 'captcha'; message: string };
|
|
29
|
-
|
|
30
|
-
type VerifyOptions = {
|
|
31
|
-
// A block/form may pin a specific provider; otherwise the site-wide setting
|
|
32
|
-
// (site_settings.bot_protection_public.provider) is used.
|
|
33
|
-
botProtectionProvider?: BotProtectionProvider;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Verify a submitted FormData against the configured bot protection.
|
|
38
|
-
*
|
|
39
|
-
* Phase 1 (honeypot) needs no network/DB and always runs — it is the always-on
|
|
40
|
-
* baseline. Phase 2 (captcha) reads the global provider + secret from
|
|
41
|
-
* `site_settings` and calls the provider's siteverify endpoint.
|
|
42
|
-
*/
|
|
43
|
-
export async function verifyBotProtection(
|
|
44
|
-
formData: FormData,
|
|
45
|
-
options?: VerifyOptions
|
|
46
|
-
): Promise<BotProtectionResult> {
|
|
47
|
-
// Phase 1: Honeypot validation
|
|
48
|
-
const honeypot = formData.get(HONEYPOT_FIELD);
|
|
49
|
-
if (honeypot && typeof honeypot === 'string' && honeypot.length > 0) {
|
|
50
|
-
console.warn('[Bot Protection] Honeypot triggered. Discarding submission from bot.');
|
|
51
|
-
return { ok: false, reason: 'honeypot' };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Phase 2: Advanced captcha verification
|
|
55
|
-
try {
|
|
56
|
-
const supabase = getServiceRoleSupabaseClient();
|
|
57
|
-
|
|
58
|
-
const { data: publicSetting } = await supabase
|
|
59
|
-
.from('site_settings')
|
|
60
|
-
.select('value')
|
|
61
|
-
.eq('key', 'bot_protection_public')
|
|
62
|
-
.maybeSingle();
|
|
63
|
-
|
|
64
|
-
const { data: secretSetting } = await supabase
|
|
65
|
-
.from('site_settings')
|
|
66
|
-
.select('value')
|
|
67
|
-
.eq('key', 'bot_protection_secret')
|
|
68
|
-
.maybeSingle();
|
|
69
|
-
|
|
70
|
-
const publicVal = (publicSetting?.value || {}) as Record<string, any>;
|
|
71
|
-
const secretVal = (secretSetting?.value || {}) as Record<string, any>;
|
|
72
|
-
|
|
73
|
-
const pinnedProvider =
|
|
74
|
-
options?.botProtectionProvider === 'turnstile' || options?.botProtectionProvider === 'recaptcha'
|
|
75
|
-
? options.botProtectionProvider
|
|
76
|
-
: undefined;
|
|
77
|
-
const provider: BotProtectionProvider = pinnedProvider || publicVal.provider || 'none';
|
|
78
|
-
const secretKey =
|
|
79
|
-
secretVal.secretKey ||
|
|
80
|
-
(provider === 'turnstile'
|
|
81
|
-
? process.env.TURNSTILE_SECRET_KEY
|
|
82
|
-
: process.env.RECAPTCHA_SECRET_KEY) ||
|
|
83
|
-
'';
|
|
84
|
-
|
|
85
|
-
if (provider === 'turnstile') {
|
|
86
|
-
const token = formData.get(TURNSTILE_TOKEN_FIELD) as string;
|
|
87
|
-
if (!token) {
|
|
88
|
-
return { ok: false, reason: 'captcha', message: 'Security verification token is missing. Please try again.' };
|
|
89
|
-
}
|
|
90
|
-
if (!secretKey) {
|
|
91
|
-
console.error('[Bot Protection] Turnstile secret key is not configured.');
|
|
92
|
-
return { ok: false, reason: 'captcha', message: 'Bot protection is misconfigured. Please contact support.' };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
|
96
|
-
method: 'POST',
|
|
97
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
98
|
-
body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const outcome = await res.json();
|
|
102
|
-
if (!outcome.success) {
|
|
103
|
-
console.warn('[Bot Protection] Turnstile verification failed:', outcome);
|
|
104
|
-
return { ok: false, reason: 'captcha', message: 'Security verification failed. Please try again.' };
|
|
105
|
-
}
|
|
106
|
-
} else if (provider === 'recaptcha') {
|
|
107
|
-
const token = formData.get(RECAPTCHA_TOKEN_FIELD) as string;
|
|
108
|
-
if (!token) {
|
|
109
|
-
return { ok: false, reason: 'captcha', message: 'Security verification token is missing. Please try again.' };
|
|
110
|
-
}
|
|
111
|
-
if (!secretKey) {
|
|
112
|
-
console.error('[Bot Protection] reCAPTCHA secret key is not configured.');
|
|
113
|
-
return { ok: false, reason: 'captcha', message: 'Bot protection is misconfigured. Please contact support.' };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
|
|
117
|
-
method: 'POST',
|
|
118
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
119
|
-
body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
const outcome = await res.json();
|
|
123
|
-
if (!outcome.success || outcome.score < 0.5) {
|
|
124
|
-
console.warn('[Bot Protection] reCAPTCHA verification failed:', outcome);
|
|
125
|
-
return { ok: false, reason: 'captcha', message: 'Security verification failed. Please try again.' };
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
} catch (error) {
|
|
129
|
-
console.error('[Bot Protection] Error during validation:', error);
|
|
130
|
-
return { ok: false, reason: 'captcha', message: 'Sorry, security verification could not be completed at this time.' };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
return { ok: true };
|
|
134
|
-
}
|
|
1
|
+
// lib/botProtection/verify.ts
|
|
2
|
+
//
|
|
3
|
+
// Shared server-side bot-protection verification: the honeypot check plus
|
|
4
|
+
// Cloudflare Turnstile / Google reCAPTCHA token verification. This is the single
|
|
5
|
+
// source of truth used by BOTH the page contact-form handler
|
|
6
|
+
// (app/actions/formActions.ts) and the account-signup action (app/actions.ts).
|
|
7
|
+
//
|
|
8
|
+
// Server-only: it reads the RLS-bypassing service-role client, which throws if
|
|
9
|
+
// imported into a Client Component.
|
|
10
|
+
|
|
11
|
+
import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
|
|
12
|
+
|
|
13
|
+
export type BotProtectionProvider = 'none' | 'turnstile' | 'recaptcha';
|
|
14
|
+
|
|
15
|
+
// Shared field names — the client widgets emit these, the verifier reads them.
|
|
16
|
+
export const HONEYPOT_FIELD = 'verification_secondary_email';
|
|
17
|
+
export const TURNSTILE_TOKEN_FIELD = 'cf-turnstile-response';
|
|
18
|
+
export const RECAPTCHA_TOKEN_FIELD = 'g-recaptcha-response';
|
|
19
|
+
|
|
20
|
+
export type BotProtectionResult =
|
|
21
|
+
// Passed (or nothing configured beyond the honeypot).
|
|
22
|
+
| { ok: true }
|
|
23
|
+
// The honeypot was filled — almost certainly a bot. Callers should silently
|
|
24
|
+
// discard the submission and fake a success so the bot learns nothing.
|
|
25
|
+
| { ok: false; reason: 'honeypot' }
|
|
26
|
+
// The captcha was missing, failed, or could not be checked. `message` is a
|
|
27
|
+
// human-readable explanation safe to surface to the user.
|
|
28
|
+
| { ok: false; reason: 'captcha'; message: string };
|
|
29
|
+
|
|
30
|
+
type VerifyOptions = {
|
|
31
|
+
// A block/form may pin a specific provider; otherwise the site-wide setting
|
|
32
|
+
// (site_settings.bot_protection_public.provider) is used.
|
|
33
|
+
botProtectionProvider?: BotProtectionProvider;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Verify a submitted FormData against the configured bot protection.
|
|
38
|
+
*
|
|
39
|
+
* Phase 1 (honeypot) needs no network/DB and always runs — it is the always-on
|
|
40
|
+
* baseline. Phase 2 (captcha) reads the global provider + secret from
|
|
41
|
+
* `site_settings` and calls the provider's siteverify endpoint.
|
|
42
|
+
*/
|
|
43
|
+
export async function verifyBotProtection(
|
|
44
|
+
formData: FormData,
|
|
45
|
+
options?: VerifyOptions
|
|
46
|
+
): Promise<BotProtectionResult> {
|
|
47
|
+
// Phase 1: Honeypot validation
|
|
48
|
+
const honeypot = formData.get(HONEYPOT_FIELD);
|
|
49
|
+
if (honeypot && typeof honeypot === 'string' && honeypot.length > 0) {
|
|
50
|
+
console.warn('[Bot Protection] Honeypot triggered. Discarding submission from bot.');
|
|
51
|
+
return { ok: false, reason: 'honeypot' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Phase 2: Advanced captcha verification
|
|
55
|
+
try {
|
|
56
|
+
const supabase = getServiceRoleSupabaseClient();
|
|
57
|
+
|
|
58
|
+
const { data: publicSetting } = await supabase
|
|
59
|
+
.from('site_settings')
|
|
60
|
+
.select('value')
|
|
61
|
+
.eq('key', 'bot_protection_public')
|
|
62
|
+
.maybeSingle();
|
|
63
|
+
|
|
64
|
+
const { data: secretSetting } = await supabase
|
|
65
|
+
.from('site_settings')
|
|
66
|
+
.select('value')
|
|
67
|
+
.eq('key', 'bot_protection_secret')
|
|
68
|
+
.maybeSingle();
|
|
69
|
+
|
|
70
|
+
const publicVal = (publicSetting?.value || {}) as Record<string, any>;
|
|
71
|
+
const secretVal = (secretSetting?.value || {}) as Record<string, any>;
|
|
72
|
+
|
|
73
|
+
const pinnedProvider =
|
|
74
|
+
options?.botProtectionProvider === 'turnstile' || options?.botProtectionProvider === 'recaptcha'
|
|
75
|
+
? options.botProtectionProvider
|
|
76
|
+
: undefined;
|
|
77
|
+
const provider: BotProtectionProvider = pinnedProvider || publicVal.provider || 'none';
|
|
78
|
+
const secretKey =
|
|
79
|
+
secretVal.secretKey ||
|
|
80
|
+
(provider === 'turnstile'
|
|
81
|
+
? process.env.TURNSTILE_SECRET_KEY
|
|
82
|
+
: process.env.RECAPTCHA_SECRET_KEY) ||
|
|
83
|
+
'';
|
|
84
|
+
|
|
85
|
+
if (provider === 'turnstile') {
|
|
86
|
+
const token = formData.get(TURNSTILE_TOKEN_FIELD) as string;
|
|
87
|
+
if (!token) {
|
|
88
|
+
return { ok: false, reason: 'captcha', message: 'Security verification token is missing. Please try again.' };
|
|
89
|
+
}
|
|
90
|
+
if (!secretKey) {
|
|
91
|
+
console.error('[Bot Protection] Turnstile secret key is not configured.');
|
|
92
|
+
return { ok: false, reason: 'captcha', message: 'Bot protection is misconfigured. Please contact support.' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
98
|
+
body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const outcome = await res.json();
|
|
102
|
+
if (!outcome.success) {
|
|
103
|
+
console.warn('[Bot Protection] Turnstile verification failed:', outcome);
|
|
104
|
+
return { ok: false, reason: 'captcha', message: 'Security verification failed. Please try again.' };
|
|
105
|
+
}
|
|
106
|
+
} else if (provider === 'recaptcha') {
|
|
107
|
+
const token = formData.get(RECAPTCHA_TOKEN_FIELD) as string;
|
|
108
|
+
if (!token) {
|
|
109
|
+
return { ok: false, reason: 'captcha', message: 'Security verification token is missing. Please try again.' };
|
|
110
|
+
}
|
|
111
|
+
if (!secretKey) {
|
|
112
|
+
console.error('[Bot Protection] reCAPTCHA secret key is not configured.');
|
|
113
|
+
return { ok: false, reason: 'captcha', message: 'Bot protection is misconfigured. Please contact support.' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const res = await fetch('https://www.google.com/recaptcha/api/siteverify', {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
119
|
+
body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const outcome = await res.json();
|
|
123
|
+
if (!outcome.success || outcome.score < 0.5) {
|
|
124
|
+
console.warn('[Bot Protection] reCAPTCHA verification failed:', outcome);
|
|
125
|
+
return { ok: false, reason: 'captcha', message: 'Security verification failed. Please try again.' };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
} catch (error) {
|
|
129
|
+
console.error('[Bot Protection] Error during validation:', error);
|
|
130
|
+
return { ok: false, reason: 'captcha', message: 'Sorry, security verification could not be completed at this time.' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|