create-nextblock 0.10.1 → 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nextblock",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  "use server";
2
2
 
3
- import { getEmailServerConfig } from '@nextblock-cms/utils/server';
3
+ import { resolveEmailServerConfig } from '../../lib/config/email-settings';
4
4
  import nodemailer from 'nodemailer';
5
5
 
6
6
  interface EmailParams {
@@ -11,10 +11,11 @@ interface EmailParams {
11
11
  }
12
12
 
13
13
  export async function sendEmail({ to, subject, text, html }: EmailParams) {
14
- const emailConfig = await getEmailServerConfig();
14
+ // DB-first (CMS Settings → Configuration → Email), falling back to SMTP_* env vars.
15
+ const emailConfig = await resolveEmailServerConfig();
15
16
 
16
17
  if (!emailConfig) {
17
- throw new Error("Email server is not configured. Please check environment variables.");
18
+ throw new Error("Email server is not configured. Configure SMTP in CMS Settings → Configuration → Email.");
18
19
  }
19
20
 
20
21
  const transporter = nodemailer.createTransport(emailConfig);
@@ -4,36 +4,45 @@
4
4
  import { sendEmail } from './email';
5
5
  import { getServiceRoleSupabaseClient } from '@nextblock-cms/db/server';
6
6
 
7
- interface FormSubmissionResult {
8
- success: boolean;
9
- message: string;
10
- }
11
-
12
- type BotProtectionProvider = 'none' | 'turnstile' | 'recaptcha';
13
-
14
- type FormSubmissionConfig = {
15
- recipient: string;
16
- botProtectionProvider?: BotProtectionProvider;
17
- };
18
-
19
- function normalizeSubmissionConfig(config: string | FormSubmissionConfig) {
20
- if (typeof config === 'string') {
21
- return {
22
- recipient: config,
23
- botProtectionProvider: undefined,
24
- };
25
- }
26
-
27
- return config;
28
- }
29
-
30
- export async function handleFormSubmission(
31
- config: string | FormSubmissionConfig,
32
- prevState: unknown,
33
- formData: FormData
34
- ): Promise<FormSubmissionResult> {
35
- const { recipient, botProtectionProvider } = normalizeSubmissionConfig(config);
36
-
7
+ interface FormSubmissionResult {
8
+ success: boolean;
9
+ message: string;
10
+ }
11
+
12
+ type BotProtectionProvider = 'none' | 'turnstile' | 'recaptcha';
13
+
14
+ type FormSubmissionConfig = {
15
+ recipient: string;
16
+ botProtectionProvider?: BotProtectionProvider;
17
+ };
18
+
19
+ function normalizeSubmissionConfig(config: string | FormSubmissionConfig) {
20
+ if (typeof config === 'string') {
21
+ return {
22
+ recipient: config,
23
+ botProtectionProvider: undefined,
24
+ };
25
+ }
26
+
27
+ return config;
28
+ }
29
+
30
+ export async function handleFormSubmission(
31
+ config: string | FormSubmissionConfig,
32
+ prevState: unknown,
33
+ formData: FormData
34
+ ): Promise<FormSubmissionResult> {
35
+ const { recipient: configuredRecipient, botProtectionProvider } = normalizeSubmissionConfig(config);
36
+
37
+ // In sandbox mode the DB is periodically wiped and re-seeded with a dummy
38
+ // recipient, so route every submission to the operator's sandbox inbox instead.
39
+ // Real installs ignore this and use the recipient configured on the form block.
40
+ const sandboxRecipient =
41
+ process.env.NEXT_PUBLIC_IS_SANDBOX === 'true'
42
+ ? process.env.SANDBOX_CONTACT_EMAIL?.trim() || ''
43
+ : '';
44
+ const recipient = sandboxRecipient || configuredRecipient;
45
+
37
46
  // Phase 1: Honeypot Validation
38
47
  const honeypot = formData.get('verification_secondary_email');
39
48
  if (honeypot && typeof honeypot === 'string' && honeypot.length > 0) {
@@ -59,17 +68,17 @@ export async function handleFormSubmission(
59
68
  .eq('key', 'bot_protection_secret')
60
69
  .maybeSingle();
61
70
 
62
- const publicVal = (publicSetting?.value || {}) as Record<string, any>;
63
- const secretVal = (secretSetting?.value || {}) as Record<string, any>;
64
-
65
- const blockProvider =
66
- botProtectionProvider === 'turnstile' || botProtectionProvider === 'recaptcha'
67
- ? botProtectionProvider
68
- : undefined;
69
- const provider = blockProvider || publicVal.provider || 'none';
70
- const secretKey = secretVal.secretKey ||
71
- (provider === 'turnstile' ? process.env.TURNSTILE_SECRET_KEY : process.env.RECAPTCHA_SECRET_KEY) ||
72
- '';
71
+ const publicVal = (publicSetting?.value || {}) as Record<string, any>;
72
+ const secretVal = (secretSetting?.value || {}) as Record<string, any>;
73
+
74
+ const blockProvider =
75
+ botProtectionProvider === 'turnstile' || botProtectionProvider === 'recaptcha'
76
+ ? botProtectionProvider
77
+ : undefined;
78
+ const provider = blockProvider || publicVal.provider || 'none';
79
+ const secretKey = secretVal.secretKey ||
80
+ (provider === 'turnstile' ? process.env.TURNSTILE_SECRET_KEY : process.env.RECAPTCHA_SECRET_KEY) ||
81
+ '';
73
82
 
74
83
  if (provider === 'turnstile') {
75
84
  const token = formData.get('cf-turnstile-response') as string;
@@ -181,4 +190,4 @@ export async function handleFormSubmission(
181
190
  console.error("Email sending failed:", error);
182
191
  return { success: false, message: "Sorry, there was an error sending your message. Please try again later." };
183
192
  }
184
- }
193
+ }
@@ -7899,6 +7899,251 @@ CREATE POLICY system_configuration_service_role_all
7899
7899
  WITH CHECK (true);
7900
7900
 
7901
7901
 
7902
+ -- >>> FROM: 00000000000031_seed_footer_navigation.sql <<<
7903
+ -- 00000000000031_seed_footer_navigation.sql
7904
+ -- Seed editable FOOTER navigation items (EN + FR).
7905
+ --
7906
+ -- The public footer used to hard-code its "Privacy Policy" and "Terms of Service"
7907
+ -- links in apps/nextblock/components/AppShell.tsx. This migration turns them into
7908
+ -- real navigation_items rows under the FOOTER menu so they are editable from the
7909
+ -- CMS (/cms/navigation) like any other menu, and so the French footer points at
7910
+ -- the localized page slugs.
7911
+ --
7912
+ -- Targets the legal pages seeded by 00000000000027_setup_privacy_and_mfa.sql:
7913
+ -- EN /privacy-policy FR /politique-de-confidentialite
7914
+ -- EN /terms-of-service FR /conditions-utilisation
7915
+ --
7916
+ -- Idempotent: re-running replaces the FOOTER rows it owns (matched by URL) and
7917
+ -- reuses their translation_group_id so EN/FR stay linked across re-runs.
7918
+ DO $seed_footer$
7919
+ DECLARE
7920
+ v_en bigint;
7921
+ v_fr bigint;
7922
+ v_privacy_group uuid;
7923
+ v_terms_group uuid;
7924
+ v_en_privacy_page bigint;
7925
+ v_fr_privacy_page bigint;
7926
+ v_en_terms_page bigint;
7927
+ v_fr_terms_page bigint;
7928
+ BEGIN
7929
+ SELECT id INTO v_en FROM public.languages WHERE code = 'en' LIMIT 1;
7930
+ SELECT id INTO v_fr FROM public.languages WHERE code = 'fr' LIMIT 1;
7931
+
7932
+ IF v_en IS NULL THEN
7933
+ RAISE NOTICE 'Default language "en" not found; skipping footer navigation seed.';
7934
+ RETURN;
7935
+ END IF;
7936
+
7937
+ -- Reuse existing translation groups if these footer items were seeded before,
7938
+ -- so EN <-> FR stay paired and re-runs do not orphan translations.
7939
+ SELECT translation_group_id INTO v_privacy_group
7940
+ FROM public.navigation_items
7941
+ WHERE menu_key = 'FOOTER' AND url = '/privacy-policy' LIMIT 1;
7942
+ IF v_privacy_group IS NULL THEN v_privacy_group := gen_random_uuid(); END IF;
7943
+
7944
+ SELECT translation_group_id INTO v_terms_group
7945
+ FROM public.navigation_items
7946
+ WHERE menu_key = 'FOOTER' AND url = '/terms-of-service' LIMIT 1;
7947
+ IF v_terms_group IS NULL THEN v_terms_group := gen_random_uuid(); END IF;
7948
+
7949
+ -- Best-effort link each item to its underlying page (ON DELETE SET NULL keeps
7950
+ -- the link working even if a page is later removed; url remains the source of truth).
7951
+ SELECT id INTO v_en_privacy_page
7952
+ FROM public.pages WHERE slug = 'privacy-policy' AND language_id = v_en LIMIT 1;
7953
+ SELECT id INTO v_en_terms_page
7954
+ FROM public.pages WHERE slug = 'terms-of-service' AND language_id = v_en LIMIT 1;
7955
+
7956
+ -- Remove any prior copies of the footer links we own, then re-insert cleanly.
7957
+ DELETE FROM public.navigation_items
7958
+ WHERE menu_key = 'FOOTER'
7959
+ AND url IN (
7960
+ '/privacy-policy', '/terms-of-service',
7961
+ '/politique-de-confidentialite', '/conditions-utilisation'
7962
+ );
7963
+
7964
+ -- ----- English footer -----
7965
+ INSERT INTO public.navigation_items
7966
+ (language_id, menu_key, label, url, "order", page_id, translation_group_id)
7967
+ VALUES
7968
+ (v_en, 'FOOTER', 'Privacy Policy', '/privacy-policy', 0, v_en_privacy_page, v_privacy_group),
7969
+ (v_en, 'FOOTER', 'Terms of Service', '/terms-of-service', 1, v_en_terms_page, v_terms_group);
7970
+
7971
+ -- ----- French footer (only if the French language exists) -----
7972
+ IF v_fr IS NOT NULL THEN
7973
+ SELECT id INTO v_fr_privacy_page
7974
+ FROM public.pages WHERE slug = 'politique-de-confidentialite' AND language_id = v_fr LIMIT 1;
7975
+ SELECT id INTO v_fr_terms_page
7976
+ FROM public.pages WHERE slug = 'conditions-utilisation' AND language_id = v_fr LIMIT 1;
7977
+
7978
+ INSERT INTO public.navigation_items
7979
+ (language_id, menu_key, label, url, "order", page_id, translation_group_id)
7980
+ VALUES
7981
+ (v_fr, 'FOOTER', 'Politique de confidentialité', '/politique-de-confidentialite', 0, v_fr_privacy_page, v_privacy_group),
7982
+ (v_fr, 'FOOTER', 'Conditions d''utilisation', '/conditions-utilisation', 1, v_fr_terms_page, v_terms_group);
7983
+ END IF;
7984
+
7985
+ RAISE NOTICE 'Seeded FOOTER navigation items (Privacy Policy, Terms of Service).';
7986
+ END;
7987
+ $seed_footer$;
7988
+
7989
+
7990
+ -- >>> FROM: 00000000000032_neutralize_seeded_contact_emails.sql <<<
7991
+ -- 00000000000032_neutralize_seeded_contact_emails.sql
7992
+ -- Remove the original authors' contact addresses from seeded content so a
7993
+ -- downloaded / self-hosted copy of NextBlock never routes mail to us.
7994
+ --
7995
+ -- Earlier migrations baked real addresses into block content:
7996
+ -- * 00000000000027 seeded \`privacy@nextblock.dev\` across the Privacy Policy and
7997
+ -- Terms pages (EN + FR), as visible text and \`mailto:\` links.
7998
+ -- * 00000000000010 seeded a \`mailto:info@nextblock.dev\` CTA on the French home
7999
+ -- page (the English page correctly links to /contact), and \`foo@bar.com\` as
8000
+ -- the contact form recipient.
8001
+ --
8002
+ -- Migrations are append-only, so this is a forward-only data fix rather than an
8003
+ -- edit of those files. Each statement is idempotent (a no-op once applied).
8004
+ --
8005
+ -- The \`{{privacy_email}}\` token is resolved at render time by the app
8006
+ -- (apps/nextblock/lib/privacy/contact-emails.ts): admin "Support email" setting
8007
+ -- -> SANDBOX_PRIVACY_EMAIL env (sandbox only) -> privacy@example.com fallback.
8008
+
8009
+ -- 1. Privacy / Terms legal pages: swap the hard-coded address for a merge tag.
8010
+ UPDATE public.blocks
8011
+ SET content = replace(content::text, 'privacy@nextblock.dev', '{{privacy_email}}')::jsonb
8012
+ WHERE content::text LIKE '%privacy@nextblock.dev%';
8013
+
8014
+ -- 2. French home "Nous contacter" CTA: point at the contact form like the English
8015
+ -- page instead of a mailto to our inbox.
8016
+ UPDATE public.blocks
8017
+ SET content = replace(content::text, 'mailto:info@nextblock.dev', '/contact')::jsonb
8018
+ WHERE content::text LIKE '%mailto:info@nextblock.dev%';
8019
+
8020
+ -- 3. Contact form default recipient: use a neutral placeholder. In sandbox the
8021
+ -- app overrides this with SANDBOX_CONTACT_EMAIL at submit time.
8022
+ UPDATE public.blocks
8023
+ SET content = replace(content::text, 'foo@bar.com', 'contact@example.com')::jsonb
8024
+ WHERE content::text LIKE '%foo@bar.com%';
8025
+
8026
+
8027
+ -- >>> FROM: 00000000000033_setup_config_settings.sql <<<
8028
+ -- 00000000000033_setup_config_settings.sql
8029
+ -- DB-backed CMS configuration: move SMTP and payment-provider credentials out of
8030
+ -- environment variables and into \`site_settings\`. Secret rows (email_secret,
8031
+ -- payment_secret) hold AES-256-GCM envelopes and are restricted to ADMIN / service_role,
8032
+ -- extending the sensitive-key masking established in migration 018. Public rows hold
8033
+ -- non-secret config (SMTP host/from, publishable keys, provider flags) and the dashboard
8034
+ -- onboarding state.
8035
+ --
8036
+ -- This re-issues ALL FOUR site_settings policies because the masked-key list is embedded
8037
+ -- in each policy body and Postgres has no incremental "add a key" operation.
8038
+
8039
+ COMMENT ON TABLE public.site_settings IS 'Key-value store for global site settings. Sensitive keys (Cortex AI BYOK, Bot Protection Secret, Email secret, Payment secret) hold encrypted envelopes and are restricted to ADMIN via row-level policies.';
8040
+
8041
+ DROP POLICY IF EXISTS site_settings_read_policy ON public.site_settings;
8042
+ DROP POLICY IF EXISTS site_settings_insert_policy ON public.site_settings;
8043
+ DROP POLICY IF EXISTS site_settings_update_policy ON public.site_settings;
8044
+ DROP POLICY IF EXISTS site_settings_delete_policy ON public.site_settings;
8045
+
8046
+ CREATE POLICY site_settings_read_policy
8047
+ ON public.site_settings
8048
+ FOR SELECT
8049
+ TO public
8050
+ USING (
8051
+ key NOT IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')
8052
+ OR (
8053
+ key IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')
8054
+ AND (SELECT auth.role()) = 'authenticated'
8055
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'
8056
+ )
8057
+ );
8058
+
8059
+ CREATE POLICY site_settings_insert_policy
8060
+ ON public.site_settings
8061
+ FOR INSERT
8062
+ TO authenticated
8063
+ WITH CHECK (
8064
+ (
8065
+ key NOT IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')
8066
+ AND (SELECT public.get_current_user_role()) IN ('ADMIN', 'WRITER')
8067
+ )
8068
+ OR (
8069
+ key IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')
8070
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'
8071
+ )
8072
+ );
8073
+
8074
+ CREATE POLICY site_settings_update_policy
8075
+ ON public.site_settings
8076
+ FOR UPDATE
8077
+ TO authenticated
8078
+ USING (
8079
+ (
8080
+ key NOT IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')
8081
+ AND (SELECT public.get_current_user_role()) IN ('ADMIN', 'WRITER')
8082
+ )
8083
+ OR (
8084
+ key IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')
8085
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'
8086
+ )
8087
+ )
8088
+ WITH CHECK (
8089
+ (
8090
+ key NOT IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')
8091
+ AND (SELECT public.get_current_user_role()) IN ('ADMIN', 'WRITER')
8092
+ )
8093
+ OR (
8094
+ key IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')
8095
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'
8096
+ )
8097
+ );
8098
+
8099
+ CREATE POLICY site_settings_delete_policy
8100
+ ON public.site_settings
8101
+ FOR DELETE
8102
+ TO authenticated
8103
+ USING (
8104
+ (
8105
+ key NOT IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')
8106
+ AND (SELECT public.get_current_user_role()) IN ('ADMIN', 'WRITER')
8107
+ )
8108
+ OR (
8109
+ key IN ('cortex_ai_openrouter_api_key', 'bot_protection_secret', 'email_secret', 'payment_secret')
8110
+ AND (SELECT public.get_current_user_role()) = 'ADMIN'
8111
+ )
8112
+ );
8113
+
8114
+ -- Seed the new configuration rows (idempotent). Secret rows are created on first save
8115
+ -- from the CMS, so they are intentionally not seeded here.
8116
+ INSERT INTO public.site_settings (key, value)
8117
+ VALUES ('email_public', '{"host": "", "port": "", "fromEmail": "", "fromName": "", "secure": true}'::jsonb)
8118
+ ON CONFLICT (key) DO NOTHING;
8119
+
8120
+ INSERT INTO public.site_settings (key, value)
8121
+ VALUES ('payment_public', '{"stripe": {"publishableKey": ""}, "freemius": {"developerId": "", "publicKey": "", "productId": "", "sandboxEnabled": false}}'::jsonb)
8122
+ ON CONFLICT (key) DO NOTHING;
8123
+
8124
+ INSERT INTO public.site_settings (key, value)
8125
+ VALUES ('onboarding_state', '{"dismissed": false, "skipped": []}'::jsonb)
8126
+ ON CONFLICT (key) DO NOTHING;
8127
+
8128
+
8129
+ -- >>> FROM: 00000000000034_enable_staff_2fa_reminder_default.sql <<<
8130
+ -- 00000000000034_enable_staff_2fa_reminder_default.sql
8131
+ -- Turn the "Encourage staff to enable 2FA" policy ON by default. The reminder banner is
8132
+ -- now implemented (shown to ADMIN/WRITER accounts without a second factor); migration 027
8133
+ -- seeded this flag to false, so flip the existing security_settings row to true. Admins can
8134
+ -- still turn it off afterward. The sandbox never enforces it (handled at runtime), so the
8135
+ -- stored value here is harmless after a sandbox reset.
8136
+
8137
+ UPDATE public.site_settings
8138
+ SET value = jsonb_set(coalesce(value, '{}'::jsonb), '{enforce_staff_2fa}', 'true'::jsonb)
8139
+ WHERE key = 'security_settings';
8140
+
8141
+ -- Cover the unlikely case where the row is missing (it is seeded in migration 027).
8142
+ INSERT INTO public.site_settings (key, value)
8143
+ VALUES ('security_settings', '{"trusted_device_days": 30, "enforce_staff_2fa": true}'::jsonb)
8144
+ ON CONFLICT (key) DO NOTHING;
8145
+
8146
+
7902
8147
  -- Step D: Anchor preserved profiles
7903
8148
  INSERT INTO public.profiles (id, updated_at, full_name, avatar_url, website, role)
7904
8149
  SELECT preserved_user.id, NULL, NULL, NULL, NULL, 'ADMIN'
@@ -1,11 +1,15 @@
1
1
  import { NextResponse } from 'next/server';
2
2
  import {
3
+ hydrateFreemiusEnvFromDb,
3
4
  syncFreemiusOrderFromWebhookEvent,
4
5
  verifyFreemiusWebhookSignature,
5
6
  } from '@nextblock-cms/ecommerce/server';
6
7
 
7
8
  export async function POST(req: Request) {
8
9
  try {
10
+ // Make CMS-stored Freemius credentials available to the (sync) signature + sync helpers.
11
+ await hydrateFreemiusEnvFromDb();
12
+
9
13
  const rawBody = await req.text();
10
14
  const signature =
11
15
  req.headers.get('x-signature') ?? req.headers.get('x-freemius-signature');
@@ -5,7 +5,7 @@ import {
5
5
  applyOrderInventoryDeduction,
6
6
  assignInvoiceMetadata,
7
7
  getInvoicePresentationData,
8
- stripe,
8
+ getStripeClient,
9
9
  syncStripeOrderFromSession,
10
10
  } from '@nextblock-cms/ecommerce/server';
11
11
  import { resolveSupabaseServiceKey, resolveSupabaseUrl } from '../../../lib/setup/env-status';
@@ -37,6 +37,7 @@ export async function fulfillOrderAction(sessionId: string) {
37
37
 
38
38
  try {
39
39
  if (sessionId.startsWith('cs_')) {
40
+ const stripe = await getStripeClient();
40
41
  const session = await stripe.checkout.sessions.retrieve(sessionId);
41
42
 
42
43
  if (session.payment_status !== 'paid') {
@@ -9,8 +9,9 @@ import {
9
9
  LayoutDashboard, FileText, PenTool, Users, Settings, ChevronRight, LogOut, Menu, ListTree, Image as ImageIconLucide, X, Languages as LanguagesIconLucide, MessageSquare,
10
10
  Copyright as CopyrightIcon, ShoppingBag, ListOrdered, CreditCard, Package, Coins,
11
11
  ExternalLink, Paintbrush, Brain, TicketPercent, ShieldAlert, Folder, DatabaseBackup, Boxes, Tag,
12
- ShieldCheck, Cookie,
12
+ ShieldCheck, Cookie, LineChart, Mail, UserPlus, SlidersHorizontal,
13
13
  } from "lucide-react"
14
+ import TwoFactorReminderBanner from "./components/TwoFactorReminderBanner"
14
15
  import { Button } from "@nextblock-cms/ui"
15
16
  import { Avatar, AvatarFallback, AvatarImage } from "@nextblock-cms/ui"
16
17
  import { cn } from "@nextblock-cms/utils"
@@ -115,11 +116,14 @@ export default function CmsClientLayout({
115
116
  children,
116
117
  isCortexAiActive = false,
117
118
  isEcommerceActive = false,
119
+ showTwoFactorReminder = false,
118
120
  }: {
119
121
  children: ReactNode,
120
122
  isCortexAiActive?: boolean,
121
123
  isEcommerceActive?: boolean,
124
+ showTwoFactorReminder?: boolean,
122
125
  }) {
126
+ const isSandbox = process.env.NEXT_PUBLIC_IS_SANDBOX === 'true';
123
127
  const { user, profile, role, isLoading, isAdmin, isWriter } = useAuth();
124
128
  const { logo, siteTitle } = useAppBranding();
125
129
  const router = useRouter();
@@ -228,6 +232,9 @@ export default function CmsClientLayout({
228
232
  else if (pathname.startsWith("/cms/settings/taxes")) pageTitle = "Tax Settings";
229
233
  else if (pathname.startsWith("/cms/settings/cortex-ai")) pageTitle = "Cortex AI";
230
234
  else if (pathname.startsWith("/cms/settings/bot-protection")) pageTitle = "Bot Protection";
235
+ else if (pathname.startsWith("/cms/settings/email")) pageTitle = "Email";
236
+ else if (pathname.startsWith("/cms/settings/registration")) pageTitle = "Sign-ups & Registration";
237
+ else if (pathname.startsWith("/cms/settings/google-analytics")) pageTitle = "Google Analytics";
231
238
  else if (pathname.startsWith("/cms/settings/privacy")) pageTitle = "Privacy & Consent";
232
239
  else if (pathname.startsWith("/cms/settings/security")) pageTitle = "Security & 2FA";
233
240
  else if (pathname.startsWith("/cms/payments")) pageTitle = "Payment Settings";
@@ -309,9 +316,11 @@ export default function CmsClientLayout({
309
316
  <NavItem href="/cms/navigation" icon={ListTree} isActive={pathname.startsWith("/cms/navigation")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
310
317
  Navigation
311
318
  </NavItem>
312
- <NavItem href="/cms/settings/security" icon={ShieldCheck} isActive={pathname.startsWith("/cms/settings/security")} writerOnly isAdmin={isAdmin} isWriter={isWriter} onClick={closeSidebarOnMobile}>
313
- Security
314
- </NavItem>
319
+ {!isSandbox && (
320
+ <NavItem href="/cms/settings/security" icon={ShieldCheck} isActive={pathname.startsWith("/cms/settings/security")} writerOnly isAdmin={isAdmin} isWriter={isWriter} onClick={closeSidebarOnMobile}>
321
+ Security
322
+ </NavItem>
323
+ )}
315
324
 
316
325
  {isEcommerceActive && (
317
326
  <>
@@ -347,18 +356,31 @@ export default function CmsClientLayout({
347
356
  <NavItem href="/cms/promotions" icon={Tag} isActive={pathname.startsWith("/cms/promotions")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
348
357
  Bulk Price & Sales
349
358
  </NavItem>
350
- <NavItem href="/cms/shipping" icon={Package} isActive={pathname.startsWith("/cms/shipping")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
351
- Shipping
352
- </NavItem>
353
- <NavItem href="/cms/payments" icon={CreditCard} isActive={pathname.startsWith("/cms/payments")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
354
- Payments
355
- </NavItem>
356
- <NavItem href="/cms/settings/taxes" icon={Settings} isActive={pathname.startsWith("/cms/settings/taxes")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
357
- Taxes
358
- </NavItem>
359
- <NavItem href="/cms/settings/currencies" icon={Coins} isActive={pathname.startsWith("/cms/settings/currencies")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
360
- Currencies
361
- </NavItem>
359
+ <CollapsibleNavItem
360
+ icon={SlidersHorizontal}
361
+ title="Configuration"
362
+ isActive={
363
+ pathname.startsWith("/cms/payments") ||
364
+ pathname.startsWith("/cms/shipping") ||
365
+ pathname.startsWith("/cms/settings/taxes") ||
366
+ pathname.startsWith("/cms/settings/currencies")
367
+ }
368
+ adminOnly
369
+ isAdmin={isAdmin}
370
+ >
371
+ <NavItem href="/cms/payments" icon={CreditCard} isActive={pathname.startsWith("/cms/payments")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
372
+ Payments
373
+ </NavItem>
374
+ <NavItem href="/cms/shipping" icon={Package} isActive={pathname.startsWith("/cms/shipping")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
375
+ Shipping
376
+ </NavItem>
377
+ <NavItem href="/cms/settings/taxes" icon={Settings} isActive={pathname.startsWith("/cms/settings/taxes")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
378
+ Taxes
379
+ </NavItem>
380
+ <NavItem href="/cms/settings/currencies" icon={Coins} isActive={pathname.startsWith("/cms/settings/currencies")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
381
+ Currencies
382
+ </NavItem>
383
+ </CollapsibleNavItem>
362
384
  </>
363
385
  )}
364
386
 
@@ -378,7 +400,7 @@ export default function CmsClientLayout({
378
400
  <CollapsibleNavItem
379
401
  icon={Settings}
380
402
  title="Settings"
381
- isActive={pathname.startsWith("/cms/settings") && !pathname.startsWith("/cms/settings/taxes") && !pathname.startsWith("/cms/settings/currencies")}
403
+ isActive={pathname.startsWith("/cms/settings") && !pathname.startsWith("/cms/settings/taxes") && !pathname.startsWith("/cms/settings/currencies") && !pathname.startsWith("/cms/settings/email") && !pathname.startsWith("/cms/settings/registration") && !pathname.startsWith("/cms/settings/bot-protection")}
382
404
  adminOnly
383
405
  isAdmin={isAdmin}
384
406
  >
@@ -394,12 +416,12 @@ export default function CmsClientLayout({
394
416
  <NavItem href="/cms/settings/global-css" icon={Paintbrush} isActive={pathname.startsWith("/cms/settings/global-css")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
395
417
  Global CSS
396
418
  </NavItem>
397
- <NavItem href="/cms/settings/bot-protection" icon={ShieldAlert} isActive={pathname.startsWith("/cms/settings/bot-protection")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
398
- Bot Protection
399
- </NavItem>
400
419
  <NavItem href="/cms/settings/privacy" icon={Cookie} isActive={pathname.startsWith("/cms/settings/privacy")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
401
420
  Privacy &amp; Consent
402
421
  </NavItem>
422
+ <NavItem href="/cms/settings/google-analytics" icon={LineChart} isActive={pathname.startsWith("/cms/settings/google-analytics")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
423
+ Google Analytics
424
+ </NavItem>
403
425
  <NavItem href="/cms/settings/extra-translations" icon={MessageSquare} isActive={pathname.startsWith("/cms/settings/extra-translations")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
404
426
  Extra Translations
405
427
  </NavItem>
@@ -407,6 +429,27 @@ export default function CmsClientLayout({
407
429
  Backup / Restore
408
430
  </NavItem>
409
431
  </CollapsibleNavItem>
432
+ <CollapsibleNavItem
433
+ icon={SlidersHorizontal}
434
+ title="Configuration"
435
+ isActive={
436
+ pathname.startsWith("/cms/settings/email") ||
437
+ pathname.startsWith("/cms/settings/registration") ||
438
+ pathname.startsWith("/cms/settings/bot-protection")
439
+ }
440
+ adminOnly
441
+ isAdmin={isAdmin}
442
+ >
443
+ <NavItem href="/cms/settings/email" icon={Mail} isActive={pathname.startsWith("/cms/settings/email")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
444
+ Email
445
+ </NavItem>
446
+ <NavItem href="/cms/settings/bot-protection" icon={ShieldAlert} isActive={pathname.startsWith("/cms/settings/bot-protection")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
447
+ Bot Protection
448
+ </NavItem>
449
+ <NavItem href="/cms/settings/registration" icon={UserPlus} isActive={pathname.startsWith("/cms/settings/registration")} adminOnly isAdmin={isAdmin} onClick={closeSidebarOnMobile}>
450
+ Sign-ups
451
+ </NavItem>
452
+ </CollapsibleNavItem>
410
453
  </>
411
454
  )}
412
455
  </ul>
@@ -454,6 +497,7 @@ export default function CmsClientLayout({
454
497
  </Button>
455
498
  </header>
456
499
  <main className="flex-1 min-h-0 w-full overflow-y-auto overscroll-contain px-6 pt-6 pb-20 scroll-pb-24 md:pb-24">
500
+ {showTwoFactorReminder && <TwoFactorReminderBanner />}
457
501
  {children}
458
502
  </main>
459
503
  </div>
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+ import { useState } from 'react';
5
+ import { Button } from '@nextblock-cms/ui';
6
+ import { ShieldAlert, X } from 'lucide-react';
7
+
8
+ /**
9
+ * In-app reminder for staff (ADMIN/WRITER) who have not enrolled a second factor,
10
+ * shown when the "Encourage staff to enable 2FA" policy is on. Dismissible for the
11
+ * session (state lives in the persistent CMS layout, so it survives client-side
12
+ * navigation but reappears on the next full load until 2FA is set up).
13
+ */
14
+ export default function TwoFactorReminderBanner() {
15
+ const [dismissed, setDismissed] = useState(false);
16
+ if (dismissed) return null;
17
+
18
+ return (
19
+ <div className="mb-6 flex items-start gap-3 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-amber-900 dark:border-amber-700/60 dark:bg-amber-900/20 dark:text-amber-200">
20
+ <ShieldAlert className="mt-0.5 h-5 w-5 shrink-0" />
21
+ <div className="min-w-0 flex-1">
22
+ <p className="text-sm font-medium">
23
+ Your account doesn&rsquo;t have two-factor authentication enabled.
24
+ </p>
25
+ <p className="text-xs opacity-90">
26
+ Protect your CMS account by adding a second factor (authenticator app or email code).
27
+ </p>
28
+ </div>
29
+ <div className="flex shrink-0 items-center gap-2">
30
+ <Button asChild size="sm" variant="outline" className="border-amber-400">
31
+ <Link href="/cms/settings/security">Set up 2FA</Link>
32
+ </Button>
33
+ <Button
34
+ variant="ghost"
35
+ size="icon"
36
+ onClick={() => setDismissed(true)}
37
+ aria-label="Dismiss for now"
38
+ className="h-8 w-8"
39
+ >
40
+ <X className="h-4 w-4" />
41
+ </Button>
42
+ </div>
43
+ </div>
44
+ );
45
+ }