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.
- package/package.json +1 -1
- package/templates/nextblock-template/AGENTS.md +9 -0
- package/templates/nextblock-template/CLAUDE.md +1 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +96 -1
- package/templates/nextblock-template/app/api/mcp/route.ts +346 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/McpServerSettingsCard.tsx +584 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx +6 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/actions.ts +4 -20
- package/templates/nextblock-template/app/cms/settings/cortex-ai/mcp-actions.ts +205 -0
- package/templates/nextblock-template/app/cms/settings/cortex-ai/page.tsx +64 -1
- package/templates/nextblock-template/app/cms/settings/cortex-ai/require-admin.ts +34 -0
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +31 -1
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +151 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +5 -0
- package/templates/nextblock-template/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
|
@@ -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’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 → Connectors → 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’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
|
+
}
|
package/templates/nextblock-template/app/cms/settings/cortex-ai/StoredCortexAiSettingsClient.tsx
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
}
|