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.
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 +245 -0
  5. package/templates/nextblock-template/app/api/webhooks/freemius/route.ts +4 -0
  6. package/templates/nextblock-template/app/checkout/success/actions.ts +2 -1
  7. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +64 -20
  8. package/templates/nextblock-template/app/cms/components/TwoFactorReminderBanner.tsx +45 -0
  9. package/templates/nextblock-template/app/cms/dashboard/components/DashboardOnboarding.tsx +118 -0
  10. package/templates/nextblock-template/app/cms/dashboard/page.tsx +6 -11
  11. package/templates/nextblock-template/app/cms/layout.tsx +8 -3
  12. package/templates/nextblock-template/app/cms/settings/email/actions.ts +60 -0
  13. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +181 -0
  14. package/templates/nextblock-template/app/cms/settings/email/page.tsx +28 -0
  15. package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +60 -0
  16. package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +129 -0
  17. package/templates/nextblock-template/app/cms/settings/google-analytics/page.tsx +26 -0
  18. package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +5 -6
  19. package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +0 -48
  20. package/templates/nextblock-template/app/cms/settings/privacy/page.tsx +4 -3
  21. package/templates/nextblock-template/app/cms/settings/registration/actions.ts +44 -0
  22. package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +65 -0
  23. package/templates/nextblock-template/app/cms/settings/registration/page.tsx +27 -0
  24. package/templates/nextblock-template/app/cms/settings/security/actions.ts +3 -0
  25. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +2 -2
  26. package/templates/nextblock-template/app/cms/settings/security/page.tsx +20 -0
  27. package/templates/nextblock-template/app/layout.tsx +5 -1
  28. package/templates/nextblock-template/app/setup/SetupWizard.tsx +15 -158
  29. package/templates/nextblock-template/app/setup/page.tsx +0 -8
  30. package/templates/nextblock-template/components/AppShell.tsx +0 -7
  31. package/templates/nextblock-template/components/BlockRenderer.tsx +9 -1
  32. package/templates/nextblock-template/components/DeferredGoogleAnalytics.tsx +70 -0
  33. package/templates/nextblock-template/components/blocks/renderers/TextBlockRenderer.tsx +25 -20
  34. package/templates/nextblock-template/components/privacy/ConsentGatedAnalytics.tsx +13 -2
  35. package/templates/nextblock-template/docs/05-DEVELOPER-GUIDE.md +11 -0
  36. package/templates/nextblock-template/docs/06-CLI-AND-SCAFFOLDING.md +59 -8
  37. package/templates/nextblock-template/docs/README.md +3 -0
  38. package/templates/nextblock-template/docs/TECHNICAL_SPECIFICATION.md +11 -13
  39. package/templates/nextblock-template/lib/auth/twoFactor.ts +41 -0
  40. package/templates/nextblock-template/lib/config/email-settings.ts +217 -0
  41. package/templates/nextblock-template/lib/onboarding/actions.ts +31 -0
  42. package/templates/nextblock-template/lib/onboarding/status.ts +136 -0
  43. package/templates/nextblock-template/lib/privacy/contact-emails.ts +64 -0
  44. package/templates/nextblock-template/lib/privacy/settings.ts +12 -0
  45. package/templates/nextblock-template/lib/privacy/types.ts +3 -1
  46. package/templates/nextblock-template/lib/setup/actions.ts +6 -21
  47. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +10 -0
  48. package/templates/nextblock-template/next-env.d.ts +1 -1
  49. package/templates/nextblock-template/package.json +1 -1
  50. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type ComponentType } from "react";
