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
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from '../../../../lib/privacy/types';
|
|
15
15
|
import {
|
|
16
16
|
createEmailChallenge,
|
|
17
|
+
getEmailResendCooldownSeconds,
|
|
17
18
|
issueTwoFactorVerifiedCookie,
|
|
18
19
|
clearTwoFactorVerifiedCookie,
|
|
19
20
|
verifyEmailChallenge,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
getSystemConfiguration,
|
|
29
30
|
updateSystemConfiguration,
|
|
30
31
|
} from '../../../../lib/setup/system-config';
|
|
32
|
+
import { isEmailConfigured } from '../../../../lib/config/email-settings';
|
|
31
33
|
|
|
32
34
|
export interface SecurityPanelData {
|
|
33
35
|
email: string;
|
|
@@ -38,8 +40,20 @@ export interface SecurityPanelData {
|
|
|
38
40
|
globalSettings: SecuritySettings;
|
|
39
41
|
trustedDevices: TrustedDeviceRow[];
|
|
40
42
|
autoAcceptSignups: boolean;
|
|
43
|
+
/** False when no SMTP transport resolves — the email factor cannot be offered. */
|
|
44
|
+
emailConfigured: boolean;
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Every mutating action here returns this instead of throwing. Next replaces the message of
|
|
49
|
+
* an uncaught Server Action error with a generic string in production, and these messages
|
|
50
|
+
* ("that code was not valid", "only administrators can…") are exactly the ones the user has
|
|
51
|
+
* to be able to read.
|
|
52
|
+
*/
|
|
53
|
+
export type ActionResult =
|
|
54
|
+
| { ok: true; message: string }
|
|
55
|
+
| { ok: false; error: string; needsSmtp?: boolean };
|
|
56
|
+
|
|
43
57
|
async function requireUser() {
|
|
44
58
|
const supabase = createClient();
|
|
45
59
|
const {
|
|
@@ -51,10 +65,37 @@ async function requireUser() {
|
|
|
51
65
|
return { supabase, user };
|
|
52
66
|
}
|
|
53
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Runs an action body, converting anything it throws into a readable returned result.
|
|
70
|
+
* Business-rule failures should `return` a failure directly; this is the net for the
|
|
71
|
+
* unexpected (expired session, dropped connection) so it still surfaces a real message.
|
|
72
|
+
*/
|
|
73
|
+
async function guard(body: () => Promise<ActionResult>): Promise<ActionResult> {
|
|
74
|
+
try {
|
|
75
|
+
return await body();
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error('Security settings action failed:', error);
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
error: error instanceof Error ? error.message : 'Something went wrong.',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
54
85
|
export async function getSecurityPanelData(): Promise<SecurityPanelData> {
|
|
55
86
|
const { supabase, user } = await requireUser();
|
|
56
87
|
|
|
57
|
-
|
|
88
|
+
// Every read here is independent, so they all go out together — awaiting them inline in
|
|
89
|
+
// the returned object would serialize the round trips behind each other on page load.
|
|
90
|
+
const [
|
|
91
|
+
{ data: settings },
|
|
92
|
+
{ data: profile },
|
|
93
|
+
{ data: factors },
|
|
94
|
+
emailConfigured,
|
|
95
|
+
globalSettings,
|
|
96
|
+
trustedDevices,
|
|
97
|
+
systemConfig,
|
|
98
|
+
] = await Promise.all([
|
|
58
99
|
supabase
|
|
59
100
|
.from('user_security_settings')
|
|
60
101
|
.select('mfa_enabled, mfa_type')
|
|
@@ -62,6 +103,10 @@ export async function getSecurityPanelData(): Promise<SecurityPanelData> {
|
|
|
62
103
|
.maybeSingle(),
|
|
63
104
|
supabase.from('profiles').select('role').eq('id', user.id).single(),
|
|
64
105
|
supabase.auth.mfa.listFactors(),
|
|
106
|
+
isEmailConfigured(),
|
|
107
|
+
readSecuritySettings(),
|
|
108
|
+
listTrustedDevices(user.id),
|
|
109
|
+
getSystemConfiguration(),
|
|
65
110
|
]);
|
|
66
111
|
|
|
67
112
|
// listFactors().totp only contains verified TOTP factors.
|
|
@@ -73,63 +118,68 @@ export async function getSecurityPanelData(): Promise<SecurityPanelData> {
|
|
|
73
118
|
mfaType: (settings?.mfa_type as 'totp' | 'email' | null) ?? null,
|
|
74
119
|
hasVerifiedTotp,
|
|
75
120
|
isAdmin: profile?.role === 'ADMIN',
|
|
76
|
-
globalSettings
|
|
77
|
-
trustedDevices
|
|
78
|
-
autoAcceptSignups:
|
|
121
|
+
globalSettings,
|
|
122
|
+
trustedDevices,
|
|
123
|
+
autoAcceptSignups: systemConfig.auto_accept_signups,
|
|
124
|
+
emailConfigured,
|
|
79
125
|
};
|
|
80
126
|
}
|
|
81
127
|
|
|
82
128
|
// --- Sign-up policy (admin only) ------------------------------------------------
|
|
83
129
|
|
|
84
|
-
export async function updateAutoAcceptSignups(formData: FormData) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
130
|
+
export async function updateAutoAcceptSignups(formData: FormData): Promise<ActionResult> {
|
|
131
|
+
return guard(async () => {
|
|
132
|
+
const { supabase, user } = await requireUser();
|
|
133
|
+
const { data: profile } = await supabase
|
|
134
|
+
.from('profiles')
|
|
135
|
+
.select('role')
|
|
136
|
+
.eq('id', user.id)
|
|
137
|
+
.single();
|
|
138
|
+
if (profile?.role !== 'ADMIN') {
|
|
139
|
+
return { ok: false, error: 'Only administrators can change the sign-up policy.' };
|
|
140
|
+
}
|
|
94
141
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
142
|
+
const enabled = formData.get('auto_accept_signups') === 'true';
|
|
143
|
+
await updateSystemConfiguration({ auto_accept_signups: enabled });
|
|
144
|
+
revalidatePath('/cms/settings/security');
|
|
145
|
+
return {
|
|
146
|
+
ok: true,
|
|
147
|
+
message: enabled
|
|
148
|
+
? 'New sign-ups will be auto-approved without email verification.'
|
|
149
|
+
: 'New sign-ups now require email verification.',
|
|
150
|
+
};
|
|
151
|
+
});
|
|
104
152
|
}
|
|
105
153
|
|
|
106
154
|
// --- Global policy (admin only) -------------------------------------------------
|
|
107
155
|
|
|
108
|
-
export async function updateGlobalSecuritySettings(formData: FormData) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
156
|
+
export async function updateGlobalSecuritySettings(formData: FormData): Promise<ActionResult> {
|
|
157
|
+
return guard(async () => {
|
|
158
|
+
if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
|
|
159
|
+
return { ok: false, error: 'Security settings are disabled in the sandbox environment.' };
|
|
160
|
+
}
|
|
161
|
+
const { supabase, user } = await requireUser();
|
|
162
|
+
const { data: profile } = await supabase
|
|
163
|
+
.from('profiles')
|
|
164
|
+
.select('role')
|
|
165
|
+
.eq('id', user.id)
|
|
166
|
+
.single();
|
|
167
|
+
if (profile?.role !== 'ADMIN') {
|
|
168
|
+
return { ok: false, error: 'Only administrators can change the global security policy.' };
|
|
169
|
+
}
|
|
121
170
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
171
|
+
const days = Number.parseInt(formData.get('trusted_device_days')?.toString() ?? '', 10);
|
|
172
|
+
const settings: SecuritySettings = {
|
|
173
|
+
trusted_device_days: Number.isFinite(days)
|
|
174
|
+
? Math.min(MAX_TRUSTED_DEVICE_DAYS, Math.max(MIN_TRUSTED_DEVICE_DAYS, days))
|
|
175
|
+
: 30,
|
|
176
|
+
enforce_staff_2fa: formData.get('enforce_staff_2fa') === 'true',
|
|
177
|
+
};
|
|
129
178
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
179
|
+
await saveSecuritySettings(settings);
|
|
180
|
+
revalidatePath('/cms/settings/security');
|
|
181
|
+
return { ok: true, message: 'Security policy saved.' };
|
|
182
|
+
});
|
|
133
183
|
}
|
|
134
184
|
|
|
135
185
|
// --- TOTP enrollment ------------------------------------------------------------
|
|
@@ -172,113 +222,159 @@ export async function startTotpEnrollment(): Promise<EnrollTotpResult> {
|
|
|
172
222
|
};
|
|
173
223
|
}
|
|
174
224
|
|
|
175
|
-
export async function verifyTotpEnrollment(formData: FormData) {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
225
|
+
export async function verifyTotpEnrollment(formData: FormData): Promise<ActionResult> {
|
|
226
|
+
return guard(async () => {
|
|
227
|
+
const { supabase, user } = await requireUser();
|
|
228
|
+
const factorId = formData.get('factorId')?.toString() ?? '';
|
|
229
|
+
const code = (formData.get('code')?.toString() ?? '').trim();
|
|
179
230
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
231
|
+
if (!factorId || !/^\d{6}$/.test(code)) {
|
|
232
|
+
return { ok: false, error: 'Enter the 6-digit code from your authenticator app.' };
|
|
233
|
+
}
|
|
183
234
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
235
|
+
const { data: challenge, error: challengeError } = await supabase.auth.mfa.challenge({
|
|
236
|
+
factorId,
|
|
237
|
+
});
|
|
238
|
+
if (challengeError || !challenge) {
|
|
239
|
+
return { ok: false, error: challengeError?.message ?? 'Could not start verification.' };
|
|
240
|
+
}
|
|
190
241
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
242
|
+
const { error: verifyError } = await supabase.auth.mfa.verify({
|
|
243
|
+
factorId,
|
|
244
|
+
challengeId: challenge.id,
|
|
245
|
+
code,
|
|
246
|
+
});
|
|
247
|
+
if (verifyError) {
|
|
248
|
+
return { ok: false, error: 'That code was not valid. Please try again.' };
|
|
249
|
+
}
|
|
199
250
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
251
|
+
const { error: upsertError } = await supabase.from('user_security_settings').upsert({
|
|
252
|
+
user_id: user.id,
|
|
253
|
+
mfa_enabled: true,
|
|
254
|
+
mfa_type: 'totp',
|
|
255
|
+
updated_at: new Date().toISOString(),
|
|
256
|
+
});
|
|
257
|
+
if (upsertError) {
|
|
258
|
+
return { ok: false, error: 'Verified, but failed to save your preference. Please retry.' };
|
|
259
|
+
}
|
|
209
260
|
|
|
210
|
-
|
|
211
|
-
|
|
261
|
+
revalidatePath('/cms/settings/security');
|
|
262
|
+
return { ok: true, message: 'Authenticator app enabled.' };
|
|
263
|
+
});
|
|
212
264
|
}
|
|
213
265
|
|
|
214
266
|
// --- Email-code enrollment ------------------------------------------------------
|
|
215
267
|
|
|
216
|
-
export async function sendEmailEnrollmentCode() {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
await sendTwoFactorCodeEmail(user.email, code, 'enable email two-factor authentication');
|
|
223
|
-
return { success: true, message: `We sent a 6-digit code to ${user.email}.` };
|
|
224
|
-
}
|
|
268
|
+
export async function sendEmailEnrollmentCode(): Promise<ActionResult> {
|
|
269
|
+
return guard(async () => {
|
|
270
|
+
const { user } = await requireUser();
|
|
271
|
+
if (!user.email) {
|
|
272
|
+
return { ok: false, error: 'Your account has no email address on file.' };
|
|
273
|
+
}
|
|
225
274
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
275
|
+
// Check before minting a challenge: without a transport the code can never arrive, and
|
|
276
|
+
// an unconfigured instance should say so rather than claim an email is on its way.
|
|
277
|
+
if (!(await isEmailConfigured())) {
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
needsSmtp: true,
|
|
281
|
+
error:
|
|
282
|
+
'Email is not configured on this site, so a code cannot be delivered. Set up SMTP first.',
|
|
283
|
+
};
|
|
284
|
+
}
|
|
229
285
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
286
|
+
const wait = await getEmailResendCooldownSeconds(user.id);
|
|
287
|
+
if (wait > 0) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
error: `A code was just sent. Please wait ${wait}s before requesting another.`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const code = await createEmailChallenge(user.id);
|
|
295
|
+
try {
|
|
296
|
+
await sendTwoFactorCodeEmail(user.email, code, 'enable email two-factor authentication');
|
|
297
|
+
} catch (sendError) {
|
|
298
|
+
console.error('Failed to send 2FA enrollment code:', sendError);
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
error:
|
|
302
|
+
'Your mail server rejected the message. Check the SMTP settings under Settings → Email and try again.',
|
|
303
|
+
};
|
|
304
|
+
}
|
|
234
305
|
|
|
235
|
-
|
|
236
|
-
user_id: user.id,
|
|
237
|
-
mfa_enabled: true,
|
|
238
|
-
mfa_type: 'email',
|
|
239
|
-
updated_at: new Date().toISOString(),
|
|
306
|
+
return { ok: true, message: `We sent a 6-digit code to ${user.email}.` };
|
|
240
307
|
});
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function verifyEmailEnrollment(formData: FormData): Promise<ActionResult> {
|
|
311
|
+
return guard(async () => {
|
|
312
|
+
const { supabase, user } = await requireUser();
|
|
313
|
+
const code = (formData.get('code')?.toString() ?? '').trim();
|
|
244
314
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
315
|
+
const verified = await verifyEmailChallenge(user.id, code);
|
|
316
|
+
if (!verified) {
|
|
317
|
+
return { ok: false, error: 'That code was incorrect or expired. Request a new one.' };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const { error } = await supabase.from('user_security_settings').upsert({
|
|
321
|
+
user_id: user.id,
|
|
322
|
+
mfa_enabled: true,
|
|
323
|
+
mfa_type: 'email',
|
|
324
|
+
updated_at: new Date().toISOString(),
|
|
325
|
+
});
|
|
326
|
+
if (error) {
|
|
327
|
+
return { ok: false, error: 'Verified, but failed to save your preference. Please retry.' };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The user just proved control of their inbox, so this session is satisfied.
|
|
331
|
+
await issueTwoFactorVerifiedCookie(user.id);
|
|
332
|
+
revalidatePath('/cms/settings/security');
|
|
333
|
+
return { ok: true, message: 'Email verification enabled.' };
|
|
334
|
+
});
|
|
249
335
|
}
|
|
250
336
|
|
|
251
337
|
// --- Disable / device management ------------------------------------------------
|
|
252
338
|
|
|
253
|
-
export async function disableMfa() {
|
|
254
|
-
|
|
339
|
+
export async function disableMfa(): Promise<ActionResult> {
|
|
340
|
+
return guard(async () => {
|
|
341
|
+
const { supabase, user } = await requireUser();
|
|
255
342
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
343
|
+
const { data: factors } = await supabase.auth.mfa.listFactors();
|
|
344
|
+
for (const factor of factors?.all ?? []) {
|
|
345
|
+
await supabase.auth.mfa.unenroll({ factorId: factor.id });
|
|
346
|
+
}
|
|
260
347
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
348
|
+
const { error } = await supabase.from('user_security_settings').upsert({
|
|
349
|
+
user_id: user.id,
|
|
350
|
+
mfa_enabled: false,
|
|
351
|
+
mfa_type: null,
|
|
352
|
+
updated_at: new Date().toISOString(),
|
|
353
|
+
});
|
|
354
|
+
if (error) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
error: 'Could not turn off two-factor authentication. Please retry.',
|
|
358
|
+
};
|
|
359
|
+
}
|
|
267
360
|
|
|
268
|
-
|
|
269
|
-
|
|
361
|
+
await revokeAllTrustedDevices(user.id);
|
|
362
|
+
await clearTwoFactorVerifiedCookie();
|
|
270
363
|
|
|
271
|
-
|
|
272
|
-
|
|
364
|
+
revalidatePath('/cms/settings/security');
|
|
365
|
+
return { ok: true, message: 'Two-factor authentication disabled.' };
|
|
366
|
+
});
|
|
273
367
|
}
|
|
274
368
|
|
|
275
|
-
export async function revokeTrustedDeviceAction(formData: FormData) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
369
|
+
export async function revokeTrustedDeviceAction(formData: FormData): Promise<ActionResult> {
|
|
370
|
+
return guard(async () => {
|
|
371
|
+
const { user } = await requireUser();
|
|
372
|
+
const id = formData.get('id')?.toString() ?? '';
|
|
373
|
+
if (!id) {
|
|
374
|
+
return { ok: false, error: 'Missing device id.' };
|
|
375
|
+
}
|
|
376
|
+
await revokeTrustedDevice(user.id, id);
|
|
377
|
+
revalidatePath('/cms/settings/security');
|
|
378
|
+
return { ok: true, message: 'Device revoked.' };
|
|
379
|
+
});
|
|
284
380
|
}
|