create-nextblock 0.13.7 → 0.13.9
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/(auth-pages)/two-factor/actions.ts +21 -1
- package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +34 -10
- package/templates/nextblock-template/app/actions/email.ts +78 -7
- package/templates/nextblock-template/app/actions/feedback.ts +57 -14
- package/templates/nextblock-template/app/actions/interactions.test.ts +3 -0
- package/templates/nextblock-template/app/actions/productGridActions.ts +40 -0
- package/templates/nextblock-template/app/actions.ts +17 -4
- package/templates/nextblock-template/app/api/cms/ecommerce/product-picker/route.ts +151 -0
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +4 -1
- package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +17 -5
- package/templates/nextblock-template/app/cms/blocks/components/MultiEntityPicker.tsx +251 -0
- package/templates/nextblock-template/app/cms/blocks/editors/ProductGridBlockEditor.tsx +375 -18
- package/templates/nextblock-template/app/cms/components/EcommerceActiveContext.tsx +27 -0
- package/templates/nextblock-template/app/cms/settings/bot-protection/actions.ts +9 -6
- package/templates/nextblock-template/app/cms/settings/bot-protection/components/BotProtectionForm.tsx +1 -5
- package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +9 -6
- package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +1 -5
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +18 -8
- package/templates/nextblock-template/app/cms/settings/email/actions.ts +59 -29
- package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +5 -1
- package/templates/nextblock-template/app/cms/settings/global-css/actions.ts +6 -5
- package/templates/nextblock-template/app/cms/settings/global-css/components/GlobalCssForm.tsx +2 -1
- package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +18 -7
- package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +16 -7
- package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/registration/actions.ts +18 -7
- package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +1 -1
- package/templates/nextblock-template/app/cms/settings/security/actions.ts +227 -131
- package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +134 -18
- package/templates/nextblock-template/components/blocks/ProductGridClient.tsx +114 -0
- package/templates/nextblock-template/lib/auth/twoFactor.test.ts +254 -0
- package/templates/nextblock-template/lib/auth/twoFactor.ts +56 -13
- package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +78 -139
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +3 -3
- package/templates/nextblock-template/lib/blocks/blockTypes.ts +19 -0
- package/templates/nextblock-template/lib/blocks/ecommerce-block-schemas.ts +61 -2
- package/templates/nextblock-template/lib/blocks/product-grid-data.ts +210 -0
- package/templates/nextblock-template/lib/cms/action-result.ts +12 -0
- package/templates/nextblock-template/lib/config/email-settings.ts +40 -3
- 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
|
@@ -5,6 +5,7 @@ import { redirect } from 'next/navigation';
|
|
|
5
5
|
import { createClient } from '@nextblock-cms/db/server';
|
|
6
6
|
import {
|
|
7
7
|
createEmailChallenge,
|
|
8
|
+
getEmailResendCooldownSeconds,
|
|
8
9
|
issueTwoFactorVerifiedCookie,
|
|
9
10
|
verifyEmailChallenge,
|
|
10
11
|
} from '../../../lib/auth/twoFactor';
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
getCookieValue,
|
|
16
17
|
} from '../../../lib/auth/cookies';
|
|
17
18
|
import { sendTwoFactorCodeEmail } from '../../actions/twoFactorEmail';
|
|
19
|
+
import { isEmailConfigured } from '../../../lib/config/email-settings';
|
|
18
20
|
|
|
19
21
|
function safeRedirect(path?: string): string {
|
|
20
22
|
return path && path.startsWith('/') && !path.startsWith('//') ? path : '/cms/dashboard';
|
|
@@ -85,7 +87,25 @@ export async function resendEmailCode() {
|
|
|
85
87
|
} = await supabase.auth.getUser();
|
|
86
88
|
if (!user?.email) return { error: 'No email address is associated with your account.' };
|
|
87
89
|
|
|
90
|
+
// No transport means the code can never arrive; say so rather than claiming it was sent.
|
|
91
|
+
if (!(await isEmailConfigured())) {
|
|
92
|
+
return {
|
|
93
|
+
error:
|
|
94
|
+
'This site has no email server configured, so a code cannot be sent. Contact your administrator.',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const wait = await getEmailResendCooldownSeconds(user.id);
|
|
99
|
+
if (wait > 0) {
|
|
100
|
+
return { error: `A code was just sent. Please wait ${wait}s before requesting another.` };
|
|
101
|
+
}
|
|
102
|
+
|
|
88
103
|
const code = await createEmailChallenge(user.id);
|
|
89
|
-
|
|
104
|
+
try {
|
|
105
|
+
await sendTwoFactorCodeEmail(user.email, code);
|
|
106
|
+
} catch (sendError) {
|
|
107
|
+
console.error('Failed to send 2FA email code:', sendError);
|
|
108
|
+
return { error: 'The mail server rejected the message. Please try again in a moment.' };
|
|
109
|
+
}
|
|
90
110
|
return { success: true, message: `A new code is on its way to ${user.email}.` };
|
|
91
111
|
}
|
package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useTransition } from 'react';
|
|
3
|
+
import { useEffect, useState, useTransition } from 'react';
|
|
4
4
|
import { unstable_rethrow } from 'next/navigation';
|
|
5
5
|
import { Alert, AlertDescription, Button, Input, Label, Spinner } from '@nextblock-cms/ui';
|
|
6
6
|
import { resendEmailCode, verifyEmailCode, verifyTotpChallenge } from '../actions';
|
|
7
7
|
|
|
8
|
+
/** Matches the security panel: relays queue, so parking the button beats spamming it. */
|
|
9
|
+
const RESEND_COOLDOWN_SECONDS = 30;
|
|
10
|
+
|
|
8
11
|
interface TwoFactorFormProps {
|
|
9
12
|
type: 'totp' | 'email';
|
|
10
13
|
email: string;
|
|
@@ -24,6 +27,13 @@ export default function TwoFactorForm({
|
|
|
24
27
|
const [info, setInfo] = useState<string | null>(
|
|
25
28
|
type === 'email' && pendingEmailCode ? `Enter the code we sent to ${email}.` : null,
|
|
26
29
|
);
|
|
30
|
+
const [cooldown, setCooldown] = useState(0);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (cooldown <= 0) return;
|
|
34
|
+
const timer = setTimeout(() => setCooldown((seconds) => seconds - 1), 1000);
|
|
35
|
+
return () => clearTimeout(timer);
|
|
36
|
+
}, [cooldown]);
|
|
27
37
|
|
|
28
38
|
const submit = (codeToSubmit: string = code) => {
|
|
29
39
|
if (codeToSubmit.length !== 6) return;
|
|
@@ -48,13 +58,17 @@ export default function TwoFactorForm({
|
|
|
48
58
|
};
|
|
49
59
|
|
|
50
60
|
const resend = () => {
|
|
61
|
+
if (cooldown > 0) return;
|
|
51
62
|
setError(null);
|
|
52
63
|
setInfo(null);
|
|
53
64
|
startTransition(async () => {
|
|
54
65
|
try {
|
|
55
66
|
const result = await resendEmailCode();
|
|
56
67
|
if (result?.error) setError(result.error);
|
|
57
|
-
else if (result?.message)
|
|
68
|
+
else if (result?.message) {
|
|
69
|
+
setInfo(result.message);
|
|
70
|
+
setCooldown(RESEND_COOLDOWN_SECONDS);
|
|
71
|
+
}
|
|
58
72
|
} catch (err) {
|
|
59
73
|
setError(err instanceof Error ? err.message : 'Could not send a code.');
|
|
60
74
|
}
|
|
@@ -116,14 +130,24 @@ export default function TwoFactorForm({
|
|
|
116
130
|
</form>
|
|
117
131
|
|
|
118
132
|
{type === 'email' && (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
133
|
+
<>
|
|
134
|
+
<button
|
|
135
|
+
type="button"
|
|
136
|
+
onClick={resend}
|
|
137
|
+
disabled={isPending || cooldown > 0}
|
|
138
|
+
className="mt-4 w-full text-center text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground disabled:no-underline disabled:opacity-50"
|
|
139
|
+
>
|
|
140
|
+
{cooldown > 0
|
|
141
|
+
? `Resend available in ${cooldown}s`
|
|
142
|
+
: pendingEmailCode
|
|
143
|
+
? 'Resend code'
|
|
144
|
+
: 'Send me a code'}
|
|
145
|
+
</button>
|
|
146
|
+
<p className="mt-2 text-center text-xs text-muted-foreground">
|
|
147
|
+
Codes can take a minute to arrive. If you request another, the earlier one still
|
|
148
|
+
works — enter whichever reaches you first.
|
|
149
|
+
</p>
|
|
150
|
+
</>
|
|
127
151
|
)}
|
|
128
152
|
</div>
|
|
129
153
|
);
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
import 'server-only';
|
|
2
|
+
// The single outbound-mail choke point. Deliberately NOT a "use server" module: every
|
|
3
|
+
// caller is server-side (2FA codes, form/interaction notifications, feedback, the SMTP
|
|
4
|
+
// test), and marking it as an action would register `sendEmail` as a client-callable
|
|
5
|
+
// endpoint — an open relay taking an arbitrary recipient, subject and HTML body.
|
|
2
6
|
|
|
3
|
-
import {
|
|
7
|
+
import nodemailer, { type Transporter } from 'nodemailer';
|
|
8
|
+
import { resolveEmailServerConfig, type ResolvedEmailConfig } from '../../lib/config/email-settings';
|
|
4
9
|
import { applyEmailBranding, resolveEmailBranding } from '../../lib/email/branding';
|
|
5
|
-
import nodemailer from 'nodemailer';
|
|
6
10
|
|
|
7
11
|
interface EmailParams {
|
|
8
12
|
to: string;
|
|
@@ -11,9 +15,77 @@ interface EmailParams {
|
|
|
11
15
|
html: string;
|
|
12
16
|
}
|
|
13
17
|
|
|
18
|
+
// Without explicit bounds nodemailer inherits the OS socket timeouts, so an unreachable
|
|
19
|
+
// or silently-dropping relay hangs the request (and the user's spinner) for minutes.
|
|
20
|
+
const CONNECTION_TIMEOUT_MS = 10_000;
|
|
21
|
+
const GREETING_TIMEOUT_MS = 10_000;
|
|
22
|
+
const SOCKET_TIMEOUT_MS = 20_000;
|
|
23
|
+
|
|
24
|
+
// Opening a fresh SMTP connection per message costs a TCP handshake + TLS negotiation +
|
|
25
|
+
// AUTH round trip before the first byte of the message is sent — typically the bulk of the
|
|
26
|
+
// wait when a user clicks "send me a code". A pooled transport keeps the authenticated
|
|
27
|
+
// connection warm so subsequent sends start at DATA. Set SMTP_POOL=false to opt out.
|
|
28
|
+
const POOL_ENABLED = process.env['SMTP_POOL'] !== 'false';
|
|
29
|
+
|
|
30
|
+
let cachedTransport: { key: string; transporter: Transporter } | null = null;
|
|
31
|
+
|
|
32
|
+
/** Identity of a transport: anything that changes it must force a rebuild. */
|
|
33
|
+
function transportKey(config: ResolvedEmailConfig): string {
|
|
34
|
+
return [config.host, config.port, config.secure, config.auth.user].join('|');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getTransporter(config: ResolvedEmailConfig): Transporter {
|
|
38
|
+
const key = transportKey(config);
|
|
39
|
+
if (cachedTransport && cachedTransport.key === key) {
|
|
40
|
+
return cachedTransport.transporter;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Settings changed — tear the old pool down rather than leaking its sockets.
|
|
44
|
+
if (cachedTransport) {
|
|
45
|
+
try {
|
|
46
|
+
cachedTransport.transporter.close();
|
|
47
|
+
} catch {
|
|
48
|
+
/* already closed */
|
|
49
|
+
}
|
|
50
|
+
cachedTransport = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const base = {
|
|
54
|
+
host: config.host,
|
|
55
|
+
port: config.port,
|
|
56
|
+
secure: config.secure,
|
|
57
|
+
auth: config.auth,
|
|
58
|
+
connectionTimeout: CONNECTION_TIMEOUT_MS,
|
|
59
|
+
greetingTimeout: GREETING_TIMEOUT_MS,
|
|
60
|
+
socketTimeout: SOCKET_TIMEOUT_MS,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Branched rather than `pool: POOL_ENABLED` — nodemailer types `pool` as the literal
|
|
64
|
+
// `true` to select the pooled transport overload, so a boolean matches neither.
|
|
65
|
+
const transporter: Transporter = POOL_ENABLED
|
|
66
|
+
? nodemailer.createTransport({ ...base, pool: true, maxConnections: 3, maxMessages: 100 })
|
|
67
|
+
: nodemailer.createTransport(base);
|
|
68
|
+
|
|
69
|
+
// A pool that errors out (relay restart, credentials rotated, idle socket reaped) must
|
|
70
|
+
// not be handed to the next caller — drop it so the following send reconnects cleanly.
|
|
71
|
+
transporter.on('error', () => {
|
|
72
|
+
if (cachedTransport?.transporter === transporter) {
|
|
73
|
+
cachedTransport = null;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
cachedTransport = { key, transporter };
|
|
78
|
+
return transporter;
|
|
79
|
+
}
|
|
80
|
+
|
|
14
81
|
export async function sendEmail({ to, subject, text, html }: EmailParams) {
|
|
15
82
|
// DB-first (CMS Settings → Configuration → Email), falling back to SMTP_* env vars.
|
|
16
|
-
|
|
83
|
+
// Resolved in parallel with branding — neither depends on the other, and both are
|
|
84
|
+
// pure reads standing between the click and the first SMTP packet.
|
|
85
|
+
const [emailConfig, branding] = await Promise.all([
|
|
86
|
+
resolveEmailServerConfig(),
|
|
87
|
+
resolveEmailBranding(),
|
|
88
|
+
]);
|
|
17
89
|
|
|
18
90
|
if (!emailConfig) {
|
|
19
91
|
throw new Error("Email server is not configured. Configure SMTP in CMS Settings → Configuration → Email.");
|
|
@@ -22,10 +94,9 @@ export async function sendEmail({ to, subject, text, html }: EmailParams) {
|
|
|
22
94
|
// Single interception point: white-label every outgoing email with the tenant's own
|
|
23
95
|
// logo + site name (or a text banner when no logo is set). Every app-dispatched email
|
|
24
96
|
// funnels through here, so branding is applied once, centrally.
|
|
25
|
-
const branding = await resolveEmailBranding();
|
|
26
97
|
const brandedHtml = applyEmailBranding(html, branding);
|
|
27
98
|
|
|
28
|
-
const transporter =
|
|
99
|
+
const transporter = getTransporter(emailConfig);
|
|
29
100
|
|
|
30
101
|
const options = {
|
|
31
102
|
from: emailConfig.from,
|
|
@@ -36,4 +107,4 @@ export async function sendEmail({ to, subject, text, html }: EmailParams) {
|
|
|
36
107
|
};
|
|
37
108
|
|
|
38
109
|
return transporter.sendMail(options);
|
|
39
|
-
}
|
|
110
|
+
}
|
|
@@ -1,37 +1,79 @@
|
|
|
1
1
|
"use server";
|
|
2
2
|
|
|
3
|
+
import { createClient } from "@nextblock-cms/db/server";
|
|
3
4
|
import { sendEmail } from "./email";
|
|
4
5
|
|
|
5
|
-
|
|
6
6
|
interface FeedbackData {
|
|
7
7
|
subject: string;
|
|
8
8
|
message: string;
|
|
9
|
-
|
|
9
|
+
/** Accepted for call-site compatibility but ignored — identity comes from the session. */
|
|
10
|
+
userEmail?: string;
|
|
10
11
|
userName?: string;
|
|
11
12
|
url?: string;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
const MAX_SUBJECT_LENGTH = 200;
|
|
16
|
+
const MAX_MESSAGE_LENGTH = 5000;
|
|
17
|
+
const MAX_URL_LENGTH = 500;
|
|
18
|
+
|
|
19
|
+
/** Everything below is attacker-controlled text landing in an HTML email body. */
|
|
20
|
+
function escapeHtml(value: string): string {
|
|
21
|
+
return value
|
|
22
|
+
.replace(/&/g, '&')
|
|
23
|
+
.replace(/</g, '<')
|
|
24
|
+
.replace(/>/g, '>')
|
|
25
|
+
.replace(/"/g, '"')
|
|
26
|
+
.replace(/'/g, ''');
|
|
27
|
+
}
|
|
28
|
+
|
|
14
29
|
export async function submitFeedback(data: FeedbackData) {
|
|
15
30
|
try {
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
31
|
+
// This is a client-callable action, so the CMS-only modal in front of it is not a
|
|
32
|
+
// gate: without this check any unauthenticated caller could relay arbitrary mail
|
|
33
|
+
// through the tenant's SMTP credentials.
|
|
34
|
+
const supabase = createClient();
|
|
35
|
+
const {
|
|
36
|
+
data: { user },
|
|
37
|
+
} = await supabase.auth.getUser();
|
|
38
|
+
if (!user) {
|
|
39
|
+
return { success: false, error: "You must be signed in to send feedback." };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { data: profile } = await supabase
|
|
43
|
+
.from('profiles')
|
|
44
|
+
.select('role, full_name')
|
|
45
|
+
.eq('id', user.id)
|
|
46
|
+
.maybeSingle();
|
|
47
|
+
if (profile?.role !== 'ADMIN' && profile?.role !== 'WRITER') {
|
|
48
|
+
return { success: false, error: "You do not have permission to send feedback." };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const subject = (data.subject ?? '').slice(0, MAX_SUBJECT_LENGTH);
|
|
52
|
+
const message = (data.message ?? '').slice(0, MAX_MESSAGE_LENGTH);
|
|
53
|
+
const url = (data.url ?? '').slice(0, MAX_URL_LENGTH);
|
|
54
|
+
if (!message.trim()) {
|
|
55
|
+
return { success: false, error: "Feedback message is empty." };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Identity is read from the session rather than the request body — the client cannot
|
|
59
|
+
// choose whose name a report is filed under.
|
|
60
|
+
const senderEmail = user.email ?? 'unknown';
|
|
61
|
+
const senderName = profile?.full_name || senderEmail;
|
|
62
|
+
|
|
21
63
|
const htmlContent = `
|
|
22
64
|
{{brand_header}}
|
|
23
65
|
<h2>New Feedback Received</h2>
|
|
24
|
-
<p><strong>From:</strong> ${
|
|
25
|
-
<p><strong>Subject:</strong> ${subject}</p>
|
|
26
|
-
<p><strong>URL:</strong> ${url || 'N/A'}</p>
|
|
66
|
+
<p><strong>From:</strong> ${escapeHtml(senderName)} (${escapeHtml(senderEmail)})</p>
|
|
67
|
+
<p><strong>Subject:</strong> ${escapeHtml(subject)}</p>
|
|
68
|
+
<p><strong>URL:</strong> ${escapeHtml(url) || 'N/A'}</p>
|
|
27
69
|
<br/>
|
|
28
70
|
<h3>Message:</h3>
|
|
29
|
-
<p style="white-space: pre-wrap;">${message}</p>
|
|
71
|
+
<p style="white-space: pre-wrap;">${escapeHtml(message)}</p>
|
|
30
72
|
`;
|
|
31
73
|
|
|
32
74
|
const textContent = `
|
|
33
75
|
New Feedback Received
|
|
34
|
-
From: ${
|
|
76
|
+
From: ${senderName} (${senderEmail})
|
|
35
77
|
Subject: ${subject}
|
|
36
78
|
URL: ${url || 'N/A'}
|
|
37
79
|
|
|
@@ -40,8 +82,9 @@ export async function submitFeedback(data: FeedbackData) {
|
|
|
40
82
|
`;
|
|
41
83
|
|
|
42
84
|
await sendEmail({
|
|
43
|
-
to: "feedback@nextblock.ca",
|
|
44
|
-
|
|
85
|
+
to: "feedback@nextblock.ca",
|
|
86
|
+
// Newlines in a header would let a caller inject extra headers (Bcc, …).
|
|
87
|
+
subject: `[CMS Feedback] ${subject.replace(/[\r\n]+/g, ' ')}`,
|
|
45
88
|
text: textContent,
|
|
46
89
|
html: htmlContent,
|
|
47
90
|
});
|
|
@@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
const cacheMocks = vi.hoisted(() => ({
|
|
4
4
|
revalidatePath: vi.fn(),
|
|
5
|
+
// Reached transitively: interactions -> email -> email/branding -> site-settings, which
|
|
6
|
+
// wraps its reader in unstable_cache at module scope. Pass the loader straight through.
|
|
7
|
+
unstable_cache: vi.fn((fn: unknown) => fn),
|
|
5
8
|
}));
|
|
6
9
|
|
|
7
10
|
const dbServerMocks = vi.hoisted(() => ({
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
loadProductGridPage,
|
|
5
|
+
type ProductGridQuery,
|
|
6
|
+
} from '../../lib/blocks/product-grid-data';
|
|
7
|
+
import {
|
|
8
|
+
PRODUCT_GRID_MAX_LIMIT,
|
|
9
|
+
PRODUCT_GRID_DEFAULT_PAGE_SIZE,
|
|
10
|
+
} from '../../lib/blocks/ecommerce-block-schemas';
|
|
11
|
+
import type { Product } from '@nextblock-cms/ecommerce/types';
|
|
12
|
+
|
|
13
|
+
export interface ProductGridPageResult {
|
|
14
|
+
products: Product[];
|
|
15
|
+
totalCount: number;
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pagination for the Product Grid block. Only ever returns published products
|
|
21
|
+
* (`getProducts` defaults to status `active`), so this is safe to call from the
|
|
22
|
+
* storefront. The page size is clamped here because the argument arrives from
|
|
23
|
+
* the client — the unlimited path stays server-render only.
|
|
24
|
+
*/
|
|
25
|
+
export async function fetchProductGridPage(
|
|
26
|
+
query: ProductGridQuery
|
|
27
|
+
): Promise<ProductGridPageResult> {
|
|
28
|
+
try {
|
|
29
|
+
const limit = Math.min(
|
|
30
|
+
Math.max(1, Math.floor(query.limit) || PRODUCT_GRID_DEFAULT_PAGE_SIZE),
|
|
31
|
+
PRODUCT_GRID_MAX_LIMIT
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const { products, totalCount } = await loadProductGridPage({ ...query, limit });
|
|
35
|
+
return { products, totalCount };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
console.error('[Product Grid] Failed to load page:', error);
|
|
38
|
+
return { products: [], totalCount: 0, error: 'Failed to load products.' };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { encodedRedirect } from "@nextblock-cms/utils/server";
|
|
4
4
|
import { createClient, getServiceRoleSupabaseClient } from "@nextblock-cms/db/server";
|
|
5
5
|
import { headers } from "next/headers";
|
|
6
|
+
import { after } from "next/server";
|
|
6
7
|
import { redirect } from "next/navigation";
|
|
7
8
|
import { resolvePostAuthRedirect } from "../lib/auth-redirects";
|
|
8
9
|
import { createEmailChallenge, evaluateTwoFactor } from "../lib/auth/twoFactor";
|
|
@@ -169,13 +170,25 @@ export const signInAction = async (formData: FormData) => {
|
|
|
169
170
|
await setSecureCookie(REMEMBER_INTENT_COOKIE, "1", 15 * 60);
|
|
170
171
|
}
|
|
171
172
|
|
|
172
|
-
// Email factor:
|
|
173
|
+
// Email factor: mint the first code now so the challenge page has one waiting. The
|
|
174
|
+
// challenge row is created synchronously because the page reads it to decide between
|
|
175
|
+
// "Resend code" and "Send me a code"; only the SMTP conversation is deferred, so the
|
|
176
|
+
// redirect isn't held behind a relay handshake that can take seconds. Delivery failure
|
|
177
|
+
// was already swallowed here — the user resends from the challenge page — so moving it
|
|
178
|
+
// off the response path costs no error reporting.
|
|
173
179
|
if (evaluation.status === "email_required" && data.user.email) {
|
|
180
|
+
const recipient = data.user.email;
|
|
174
181
|
try {
|
|
175
182
|
const code = await createEmailChallenge(data.user.id);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
183
|
+
after(async () => {
|
|
184
|
+
try {
|
|
185
|
+
await sendTwoFactorCodeEmail(recipient, code);
|
|
186
|
+
} catch (sendError) {
|
|
187
|
+
console.error("Failed to send 2FA email code:", sendError);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
} catch (challengeError) {
|
|
191
|
+
console.error("Failed to create 2FA email challenge:", challengeError);
|
|
179
192
|
}
|
|
180
193
|
}
|
|
181
194
|
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server';
|
|
2
|
+
import {
|
|
3
|
+
createClient,
|
|
4
|
+
getServiceRoleSupabaseClient,
|
|
5
|
+
verifyPackageOnline,
|
|
6
|
+
} from '@nextblock-cms/db/server';
|
|
7
|
+
|
|
8
|
+
export const dynamic = 'force-dynamic';
|
|
9
|
+
|
|
10
|
+
/** How many products a single search returns before the UI asks for a narrower query. */
|
|
11
|
+
const PRODUCT_PAGE_SIZE = 50;
|
|
12
|
+
/** Guard against an unbounded `ids` list when hydrating labels for an existing selection. */
|
|
13
|
+
const MAX_HYDRATED_IDS = 100;
|
|
14
|
+
|
|
15
|
+
export interface ProductPickerCategory {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
slug: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProductPickerProduct {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
slug: string | null;
|
|
25
|
+
sku: string | null;
|
|
26
|
+
status: string | null;
|
|
27
|
+
languageCode: string | null;
|
|
28
|
+
translationGroupId: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* PostgREST parses `,`, `.`, `(` and `)` inside an `or(...)` filter, so anything
|
|
33
|
+
* the author types has to be stripped before it is interpolated into one.
|
|
34
|
+
*/
|
|
35
|
+
function sanitizeSearchTerm(term: string): string {
|
|
36
|
+
return term.replace(/[,().*%\\]/g, ' ').trim().slice(0, 80);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Options for the Product Grid block pickers: every category plus a page of
|
|
41
|
+
* products (optionally filtered by `search`). `ids` hydrates labels for an
|
|
42
|
+
* existing selection whose products fall outside the current page.
|
|
43
|
+
*/
|
|
44
|
+
export async function GET(request: Request) {
|
|
45
|
+
try {
|
|
46
|
+
const supabase = createClient();
|
|
47
|
+
const {
|
|
48
|
+
data: { user },
|
|
49
|
+
error: authError,
|
|
50
|
+
} = await supabase.auth.getUser();
|
|
51
|
+
|
|
52
|
+
if (authError || !user) {
|
|
53
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { data: profile } = await supabase
|
|
57
|
+
.from('profiles')
|
|
58
|
+
.select('role')
|
|
59
|
+
.eq('id', user.id)
|
|
60
|
+
.single();
|
|
61
|
+
|
|
62
|
+
if (!profile || !['ADMIN', 'WRITER'].includes(profile.role)) {
|
|
63
|
+
return NextResponse.json(
|
|
64
|
+
{ error: 'Forbidden: Insufficient permissions' },
|
|
65
|
+
{ status: 403 }
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Fail closed: without the ecommerce package there is no product catalog to
|
|
70
|
+
// browse, and the blocks that use this route are hidden from the picker.
|
|
71
|
+
if (!(await verifyPackageOnline('ecommerce'))) {
|
|
72
|
+
return NextResponse.json(
|
|
73
|
+
{ error: 'The ecommerce package is not active.' },
|
|
74
|
+
{ status: 403 }
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const url = new URL(request.url);
|
|
79
|
+
const search = sanitizeSearchTerm(url.searchParams.get('search') ?? '');
|
|
80
|
+
const ids = (url.searchParams.get('ids') ?? '')
|
|
81
|
+
.split(',')
|
|
82
|
+
.map((id) => id.trim())
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.slice(0, MAX_HYDRATED_IDS);
|
|
85
|
+
|
|
86
|
+
// Reading the catalog is admin work behind the role check above.
|
|
87
|
+
const admin = getServiceRoleSupabaseClient();
|
|
88
|
+
|
|
89
|
+
const productColumns =
|
|
90
|
+
'id, title, slug, sku, status, translation_group_id, languages(code)';
|
|
91
|
+
|
|
92
|
+
let productsQuery = admin
|
|
93
|
+
.from('products')
|
|
94
|
+
.select(productColumns)
|
|
95
|
+
.order('created_at', { ascending: false })
|
|
96
|
+
// Fetch one extra row so the UI can tell the author the list was truncated.
|
|
97
|
+
.limit(PRODUCT_PAGE_SIZE + 1);
|
|
98
|
+
|
|
99
|
+
if (search) {
|
|
100
|
+
productsQuery = productsQuery.or(`title.ilike.%${search}%,sku.ilike.%${search}%`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const [categoriesResult, productsResult, selectedResult] = await Promise.all([
|
|
104
|
+
admin.from('categories').select('id, name, slug').order('name', { ascending: true }),
|
|
105
|
+
productsQuery,
|
|
106
|
+
ids.length > 0
|
|
107
|
+
? admin.from('products').select(productColumns).in('id', ids)
|
|
108
|
+
: Promise.resolve({ data: [], error: null }),
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
if (categoriesResult.error) throw categoriesResult.error;
|
|
112
|
+
if (productsResult.error) throw productsResult.error;
|
|
113
|
+
if (selectedResult.error) throw selectedResult.error;
|
|
114
|
+
|
|
115
|
+
const toPickerProduct = (row: any): ProductPickerProduct => ({
|
|
116
|
+
id: row.id,
|
|
117
|
+
title: row.title,
|
|
118
|
+
slug: row.slug ?? null,
|
|
119
|
+
sku: row.sku ?? null,
|
|
120
|
+
status: row.status ?? null,
|
|
121
|
+
languageCode: row.languages?.code ?? null,
|
|
122
|
+
translationGroupId: row.translation_group_id ?? null,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const productRows = (productsResult.data ?? []) as any[];
|
|
126
|
+
const hasMore = productRows.length > PRODUCT_PAGE_SIZE;
|
|
127
|
+
const products = productRows.slice(0, PRODUCT_PAGE_SIZE).map(toPickerProduct);
|
|
128
|
+
|
|
129
|
+
// Merge the hydrated selection in so already-chosen products always render
|
|
130
|
+
// with a real label, even when a search hides them.
|
|
131
|
+
const byId = new Map(products.map((product) => [product.id, product]));
|
|
132
|
+
for (const row of (selectedResult.data ?? []) as any[]) {
|
|
133
|
+
if (!byId.has(row.id)) byId.set(row.id, toPickerProduct(row));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return NextResponse.json(
|
|
137
|
+
{
|
|
138
|
+
categories: (categoriesResult.data ?? []) as ProductPickerCategory[],
|
|
139
|
+
products: Array.from(byId.values()),
|
|
140
|
+
hasMore,
|
|
141
|
+
},
|
|
142
|
+
{ status: 200 }
|
|
143
|
+
);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
console.error('[Product Picker API] Unexpected error:', error);
|
|
146
|
+
return NextResponse.json(
|
|
147
|
+
{ error: 'Failed to load product picker options.' },
|
|
148
|
+
{ status: 500 }
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -22,6 +22,7 @@ import { FeedbackModal } from "./components/FeedbackModal";
|
|
|
22
22
|
import { CortexGlobalAgentChat } from "./components/CortexGlobalAgentChat";
|
|
23
23
|
import { CortexAiPageContextProvider } from "./components/CortexAiPageContext";
|
|
24
24
|
import { CortexAiActiveProvider } from "./components/CortexAiActiveContext";
|
|
25
|
+
import { EcommerceActiveProvider } from "./components/EcommerceActiveContext";
|
|
25
26
|
import { useAppBranding } from "../../components/AppShell";
|
|
26
27
|
import { resolveMediaUrl } from "../../lib/media/resolveMediaUrl";
|
|
27
28
|
|
|
@@ -257,6 +258,7 @@ export default function CmsClientLayout({
|
|
|
257
258
|
return (
|
|
258
259
|
<CortexAiPageContextProvider>
|
|
259
260
|
<CortexAiActiveProvider isActive={isCortexAiActive}>
|
|
261
|
+
<EcommerceActiveProvider isActive={isEcommerceActive}>
|
|
260
262
|
<div className="relative flex h-full min-h-0 w-full overflow-hidden bg-slate-50 dark:bg-slate-950 md:flex-row">
|
|
261
263
|
<div className="fixed bottom-4 right-4 z-[60] md:hidden">
|
|
262
264
|
<Button
|
|
@@ -496,7 +498,7 @@ export default function CmsClientLayout({
|
|
|
496
498
|
{pageTitle}
|
|
497
499
|
</h1>
|
|
498
500
|
<Button asChild variant="outline" size="sm" className="shrink-0">
|
|
499
|
-
<Link href="/">
|
|
501
|
+
<Link href="/" target="_blank">
|
|
500
502
|
<ExternalLink className="h-4 w-4" />
|
|
501
503
|
<span className="hidden sm:inline">View Site</span>
|
|
502
504
|
</Link>
|
|
@@ -516,6 +518,7 @@ export default function CmsClientLayout({
|
|
|
516
518
|
)}
|
|
517
519
|
{isAdmin && isCortexAiActive && <CortexGlobalAgentChat />}
|
|
518
520
|
</div>
|
|
521
|
+
</EcommerceActiveProvider>
|
|
519
522
|
</CortexAiActiveProvider>
|
|
520
523
|
</CortexAiPageContextProvider>
|
|
521
524
|
)
|