create-nextblock 0.14.5 → 0.14.6

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.
@@ -0,0 +1,584 @@
1
+ 'use client';
2
+
3
+ import React, { useMemo, useState, useTransition } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import {
6
+ Alert,
7
+ AlertDescription,
8
+ AlertTitle,
9
+ Badge,
10
+ Button,
11
+ Card,
12
+ CardContent,
13
+ CardDescription,
14
+ CardHeader,
15
+ CardTitle,
16
+ Checkbox,
17
+ Input,
18
+ Label,
19
+ } from '@nextblock-cms/ui';
20
+ import {
21
+ AlertTriangle,
22
+ Check,
23
+ Copy,
24
+ KeyRound,
25
+ Plug,
26
+ Plus,
27
+ Trash2,
28
+ } from 'lucide-react';
29
+
30
+ import {
31
+ createMcpAccessTokenAction,
32
+ revokeMcpAccessTokenAction,
33
+ saveMcpSettingsAction,
34
+ type McpAccessTokenSummary,
35
+ } from './mcp-actions';
36
+
37
+ type McpScope = 'read' | 'write';
38
+
39
+ type McpServerSettingsCardProps = {
40
+ allowLocalhostWithoutToken: boolean;
41
+ enabled: boolean;
42
+ /** Loopback URL for a locally-running dev server. */
43
+ localMcpUrl: string;
44
+ /** The publicly reachable endpoint, derived from NEXT_PUBLIC_URL. */
45
+ mcpUrl: string;
46
+ tokens: McpAccessTokenSummary[];
47
+ };
48
+
49
+ const TOKEN_PLACEHOLDER = 'YOUR_TOKEN';
50
+
51
+ function CopyButton({ label = 'Copy', value }: { label?: string; value: string }) {
52
+ const [copied, setCopied] = useState(false);
53
+
54
+ return (
55
+ <Button
56
+ type="button"
57
+ variant="ghost"
58
+ size="sm"
59
+ className="h-7 shrink-0"
60
+ onClick={() => {
61
+ void navigator.clipboard.writeText(value).then(() => {
62
+ setCopied(true);
63
+ setTimeout(() => setCopied(false), 1800);
64
+ });
65
+ }}
66
+ >
67
+ {copied ? <Check className="mr-1.5 h-3.5 w-3.5" /> : <Copy className="mr-1.5 h-3.5 w-3.5" />}
68
+ {copied ? 'Copied' : label}
69
+ </Button>
70
+ );
71
+ }
72
+
73
+ function Snippet({ code, title }: { code: string; title: string }) {
74
+ return (
75
+ <div className="space-y-1.5">
76
+ <div className="flex items-center justify-between gap-2">
77
+ <p className="text-xs font-medium">{title}</p>
78
+ <CopyButton value={code} />
79
+ </div>
80
+ <pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-[11px] leading-relaxed">
81
+ <code>{code}</code>
82
+ </pre>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ function formatDate(value: string | null) {
88
+ if (!value) return null;
89
+ return new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeStyle: 'short' }).format(
90
+ new Date(value)
91
+ );
92
+ }
93
+
94
+ export function McpServerSettingsCard({
95
+ allowLocalhostWithoutToken,
96
+ enabled,
97
+ localMcpUrl,
98
+ mcpUrl,
99
+ tokens,
100
+ }: McpServerSettingsCardProps) {
101
+ const router = useRouter();
102
+ const [isPending, startTransition] = useTransition();
103
+
104
+ const [isEnabled, setIsEnabled] = useState(enabled);
105
+ const [allowLocalhost, setAllowLocalhost] = useState(allowLocalhostWithoutToken);
106
+ const [tokenName, setTokenName] = useState('');
107
+ const [allowWrites, setAllowWrites] = useState(true);
108
+ const [expiresInDays, setExpiresInDays] = useState('');
109
+ const [mintedToken, setMintedToken] = useState<string | null>(null);
110
+ const [error, setError] = useState<string | null>(null);
111
+ const [activeClient, setActiveClient] = useState<'claude-code' | 'claude-desktop' | 'cursor' | 'vscode'>(
112
+ 'claude-code'
113
+ );
114
+ const [useLocalUrl, setUseLocalUrl] = useState(false);
115
+
116
+ const url = useLocalUrl ? localMcpUrl : mcpUrl;
117
+
118
+ // Once a token has been minted in this session, bake it into the snippets so the
119
+ // admin can copy a config that actually works instead of hand-substituting.
120
+ const tokenForSnippet = mintedToken ?? TOKEN_PLACEHOLDER;
121
+
122
+ /**
123
+ * Whether the generated snippets should carry an Authorization header at all.
124
+ *
125
+ * A loopback connection covered by localhost trust needs none — and must not send
126
+ * one: the route verifies any bearer it receives and rejects an invalid value
127
+ * outright rather than falling back to localhost trust, so pasting the literal
128
+ * placeholder would 401 instead of silently working. Once a real token exists we
129
+ * include it either way, since it works on both origins.
130
+ */
131
+ const usesLocalhostTrust = useLocalUrl && allowLocalhost && !mintedToken;
132
+
133
+ const snippets = useMemo(() => {
134
+ const authHeader = usesLocalhostTrust
135
+ ? undefined
136
+ : { Authorization: `Bearer ${tokenForSnippet}` };
137
+
138
+ const claudeCode = JSON.stringify(
139
+ {
140
+ mcpServers: {
141
+ nextblock: {
142
+ ...(authHeader ? { headers: authHeader } : {}),
143
+ type: 'http',
144
+ url,
145
+ },
146
+ },
147
+ },
148
+ null,
149
+ 2
150
+ );
151
+
152
+ const cursor = JSON.stringify(
153
+ {
154
+ mcpServers: {
155
+ nextblock: {
156
+ ...(authHeader ? { headers: authHeader } : {}),
157
+ url,
158
+ },
159
+ },
160
+ },
161
+ null,
162
+ 2
163
+ );
164
+
165
+ const vscode = JSON.stringify(
166
+ usesLocalhostTrust
167
+ ? { servers: { nextblock: { type: 'http', url } } }
168
+ : {
169
+ inputs: [
170
+ {
171
+ description: 'NextBlock MCP access token',
172
+ id: 'nextblockToken',
173
+ password: true,
174
+ type: 'promptString',
175
+ },
176
+ ],
177
+ servers: {
178
+ nextblock: {
179
+ headers: { Authorization: 'Bearer ${input:nextblockToken}' },
180
+ type: 'http',
181
+ url,
182
+ },
183
+ },
184
+ },
185
+ null,
186
+ 2
187
+ );
188
+
189
+ const claudeDesktop = JSON.stringify(
190
+ {
191
+ mcpServers: {
192
+ nextblock: {
193
+ args: [
194
+ '-y',
195
+ 'mcp-remote',
196
+ url,
197
+ ...(usesLocalhostTrust
198
+ ? []
199
+ : ['--header', `Authorization: Bearer ${tokenForSnippet}`]),
200
+ ],
201
+ command: 'npx',
202
+ },
203
+ },
204
+ },
205
+ null,
206
+ 2
207
+ );
208
+
209
+ return { claudeCode, claudeDesktop, cursor, vscode };
210
+ }, [tokenForSnippet, url, usesLocalhostTrust]);
211
+
212
+ const claudeCodeCli = usesLocalhostTrust
213
+ ? `claude mcp add --transport http nextblock ${url}`
214
+ : `claude mcp add --transport http nextblock ${url} --header "Authorization: Bearer ${tokenForSnippet}"`;
215
+
216
+ function persistSettings(next: { allowLocalhostWithoutToken: boolean; enabled: boolean }) {
217
+ setError(null);
218
+ startTransition(async () => {
219
+ const result = await saveMcpSettingsAction(next);
220
+
221
+ if (!result.success) {
222
+ setError(result.error ?? 'Failed to save MCP settings.');
223
+ // Snap the optimistic toggle back so the UI never claims a state the DB rejected.
224
+ setIsEnabled(enabled);
225
+ setAllowLocalhost(allowLocalhostWithoutToken);
226
+ return;
227
+ }
228
+
229
+ router.refresh();
230
+ });
231
+ }
232
+
233
+ function handleCreateToken() {
234
+ setError(null);
235
+ setMintedToken(null);
236
+
237
+ startTransition(async () => {
238
+ const parsedDays = Number.parseInt(expiresInDays, 10);
239
+ const result = await createMcpAccessTokenAction({
240
+ expiresInDays: Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : null,
241
+ name: tokenName,
242
+ scopes: allowWrites ? (['read', 'write'] as McpScope[]) : (['read'] as McpScope[]),
243
+ });
244
+
245
+ if (!result.success || !result.token) {
246
+ setError(result.error ?? 'Failed to create the token.');
247
+ return;
248
+ }
249
+
250
+ setMintedToken(result.token);
251
+ setTokenName('');
252
+ setExpiresInDays('');
253
+ router.refresh();
254
+ });
255
+ }
256
+
257
+ function handleRevoke(id: string) {
258
+ setError(null);
259
+ startTransition(async () => {
260
+ const result = await revokeMcpAccessTokenAction({ id });
261
+
262
+ if (!result.success) {
263
+ setError(result.error ?? 'Failed to revoke the token.');
264
+ return;
265
+ }
266
+
267
+ router.refresh();
268
+ });
269
+ }
270
+
271
+ return (
272
+ <Card>
273
+ <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
274
+ <div>
275
+ <CardTitle className="flex flex-wrap items-center gap-2 text-base">
276
+ <Plug className="h-4 w-4" />
277
+ MCP server access
278
+ <Badge variant={isEnabled ? 'default' : 'outline'} className="ml-0.5 font-normal">
279
+ {isEnabled ? 'Enabled' : 'Disabled'}
280
+ </Badge>
281
+ {isEnabled && tokens.length > 0 && (
282
+ <Badge variant="secondary" className="font-normal">
283
+ {tokens.length} active {tokens.length === 1 ? 'token' : 'tokens'}
284
+ </Badge>
285
+ )}
286
+ </CardTitle>
287
+ <CardDescription className="text-xs">
288
+ Expose this CMS to external AI clients over the Model Context Protocol. Claude Code,
289
+ Claude Desktop, Cursor, and VS Code get the same typed Cortex tools the dashboard agent
290
+ uses — building pages, editing blocks, querying analytics — from inside your editor.
291
+ </CardDescription>
292
+ </div>
293
+ </CardHeader>
294
+
295
+ <CardContent className="space-y-4 pt-0">
296
+ {error && (
297
+ <Alert variant="destructive">
298
+ <AlertTriangle className="h-4 w-4" />
299
+ <AlertTitle>Could not save</AlertTitle>
300
+ <AlertDescription className="text-xs">{error}</AlertDescription>
301
+ </Alert>
302
+ )}
303
+
304
+ {/* Toggles */}
305
+ <div className="space-y-3 rounded-md border bg-muted/20 p-3">
306
+ <div className="flex items-start gap-2.5">
307
+ <Checkbox
308
+ id="mcp_enabled"
309
+ checked={isEnabled}
310
+ disabled={isPending}
311
+ onCheckedChange={(checked) => {
312
+ const next = checked === true;
313
+ setIsEnabled(next);
314
+ persistSettings({ allowLocalhostWithoutToken: allowLocalhost, enabled: next });
315
+ }}
316
+ />
317
+ <div className="space-y-0.5">
318
+ <Label htmlFor="mcp_enabled" className="text-sm font-medium">
319
+ Enable the MCP server
320
+ </Label>
321
+ <p className="text-xs text-muted-foreground">
322
+ While off, <span className="font-mono">/api/mcp</span> rejects every request. Off by
323
+ default because this is a remote write surface onto your live content.
324
+ </p>
325
+ </div>
326
+ </div>
327
+
328
+ <div className="flex items-start gap-2.5">
329
+ <Checkbox
330
+ id="mcp_allow_localhost"
331
+ checked={allowLocalhost}
332
+ disabled={isPending || !isEnabled}
333
+ onCheckedChange={(checked) => {
334
+ const next = checked === true;
335
+ setAllowLocalhost(next);
336
+ persistSettings({ allowLocalhostWithoutToken: next, enabled: isEnabled });
337
+ }}
338
+ />
339
+ <div className="space-y-0.5">
340
+ <Label htmlFor="mcp_allow_localhost" className="text-sm font-medium">
341
+ Trust localhost without a token
342
+ </Label>
343
+ <p className="text-xs text-muted-foreground">
344
+ Convenience for local development only — it is ignored whenever{' '}
345
+ <span className="font-mono">NODE_ENV=production</span>, so your deployed site always
346
+ requires a token.
347
+ </p>
348
+ </div>
349
+ </div>
350
+ </div>
351
+
352
+ {/* Endpoint */}
353
+ <div className="space-y-1.5">
354
+ <Label className="text-xs">Endpoint URL</Label>
355
+ <div className="flex items-center gap-2">
356
+ <Input readOnly value={url} className="font-mono text-xs" />
357
+ <CopyButton value={url} />
358
+ </div>
359
+ <div className="flex gap-1.5 pt-0.5">
360
+ <Button
361
+ type="button"
362
+ size="sm"
363
+ variant={useLocalUrl ? 'ghost' : 'secondary'}
364
+ className="h-6 text-[11px]"
365
+ onClick={() => setUseLocalUrl(false)}
366
+ >
367
+ Live site
368
+ </Button>
369
+ <Button
370
+ type="button"
371
+ size="sm"
372
+ variant={useLocalUrl ? 'secondary' : 'ghost'}
373
+ className="h-6 text-[11px]"
374
+ onClick={() => setUseLocalUrl(true)}
375
+ >
376
+ Localhost
377
+ </Button>
378
+ </div>
379
+ </div>
380
+
381
+ {/* Token minting */}
382
+ <div className="space-y-3 rounded-md border p-3">
383
+ <div className="space-y-0.5">
384
+ <p className="text-sm font-medium">Access tokens</p>
385
+ <p className="text-xs text-muted-foreground">
386
+ Only the token&rsquo;s hash is stored, so it is shown once and cannot be recovered
387
+ afterwards. A read-only token cannot even list the mutating tools.
388
+ </p>
389
+ </div>
390
+
391
+ <div className="flex flex-wrap items-end gap-2">
392
+ <div className="min-w-[180px] flex-1 space-y-1.5">
393
+ <Label htmlFor="mcp_token_name" className="text-xs">
394
+ Name
395
+ </Label>
396
+ <Input
397
+ id="mcp_token_name"
398
+ value={tokenName}
399
+ maxLength={80}
400
+ placeholder="My laptop — Claude Code"
401
+ onChange={(event) => setTokenName(event.target.value)}
402
+ />
403
+ </div>
404
+ <div className="w-28 space-y-1.5">
405
+ <Label htmlFor="mcp_token_expiry" className="text-xs">
406
+ Expires (days)
407
+ </Label>
408
+ <Input
409
+ id="mcp_token_expiry"
410
+ type="number"
411
+ min={1}
412
+ max={3650}
413
+ value={expiresInDays}
414
+ placeholder="Never"
415
+ onChange={(event) => setExpiresInDays(event.target.value)}
416
+ />
417
+ </div>
418
+ <div className="flex h-9 items-center gap-2">
419
+ <Checkbox
420
+ id="mcp_token_write"
421
+ checked={allowWrites}
422
+ onCheckedChange={(checked) => setAllowWrites(checked === true)}
423
+ />
424
+ <Label htmlFor="mcp_token_write" className="text-xs">
425
+ Allow writes
426
+ </Label>
427
+ </div>
428
+ <Button
429
+ type="button"
430
+ size="sm"
431
+ disabled={isPending || !tokenName.trim()}
432
+ onClick={handleCreateToken}
433
+ >
434
+ <Plus className="mr-1.5 h-3.5 w-3.5" />
435
+ Create
436
+ </Button>
437
+ </div>
438
+
439
+ {mintedToken && (
440
+ <Alert>
441
+ <KeyRound className="h-4 w-4" />
442
+ <AlertTitle>Copy this token now</AlertTitle>
443
+ <AlertDescription className="space-y-2">
444
+ <p className="text-xs">
445
+ This is the only time it will be shown. The snippets below already include it.
446
+ </p>
447
+ <div className="flex items-center gap-2">
448
+ <Input readOnly value={mintedToken} className="font-mono text-xs" />
449
+ <CopyButton value={mintedToken} />
450
+ </div>
451
+ </AlertDescription>
452
+ </Alert>
453
+ )}
454
+
455
+ {tokens.length > 0 && (
456
+ <div className="divide-y rounded-md border">
457
+ {tokens.map((token) => {
458
+ const lastUsed = formatDate(token.lastUsedAt);
459
+ const expires = formatDate(token.expiresAt);
460
+
461
+ return (
462
+ <div key={token.id} className="flex items-center justify-between gap-2 p-2.5">
463
+ <div className="min-w-0 space-y-0.5">
464
+ <div className="flex flex-wrap items-center gap-1.5">
465
+ <span className="truncate text-sm font-medium">{token.name}</span>
466
+ <Badge
467
+ variant={token.scopes.includes('write') ? 'secondary' : 'outline'}
468
+ className="font-normal"
469
+ >
470
+ {token.scopes.includes('write') ? 'read + write' : 'read only'}
471
+ </Badge>
472
+ </div>
473
+ <p className="text-[11px] text-muted-foreground">
474
+ <span className="font-mono">{token.tokenPrefix}…</span>
475
+ {' · '}
476
+ {lastUsed ? `last used ${lastUsed}` : 'never used'}
477
+ {expires ? ` · expires ${expires}` : ''}
478
+ </p>
479
+ </div>
480
+ <Button
481
+ type="button"
482
+ variant="ghost"
483
+ size="sm"
484
+ disabled={isPending}
485
+ className="h-7 shrink-0 text-destructive hover:text-destructive"
486
+ onClick={() => handleRevoke(token.id)}
487
+ >
488
+ <Trash2 className="mr-1.5 h-3.5 w-3.5" />
489
+ Revoke
490
+ </Button>
491
+ </div>
492
+ );
493
+ })}
494
+ </div>
495
+ )}
496
+ </div>
497
+
498
+ {/* Client configuration */}
499
+ <div className="space-y-3 rounded-md border p-3">
500
+ <div className="space-y-0.5">
501
+ <p className="text-sm font-medium">Connect a client</p>
502
+ <p className="text-xs text-muted-foreground">
503
+ {usesLocalhostTrust
504
+ ? 'No token needed: localhost trust covers this connection while the dev server runs. These snippets deliberately send no Authorization header — an invalid one would be rejected rather than falling back to localhost trust.'
505
+ : mintedToken
506
+ ? 'These snippets include the token you just created.'
507
+ : `Create a token above and these snippets will fill it in; otherwise replace ${TOKEN_PLACEHOLDER}.`}
508
+ </p>
509
+ </div>
510
+
511
+ <div className="flex flex-wrap gap-1.5">
512
+ {(
513
+ [
514
+ ['claude-code', 'Claude Code'],
515
+ ['claude-desktop', 'Claude Desktop'],
516
+ ['cursor', 'Cursor'],
517
+ ['vscode', 'VS Code'],
518
+ ] as const
519
+ ).map(([key, label]) => (
520
+ <Button
521
+ key={key}
522
+ type="button"
523
+ size="sm"
524
+ variant={activeClient === key ? 'secondary' : 'ghost'}
525
+ className="h-7 text-xs"
526
+ onClick={() => setActiveClient(key)}
527
+ >
528
+ {label}
529
+ </Button>
530
+ ))}
531
+ </div>
532
+
533
+ {activeClient === 'claude-code' && (
534
+ <div className="space-y-3">
535
+ <Snippet code={claudeCodeCli} title="One-line CLI setup" />
536
+ <Snippet
537
+ code={snippets.claudeCode}
538
+ title="…or add to .mcp.json in your project root"
539
+ />
540
+ <p className="text-[11px] text-muted-foreground">
541
+ The <span className="font-mono">type</span> field is required — Claude Code skips a
542
+ server entry that has a <span className="font-mono">url</span> but no{' '}
543
+ <span className="font-mono">type</span>.
544
+ </p>
545
+ </div>
546
+ )}
547
+
548
+ {activeClient === 'claude-desktop' && (
549
+ <div className="space-y-3">
550
+ <Alert>
551
+ <AlertTriangle className="h-4 w-4" />
552
+ <AlertTitle>Two options, and the easy one has a catch</AlertTitle>
553
+ <AlertDescription className="text-xs">
554
+ Settings &rarr; Connectors &rarr; Add custom connector accepts{' '}
555
+ <span className="font-mono">{url}</span> directly (type{' '}
556
+ <span className="font-mono">Bearer YOUR_TOKEN</span>, including the space, in the
557
+ auth field) — but custom connectors dial out from Anthropic&rsquo;s cloud, so a
558
+ localhost or firewalled site will not connect that way. Use the config below
559
+ instead in that case; it bridges over stdio from your own machine.
560
+ </AlertDescription>
561
+ </Alert>
562
+ <Snippet code={snippets.claudeDesktop} title="claude_desktop_config.json" />
563
+ </div>
564
+ )}
565
+
566
+ {activeClient === 'cursor' && (
567
+ <Snippet code={snippets.cursor} title=".cursor/mcp.json" />
568
+ )}
569
+
570
+ {activeClient === 'vscode' && (
571
+ <div className="space-y-3">
572
+ <Snippet code={snippets.vscode} title=".vscode/mcp.json" />
573
+ <p className="text-[11px] text-muted-foreground">
574
+ VS Code uses <span className="font-mono">servers</span> at the top level, not{' '}
575
+ <span className="font-mono">mcpServers</span>, and prompts for the token rather than
576
+ storing it in the file.
577
+ </p>
578
+ </div>
579
+ )}
580
+ </div>
581
+ </CardContent>
582
+ </Card>
583
+ );
584
+ }
@@ -72,6 +72,8 @@ type StoredCortexAiSettingsClientProps = {
72
72
  stockKeysUpdatedAt: string | null;
73
73
  unsplashAppName: string | null;
74
74
  agentSettings: CortexAiAgentSettings;
75
+ /** Slot for server-rendered cards (currently the MCP server access card). */
76
+ children?: React.ReactNode;
75
77
  successMessage?: string;
76
78
  errorMessage?: string;
77
79
  };
