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
|
@@ -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) | `
|
|
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) | `
|
|
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
|
|
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`
|
|
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{{
|
|
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
|
|
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/>
|
|
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 |
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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';
|