create-nextblock 0.10.1 → 0.10.3
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/actions/email.ts +4 -3
- package/templates/nextblock-template/app/actions/formActions.ts +51 -42
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +245 -0
- package/templates/nextblock-template/app/api/webhooks/freemius/route.ts +4 -0
- package/templates/nextblock-template/app/checkout/success/actions.ts +2 -1
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +64 -20
- package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -0
- package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +118 -0
- package/templates/nextblock-template/app/cms/dashboard/page.tsx +6 -11
- package/templates/nextblock-template/app/cms/layout.tsx +8 -3
- package/templates/nextblock-template/app/cms/settings/email/actions.ts +60 -0
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +181 -0
- package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -0
- package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +60 -0
- package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +129 -0
- package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -0
- package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +5 -6
- package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +0 -48
- package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +4 -3
- package/templates/nextblock-template/app/cms/settings/registration/actions.ts +44 -0
- package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +65 -0
- package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -0
- package/templates/nextblock-template/app/cms/settings/security/actions.ts +3 -0
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +2 -2
- package/templates/nextblock-template/app/cms/settings/security/page.tsx +20 -0
- package/templates/nextblock-template/app/layout.tsx +5 -1
- package/templates/nextblock-template/app/setup/SetupWizard.tsx +15 -158
- package/templates/nextblock-template/app/setup/page.tsx +0 -8
- package/templates/nextblock-template/components/AppShell.tsx +0 -7
- package/templates/nextblock-template/components/BlockRenderer.tsx +9 -1
- package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -0
- package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +25 -20
- package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +13 -2
- package/templates/nextblock-template/docs/05-DEVELOPER-GUIDE.md +11 -0
- package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +59 -8
- package/templates/nextblock-template/docs/README.md +3 -0
- package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +11 -13
- package/templates/nextblock-template/lib/auth/twoFactor.ts +41 -0
- package/templates/nextblock-template/lib/config/email-settings.ts +217 -0
- package/templates/nextblock-template/lib/onboarding/actions.ts +31 -0
- package/templates/nextblock-template/lib/onboarding/status.ts +136 -0
- package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -0
- package/templates/nextblock-template/lib/privacy/settings.ts +12 -0
- package/templates/nextblock-template/lib/privacy/types.ts +3 -1
- package/templates/nextblock-template/lib/setup/actions.ts +6 -21
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
- package/templates/nextblock-template/next-env.d.ts +1 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -4,7 +4,7 @@ import { createClient } from '@nextblock-cms/db/server';
|
|
|
4
4
|
import { revalidatePath } from 'next/cache';
|
|
5
5
|
import {
|
|
6
6
|
getPrivacySettings as readPrivacySettings,
|
|
7
|
-
|
|
7
|
+
mergePrivacySettings,
|
|
8
8
|
} from '../../../../lib/privacy/settings';
|
|
9
9
|
import type { PrivacySettings } from '../../../../lib/privacy/types';
|
|
10
10
|
|
|
@@ -33,11 +33,10 @@ async function assertAdmin(): Promise<void> {
|
|
|
33
33
|
export async function updatePrivacySettings(formData: FormData) {
|
|
34
34
|
await assertAdmin();
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
// Analytics fields (GTM/GA4/custom scripts) are owned by the Google Analytics
|
|
37
|
+
// settings page; merge only the consent + corporate fields so they aren't clobbered.
|
|
38
|
+
const patch: Partial<PrivacySettings> = {
|
|
37
39
|
banner_enabled: formData.get('banner_enabled') === 'true',
|
|
38
|
-
gtm_id: (formData.get('gtm_id')?.toString() ?? '').trim(),
|
|
39
|
-
ga_measurement_id: (formData.get('ga_measurement_id')?.toString() ?? '').trim(),
|
|
40
|
-
custom_scripts: formData.get('custom_scripts')?.toString() ?? '',
|
|
41
40
|
corporate: {
|
|
42
41
|
legal_name: (formData.get('legal_name')?.toString() ?? '').trim(),
|
|
43
42
|
address: (formData.get('address')?.toString() ?? '').trim(),
|
|
@@ -45,7 +44,7 @@ export async function updatePrivacySettings(formData: FormData) {
|
|
|
45
44
|
},
|
|
46
45
|
};
|
|
47
46
|
|
|
48
|
-
await
|
|
47
|
+
await mergePrivacySettings(patch);
|
|
49
48
|
// Footer (corporate identity) and the analytics guard live in the root layout.
|
|
50
49
|
revalidatePath('/', 'layout');
|
|
51
50
|
|
|
@@ -26,9 +26,6 @@ export default function PrivacyForm({ initialSettings }: PrivacyFormProps) {
|
|
|
26
26
|
const [message, setMessage] = useState<Message | null>(null);
|
|
27
27
|
|
|
28
28
|
const [bannerEnabled, setBannerEnabled] = useState(initialSettings.banner_enabled);
|
|
29
|
-
const [gtmId, setGtmId] = useState(initialSettings.gtm_id);
|
|
30
|
-
const [gaId, setGaId] = useState(initialSettings.ga_measurement_id);
|
|
31
|
-
const [customScripts, setCustomScripts] = useState(initialSettings.custom_scripts);
|
|
32
29
|
const [legalName, setLegalName] = useState(initialSettings.corporate.legal_name);
|
|
33
30
|
const [address, setAddress] = useState(initialSettings.corporate.address);
|
|
34
31
|
const [supportEmail, setSupportEmail] = useState(initialSettings.corporate.support_email);
|
|
@@ -42,9 +39,6 @@ export default function PrivacyForm({ initialSettings }: PrivacyFormProps) {
|
|
|
42
39
|
|
|
43
40
|
const formData = new FormData();
|
|
44
41
|
formData.append('banner_enabled', String(bannerEnabled));
|
|
45
|
-
formData.append('gtm_id', gtmId);
|
|
46
|
-
formData.append('ga_measurement_id', gaId);
|
|
47
|
-
formData.append('custom_scripts', customScripts);
|
|
48
42
|
formData.append('legal_name', legalName);
|
|
49
43
|
formData.append('address', address);
|
|
50
44
|
formData.append('support_email', supportEmail);
|
|
@@ -84,48 +78,6 @@ export default function PrivacyForm({ initialSettings }: PrivacyFormProps) {
|
|
|
84
78
|
|
|
85
79
|
<Separator />
|
|
86
80
|
|
|
87
|
-
{/* Analytics */}
|
|
88
|
-
<section className="space-y-4">
|
|
89
|
-
<div>
|
|
90
|
-
<h3 className="text-sm font-semibold">Analytics & tracking</h3>
|
|
91
|
-
<p className="text-xs text-slate-500">
|
|
92
|
-
These load <strong>only</strong> after a visitor consents to analytics, so
|
|
93
|
-
the default page weight stays at zero tracking bytes.
|
|
94
|
-
</p>
|
|
95
|
-
</div>
|
|
96
|
-
<div className="space-y-2">
|
|
97
|
-
<Label htmlFor="gtm_id">Google Tag Manager ID</Label>
|
|
98
|
-
<Input
|
|
99
|
-
id="gtm_id"
|
|
100
|
-
value={gtmId}
|
|
101
|
-
onChange={(e) => setGtmId(e.target.value)}
|
|
102
|
-
placeholder="GTM-XXXXXXX"
|
|
103
|
-
/>
|
|
104
|
-
</div>
|
|
105
|
-
<div className="space-y-2">
|
|
106
|
-
<Label htmlFor="ga_measurement_id">GA4 Measurement ID (optional)</Label>
|
|
107
|
-
<Input
|
|
108
|
-
id="ga_measurement_id"
|
|
109
|
-
value={gaId}
|
|
110
|
-
onChange={(e) => setGaId(e.target.value)}
|
|
111
|
-
placeholder="G-XXXXXXXXXX"
|
|
112
|
-
/>
|
|
113
|
-
</div>
|
|
114
|
-
<div className="space-y-2">
|
|
115
|
-
<Label htmlFor="custom_scripts">Custom consented scripts (optional)</Label>
|
|
116
|
-
<Textarea
|
|
117
|
-
id="custom_scripts"
|
|
118
|
-
value={customScripts}
|
|
119
|
-
onChange={(e) => setCustomScripts(e.target.value)}
|
|
120
|
-
placeholder="<!-- Additional marketing/analytics <script> tags, injected only after consent -->"
|
|
121
|
-
rows={4}
|
|
122
|
-
className="font-mono text-xs"
|
|
123
|
-
/>
|
|
124
|
-
</div>
|
|
125
|
-
</section>
|
|
126
|
-
|
|
127
|
-
<Separator />
|
|
128
|
-
|
|
129
81
|
{/* Corporate identity */}
|
|
130
82
|
<section className="space-y-4">
|
|
131
83
|
<div>
|
|
@@ -12,9 +12,10 @@ export default async function PrivacySettingsPage() {
|
|
|
12
12
|
<CardHeader>
|
|
13
13
|
<CardTitle>Privacy & Consent (Law 25 / CASL)</CardTitle>
|
|
14
14
|
<CardDescription>
|
|
15
|
-
Control the Quebec Law 25 consent banner
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
Control the Quebec Law 25 consent banner and the corporate identity appended
|
|
16
|
+
to the CASL-compliant footer. Analytics tags (Google Tag Manager / GA4) are
|
|
17
|
+
configured under Settings → Google Analytics and download zero bytes until
|
|
18
|
+
a visitor opts in here.
|
|
18
19
|
</CardDescription>
|
|
19
20
|
</CardHeader>
|
|
20
21
|
<CardContent>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// app/cms/settings/registration/actions.ts
|
|
2
|
+
'use server';
|
|
3
|
+
|
|
4
|
+
import { createClient } from '@nextblock-cms/db/server';
|
|
5
|
+
import { revalidatePath } from 'next/cache';
|
|
6
|
+
import {
|
|
7
|
+
getSystemConfiguration,
|
|
8
|
+
updateSystemConfiguration,
|
|
9
|
+
} from '../../../../lib/setup/system-config';
|
|
10
|
+
|
|
11
|
+
export async function getRegistrationSettings(): Promise<{ autoAcceptSignups: boolean }> {
|
|
12
|
+
const config = await getSystemConfiguration();
|
|
13
|
+
return { autoAcceptSignups: config.auto_accept_signups };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function assertAdmin() {
|
|
17
|
+
const supabase = createClient();
|
|
18
|
+
const {
|
|
19
|
+
data: { user },
|
|
20
|
+
} = await supabase.auth.getUser();
|
|
21
|
+
if (!user) {
|
|
22
|
+
throw new Error('You must be logged in to update settings.');
|
|
23
|
+
}
|
|
24
|
+
const { data: profile, error } = await supabase
|
|
25
|
+
.from('profiles')
|
|
26
|
+
.select('role')
|
|
27
|
+
.eq('id', user.id)
|
|
28
|
+
.single();
|
|
29
|
+
if (error || !profile || profile.role !== 'ADMIN') {
|
|
30
|
+
throw new Error('You do not have permission to perform this action.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function updateRegistrationSettings(formData: FormData) {
|
|
35
|
+
await assertAdmin();
|
|
36
|
+
|
|
37
|
+
const autoAcceptSignups =
|
|
38
|
+
formData.get('autoAcceptSignups') === 'on' || formData.get('autoAcceptSignups') === 'true';
|
|
39
|
+
|
|
40
|
+
await updateSystemConfiguration({ auto_accept_signups: autoAcceptSignups });
|
|
41
|
+
|
|
42
|
+
revalidatePath('/cms/settings/registration');
|
|
43
|
+
return { success: true as const, message: 'Registration settings saved.' };
|
|
44
|
+
}
|
package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, useTransition } from 'react';
|
|
4
|
+
import { Alert, AlertDescription, Button, Checkbox, Spinner } from '@nextblock-cms/ui';
|
|
5
|
+
import { updateRegistrationSettings } from '../actions';
|
|
6
|
+
import type { Message } from '../../../../../components/form-message';
|
|
7
|
+
import { useHotkeys } from '../../../../../hooks/use-hotkeys';
|
|
8
|
+
|
|
9
|
+
interface RegistrationFormProps {
|
|
10
|
+
initialSettings: { autoAcceptSignups: boolean };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function RegistrationForm({ initialSettings }: RegistrationFormProps) {
|
|
14
|
+
const [isPending, startTransition] = useTransition();
|
|
15
|
+
const [message, setMessage] = useState<Message | null>(null);
|
|
16
|
+
const [autoAccept, setAutoAccept] = useState(initialSettings.autoAcceptSignups);
|
|
17
|
+
|
|
18
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
19
|
+
useHotkeys('ctrl+s', () => formRef.current?.requestSubmit());
|
|
20
|
+
|
|
21
|
+
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
|
22
|
+
event.preventDefault();
|
|
23
|
+
setMessage(null);
|
|
24
|
+
const formData = new FormData();
|
|
25
|
+
formData.append('autoAcceptSignups', autoAccept ? 'on' : '');
|
|
26
|
+
|
|
27
|
+
startTransition(async () => {
|
|
28
|
+
try {
|
|
29
|
+
const result = await updateRegistrationSettings(formData);
|
|
30
|
+
setMessage({ success: result.message });
|
|
31
|
+
} catch (error) {
|
|
32
|
+
setMessage({ error: error instanceof Error ? error.message : 'Failed to save settings.' });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
|
|
39
|
+
<label className="flex items-start gap-3 rounded-lg border p-4">
|
|
40
|
+
<Checkbox checked={autoAccept} onCheckedChange={(c) => setAutoAccept(c === true)} className="mt-1" />
|
|
41
|
+
<span className="text-sm">
|
|
42
|
+
<span className="font-medium">Auto-approve new registrations (skip email verification)</span>
|
|
43
|
+
<span className="block text-xs text-muted-foreground">
|
|
44
|
+
New sign-ups become active immediately, even without SMTP configured. Convenient for
|
|
45
|
+
local / self-hosted use. Leave off for public production sites so new accounts must
|
|
46
|
+
confirm their email address.
|
|
47
|
+
</span>
|
|
48
|
+
</span>
|
|
49
|
+
</label>
|
|
50
|
+
|
|
51
|
+
<div className="flex items-center gap-4">
|
|
52
|
+
<Button type="submit" disabled={isPending}>
|
|
53
|
+
{isPending ? (<><Spinner className="mr-2 h-4 w-4" /> Saving…</>) : 'Save settings'}
|
|
54
|
+
</Button>
|
|
55
|
+
{message && (
|
|
56
|
+
<Alert variant={'error' in message ? 'destructive' : 'success'} className="py-2 px-4 w-auto inline-flex items-center">
|
|
57
|
+
<AlertDescription>
|
|
58
|
+
{'error' in message ? message.error : 'success' in message ? message.success : message.message}
|
|
59
|
+
</AlertDescription>
|
|
60
|
+
</Alert>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
</form>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// app/cms/settings/registration/page.tsx
|
|
2
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
|
|
3
|
+
import { getRegistrationSettings } from './actions';
|
|
4
|
+
import RegistrationForm from './components/RegistrationForm';
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic';
|
|
7
|
+
|
|
8
|
+
export default async function RegistrationSettingsPage() {
|
|
9
|
+
const settings = await getRegistrationSettings();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="max-w-4xl mx-auto">
|
|
13
|
+
<Card>
|
|
14
|
+
<CardHeader>
|
|
15
|
+
<CardTitle>Sign-ups & Registration</CardTitle>
|
|
16
|
+
<CardDescription>
|
|
17
|
+
Control how new public registrations are handled. Email verification requires a
|
|
18
|
+
configured SMTP server (Settings → Configuration → Email).
|
|
19
|
+
</CardDescription>
|
|
20
|
+
</CardHeader>
|
|
21
|
+
<CardContent>
|
|
22
|
+
<RegistrationForm initialSettings={settings} />
|
|
23
|
+
</CardContent>
|
|
24
|
+
</Card>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -106,6 +106,9 @@ export async function updateAutoAcceptSignups(formData: FormData) {
|
|
|
106
106
|
// --- Global policy (admin only) -------------------------------------------------
|
|
107
107
|
|
|
108
108
|
export async function updateGlobalSecuritySettings(formData: FormData) {
|
|
109
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
110
|
+
throw new Error('Security settings are disabled in the sandbox environment.');
|
|
111
|
+
}
|
|
109
112
|
const { supabase, user } = await requireUser();
|
|
110
113
|
const { data: profile } = await supabase
|
|
111
114
|
.from('profiles')
|
|
@@ -503,8 +503,8 @@ function AdminPolicyCard({
|
|
|
503
503
|
<div className="space-y-1">
|
|
504
504
|
<Label htmlFor="enforce_staff_2fa">Encourage staff to enable 2FA</Label>
|
|
505
505
|
<p className="text-xs text-slate-500">
|
|
506
|
-
|
|
507
|
-
second factor.
|
|
506
|
+
Shows a reminder banner across the CMS to ADMIN/WRITER accounts that
|
|
507
|
+
haven’t set up a second factor. On by default.
|
|
508
508
|
</p>
|
|
509
509
|
</div>
|
|
510
510
|
</div>
|
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
// app/cms/settings/security/page.tsx
|
|
2
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
|
|
2
3
|
import { getSecurityPanelData } from './actions';
|
|
3
4
|
import SecurityPanel from './components/SecurityPanel';
|
|
4
5
|
|
|
5
6
|
export default async function SecuritySettingsPage() {
|
|
7
|
+
// The sandbox/demo runs on a single shared account, so per-account security (2FA,
|
|
8
|
+
// trusted devices, policy) is intentionally disabled there.
|
|
9
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
10
|
+
return (
|
|
11
|
+
<div className="max-w-4xl mx-auto">
|
|
12
|
+
<Card>
|
|
13
|
+
<CardHeader>
|
|
14
|
+
<CardTitle>Security & 2FA</CardTitle>
|
|
15
|
+
<CardDescription>
|
|
16
|
+
Security settings are disabled in the sandbox/demo environment, which runs on a
|
|
17
|
+
shared account and resets daily. They are available in a real installation.
|
|
18
|
+
</CardDescription>
|
|
19
|
+
</CardHeader>
|
|
20
|
+
<CardContent />
|
|
21
|
+
</Card>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
6
26
|
const data = await getSecurityPanelData();
|
|
7
27
|
|
|
8
28
|
return (
|
|
@@ -440,7 +440,10 @@ export default async function RootLayout({
|
|
|
440
440
|
privacySettings,
|
|
441
441
|
} = await loadLayoutData();
|
|
442
442
|
const draft = await draftMode();
|
|
443
|
-
|
|
443
|
+
// GTM container id comes solely from the privacy settings row (site_settings).
|
|
444
|
+
// There is intentionally no NEXT_PUBLIC_GTM_ID env fallback — analytics is
|
|
445
|
+
// configured in the CMS (Settings -> Privacy), not via build-time env.
|
|
446
|
+
const resolvedGtmId = privacySettings.gtm_id || '';
|
|
444
447
|
const visualEditingEnabled =
|
|
445
448
|
draft.isEnabled || process.env.NEXTBLOCK_VISUAL_EDITING_ENABLED === 'true';
|
|
446
449
|
const isVercelDeployment = process.env.VERCEL === '1';
|
|
@@ -519,6 +522,7 @@ export default async function RootLayout({
|
|
|
519
522
|
<DeferredSpeedInsights />
|
|
520
523
|
<ConsentGatedAnalytics
|
|
521
524
|
gtmId={resolvedGtmId}
|
|
525
|
+
gaMeasurementId={privacySettings.ga_measurement_id || ''}
|
|
522
526
|
customScripts={privacySettings.custom_scripts}
|
|
523
527
|
nonce={nonce}
|
|
524
528
|
/>
|
|
@@ -35,14 +35,6 @@ export interface StoragePrefill {
|
|
|
35
35
|
secretAccessKey: string;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
interface SmtpPrefill {
|
|
39
|
-
host: string;
|
|
40
|
-
port: string;
|
|
41
|
-
user: string;
|
|
42
|
-
fromEmail: string;
|
|
43
|
-
fromName: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
38
|
export interface SupabaseEnvDetected {
|
|
47
39
|
NEXT_PUBLIC_SUPABASE_URL: boolean;
|
|
48
40
|
SUPABASE_URL: boolean;
|
|
@@ -58,20 +50,19 @@ interface Props {
|
|
|
58
50
|
writable: boolean;
|
|
59
51
|
siteUrl: string;
|
|
60
52
|
storagePrefill: StoragePrefill;
|
|
61
|
-
smtpPrefill: SmtpPrefill;
|
|
62
|
-
turnstilePrefill: { siteKey: string };
|
|
63
53
|
/** Which Supabase env vars the running deployment can actually see (read-only channels). */
|
|
64
54
|
supabaseEnvDetected: SupabaseEnvDetected;
|
|
65
55
|
}
|
|
66
56
|
|
|
67
|
-
|
|
57
|
+
// Email (SMTP), bot protection, and sign-up policy are no longer collected here — they
|
|
58
|
+
// moved to the CMS (Settings → Configuration) and are nudged from the dashboard onboarding
|
|
59
|
+
// checklist. A fresh install only needs the database connection, media storage, and the
|
|
60
|
+
// first administrator account.
|
|
61
|
+
type StepId = 'connection' | 'storage' | 'admin';
|
|
68
62
|
|
|
69
63
|
const STEP_TITLES: Record<StepId, string> = {
|
|
70
64
|
connection: 'Database connection',
|
|
71
65
|
storage: 'Media storage',
|
|
72
|
-
email: 'Email (SMTP)',
|
|
73
|
-
bot: 'Bot protection',
|
|
74
|
-
signups: 'Sign-ups',
|
|
75
66
|
admin: 'Administrator account',
|
|
76
67
|
};
|
|
77
68
|
|
|
@@ -89,8 +80,6 @@ export default function SetupWizard({
|
|
|
89
80
|
writable,
|
|
90
81
|
siteUrl,
|
|
91
82
|
storagePrefill,
|
|
92
|
-
smtpPrefill,
|
|
93
|
-
turnstilePrefill,
|
|
94
83
|
supabaseEnvDetected,
|
|
95
84
|
}: Props) {
|
|
96
85
|
const [isPending, startTransition] = useTransition();
|
|
@@ -110,15 +99,8 @@ export default function SetupWizard({
|
|
|
110
99
|
// "Start from a clean database" — only offered/honored on a local fresh install.
|
|
111
100
|
const [resetFirst, setResetFirst] = useState(true);
|
|
112
101
|
|
|
113
|
-
// Storage /
|
|
102
|
+
// Storage / admin.
|
|
114
103
|
const [storage, setStorage] = useState(storagePrefill);
|
|
115
|
-
const [smtp, setSmtp] = useState({ ...smtpPrefill, pass: '' });
|
|
116
|
-
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
|
|
117
|
-
const [turnstile, setTurnstile] = useState({
|
|
118
|
-
siteKey: turnstilePrefill.siteKey,
|
|
119
|
-
secretKey: '',
|
|
120
|
-
});
|
|
121
|
-
const [autoAccept, setAutoAccept] = useState(false); // off by default (defense-in-depth)
|
|
122
104
|
const [admin, setAdmin] = useState({ email: '', password: '', fullName: '' });
|
|
123
105
|
|
|
124
106
|
const steps = useMemo<StepId[]>(() => {
|
|
@@ -131,7 +113,7 @@ export default function SetupWizard({
|
|
|
131
113
|
if (channel !== 'vercel') {
|
|
132
114
|
list.push('storage');
|
|
133
115
|
}
|
|
134
|
-
list.push('
|
|
116
|
+
list.push('admin');
|
|
135
117
|
return list;
|
|
136
118
|
}, [configured, channel]);
|
|
137
119
|
|
|
@@ -205,12 +187,6 @@ export default function SetupWizard({
|
|
|
205
187
|
// (baseUrl only differs if a channel prefilled a separate custom domain).
|
|
206
188
|
put('NEXT_PUBLIC_R2_PUBLIC_URL', storage.publicUrl);
|
|
207
189
|
put('NEXT_PUBLIC_R2_BASE_URL', storage.baseUrl || storage.publicUrl);
|
|
208
|
-
put('SMTP_HOST', smtp.host);
|
|
209
|
-
put('SMTP_PORT', smtp.port);
|
|
210
|
-
put('SMTP_USER', smtp.user);
|
|
211
|
-
put('SMTP_PASS', smtp.pass);
|
|
212
|
-
put('SMTP_FROM_EMAIL', smtp.fromEmail);
|
|
213
|
-
put('SMTP_FROM_NAME', smtp.fromName);
|
|
214
190
|
}
|
|
215
191
|
|
|
216
192
|
setMessage(null);
|
|
@@ -218,12 +194,8 @@ export default function SetupWizard({
|
|
|
218
194
|
startTransition(async () => {
|
|
219
195
|
const result = await completeSetup({
|
|
220
196
|
admin,
|
|
221
|
-
autoAcceptSignups: autoAccept,
|
|
222
197
|
envValues,
|
|
223
198
|
resetFirst: resetFirst && writable,
|
|
224
|
-
turnstile: turnstileEnabled
|
|
225
|
-
? { provider: 'turnstile', siteKey: turnstile.siteKey, secretKey: turnstile.secretKey }
|
|
226
|
-
: { provider: 'none', siteKey: '', secretKey: '' },
|
|
227
199
|
});
|
|
228
200
|
|
|
229
201
|
if (!result.ok) {
|
|
@@ -400,122 +372,6 @@ export default function SetupWizard({
|
|
|
400
372
|
<StorageStep storage={storage} setStorage={setStorage} channel={channel} />
|
|
401
373
|
)}
|
|
402
374
|
|
|
403
|
-
{current === 'email' && (
|
|
404
|
-
<div className="space-y-4">
|
|
405
|
-
{channel === 'docker' && (
|
|
406
|
-
<p className="text-xs text-muted-foreground">
|
|
407
|
-
Docker auto-confirms sign-ups when SMTP is not configured, so this is optional.
|
|
408
|
-
</p>
|
|
409
|
-
)}
|
|
410
|
-
<div className="grid gap-4 sm:grid-cols-2">
|
|
411
|
-
<Field label="SMTP host" htmlFor="smtpHost">
|
|
412
|
-
<Input
|
|
413
|
-
id="smtpHost"
|
|
414
|
-
value={smtp.host}
|
|
415
|
-
onChange={(e) => setSmtp({ ...smtp, host: e.target.value })}
|
|
416
|
-
/>
|
|
417
|
-
</Field>
|
|
418
|
-
<Field label="Port" htmlFor="smtpPort">
|
|
419
|
-
<Input
|
|
420
|
-
id="smtpPort"
|
|
421
|
-
value={smtp.port}
|
|
422
|
-
onChange={(e) => setSmtp({ ...smtp, port: e.target.value })}
|
|
423
|
-
/>
|
|
424
|
-
</Field>
|
|
425
|
-
<Field label="Username" htmlFor="smtpUser">
|
|
426
|
-
<Input
|
|
427
|
-
id="smtpUser"
|
|
428
|
-
value={smtp.user}
|
|
429
|
-
onChange={(e) => setSmtp({ ...smtp, user: e.target.value })}
|
|
430
|
-
/>
|
|
431
|
-
</Field>
|
|
432
|
-
<Field label="Password" htmlFor="smtpPass">
|
|
433
|
-
<Input
|
|
434
|
-
id="smtpPass"
|
|
435
|
-
type="password"
|
|
436
|
-
value={smtp.pass}
|
|
437
|
-
onChange={(e) => setSmtp({ ...smtp, pass: e.target.value })}
|
|
438
|
-
/>
|
|
439
|
-
</Field>
|
|
440
|
-
<Field label="From email" htmlFor="smtpFromEmail">
|
|
441
|
-
<Input
|
|
442
|
-
id="smtpFromEmail"
|
|
443
|
-
value={smtp.fromEmail}
|
|
444
|
-
onChange={(e) => setSmtp({ ...smtp, fromEmail: e.target.value })}
|
|
445
|
-
/>
|
|
446
|
-
</Field>
|
|
447
|
-
<Field label="From name" htmlFor="smtpFromName">
|
|
448
|
-
<Input
|
|
449
|
-
id="smtpFromName"
|
|
450
|
-
value={smtp.fromName}
|
|
451
|
-
onChange={(e) => setSmtp({ ...smtp, fromName: e.target.value })}
|
|
452
|
-
/>
|
|
453
|
-
</Field>
|
|
454
|
-
</div>
|
|
455
|
-
{!writable && channel !== 'docker' && (
|
|
456
|
-
<p className="text-xs text-muted-foreground">
|
|
457
|
-
On this platform, set SMTP_* as environment variables in your hosting dashboard.
|
|
458
|
-
</p>
|
|
459
|
-
)}
|
|
460
|
-
</div>
|
|
461
|
-
)}
|
|
462
|
-
|
|
463
|
-
{current === 'bot' && (
|
|
464
|
-
<div className="space-y-4">
|
|
465
|
-
<label className="flex items-start gap-3">
|
|
466
|
-
<Checkbox
|
|
467
|
-
checked={turnstileEnabled}
|
|
468
|
-
onCheckedChange={(c) => setTurnstileEnabled(c === true)}
|
|
469
|
-
className="mt-1"
|
|
470
|
-
/>
|
|
471
|
-
<span className="text-sm">
|
|
472
|
-
<span className="font-medium">Enable Cloudflare Turnstile</span>
|
|
473
|
-
<span className="block text-xs text-muted-foreground">
|
|
474
|
-
Protects sign-up / sign-in forms. Stored securely in the database.
|
|
475
|
-
</span>
|
|
476
|
-
</span>
|
|
477
|
-
</label>
|
|
478
|
-
{turnstileEnabled && (
|
|
479
|
-
<div className="grid gap-4 sm:grid-cols-2">
|
|
480
|
-
<Field label="Site key" htmlFor="tsSite">
|
|
481
|
-
<Input
|
|
482
|
-
id="tsSite"
|
|
483
|
-
value={turnstile.siteKey}
|
|
484
|
-
onChange={(e) => setTurnstile({ ...turnstile, siteKey: e.target.value })}
|
|
485
|
-
/>
|
|
486
|
-
</Field>
|
|
487
|
-
<Field label="Secret key" htmlFor="tsSecret">
|
|
488
|
-
<Input
|
|
489
|
-
id="tsSecret"
|
|
490
|
-
type="password"
|
|
491
|
-
value={turnstile.secretKey}
|
|
492
|
-
onChange={(e) => setTurnstile({ ...turnstile, secretKey: e.target.value })}
|
|
493
|
-
/>
|
|
494
|
-
</Field>
|
|
495
|
-
</div>
|
|
496
|
-
)}
|
|
497
|
-
</div>
|
|
498
|
-
)}
|
|
499
|
-
|
|
500
|
-
{current === 'signups' && (
|
|
501
|
-
<label className="flex items-start gap-3">
|
|
502
|
-
<Checkbox
|
|
503
|
-
checked={autoAccept}
|
|
504
|
-
onCheckedChange={(c) => setAutoAccept(c === true)}
|
|
505
|
-
className="mt-1"
|
|
506
|
-
/>
|
|
507
|
-
<span className="text-sm">
|
|
508
|
-
<span className="font-medium">
|
|
509
|
-
Auto-approve local registrations (skip outbound email verification)
|
|
510
|
-
</span>
|
|
511
|
-
<span className="block text-xs text-muted-foreground">
|
|
512
|
-
New sign-ups become active immediately, even without SMTP configured. Convenient
|
|
513
|
-
for local / self-hosted use; leave off for public production sites.
|
|
514
|
-
</span>
|
|
515
|
-
</span>
|
|
516
|
-
</label>
|
|
517
|
-
)}
|
|
518
|
-
|
|
519
375
|
{current === 'admin' && (
|
|
520
376
|
<div className="space-y-4">
|
|
521
377
|
<Field label="Full name" htmlFor="adminName">
|
|
@@ -546,6 +402,13 @@ export default function SetupWizard({
|
|
|
546
402
|
verification email needed). Finishing also applies the database schema and saved
|
|
547
403
|
settings, so it can take up to a minute.
|
|
548
404
|
</p>
|
|
405
|
+
<Alert className="py-2 px-4">
|
|
406
|
+
<AlertDescription className="text-xs">
|
|
407
|
+
Next, your dashboard will guide you through finishing setup — branding, copyright,
|
|
408
|
+
email (SMTP), payment providers, and bot protection are all configured there, no
|
|
409
|
+
environment variables required.
|
|
410
|
+
</AlertDescription>
|
|
411
|
+
</Alert>
|
|
549
412
|
|
|
550
413
|
{writable && (
|
|
551
414
|
<label className="flex items-start gap-3 rounded-lg border p-3">
|
|
@@ -606,14 +469,8 @@ function stepDescription(step: StepId, channel: DeployChannel): string {
|
|
|
606
469
|
: channel === 'vercel'
|
|
607
470
|
? 'Using Supabase Storage (S3-compatible) for media.'
|
|
608
471
|
: 'Bring your own Cloudflare R2 bucket for media storage.';
|
|
609
|
-
case 'email':
|
|
610
|
-
return 'Optional. Used for verification emails, password resets, and 2FA codes.';
|
|
611
|
-
case 'bot':
|
|
612
|
-
return 'Optional. Add Cloudflare Turnstile to your auth forms.';
|
|
613
|
-
case 'signups':
|
|
614
|
-
return 'Choose how new public registrations are handled.';
|
|
615
472
|
case 'admin':
|
|
616
|
-
return 'Create the first administrator account.';
|
|
473
|
+
return 'Create the first administrator account — the last step. Email, payments, branding, and the rest are configured from your dashboard.';
|
|
617
474
|
default:
|
|
618
475
|
return '';
|
|
619
476
|
}
|
|
@@ -91,14 +91,6 @@ export default async function SetupPage() {
|
|
|
91
91
|
writable={isLocalWritableEnv()}
|
|
92
92
|
siteUrl={process.env.NEXT_PUBLIC_URL ?? ''}
|
|
93
93
|
storagePrefill={buildStoragePrefill(channel, supabaseUrl)}
|
|
94
|
-
smtpPrefill={{
|
|
95
|
-
host: process.env.SMTP_HOST ?? '',
|
|
96
|
-
port: process.env.SMTP_PORT ?? '465',
|
|
97
|
-
user: process.env.SMTP_USER ?? '',
|
|
98
|
-
fromEmail: process.env.SMTP_FROM_EMAIL ?? '',
|
|
99
|
-
fromName: process.env.SMTP_FROM_NAME ?? '',
|
|
100
|
-
}}
|
|
101
|
-
turnstilePrefill={{ siteKey: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? '' }}
|
|
102
94
|
supabaseEnvDetected={{
|
|
103
95
|
NEXT_PUBLIC_SUPABASE_URL: Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL),
|
|
104
96
|
SUPABASE_URL: Boolean(process.env.SUPABASE_URL),
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
import type { Database } from '@nextblock-cms/db';
|
|
4
4
|
import { cn } from '@nextblock-cms/utils';
|
|
5
5
|
import { usePathname } from 'next/navigation';
|
|
6
|
-
import Link from 'next/link';
|
|
7
6
|
import { createContext, useContext, useMemo, type ReactNode } from 'react';
|
|
8
7
|
import Header from './Header';
|
|
9
8
|
import FooterNavigation from './FooterNavigation';
|
|
@@ -148,12 +147,6 @@ export function AppShell({
|
|
|
148
147
|
)}
|
|
149
148
|
<div className="flex flex-row items-center gap-4">
|
|
150
149
|
<p className="text-muted-foreground">{copyrightText}</p>
|
|
151
|
-
<Link href="/privacy-policy" className="hover:underline">
|
|
152
|
-
Privacy Policy
|
|
153
|
-
</Link>
|
|
154
|
-
<Link href="/terms-of-service" className="hover:underline">
|
|
155
|
-
Terms of Service
|
|
156
|
-
</Link>
|
|
157
150
|
<ThemeSwitcher />
|
|
158
151
|
</div>
|
|
159
152
|
</div>
|
|
@@ -17,6 +17,7 @@ import ClientTextBlockRenderer from "./blocks/renderers/ClientTextBlockRenderer"
|
|
|
17
17
|
import { getCachedCustomBlockDefinitionBySlug } from "../lib/custom-block-definitions";
|
|
18
18
|
import { CachedDynamicLayoutEngine } from "./renderers/CachedDynamicLayoutEngine";
|
|
19
19
|
import { resolveBlockRelations } from "../lib/resolve-block-relations";
|
|
20
|
+
import { substitutePrivacyMergeTags } from "../lib/privacy/contact-emails";
|
|
20
21
|
|
|
21
22
|
const ECOMMERCE_BLOCK_TYPES = new Set([
|
|
22
23
|
"product_grid",
|
|
@@ -143,9 +144,16 @@ async function renderLoadedBlock({
|
|
|
143
144
|
|
|
144
145
|
// Keep common LCP-adjacent text blocks out of the dynamic renderer manifest.
|
|
145
146
|
if (block.block_type === 'text') {
|
|
147
|
+
// Top-level text blocks bypass the server TextBlockRenderer, so resolve any
|
|
148
|
+
// merge tags (e.g. {{privacy_email}} on the Privacy/Terms pages) here.
|
|
149
|
+
const textContent = block.content as { html_content?: string } | null;
|
|
150
|
+
const rawHtml = typeof textContent?.html_content === 'string' ? textContent.html_content : '';
|
|
151
|
+
const html = rawHtml.includes('{{')
|
|
152
|
+
? await substitutePrivacyMergeTags(rawHtml)
|
|
153
|
+
: rawHtml;
|
|
146
154
|
return (
|
|
147
155
|
<ClientTextBlockRenderer
|
|
148
|
-
content={
|
|
156
|
+
content={{ ...(textContent as any), html_content: html }}
|
|
149
157
|
languageId={languageId}
|
|
150
158
|
visualEditAttributes={visualEditAttributes}
|
|
151
159
|
/>
|