create-nextblock 0.11.3 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +141 -0
  3. package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +40 -122
  4. package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +5 -0
  5. package/templates/nextblock-template/app/[slug]/page.tsx +5 -2
  6. package/templates/nextblock-template/app/[slug]/page.utils.ts +10 -9
  7. package/templates/nextblock-template/app/actions/email.ts +8 -1
  8. package/templates/nextblock-template/app/actions/feedback.ts +1 -0
  9. package/templates/nextblock-template/app/actions/formActions.ts +20 -97
  10. package/templates/nextblock-template/app/actions/interactions.ts +3 -2
  11. package/templates/nextblock-template/app/actions/twoFactorEmail.ts +3 -2
  12. package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +1 -1
  13. package/templates/nextblock-template/app/actions/visualEditingActions.ts +2 -0
  14. package/templates/nextblock-template/app/actions.ts +14 -0
  15. package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -0
  16. package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +47 -1
  17. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4967 -8677
  18. package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -2
  19. package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -0
  20. package/templates/nextblock-template/app/cms/pages/actions.ts +22 -18
  21. package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +20 -2
  22. package/templates/nextblock-template/app/cms/posts/actions.ts +5 -0
  23. package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +137 -133
  24. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +36 -0
  25. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +26 -1
  26. package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +6 -2
  27. package/templates/nextblock-template/app/cms/settings/email/actions.ts +7 -3
  28. package/templates/nextblock-template/app/cms/settings/logos/actions.ts +29 -14
  29. package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -0
  30. package/templates/nextblock-template/app/cms/settings/logos/page.tsx +18 -5
  31. package/templates/nextblock-template/app/layout.tsx +29 -17
  32. package/templates/nextblock-template/app/lib/seo.test.ts +36 -0
  33. package/templates/nextblock-template/app/lib/seo.ts +32 -0
  34. package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -2
  35. package/templates/nextblock-template/components/AppShell.tsx +16 -1
  36. package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -0
  37. package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +36 -42
  38. package/templates/nextblock-template/lib/botProtection/verify.ts +134 -0
  39. package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -0
  40. package/templates/nextblock-template/lib/email/branding-format.ts +123 -0
  41. package/templates/nextblock-template/lib/email/branding.ts +76 -0
  42. package/templates/nextblock-template/lib/logos/active-logo.ts +53 -0
  43. package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +1 -0
  44. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +8 -213
  45. package/templates/nextblock-template/lib/visual-editing/draft-content.ts +4 -2
  46. package/templates/nextblock-template/lib/visual-editing/mutations.ts +2 -2
  47. package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +1 -0
  48. package/templates/nextblock-template/package.json +1 -1
  49. package/templates/nextblock-template/proxy.ts +16 -0
  50. package/templates/nextblock-template/public/images/nextblock-logo-button-tiny.png +0 -0
  51. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -0,0 +1,141 @@
