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.
- 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 +245 -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,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
|
-
|
|
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>
|
|
@@ -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’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
|
+
}
|