4
+
5
+ interface DeferredGoogleAnalyticsProps {
6
+ gaId?: string;
7
+ nonce?: string;
8
+ }
9
+
10
+ const interactionEvents: Array<keyof WindowEventMap> = [
11
+ "pointerdown",
12
+ "keydown",
13
+ "scroll",
14
+ "touchstart",
15
+ ];
16
+
17
+ type GoogleAnalyticsComponent = ComponentType<{
18
+ gaId: string;
19
+ nonce?: string;
20
+ }>;
21
+
22
+ export function DeferredGoogleAnalytics({
23
+ gaId,
24
+ nonce,
25
+ }: DeferredGoogleAnalyticsProps) {
26
+ const [GoogleAnalytics, setGoogleAnalytics] =
27
+ useState<GoogleAnalyticsComponent | null>(null);
28
+
29
+ useEffect(() => {
30
+ if (!gaId) {
31
+ return;
32
+ }
33
+
34
+ let isMounted = true;
35
+
36
+ function removeListeners() {
37
+ interactionEvents.forEach((eventName) => {
38
+ window.removeEventListener(eventName, enableGa);
39
+ });
40
+ }
41
+
42
+ function enableGa() {
43
+ removeListeners();
44
+
45
+ void import("@next/third-parties/google").then(({ GoogleAnalytics }) => {
46
+ if (isMounted) {
47
+ setGoogleAnalytics(() => GoogleAnalytics as GoogleAnalyticsComponent);
48
+ }
49
+ });
50
+ }
51
+
52
+ interactionEvents.forEach((eventName) => {
53
+ window.addEventListener(eventName, enableGa, {
54
+ once: true,
55
+ passive: true,
56
+ });
57
+ });
58
+
59
+ return () => {
60
+ isMounted = false;
61
+ removeListeners();
62
+ };
63
+ }, [gaId]);
64
+
65
+ if (!gaId || !GoogleAnalytics) {
66
+ return null;
67
+ }
68
+
69
+ return <GoogleAnalytics gaId={gaId} nonce={nonce} />;
70
+ }
@@ -2,17 +2,18 @@ import React from "react";
2
2
  import { headers } from 'next/headers';
3
3
  import ClientTextBlockRenderer from "./ClientTextBlockRenderer";
4
4
  import type { VisualEditAttributes } from "../../../lib/visual-editing/types";
5
+ import { substitutePrivacyMergeTags } from "../../../lib/privacy/contact-emails";
5
6
 
6
7
  export type TextBlockContent = {
7
8
  html_content?: string;
8
9
  };
9
10
 
10
- interface TextBlockRendererProps {
11
- content: TextBlockContent;
12
- languageId: number;
13
- visualEditAttributes?: VisualEditAttributes;
14
- renderContext?: 'prose' | 'section';
15
- }
11
+ interface TextBlockRendererProps {
12
+ content: TextBlockContent;
13
+ languageId: number;
14
+ visualEditAttributes?: VisualEditAttributes;
15
+ renderContext?: 'prose' | 'section';
16
+ }
16
17
 
17
18
  function addNonceToInlineScripts(html: string, nonce: string): string {
18
19
  if (!html || !nonce) return html || '';
@@ -24,23 +25,27 @@ function addNonceToInlineScripts(html: string, nonce: string): string {
24
25
  }
25
26
 
26
27
  const TextBlockRenderer: React.FC<TextBlockRendererProps> = async ({
27
- content,
28
- languageId,
29
- visualEditAttributes,
30
- renderContext = 'prose',
31
- }) => {
28
+ content,
29
+ languageId,
30
+ visualEditAttributes,
31
+ renderContext = 'prose',
32
+ }) => {
32
33
  const hdrs = await headers();
33
34
  const nonce = hdrs.get('x-nonce') || '';
34
- const htmlWithNonce = content.html_content ? addNonceToInlineScripts(content.html_content, nonce) : '';
35
+ let html = content.html_content || '';
36
+ if (html.includes('{{')) {
37
+ html = await substitutePrivacyMergeTags(html);
38
+ }
39
+ const htmlWithNonce = html ? addNonceToInlineScripts(html, nonce) : '';
35
40
  const patchedContent = { ...content, html_content: htmlWithNonce };
36
41
  return (
37
- <ClientTextBlockRenderer
38
- content={patchedContent}
39
- languageId={languageId}
40
- visualEditAttributes={visualEditAttributes}
41
- renderContext={renderContext}
42
- />
43
- );
44
- };
42
+ <ClientTextBlockRenderer
43
+ content={patchedContent}
44
+ languageId={languageId}
45
+ visualEditAttributes={visualEditAttributes}
46
+ renderContext={renderContext}
47
+ />
48
+ );
49
+ };
45
50
 
46
51
  export default TextBlockRenderer;
@@ -5,10 +5,12 @@
5
5
  // shows zero analytics bytes, preserving the Lighthouse budget.
6
6
  import { useEffect, useRef, useState } from 'react';
7
7
  import { DeferredGoogleTagManager } from '../DeferredGoogleTagManager';
8
+ import { DeferredGoogleAnalytics } from '../DeferredGoogleAnalytics';
8
9
  import { CONSENT_CHANGE_EVENT, readConsent } from '../../lib/privacy/consent-client';
9
10
 
10
11
  interface ConsentGatedAnalyticsProps {
11
12
  gtmId?: string;
13
+ gaMeasurementId?: string;
12
14
  customScripts?: string;
13
15
  nonce?: string;
14
16
  }
