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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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
|
-
|
|
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.
|
|
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,287 @@ 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
|
+
|
|
8147
|
+
-- >>> FROM: 00000000000035_reassert_advisor_fixes.sql <<<
|
|
8148
|
+
-- 00000000000035_reassert_advisor_fixes.sql
|
|
8149
|
+
-- Re-assert two Supabase Advisor (database linter) fixes that were first applied in
|
|
8150
|
+
-- migration 00000000000028 but can be lost when a database is restored/reset to a
|
|
8151
|
+
-- pre-028 state while its migration history still records 028 as applied (so the forward
|
|
8152
|
+
-- tooling never re-runs it). These two advisors reappeared, so we re-apply the fixes in a
|
|
8153
|
+
-- forward-only, idempotent way. No application behaviour changes.
|
|
8154
|
+
--
|
|
8155
|
+
-- 1. 0011 function_search_path_mutable
|
|
8156
|
+
-- public.handle_ucp_cart_sessions_update() needs a pinned search_path.
|
|
8157
|
+
-- 2. 0029 authenticated_security_definer_function_executable
|
|
8158
|
+
-- public.duplicate_block_definition(uuid) must run as SECURITY INVOKER (it already
|
|
8159
|
+
-- keeps its own ADMIN/WRITER role check and is gated by custom_block_definitions RLS).
|
|
8160
|
+
|
|
8161
|
+
-- 1. Pin the search_path. Re-create the function with SET search_path baked into its
|
|
8162
|
+
-- definition (not just an ALTER) so a future CREATE OR REPLACE can't silently drop it.
|
|
8163
|
+
-- The body only calls now() (pg_catalog, always implicitly searched), so an empty
|
|
8164
|
+
-- search_path is safe. CREATE OR REPLACE keeps the function OID, so the existing
|
|
8165
|
+
-- trg_handle_ucp_cart_sessions_update trigger and the service_role grant are preserved.
|
|
8166
|
+
CREATE OR REPLACE FUNCTION public.handle_ucp_cart_sessions_update()
|
|
8167
|
+
RETURNS trigger
|
|
8168
|
+
LANGUAGE plpgsql
|
|
8169
|
+
SET search_path = ''
|
|
8170
|
+
AS $$
|
|
8171
|
+
BEGIN
|
|
8172
|
+
NEW.updated_at = now();
|
|
8173
|
+
RETURN NEW;
|
|
8174
|
+
END;
|
|
8175
|
+
$$;
|
|
8176
|
+
|
|
8177
|
+
-- 2. Ensure the duplicate helper runs with the caller's privileges. The function body
|
|
8178
|
+
-- (unchanged) still raises 42501 for non-ADMIN/WRITER callers, and its SELECT/INSERT
|
|
8179
|
+
-- are gated by custom_block_definitions RLS, so it does not need definer privileges.
|
|
8180
|
+
ALTER FUNCTION public.duplicate_block_definition(uuid) SECURITY INVOKER;
|
|
8181
|
+
|
|
8182
|
+
|
|
7902
8183
|
-- Step D: Anchor preserved profiles
|
|
7903
8184
|
INSERT INTO public.profiles (id, updated_at, full_name, avatar_url, website, role)
|
|
7904
8185
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
<
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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 & 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>
|