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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/(auth-pages)/sign-up/SignUpForm.tsx +141 -0
- package/templates/nextblock-template/app/(auth-pages)/sign-up/page.tsx +40 -122
- package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +5 -0
- package/templates/nextblock-template/app/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/app/[slug]/page.utils.ts +10 -9
- package/templates/nextblock-template/app/actions/email.ts +8 -1
- package/templates/nextblock-template/app/actions/feedback.ts +1 -0
- package/templates/nextblock-template/app/actions/formActions.ts +20 -97
- package/templates/nextblock-template/app/actions/interactions.ts +3 -2
- package/templates/nextblock-template/app/actions/twoFactorEmail.ts +3 -2
- package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +1 -1
- package/templates/nextblock-template/app/actions/visualEditingActions.ts +2 -0
- package/templates/nextblock-template/app/actions.ts +14 -0
- package/templates/nextblock-template/app/api/brand/email-logo/route.ts +48 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/route.ts +47 -1
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +4967 -8677
- package/templates/nextblock-template/app/article/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -0
- package/templates/nextblock-template/app/cms/pages/actions.ts +22 -18
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +20 -2
- package/templates/nextblock-template/app/cms/posts/actions.ts +5 -0
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +137 -133
- package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +36 -0
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +26 -1
- package/templates/nextblock-template/app/cms/settings/copyright/page.tsx +6 -2
- package/templates/nextblock-template/app/cms/settings/email/actions.ts +7 -3
- package/templates/nextblock-template/app/cms/settings/logos/actions.ts +29 -14
- package/templates/nextblock-template/app/cms/settings/logos/components/SetActiveLogoButton.tsx +42 -0
- package/templates/nextblock-template/app/cms/settings/logos/page.tsx +18 -5
- package/templates/nextblock-template/app/layout.tsx +29 -17
- package/templates/nextblock-template/app/lib/seo.test.ts +36 -0
- package/templates/nextblock-template/app/lib/seo.ts +32 -0
- package/templates/nextblock-template/app/product/[slug]/page.tsx +5 -2
- package/templates/nextblock-template/components/AppShell.tsx +16 -1
- package/templates/nextblock-template/components/auth/AuthBotProtection.tsx +182 -0
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +36 -42
- package/templates/nextblock-template/lib/botProtection/verify.ts +134 -0
- package/templates/nextblock-template/lib/email/branding-format.test.ts +133 -0
- package/templates/nextblock-template/lib/email/branding-format.ts +123 -0
- package/templates/nextblock-template/lib/email/branding.ts +76 -0
- package/templates/nextblock-template/lib/logos/active-logo.ts +53 -0
- package/templates/nextblock-template/lib/media/resolveMediaUrl.ts +1 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +8 -213
- package/templates/nextblock-template/lib/visual-editing/draft-content.ts +4 -2
- package/templates/nextblock-template/lib/visual-editing/mutations.ts +2 -2
- package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +1 -0
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +16 -0
- package/templates/nextblock-template/public/images/nextblock-logo-button-tiny.png +0 -0
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
<
|
|
81
|
-
<
|
|
82
|
-
|
|
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
|
}
|
package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx
CHANGED
|
@@ -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:
|
|
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:
|
|
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 {
|
|
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
|
-
//
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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 !==
|
|
147
|
-
key !==
|
|
148
|
-
key !==
|
|
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 = `
|
|
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
|
|
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
|
|
13
|
-
text: `Your
|
|
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
|
|
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
|
|