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.
Files changed (32) 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.ts +17 -4
  8. package/templates/nextblock-template/app/cms/CmsClientLayout.tsx +1 -1
  9. package/templates/nextblock-template/app/cms/settings/bot-protection/actions.ts +9 -6
  10. package/templates/nextblock-template/app/cms/settings/bot-protection/components/BotProtectionForm.tsx +1 -5
  11. package/templates/nextblock-template/app/cms/settings/copyright/actions.ts +9 -6
  12. package/templates/nextblock-template/app/cms/settings/copyright/components/CopyrightForm.tsx +1 -5
  13. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +18 -8
  14. package/templates/nextblock-template/app/cms/settings/email/actions.ts +59 -29
  15. package/templates/nextblock-template/app/cms/settings/email/components/EmailForm.tsx +5 -1
  16. package/templates/nextblock-template/app/cms/settings/global-css/actions.ts +6 -5
  17. package/templates/nextblock-template/app/cms/settings/global-css/components/GlobalCssForm.tsx +2 -1
  18. package/templates/nextblock-template/app/cms/settings/google-analytics/actions.ts +18 -7
  19. package/templates/nextblock-template/app/cms/settings/google-analytics/components/GoogleAnalyticsForm.tsx +1 -1
  20. package/templates/nextblock-template/app/cms/settings/privacy/actions.ts +16 -7
  21. package/templates/nextblock-template/app/cms/settings/privacy/components/PrivacyForm.tsx +1 -1
  22. package/templates/nextblock-template/app/cms/settings/registration/actions.ts +18 -7
  23. package/templates/nextblock-template/app/cms/settings/registration/components/RegistrationForm.tsx +1 -1
  24. package/templates/nextblock-template/app/cms/settings/security/actions.ts +227 -131
  25. package/templates/nextblock-template/app/cms/settings/security/components/SecurityPanel.tsx +134 -18
  26. package/templates/nextblock-template/lib/auth/twoFactor.test.ts +254 -0
  27. package/templates/nextblock-template/lib/auth/twoFactor.ts +56 -13
  28. package/templates/nextblock-template/lib/cms/action-result.ts +12 -0
  29. package/templates/nextblock-template/lib/config/email-settings.ts +40 -3
  30. package/templates/nextblock-template/next-env.d.ts +1 -1
  31. package/templates/nextblock-template/package.json +1 -1
  32. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -6,13 +6,23 @@ import { revalidatePath } from 'next/cache';
6
6
  import { saveEmailSettings } from '../../../../lib/config/email-settings';
7
7
  import { sendEmail } from '../../../actions/email';
8
8
 
