create-nextblock 0.12.16 → 0.13.2

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 (45) hide show
  1. package/package.json +1 -1
  2. package/templates/nextblock-template/app/actions/interactions.ts +27 -4
  3. package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
  4. package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
  5. package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
  6. package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
  7. package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
  8. package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
  9. package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
  10. package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
  11. package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
  12. package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +40 -2
  13. package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -0
  14. package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +138 -71
  15. package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
  16. package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
  17. package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
  18. package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
  19. package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
  20. package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
  21. package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
  22. package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
  23. package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
  24. package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
  25. package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +20 -1
  26. package/templates/nextblock-template/app/cms/users/actions.ts +69 -0
  27. package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -0
  28. package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +4 -1
  29. package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -0
  30. package/templates/nextblock-template/app/cms/users/page.tsx +12 -3
  31. package/templates/nextblock-template/app/lib/homepage.ts +36 -0
  32. package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
  33. package/templates/nextblock-template/app/page.tsx +55 -12
  34. package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
  35. package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
  36. package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
  37. package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
  38. package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
  39. package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
  40. package/templates/nextblock-template/lib/search/server.ts +11 -1
  41. package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
  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/proxy.ts +5 -0
  45. package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
@@ -6,16 +6,23 @@ import { redirect } from 'next/navigation';
6
6
  import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
7
7
 
8
8
  import {
9
+ CORTEX_AI_AGENT_SETTINGS_DEFAULTS,
10
+ CORTEX_AI_AGENT_SETTINGS_KEY,
9
11
  CORTEX_AI_OPENROUTER_MODEL_SELECTION_SETTING_KEY,
10
12
  CORTEX_AI_OPENROUTER_SETTING_KEY,
11
13
  CORTEX_AI_PACKAGE_ID,
14
+ CORTEX_AI_PEXELS_SETTING_KEY,
15
+ CORTEX_AI_UNSPLASH_APP_NAME_SETTING_KEY,
16
+ CORTEX_AI_UNSPLASH_SETTING_KEY,
12
17
  createCortexAiStoredModelSelection,
13
18
  encryptStoredOpenRouterApiKey,
14
19
  getCortexAiEnvConfig,
15
20
  getEnvOpenRouterKeyStatus,
16
21
  getStoredOpenRouterKeyStatus,
17
22
  listCortexAiCompatibleOpenRouterModels,
23
+ normalizeCortexAiAgentSettings,
18
24
  safeParseCortexAiModelSelection,
25
+ type CortexAiAgentSettings,
19
26
  type CortexAiStoredModelSelection,
20
27
  } from '@nextblock-cms/cortex';
21
28
 
@@ -23,14 +30,24 @@ const CORTEX_AI_SETTINGS_PATH = '/cms/settings/cortex-ai';
23
30
 
24
31
  type CortexAiSettingsStatus = {
25
32
  activeKeySource: 'env' | 'stored' | 'none';
33
+ activeStockProvider: 'pexels' | 'unsplash' | null;
34
+ agentSettings: CortexAiAgentSettings;
26
35
  hasEncryptionKey: boolean;
27
36
  hasEnvOpenRouterKey: boolean;
37
+ hasEnvPexelsKey: boolean;
38
+ hasEnvUnsplashKey: boolean;
28
39
  hasStoredOpenRouterKey: boolean;
40
+ hasStoredPexelsKey: boolean;
41
+ hasStoredUnsplashKey: boolean;
29
42
  isPackageActive: boolean;
30
43
  maskedEnvOpenRouterKey: string | null;
31
44
  maskedStoredOpenRouterKey: string | null;
45
+ maskedStoredPexelsKey: string | null;
46
+ maskedStoredUnsplashKey: string | null;
32
47
  selectedModel: CortexAiStoredModelSelection | null;
48
+ stockKeysUpdatedAt: string | null;
33
49
  storedOpenRouterKeyUpdatedAt: string | null;
50
+ unsplashAppName: string | null;
34
51
  };
35
52
 