@@ -29,6 +31,7 @@ function injectCustomScripts(markup: string, nonce?: string) {
29
31
 
30
32
  export function ConsentGatedAnalytics({
31
33
  gtmId,
34
+ gaMeasurementId,
32
35
  customScripts,
33
36
  nonce,
34
37
  }: ConsentGatedAnalyticsProps) {
@@ -54,6 +57,14 @@ export function ConsentGatedAnalytics({
54
57
  }
55
58
  }, [analyticsAllowed, customScripts, nonce]);
56
59
 
57
- if (!analyticsAllowed || !gtmId) return null;
58
- return <DeferredGoogleTagManager gtmId={gtmId} nonce={nonce} />;
60
+ if (!analyticsAllowed) return null;
61
+ if (!gtmId && !gaMeasurementId) return null;
62
+ return (
63
+ <>
64
+ {gtmId && <DeferredGoogleTagManager gtmId={gtmId} nonce={nonce} />}
65
+ {gaMeasurementId && (
66
+ <DeferredGoogleAnalytics gaId={gaMeasurementId} nonce={nonce} />
67
+ )}
68
+ </>
69
+ );
59
70
  }
@@ -109,6 +109,17 @@ repo expects at least:
109
109
  - `CRON_SECRET`, `DRAFT_MODE_SECRET`, `REVALIDATE_SECRET_TOKEN` — auto-generated
110
110
  by `npm run setup`
111
111
 
112
+ > **Supabase key aliases.** The names above are the local-dev canon, but the app also
113
+ > accepts the *new-style* names the hosted/Vercel Supabase Marketplace integration
114
+ > injects: `SUPABASE_URL` (non-prefixed), `SUPABASE_PUBLISHABLE_KEY` /
115
+ > `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` (anon-equivalent), and `SUPABASE_SECRET_KEY`
116
+ > (service-role-equivalent). Resolution lives in `apps/nextblock/lib/setup/env-status.ts`
117
+ > (`resolveSupabaseUrl` / `resolveSupabaseAnonKey` / `resolveSupabaseServiceKey`); read
118
+ > Supabase env through those (or the same inline alias chain in published libs) rather
119
+ > than a single raw name. `DRAFT_MODE_SECRET` / `REVALIDATE_SECRET_TOKEN` are optional in
120
+ > production — when unset they are derived from the service-role key (see
121
+ > `apps/nextblock/lib/app-secrets.ts`). See [12-VERCEL-DEPLOYMENT.md](./12-VERCEL-DEPLOYMENT.md).
122
+
112
123
  Captured by `npm run setup` and needed for a complete CMS:
113
124
 
114
125
  - R2 credentials for media storage. The app builds and serves without them, but
@@ -115,11 +115,62 @@ package activation state.
115
115
 
116
116
  ## Publishing and Release Notes
117
117
 
