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.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/actions/email.ts +4 -3
  3. package/templates/nextblock-template/app/actions/formActions.ts +51 -42
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +245 -0
  5. package/templates/nextblock-template/app/api/webhooks/freemius/route.ts +4 -0
  6. package/templates/nextblock-template/app/checkout/success/actions.ts +2 -1
  7. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +64 -20
  8. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -0
  9. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +118 -0
  10. package/templates/nextblock-template/app/cms/dashboard/page.tsx +6 -11
  11. package/templates/nextblock-template/app/cms/layout.tsx +8 -3
  12. package/templates/nextblock-template/app/cms/settings/email/actions.ts +60 -0
  13. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +181 -0
  14. package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -0
  15. package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +60 -0
  16. package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +129 -0
  17. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -0
  18. package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +5 -6
  19. package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +0 -48
  20. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +4 -3
  21. package/templates/nextblock-template/app/cms/settings/registration/actions.ts +44 -0
  22. package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +65 -0
  23. package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -0
  24. package/templates/nextblock-template/app/cms/settings/security/actions.ts +3 -0
  25. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +2 -2
  26. package/templates/nextblock-template/app/cms/settings/security/page.tsx +20 -0
  27. package/templates/nextblock-template/app/layout.tsx +5 -1
  28. package/templates/nextblock-template/app/setup/SetupWizard.tsx +15 -158
  29. package/templates/nextblock-template/app/setup/page.tsx +0 -8
  30. package/templates/nextblock-template/components/AppShell.tsx +0 -7
  31. package/templates/nextblock-template/components/BlockRenderer.tsx +9 -1
  32. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -0
  33. package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +25 -20
  34. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +13 -2
  35. package/templates/nextblock-template/docs/05-DEVELOPER-GUIDE.md +11 -0
  36. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +59 -8
  37. package/templates/nextblock-template/docs/README.md +3 -0
  38. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +11 -13
  39. package/templates/nextblock-template/lib/auth/twoFactor.ts +41 -0
  40. package/templates/nextblock-template/lib/config/email-settings.ts +217 -0
  41. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -0
  42. package/templates/nextblock-template/lib/onboarding/status.ts +136 -0
  43. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -0
  44. package/templates/nextblock-template/lib/privacy/settings.ts +12 -0
  45. package/templates/nextblock-template/lib/privacy/types.ts +3 -1
  46. package/templates/nextblock-template/lib/setup/actions.ts +6 -21
  47. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
  48. package/templates/nextblock-template/next-env.d.ts +1 -1
  49. package/templates/nextblock-template/package.json +1 -1
  50. 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
- savePrivacySettings,
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
- const settings: PrivacySettings = {
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 savePrivacySettings(settings);
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 &amp; 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 &amp; Consent (Law 25 / CASL)</CardTitle>
14
14
  <CardDescription>
15
- Control the Quebec Law 25 consent banner, the analytics that load only
16
- after consent, and the corporate identity appended to the CASL-compliant
17
- footer. Analytics scripts download zero bytes until a visitor opts in.
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 &rarr; 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
+ }
@@ -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 &amp; 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
- Surfaces a reminder for ADMIN/WRITER accounts that haven&rsquo;t set up a
507
- second factor.
506
+ Shows a reminder banner across the CMS to ADMIN/WRITER accounts that
507
+ haven&rsquo;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 &amp; 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
- const resolvedGtmId = privacySettings.gtm_id || process.env.NEXT_PUBLIC_GTM_ID || '';
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
- type StepId = 'connection' | 'storage' | 'email' | 'bot' | 'signups' | 'admin';
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 / SMTP / bot / signups / admin.
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('email', 'bot', 'signups', 'admin');
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={block.content as any}
156
+ content={{ ...(textContent as any), html_content: html }}
149
157
  languageId={languageId}
150
158
  visualEditAttributes={visualEditAttributes}
151
159
  />