@@ -142,6 +144,7 @@ export function StoredCortexAiSettingsClient({
142
144
  hasEnvUnsplashKey,
143
145
  unsplashAppName,
144
146
  agentSettings,
147
+ children,
145
148
  successMessage,
146
149
  errorMessage,
147
150
  }: StoredCortexAiSettingsClientProps) {
@@ -496,6 +499,9 @@ export function StoredCortexAiSettingsClient({
496
499
  </CardContent>
497
500
  </Card>
498
501
 
502
+ {/* MCP server access — rendered by the server page so it can read token state. */}
503
+ {children}
504
+
499
505
  {/* Advanced settings (collapsed by default) */}
500
506
  <div>
501
507
  <button
@@ -3,7 +3,9 @@
3
3
  import { revalidatePath } from 'next/cache';
4
4
  import { redirect } from 'next/navigation';
5
5
 
6
- import { createClient, verifyPackageOnline } from '@nextblock-cms/db/server';
6
+ import { verifyPackageOnline } from '@nextblock-cms/db/server';
7
+
8
+ import { requireAdminSupabaseClient as requireAdminAccess } from './require-admin';
7
9
 
8
10
  import {
9
11
  CORTEX_AI_AGENT_SETTINGS_DEFAULTS,
@@ -65,25 +67,7 @@ function redirectWithStatus(status: 'success' | 'error', message: string): never
65
67
  }
66
68
 
67
69
  async function requireAdminSupabaseClient() {
68
- const supabase = createClient();
69
- const {
70
- data: { user },
71
- error: userError,
72
- } = await supabase.auth.getUser();
73
-
74
- if (userError || !user) {
75
- throw new Error('You must be logged in to manage Cortex AI settings.');
76
- }
77
-
78
- const { data: profile, error: profileError } = await supabase
79
- .from('profiles')
80
- .select('role')
81
- .eq('id', user.id)
82
- .single();
83
-
84
- if (profileError || !profile || profile.role !== 'ADMIN') {
85
- throw new Error('You do not have permission to manage Cortex AI settings.');
86
- }
70
+ const { supabase } = await requireAdminAccess();
87
71
 
88
72
  return supabase;
89
73
  }