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
@@ -2207,7 +2207,7 @@ Both endpoints enforce `Authorization: Bearer ${CRON_SECRET}` per the security m
2207
2207
 
2208
2208
  ### 3.4.8 Google Tag Manager — Analytics Delivery
2209
2209
 
2210
- Google Tag Manager is loaded via `@next/third-parties` (`^16.1.1`) using the `NEXT_PUBLIC_GTM_ID` environment variable. The production CSP allowlist emitted by `apps/nextblock/proxy.ts` explicitly includes `googletagmanager.com`, `google-analytics.com`, and `analytics.google.com` per F-011's origin allowlist.
2210
+ Google Tag Manager is loaded via `@next/third-parties` (`^16.1.1`) using the GTM container id configured in the CMS at **Settings → Privacy** and stored in the `site_settings` table (`privacy_settings.gtm_id`); there is no `NEXT_PUBLIC_GTM_ID` environment variable. The id is read in the root layout via `getPrivacySettings()` and passed through the consent gate, so the tag loads only after the visitor accepts analytics. The production CSP allowlist emitted by `apps/nextblock/proxy.ts` explicitly includes `googletagmanager.com`, `google-analytics.com`, and `analytics.google.com` per F-011's origin allowlist.
2211
2211
 
2212
2212
  ## 3.5 DATABASES AND STORAGE
2213
2213
 