9
- async function assertAdmin() {
9
+ /**
10
+ * Returned rather than thrown: Next replaces uncaught Server Action error messages with a
11
+ * generic string in production, and the relay's own rejection ("535 authentication failed")
12
+ * is the single most useful thing an admin can see while getting SMTP working.
13
+ */
14
+ export type EmailActionResult =
15
+ | { ok: true; message: string }
16
+ | { ok: false; error: string };
17
+
18
+ /** Null when the caller is an ADMIN, otherwise the failure to return. */
19
+ async function adminCheck(): Promise<EmailActionResult | null> {
10
20
  const supabase = createClient();
11
21
  const {
12
22
  data: { user },
13
23
  } = await supabase.auth.getUser();
14
24
  if (!user) {
15
- throw new Error('You must be logged in to update settings.');
25
+ return { ok: false, error: 'You must be logged in to update settings.' };
16
26
  }
17
27
  const { data: profile, error } = await supabase
18
28
  .from('profiles')
@@ -20,45 +30,65 @@ async function assertAdmin() {
20
30
  .eq('id', user.id)
21
31
  .single();
22
32
  if (error || !profile || profile.role !== 'ADMIN') {
23
- throw new Error('You do not have permission to perform this action.');
33
+ return { ok: false, error: 'You do not have permission to perform this action.' };
24
34
  }
35
+ return null;
25
36
  }
26
37
 
27
- export async function updateEmailSettings(formData: FormData) {
28
- await assertAdmin();
38
+ export async function updateEmailSettings(formData: FormData): Promise<EmailActionResult> {
39
+ const denied = await adminCheck();
40
+ if (denied) return denied;
29
41
 
30
- await saveEmailSettings({
31
- host: String(formData.get('host') ?? ''),
32
- port: String(formData.get('port') ?? ''),
33
- fromEmail: String(formData.get('fromEmail') ?? ''),
34
- fromName: String(formData.get('fromName') ?? ''),
35
- secure: formData.get('secure') === 'on' || formData.get('secure') === 'true',
36
- user: String(formData.get('user') ?? ''),
37
- pass: String(formData.get('pass') ?? ''),
38
- });
42
+ try {
43
+ await saveEmailSettings({
44
+ host: String(formData.get('host') ?? ''),
45
+ port: String(formData.get('port') ?? ''),
46
+ fromEmail: String(formData.get('fromEmail') ?? ''),
47
+ fromName: String(formData.get('fromName') ?? ''),
48
+ secure: formData.get('secure') === 'on' || formData.get('secure') === 'true',
49
+ user: String(formData.get('user') ?? ''),
50
+ pass: String(formData.get('pass') ?? ''),
51
+ });
52
+ } catch (error) {
53
+ console.error('Failed to save email settings:', error);
54
+ return {
55
+ ok: false,
56
+ error: error instanceof Error ? error.message : 'Failed to save email settings.',
57
+ };
58
+ }
39
59
 
40
60
  revalidatePath('/cms/settings/email');
41
- return { success: true as const, message: 'Email settings saved.' };
61
+ return { ok: true, message: 'Email settings saved.' };
42
62
  }
43
63
 
44
- export async function sendTestEmail(formData: FormData) {
45
- await assertAdmin();
64
+ export async function sendTestEmail(formData: FormData): Promise<EmailActionResult> {
65
+ const denied = await adminCheck();
66
+ if (denied) return denied;
46
67
 
47
68
  const to = String(formData.get('to') ?? '').trim();
48
69
  if (!to) {
49
- throw new Error('Enter a recipient email address.');
70
+ return { ok: false, error: 'Enter a recipient email address.' };
50
71
  }
51
72
 
52
- await sendEmail({
53
- to,
54
- subject: 'Test email',
55
- text: 'This is a test email from your CMS. SMTP is configured correctly.',
56
- html:
57
- '<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto;padding:24px;">' +
58
- '{{brand_header}}' +
59
- '<p>This is a test email from your CMS. SMTP is configured correctly. 🎉</p>' +
60
- '</div>',
61
- });
73
+ try {
74
+ await sendEmail({
75
+ to,
76
+ subject: 'Test email',
77
+ text: 'This is a test email from your CMS. SMTP is configured correctly.',
78
+ html:
79
+ '<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto;padding:24px;">' +
80
+ '{{brand_header}}' +
81
+ '<p>This is a test email from your CMS. SMTP is configured correctly. 🎉</p>' +
82
+ '</div>',
83
+ });
84
+ } catch (error) {
85
+ console.error('Test email failed:', error);
86
+ // Surface the relay's verbatim complaint — that is the whole point of a test send.
87
+ return {
88
+ ok: false,
89
+ error: error instanceof Error ? error.message : 'Failed to send test email.',
90
+ };
91
+ }
62
92
 
63
- return { success: true as const, message: `Test email sent to ${to}.` };
93
+ return { ok: true, message: `Test email sent to ${to}.` };
64
94
  }
@@ -52,6 +52,10 @@ export default function EmailForm({ initialSettings }: EmailFormProps) {
52
52
  startTransition(async () => {
53
53
  try {
54
54
  const result = await updateEmailSettings(formData);
55
+ if (!result.ok) {
56
+ setMessage({ error: result.error });
57
+ return;
58
+ }
55
59
  setMessage({ success: result.message });
56
60
  setUser('');
57
61
  setPass('');
@@ -68,7 +72,7 @@ export default function EmailForm({ initialSettings }: EmailFormProps) {
68
72
  startTestTransition(async () => {
69
73
  try {
70
74
  const result = await sendTestEmail(formData);
71
- setMessage({ success: result.message });
75
+ setMessage(result.ok ? { success: result.message } : { error: result.error });
72
76
  } catch (error) {
73
77
  setMessage({ error: error instanceof Error ? error.message : 'Failed to send test email.' });
74
78
  }
@@ -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 async function getGlobalCss(): Promise<string> {
8
9
  const supabase = createClient();
@@ -33,12 +34,12 @@ export async function getGlobalCss(): Promise<string> {
33
34
  return String(data.value);
34
35
  }
35
36
 
36
- export async function updateGlobalCss(css: string) {
37
+ export async function updateGlobalCss(css: string): Promise<SettingsActionResult> {
37
38
  const supabase = createClient();
38
39
 
39
40
  const { data: { user } } = await supabase.auth.getUser();
40
41
  if (!user) {
41
- throw new Error('You must be logged in to update settings.');
42
+ return { ok: false, error: 'You must be logged in to update settings.' };
42
43
  }
43
44
 
44
45
  const { data: profile, error: profileError } = await supabase
@@ -48,7 +49,7 @@ export async function updateGlobalCss(css: string) {
48
49
  .single();
49
50
 
50
51
  if (profileError || !profile || !['ADMIN', 'WRITER'].includes(profile.role)) {
51
- throw new Error('You do not have permission to perform this action.');
52
+ return { ok: false, error: 'You do not have permission to perform this action.' };
52
53
  }
53
54
 
54
55
  const { error } = await supabase
@@ -57,9 +58,9 @@ export async function updateGlobalCss(css: string) {
57
58
 
58
59
  if (error) {
59
60
  console.error('Error updating global CSS:', error);
60
- throw new Error('Failed to update CSS.');
61
+ return { ok: false, error: 'Failed to update CSS.' };
61
62
  }
62
63
 
63
64
  revalidatePath('/', 'layout');
64
- return { success: true, message: 'Global CSS updated successfully.' };
65
+ return { ok: true, message: 'Global CSS updated successfully.' };
65
66
  }
@@ -15,7 +15,8 @@ export default function GlobalCssForm({ initialCss }: { initialCss: string }) {
15
15
  setIsSubmitting(true);
16
16
  try {
17
17
  const res = await updateGlobalCss(css);
18
- toast.success(res.message);
18
+ if (res.ok) toast.success(res.message);
19
+ else toast.error(res.error);
19
20
  } catch (err: any) {
20
21
  toast.error(err.message || 'An error occurred.');
21
22
  } finally {
@@ -7,6 +7,7 @@ import {
7
7
  mergePrivacySettings,
8
8
  } from '../../../../lib/privacy/settings';
9
9
  import type { PrivacySettings } from '../../../../lib/privacy/types';
10
+ import type { SettingsActionResult } from '../../../../lib/cms/action-result';
10
11
 
11
12
  export interface GoogleAnalyticsSettings {
12
13
  gtm_id: string;
@@ -23,13 +24,14 @@ export async function getGoogleAnalyticsSettings(): Promise<GoogleAnalyticsSetti
23
24
  };
24
25
  }
25
26
 
26
- async function assertAdmin(): Promise<void> {
27
+ /** Null when the caller is an ADMIN, otherwise the failure to return. */
28
+ async function adminCheck(): Promise<SettingsActionResult | null> {
27
29
  const supabase = createClient();
28
30
  const {
29
31
  data: { user },
30
32
  } = await supabase.auth.getUser();
31
33
  if (!user) {
32
- throw new Error('You must be logged in to update settings.');
34
+ return { ok: false, error: 'You must be logged in to update settings.' };
33
35
  }
34
36
  const { data: profile } = await supabase
35
37
  .from('profiles')
@@ -37,12 +39,16 @@ async function assertAdmin(): Promise<void> {
37
39
  .eq('id', user.id)
38
40
  .single();
39
41
  if (!profile || profile.role !== 'ADMIN') {
40
- throw new Error('You do not have permission to perform this action.');
42
+ return { ok: false, error: 'You do not have permission to perform this action.' };
41
43
  }
44
+ return null;
42
45
  }
43
46
 
44
- export async function updateGoogleAnalyticsSettings(formData: FormData) {
45
- await assertAdmin();
47
+ export async function updateGoogleAnalyticsSettings(
48
+ formData: FormData,
49
+ ): Promise<SettingsActionResult> {
50
+ const denied = await adminCheck();
51
+ if (denied) return denied;
46
52
 
47
53
  // Only the analytics fields are touched; mergePrivacySettings preserves the
48
54
  // banner/corporate fields owned by the Privacy & Consent page.
@@ -52,9 +58,14 @@ export async function updateGoogleAnalyticsSettings(formData: FormData) {
52
58
  custom_scripts: formData.get('custom_scripts')?.toString() ?? '',
53
59
  };
54
60
 
55
- await mergePrivacySettings(patch);
61
+ try {
62
+ await mergePrivacySettings(patch);
63
+ } catch (error) {
64
+ console.error('Failed to save Google Analytics settings:', error);
65
+ return { ok: false, error: 'Failed to save Google Analytics settings.' };
66
+ }
56
67
  // The analytics guard (GTM/GA4 + custom scripts) lives in the root layout.
57
68
  revalidatePath('/', 'layout');
58
69
 
59
- return { success: true, message: 'Google Analytics settings saved.' };
70
+ return { ok: true, message: 'Google Analytics settings saved.' };
60
71
  }
@@ -44,7 +44,7 @@ export default function GoogleAnalyticsForm({ initialSettings }: GoogleAnalytics
44
44
  startTransition(async () => {
45
45
  try {
46
46
  const result = await updateGoogleAnalyticsSettings(formData);
47
- setMessage({ success: result.message });
47
+ setMessage(result.ok ? { success: result.message } : { error: result.error });
48
48
  } catch (error) {
49
49
  setMessage({
50
50
  error: error instanceof Error ? error.message : 'An unknown error occurred.',
@@ -7,18 +7,20 @@ import {
7
7
  mergePrivacySettings,
8
8
  } from '../../../../lib/privacy/settings';
9
9
  import type { PrivacySettings } from '../../../../lib/privacy/types';
10
+ import type { SettingsActionResult } from '../../../../lib/cms/action-result';
10
11
 
11
12
  export async function getPrivacySettings(): Promise<PrivacySettings> {
12
13
  return readPrivacySettings();
13
14
  }
14
15
 
15
- async function assertAdmin(): Promise<void> {
16
+ /** Null when the caller is an ADMIN, otherwise the failure to return. */
17
+ async function adminCheck(): Promise<SettingsActionResult | null> {
16
18
  const supabase = createClient();
17
19
  const {
18
20
  data: { user },
19
21
  } = await supabase.auth.getUser();
20
22
  if (!user) {
21
- throw new Error('You must be logged in to update settings.');
23
+ return { ok: false, error: 'You must be logged in to update settings.' };
22
24
  }
23
25
  const { data: profile } = await supabase
24
26
  .from('profiles')
@@ -26,12 +28,14 @@ async function assertAdmin(): Promise<void> {
26
28
  .eq('id', user.id)
27
29
  .single();
28
30
  if (!profile || profile.role !== 'ADMIN') {
29
- throw new Error('You do not have permission to perform this action.');
31
+ return { ok: false, error: 'You do not have permission to perform this action.' };
30
32
  }
33
+ return null;
31
34
  }
32
35
 
33
- export async function updatePrivacySettings(formData: FormData) {
34
- await assertAdmin();
36
+ export async function updatePrivacySettings(formData: FormData): Promise<SettingsActionResult> {
37
+ const denied = await adminCheck();
38
+ if (denied) return denied;
35
39
 
36
40
  // Analytics fields (GTM/GA4/custom scripts) are owned by the Google Analytics
37
41
  // settings page; merge only the consent + corporate fields so they aren't clobbered.
@@ -44,9 +48,14 @@ export async function updatePrivacySettings(formData: FormData) {
44
48
  },
45
49
  };
46
50
 
47
- await mergePrivacySettings(patch);
51
+ try {
52
+ await mergePrivacySettings(patch);
53
+ } catch (error) {
54
+ console.error('Failed to save privacy settings:', error);
55
+ return { ok: false, error: 'Failed to save privacy settings.' };
56
+ }
48
57
  // Footer (corporate identity) and the analytics guard live in the root layout.
49
58
  revalidatePath('/', 'layout');
50
59
 
51
- return { success: true, message: 'Privacy settings saved.' };
60
+ return { ok: true, message: 'Privacy settings saved.' };
52
61
  }
@@ -46,7 +46,7 @@ export default function PrivacyForm({ initialSettings }: PrivacyFormProps) {
46
46
  startTransition(async () => {
47
47
  try {
48
48
  const result = await updatePrivacySettings(formData);
49
- setMessage({ success: result.message });
49
+ setMessage(result.ok ? { success: result.message } : { error: result.error });
50
50
  } catch (error) {
51
51
  setMessage({
52
52
  error: error instanceof Error ? error.message : 'An unknown error occurred.',
@@ -7,19 +7,21 @@ import {
7
7
  getSystemConfiguration,
8
8
  updateSystemConfiguration,
9
9
  } from '../../../../lib/setup/system-config';
10
+ import type { SettingsActionResult } from '../../../../lib/cms/action-result';
10
11
 
11
12
  export async function getRegistrationSettings(): Promise<{ autoAcceptSignups: boolean }> {
12
13
  const config = await getSystemConfiguration();
13
14
  return { autoAcceptSignups: config.auto_accept_signups };
14
15
  }
15
16
 
16
- async function assertAdmin() {
17
+ /** Null when the caller is an ADMIN, otherwise the failure to return. */
18
+ async function adminCheck(): Promise<SettingsActionResult | null> {
17
19
  const supabase = createClient();
18
20
  const {
19
21
  data: { user },
20
22
  } = await supabase.auth.getUser();
21
23
  if (!user) {
22
- throw new Error('You must be logged in to update settings.');
24
+ return { ok: false, error: 'You must be logged in to update settings.' };
23
25
  }
24
26
  const { data: profile, error } = await supabase
25
27
  .from('profiles')
@@ -27,18 +29,27 @@ async function assertAdmin() {
27
29
  .eq('id', user.id)
28
30
  .single();
29
31
  if (error || !profile || profile.role !== 'ADMIN') {
30
- throw new Error('You do not have permission to perform this action.');
32
+ return { ok: false, error: 'You do not have permission to perform this action.' };
31
33
  }
34
+ return null;
32
35
  }
33
36
 
34
- export async function updateRegistrationSettings(formData: FormData) {
35
- await assertAdmin();
37
+ export async function updateRegistrationSettings(
38
+ formData: FormData,
39
+ ): Promise<SettingsActionResult> {
40
+ const denied = await adminCheck();
41
+ if (denied) return denied;
36
42
 
37
43
  const autoAcceptSignups =
38
44
  formData.get('autoAcceptSignups') === 'on' || formData.get('autoAcceptSignups') === 'true';
39
45
 
40
- await updateSystemConfiguration({ auto_accept_signups: autoAcceptSignups });
46
+ try {
47
+ await updateSystemConfiguration({ auto_accept_signups: autoAcceptSignups });
48
+ } catch (error) {
49
+ console.error('Failed to save registration settings:', error);
50
+ return { ok: false, error: 'Failed to save registration settings.' };
51
+ }
41
52
 
42
53
  revalidatePath('/cms/settings/registration');
43
- return { success: true as const, message: 'Registration settings saved.' };
54
+ return { ok: true, message: 'Registration settings saved.' };
44
55
  }
@@ -27,7 +27,7 @@ export default function RegistrationForm({ initialSettings }: RegistrationFormPr
27
27
  startTransition(async () => {
28
28
  try {
29
29
  const result = await updateRegistrationSettings(formData);
30
- setMessage({ success: result.message });
30
+ setMessage(result.ok ? { success: result.message } : { error: result.error });
31
31
  } catch (error) {
32
32
  setMessage({ error: error instanceof Error ? error.message : 'Failed to save settings.' });
33
33
  }