36
53
  function redirectWithStatus(status: 'success' | 'error', message: string): never {
@@ -66,7 +83,15 @@ export async function getCortexAiSettingsStatus(): Promise<CortexAiSettingsStatu
66
83
  const env = getCortexAiEnvConfig();
67
84
  const envKeyStatus = getEnvOpenRouterKeyStatus();
68
85
 
69
- const [{ data: storedKeyRow }, { data: selectedModelRow }, isPackageActive] = await Promise.all([
86
+ const [
87
+ { data: storedKeyRow },
88
+ { data: selectedModelRow },
89
+ { data: storedPexelsRow },
90
+ { data: storedUnsplashRow },
91
+ { data: unsplashAppNameRow },
92
+ { data: agentSettingsRow },
93
+ isPackageActive,
94
+ ] = await Promise.all([
70
95
  supabase
71
96
  .from('site_settings')
72
97
  .select('value')
@@ -77,11 +102,44 @@ export async function getCortexAiSettingsStatus(): Promise<CortexAiSettingsStatu
77
102
  .select('value')
78
103
  .eq('key', CORTEX_AI_OPENROUTER_MODEL_SELECTION_SETTING_KEY)
79
104
  .maybeSingle(),
105
+ supabase
106
+ .from('site_settings')
107
+ .select('value')
108
+ .eq('key', CORTEX_AI_PEXELS_SETTING_KEY)
109
+ .maybeSingle(),
110
+ supabase
111
+ .from('site_settings')
112
+ .select('value')
113
+ .eq('key', CORTEX_AI_UNSPLASH_SETTING_KEY)
114
+ .maybeSingle(),
115
+ supabase
116
+ .from('site_settings')
117
+ .select('value')
118
+ .eq('key', CORTEX_AI_UNSPLASH_APP_NAME_SETTING_KEY)
119
+ .maybeSingle(),
120
+ supabase
121
+ .from('site_settings')
122
+ .select('value')
123
+ .eq('key', CORTEX_AI_AGENT_SETTINGS_KEY)
124
+ .maybeSingle(),
80
125
  verifyPackageOnline(CORTEX_AI_PACKAGE_ID).catch(() => false),
81
126
  ]);
82
127
 
83
128
  const storedKeyStatus = getStoredOpenRouterKeyStatus(storedKeyRow?.value);
84
129
  const selectedModel = safeParseCortexAiModelSelection(selectedModelRow?.value);
130
+ const pexelsStatus = getStoredOpenRouterKeyStatus(storedPexelsRow?.value);
131
+ const unsplashStatus = getStoredOpenRouterKeyStatus(storedUnsplashRow?.value);
132
+ const hasEnvPexelsKey = Boolean(process.env.PEXELS_API_KEY?.trim());
133
+ const hasEnvUnsplashKey = Boolean(process.env.UNSPLASH_ACCESS_KEY?.trim());
134
+ const activeStockProvider: 'pexels' | 'unsplash' | null = pexelsStatus.hasStoredKey
135
+ ? 'pexels'
136
+ : unsplashStatus.hasStoredKey
137
+ ? 'unsplash'
138
+ : hasEnvPexelsKey
139
+ ? 'pexels'
140
+ : hasEnvUnsplashKey
141
+ ? 'unsplash'
142
+ : null;
85
143
 
86
144
  return {
87
145
  activeKeySource: storedKeyStatus.hasStoredKey
@@ -89,14 +147,27 @@ export async function getCortexAiSettingsStatus(): Promise<CortexAiSettingsStatu
89
147
  : env.hasOpenRouterEnvKey
90
148
  ? 'env'
91
149
  : 'none',
150
+ activeStockProvider,
92
151
  hasEncryptionKey: env.hasEncryptionKey,
93
152
  hasEnvOpenRouterKey: env.hasOpenRouterEnvKey,
153
+ hasEnvPexelsKey,
154
+ hasEnvUnsplashKey,
94
155
  hasStoredOpenRouterKey: storedKeyStatus.hasStoredKey,
156
+ hasStoredPexelsKey: pexelsStatus.hasStoredKey,
157
+ hasStoredUnsplashKey: unsplashStatus.hasStoredKey,
95
158
  isPackageActive,
96
159
  maskedEnvOpenRouterKey: envKeyStatus.maskedEnvOpenRouterKey,
97
160
  maskedStoredOpenRouterKey: storedKeyStatus.maskedKey,
161
+ maskedStoredPexelsKey: pexelsStatus.maskedKey,
162
+ maskedStoredUnsplashKey: unsplashStatus.maskedKey,
163
+ agentSettings: normalizeCortexAiAgentSettings(agentSettingsRow?.value),
98
164
  selectedModel: storedKeyStatus.hasStoredKey ? selectedModel : null,
165
+ stockKeysUpdatedAt: pexelsStatus.updatedAt || unsplashStatus.updatedAt || null,
99
166
  storedOpenRouterKeyUpdatedAt: storedKeyStatus.updatedAt,
167
+ unsplashAppName:
168
+ typeof unsplashAppNameRow?.value === 'string' && unsplashAppNameRow.value.trim()
169
+ ? unsplashAppNameRow.value.trim()
170
+ : null,
100
171
  };
101
172
  }
102
173
 
@@ -162,6 +233,154 @@ export async function clearOpenRouterApiKeyAction() {
162
233
  redirectWithStatus('success', 'Stored OpenRouter key cleared.');
163
234
  }
164
235
 
236
+ export async function saveStockPhotoKeysAction(formData: FormData) {
237
+ if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
238
+ throw new Error('Sandbox environment cannot save keys to the database.');
239
+ }
240
+
241
+ try {
242
+ const supabase = await requireAdminSupabaseClient();
243
+ const pexelsKey = String(formData.get('pexels_api_key') || '').trim();
244
+ const unsplashKey = String(formData.get('unsplash_access_key') || '').trim();
245
+ const unsplashAppName = String(formData.get('unsplash_app_name') || '').trim();
246
+
247
+ if (!pexelsKey && !unsplashKey && !unsplashAppName) {
248
+ throw new Error('Enter a Pexels or Unsplash API key, or an Unsplash app name.');
249
+ }
250
+
251
+ const rows: Array<{ key: string; value: unknown }> = [];
252
+
253
+ if (pexelsKey) {
254
+ rows.push({
255
+ key: CORTEX_AI_PEXELS_SETTING_KEY,
256
+ value: encryptStoredOpenRouterApiKey(pexelsKey),
257
+ });
258
+ }
259
+
260
+ if (unsplashKey) {
261
+ rows.push({
262
+ key: CORTEX_AI_UNSPLASH_SETTING_KEY,
263
+ value: encryptStoredOpenRouterApiKey(unsplashKey),
264
+ });
265
+ }
266
+
267
+ // Non-secret: the registered Unsplash app name for attribution utm_source.
268
+ if (unsplashAppName) {
269
+ rows.push({ key: CORTEX_AI_UNSPLASH_APP_NAME_SETTING_KEY, value: unsplashAppName });
270
+ }
271
+
272
+ const { error } = await supabase.from('site_settings').upsert(rows);
273
+
274
+ if (error) {
275
+ throw new Error(error.message);
276
+ }
277
+ } catch (error) {
278
+ redirectWithStatus(
279
+ 'error',
280
+ error instanceof Error ? error.message : 'Failed to save stock photo keys.'
281
+ );
282
+ }
283
+
284
+ revalidatePath(CORTEX_AI_SETTINGS_PATH);
285
+ redirectWithStatus('success', 'Stock photo API key saved.');
286
+ }
287
+
288
+ export async function clearStockPhotoKeysAction() {
289
+ if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
290
+ throw new Error('Sandbox environment cannot clear keys from the database.');
291
+ }
292
+
293
+ try {
294
+ const supabase = await requireAdminSupabaseClient();
295
+ const { error } = await supabase
296
+ .from('site_settings')
297
+ .delete()
298
+ .in('key', [CORTEX_AI_PEXELS_SETTING_KEY, CORTEX_AI_UNSPLASH_SETTING_KEY]);
299
+
300
+ if (error) {
301
+ throw new Error(error.message);
302
+ }
303
+ } catch (error) {
304
+ redirectWithStatus(
305
+ 'error',
306
+ error instanceof Error ? error.message : 'Failed to clear stock photo keys.'
307
+ );
308
+ }
309
+
310
+ revalidatePath(CORTEX_AI_SETTINGS_PATH);
311
+ redirectWithStatus('success', 'Stock photo API keys cleared.');
312
+ }
313
+
314
+ export async function saveCortexAiAgentSettingsAction(formData: FormData) {
315
+ if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
316
+ throw new Error('Sandbox environment cannot save settings to the database.');
317
+ }
318
+
319
+ try {
320
+ const supabase = await requireAdminSupabaseClient();
321
+ const unlimited = String(formData.get('max_output_unlimited') || '') === 'on';
322
+ const rawMaxOutput = String(formData.get('max_output_tokens') || '').trim();
323
+ const rawTimeoutSeconds = Number(formData.get('response_timeout_seconds'));
324
+
325
+ // normalizeCortexAiAgentSettings clamps everything to safe bounds.
326
+ const settings = normalizeCortexAiAgentSettings({
327
+ maxOutputTokens: unlimited
328
+ ? null
329
+ : rawMaxOutput
330
+ ? Number(rawMaxOutput)
331
+ : CORTEX_AI_AGENT_SETTINGS_DEFAULTS.maxOutputTokens,
332
+ maxSteps: Number(formData.get('max_steps')),
333
+ responseTimeoutMs: Number.isFinite(rawTimeoutSeconds)
334
+ ? rawTimeoutSeconds * 1000
335
+ : CORTEX_AI_AGENT_SETTINGS_DEFAULTS.responseTimeoutMs,
336
+ temperature: Number(formData.get('temperature')),
337
+ });
338
+
339
+ const { error } = await supabase.from('site_settings').upsert({
340
+ key: CORTEX_AI_AGENT_SETTINGS_KEY,
341
+ value: settings,
342
+ });
343
+
344
+ if (error) {
345
+ throw new Error(error.message);
346
+ }
347
+ } catch (error) {
348
+ redirectWithStatus(
349
+ 'error',
350
+ error instanceof Error ? error.message : 'Failed to save advanced settings.'
351
+ );
352
+ }
353
+
354
+ revalidatePath(CORTEX_AI_SETTINGS_PATH);
355
+ redirectWithStatus('success', 'Advanced agent settings saved.');
356
+ }
357
+
358
+ export async function resetCortexAiAgentSettingsAction() {
359
+ if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
360
+ throw new Error('Sandbox environment cannot change settings in the database.');
361
+ }
362
+
363
+ try {
364
+ const supabase = await requireAdminSupabaseClient();
365
+ const { error } = await supabase
366
+ .from('site_settings')
367
+ .delete()
368
+ .eq('key', CORTEX_AI_AGENT_SETTINGS_KEY);
369
+
370
+ if (error) {
371
+ throw new Error(error.message);
372
+ }
373
+ } catch (error) {
374
+ redirectWithStatus(
375
+ 'error',
376
+ error instanceof Error ? error.message : 'Failed to reset advanced settings.'
377
+ );
378
+ }
379
+
380
+ revalidatePath(CORTEX_AI_SETTINGS_PATH);
381
+ redirectWithStatus('success', 'Advanced agent settings reset to defaults.');
382
+ }
383
+
165
384
  export async function saveCortexAiModelSelectionAction(formData: FormData) {
166
385
  if (process.env.NEXT_PUBLIC_IS_SANDBOX === 'true') {
167
386
  throw new Error('Sandbox environment cannot save model selection to the database.');
@@ -36,6 +36,7 @@ export default async function CortexAiSettingsPage({
36
36
  : {};
37
37
  const storedKeyUpdatedAt = formatDate(status.storedOpenRouterKeyUpdatedAt);
38
38
  const selectedModelUpdatedAt = formatDate(status.selectedModel?.updatedAt || null);
39
+ const stockKeysUpdatedAt = formatDate(status.stockKeysUpdatedAt);
39
40
  let compatibleModels: Awaited<ReturnType<typeof listCortexAiCompatibleOpenRouterModels>> = [];
40
41
  let modelCatalogError: string | null = null;
41
42
 
@@ -73,6 +74,16 @@ export default async function CortexAiSettingsPage({
73
74
  selectedModelUpdatedAt={selectedModelUpdatedAt}
74
75
  hasEncryptionKey={status.hasEncryptionKey}
75
76
  modelCatalogError={modelCatalogError}
77
+ activeStockProvider={status.activeStockProvider}
78
+ hasStoredPexelsKey={status.hasStoredPexelsKey}
79
+ maskedStoredPexelsKey={status.maskedStoredPexelsKey}
80
+ hasStoredUnsplashKey={status.hasStoredUnsplashKey}
81
+ maskedStoredUnsplashKey={status.maskedStoredUnsplashKey}
82
+ hasEnvPexelsKey={status.hasEnvPexelsKey}
83
+ hasEnvUnsplashKey={status.hasEnvUnsplashKey}
84
+ stockKeysUpdatedAt={stockKeysUpdatedAt}
85
+ unsplashAppName={status.unsplashAppName}
86
+ agentSettings={status.agentSettings}
76
87
  successMessage={params.success}
77
88
  errorMessage={params.error}
78
89
  />
@@ -17,6 +17,7 @@ async function getUserAndProfileData(userId: string): Promise<{
17
17
  authUser: AuthUser;
18
18
  profile: Profile | null;
19
19
  addresses: Awaited<ReturnType<typeof getDefaultUserAddresses>>;
20
+ isSoleAdmin: boolean;
20
21
  } | null> {
21
22
 
22
23
  // Fetch user from auth.users
@@ -64,7 +65,24 @@ async function getUserAndProfileData(userId: string): Promise<{
64
65
 
65
66
  const addresses = await getDefaultUserAddresses(userId, serviceSupabase as any);
66
67
 
67
- return { authUser: simplifiedAuthUser, profile: profileData as Profile | null, addresses };
68
+ // Is this user the only remaining Admin? If so, the edit form locks the role selector
69
+ // so the last admin can't demote themselves out of CMS access (the server action also
70
+ // guards this — the lock just prevents hitting that error).
71
+ let isSoleAdmin = false;
72
+ if ((profileData as Profile | null)?.role === 'ADMIN') {
73
+ const { count } = await serviceSupabase
74
+ .from('profiles')
75
+ .select('*', { count: 'exact', head: true })
76
+ .eq('role', 'ADMIN');
77
+ isSoleAdmin = count === 1;
78
+ }
79
+
80
+ return {
81
+ authUser: simplifiedAuthUser,
82
+ profile: profileData as Profile | null,
83
+ addresses,
84
+ isSoleAdmin,
85
+ };
68
86
  }
69
87
 
70
88
  export default async function EditUserPage(props: { params: Promise<{ id: string }> }) {
@@ -90,6 +108,7 @@ export default async function EditUserPage(props: { params: Promise<{ id: string
90
108
  userToEditProfile={userData.profile}
91
109
  userToEditAddresses={userData.addresses}
92
110
  formAction={updateUserActionWithId}
111
+ lockRole={userData.isSoleAdmin}
93
112
  />
94
113
  </div>
95
114
  );
@@ -57,6 +57,75 @@ function createServiceRoleClient() {
57
57
  });
58
58
  }
59
59
 
60
+ export async function createUser(formData: FormData) {
61
+ const supabase = createClient();
62
+ const adminCheck = await verifyAdmin(supabase);
63
+ if (!adminCheck.isAdmin) {
64
+ return { error: adminCheck.error || "Unauthorized" };
65
+ }
66
+
67
+ const email = (formData.get("email") as string | null)?.trim().toLowerCase() || "";
68
+ const password = (formData.get("password") as string | null) || "";
69
+ const fullName = (formData.get("full_name") as string | null)?.trim() || "";
70
+ const role = formData.get("role") as UserRole;
71
+ // Admin-created accounts are confirmed by default so the user can sign in
72
+ // immediately without an SMTP round-trip (mirrors completeSetup / auto-accept).
73
+ const emailConfirm = formData.get("email_confirm") !== "false";
74
+
75
+ if (!email) {
76
+ return { error: "Email is required." };
77
+ }
78
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
79
+ return { error: "Enter a valid email address." };
80
+ }
81
+ if (!password || password.length < 8) {
82
+ return { error: "Password must be at least 8 characters." };
83
+ }
84
+ if (!role || !['ADMIN', 'WRITER', 'USER'].includes(role)) {
85
+ return { error: "Invalid role specified." };
86
+ }
87
+
88
+ const adminSupabase = createServiceRoleClient();
89
+
90
+ const { data: created, error: createError } = await adminSupabase.auth.admin.createUser({
91
+ email,
92
+ password,
93
+ email_confirm: emailConfirm,
94
+ user_metadata: fullName ? { full_name: fullName } : {},
95
+ });
96
+
97
+ if (createError || !created?.user) {
98
+ if (createError && /already|registered|exists/i.test(createError.message)) {
99
+ return { error: "An account with this email already exists." };
100
+ }
101
+ return { error: `Failed to create user: ${createError?.message ?? 'unknown error'}` };
102
+ }
103
+
104
+ // The handle_new_user trigger inserts the profile row during createUser and assigns
105
+ // role USER (an admin already exists, so this account is never the first user). Apply
106
+ // the admin's chosen role and name explicitly afterward.
107
+ const { error: profileError } = await adminSupabase
108
+ .from("profiles")
109
+ .update({ role, full_name: fullName || null })
110
+ .eq("id", created.user.id);
111
+
112
+ revalidatePath("/cms/users");
113
+
114
+ if (profileError) {
115
+ // The account was created (trigger seeded role USER), but applying the chosen role
116
+ // failed. Land the admin on the edit screen — the recovery path — rather than
117
+ // stranding them on the create form, where a retry would hit "email already exists".
118
+ console.error("Error setting new user profile:", profileError);
119
+ redirect(
120
+ `/cms/users/${created.user.id}/edit?success=${encodeURIComponent(
121
+ "User created, but their role wasn't applied automatically — set it below and save.",
122
+ )}`,
123
+ );
124
+ }
125
+
126
+ redirect(`/cms/users/${created.user.id}/edit?success=User created successfully`);
127
+ }
128
+
60
129
  export async function updateUserProfile(userIdToUpdate: string, formData: FormData) {
61
130
  const supabase = createClient();
62
131
  const adminCheck = await verifyAdmin(supabase);
@@ -0,0 +1,217 @@
1
+ // app/cms/users/components/CreateUserForm.tsx
2
+ "use client";
3
+
4
+ import React, { useState, useTransition } from "react";
5
+ import { useRouter } from "next/navigation";
6
+ import { Button } from "@nextblock-cms/ui";
7
+ import { Input } from "@nextblock-cms/ui";
8
+ import { Label } from "@nextblock-cms/ui";
9
+ import { Checkbox } from "@nextblock-cms/ui";
10
+ import {
11
+ Select,
12
+ SelectContent,
13
+ SelectItem,
14
+ SelectTrigger,
15
+ SelectValue,
16
+ } from "@nextblock-cms/ui";
17
+ import { Alert, AlertTitle, AlertDescription, Spinner } from "@nextblock-cms/ui";
18
+ import { Eye, EyeOff, RefreshCw } from "lucide-react";
19
+ import type { Database } from "@nextblock-cms/db";
20
+ import { createUser } from "../actions";
21
+
22
+ type UserRole = Database["public"]["Enums"]["user_role"];
23
+
24
+ function generatePassword(length = 16): string {
25
+ const charset =
26
+ "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*";
27
+ const values = new Uint32Array(length);
28
+ crypto.getRandomValues(values);
29
+ let result = "";
30
+ for (let i = 0; i < length; i++) {
31
+ result += charset[values[i] % charset.length];
32
+ }
33
+ return result;
34
+ }
35
+
36
+ export default function CreateUserForm() {
37
+ const router = useRouter();
38
+ const [isPending, startTransition] = useTransition();
39
+
40
+ const [email, setEmail] = useState("");
41
+ const [password, setPassword] = useState("");
42
+ const [fullName, setFullName] = useState("");
43
+ const [role, setRole] = useState<UserRole>("USER");
44
+ const [emailConfirm, setEmailConfirm] = useState(true);
45
+ const [showPassword, setShowPassword] = useState(false);
46
+
47
+ // Only errors surface here — the action redirects on success.
48
+ const [formError, setFormError] = useState<string | null>(null);
49
+
50
+ const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
51
+ event.preventDefault();
52
+ setFormError(null);
53
+
54
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
55
+ setFormError("Enter a valid email address.");
56
+ return;
57
+ }
58
+ if (password.length < 8) {
59
+ setFormError("Password must be at least 8 characters.");
60
+ return;
61
+ }
62
+
63
+ const formData = new FormData();
64
+ formData.set("email", email);
65
+ formData.set("password", password);
66
+ formData.set("full_name", fullName);
67
+ formData.set("role", role);
68
+ formData.set("email_confirm", emailConfirm ? "true" : "false");
69
+
70
+ startTransition(async () => {
71
+ try {
72
+ const result = await createUser(formData);
73
+ // On success the action redirects; only errors return here.
74
+ if (result?.error) {
75
+ setFormError(result.error);
76
+ }
77
+ } catch {
78
+ setFormError("Something went wrong creating the user. Please try again.");
79
+ }
80
+ });
81
+ };
82
+
83
+ return (
84
+ <form onSubmit={handleSubmit} className="space-y-6">
85
+ {formError && (
86
+ <Alert variant="destructive">
87
+ <AlertTitle>Error</AlertTitle>
88
+ <AlertDescription>{formError}</AlertDescription>
89
+ </Alert>
90
+ )}
91
+
92
+ <div>
93
+ <Label htmlFor="email">Email</Label>
94
+ <Input
95
+ id="email"
96
+ name="email"
97
+ type="email"
98
+ value={email}
99
+ onChange={(e) => setEmail(e.target.value)}
100
+ required
101
+ autoComplete="off"
102
+ className="mt-1"
103
+ placeholder="user@example.com"
104
+ />
105
+ </div>
106
+
107
+ <div>
108
+ <Label htmlFor="full_name">Full Name</Label>
109
+ <Input
110
+ id="full_name"
111
+ name="full_name"
112
+ value={fullName}
113
+ onChange={(e) => setFullName(e.target.value)}
114
+ className="mt-1"
115
+ placeholder="Jane Doe"
116
+ />
117
+ <p className="text-xs text-muted-foreground mt-1">Optional. Can be edited later.</p>
118
+ </div>
119
+
120
+ <div>
121
+ <Label htmlFor="password">Password</Label>
122
+ <div className="flex gap-2 mt-1">
123
+ <div className="relative flex-1">
124
+ <Input
125
+ id="password"
126
+ name="password"
127
+ type={showPassword ? "text" : "password"}
128
+ value={password}
129
+ onChange={(e) => setPassword(e.target.value)}
130
+ required
131
+ minLength={8}
132
+ autoComplete="new-password"
133
+ className="pr-10"
134
+ placeholder="At least 8 characters"
135
+ />
136
+ <button
137
+ type="button"
138
+ onClick={() => setShowPassword((v) => !v)}
139
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
140
+ aria-label={showPassword ? "Hide password" : "Show password"}
141
+ >
142
+ {showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
143
+ </button>
144
+ </div>
145
+ <Button
146
+ type="button"
147
+ variant="outline"
148
+ onClick={() => {
149
+ setPassword(generatePassword());
150
+ setShowPassword(true);
151
+ }}
152
+ >
153
+ <RefreshCw className="mr-2 h-4 w-4" /> Generate
154
+ </Button>
155
+ </div>
156
+ <p className="text-xs text-muted-foreground mt-1">
157
+ Share this password with the user securely. They can change it later from their profile.
158
+ </p>
159
+ </div>
160
+
161
+ <div>
162
+ <Label htmlFor="role">Role</Label>
163
+ <Select value={role} onValueChange={(val: UserRole) => setRole(val)}>
164
+ <SelectTrigger id="role" className="mt-1">
165
+ <SelectValue placeholder="Select role" />
166
+ </SelectTrigger>
167
+ <SelectContent>
168
+ <SelectItem value="USER">User</SelectItem>
169
+ <SelectItem value="WRITER">Writer</SelectItem>
170
+ <SelectItem value="ADMIN">Admin</SelectItem>
171
+ </SelectContent>
172
+ </Select>
173
+ <p className="text-xs text-muted-foreground mt-1">
174
+ Writers and Admins can access the CMS. Users have public-site access only.
175
+ </p>
176
+ </div>
177
+
178
+ <div className="flex items-start space-x-2 pt-2">
179
+ <Checkbox
180
+ id="email_confirm"
181
+ checked={emailConfirm}
182
+ onCheckedChange={(checked) => setEmailConfirm(checked as boolean)}
183
+ className="mt-0.5"
184
+ />
185
+ <div>
186
+ <Label htmlFor="email_confirm" className="font-normal leading-none">
187
+ Mark email as confirmed
188
+ </Label>
189
+ <p className="text-xs text-muted-foreground mt-1">
190
+ The user can sign in immediately without a verification email. Uncheck only if your
191
+ project sends confirmation emails and you want the user to verify first.
192
+ </p>
193
+ </div>
194
+ </div>
195
+
196
+ <div className="flex justify-end space-x-3 pt-4">
197
+ <Button
198
+ type="button"
199
+ variant="outline"
200
+ onClick={() => router.push("/cms/users")}
201
+ disabled={isPending}
202
+ >
203
+ Cancel
204
+ </Button>
205
+ <Button type="submit" disabled={isPending}>
206
+ {isPending ? (
207
+ <>
208
+ <Spinner className="mr-2 h-4 w-4" /> Creating...
209
+ </>
210
+ ) : (
211
+ "Create User"
212
+ )}
213
+ </Button>
214
+ </div>
215
+ </form>
216
+ );
217
+ }
@@ -16,9 +16,11 @@ interface UserFormProps {
16
16
  shippingAddress: CustomerAddressInput | null;
17
17
  };
18
18
  formAction: (formData: FormData) => Promise<{ error?: string } | void>;
19
+ /** Lock the role selector — true when this user is the only remaining Admin. */
20
+ lockRole?: boolean;
19
21
  }
20
22
 
21
- export default function UserForm({ userToEditAuth, userToEditProfile, userToEditAddresses, formAction }: UserFormProps) {
23
+ export default function UserForm({ userToEditAuth, userToEditProfile, userToEditAddresses, formAction, lockRole }: UserFormProps) {
22
24
  const searchParams = useSearchParams();
23
25
  const successMsg = searchParams.get('success');
24
26
 
@@ -63,6 +65,7 @@ export default function UserForm({ userToEditAuth, userToEditProfile, userToEdit
63
65
  email={userToEditAuth.email}
64
66
  onAction={handleAdminSave}
65
67
  initialSuccessMessage={successMsg}
68
+ lockRole={lockRole}
66
69
  />
67
70
  </div>
68
71
  );