1
+ 'use client';
2
+
3
+ import { signUpAction } from "../../actions";
4
+ import { FormMessage, Message } from "../../../components/form-message";
5
+ import { SubmitButton } from "../../../components/submit-button";
6
+ import { Button, Input, Label } from "@nextblock-cms/ui";
7
+ import Link from "next/link";
8
+ import { useTranslations } from "@nextblock-cms/utils";
9
+ import { useSearchParams } from "next/navigation";
10
+ import { GitHubLoginButton } from "../../../components/GitHubLoginButton";
11
+ import { ArrowRight, CheckCircle2, Mail } from "lucide-react";
12
+
13
+ import { SandboxCredentialsAlert } from "../../../components/SandboxCredentialsAlert";
14
+ import { AuthBotProtection } from "../../../components/auth/AuthBotProtection";
15
+
16
+ type BotProtectionProvider = 'none' | 'turnstile' | 'recaptcha';
17
+
18
+ interface SignUpFormProps {
19
+ botProtection: { provider: BotProtectionProvider; siteKey: string };
20
+ scriptNonce?: string;
21
+ }
22
+
23
+ function getMessage(searchParams: URLSearchParams): Message | undefined {
24
+ if (searchParams.has('error')) {
25
+ const error = searchParams.get('error');
26
+ if (error) return { error };
27
+ }
28
+ if (searchParams.has('success')) {
29
+ const success = searchParams.get('success');
30
+ if (success) return { success };
31
+ }
32
+ if (searchParams.has('message')) {
33
+ const message = searchParams.get('message');
34
+ if (message) return { message };
35
+ }
36
+ return undefined;
37
+ }
38
+
39
+ export default function SignUpForm({ botProtection, scriptNonce }: SignUpFormProps) {
40
+ const { t } = useTranslations();
41
+ const searchParams = useSearchParams();
42
+ const formMessage = getMessage(searchParams);
43
+ const successKey = searchParams.get('success');
44
+
45
+ if (successKey) {
46
+ return (
47
+ <div className="flex-1 flex flex-col w-full max-w-160 mx-auto">
48
+ <div className="mb-4 flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
49
+ <CheckCircle2 className="h-5 w-5" />
50
+ </div>
51
+ <p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
52
+ {t('auth.signup_success_badge')}
53
+ </p>
54
+ <h1 className="mt-3 text-2xl font-medium">{t('auth.signup_success_title')}</h1>
55
+ <p className="mt-2 text-sm leading-6 text-muted-foreground">
56
+ {t(successKey)}
57
+ </p>
58
+
59
+ <div className="mt-8 rounded-lg border p-5">
60
+ <div className="flex items-start gap-3">
61
+ <div className="mt-0.5 rounded-full bg-muted p-2 text-muted-foreground">
62
+ <Mail className="h-4 w-4" />
63
+ </div>
64
+ <div className="space-y-3 text-sm text-muted-foreground">
65
+ <p>{t('auth.signup_success_step_confirm')}</p>
66
+ <p>{t('auth.signup_success_step_profile')}</p>
67
+ <p>{t('auth.signup_success_step_spam')}</p>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <div className="mt-6 flex flex-col gap-3 sm:flex-row">
73
+ <Button asChild>
74
+ <Link href="/sign-in">
75
+ {t('auth.back_to_sign_in')}
76
+ <ArrowRight className="ml-2 h-4 w-4" />
77
+ </Link>
78
+ </Button>
79
+ <Button asChild variant="outline">
80
+ <Link href="/sign-up">{t('auth.signup_use_different_email')}</Link>
81
+ </Button>
82
+ </div>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ return (
88
+ <div className="flex-1 flex flex-col w-full max-w-160 mx-auto">
89
+ <form className="flex flex-col">
90
+ <SandboxCredentialsAlert />
91
+ <h1 className="text-2xl font-medium">{t('sign_up')}</h1>
92
+ <p className="mt-2 text-sm leading-6 text-muted-foreground">
93
+ {t('auth.signup_form_description')}
94
+ </p>
95
+ <p className="text-sm text-foreground">
96
+ {t('already_have_account')}{" "}
97
+ <Link className="text-foreground font-medium underline" href="/sign-in">
98
+ {t('sign_in')}
99
+ </Link>
100
+ </p>
101
+
102
+ <div className="mt-8 flex flex-col gap-2">
103
+ <GitHubLoginButton t={t} redirectTo="/profile" />
104
+
105
+ <div className="relative py-2">
106
+ <div className="absolute inset-0 flex items-center">
107
+ <span className="w-full border-t" />
108
+ </div>
109
+ <div className="relative flex justify-center text-xs uppercase">
110
+ <span className="bg-background px-2 text-muted-foreground">
111
+ {t('or_continue_with') || "Or continue with"}
112
+ </span>
113
+ </div>
114
+ </div>
115
+
116
+ <div className="flex flex-col gap-2 [&>input]:mb-3">
117
+ <Label htmlFor="email">{t('email')}</Label>
118
+ <Input name="email" placeholder={t('you_at_example_com')} required />
119
+ <Label htmlFor="password">{t('password')}</Label>
120
+ <Input
121
+ type="password"
122
+ name="password"
123
+ placeholder={t('your_password')}
124
+ minLength={6}
125
+ required
126
+ />
127
+ <AuthBotProtection
128
+ provider={botProtection.provider}
129
+ siteKey={botProtection.siteKey}
130
+ scriptNonce={scriptNonce}
131
+ />
132
+ <SubmitButton formAction={signUpAction} pendingText={t('signing_up_pending')}>
133
+ {t('sign_up')}
134
+ </SubmitButton>
135
+ <FormMessage message={formMessage} />
136
+ </div>
137
+ </div>
138
+ </form>
139
+ </div>
140
+ );
141
+ }
@@ -1,128 +1,46 @@
1
- 'use client';
2
-
3
- import { signUpAction } from "../../actions";
4
- import { FormMessage, Message } from "../../../components/form-message";
5
- import { SubmitButton } from "../../../components/submit-button";
6
- import { Button, Input, Label } from "@nextblock-cms/ui";
7
- import Link from "next/link";
8
- import { useTranslations } from "@nextblock-cms/utils";
9
- import { useSearchParams } from "next/navigation";
10
- import { GitHubLoginButton } from "../../../components/GitHubLoginButton";
11
- import { ArrowRight, CheckCircle2, Mail } from "lucide-react";
12
-
13
- import { SandboxCredentialsAlert } from "../../../components/SandboxCredentialsAlert";
14
-
15
- function getMessage(searchParams: URLSearchParams): Message | undefined {
16
- if (searchParams.has('error')) {
17
- const error = searchParams.get('error');
18
- if (error) return { error };
19
- }
20
- if (searchParams.has('success')) {
21
- const success = searchParams.get('success');
22
- if (success) return { success };
23
- }
24
- if (searchParams.has('message')) {
25
- const message = searchParams.get('message');
26
- if (message) return { message };
27
- }
28
- return undefined;
29
- }
30
-
31
- export default function Signup() {
32
- const { t } = useTranslations();
33
- const searchParams = useSearchParams();
34
- const formMessage = getMessage(searchParams);
35
- const successKey = searchParams.get('success');
36
-
37
- if (successKey) {
38
- return (
39
- <div className="flex-1 flex flex-col w-full max-w-160 mx-auto">
40
- <div className="mb-4 flex h-11 w-11 items-center justify-center rounded-full bg-primary/10 text-primary">
41
- <CheckCircle2 className="h-5 w-5" />
42
- </div>
43
- <p className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
44
- {t('auth.signup_success_badge')}
45
- </p>
46
- <h1 className="mt-3 text-2xl font-medium">{t('auth.signup_success_title')}</h1>
47
- <p className="mt-2 text-sm leading-6 text-muted-foreground">
48
- {t(successKey)}
49
- </p>
50
-
51
- <div className="mt-8 rounded-lg border p-5">
52
- <div className="flex items-start gap-3">
53
- <div className="mt-0.5 rounded-full bg-muted p-2 text-muted-foreground">
54
- <Mail className="h-4 w-4" />
55
- </div>
56
- <div className="space-y-3 text-sm text-muted-foreground">
57
- <p>{t('auth.signup_success_step_confirm')}</p>
58
- <p>{t('auth.signup_success_step_profile')}</p>
59
- <p>{t('auth.signup_success_step_spam')}</p>
60
- </div>
61
- </div>
62
- </div>
1
+ import { Suspense } from "react";
2
+ import { headers } from "next/headers";
3
+ import { createClient } from "@nextblock-cms/db/server";
4
+ import SignUpForm from "./SignUpForm";
5
+
6
+ type BotProtectionProvider = 'none' | 'turnstile' | 'recaptcha';
7
+
8
+ // Server wrapper: resolves the site-wide bot-protection provider + site key
9
+ // (CMS Settings Bot Protection) and the CSP nonce, then hands them to the
10
+ // interactive client form. Mirrors the read in components/BlockRenderer.tsx.
11
+ export default async function SignUpPage() {
12
+ let scriptNonce = '';
13
+ try {
14
+ scriptNonce = (await headers()).get('x-nonce') || '';
15
+ } catch (e) {
16
+ console.error('[Bot Protection] Error loading CSP nonce on sign-up page:', e);
17
+ }
63
18
 
64
- <div className="mt-6 flex flex-col gap-3 sm:flex-row">
65
- <Button asChild>
66
- <Link href="/sign-in">
67
- {t('auth.back_to_sign_in')}
68
- <ArrowRight className="ml-2 h-4 w-4" />
69
- </Link>
70
- </Button>
71
- <Button asChild variant="outline">
72
- <Link href="/sign-up">{t('auth.signup_use_different_email')}</Link>
73
- </Button>
74
- </div>
75
- </div>
76
- );
19
+ let botProtection: { provider: BotProtectionProvider; siteKey: string } = {
20
+ provider: 'none',
21
+ siteKey: '',
22
+ };
23
+ try {
24
+ const supabase = createClient();
25
+ const { data: publicSetting } = await supabase
26
+ .from('site_settings')
27
+ .select('value')
28
+ .eq('key', 'bot_protection_public')
29
+ .maybeSingle();
30
+ if (publicSetting?.value) {
31
+ const publicVal = publicSetting.value as Record<string, any>;
32
+ botProtection = {
33
+ provider: publicVal.provider || 'none',
34
+ siteKey: publicVal.siteKey || '',
35
+ };
36
+ }
37
+ } catch (e) {
38
+ console.error('[Bot Protection] Error loading settings on sign-up page:', e);
77
39
  }
78
40
 
79
41
  return (
80
- <div className="flex-1 flex flex-col w-full max-w-160 mx-auto">
81
- <form className="flex flex-col">
82
- <SandboxCredentialsAlert />
83
- <h1 className="text-2xl font-medium">{t('sign_up')}</h1>
84
- <p className="mt-2 text-sm leading-6 text-muted-foreground">
85
- {t('auth.signup_form_description')}
86
- </p>
87
- <p className="text-sm text-foreground">
88
- {t('already_have_account')}{" "}
89
- <Link className="text-foreground font-medium underline" href="/sign-in">
90
- {t('sign_in')}
91
- </Link>
92
- </p>
93
-
94
- <div className="mt-8 flex flex-col gap-2">
95
- <GitHubLoginButton t={t} redirectTo="/profile" />
96
-
97
- <div className="relative py-2">
98
- <div className="absolute inset-0 flex items-center">
99
- <span className="w-full border-t" />
100
- </div>
101
- <div className="relative flex justify-center text-xs uppercase">
102
- <span className="bg-background px-2 text-muted-foreground">
103
- {t('or_continue_with') || "Or continue with"}
104
- </span>
105
- </div>
106
- </div>
107
-
108
- <div className="flex flex-col gap-2 [&>input]:mb-3">
109
- <Label htmlFor="email">{t('email')}</Label>
110
- <Input name="email" placeholder={t('you_at_example_com')} required />
111
- <Label htmlFor="password">{t('password')}</Label>
112
- <Input
113
- type="password"
114
- name="password"
115
- placeholder={t('your_password')}
116
- minLength={6}
117
- required
118
- />
119
- <SubmitButton formAction={signUpAction} pendingText={t('signing_up_pending')}>
120
- {t('sign_up')}
121
- </SubmitButton>
122
- <FormMessage message={formMessage} />
123
- </div>
124
- </div>
125
- </form>
126
- </div>
42
+ <Suspense fallback={null}>
43
+ <SignUpForm botProtection={botProtection} scriptNonce={scriptNonce} />
44
+ </Suspense>
127
45
  );
128
46
  }
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useTransition } from 'react';
4
+ import { unstable_rethrow } from 'next/navigation';
4
5
  import { Alert, AlertDescription, Button, Input, Label, Spinner } from '@nextblock-cms/ui';
5
6
  import { resendEmailCode, verifyEmailCode, verifyTotpChallenge } from '../actions';
6
7
 
@@ -36,6 +37,10 @@ export default function TwoFactorForm({
36
37
  // A successful action redirects server-side; only failures return here.
37
38
  if (result?.error) setError(result.error);
38
39
  } catch (err) {
40
+ // A successful verify ends in redirect(), which Next signals by throwing a
41
+ // NEXT_REDIRECT control-flow error. Let Next handle it (perform the navigation)
42
+ // instead of surfacing it as a red error flash; only real errors fall through.
43
+ unstable_rethrow(err);
39
44
  setError(err instanceof Error ? err.message : 'Verification failed.');
40
45
  }
41
46
  });
@@ -12,6 +12,7 @@ import {
12
12
  resolvePageMetaDescription,
13
13
  stringifyJsonLd,
14
14
  buildSocialMetadata,
15
+ buildCanonicalUrl,
15
16
  toOpenGraphLocale,
16
17
  } from "../lib/seo";
17
18
  import { getSiteSettings } from "../lib/site-settings";
@@ -118,6 +119,8 @@ export async function generateMetadata(
118
119
  const title = resolveMetaTitle(pageData.meta_title, pageData.title);
119
120
  const description = resolvePageMetaDescription(pageData.meta_description, pageData.blocks);
120
121
  const { siteTitle } = await getSiteSettings();
122
+ // Self-referencing `<siteUrl>/<slug>` unless the page sets a manual custom_canonical override.
123
+ const canonicalUrl = buildCanonicalUrl(pageData.custom_canonical, siteUrl, `/${params.slug}`);
121
124
 
122
125
  return {
123
126
  title,
@@ -125,14 +128,14 @@ export async function generateMetadata(
125
128
  ...buildSocialMetadata({
126
129
  title,
127
130
  description,
128
- url: `${siteUrl}/${params.slug}`,
131
+ url: canonicalUrl,
129
132
  siteTitle,
130
133
  imageUrl: pageData.feature_image_url,
131
134
  type: 'website',
132
135
  locale: toOpenGraphLocale(pageData.language_code),
133
136
  }),
134
137
  alternates: {
135
- canonical: `${siteUrl}/${params.slug}`,
138
+ canonical: canonicalUrl,
136
139
  languages: Object.keys(alternates).length > 0 ? alternates : undefined,
137
140
  },
138
141
  };
@@ -99,6 +99,7 @@ function applyDraftToPage(page: SelectedPageType, draft: ContentDraftRow): Selec
99
99
  status: draftString(draft, "status", page.status) as PageType["status"],
100
100
  meta_title: draftNullableString(draft, "meta_title", page.meta_title),
101
101
  meta_description: draftNullableString(draft, "meta_description", page.meta_description),
102
+ custom_canonical: draftNullableString(draft, "custom_canonical", page.custom_canonical),
102
103
  feature_image_id: draftNullableString(draft, "feature_image_id", page.feature_image_id),
103
104
  translation_group_id: draftString(
104
105
  draft,
@@ -255,7 +256,7 @@ export async function getPageDataBySlug(
255
256
  const supabase = isDraftModeEnabled ? createClient() : getSsgSupabaseClient();
256
257
 
257
258
  const baseSelect = `
258
- id, slug, title, meta_title, meta_description, feature_image_id, status, language_id, translation_group_id, author_id, created_at, updated_at,
259
+ id, slug, title, meta_title, meta_description, custom_canonical, feature_image_id, status, language_id, translation_group_id, author_id, created_at, updated_at,
259
260
  language_details:languages!inner(id, code),
260
261
  feature_media_object:media!pages_feature_image_id_fkey(object_key, file_path, blur_data_url, width, height),
261
262
  blocks (id, page_id, block_type, content, order)
@@ -404,14 +405,14 @@ export async function getPageDataBySlug(
404
405
  .select('id, object_key, blur_data_url')
405
406
  .in('id', mediaIds);
406
407
 
407
- if (mediaError) {
408
- console.error('Error fetching media data:', mediaError);
409
- } else if (mediaItems) {
410
- const mediaMap = new Map(mediaItems.map(m => [m.id, { object_key: m.object_key, blur_data_url: m.blur_data_url }]));
411
- blocksWithMediaData = blocksWithMediaData.map(block => mapMediaDataToBlock(block, mediaMap));
412
- }
413
- }
414
- }
408
+ if (mediaError) {
409
+ console.error('Error fetching media data:', mediaError);
410
+ } else if (mediaItems) {
411
+ const mediaMap = new Map(mediaItems.map(m => [m.id, { object_key: m.object_key, blur_data_url: m.blur_data_url }]));
412
+ blocksWithMediaData = blocksWithMediaData.map(block => mapMediaDataToBlock(block, mediaMap));
413
+ }
414
+ }
415
+ }
415
416
 
416
417
  let featureMedia = selectedPage.feature_media_object ?? null;
417
418
  if (selectedPage.feature_image_id && (!featureMedia || contentDraft)) {
@@ -1,6 +1,7 @@
1
1
  "use server";
2
2
 
3
3
  import { resolveEmailServerConfig } from '../../lib/config/email-settings';
4
+ import { applyEmailBranding, resolveEmailBranding } from '../../lib/email/branding';
4
5
  import nodemailer from 'nodemailer';
5
6
 
6
7
  interface EmailParams {
@@ -18,6 +19,12 @@ export async function sendEmail({ to, subject, text, html }: EmailParams) {
18
19
  throw new Error("Email server is not configured. Configure SMTP in CMS Settings → Configuration → Email.");
19
20
  }
20
21
 
22
+ // Single interception point: white-label every outgoing email with the tenant's own
23
+ // logo + site name (or a text banner when no logo is set). Every app-dispatched email
24
+ // funnels through here, so branding is applied once, centrally.
25
+ const branding = await resolveEmailBranding();
26
+ const brandedHtml = applyEmailBranding(html, branding);
27
+
21
28
  const transporter = nodemailer.createTransport(emailConfig);
22
29
 
23
30
  const options = {
@@ -25,7 +32,7 @@ export async function sendEmail({ to, subject, text, html }: EmailParams) {
25
32
  to,
26
33
  subject,
27
34
  text,
28
- html,
35
+ html: brandedHtml,
29
36
  };
30
37
 
31
38
  return transporter.sendMail(options);
@@ -19,6 +19,7 @@ export async function submitFeedback(data: FeedbackData) {
19
19
  const { subject, message, userEmail, userName, url } = data;
20
20
 
21
21
  const htmlContent = `
22
+ {{brand_header}}
22
23
  <h2>New Feedback Received</h2>
23
24
  <p><strong>From:</strong> ${userName || 'Unknown'} (${userEmail})</p>
24
25
  <p><strong>Subject:</strong> ${subject}</p>
@@ -2,15 +2,19 @@
2
2
  "use server";
3
3
 
4
4
  import { sendEmail } from './email';
5
- import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
5
+ import {
6
+ verifyBotProtection,
7
+ type BotProtectionProvider,
8
+ HONEYPOT_FIELD,
9
+ TURNSTILE_TOKEN_FIELD,
10
+ RECAPTCHA_TOKEN_FIELD,
11
+ } from '../../lib/botProtection/verify';
6
12
 
7
13
  interface FormSubmissionResult {
8
14
  success: boolean;
9
15
  message: string;
10
16
  }
11
17
 
12
- type BotProtectionProvider = 'none' | 'turnstile' | 'recaptcha';
13
-
14
18
  type FormSubmissionConfig = {
15
19
  recipient: string;
16
20
  botProtectionProvider?: BotProtectionProvider;
@@ -43,96 +47,14 @@ export async function handleFormSubmission(
43
47
  : '';
44
48
  const recipient = sandboxRecipient || configuredRecipient;
45
49
 
46
- // Phase 1: Honeypot Validation
47
- const honeypot = formData.get('verification_secondary_email');
48
- if (honeypot && typeof honeypot === 'string' && honeypot.length > 0) {
49
- console.warn("[Bot Protection] Honeypot triggered. Discarding submission from bot.");
50
- // Fool the bot by returning a fake success response immediately
51
- return { success: true, message: "Submission successful!" };
52
- }
53
-
54
- // Phase 2: Advanced Captcha Verification
55
- try {
56
- const supabase = getServiceRoleSupabaseClient();
57
-
58
- // Fetch global bot protection settings
59
- const { data: publicSetting } = await supabase
60
- .from('site_settings')
61
- .select('value')
62
- .eq('key', 'bot_protection_public')
63
- .maybeSingle();
64
-
65
- const { data: secretSetting } = await supabase
66
- .from('site_settings')
67
- .select('value')
68
- .eq('key', 'bot_protection_secret')
69
- .maybeSingle();
70
-
71
- const publicVal = (publicSetting?.value || {}) as Record<string, any>;
72
- const secretVal = (secretSetting?.value || {}) as Record<string, any>;
73
-
74
- const blockProvider =
75
- botProtectionProvider === 'turnstile' || botProtectionProvider === 'recaptcha'
76
- ? botProtectionProvider
77
- : undefined;
78
- const provider = blockProvider || publicVal.provider || 'none';
79
- const secretKey = secretVal.secretKey ||
80
- (provider === 'turnstile' ? process.env.TURNSTILE_SECRET_KEY : process.env.RECAPTCHA_SECRET_KEY) ||
81
- '';
82
-
83
- if (provider === 'turnstile') {
84
- const token = formData.get('cf-turnstile-response') as string;
85
- if (!token) {
86
- return { success: false, message: "Security verification token is missing. Please try again." };
87
- }
88
- if (!secretKey) {
89
- console.error("[Bot Protection] Turnstile secret key is not configured.");
90
- return { success: false, message: "Bot protection is misconfigured. Please contact support." };
91
- }
92
-
93
- const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
94
- method: 'POST',
95
- headers: {
96
- 'Content-Type': 'application/x-www-form-urlencoded',
97
- },
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 { success: false, message: "Security verification failed. Please try again." };
105
- }
106
- } else if (provider === 'recaptcha') {
107
- const token = formData.get('g-recaptcha-response') as string;
108
- if (!token) {
109
- return { success: false, 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 { success: false, 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: {
119
- 'Content-Type': 'application/x-www-form-urlencoded',
120
- },
121
- body: `secret=${encodeURIComponent(secretKey)}&response=${encodeURIComponent(token)}`,
122
- });
123
-
124
- const outcome = await res.json();
125
- if (!outcome.success || outcome.score < 0.5) {
126
- console.warn("[Bot Protection] reCAPTCHA verification failed:", outcome);
127
- return { success: false, message: "Security verification failed. Please try again." };
128
- }
50
+ // Honeypot + captcha verification (shared with the account-signup flow).
51
+ const verification = await verifyBotProtection(formData, { botProtectionProvider });
52
+ if (!verification.ok) {
53
+ if (verification.reason === 'honeypot') {
54
+ // Fool the bot by returning a fake success response immediately.
55
+ return { success: true, message: "Submission successful!" };
129
56
  }
130
- } catch (error) {
131
- console.error("[Bot Protection] Error during validation:", error);
132
- // If database or fetch error occurs, we gracefully degrade or warn, but let's be secure and fail open/closed depending on preference.
133
- // The requirement says: "If the API indicates a verification failure or falls below a threshold... reject the operation securely."
134
- // Let's return error message.
135
- return { success: false, message: "Sorry, security verification could not be completed at this time." };
57
+ return { success: false, message: verification.message };
136
58
  }
137
59
 
138
60
  const data: Record<string, string | File> = {};
@@ -141,11 +63,11 @@ export async function handleFormSubmission(
141
63
  formData.forEach((value, key) => {
142
64
  // Avoid sending internal bot protection tokens and honeypots in the notification email
143
65
  if (
144
- typeof value === 'string' &&
145
- !key.startsWith('$') &&
146
- key !== 'verification_secondary_email' &&
147
- key !== 'g-recaptcha-response' &&
148
- key !== 'cf-turnstile-response'
66
+ typeof value === 'string' &&
67
+ !key.startsWith('$') &&
68
+ key !== HONEYPOT_FIELD &&
69
+ key !== RECAPTCHA_TOKEN_FIELD &&
70
+ key !== TURNSTILE_TOKEN_FIELD
149
71
  ) {
150
72
  data[key] = value;
151
73
  // Attempt to find a field that looks like an email address to use in the subject
@@ -157,6 +79,7 @@ export async function handleFormSubmission(
157
79
 
158
80
  // Create a more readable HTML body for the email
159
81
  const htmlBody = `
82
+ {{brand_header}}
160
83
  <h2>New Form Submission</h2>
161
84
  <p>You have received a new submission from your website form.</p>
162
85
  <table border="1" cellpadding="5" cellspacing="0" style="border-collapse: collapse;">
@@ -88,10 +88,11 @@ export async function submitInteraction(input: SubmitInteractionInput) {
88
88
  const origin = `${protocol}://${host}`;
89
89
 
90
90
  const capitalizedType = input.type.charAt(0).toUpperCase() + input.type.slice(1);
91
- const subject = `[NextBlock CMS] New Pending ${capitalizedType} Submitted`;
91
+ const subject = `New Pending ${capitalizedType} Submitted`;
92
92
 
93
93
  const html = `
94
94
  <div style="font-family: sans-serif; padding: 20px; color: #333; max-width: 600px; margin: 0 auto; border: 1px solid #eee; border-radius: 8px;">
95
+ {{brand_header}}
95
96
  <h2 style="color: #6366f1; margin-top: 0;">New Pending ${capitalizedType} Submitted</h2>
96
97
  <p>Hello,</p>
97
98
  <p>A new content interaction has been submitted and is currently <strong>pending moderation</strong>.</p>
@@ -111,7 +112,7 @@ export async function submitInteraction(input: SubmitInteractionInput) {
111
112
  </a>
112
113
  </p>
113
114
  <hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;" />
114
- <p style="font-size: 11px; color: #888;">This is an automated notification from your NextBlock™ CMS instance.</p>
115
+ <p style="font-size: 11px; color: #888;">This is an automated notification from your CMS.</p>
115
116
  </div>
116
117
  `;
117
118
 
@@ -9,10 +9,11 @@ export async function sendTwoFactorCodeEmail(
9
9
  ): Promise<void> {
10
10
  await sendEmail({
11
11
  to,
12
- subject: 'Your NextBlock verification code',
13
- text: `Your NextBlock verification code is ${code}. It expires in 5 minutes. If you didn't request it, you can ignore this email.`,
12
+ subject: 'Your verification code',
13
+ text: `Your verification code is ${code}. It expires in 5 minutes. If you didn't request it, you can ignore this email.`,
14
14
  html: `
15
15
  <div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto;padding:24px;">
16
+ {{brand_header}}
16
17
  <p style="font-size:14px;color:#475569;">Use this code to ${purpose}:</p>
17
18
  <p style="font-size:32px;font-weight:700;letter-spacing:8px;margin:16px 0;color:#0f172a;">${code}</p>
18
19
  <p style="font-size:13px;color:#64748b;">This code expires in 5 minutes. If you didn't request it, you can safely ignore this email.</p>
@@ -132,7 +132,7 @@ describe("visual editing server actions", () => {
132
132
 
133
133
  expect(result).toEqual({
134
134
  error:
135
- "Draft storage is not set up in this database. Apply the content_drafts migration: libs/db/src/supabase/migrations/00000000000014_setup_content_drafts.sql.",
135
+ "Draft storage is not set up in this database. Apply the schema (npm run db:migrate or the /setup wizard); content_drafts is created by the baseline schema migration.",
136
136
  });
137
137
  });
138
138