create-nextblock 0.13.7 → 0.13.8
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.ts +17 -4
- package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +1 -1
- 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/lib/auth/twoFactor.test.ts +254 -0
- package/templates/nextblock-template/lib/auth/twoFactor.ts +56 -13
- 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(() => ({
|
|
@@ -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
|
|
|
@@ -496,7 +496,7 @@ export default function CmsClientLayout({
|
|
|
496
496
|
{pageTitle}
|
|
497
497
|
</h1>
|
|
498
498
|
<Button asChild variant="outline" size="sm" className="shrink-0">
|
|
499
|
-
<Link href="/">
|
|
499
|
+
<Link href="/" target="_blank">
|
|
500
500
|
<ExternalLink className="h-4 w-4" />
|
|
501
501
|
<span className="hidden sm:inline">View Site</span>
|
|
502
502
|
</Link>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { createClient } from '@nextblock-cms/db/server';
|
|
5
5
|
import { revalidatePath } from 'next/cache';
|
|
6
|
+
import type { SettingsActionResult } from '../../../../lib/cms/action-result';
|
|
6
7
|
|
|
7
8
|
export type BotProtectionSettings = {
|
|
8
9
|
provider: 'none' | 'turnstile' | 'recaptcha';
|
|
@@ -37,13 +38,15 @@ export async function getBotProtectionSettings(): Promise<BotProtectionSettings>
|
|
|
37
38
|
};
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
export async function updateBotProtectionSettings(
|
|
41
|
+
export async function updateBotProtectionSettings(
|
|
42
|
+
formData: FormData,
|
|
43
|
+
): Promise<SettingsActionResult> {
|
|
41
44
|
const supabase = createClient();
|
|
42
45
|
|
|
43
46
|
// Verify auth and role
|
|
44
47
|
const { data: { user } } = await supabase.auth.getUser();
|
|
45
48
|
if (!user) {
|
|
46
|
-
|
|
49
|
+
return { ok: false, error: 'You must be logged in to update settings.' };
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
const { data: profile, error: profileError } = await supabase
|
|
@@ -53,7 +56,7 @@ export async function updateBotProtectionSettings(formData: FormData) {
|
|
|
53
56
|
.single();
|
|
54
57
|
|
|
55
58
|
if (profileError || !profile || profile.role !== 'ADMIN') {
|
|
56
|
-
|
|
59
|
+
return { ok: false, error: 'You do not have permission to perform this action.' };
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
const provider = formData.get('provider') as 'none' | 'turnstile' | 'recaptcha';
|
|
@@ -70,7 +73,7 @@ export async function updateBotProtectionSettings(formData: FormData) {
|
|
|
70
73
|
|
|
71
74
|
if (publicError) {
|
|
72
75
|
console.error('Error updating public bot protection settings:', publicError);
|
|
73
|
-
|
|
76
|
+
return { ok: false, error: 'Failed to update bot protection settings.' };
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
// Update secret settings (secretKey)
|
|
@@ -83,11 +86,11 @@ export async function updateBotProtectionSettings(formData: FormData) {
|
|
|
83
86
|
|
|
84
87
|
if (secretError) {
|
|
85
88
|
console.error('Error updating secret bot protection settings:', secretError);
|
|
86
|
-
|
|
89
|
+
return { ok: false, error: 'Failed to update bot protection secrets.' };
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
// Revalidate root layout so scripts update instantly
|
|
90
93
|
revalidatePath('/', 'layout');
|
|
91
94
|
|
|
92
|
-
return {
|
|
95
|
+
return { ok: true, message: 'Bot protection settings updated successfully.' };
|
|
93
96
|
}
|
|
@@ -33,11 +33,7 @@ export default function BotProtectionForm({ initialSettings }: BotProtectionForm
|
|
|
33
33
|
startTransition(async () => {
|
|
34
34
|
try {
|
|
35
35
|
const result = await updateBotProtectionSettings(formData);
|
|
36
|
-
|
|
37
|
-
setMessage({ success: result.message });
|
|
38
|
-
} else {
|
|
39
|
-
setMessage({ error: 'An unexpected error occurred.' });
|
|
40
|
-
}
|
|
36
|
+
setMessage(result.ok ? { success: result.message } : { error: result.error });
|
|
41
37
|
} catch (error) {
|
|
42
38
|
setMessage({ error: error instanceof Error ? error.message : 'An unknown error occurred.' });
|
|
43
39
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { createClient } from '@nextblock-cms/db/server';
|
|
5
5
|
import { revalidatePath } from 'next/cache';
|
|
6
|
+
import type { SettingsActionResult } from '../../../../lib/cms/action-result';
|
|
6
7
|
|
|
7
8
|
export type CopyrightSettings = {
|
|
8
9
|
[key: string]: string;
|
|
@@ -50,13 +51,15 @@ export async function getCopyrightSettings(): Promise<CopyrightSettings> {
|
|
|
50
51
|
return data.value as CopyrightSettings;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
export async function updateCopyrightSettings(
|
|
54
|
+
export async function updateCopyrightSettings(
|
|
55
|
+
formData: FormData,
|
|
56
|
+
): Promise<SettingsActionResult> {
|
|
54
57
|
const supabase = createClient();
|
|
55
58
|
|
|
56
59
|
// Check if user is an admin
|
|
57
60
|
const { data: { user } } = await supabase.auth.getUser();
|
|
58
61
|
if (!user) {
|
|
59
|
-
|
|
62
|
+
return { ok: false, error: 'You must be logged in to update settings.' };
|
|
60
63
|
}
|
|
61
64
|
const { data: profile, error: profileError } = await supabase
|
|
62
65
|
.from('profiles')
|
|
@@ -65,7 +68,7 @@ export async function updateCopyrightSettings(formData: FormData) {
|
|
|
65
68
|
.single();
|
|
66
69
|
|
|
67
70
|
if (profileError || !profile || !['ADMIN', 'WRITER'].includes(profile.role)) {
|
|
68
|
-
|
|
71
|
+
return { ok: false, error: 'You do not have permission to perform this action.' };
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
const newSettings: CopyrightSettings = {};
|
|
@@ -82,7 +85,7 @@ export async function updateCopyrightSettings(formData: FormData) {
|
|
|
82
85
|
|
|
83
86
|
if (error) {
|
|
84
87
|
console.error('Error updating copyright settings:', error);
|
|
85
|
-
|
|
88
|
+
return { ok: false, error: 'Failed to update copyright settings.' };
|
|
86
89
|
}
|
|
87
90
|
|
|
88
91
|
// Persist the footer attribution toggle. The client always submits an explicit
|
|
@@ -94,11 +97,11 @@ export async function updateCopyrightSettings(formData: FormData) {
|
|
|
94
97
|
|
|
95
98
|
if (attributionError) {
|
|
96
99
|
console.error('Error updating footer attribution setting:', attributionError);
|
|
97
|
-
|
|
100
|
+
return { ok: false, error: 'Failed to update footer attribution setting.' };
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
// Revalidate the root layout to reflect changes immediately across the site.
|
|
101
104
|
revalidatePath('/', 'layout');
|
|
102
105
|
|
|
103
|
-
return {
|
|
106
|
+
return { ok: true, message: 'Copyright settings updated successfully.' };
|
|
104
107
|
}
|
package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx
CHANGED
|
@@ -44,11 +44,7 @@ export default function CopyrightForm({ languages, initialSettings, initialAttri
|
|
|
44
44
|
startTransition(async () => {
|
|
45
45
|
try {
|
|
46
46
|
const result = await updateCopyrightSettings(formData);
|
|
47
|
-
|
|
48
|
-
setMessage({ success: result.message });
|
|
49
|
-
} else {
|
|
50
|
-
setMessage({ error: 'An unexpected error occurred.' });
|
|
51
|
-
}
|
|
47
|
+
setMessage(result.ok ? { success: result.message } : { error: result.error });
|
|
52
48
|
} catch (error) {
|
|
53
49
|
setMessage({ error: error instanceof Error ? error.message : 'An unknown error occurred.' });
|
|
54
50
|
}
|
|
@@ -50,6 +50,16 @@ type CortexAiSettingsStatus = {
|
|
|
50
50
|
unsplashAppName: string | null;
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* How every action in this file reports back: the page reads `?success=` / `?error=` and
|
|
55
|
+
* renders it. These are plain `<form action={fn}>` submissions, so a return value would be
|
|
56
|
+
* discarded and a thrown error would take out the page (with its message replaced by a
|
|
57
|
+
* generic string in production).
|
|
58
|
+
*
|
|
59
|
+
* Must be called OUTSIDE the try blocks below. `redirect()` signals itself by throwing
|
|
60
|
+
* NEXT_REDIRECT, so calling this inside a `try` would have the `catch` swallow the
|
|
61
|
+
* navigation and re-redirect with the framework's digest as the user-facing message.
|
|
62
|
+
*/
|
|
53
63
|
function redirectWithStatus(status: 'success' | 'error', message: string): never {
|
|
54
64
|
redirect(`${CORTEX_AI_SETTINGS_PATH}?${status}=${encodeURIComponent(message)}`);
|
|
55
65
|
}
|
|
@@ -173,7 +183,7 @@ export async function getCortexAiSettingsStatus(): Promise<CortexAiSettingsStatu
|
|
|
173
183
|
|
|
174
184
|
export async function saveOpenRouterApiKeyAction(formData: FormData) {
|
|
175
185
|
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
176
|
-
|
|
186
|
+
redirectWithStatus('error', 'Sandbox environment cannot save keys to the database.');
|
|
177
187
|
}
|
|
178
188
|
|
|
179
189
|
try {
|
|
@@ -206,7 +216,7 @@ export async function saveOpenRouterApiKeyAction(formData: FormData) {
|
|
|
206
216
|
|
|
207
217
|
export async function clearOpenRouterApiKeyAction() {
|
|
208
218
|
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
209
|
-
|
|
219
|
+
redirectWithStatus('error', 'Sandbox environment cannot clear keys from the database.');
|
|
210
220
|
}
|
|
211
221
|
|
|
212
222
|
try {
|
|
@@ -235,7 +245,7 @@ export async function clearOpenRouterApiKeyAction() {
|
|
|
235
245
|
|
|
236
246
|
export async function saveStockPhotoKeysAction(formData: FormData) {
|
|
237
247
|
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
238
|
-
|
|
248
|
+
redirectWithStatus('error', 'Sandbox environment cannot save keys to the database.');
|
|
239
249
|
}
|
|
240
250
|
|
|
241
251
|
try {
|
|
@@ -287,7 +297,7 @@ export async function saveStockPhotoKeysAction(formData: FormData) {
|
|
|
287
297
|
|
|
288
298
|
export async function clearStockPhotoKeysAction() {
|
|
289
299
|
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
290
|
-
|
|
300
|
+
redirectWithStatus('error', 'Sandbox environment cannot clear keys from the database.');
|
|
291
301
|
}
|
|
292
302
|
|
|
293
303
|
try {
|
|
@@ -313,7 +323,7 @@ export async function clearStockPhotoKeysAction() {
|
|
|
313
323
|
|
|
314
324
|
export async function saveCortexAiAgentSettingsAction(formData: FormData) {
|
|
315
325
|
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
316
|
-
|
|
326
|
+
redirectWithStatus('error', 'Sandbox environment cannot save settings to the database.');
|
|
317
327
|
}
|
|
318
328
|
|
|
319
329
|
try {
|
|
@@ -357,7 +367,7 @@ export async function saveCortexAiAgentSettingsAction(formData: FormData) {
|
|
|
357
367
|
|
|
358
368
|
export async function resetCortexAiAgentSettingsAction() {
|
|
359
369
|
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
360
|
-
|
|
370
|
+
redirectWithStatus('error', 'Sandbox environment cannot change settings in the database.');
|
|
361
371
|
}
|
|
362
372
|
|
|
363
373
|
try {
|
|
@@ -383,7 +393,7 @@ export async function resetCortexAiAgentSettingsAction() {
|
|
|
383
393
|
|
|
384
394
|
export async function saveCortexAiModelSelectionAction(formData: FormData) {
|
|
385
395
|
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
386
|
-
|
|
396
|
+
redirectWithStatus('error', 'Sandbox environment cannot save model selection to the database.');
|
|
387
397
|
}
|
|
388
398
|
|
|
389
399
|
try {
|
|
@@ -440,7 +450,7 @@ export async function saveCortexAiModelSelectionAction(formData: FormData) {
|
|
|
440
450
|
|
|
441
451
|
export async function clearCortexAiModelSelectionAction() {
|
|
442
452
|
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
443
|
-
|
|
453
|
+
redirectWithStatus('error', 'Sandbox environment cannot clear model selection from the database.');
|
|
444
454
|
}
|
|
445
455
|
|
|
446
456
|
try {
|