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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/(auth-pages)/two-factor/actions.ts +21 -1
  3. package/templates/nextblock-template/app/(auth-pages)/two-factor/components/TwoFactorForm.tsx +34 -10
  4. package/templates/nextblock-template/app/actions/email.ts +78 -7
  5. package/templates/nextblock-template/app/actions/feedback.ts +57 -14
  6. package/templates/nextblock-template/app/actions/interactions.test.ts +3 -0
  7. package/templates/nextblock-template/app/actions/productGridActions.ts +40 -0
  8. package/templates/nextblock-template/app/actions.ts +17 -4
  9. package/templates/nextblock-template/app/api/cms/ecommerce/product-picker/route.ts +151 -0
  10. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +4 -1
  11. package/templates/nextblock-template/app/cms/blocks/components/BlockTypeSelector.tsx +17 -5
  12. package/templates/nextblock-template/app/cms/blocks/components/MultiEntityPicker.tsx +251 -0
  13. package/templates/nextblock-template/app/cms/blocks/editors/ProductGridBlockEditor.tsx +375 -18
  14. package/templates/nextblock-template/app/cms/components/EcommerceActiveContext.tsx +27 -0
  15. package/templates/nextblock-template/app/cms/settings/bot-protection/actions.ts +9 -6
  16. package/templates/nextblock-template/app/cms/settings/bot-protection/components/BotProtectionForm.tsx +1 -5
  17. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +9 -6
  18. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +1 -5
  19. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +18 -8
  20. package/templates/nextblock-template/app/cms/settings/email/actions.ts +59 -29
  21. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +5 -1
  22. package/templates/nextblock-template/app/cms/settings/global-css/actions.ts +6 -5
  23. package/templates/nextblock-template/app/cms/settings/global-css/components/GlobalCssForm.tsx +2 -1
  24. package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +18 -7
  25. package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +1 -1
  26. package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +16 -7
  27. package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +1 -1
  28. package/templates/nextblock-template/app/cms/settings/registration/actions.ts +18 -7
  29. package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +1 -1
  30. package/templates/nextblock-template/app/cms/settings/security/actions.ts +227 -131
  31. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +134 -18
  32. package/templates/nextblock-template/components/blocks/ProductGridClient.tsx +114 -0
  33. package/templates/nextblock-template/lib/auth/twoFactor.test.ts +254 -0
  34. package/templates/nextblock-template/lib/auth/twoFactor.ts +56 -13
  35. package/templates/nextblock-template/lib/blocks/ProductGridBlock.tsx +78 -139
  36. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +3 -3
  37. package/templates/nextblock-template/lib/blocks/blockTypes.ts +19 -0
  38. package/templates/nextblock-template/lib/blocks/ecommerce-block-schemas.ts +61 -2
  39. package/templates/nextblock-template/lib/blocks/product-grid-data.ts +210 -0
  40. package/templates/nextblock-template/lib/cms/action-result.ts +12 -0
  41. package/templates/nextblock-template/lib/config/email-settings.ts +40 -3
  42. package/templates/nextblock-template/next-env.d.ts +1 -1
  43. package/templates/nextblock-template/package.json +1 -1
  44. 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