@@ -4192,7 +4192,7 @@ The integration landscape comprises eight external domains declared in `libs/env
4192
4192
  | Frankfurter FX | HTTPS (JSON) | Cron runs 18:00 UTC daily; `maxDuration: 30s` |
4193
4193
  | SMTP | SMTP + TLS | Best-effort from server actions |
4194
4194
  | Vercel Cron | HTTPS + Bearer `CRON_SECRET` | 03:00 UTC (reset-sandbox, 60s); 18:00 UTC (sync-currencies, 30s) |
4195
- | Google Tag Manager | HTTPS (JS) | `NEXT_PUBLIC_GTM_ID`; allowlisted in CSP |
4195
+ | Google Tag Manager | HTTPS (JS) | GTM id from `privacy_settings` (site_settings); allowlisted in CSP |
4196
4196
 
4197
4197
  ## 5.2 COMPONENT DETAILS
4198
4198
 
@@ -7135,7 +7135,6 @@ Freemius has the broadest environment surface, reflecting the mixture of store-s
7135
7135
  |:--|:--|
7136
7136
  | `CRON_SECRET` | Bearer token for all `/api/cron/*` endpoints |
7137
7137
  | `REVALIDATE_SECRET_TOKEN` | Header token for `/api/revalidate` |
7138
- | `NEXT_PUBLIC_GTM_ID` | Google Tag Manager container ID |
7139
7138
 
7140
7139
  ##### 6.3.4.4.7 Optional Overrides
7141
7140
 
@@ -7369,7 +7368,7 @@ Per Section 5.1.4.2, each external integration carries explicit SLA-like propert
7369
7368
  | Frankfurter | HTTPS (JSON) | Cron daily 18:00 UTC; `maxDuration: 30s` |
7370
7369
  | SMTP | SMTP + TLS | Best-effort from server actions |
7371
7370
  | Vercel Cron | HTTPS + Bearer CRON_SECRET | 03:00 UTC (reset-sandbox 60s); 18:00 UTC (sync-currencies 30s) |
7372
- | Google Tag Manager | HTTPS (JS) | `NEXT_PUBLIC_GTM_ID`; allowlisted in CSP |
7371
+ | Google Tag Manager | HTTPS (JS) | GTM id from `privacy_settings` (site_settings); allowlisted in CSP |
7373
7372
 
7374
7373
  #### 6.3.6.2 Known Integration Limitations
7375
7374
 
@@ -8324,7 +8323,7 @@ The `@vercel/speed-insights ^1.3.1` package is declared in the root `package.jso
8324
8323
 
8325
8324
  ##### 6.5.2.1.2 Google Tag Manager and Client-Side Analytics
8326
8325
 
8327
- Google Tag Manager is integrated via `@next/third-parties` and the `<GoogleTagManager gtmId={process.env.NEXT_PUBLIC_GTM_ID || ''} nonce={nonce} />` component, also mounted in the root layout. Configuration is environment-driven via `NEXT_PUBLIC_GTM_ID`; when the variable is not set, the tag is rendered with an empty `gtmId` and effectively disabled. The CSP allowlists `googletagmanager.com`, `google-analytics.com`, `analytics.google.com`, and `*.googletagmanager.com` origins for `script-src`, `img-src`, and `connect-src`, so analytics delivery operates without breaking the nonce policy.
8326
+ Google Tag Manager is integrated via `@next/third-parties`, wrapped by `ConsentGatedAnalytics` → `DeferredGoogleTagManager` and mounted in the root layout. The container id is database-driven: the layout resolves `const resolvedGtmId = privacySettings.gtm_id || ''` from `getPrivacySettings()` (the `privacy_settings` row of `site_settings`, edited at **Settings → Privacy**) — there is no `NEXT_PUBLIC_GTM_ID` env fallback. When the id is empty, no GTM chunk is imported; when set, the tag still loads only after the visitor consents to analytics and after the first interaction event. The CSP allowlists `googletagmanager.com`, `google-analytics.com`, `analytics.google.com`, and `*.googletagmanager.com` origins for `script-src`, `img-src`, and `connect-src`, so analytics delivery operates without breaking the nonce policy.
8328
8327
 
8329
8328
  ##### 6.5.2.1.3 Declared-but-Unused @vercel/analytics
8330
8329
 
@@ -8393,7 +8392,7 @@ The primary alert pathway is the **Feedback System** implemented by `FeedbackMod
8393
8392
 
8394
8393
  ##### 6.5.2.4.2 Configuration-Discovery Alerts
8395
8394
 
8396
- The CMS dashboard at `/cms/dashboard/page.tsx` reads `process.env.NEXT_PUBLIC_GTM_ID` during render and conditionally displays a destructive `<Alert>` banner when GTM is unconfigured (suppressed when explicitly set to the string `'false'`). This is a CMS-admin-facing configuration alert — not a runtime monitoring alert — designed to catch missing telemetry configuration during deployment.
8395
+ The CMS dashboard at `/cms/dashboard/page.tsx` calls `getPrivacySettings()` during render and conditionally displays a destructive `<Alert>` banner (linking to **Settings Privacy**) when no GTM container id is configured in the `privacy_settings` row. This is a CMS-admin-facing configuration alert — not a runtime monitoring alert — designed to catch missing telemetry configuration after deployment.
8397
8396
 
8398
8397
  ##### 6.5.2.4.3 Platform-Managed Alerts
8399
8398
 
@@ -8441,7 +8440,7 @@ flowchart TB
8441
8440
  end
8442
8441
 
8443
8442
  subgraph ConfigAlert["Configuration Banner"]
8444
- GtmBanner{{NEXT_PUBLIC_GTM_ID not set?<br/>Show destructive Alert}}
8443
+ GtmBanner{{privacy_settings.gtm_id not set?<br/>Show destructive Alert}}
8445
8444
  end
8446
8445
 
8447
8446
  GtmBanner --> MetricRow
@@ -8896,7 +8895,7 @@ The following observability gaps are acknowledged and documented for honest stak
8896
8895
  - `apps/nextblock/app/providers.tsx` — Client provider composition order
8897
8896
  - `libs/ecommerce/src/lib/stripe/webhooks.ts` — Stripe webhook handler with `[Stripe Webhook Error]` prefix; `console.error` on missing `STRIPE_WEBHOOK_SECRET` and on `constructEvent` failure
8898
8897
  - `libs/db/src/lib/package-validation.ts` — License gate with `console.error` and 60-second `unstable_cache` tagged `'package-activation'`
8899
- - `libs/environment.d.ts` — `NodeJS.ProcessEnv` augmentation declaring all telemetry and external-service env vars (`NEXT_PUBLIC_GTM_ID`, `NEXT_PUBLIC_IS_SANDBOX`, `REVALIDATE_SECRET_TOKEN`, `CRON_SECRET`, SMTP vars)
8898
+ - `libs/environment.d.ts` — `NodeJS.ProcessEnv` augmentation declaring external-service env vars (Supabase, R2/S3, SMTP, Freemius, OpenRouter/Cortex AI). GTM is no longer env-configured — it lives in `privacy_settings`.
8900
8899
  - `vercel.json` — Two cron schedule declarations: `0 3 * * *` reset-sandbox (60s) and `0 18 * * *` sync-currencies (30s)
8901
8900
  - `package.json` (root) — Dependency declarations including `@vercel/speed-insights ^1.3.1` and `@next/third-parties ^16.1.1`
8902
8901
  - `apps/nextblock/package.json` — Template-level dependency declarations including `@vercel/analytics ^1.6.1` (declared but not imported)
@@ -10423,7 +10422,7 @@ graph TB
10423
10422
 
10424
10423
  subgraph Telemetry["Observability Sinks"]
10425
10424
  SpeedIns[(Vercel Speed Insights<br/>LCP · INP · CLS · TTFB)]
10426
- GTM[(Google Tag Manager<br/>NEXT_PUBLIC_GTM_ID)]
10425
+ GTM[(Google Tag Manager<br/>privacy_settings.gtm_id)]
10427
10426
  VercelLogs[(Vercel Log Stream<br/>warn + error preserved)]
10428
10427
  FeedbackInbox[(feedback@nextblock.ca<br/>SMTP inbox)]
10429
10428
  end
@@ -10541,7 +10540,7 @@ Runtime configuration is **exclusively environment-variable driven**, consumed t
10541
10540
 
10542
10541
  | Category | Variable Count | Examples |
10543
10542
  |:--|:--|:--|
10544
- | Platform | 4 | `NEXT_PUBLIC_URL`, `TARGET_URL`, `NEXT_PUBLIC_IS_SANDBOX`, `NEXT_PUBLIC_GTM_ID` |
10543
+ | Platform | 3 | `NEXT_PUBLIC_URL`, `TARGET_URL`, `NEXT_PUBLIC_IS_SANDBOX` |
10545
10544
  | Secrets / Auth | 3 | `CRON_SECRET`, `REVALIDATE_SECRET_TOKEN`, `LHCI_GITHUB_APP_TOKEN` |
10546
10545
  | FX | 1 | `FX_API_BASE_URL` (defaults to `https://api.frankfurter.dev`) |
10547
10546
  | Supabase | 6 | `SUPABASE_PROJECT_ID`, `POSTGRES_URL`, `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY`, `SUPABASE_ACCESS_TOKEN` |
@@ -10723,7 +10722,7 @@ Eight external service integrations are declared across the workspace:
10723
10722
  | Frankfurter FX | native `fetch` | N/A (`api.frankfurter.dev`) | Best-effort (skipped currency telemetry) |
10724
10723
  | SMTP | `nodemailer` | `^7.0.10` | Best-effort degrade |
10725
10724
  | Vercel Speed Insights | `@vercel/speed-insights` | `^1.3.1` | Observational |
10726
- | Google Tag Manager | `@next/third-parties` | `^16.1.1` / `1.1.1` | Observational (disabled if `NEXT_PUBLIC_GTM_ID` unset) |
10725
+ | Google Tag Manager | `@next/third-parties` | `^16.1.1` / `1.1.1` | Observational (disabled if `privacy_settings.gtm_id` unset) |
10727
10726
 
10728
10727
  ### 8.3.3 High Availability Design
10729
10728
 
@@ -11380,7 +11379,7 @@ graph TB
11380
11379
 
11381
11380
  subgraph InternalDash["In-App Dashboards"]
11382
11381
  CmsDash[/cms/dashboard<br/>Live counts via Supabase]
11383
- GtmBanner[GTM Config Banner<br/>when NEXT_PUBLIC_GTM_ID unset]
11382
+ GtmBanner[GTM Config Banner<br/>when privacy_settings.gtm_id unset]
11384
11383
  end
11385
11384
 
11386
11385
  SpeedInsClient --> SpeedDash
@@ -11690,7 +11689,6 @@ The following table enumerates environment variables declared in the `NodeJS.Pro
11690
11689
  | `SMTP_FROM_NAME` | SMTP | From-name for transactional mail |
11691
11690
  | `NEXT_PUBLIC_URL` | Platform | Canonical application base URL |
11692
11691
  | `NEXT_PUBLIC_IS_SANDBOX` | Platform | Enables sandbox banner and demo behaviors |
11693
- | `NEXT_PUBLIC_GTM_ID` | Platform | Google Tag Manager container ID |
11694
11692
  | `CRON_SECRET` | Platform | Bearer token authenticating cron endpoints |
11695
11693
  | `REVALIDATE_SECRET_TOKEN` | Platform | Validates on-demand revalidation requests |
11696
11694
  | `FX_API_BASE_URL` | Platform | Frankfurter FX API base URL override |
@@ -10,6 +10,7 @@ import {
10
10
  setSecureCookie,
11
11
  } from './cookies';
12
12
  import { hasValidTrustedDevice } from './trustedDevices';
13
+ import { getSecuritySettings } from '../privacy/settings';
13
14
 
14
15
  const EMAIL_CODE_TTL_MS = 5 * 60 * 1000; // 5 minutes
15
16
  const TWO_FACTOR_SESSION_TTL_SECONDS = 12 * 60 * 60; // 12 hours
@@ -117,6 +118,46 @@ export interface TwoFactorEvaluation {
117
118
  mfaType: 'totp' | 'email' | null;
118
119
  }
119
120
 
121
+ /**
122
+ * Whether to surface the "set up two-factor authentication" reminder banner to the
123
+ * currently signed-in staff user. True only when: not in sandbox, the user is an
124
+ * ADMIN/WRITER, the global "encourage staff to enable 2FA" policy is on, and the user
125
+ * has not enrolled a second factor. Best-effort — any failure resolves to no banner.
126
+ */
127
+ export async function getStaffTwoFactorReminder(): Promise<boolean> {
128
+ // Sandbox runs on a shared demo account; never nag it (the page is disabled there too).
129
+ if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') return false;
130
+
131
+ try {
132
+ const supabase = createClient();
133
+ const {
134
+ data: { user },
135
+ } = await supabase.auth.getUser();
136
+ if (!user) return false;
137
+
138
+ const { data: profile } = await supabase
139
+ .from('profiles')
140
+ .select('role')
141
+ .eq('id', user.id)
142
+ .maybeSingle();
143
+ const role = profile?.role;
144
+ if (role !== 'ADMIN' && role !== 'WRITER') return false;
145
+
146
+ const { enforce_staff_2fa } = await getSecuritySettings();
147
+ if (!enforce_staff_2fa) return false;
148
+
149
+ const { data: settings } = await supabase
150
+ .from('user_security_settings')
151
+ .select('mfa_enabled, mfa_type')
152
+ .eq('user_id', user.id)
153
+ .maybeSingle();
154
+
155
+ return !(settings?.mfa_enabled && settings?.mfa_type);
156
+ } catch {
157
+ return false;
158
+ }
159
+ }
160
+
120
161
  /**
121
162
  * Decide whether the currently signed-in user still owes a second factor.
122
163
  * Used both to route sign-in and to guard /cms server-side.
@@ -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
- enforce_staff_2fa: false,
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';