create-nextblock 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +281 -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
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useState } from 'react';
5
+ import { Button } from '@nextblock-cms/ui';
6
+ import { ShieldAlert, X } from 'lucide-react';
7
+
8
+ /**
9
+ * In-app reminder for staff (ADMIN/WRITER) who have not enrolled a second factor,
10
+ * shown when the "Encourage staff to enable 2FA" policy is on. Dismissible for the
11
+ * session (state lives in the persistent CMS layout, so it survives client-side
12
+ * navigation but reappears on the next full load until 2FA is set up).
13
+ */
14
+ export default function TwoFactorReminderBanner() {
15
+ const [dismissed, setDismissed] = useState(false);
16
+ if (dismissed) return null;
17
+
18
+ return (
19
+ <div className="mb-6 flex items-start gap-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-amber-900 dark:border-amber-700/60 dark:bg-amber-900/20 dark:text-amber-200">
20
+ <ShieldAlert className="mt-0.5 h-5 w-5 shrink-0" />
21
+ <div className="min-w-0 flex-1">
22
+ <p className="text-sm font-medium">
23
+ Your account doesn&rsquo;t have two-factor authentication enabled.
24
+ </p>
25
+ <p className="text-xs opacity-90">
26
+ Protect your CMS account by adding a second factor (authenticator app or email code).
27
+ </p>
28
+ </div>
29
+ <div className="flex shrink-0 items-center gap-2">
30
+ <Button asChild size="sm" variant="outline" className="border-amber-400">
31
+ <Link href="/cms/settings/security">Set up 2FA</Link>
32
+ </Button>
33
+ <Button
34
+ variant="ghost"
35
+ size="icon"
36
+ onClick={() => setDismissed(true)}
37
+ aria-label="Dismiss for now"
38
+ className="h-8 w-8"
39
+ >
40
+ <X className="h-4 w-4" />
41
+ </Button>
42
+ </div>
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,118 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useTransition } from 'react';
5
+ import {
6
+ Button,
7
+ Card,
8
+ CardContent,
9
+ CardDescription,
10
+ CardHeader,
11
+ CardTitle,
12
+ } from '@nextblock-cms/ui';
13
+ import { ArrowRight, CheckCircle2, Circle, X } from 'lucide-react';
14
+ import type { OnboardingStatus } from '../../../../lib/onboarding/status';
15
+
16
+ export default function DashboardOnboarding({
17
+ status,
18
+ dismissAction,
19
+ }: {
20
+ status: OnboardingStatus;
21
+ dismissAction: (dismissed: boolean) => Promise<void>;
22
+ }) {
23
+ const [isPending, startTransition] = useTransition();
24
+
25
+ if (status.dismissed) return null;
26
+
27
+ const pct = status.total > 0 ? Math.round((status.completed / status.total) * 100) : 0;
28
+ const allDone = status.completed >= status.total;
29
+
30
+ const dismiss = () => {
31
+ startTransition(async () => {
32
+ await dismissAction(true);
33
+ });
34
+ };
35
+
36
+ return (
37
+ <Card className="border-primary/30 bg-primary/[0.03]">
38
+ <CardHeader className="pb-3">
39
+ <div className="flex items-start justify-between gap-4">
40
+ <div className="space-y-1">
41
+ <CardTitle className="flex items-center gap-2">
42
+ {allDone ? '🎉 Your site is ready' : 'Finish setting up your site'}
43
+ </CardTitle>
44
+ <CardDescription>
45
+ {allDone
46
+ ? 'Every recommended step is complete. You can dismiss this checklist.'
47
+ : 'Complete these steps to get the most out of NextBlock. You can do them in any order.'}
48
+ </CardDescription>
49
+ </div>
50
+ <Button
51
+ variant="ghost"
52
+ size="icon"
53
+ onClick={dismiss}
54
+ disabled={isPending}
55
+ aria-label="Dismiss onboarding checklist"
56
+ className="shrink-0"
57
+ >
58
+ <X className="h-4 w-4" />
59
+ </Button>
60
+ </div>
61
+
62
+ <div className="mt-3 space-y-1.5">
63
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
64
+ <span>
65
+ {status.completed} of {status.total} complete
66
+ </span>
67
+ <span>{pct}%</span>
68
+ </div>
69
+ <div className="h-2 w-full overflow-hidden rounded-full bg-muted">
70
+ <div
71
+ className="h-full rounded-full bg-primary transition-all duration-500 ease-out"
72
+ style={{ width: `${pct}%` }}
73
+ />
74
+ </div>
75
+ </div>
76
+ </CardHeader>
77
+
78
+ <CardContent className="pt-0">
79
+ <ul className="divide-y">
80
+ {status.steps.map((step) => (
81
+ <li key={step.key} className="flex items-center gap-3 py-3">
82
+ {step.done ? (
83
+ <CheckCircle2 className="h-5 w-5 shrink-0 text-emerald-600" />
84
+ ) : (
85
+ <Circle className="h-5 w-5 shrink-0 text-muted-foreground/50" />
86
+ )}
87
+ <div className="min-w-0 flex-1">
88
+ <div className="flex items-center gap-2">
89
+ <span
90
+ className={`text-sm font-medium ${
91
+ step.done ? 'text-muted-foreground line-through' : ''
92
+ }`}
93
+ >
94
+ {step.title}
95
+ </span>
96
+ {step.optional && (
97
+ <span className="rounded-full border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
98
+ Optional
99
+ </span>
100
+ )}
101
+ </div>
102
+ <p className="text-xs text-muted-foreground">{step.description}</p>
103
+ </div>
104
+ {!step.done && step.key !== 'admin' && (
105
+ <Button asChild variant="outline" size="sm" className="shrink-0">
106
+ <Link href={step.href}>
107
+ Set up
108
+ <ArrowRight className="ml-1 h-3.5 w-3.5" />
109
+ </Link>
110
+ </Button>
111
+ )}
112
+ </li>
113
+ ))}
114
+ </ul>
115
+ </CardContent>
116
+ </Card>
117
+ );
118
+ }
@@ -1,26 +1,21 @@
1
- import { Alert, AlertDescription, AlertTitle, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@nextblock-cms/ui"
1
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@nextblock-cms/ui"
2
2
  import { Calendar, FileText, PenTool, Users, Eye, TrendingUp, DollarSign, Receipt, ShoppingCart } from "lucide-react"
3
3
  import { getDashboardStats } from "./actions"
4
4
  import { MetricCard, SalesLedger, PremiumCTA } from "./components/DashboardComponents"
5
+ import DashboardOnboarding from "./components/DashboardOnboarding"
6
+ import { getOnboardingStatus } from "../../../lib/onboarding/status"
7
+ import { setOnboardingDismissed } from "../../../lib/onboarding/actions"
5
8
  import { formatPrice } from "@nextblock-cms/utils"
6
9
 
7
10
  export default async function CmsDashboardPage() {
8
11
  const stats = await getDashboardStats();
9
- const gtmId = process.env.NEXT_PUBLIC_GTM_ID;
10
- const showWarning = !gtmId && gtmId !== "false";
12
+ const onboarding = await getOnboardingStatus({ isEcommerceActive: stats.isEcommerceActive });
11
13
 
12
14
  const isEcommerce = stats.isEcommerceActive;
13
15
 
14
16
  return (
15
17
  <div className="w-full space-y-8">
16
- {showWarning && (
17
- <Alert variant="destructive">
18
- <AlertTitle>Google Tag Manager Not Configured</AlertTitle>
19
- <AlertDescription>
20
- Missing <code>NEXT_PUBLIC_GTM_ID</code>. Analytics tracking is disabled.
21
- </AlertDescription>
22
- </Alert>
23
- )}
18
+ <DashboardOnboarding status={onboarding} dismissAction={setOnboardingDismissed} />
24
19
 
25
20
  <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
26
21
  <div>
@@ -2,7 +2,7 @@ import 'katex/dist/katex.min.css';
2
2
  import { redirect } from 'next/navigation';
3
3
  import CmsClientLayout from "./CmsClientLayout";
4
4
  import { verifyPackageOnline } from '@nextblock-cms/db/server';
5
- import { evaluateTwoFactor } from '../../lib/auth/twoFactor';
5
+ import { evaluateTwoFactor, getStaffTwoFactorReminder } from '../../lib/auth/twoFactor';
6
6
 
7
7
  export default async function CmsLayout({
8
8
  children,
@@ -16,13 +16,18 @@ export default async function CmsLayout({
16
16
  redirect('/two-factor?redirect_to=/cms/dashboard');
17
17
  }
18
18
 
19
- const [isEcommerceActive, isCortexAiActive] = await Promise.all([
19
+ const [isEcommerceActive, isCortexAiActive, showTwoFactorReminder] = await Promise.all([
20
20
  verifyPackageOnline('ecommerce'),
21
21
  verifyPackageOnline('cortex-ai'),
22
+ getStaffTwoFactorReminder(),
22
23
  ]);
23
24
 
24
25
  return (
25
- <CmsClientLayout isCortexAiActive={isCortexAiActive} isEcommerceActive={isEcommerceActive}>
26
+ <CmsClientLayout
27
+ isCortexAiActive={isCortexAiActive}
28
+ isEcommerceActive={isEcommerceActive}
29
+ showTwoFactorReminder={showTwoFactorReminder}
30
+ >
26
31
  {children}
27
32
  </CmsClientLayout>
28
33
  );
@@ -0,0 +1,60 @@
1
+ // app/cms/settings/email/actions.ts
2
+ 'use server';
3
+
4
+ import { createClient } from '@nextblock-cms/db/server';
5
+ import { revalidatePath } from 'next/cache';
6
+ import { saveEmailSettings } from '../../../../lib/config/email-settings';
7
+ import { sendEmail } from '../../../actions/email';
8
+
9
+ async function assertAdmin() {
10
+ const supabase = createClient();
11
+ const {
12
+ data: { user },
13
+ } = await supabase.auth.getUser();
14
+ if (!user) {
15
+ throw new Error('You must be logged in to update settings.');
16
+ }
17
+ const { data: profile, error } = await supabase
18
+ .from('profiles')
19
+ .select('role')
20
+ .eq('id', user.id)
21
+ .single();
22
+ if (error || !profile || profile.role !== 'ADMIN') {
23
+ throw new Error('You do not have permission to perform this action.');
24
+ }
25
+ }
26
+
27
+ export async function updateEmailSettings(formData: FormData) {
28
+ await assertAdmin();
29
+
30
+ await saveEmailSettings({
31
+ host: String(formData.get('host') ?? ''),
32
+ port: String(formData.get('port') ?? ''),
33
+ fromEmail: String(formData.get('fromEmail') ?? ''),
34
+ fromName: String(formData.get('fromName') ?? ''),
35
+ secure: formData.get('secure') === 'on' || formData.get('secure') === 'true',
36
+ user: String(formData.get('user') ?? ''),
37
+ pass: String(formData.get('pass') ?? ''),
38
+ });
39
+
40
+ revalidatePath('/cms/settings/email');
41
+ return { success: true as const, message: 'Email settings saved.' };
42
+ }
43
+
44
+ export async function sendTestEmail(formData: FormData) {
45
+ await assertAdmin();
46
+
47
+ const to = String(formData.get('to') ?? '').trim();
48
+ if (!to) {
49
+ throw new Error('Enter a recipient email address.');
50
+ }
51
+
52
+ await sendEmail({
53
+ to,
54
+ subject: 'NextBlock test email',
55
+ text: 'This is a test email from your NextBlock CMS. SMTP is configured correctly.',
56
+ html: '<p>This is a test email from your NextBlock CMS. SMTP is configured correctly. 🎉</p>',
57
+ });
58
+
59
+ return { success: true as const, message: `Test email sent to ${to}.` };
60
+ }
@@ -0,0 +1,181 @@
1
+ 'use client';
2
+
3
+ import { useRef, useState, useTransition } from 'react';
4
+ import {
5
+ Alert,
6
+ AlertDescription,
7
+ Button,
8
+ Checkbox,
9
+ Input,
10
+ Label,
11
+ Spinner,
12
+ } from '@nextblock-cms/ui';
13
+ import type { EmailSettingsView } from '../../../../../lib/config/email-settings';
14
+ import { updateEmailSettings, sendTestEmail } from '../actions';
15
+ import type { Message } from '../../../../../components/form-message';
16
+ import { useHotkeys } from '../../../../../hooks/use-hotkeys';
17
+
18
+ interface EmailFormProps {
19
+ initialSettings: EmailSettingsView;
20
+ }
21
+
22
+ export default function EmailForm({ initialSettings }: EmailFormProps) {
23
+ const [isPending, startTransition] = useTransition();
24
+ const [isTesting, startTestTransition] = useTransition();
25
+ const [message, setMessage] = useState<Message | null>(null);
26
+
27
+ const [host, setHost] = useState(initialSettings.host);
28
+ const [port, setPort] = useState(initialSettings.port);
29
+ const [fromEmail, setFromEmail] = useState(initialSettings.fromEmail);
30
+ const [fromName, setFromName] = useState(initialSettings.fromName);
31
+ const [secure, setSecure] = useState(initialSettings.secure);
32
+ const [user, setUser] = useState('');
33
+ const [pass, setPass] = useState('');
34
+ const [testTo, setTestTo] = useState('');
35
+
36
+ const formRef = useRef<HTMLFormElement>(null);
37
+ useHotkeys('ctrl+s', () => formRef.current?.requestSubmit());
38
+
39
+ const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
40
+ event.preventDefault();
41
+ setMessage(null);
42
+ const formData = new FormData();
43
+ formData.append('host', host);
44
+ formData.append('port', port);
45
+ formData.append('fromEmail', fromEmail);
46
+ formData.append('fromName', fromName);
47
+ formData.append('secure', secure ? 'on' : '');
48
+ // Only send credentials when changed — blank keeps the stored secret.
49
+ if (user.trim()) formData.append('user', user.trim());
50
+ if (pass.trim()) formData.append('pass', pass.trim());
51
+
52
+ startTransition(async () => {
53
+ try {
54
+ const result = await updateEmailSettings(formData);
55
+ setMessage({ success: result.message });
56
+ setUser('');
57
+ setPass('');
58
+ } catch (error) {
59
+ setMessage({ error: error instanceof Error ? error.message : 'Failed to save settings.' });
60
+ }
61
+ });
62
+ };
63
+
64
+ const handleSendTest = () => {
65
+ setMessage(null);
66
+ const formData = new FormData();
67
+ formData.append('to', testTo.trim());
68
+ startTestTransition(async () => {
69
+ try {
70
+ const result = await sendTestEmail(formData);
71
+ setMessage({ success: result.message });
72
+ } catch (error) {
73
+ setMessage({ error: error instanceof Error ? error.message : 'Failed to send test email.' });
74
+ }
75
+ });
76
+ };
77
+
78
+ return (
79
+ <div className="space-y-6">
80
+ {initialSettings.envFallbackActive && (
81
+ <Alert className="py-2 px-4">
82
+ <AlertDescription className="text-xs">
83
+ SMTP is currently read from environment variables (<code>SMTP_*</code>). Saving here
84
+ moves it into the database and takes precedence.
85
+ </AlertDescription>
86
+ </Alert>
87
+ )}
88
+
89
+ <form ref={formRef} onSubmit={handleSubmit} className="space-y-6">
90
+ <div className="grid gap-4 sm:grid-cols-2">
91
+ <div className="space-y-2">
92
+ <Label htmlFor="host">SMTP host</Label>
93
+ <Input id="host" value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" />
94
+ </div>
95
+ <div className="space-y-2">
96
+ <Label htmlFor="port">Port</Label>
97
+ <Input id="port" value={port} onChange={(e) => setPort(e.target.value)} placeholder="465" />
98
+ </div>
99
+ <div className="space-y-2">
100
+ <Label htmlFor="fromEmail">From email</Label>
101
+ <Input id="fromEmail" type="email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} placeholder="no-reply@example.com" />
102
+ </div>
103
+ <div className="space-y-2">
104
+ <Label htmlFor="fromName">From name</Label>
105
+ <Input id="fromName" value={fromName} onChange={(e) => setFromName(e.target.value)} placeholder="My Site" />
106
+ </div>
107
+ <div className="space-y-2">
108
+ <Label htmlFor="user">
109
+ Username{' '}
110
+ {initialSettings.hasUser && (
111
+ <span className="text-xs text-muted-foreground">
112
+ (stored{initialSettings.userLast4 ? ` ••••${initialSettings.userLast4}` : ''} — leave blank to keep)
113
+ </span>
114
+ )}
115
+ </Label>
116
+ <Input id="user" autoComplete="off" value={user} onChange={(e) => setUser(e.target.value)} placeholder={initialSettings.hasUser ? '••••••••' : 'smtp username'} />
117
+ </div>
118
+ <div className="space-y-2">
119
+ <Label htmlFor="pass">
120
+ Password{' '}
121
+ {initialSettings.hasPass && (
122
+ <span className="text-xs text-muted-foreground">(stored — leave blank to keep)</span>
123
+ )}
124
+ </Label>
125
+ <Input id="pass" type="password" autoComplete="new-password" value={pass} onChange={(e) => setPass(e.target.value)} placeholder={initialSettings.hasPass ? '••••••••' : 'smtp password'} />
126
+ </div>
127
+ </div>
128
+
129
+ <label className="flex items-start gap-3">
130
+ <Checkbox checked={secure} onCheckedChange={(c) => setSecure(c === true)} className="mt-1" />
131
+ <span className="text-sm">
132
+ <span className="font-medium">Use implicit TLS (SMTPS)</span>
133
+ <span className="block text-xs text-muted-foreground">
134
+ Enable for port 465. Disable for STARTTLS on ports like 587.
135
+ </span>
136
+ </span>
137
+ </label>
138
+
139
+ <div className="flex items-center gap-4">
140
+ <Button type="submit" disabled={isPending}>
141
+ {isPending ? (<><Spinner className="mr-2 h-4 w-4" /> Saving…</>) : 'Save settings'}
142
+ </Button>
143
+ </div>
144
+ </form>
145
+
146
+ <div className="rounded-lg border p-4 space-y-3">
147
+ <div className="space-y-1">
148
+ <Label htmlFor="testTo">Send a test email</Label>
149
+ <p className="text-xs text-muted-foreground">
150
+ Save your settings first, then send a test message to confirm delivery.
151
+ </p>
152
+ </div>
153
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
154
+ <Input
155
+ id="testTo"
156
+ type="email"
157
+ value={testTo}
158
+ onChange={(e) => setTestTo(e.target.value)}
159
+ placeholder="you@example.com"
160
+ className="sm:max-w-xs"
161
+ />
162
+ <Button type="button" variant="outline" onClick={handleSendTest} disabled={isTesting || !testTo.trim()}>
163
+ {isTesting ? (<><Spinner className="mr-2 h-4 w-4" /> Sending…</>) : 'Send test email'}
164
+ </Button>
165
+ </div>
166
+ </div>
167
+
168
+ {message && (
169
+ <Alert variant={'error' in message ? 'destructive' : 'success'} className="py-2 px-4">
170
+ <AlertDescription>
171
+ {'error' in message
172
+ ? message.error
173
+ : 'success' in message
174
+ ? message.success
175
+ : message.message}
176
+ </AlertDescription>
177
+ </Alert>
178
+ )}
179
+ </div>
180
+ );
181
+ }
@@ -0,0 +1,28 @@
1
+ // app/cms/settings/email/page.tsx
2
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
3
+ import { getEmailSettingsView } from '../../../../lib/config/email-settings';
4
+ import EmailForm from './components/EmailForm';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export default async function EmailSettingsPage() {
9
+ const settings = await getEmailSettingsView();
10
+
11
+ return (
12
+ <div className="max-w-4xl mx-auto">
13
+ <Card>
14
+ <CardHeader>
15
+ <CardTitle>Email (SMTP)</CardTitle>
16
+ <CardDescription>
17
+ Configure the SMTP server used for transactional email (verification, password
18
+ resets, 2FA codes, contact notifications). Credentials are encrypted at rest. If left
19
+ blank, NextBlock falls back to the <code>SMTP_*</code> environment variables.
20
+ </CardDescription>
21
+ </CardHeader>
22
+ <CardContent>
23
+ <EmailForm initialSettings={settings} />
24
+ </CardContent>
25
+ </Card>
26
+ </div>
27
+ );
28
+ }
@@ -0,0 +1,60 @@
1
+ 'use server';
2
+
3
+ import { createClient } from '@nextblock-cms/db/server';
4
+ import { revalidatePath } from 'next/cache';
5
+ import {
6
+ getPrivacySettings as readPrivacySettings,
7
+ mergePrivacySettings,
8
+ } from '../../../../lib/privacy/settings';
9
+ import type { PrivacySettings } from '../../../../lib/privacy/types';
10
+
11
+ export interface GoogleAnalyticsSettings {
12
+ gtm_id: string;
13
+ ga_measurement_id: string;
14
+ custom_scripts: string;
15
+ }
16
+
17
+ export async function getGoogleAnalyticsSettings(): Promise<GoogleAnalyticsSettings> {
18
+ const settings = await readPrivacySettings();
19
+ return {
20
+ gtm_id: settings.gtm_id,
21
+ ga_measurement_id: settings.ga_measurement_id,
22
+ custom_scripts: settings.custom_scripts,
23
+ };
24
+ }
25
+
26
+ async function assertAdmin(): Promise<void> {
27
+ const supabase = createClient();
28
+ const {
29
+ data: { user },
30
+ } = await supabase.auth.getUser();
31
+ if (!user) {
32
+ throw new Error('You must be logged in to update settings.');
33
+ }
34
+ const { data: profile } = await supabase
35
+ .from('profiles')
36
+ .select('role')
37
+ .eq('id', user.id)
38
+ .single();
39
+ if (!profile || profile.role !== 'ADMIN') {
40
+ throw new Error('You do not have permission to perform this action.');
41
+ }
42
+ }
43
+
44
+ export async function updateGoogleAnalyticsSettings(formData: FormData) {
45
+ await assertAdmin();
46
+
47
+ // Only the analytics fields are touched; mergePrivacySettings preserves the
48
+ // banner/corporate fields owned by the Privacy & Consent page.
49
+ const patch: Partial<PrivacySettings> = {
50
+ gtm_id: (formData.get('gtm_id')?.toString() ?? '').trim(),
51
+ ga_measurement_id: (formData.get('ga_measurement_id')?.toString() ?? '').trim(),
52
+ custom_scripts: formData.get('custom_scripts')?.toString() ?? '',
53
+ };
54
+
55
+ await mergePrivacySettings(patch);
56
+ // The analytics guard (GTM/GA4 + custom scripts) lives in the root layout.
57
+ revalidatePath('/', 'layout');
58
+
59
+ return { success: true, message: 'Google Analytics settings saved.' };
60
+ }