118
- Inside the monorepo, CLI release work is still tied to the source workspace:
119
-
120
- - library builds and publishes happen from the workspace
121
- - template sync happens before CLI packaging
122
- - the CLI package itself is versioned in `apps/create-nextblock/package.json`
123
-
124
- If a generated project looks stale, check the sync script and template output
125
- before assuming the source app is missing the feature.
118
+ A generated project installs the libraries from **npm**, so a feature only reaches
119
+ scaffolds after the libs are republished. (The monorepo's own Vercel deploy builds the
120
+ libs from source, so it sees changes immediately — only `npm create` scaffolds need a
121
+ republish.)
122
+
123
+ ### Release commands
124
+
125
+ - `npm run release:all -- <version>` build **and publish every package** at one
126
+ synchronized version, in dependency order: `utils → ui → sdk → db → editor → ecom`,
127
+ then `release-cli.js` (which stamps the root + template + `create-nextblock`, re-syncs
128
+ the template, and publishes the CLI). Pass an explicit semver (e.g. `0.10.2`);
129
+ `--dry-run` prints the plan only.
130
+ - `npm run build:<lib>` (`build:utils|ui|db|editor|sdk|ecom`) and
131
+ `node tools/scripts/release-lib.js <lib> <version>` — build + publish a **single** lib
132
+ (`<lib>` is the nx project name, so use `ecommerce`, which maps to the published
133
+ `@nextblock-cms/ecom`). `npx nx build <lib>` only *compiles*, it does not publish.
134
+
135
+ ### npm 2FA / OTP (and capturing a log)
136
+
137
+ Publishing requires a one-time password if the npm account has 2FA. **Piping the command
138
+ output breaks the interactive OTP prompt** — `npm run release:all -- … 2>&1 | Tee-Object …`
139
+ (or `| tee`) fails with `npm error code EOTP` because npm no longer has a TTY. Either:
140
+
141
+ - set an npm **Automation** token (`npm config set //registry.npmjs.org/:_authToken …`),
142
+ which bypasses 2FA — then piping to a log file is fine; or
143
+ - capture with PowerShell `Start-Transcript -Path release.log; npm run release:all -- … ;
144
+ Stop-Transcript`, which records the session while npm keeps its terminal.
145
+
146
+ `release:all` has no "already published" guard: if it dies partway, re-running the same
147
+ version re-publishes from the top and 403s on the first already-published lib. Finish a
148
+ partial release by running the **remaining** libs individually
149
+ (`node tools/scripts/release-lib.js <lib> <version>` … then `release-cli.js <version>`),
150
+ or bump to a fresh version and re-run the whole thing.
151
+
152
+ ### Library build gotchas (dts / tsconfig)
153
+
154
+ Each lib emits its `.d.ts` via `vite-plugin-dts` running tsc on `tsconfig.lib.json`. When a
155
+ lib imports a sibling (`ui`/`db` import `utils`; `ecom` imports all), how you wire the
156
+ tsconfig decides whether the build log is clean:
157
+
158
+ - A **composite** lib (`ui`, `db` — `db` inherits it) must **list the imported sibling's
159
+ sources in `include`** (e.g. `"../utils/src/**/*.ts"`) and keep `"references": []`.
160
+ Mirror `libs/editor`, which always built clean this way. A composite project
161
+ `reference` to the sibling triggers `TS6305` ("output not built" — vite never produces
162
+ the `tsc -b` out-tsc output); empty `references` *without* the `include` triggers
163
+ `TS6307` ("file not listed"). Both are non-fatal log noise but should stay at zero.
164
+ - A **non-composite** lib (`ecom`, which extends `tsconfig.base.json`) just needs
165
+ `"references": []` — no `include` of siblings.
166
+ - A **strict** lib (`db`/`sdk` set `noPropertyAccessFromIndexSignature`) compiles the
167
+ sibling's *source* under its strict rules, so `libs/utils` must stay strict-clean
168
+ (bracket-access undeclared keys, e.g. `process.env['R2_BUCKET_NAME']`).
169
+
170
+ `vite-plugin-dts` `entryRoot: 'src'` keeps emission to the lib's own `src`, so listing
171
+ sibling sources does **not** leak their `.d.ts` into the tarball. The published `bin` path
172
+ in `apps/create-nextblock/package.json` should have **no leading `./`** (`bin/…`, not
173
+ `./bin/…`) or npm "auto-corrects" it with a publish warning.
174
+
175
+ If a generated project looks stale, check the sync script and template output (and whether
176
+ the libs were actually republished) before assuming the source app is missing the feature.
@@ -17,6 +17,7 @@ library surfaces rather than historical planning notes.
17
17
  - Live draft (visual editing) mode: [09-LIVE-DRAFT-MODE.md](./09-LIVE-DRAFT-MODE.md)
18
18
  - Custom blocks (data-driven CRUD): [10-CUSTOM-BLOCKS.md](./10-CUSTOM-BLOCKS.md)
19
19
  - Self-hosted local Docker stack: [11-SELF-HOSTED-DOCKER.md](./11-SELF-HOSTED-DOCKER.md)
20
+ - One-click cloud deploy (Deploy to Vercel): [12-VERCEL-DEPLOYMENT.md](./12-VERCEL-DEPLOYMENT.md)
20
21
 
21
22
  ## Audience Guide
22
23
 
@@ -26,7 +27,9 @@ library surfaces rather than historical planning notes.
26
27
  - Custom block work: read `03`, then `10`.
27
28
  - AI / Cortex work: read `08`.
28
29
  - CLI or template work: read `06`.
30
+ - Publishing the libraries / scaffold CLI: read `06`.
29
31
  - Running everything locally without cloud accounts: read `11`.
32
+ - One-click cloud deploy and the browser setup wizard: read `12`.
30
33
  - AI agents: start with this index, then move directly to the subsystem file that
31
34
  matches the task. Treat `apps/nextblock`, `libs/*`, and
32
35
  `libs/db/src/supabase/migrations` as the final authority if a doc and code ever
@@ -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.