create-nextblock 0.10.2 → 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
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
// DB-backed SMTP configuration. Non-secret fields (host, port, from, secure) live in the
|
|
3
|
+
// public `email_public` row; the SMTP username and password live encrypted in the
|
|
4
|
+
// ADMIN-only `email_secret` row. Resolution is DB-first with an env fallback
|
|
5
|
+
// (SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM_EMAIL / SMTP_FROM_NAME) so
|
|
6
|
+
// existing deployments keep working until the values are moved into the CMS.
|
|
7
|
+
import {
|
|
8
|
+
createClient,
|
|
9
|
+
getServiceRoleSupabaseClient,
|
|
10
|
+
encryptWithEnvKey,
|
|
11
|
+
getSecretEnvelopeStatus,
|
|
12
|
+
isSandboxEnvironment,
|
|
13
|
+
resolveConfigValue,
|
|
14
|
+
tryDecryptWithEnvKey,
|
|
15
|
+
} from '@nextblock-cms/db/server';
|
|
16
|
+
|
|
17
|
+
const EMAIL_PUBLIC_KEY = 'email_public';
|
|
18
|
+
const EMAIL_SECRET_KEY = 'email_secret';
|
|
19
|
+
|
|
20
|
+
export type EmailPublicSettings = {
|
|
21
|
+
host: string;
|
|
22
|
+
port: string;
|
|
23
|
+
fromEmail: string;
|
|
24
|
+
fromName: string;
|
|
25
|
+
secure: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_EMAIL_PUBLIC_SETTINGS: EmailPublicSettings = {
|
|
29
|
+
host: '',
|
|
30
|
+
port: '',
|
|
31
|
+
fromEmail: '',
|
|
32
|
+
fromName: '',
|
|
33
|
+
secure: true,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** What the CMS form needs: public fields + whether each secret is already stored. */
|
|
37
|
+
export type EmailSettingsView = EmailPublicSettings & {
|
|
38
|
+
hasUser: boolean;
|
|
39
|
+
hasPass: boolean;
|
|
40
|
+
userLast4: string | null;
|
|
41
|
+
envFallbackActive: boolean;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Fully-resolved transport config consumed by nodemailer. */
|
|
45
|
+
export type ResolvedEmailConfig = {
|
|
46
|
+
host: string;
|
|
47
|
+
port: number;
|
|
48
|
+
secure: boolean;
|
|
49
|
+
auth: { user: string; pass: string };
|
|
50
|
+
from: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function asString(value: unknown, fallback = ''): string {
|
|
54
|
+
return typeof value === 'string' ? value : fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function asBool(value: unknown, fallback: boolean): boolean {
|
|
58
|
+
if (typeof value === 'boolean') return value;
|
|
59
|
+
if (typeof value === 'string') return value === 'true' || value === 'on';
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizePublic(value: unknown): EmailPublicSettings {
|
|
64
|
+
const raw = (value && typeof value === 'object' ? value : {}) as Record<string, unknown>;
|
|
65
|
+
return {
|
|
66
|
+
host: asString(raw['host']),
|
|
67
|
+
port: asString(raw['port']),
|
|
68
|
+
fromEmail: asString(raw['fromEmail']),
|
|
69
|
+
fromName: asString(raw['fromName']),
|
|
70
|
+
secure: asBool(raw['secure'], true),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function getEmailPublicSettings(): Promise<EmailPublicSettings> {
|
|
75
|
+
const supabase = createClient();
|
|
76
|
+
const { data } = await supabase
|
|
77
|
+
.from('site_settings')
|
|
78
|
+
.select('value')
|
|
79
|
+
.eq('key', EMAIL_PUBLIC_KEY)
|
|
80
|
+
.maybeSingle();
|
|
81
|
+
return normalizePublic(data?.value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read the public settings plus stored-secret status for the CMS form. Uses the
|
|
86
|
+
* request-scoped client; RLS restricts the secret row to ADMIN, which is who reaches
|
|
87
|
+
* this page.
|
|
88
|
+
*/
|
|
89
|
+
export async function getEmailSettingsView(): Promise<EmailSettingsView> {
|
|
90
|
+
const supabase = createClient();
|
|
91
|
+
const [{ data: publicData }, { data: secretData }] = await Promise.all([
|
|
92
|
+
supabase.from('site_settings').select('value').eq('key', EMAIL_PUBLIC_KEY).maybeSingle(),
|
|
93
|
+
supabase.from('site_settings').select('value').eq('key', EMAIL_SECRET_KEY).maybeSingle(),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const pub = normalizePublic(publicData?.value);
|
|
97
|
+
const secret = (secretData?.value ?? {}) as Record<string, unknown>;
|
|
98
|
+
const userStatus = getSecretEnvelopeStatus(secret['user']);
|
|
99
|
+
const passStatus = getSecretEnvelopeStatus(secret['pass']);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
...pub,
|
|
103
|
+
hasUser: userStatus.hasStoredValue,
|
|
104
|
+
hasPass: passStatus.hasStoredValue,
|
|
105
|
+
userLast4: userStatus.last4,
|
|
106
|
+
// Show a hint in the UI when SMTP still comes from env vars rather than the CMS.
|
|
107
|
+
envFallbackActive: !pub.host && Boolean(process.env['SMTP_HOST']),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type SaveEmailSettingsInput = {
|
|
112
|
+
host: string;
|
|
113
|
+
port: string;
|
|
114
|
+
fromEmail: string;
|
|
115
|
+
fromName: string;
|
|
116
|
+
secure: boolean;
|
|
117
|
+
/** Only persisted when non-empty — a blank field keeps the existing stored secret. */
|
|
118
|
+
user?: string;
|
|
119
|
+
pass?: string;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Persist email settings. Public fields always overwrite; secret fields are encrypted
|
|
124
|
+
* and only written when a new value is supplied. Refuses to store real secrets in the
|
|
125
|
+
* sandbox (its DB resets daily). Caller must enforce ADMIN; RLS double-enforces.
|
|
126
|
+
*/
|
|
127
|
+
export async function saveEmailSettings(input: SaveEmailSettingsInput): Promise<void> {
|
|
128
|
+
const supabase = createClient();
|
|
129
|
+
|
|
130
|
+
const publicValue: EmailPublicSettings = {
|
|
131
|
+
host: input.host.trim(),
|
|
132
|
+
port: input.port.trim(),
|
|
133
|
+
fromEmail: input.fromEmail.trim(),
|
|
134
|
+
fromName: input.fromName.trim(),
|
|
135
|
+
secure: input.secure,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const { error: publicError } = await supabase
|
|
139
|
+
.from('site_settings')
|
|
140
|
+
.upsert({ key: EMAIL_PUBLIC_KEY, value: publicValue });
|
|
141
|
+
if (publicError) {
|
|
142
|
+
console.error('Error saving email_public settings:', publicError.message);
|
|
143
|
+
throw new Error('Failed to save email settings.');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const newUser = input.user?.trim();
|
|
147
|
+
const newPass = input.pass?.trim();
|
|
148
|
+
if (newUser || newPass) {
|
|
149
|
+
if (isSandboxEnvironment()) {
|
|
150
|
+
throw new Error('The sandbox cannot store live SMTP credentials.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Read-merge so updating only one of user/pass keeps the other.
|
|
154
|
+
const { data: existing } = await supabase
|
|
155
|
+
.from('site_settings')
|
|
156
|
+
.select('value')
|
|
157
|
+
.eq('key', EMAIL_SECRET_KEY)
|
|
158
|
+
.maybeSingle();
|
|
159
|
+
const current = (existing?.value ?? {}) as Record<string, unknown>;
|
|
160
|
+
|
|
161
|
+
const nextValue: Record<string, unknown> = { ...current };
|
|
162
|
+
if (newUser) nextValue['user'] = encryptWithEnvKey(newUser);
|
|
163
|
+
if (newPass) nextValue['pass'] = encryptWithEnvKey(newPass);
|
|
164
|
+
|
|
165
|
+
const { error: secretError } = await supabase
|
|
166
|
+
.from('site_settings')
|
|
167
|
+
.upsert({ key: EMAIL_SECRET_KEY, value: nextValue });
|
|
168
|
+
if (secretError) {
|
|
169
|
+
console.error('Error saving email_secret settings:', secretError.message);
|
|
170
|
+
throw new Error('Failed to save email credentials.');
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Resolve the full SMTP transport config, DB-first with an env fallback. Uses the
|
|
177
|
+
* service-role client so it works from any context (the secret row is ADMIN-only under
|
|
178
|
+
* RLS). Returns null when host/user/pass/from cannot be resolved from either source.
|
|
179
|
+
*/
|
|
180
|
+
export async function resolveEmailServerConfig(): Promise<ResolvedEmailConfig | null> {
|
|
181
|
+
let pub: EmailPublicSettings = DEFAULT_EMAIL_PUBLIC_SETTINGS;
|
|
182
|
+
let secret: Record<string, unknown> = {};
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const supabase = getServiceRoleSupabaseClient();
|
|
186
|
+
const [{ data: publicData }, { data: secretData }] = await Promise.all([
|
|
187
|
+
supabase.from('site_settings').select('value').eq('key', EMAIL_PUBLIC_KEY).maybeSingle(),
|
|
188
|
+
supabase.from('site_settings').select('value').eq('key', EMAIL_SECRET_KEY).maybeSingle(),
|
|
189
|
+
]);
|
|
190
|
+
pub = normalizePublic(publicData?.value);
|
|
191
|
+
secret = (secretData?.value ?? {}) as Record<string, unknown>;
|
|
192
|
+
} catch {
|
|
193
|
+
// No service-role key (unconfigured instance) — fall through to env-only resolution.
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const host = resolveConfigValue(pub.host, 'SMTP_HOST');
|
|
197
|
+
const port = resolveConfigValue(pub.port, 'SMTP_PORT');
|
|
198
|
+
const fromEmail = resolveConfigValue(pub.fromEmail, 'SMTP_FROM_EMAIL');
|
|
199
|
+
const fromName = resolveConfigValue(pub.fromName, 'SMTP_FROM_NAME');
|
|
200
|
+
const user = resolveConfigValue(tryDecryptWithEnvKey(secret['user']), 'SMTP_USER');
|
|
201
|
+
const pass = resolveConfigValue(tryDecryptWithEnvKey(secret['pass']), 'SMTP_PASS');
|
|
202
|
+
|
|
203
|
+
if (!host || !port || !user || !pass || !fromEmail) {
|
|
204
|
+
console.warn('Email is not configured (CMS or SMTP_* env). Outbound email will not be sent.');
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const portNumber = Number(port);
|
|
209
|
+
return {
|
|
210
|
+
host,
|
|
211
|
+
port: portNumber,
|
|
212
|
+
// Honor the CMS toggle; fall back to the SMTPS convention (465 ⇒ implicit TLS).
|
|
213
|
+
secure: pub.host ? pub.secure : portNumber === 465,
|
|
214
|
+
auth: { user, pass },
|
|
215
|
+
from: fromName ? `"${fromName}" <${fromEmail}>` : fromEmail,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { createClient } from '@nextblock-cms/db/server';
|
|
4
|
+
import { revalidatePath } from 'next/cache';
|
|
5
|
+
|
|
6
|
+
/** Persist the onboarding dismiss flag in the `onboarding_state` row (read-merge). */
|
|
7
|
+
export async function setOnboardingDismissed(dismissed: boolean) {
|
|
8
|
+
const supabase = createClient();
|
|
9
|
+
|
|
10
|
+
const { data: existing } = await supabase
|
|
11
|
+
.from('site_settings')
|
|
12
|
+
.select('value')
|
|
13
|
+
.eq('key', 'onboarding_state')
|
|
14
|
+
.maybeSingle();
|
|
15
|
+
|
|
16
|
+
const current =
|
|
17
|
+
existing?.value && typeof existing.value === 'object' && !Array.isArray(existing.value)
|
|
18
|
+
? (existing.value as Record<string, unknown>)
|
|
19
|
+
: {};
|
|
20
|
+
|
|
21
|
+
const { error } = await supabase
|
|
22
|
+
.from('site_settings')
|
|
23
|
+
.upsert({ key: 'onboarding_state', value: { ...current, dismissed } });
|
|
24
|
+
|
|
25
|
+
if (error) {
|
|
26
|
+
console.error('Error updating onboarding state:', error.message);
|
|
27
|
+
throw new Error('Failed to update onboarding state.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
revalidatePath('/cms/dashboard');
|
|
31
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
// Derives the dashboard onboarding checklist live from configuration state. Every check
|
|
3
|
+
// reads public/service-role data only, so it works for both ADMIN and WRITER dashboards.
|
|
4
|
+
import { createClient } from '@nextblock-cms/db/server';
|
|
5
|
+
import { getStoreConfigStatus } from '@nextblock-cms/ecommerce/server';
|
|
6
|
+
import { getEmailPublicSettings } from '../config/email-settings';
|
|
7
|
+
import { getPrivacySettings } from '../privacy/settings';
|
|
8
|
+
|
|
9
|
+
export type OnboardingStep = {
|
|
10
|
+
key: string;
|
|
11
|
+
title: string;
|
|
12
|
+
description: string;
|
|
13
|
+
href: string;
|
|
14
|
+
done: boolean;
|
|
15
|
+
optional: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type OnboardingStatus = {
|
|
19
|
+
steps: OnboardingStep[];
|
|
20
|
+
completed: number;
|
|
21
|
+
total: number;
|
|
22
|
+
dismissed: boolean;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function hasCopyright(value: unknown): boolean {
|
|
26
|
+
if (!value || typeof value !== 'object') return false;
|
|
27
|
+
return Object.values(value as Record<string, unknown>).some(
|
|
28
|
+
(v) => typeof v === 'string' && v.trim().length > 0
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function getOnboardingStatus(opts: {
|
|
33
|
+
isEcommerceActive: boolean;
|
|
34
|
+
}): Promise<OnboardingStatus> {
|
|
35
|
+
const supabase = createClient();
|
|
36
|
+
|
|
37
|
+
const [{ data: settingRows }, { data: logoRow }, emailPublic, privacy] = await Promise.all([
|
|
38
|
+
supabase
|
|
39
|
+
.from('site_settings')
|
|
40
|
+
.select('key, value')
|
|
41
|
+
.in('key', ['footer_copyright', 'bot_protection_public', 'onboarding_state']),
|
|
42
|
+
supabase.from('logos').select('id').limit(1).maybeSingle(),
|
|
43
|
+
getEmailPublicSettings(),
|
|
44
|
+
getPrivacySettings(),
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const rows = new Map((settingRows ?? []).map((r) => [r.key, r.value]));
|
|
48
|
+
const botPublic = (rows.get('bot_protection_public') ?? {}) as Record<string, unknown>;
|
|
49
|
+
const onboardingState = (rows.get('onboarding_state') ?? {}) as Record<string, unknown>;
|
|
50
|
+
|
|
51
|
+
const brandingDone = Boolean(logoRow);
|
|
52
|
+
const copyrightDone = hasCopyright(rows.get('footer_copyright'));
|
|
53
|
+
const emailDone = Boolean(emailPublic.host) || Boolean(process.env['SMTP_HOST']);
|
|
54
|
+
const analyticsDone = Boolean(privacy.gtm_id);
|
|
55
|
+
const botProvider = typeof botPublic['provider'] === 'string' ? (botPublic['provider'] as string) : 'none';
|
|
56
|
+
const botDone = botProvider !== 'none' && botProvider !== '';
|
|
57
|
+
|
|
58
|
+
const steps: OnboardingStep[] = [
|
|
59
|
+
{
|
|
60
|
+
key: 'admin',
|
|
61
|
+
title: 'Create your admin account',
|
|
62
|
+
description: 'Your administrator account is set up.',
|
|
63
|
+
href: '/cms/users',
|
|
64
|
+
done: true,
|
|
65
|
+
optional: false,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: 'branding',
|
|
69
|
+
title: 'Add your branding',
|
|
70
|
+
description: 'Upload your logo and set your site identity.',
|
|
71
|
+
href: '/cms/settings/logos',
|
|
72
|
+
done: brandingDone,
|
|
73
|
+
optional: false,
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
key: 'copyright',
|
|
77
|
+
title: 'Set your copyright / footer',
|
|
78
|
+
description: 'Configure the footer copyright shown across your site.',
|
|
79
|
+
href: '/cms/settings/copyright',
|
|
80
|
+
done: copyrightDone,
|
|
81
|
+
optional: false,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: 'email',
|
|
85
|
+
title: 'Configure email (SMTP)',
|
|
86
|
+
description: 'Enable verification emails, password resets, and notifications.',
|
|
87
|
+
href: '/cms/settings/email',
|
|
88
|
+
done: emailDone,
|
|
89
|
+
optional: false,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: 'analytics',
|
|
93
|
+
title: 'Connect analytics',
|
|
94
|
+
description: 'Add a Google Tag Manager ID for consent-gated analytics.',
|
|
95
|
+
href: '/cms/settings/google-analytics',
|
|
96
|
+
done: analyticsDone,
|
|
97
|
+
optional: true,
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
key: 'bot',
|
|
101
|
+
title: 'Enable bot protection',
|
|
102
|
+
description: 'Protect your sign-up and sign-in forms with a CAPTCHA.',
|
|
103
|
+
href: '/cms/settings/bot-protection',
|
|
104
|
+
done: botDone,
|
|
105
|
+
optional: true,
|
|
106
|
+
},
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
if (opts.isEcommerceActive) {
|
|
110
|
+
let paymentsDone = false;
|
|
111
|
+
try {
|
|
112
|
+
const storeStatus = await getStoreConfigStatus();
|
|
113
|
+
paymentsDone = storeStatus.stripe.hasKeys || storeStatus.freemius.hasKeys;
|
|
114
|
+
} catch {
|
|
115
|
+
paymentsDone = false;
|
|
116
|
+
}
|
|
117
|
+
// Insert Payments before the optional steps.
|
|
118
|
+
steps.splice(4, 0, {
|
|
119
|
+
key: 'payments',
|
|
120
|
+
title: 'Set up payment providers',
|
|
121
|
+
description: 'Add your Stripe and/or Freemius API keys to accept payments.',
|
|
122
|
+
href: '/cms/payments',
|
|
123
|
+
done: paymentsDone,
|
|
124
|
+
optional: false,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const completed = steps.filter((s) => s.done).length;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
steps,
|
|
132
|
+
completed,
|
|
133
|
+
total: steps.length,
|
|
134
|
+
dismissed: onboardingState['dismissed'] === true,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Server-only resolution of the public-facing contact addresses that used to be
|
|
2
|
+
// hard-coded into seed data. The goal: a downloaded/self-hosted copy of NextBlock
|
|
3
|
+
// must NEVER route mail to the original authors. Real addresses live only in
|
|
4
|
+
// sandbox env vars (never committed); committed seeds use neutral @example.com
|
|
5
|
+
// placeholders.
|
|
6
|
+
//
|
|
7
|
+
// Resolution precedence (highest first):
|
|
8
|
+
// 1. The admin-configured value (CMS Settings -> Privacy "Support email").
|
|
9
|
+
// 2. The sandbox env var, but only when NEXT_PUBLIC_IS_SANDBOX === 'true'.
|
|
10
|
+
// 3. A neutral @example.com placeholder.
|
|
11
|
+
//
|
|
12
|
+
// Sandbox DB resets re-seed dummy values on every run, so a static seed value
|
|
13
|
+
// could never stay correct for the hosted demo — hence env-var-backed resolution
|
|
14
|
+
// at render time instead of baking the address into the migration.
|
|
15
|
+
import 'server-only';
|
|
16
|
+
import { cache } from 'react';
|
|
17
|
+
import { getPrivacySettings } from './settings';
|
|
18
|
+
|
|
19
|
+
/** Neutral placeholder shown when nothing is configured (real installs). */
|
|
20
|
+
const DUMMY_PRIVACY_EMAIL = 'privacy@example.com';
|
|
21
|
+
|
|
22
|
+
/** Merge tag seeded into the Privacy Policy / Terms page copy. */
|
|
23
|
+
export const PRIVACY_EMAIL_TAG = '{{privacy_email}}';
|
|
24
|
+
|
|
25
|
+
function isSandbox(): boolean {
|
|
26
|
+
return process.env.NEXT_PUBLIC_IS_SANDBOX === 'true';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the privacy/legal contact address. `supportEmail` is the admin-set
|
|
31
|
+
* value from privacy settings (reused as the legal contact per product choice).
|
|
32
|
+
*/
|
|
33
|
+
export function resolvePrivacyEmail(supportEmail: string): string {
|
|
34
|
+
const configured = supportEmail.trim();
|
|
35
|
+
if (configured) return configured;
|
|
36
|
+
|
|
37
|
+
if (isSandbox()) {
|
|
38
|
+
const fromEnv = process.env.SANDBOX_PRIVACY_EMAIL?.trim();
|
|
39
|
+
if (fromEnv) return fromEnv;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return DUMMY_PRIVACY_EMAIL;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Per-request cached read of the resolved privacy email. Wrapped in React
|
|
47
|
+
* `cache()` so the many text blocks on a legal page share a single settings
|
|
48
|
+
* lookup within one render.
|
|
49
|
+
*/
|
|
50
|
+
export const getPrivacyMergeEmail = cache(async (): Promise<string> => {
|
|
51
|
+
const settings = await getPrivacySettings();
|
|
52
|
+
return resolvePrivacyEmail(settings.corporate.support_email);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Substitute supported merge tags in a block's HTML. Callers should guard with a
|
|
57
|
+
* cheap `html.includes('{{')` check first so normal blocks pay nothing.
|
|
58
|
+
* Currently supports `{{privacy_email}}` (text and `mailto:` href forms).
|
|
59
|
+
*/
|
|
60
|
+
export async function substitutePrivacyMergeTags(html: string): Promise<string> {
|
|
61
|
+
if (!html.includes(PRIVACY_EMAIL_TAG)) return html;
|
|
62
|
+
const email = await getPrivacyMergeEmail();
|
|
63
|
+
return html.split(PRIVACY_EMAIL_TAG).join(email);
|
|
64
|
+
}
|
|
@@ -90,6 +90,18 @@ export async function savePrivacySettings(input: PrivacySettings): Promise<void>
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
// Privacy/consent and Google Analytics share the single `privacy_settings` row but
|
|
94
|
+
// are edited from two different CMS pages (Settings -> Privacy vs Settings -> Google
|
|
95
|
+
// Analytics). Each page must persist only its own fields without clobbering the other
|
|
96
|
+
// page's, so writes read-merge against the current row. (We may split this into a
|
|
97
|
+
// dedicated `analytics_settings` key later.)
|
|
98
|
+
export async function mergePrivacySettings(
|
|
99
|
+
patch: Partial<PrivacySettings>
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const current = await getPrivacySettings();
|
|
102
|
+
await savePrivacySettings({ ...current, ...patch });
|
|
103
|
+
}
|
|
104
|
+
|
|
93
105
|
export async function saveSecuritySettings(input: SecuritySettings): Promise<void> {
|
|
94
106
|
const supabase = createClient();
|
|
95
107
|
const value = normalizeSecurity(input);
|
|
@@ -61,7 +61,9 @@ export const DEFAULT_TRUSTED_DEVICE_DAYS = 30;
|
|
|
61
61
|
|
|
62
62
|
export const DEFAULT_SECURITY_SETTINGS: SecuritySettings = {
|
|
63
63
|
trusted_device_days: DEFAULT_TRUSTED_DEVICE_DAYS,
|
|
64
|
-
|
|
64
|
+
// On by default: staff are encouraged to enable 2FA (a CMS reminder banner is shown to
|
|
65
|
+
// ADMIN/WRITER accounts without a second factor). Never enforced in the sandbox.
|
|
66
|
+
enforce_staff_2fa: true,
|
|
65
67
|
};
|
|
66
68
|
|
|
67
69
|
export type MfaType = 'totp' | 'email';
|
|
@@ -227,11 +227,8 @@ export async function recheckStatus(): Promise<ProvisioningStatus & { writable:
|
|
|
227
227
|
|
|
228
228
|
export interface CompleteSetupInput {
|
|
229
229
|
admin: { email: string; password: string; fullName: string };
|
|
230
|
-
|
|
231
|
-
/** Local-only extra env (storage / SMTP) collected by the wizard. */
|
|
230
|
+
/** Local-only extra env (media storage) collected by the wizard. */
|
|
232
231
|
envValues?: Record<string, string>;
|
|
233
|
-
/** Bot protection — stored in the DB so it works on read-only channels too. */
|
|
234
|
-
turnstile?: { provider: 'none' | 'turnstile'; siteKey: string; secretKey: string };
|
|
235
232
|
/** "Start from a clean database" — wipe before installing (local dev only, server-gated). */
|
|
236
233
|
resetFirst?: boolean;
|
|
237
234
|
}
|
|
@@ -286,11 +283,8 @@ export async function completeSetup(input: CompleteSetupInput): Promise<ActionRe
|
|
|
286
283
|
if (password.length < 8) {
|
|
287
284
|
return { ok: false, error: 'Use an administrator password of at least 8 characters.' };
|
|
288
285
|
}
|
|
289
|
-
if (input.turnstile?.provider === 'turnstile' && !input.turnstile.secretKey?.trim()) {
|
|
290
|
-
return { ok: false, error: 'Enter a Turnstile secret key, or disable bot protection.' };
|
|
291
|
-
}
|
|
292
286
|
|
|
293
|
-
// 1) Persist any local env extras (storage
|
|
287
|
+
// 1) Persist any local env extras (media storage) for Profile B.
|
|
294
288
|
if (
|
|
295
289
|
input.envValues &&
|
|
296
290
|
Object.keys(input.envValues).length > 0 &&
|
|
@@ -377,20 +371,11 @@ export async function completeSetup(input: CompleteSetupInput): Promise<ActionRe
|
|
|
377
371
|
}
|
|
378
372
|
|
|
379
373
|
// 4) Persist DB-backed settings (service role bypasses RLS — no admin exists yet).
|
|
374
|
+
// Sign-up policy, bot protection, email, and payments are no longer collected by the
|
|
375
|
+
// wizard; they are configured later from the CMS. New sign-ups default to requiring
|
|
376
|
+
// email verification (auto_accept_signups = false) as a safe default.
|
|
380
377
|
try {
|
|
381
|
-
|
|
382
|
-
await admin.from('site_settings').upsert({
|
|
383
|
-
key: 'bot_protection_public',
|
|
384
|
-
value: { provider: input.turnstile.provider, siteKey: input.turnstile.siteKey },
|
|
385
|
-
});
|
|
386
|
-
await admin.from('site_settings').upsert({
|
|
387
|
-
key: 'bot_protection_secret',
|
|
388
|
-
value: { secretKey: input.turnstile.secretKey },
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
await setSystemConfigurationServiceRole({
|
|
392
|
-
auto_accept_signups: Boolean(input.autoAcceptSignups),
|
|
393
|
-
});
|
|
378
|
+
await setSystemConfigurationServiceRole({ auto_accept_signups: false });
|
|
394
379
|
} catch (caught) {
|
|
395
380
|
return {
|
|
396
381
|
ok: false,
|
|
@@ -168,5 +168,15 @@ export const MIGRATIONS_BUNDLE: BundledMigration[] = [
|
|
|
168
168
|
"version": "00000000000030",
|
|
169
169
|
"name": "00000000000030_setup_system_configuration.sql",
|
|
170
170
|
"sql": "-- 00000000000030_setup_system_configuration.sql\n-- First-Boot Setup Wizard: global system configuration.\n--\n-- Adds a dedicated, RLS-locked `system_configuration` table that holds settings the\n-- browser /setup wizard manages and that don't belong in the public key-value\n-- `site_settings` store. It is a singleton (exactly one row, id = 1).\n--\n-- Shape:\n-- auto_accept_signups boolean -- when true, new public sign-ups skip outbound email\n-- verification (the signup route uses a service-role\n-- admin.createUser({ email_confirm: true }) path).\n-- settings jsonb -- forward-compatible catch-all for future feature\n-- toggles ({} by default). Do NOT store true secrets\n-- here (Turnstile/AI secrets keep living in their\n-- existing site_settings sensitive keys).\n--\n-- Access is locked to the ADMIN role for normal clients (NextBlock has no separate\n-- \"super-admin\" tier — ADMIN is the top level). The service_role retains full access\n-- so the wizard can seed/read it before any admin exists.\n\nCREATE TABLE IF NOT EXISTS public.system_configuration (\n id integer PRIMARY KEY DEFAULT 1,\n auto_accept_signups boolean NOT NULL DEFAULT false,\n settings jsonb NOT NULL DEFAULT '{}'::jsonb,\n updated_at timestamptz NOT NULL DEFAULT now(),\n CONSTRAINT system_configuration_singleton CHECK (id = 1)\n);\n\nCOMMENT ON TABLE public.system_configuration IS\n 'Singleton (id = 1) of global setup-wizard configuration. ADMIN-only via RLS; never store secrets in settings.';\n\n-- Seed the single row so reads always find it.\nINSERT INTO public.system_configuration (id, auto_accept_signups, settings)\nVALUES (1, false, '{}'::jsonb)\nON CONFLICT (id) DO NOTHING;\n\nALTER TABLE public.system_configuration ENABLE ROW LEVEL SECURITY;\n\nGRANT SELECT, INSERT, UPDATE, DELETE ON public.system_configuration TO authenticated;\nGRANT ALL ON public.system_configuration TO service_role;\n\n-- ADMIN-only for every operation by authenticated clients.\nDROP POLICY IF EXISTS system_configuration_admin_select ON public.system_configuration;\nCREATE POLICY system_configuration_admin_select\n ON public.system_configuration\n FOR SELECT\n TO authenticated\n USING ((SELECT public.get_current_user_role()) = 'ADMIN');\n\nDROP POLICY IF EXISTS system_configuration_admin_insert ON public.system_configuration;\nCREATE POLICY system_configuration_admin_insert\n ON public.system_configuration\n FOR INSERT\n TO authenticated\n WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN');\n\nDROP POLICY IF EXISTS system_configuration_admin_update ON public.system_configuration;\nCREATE POLICY system_configuration_admin_update\n ON public.system_configuration\n FOR UPDATE\n TO authenticated\n USING ((SELECT public.get_current_user_role()) = 'ADMIN')\n WITH CHECK ((SELECT public.get_current_user_role()) = 'ADMIN');\n\nDROP POLICY IF EXISTS system_configuration_admin_delete ON public.system_configuration;\nCREATE POLICY system_configuration_admin_delete\n ON public.system_configuration\n FOR DELETE\n TO authenticated\n USING ((SELECT public.get_current_user_role()) = 'ADMIN');\n\n-- Service role bypasses the ADMIN checks (used by the wizard before an admin exists,\n-- and by the signup route to read auto_accept_signups as an anonymous visitor).\nDROP POLICY IF EXISTS system_configuration_service_role_all ON public.system_configuration;\nCREATE POLICY system_configuration_service_role_all\n ON public.system_configuration\n FOR ALL\n TO service_role\n USING (true)\n WITH CHECK (true);\n"
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
"version": "00000000000031",
|
|
174
|
+
"name": "00000000000031_seed_footer_navigation.sql",
|
|
175
|
+
"sql": "-- 00000000000031_seed_footer_navigation.sql\n-- Seed editable FOOTER navigation items (EN + FR).\n--\n-- The public footer used to hard-code its \"Privacy Policy\" and \"Terms of Service\"\n-- links in apps/nextblock/components/AppShell.tsx. This migration turns them into\n-- real navigation_items rows under the FOOTER menu so they are editable from the\n-- CMS (/cms/navigation) like any other menu, and so the French footer points at\n-- the localized page slugs.\n--\n-- Targets the legal pages seeded by 00000000000027_setup_privacy_and_mfa.sql:\n-- EN /privacy-policy FR /politique-de-confidentialite\n-- EN /terms-of-service FR /conditions-utilisation\n--\n-- Idempotent: re-running replaces the FOOTER rows it owns (matched by URL) and\n-- reuses their translation_group_id so EN/FR stay linked across re-runs.\nDO $seed_footer$\nDECLARE\n v_en bigint;\n v_fr bigint;\n v_privacy_group uuid;\n v_terms_group uuid;\n v_en_privacy_page bigint;\n v_fr_privacy_page bigint;\n v_en_terms_page bigint;\n v_fr_terms_page bigint;\nBEGIN\n SELECT id INTO v_en FROM public.languages WHERE code = 'en' LIMIT 1;\n SELECT id INTO v_fr FROM public.languages WHERE code = 'fr' LIMIT 1;\n\n IF v_en IS NULL THEN\n RAISE NOTICE 'Default language \"en\" not found; skipping footer navigation seed.';\n RETURN;\n END IF;\n\n -- Reuse existing translation groups if these footer items were seeded before,\n -- so EN <-> FR stay paired and re-runs do not orphan translations.\n SELECT translation_group_id INTO v_privacy_group\n FROM public.navigation_items\n WHERE menu_key = 'FOOTER' AND url = '/privacy-policy' LIMIT 1;\n IF v_privacy_group IS NULL THEN v_privacy_group := gen_random_uuid(); END IF;\n\n SELECT translation_group_id INTO v_terms_group\n FROM public.navigation_items\n WHERE menu_key = 'FOOTER' AND url = '/terms-of-service' LIMIT 1;\n IF v_terms_group IS NULL THEN v_terms_group := gen_random_uuid(); END IF;\n\n -- Best-effort link each item to its underlying page (ON DELETE SET NULL keeps\n -- the link working even if a page is later removed; url remains the source of truth).\n SELECT id INTO v_en_privacy_page\n FROM public.pages WHERE slug = 'privacy-policy' AND language_id = v_en LIMIT 1;\n SELECT id INTO v_en_terms_page\n FROM public.pages WHERE slug = 'terms-of-service' AND language_id = v_en LIMIT 1;\n\n -- Remove any prior copies of the footer links we own, then re-insert cleanly.\n DELETE FROM public.navigation_items\n WHERE menu_key = 'FOOTER'\n AND url IN (\n '/privacy-policy', '/terms-of-service',\n '/politique-de-confidentialite', '/conditions-utilisation'\n );\n\n -- ----- English footer -----\n INSERT INTO public.navigation_items\n (language_id, menu_key, label, url, \"order\", page_id, translation_group_id)\n VALUES\n (v_en, 'FOOTER', 'Privacy Policy', '/privacy-policy', 0, v_en_privacy_page, v_privacy_group),\n (v_en, 'FOOTER', 'Terms of Service', '/terms-of-service', 1, v_en_terms_page, v_terms_group);\n\n -- ----- French footer (only if the French language exists) -----\n IF v_fr IS NOT NULL THEN\n SELECT id INTO v_fr_privacy_page\n FROM public.pages WHERE slug = 'politique-de-confidentialite' AND language_id = v_fr LIMIT 1;\n SELECT id INTO v_fr_terms_page\n FROM public.pages WHERE slug = 'conditions-utilisation' AND language_id = v_fr LIMIT 1;\n\n INSERT INTO public.navigation_items\n (language_id, menu_key, label, url, \"order\", page_id, translation_group_id)\n VALUES\n (v_fr, 'FOOTER', 'Politique de confidentialité', '/politique-de-confidentialite', 0, v_fr_privacy_page, v_privacy_group),\n (v_fr, 'FOOTER', 'Conditions d''utilisation', '/conditions-utilisation', 1, v_fr_terms_page, v_terms_group);\n END IF;\n\n RAISE NOTICE 'Seeded FOOTER navigation items (Privacy Policy, Terms of Service).';\nEND;\n$seed_footer$;\n"
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
"version": "00000000000032",
|
|
179
|
+
"name": "00000000000032_neutralize_seeded_contact_emails.sql",
|
|
180
|
+
"sql": "-- 00000000000032_neutralize_seeded_contact_emails.sql\n-- Remove the original authors' contact addresses from seeded content so a\n-- downloaded / self-hosted copy of NextBlock never routes mail to us.\n--\n-- Earlier migrations baked real addresses into block content:\n-- * 00000000000027 seeded `privacy@nextblock.dev` across the Privacy Policy and\n-- Terms pages (EN + FR), as visible text and `mailto:` links.\n-- * 00000000000010 seeded a `mailto:info@nextblock.dev` CTA on the French home\n-- page (the English page correctly links to /contact), and `foo@bar.com` as\n-- the contact form recipient.\n--\n-- Migrations are append-only, so this is a forward-only data fix rather than an\n-- edit of those files. Each statement is idempotent (a no-op once applied).\n--\n-- The `{{privacy_email}}` token is resolved at render time by the app\n-- (apps/nextblock/lib/privacy/contact-emails.ts): admin \"Support email\" setting\n-- -> SANDBOX_PRIVACY_EMAIL env (sandbox only) -> privacy@example.com fallback.\n\n-- 1. Privacy / Terms legal pages: swap the hard-coded address for a merge tag.\nUPDATE public.blocks\nSET content = replace(content::text, 'privacy@nextblock.dev', '{{privacy_email}}')::jsonb\nWHERE content::text LIKE '%privacy@nextblock.dev%';\n\n-- 2. French home \"Nous contacter\" CTA: point at the contact form like the English\n-- page instead of a mailto to our inbox.\nUPDATE public.blocks\nSET content = replace(content::text, 'mailto:info@nextblock.dev', '/contact')::jsonb\nWHERE content::text LIKE '%mailto:info@nextblock.dev%';\n\n-- 3. Contact form default recipient: use a neutral placeholder. In sandbox the\n-- app overrides this with SANDBOX_CONTACT_EMAIL at submit time.\nUPDATE public.blocks\nSET content = replace(content::text, 'foo@bar.com', 'contact@example.com')::jsonb\nWHERE content::text LIKE '%foo@bar.com%';\n"
|
|
171
181
|
}
|
|
172
182
|
];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference types="next" />
|
|
2
2
|
/// <reference types="next/image-types/global" />
|
|
3
|
-
import "./.next/types/routes.d.ts";
|
|
3
|
+
import "./.next/dev/types/routes.d.ts";
|
|
4
4
|
|
|
5
5
|
// NOTE: This file should not be edited
|
|
6
6
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|