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.
- 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 +281 -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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useRef, useState, useTransition } from 'react';
|
|
4
|
+
import {
|
|
5
|
+
Alert,
|
|
6
|
+
AlertDescription,
|
|
7
|
+
Button,
|
|
8
|
+
Input,
|
|
9
|
+
Label,
|
|
10
|
+
Spinner,
|
|
11
|
+
Textarea,
|
|
12
|
+
} from '@nextblock-cms/ui';
|
|
13
|
+
import { Message } from '../../../../../components/form-message';
|
|
14
|
+
import { useHotkeys } from '../../../../../hooks/use-hotkeys';
|
|
15
|
+
import {
|
|
16
|
+
updateGoogleAnalyticsSettings,
|
|
17
|
+
type GoogleAnalyticsSettings,
|
|
18
|
+
} from '../actions';
|
|
19
|
+
|
|
20
|
+
interface GoogleAnalyticsFormProps {
|
|
21
|
+
initialSettings: GoogleAnalyticsSettings;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function GoogleAnalyticsForm({ initialSettings }: GoogleAnalyticsFormProps) {
|
|
25
|
+
const [isPending, startTransition] = useTransition();
|
|
26
|
+
const [message, setMessage] = useState<Message | null>(null);
|
|
27
|
+
|
|
28
|
+
const [gtmId, setGtmId] = useState(initialSettings.gtm_id);
|
|
29
|
+
const [gaId, setGaId] = useState(initialSettings.ga_measurement_id);
|
|
30
|
+
const [customScripts, setCustomScripts] = useState(initialSettings.custom_scripts);
|
|
31
|
+
|
|
32
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
33
|
+
useHotkeys('ctrl+s', () => formRef.current?.requestSubmit());
|
|
34
|
+
|
|
35
|
+
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
setMessage(null);
|
|
38
|
+
|
|
39
|
+
const formData = new FormData();
|
|
40
|
+
formData.append('gtm_id', gtmId);
|
|
41
|
+
formData.append('ga_measurement_id', gaId);
|
|
42
|
+
formData.append('custom_scripts', customScripts);
|
|
43
|
+
|
|
44
|
+
startTransition(async () => {
|
|
45
|
+
try {
|
|
46
|
+
const result = await updateGoogleAnalyticsSettings(formData);
|
|
47
|
+
setMessage({ success: result.message });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
setMessage({
|
|
50
|
+
error: error instanceof Error ? error.message : 'An unknown error occurred.',
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<form ref={formRef} onSubmit={handleSubmit} className="space-y-8">
|
|
58
|
+
<section className="space-y-4">
|
|
59
|
+
<div>
|
|
60
|
+
<h3 className="text-sm font-semibold">Analytics & tracking</h3>
|
|
61
|
+
<p className="text-xs text-slate-500">
|
|
62
|
+
These load <strong>only</strong> after a visitor consents to analytics in the
|
|
63
|
+
Law 25 banner (managed under Privacy & Consent), so the default page weight
|
|
64
|
+
stays at zero tracking bytes.
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
<div className="space-y-2">
|
|
68
|
+
<Label htmlFor="gtm_id">Google Tag Manager ID</Label>
|
|
69
|
+
<Input
|
|
70
|
+
id="gtm_id"
|
|
71
|
+
value={gtmId}
|
|
72
|
+
onChange={(e) => setGtmId(e.target.value)}
|
|
73
|
+
placeholder="GTM-XXXXXXX"
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
<div className="space-y-2">
|
|
77
|
+
<Label htmlFor="ga_measurement_id">GA4 Measurement ID</Label>
|
|
78
|
+
<Input
|
|
79
|
+
id="ga_measurement_id"
|
|
80
|
+
value={gaId}
|
|
81
|
+
onChange={(e) => setGaId(e.target.value)}
|
|
82
|
+
placeholder="G-XXXXXXXXXX"
|
|
83
|
+
/>
|
|
84
|
+
<p className="text-xs text-slate-500">
|
|
85
|
+
Loads Google Analytics 4 directly via <code>gtag</code>. If you also configure
|
|
86
|
+
GA4 inside your GTM container, set only one of these to avoid double-counting.
|
|
87
|
+
</p>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="space-y-2">
|
|
90
|
+
<Label htmlFor="custom_scripts">Custom consented scripts (optional)</Label>
|
|
91
|
+
<Textarea
|
|
92
|
+
id="custom_scripts"
|
|
93
|
+
value={customScripts}
|
|
94
|
+
onChange={(e) => setCustomScripts(e.target.value)}
|
|
95
|
+
placeholder="<!-- Additional marketing/analytics <script> tags, injected only after consent -->"
|
|
96
|
+
rows={4}
|
|
97
|
+
className="font-mono text-xs"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<div className="flex items-center gap-4">
|
|
103
|
+
<Button type="submit" disabled={isPending}>
|
|
104
|
+
{isPending ? (
|
|
105
|
+
<>
|
|
106
|
+
<Spinner className="mr-2 h-4 w-4 animate-spin" /> Saving...
|
|
107
|
+
</>
|
|
108
|
+
) : (
|
|
109
|
+
'Save Google Analytics Settings'
|
|
110
|
+
)}
|
|
111
|
+
</Button>
|
|
112
|
+
{message && (
|
|
113
|
+
<Alert
|
|
114
|
+
variant={'success' in message ? 'success' : 'destructive'}
|
|
115
|
+
className="py-2 px-4 w-auto inline-flex items-center"
|
|
116
|
+
>
|
|
117
|
+
<AlertDescription>
|
|
118
|
+
{'success' in message
|
|
119
|
+
? message.success
|
|
120
|
+
: 'error' in message
|
|
121
|
+
? message.error
|
|
122
|
+
: ''}
|
|
123
|
+
</AlertDescription>
|
|
124
|
+
</Alert>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
</form>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// app/cms/settings/google-analytics/page.tsx
|
|
2
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@nextblock-cms/ui';
|
|
3
|
+
import { getGoogleAnalyticsSettings } from './actions';
|
|
4
|
+
import GoogleAnalyticsForm from './components/GoogleAnalyticsForm';
|
|
5
|
+
|
|
6
|
+
export default async function GoogleAnalyticsSettingsPage() {
|
|
7
|
+
const settings = await getGoogleAnalyticsSettings();
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<div className="max-w-4xl mx-auto">
|
|
11
|
+
<Card>
|
|
12
|
+
<CardHeader>
|
|
13
|
+
<CardTitle>Google Analytics</CardTitle>
|
|
14
|
+
<CardDescription>
|
|
15
|
+
Configure Google Tag Manager and Google Analytics 4. These tags load only
|
|
16
|
+
after a visitor accepts analytics in the Law 25 consent banner (managed under
|
|
17
|
+
Privacy & Consent), so the default page weight stays at zero tracking bytes.
|
|
18
|
+
</CardDescription>
|
|
19
|
+
</CardHeader>
|
|
20
|
+
<CardContent>
|
|
21
|
+
<GoogleAnalyticsForm initialSettings={settings} />
|
|
22
|
+
</CardContent>
|
|
23
|
+
</Card>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -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
|
/>
|