- const [{ data: settings }, { data: profile }, { data: factors }] = await Promise.all([
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: await readSecuritySettings(),
77
- trustedDevices: await listTrustedDevices(user.id),
78
- autoAcceptSignups: (await getSystemConfiguration()).auto_accept_signups,
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
- const { supabase, user } = await requireUser();
86
- const { data: profile } = await supabase
87
- .from('profiles')
88
- .select('role')
89
- .eq('id', user.id)
90
- .single();
91
- if (profile?.role !== 'ADMIN') {
92
- throw new Error('Only administrators can change the sign-up policy.');
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
- const enabled = formData.get('auto_accept_signups') === 'true';
96
- await updateSystemConfiguration({ auto_accept_signups: enabled });
97
- revalidatePath('/cms/settings/security');
98
- return {
99
- success: true,
100
- message: enabled
101
- ? 'New sign-ups will be auto-approved without email verification.'
102
- : 'New sign-ups now require email verification.',
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
- if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
110
- throw new Error('Security settings are disabled in the sandbox environment.');
111
- }
112
- const { supabase, user } = await requireUser();
113
- const { data: profile } = await supabase
114
- .from('profiles')
115
- .select('role')
116
- .eq('id', user.id)
117
- .single();
118
- if (profile?.role !== 'ADMIN') {
119
- throw new Error('Only administrators can change the global security policy.');
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
- const days = Number.parseInt(formData.get('trusted_device_days')?.toString() ?? '', 10);
123
- const settings: SecuritySettings = {
124
- trusted_device_days: Number.isFinite(days)
125
- ? Math.min(MAX_TRUSTED_DEVICE_DAYS, Math.max(MIN_TRUSTED_DEVICE_DAYS, days))
126
- : 30,
127
- enforce_staff_2fa: formData.get('enforce_staff_2fa') === 'true',
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
- await saveSecuritySettings(settings);
131
- revalidatePath('/cms/settings/security');
132
- return { success: true, message: 'Security policy saved.' };
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
- const { supabase, user } = await requireUser();
177
- const factorId = formData.get('factorId')?.toString() ?? '';
178
- const code = (formData.get('code')?.toString() ?? '').trim();
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
- if (!factorId || !/^\d{6}$/.test(code)) {
181
- throw new Error('Enter the 6-digit code from your authenticator app.');
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
- const { data: challenge, error: challengeError } = await supabase.auth.mfa.challenge({
185
- factorId,
186
- });
187
- if (challengeError || !challenge) {
188
- throw new Error(challengeError?.message ?? 'Could not start verification.');
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
- const { error: verifyError } = await supabase.auth.mfa.verify({
192
- factorId,
193
- challengeId: challenge.id,
194
- code,
195
- });
196
- if (verifyError) {
197
- throw new Error('That code was not valid. Please try again.');
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
- const { error: upsertError } = await supabase.from('user_security_settings').upsert({
201
- user_id: user.id,
202
- mfa_enabled: true,
203
- mfa_type: 'totp',
204
- updated_at: new Date().toISOString(),
205
- });
206
- if (upsertError) {
207
- throw new Error('Verified, but failed to save your preference. Please retry.');
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
- revalidatePath('/cms/settings/security');
211
- return { success: true, message: 'Authenticator app enabled.' };
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
- const { user } = await requireUser();
218
- if (!user.email) {
219
- throw new Error('Your account has no email address on file.');
220
- }
221
- const code = await createEmailChallenge(user.id);
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
- export async function verifyEmailEnrollment(formData: FormData) {
227
- const { supabase, user } = await requireUser();
228
- const code = (formData.get('code')?.toString() ?? '').trim();
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
- const ok = await verifyEmailChallenge(user.id, code);
231
- if (!ok) {
232
- throw new Error('That code was incorrect or expired. Request a new one.');
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
- const { error } = await supabase.from('user_security_settings').upsert({
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
- if (error) {
242
- throw new Error('Verified, but failed to save your preference. Please retry.');
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
- // The user just proved control of their inbox, so this session is satisfied.
246
- await issueTwoFactorVerifiedCookie(user.id);
247
- revalidatePath('/cms/settings/security');
248
- return { success: true, message: 'Email verification enabled.' };
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
- const { supabase, user } = await requireUser();
339
+ export async function disableMfa(): Promise<ActionResult> {
340
+ return guard(async () => {
341
+ const { supabase, user } = await requireUser();
255
342
 
256
- const { data: factors } = await supabase.auth.mfa.listFactors();
257
- for (const factor of factors?.all ?? []) {
258
- await supabase.auth.mfa.unenroll({ factorId: factor.id });
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
- await supabase.from('user_security_settings').upsert({
262
- user_id: user.id,
263
- mfa_enabled: false,
264
- mfa_type: null,
265
- updated_at: new Date().toISOString(),
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
- await revokeAllTrustedDevices(user.id);
269
- await clearTwoFactorVerifiedCookie();
361
+ await revokeAllTrustedDevices(user.id);
362
+ await clearTwoFactorVerifiedCookie();
270
363
 
271
- revalidatePath('/cms/settings/security');
272
- return { success: true, message: 'Two-factor authentication disabled.' };
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
- const { user } = await requireUser();
277
- const id = formData.get('id')?.toString() ?? '';
278
- if (!id) {
279
- throw new Error('Missing device id.');
280
- }
281
- await revokeTrustedDevice(user.id, id);
282
- revalidatePath('/cms/settings/security');
283
- return { success: true, message: 'Device revoked.' };
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
  }