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.
- package/package.json +1 -1
- package/templates/nextblock-template/app/actions/interactions.ts +27 -4
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +287 -48
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +238 -209
- package/templates/nextblock-template/app/cms/blocks/components/BackgroundSelector.tsx +103 -8
- package/templates/nextblock-template/app/cms/blocks/components/BlockEditorArea.tsx +31 -2
- package/templates/nextblock-template/app/cms/blocks/components/ColumnEditor.tsx +37 -15
- package/templates/nextblock-template/app/cms/blocks/components/EditableBlock.tsx +26 -15
- package/templates/nextblock-template/app/cms/blocks/editors/ImageBlockEditor.tsx +123 -46
- package/templates/nextblock-template/app/cms/blocks/editors/SectionBlockEditor.tsx +8 -1
- package/templates/nextblock-template/app/cms/components/CortexGlobalAgentChat.tsx +62 -22
- package/templates/nextblock-template/app/cms/custom-blocks/components/BlockComposer.tsx +40 -2
- package/templates/nextblock-template/app/cms/interactions/EmailRecipientsInput.tsx +189 -0
- package/templates/nextblock-template/app/cms/interactions/InteractionsModerationClient.tsx +138 -71
- package/templates/nextblock-template/app/cms/media/import-external-image.ts +289 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +13 -10
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +14 -3
- package/templates/nextblock-template/app/cms/pages/actions.ts +59 -6
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +21 -11
- package/templates/nextblock-template/app/cms/posts/actions.ts +45 -0
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +11 -9
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +463 -227
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +220 -1
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +11 -0
- package/templates/nextblock-template/app/cms/users/[id]/edit/page.tsx +20 -1
- package/templates/nextblock-template/app/cms/users/actions.ts +69 -0
- package/templates/nextblock-template/app/cms/users/components/CreateUserForm.tsx +217 -0
- package/templates/nextblock-template/app/cms/users/components/UserForm.tsx +4 -1
- package/templates/nextblock-template/app/cms/users/new/page.tsx +44 -0
- package/templates/nextblock-template/app/cms/users/page.tsx +12 -3
- package/templates/nextblock-template/app/lib/homepage.ts +36 -0
- package/templates/nextblock-template/app/lib/sitemap-utils.ts +13 -6
- package/templates/nextblock-template/app/page.tsx +55 -12
- package/templates/nextblock-template/components/blocks/renderers/ImageBlockRenderer.tsx +56 -0
- package/templates/nextblock-template/components/blocks/renderers/SectionBlockRenderer.tsx +60 -30
- package/templates/nextblock-template/components/blocks/renderers/StockPhotoCredit.tsx +167 -0
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +94 -6
- package/templates/nextblock-template/docs/09-LIVE-DRAFT-MODE.md +7 -1
- package/templates/nextblock-template/lib/blocks/blockRegistry.ts +29 -3
- package/templates/nextblock-template/lib/search/server.ts +11 -1
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +82 -72
- package/templates/nextblock-template/next-env.d.ts +1 -1
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/proxy.ts +5 -0
- 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 [
|
|
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
|
-
|
|
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
|
);
|