create-nextblock 0.14.4 → 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/[slug]/page.tsx +7 -2
- package/templates/nextblock-template/app/[slug]/page.utils.ts +8 -3
- package/templates/nextblock-template/app/actions/postActions.ts +3 -0
- package/templates/nextblock-template/app/actions/visibilityActions.ts +210 -0
- package/templates/nextblock-template/app/actions/visualEditingActions.test.ts +83 -3
- package/templates/nextblock-template/app/actions/visualEditingActions.ts +34 -14
- package/templates/nextblock-template/app/api/ai/global-agent/route.ts +45 -0
- package/templates/nextblock-template/app/api/cron/reset-sandbox/sandboxResetSql.ts +457 -1
- package/templates/nextblock-template/app/api/mcp/route.ts +346 -0
- package/templates/nextblock-template/app/api/view/route.ts +114 -0
- package/templates/nextblock-template/app/article/[slug]/page.utils.ts +1 -2
- package/templates/nextblock-template/app/cms/components/DraftStatusActions.tsx +10 -0
- package/templates/nextblock-template/app/cms/components/VisibilityBadge.tsx +62 -0
- package/templates/nextblock-template/app/cms/components/VisibilityControl.tsx +528 -0
- package/templates/nextblock-template/app/cms/pages/[id]/edit/EditPageClient.tsx +33 -17
- package/templates/nextblock-template/app/cms/pages/[id]/edit/page.tsx +19 -7
- package/templates/nextblock-template/app/cms/pages/actions.ts +17 -10
- package/templates/nextblock-template/app/cms/pages/components/PageForm.tsx +7 -29
- package/templates/nextblock-template/app/cms/pages/page.tsx +6 -19
- package/templates/nextblock-template/app/cms/posts/[id]/edit/page.tsx +42 -18
- package/templates/nextblock-template/app/cms/posts/actions.ts +16 -27
- package/templates/nextblock-template/app/cms/posts/components/PostForm.tsx +3 -60
- package/templates/nextblock-template/app/cms/posts/page.tsx +6 -13
- package/templates/nextblock-template/app/cms/products/[id]/edit/page.tsx +63 -9
- package/templates/nextblock-template/app/cms/revisions/RevisionHistoryButton.tsx +66 -32
- package/templates/nextblock-template/app/cms/revisions/actions.ts +332 -285
- package/templates/nextblock-template/app/cms/revisions/service.test.ts +498 -0
- package/templates/nextblock-template/app/cms/revisions/service.ts +549 -471
- package/templates/nextblock-template/app/cms/revisions/utils.ts +304 -132
- 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/app/lib/sitemap-utils.ts +6 -4
- package/templates/nextblock-template/app/lib/ucp/server.ts +4 -1
- package/templates/nextblock-template/app/page.tsx +6 -3
- package/templates/nextblock-template/app/product/[slug]/page.tsx +27 -3
- package/templates/nextblock-template/components/visual-editing/NextblockVisualEditing.tsx +4 -1
- package/templates/nextblock-template/docs/04-DATABASE-AND-AUTH.md +38 -3
- package/templates/nextblock-template/docs/08-NEXTBLOCK-CORTEX-AI-ARCHITECTURE.md +151 -0
- package/templates/nextblock-template/lib/cms-transfer/server.ts +13 -0
- package/templates/nextblock-template/lib/full-backup/server.ts +1 -0
- package/templates/nextblock-template/lib/publishing/viewUrl.ts +26 -0
- package/templates/nextblock-template/lib/search/server.ts +3 -0
- package/templates/nextblock-template/lib/setup/migrations-bundle.ts +15 -0
- package/templates/nextblock-template/lib/visual-editing/mutations.ts +4 -1
- package/templates/nextblock-template/lib/visual-editing/product-drafts.ts +46 -1
- package/templates/nextblock-template/next-env.d.ts +2 -2
- package/templates/nextblock-template/package.json +1 -1
- package/templates/nextblock-template/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { revalidatePath } from 'next/cache';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createClient,
|
|
5
|
+
getServiceRoleSupabaseClient,
|
|
6
|
+
verifyPackageOnline,
|
|
7
|
+
} from '@nextblock-cms/db/server';
|
|
8
|
+
import {
|
|
9
|
+
CORTEX_AI_PACKAGE_ID,
|
|
10
|
+
handleCortexMcpMessage,
|
|
11
|
+
isLocalhostHost,
|
|
12
|
+
parseBearerToken,
|
|
13
|
+
resolveCortexAiMcpSettings,
|
|
14
|
+
shouldTrustLocalMcpRequest,
|
|
15
|
+
touchCortexAiMcpToken,
|
|
16
|
+
verifyCortexAiMcpToken,
|
|
17
|
+
type CortexAiMcpScope,
|
|
18
|
+
type CortexMcpToolContext,
|
|
19
|
+
type JsonRpcMessage,
|
|
20
|
+
} from '@nextblock-cms/cortex';
|
|
21
|
+
|
|
22
|
+
import { validateBlockContent } from '../../../lib/blocks/blockRegistry';
|
|
23
|
+
import { importExternalImageToMedia } from '../../cms/media/import-external-image';
|
|
24
|
+
import { captureRevisionBaseline, commitRevisionFromBaseline } from '../../cms/revisions/service';
|
|
25
|
+
import type { AnyFullContent } from '../../cms/revisions/utils';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Model Context Protocol server endpoint.
|
|
29
|
+
*
|
|
30
|
+
* Exposes the Cortex AI tool registry over MCP Streamable HTTP so external clients
|
|
31
|
+
* (Claude Code, Claude Desktop, Cursor, VS Code) can operate this CMS with the same
|
|
32
|
+
* typed, validated tools the in-app dashboard agent uses. The protocol itself lives
|
|
33
|
+
* in `@nextblock-cms/cortex` (`mcp-server.ts`); this file is the HTTP shim plus auth.
|
|
34
|
+
*
|
|
35
|
+
* Node runtime, not Edge: the tool executors reach `node:crypto`, `sharp` (via the
|
|
36
|
+
* media importer) and the service-role Supabase client.
|
|
37
|
+
*/
|
|
38
|
+
export const runtime = 'nodejs';
|
|
39
|
+
export const dynamic = 'force-dynamic';
|
|
40
|
+
|
|
41
|
+
const SERVER_VERSION = '1.0.0';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Confirmation is skipped for MCP callers, deliberately.
|
|
45
|
+
*
|
|
46
|
+
* The in-app agent's two-phase confirm works by matching a phrase in the user's *next
|
|
47
|
+
* chat message*, which has no analogue in MCP — the model calls a tool and gets a
|
|
48
|
+
* result, with no channel to carry a human phrase back. Every MCP host already gates
|
|
49
|
+
* tool calls behind its own approval UI, so the confirmation would be a second prompt
|
|
50
|
+
* the protocol cannot satisfy, and leaving it on would simply make every mutating
|
|
51
|
+
* tool return a preview forever. The real control for MCP is the token scope: a
|
|
52
|
+
* read-only token never sees a mutating tool at all.
|
|
53
|
+
*/
|
|
54
|
+
const MCP_SKIP_CONFIRMATION = true;
|
|
55
|
+
|
|
56
|
+
type McpAuth = {
|
|
57
|
+
actorUserId: string | null;
|
|
58
|
+
scopes: CortexAiMcpScope[];
|
|
59
|
+
source: 'admin-session' | 'localhost' | 'token';
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
async function importExternalImageForMcp(input: {
|
|
63
|
+
url: string;
|
|
64
|
+
altText?: string;
|
|
65
|
+
}): Promise<{ id: string } | { error: string }> {
|
|
66
|
+
const result = await importExternalImageToMedia({ altText: input.altText, url: input.url });
|
|
67
|
+
|
|
68
|
+
if ('error' in result) {
|
|
69
|
+
return { error: result.error };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { id: result.media.id };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Mirrors the global-agent route so MCP writes land in Revision History like any other edit. */
|
|
76
|
+
function createMcpRevisionRecorder(authorId: string | null) {
|
|
77
|
+
return async function recordRevision(input: {
|
|
78
|
+
baseline?: unknown;
|
|
79
|
+
contentType: 'page' | 'post' | 'product';
|
|
80
|
+
entityId: number | string;
|
|
81
|
+
phase: 'capture' | 'commit';
|
|
82
|
+
}): Promise<unknown> {
|
|
83
|
+
if (input.phase === 'capture') {
|
|
84
|
+
return captureRevisionBaseline(input.contentType, input.entityId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = await commitRevisionFromBaseline(
|
|
88
|
+
input.contentType,
|
|
89
|
+
input.entityId,
|
|
90
|
+
authorId,
|
|
91
|
+
(input.baseline ?? null) as AnyFullContent | null
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
if ('error' in result) {
|
|
95
|
+
console.error('Cortex AI MCP: revision not recorded —', result.error);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return undefined;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reject cross-origin browser calls (DNS-rebinding defence, required by the spec).
|
|
104
|
+
*
|
|
105
|
+
* Only enforced when an `Origin` header is present: native MCP clients are not
|
|
106
|
+
* browsers and send none, so requiring one would lock out every real caller.
|
|
107
|
+
*/
|
|
108
|
+
function isOriginAllowed(request: Request): boolean {
|
|
109
|
+
const origin = request.headers.get('origin');
|
|
110
|
+
|
|
111
|
+
if (!origin) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let originHost: string;
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
originHost = new URL(origin).host;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (isLocalhostHost(originHost)) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const host = request.headers.get('host');
|
|
128
|
+
|
|
129
|
+
if (host && originHost.toLowerCase() === host.toLowerCase()) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const configuredUrl = process.env.NEXT_PUBLIC_URL;
|
|
134
|
+
|
|
135
|
+
if (configuredUrl) {
|
|
136
|
+
try {
|
|
137
|
+
return new URL(configuredUrl).host.toLowerCase() === originHost.toLowerCase();
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Establish who is calling.
|
|
148
|
+
*
|
|
149
|
+
* Three accepted paths, in priority order:
|
|
150
|
+
* 1. A bearer token from `mcp_access_tokens` — the path every external client uses.
|
|
151
|
+
* 2. An authenticated ADMIN cookie session — lets the dashboard's own "Test
|
|
152
|
+
* connection" button reach the endpoint without minting a token first.
|
|
153
|
+
* 3. Loopback in development, when the operator has left that setting on.
|
|
154
|
+
*/
|
|
155
|
+
async function authenticateMcpRequest(request: Request): Promise<McpAuth | null> {
|
|
156
|
+
const serviceClient = getServiceRoleSupabaseClient();
|
|
157
|
+
const settings = await resolveCortexAiMcpSettings(serviceClient);
|
|
158
|
+
|
|
159
|
+
if (!settings.enabled) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const bearer = parseBearerToken(request.headers.get('authorization'));
|
|
164
|
+
|
|
165
|
+
if (bearer) {
|
|
166
|
+
const verification = await verifyCortexAiMcpToken(serviceClient, bearer);
|
|
167
|
+
|
|
168
|
+
if (!verification.valid) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Bookkeeping only — never block the call on it.
|
|
173
|
+
void touchCortexAiMcpToken(serviceClient, verification.token.id);
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
actorUserId: verification.token.created_by,
|
|
177
|
+
scopes: verification.scopes,
|
|
178
|
+
source: 'token',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const adminUserId = await resolveAdminSessionUserId();
|
|
183
|
+
|
|
184
|
+
if (adminUserId) {
|
|
185
|
+
return { actorUserId: adminUserId, scopes: ['read', 'write'], source: 'admin-session' };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (shouldTrustLocalMcpRequest({ hostHeader: request.headers.get('host'), settings })) {
|
|
189
|
+
return { actorUserId: null, scopes: ['read', 'write'], source: 'localhost' };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function resolveAdminSessionUserId(): Promise<string | null> {
|
|
196
|
+
try {
|
|
197
|
+
const supabase = createClient();
|
|
198
|
+
const {
|
|
199
|
+
data: { user },
|
|
200
|
+
} = await supabase.auth.getUser();
|
|
201
|
+
|
|
202
|
+
if (!user) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const { data: profile } = await supabase
|
|
207
|
+
.from('profiles')
|
|
208
|
+
.select('role')
|
|
209
|
+
.eq('id', user.id)
|
|
210
|
+
.single();
|
|
211
|
+
|
|
212
|
+
return profile?.role === 'ADMIN' ? user.id : null;
|
|
213
|
+
} catch {
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function buildToolContext(auth: McpAuth): CortexMcpToolContext {
|
|
219
|
+
return {
|
|
220
|
+
actorUserId: auth.actorUserId,
|
|
221
|
+
importExternalImage: importExternalImageForMcp,
|
|
222
|
+
// No open editor over MCP: tools that need a target take it in their arguments
|
|
223
|
+
// (`cmsTarget`, `slug`, `entityId`) rather than inheriting one from a UI.
|
|
224
|
+
pageContext: null,
|
|
225
|
+
recordRevision: createMcpRevisionRecorder(auth.actorUserId),
|
|
226
|
+
revalidatePath,
|
|
227
|
+
skipConfirmation: MCP_SKIP_CONFIRMATION,
|
|
228
|
+
supabase: getServiceRoleSupabaseClient(),
|
|
229
|
+
validateBlockContent,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const JSON_HEADERS = {
|
|
234
|
+
'Cache-Control': 'no-store',
|
|
235
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
236
|
+
} as const;
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 401 for an unauthenticated caller.
|
|
240
|
+
*
|
|
241
|
+
* The `WWW-Authenticate` value is intentionally bare. Adding a `resource_metadata`
|
|
242
|
+
* parameter would advertise RFC 9728 OAuth discovery, and Claude Code responds to
|
|
243
|
+
* that by starting an OAuth flow — which dead-ends against a static-token server.
|
|
244
|
+
* A plain challenge tells the client "send a bearer token" and nothing more.
|
|
245
|
+
*/
|
|
246
|
+
function unauthorized(message: string): Response {
|
|
247
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
248
|
+
headers: {
|
|
249
|
+
...JSON_HEADERS,
|
|
250
|
+
'WWW-Authenticate': 'Bearer realm="NextBlock Cortex AI MCP"',
|
|
251
|
+
},
|
|
252
|
+
status: 401,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function POST(request: Request): Promise<Response> {
|
|
257
|
+
if (!isOriginAllowed(request)) {
|
|
258
|
+
return new Response(JSON.stringify({ error: 'Origin not allowed.' }), {
|
|
259
|
+
headers: JSON_HEADERS,
|
|
260
|
+
status: 403,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const isCortexAiActive = await verifyPackageOnline(CORTEX_AI_PACKAGE_ID);
|
|
265
|
+
|
|
266
|
+
if (!isCortexAiActive) {
|
|
267
|
+
return new Response(
|
|
268
|
+
JSON.stringify({ error: 'NextBlock Cortex AI is not active for this workspace.' }),
|
|
269
|
+
{ headers: JSON_HEADERS, status: 403 }
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const auth = await authenticateMcpRequest(request);
|
|
274
|
+
|
|
275
|
+
if (!auth) {
|
|
276
|
+
return unauthorized(
|
|
277
|
+
'A valid NextBlock MCP access token is required. Generate one in CMS Settings → Cortex AI, and confirm the MCP server is enabled there.'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let message: JsonRpcMessage;
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
message = (await request.json()) as JsonRpcMessage;
|
|
285
|
+
} catch {
|
|
286
|
+
return new Response(
|
|
287
|
+
JSON.stringify({
|
|
288
|
+
error: { code: -32700, message: 'Parse error: request body is not valid JSON.' },
|
|
289
|
+
id: null,
|
|
290
|
+
jsonrpc: '2.0',
|
|
291
|
+
}),
|
|
292
|
+
{ headers: JSON_HEADERS, status: 400 }
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const response = await handleCortexMcpMessage(message, {
|
|
297
|
+
context: buildToolContext(auth),
|
|
298
|
+
scopes: auth.scopes,
|
|
299
|
+
serverVersion: SERVER_VERSION,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Notifications and responses: 202 Accepted with no body. Returning a JSON-RPC
|
|
303
|
+
// envelope for a message that carried no `id` desyncs strict clients.
|
|
304
|
+
if (response.body === null) {
|
|
305
|
+
return new Response(null, { status: response.status });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return new Response(JSON.stringify(response.body), {
|
|
309
|
+
headers: JSON_HEADERS,
|
|
310
|
+
status: response.status,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The optional server→client SSE stream.
|
|
316
|
+
*
|
|
317
|
+
* This server never initiates requests or pushes unsolicited notifications — every
|
|
318
|
+
* response is returned inline on the POST — so there is nothing to stream. The spec
|
|
319
|
+
* explicitly permits answering the GET with 405 in that case.
|
|
320
|
+
*/
|
|
321
|
+
export function GET(): Response {
|
|
322
|
+
return new Response(
|
|
323
|
+
JSON.stringify({
|
|
324
|
+
error:
|
|
325
|
+
'This MCP endpoint does not offer a server-initiated SSE stream. Send JSON-RPC messages via POST.',
|
|
326
|
+
}),
|
|
327
|
+
{ headers: { ...JSON_HEADERS, Allow: 'POST, DELETE, OPTIONS' }, status: 405 }
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Session termination. The server is stateless, so there is no session to tear down. */
|
|
332
|
+
export function DELETE(): Response {
|
|
333
|
+
return new Response(null, { status: 204 });
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function OPTIONS(): Response {
|
|
337
|
+
return new Response(null, {
|
|
338
|
+
headers: {
|
|
339
|
+
'Access-Control-Allow-Headers':
|
|
340
|
+
'Authorization, Content-Type, MCP-Protocol-Version, Mcp-Session-Id, Mcp-Method, Mcp-Name',
|
|
341
|
+
'Access-Control-Allow-Methods': 'POST, DELETE, OPTIONS',
|
|
342
|
+
Allow: 'POST, DELETE, OPTIONS',
|
|
343
|
+
},
|
|
344
|
+
status: 204,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { draftMode } from "next/headers";
|
|
2
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
3
|
+
import { getCurrentUserCanEdit } from "../../../lib/visual-editing/draft-content";
|
|
4
|
+
import {
|
|
5
|
+
normalizeDraftRedirectPath,
|
|
6
|
+
resolveDraftPathTarget,
|
|
7
|
+
resolveRequestOrigin,
|
|
8
|
+
} from "../../../lib/visual-editing/draft-route";
|
|
9
|
+
|
|
10
|
+
export const runtime = "nodejs";
|
|
11
|
+
export const dynamic = "force-dynamic";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Entry point behind the CMS "Preview" and "View Live" buttons.
|
|
15
|
+
*
|
|
16
|
+
* The public site carries no locale in its URLs: `/[slug]`, `/article/[slug]`,
|
|
17
|
+
* `/product/[slug]` and `/` all resolve their language per-request from the
|
|
18
|
+
* `NEXT_USER_LOCALE` cookie (see proxy.ts). A link built from the slug alone
|
|
19
|
+
* therefore renders in whatever language the *editor's own* cookie says — which,
|
|
20
|
+
* for an admin working in the CMS, is almost always the default one. Opening the
|
|
21
|
+
* French version of a page landed you on the English one, three different ways:
|
|
22
|
+
*
|
|
23
|
+
* 1. `getPageDataBySlug(slug, cookieLocale)` prefers the row matching the
|
|
24
|
+
* cookie, so when two translations share a slug the cookie's language wins.
|
|
25
|
+
* 2. `PageClientContent` / `PostClientContent` navigate to
|
|
26
|
+
* `translatedSlugs[currentLocale]` whenever the rendered row's language
|
|
27
|
+
* differs from the cookie — bouncing distinct French slugs back to English.
|
|
28
|
+
* 3. `/` (the homepage) resolves its language from the cookie with no slug to
|
|
29
|
+
* go on at all, so the French homepage was unreachable by URL.
|
|
30
|
+
*
|
|
31
|
+
* Pinning the locale here fixes all three at once, because after the redirect the
|
|
32
|
+
* cookie *agrees* with the content: the server picks the right row, the client
|
|
33
|
+
* effect is a no-op, and the surrounding chrome (nav, footer, UI strings) renders
|
|
34
|
+
* in the same language as the body — so the preview isn't lying about the page.
|
|
35
|
+
*
|
|
36
|
+
* The alternative, a `?lang=` param read by each route, would leave a second
|
|
37
|
+
* cacheable variant of every public URL behind and can leak into shares and
|
|
38
|
+
* search indexes as duplicate content. The redirect lands on the clean canonical
|
|
39
|
+
* URL instead, and the param never reaches the public route.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const LANGUAGE_COOKIE_KEY = "NEXT_USER_LOCALE";
|
|
43
|
+
|
|
44
|
+
function redirectNoStore(url: URL) {
|
|
45
|
+
const response = NextResponse.redirect(url);
|
|
46
|
+
response.headers.set("Cache-Control", "no-store");
|
|
47
|
+
return response;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function redirectToSignIn(request: NextRequest, search: string) {
|
|
51
|
+
const origin = resolveRequestOrigin(request);
|
|
52
|
+
const signInUrl = new URL("/sign-in", origin);
|
|
53
|
+
signInUrl.searchParams.set("redirect", `${request.nextUrl.pathname}${search}`);
|
|
54
|
+
return redirectNoStore(signInUrl);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function GET(request: NextRequest) {
|
|
58
|
+
const params = request.nextUrl.searchParams;
|
|
59
|
+
const normalizedPath = normalizeDraftRedirectPath(params.get("path") ?? "/");
|
|
60
|
+
|
|
61
|
+
if (!normalizedPath) {
|
|
62
|
+
return NextResponse.json({ error: "Invalid path." }, { status: 400 });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const target = resolveDraftPathTarget(normalizedPath);
|
|
66
|
+
if (!target) {
|
|
67
|
+
return NextResponse.json({ error: "Unsupported target path." }, { status: 400 });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const wantsDraft = params.get("draft") === "1";
|
|
71
|
+
|
|
72
|
+
const auth = await getCurrentUserCanEdit();
|
|
73
|
+
if (!auth.user) {
|
|
74
|
+
return redirectToSignIn(request, request.nextUrl.search);
|
|
75
|
+
}
|
|
76
|
+
if (!auth.canEdit) {
|
|
77
|
+
return NextResponse.json(
|
|
78
|
+
{ error: "You do not have permission to preview content." },
|
|
79
|
+
{ status: 403 },
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Only ever write a language the CMS actually has configured — the value lands
|
|
84
|
+
// in a cookie every public request reads, so it must not be attacker-supplied.
|
|
85
|
+
let locale: string | null = null;
|
|
86
|
+
const requestedLang = params.get("lang")?.trim();
|
|
87
|
+
if (requestedLang) {
|
|
88
|
+
const { data: language } = await (auth.supabase as any)
|
|
89
|
+
.from("languages")
|
|
90
|
+
.select("code")
|
|
91
|
+
.eq("code", requestedLang)
|
|
92
|
+
.maybeSingle();
|
|
93
|
+
locale = (language as { code?: string } | null)?.code ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (wantsDraft) {
|
|
97
|
+
const draft = await draftMode();
|
|
98
|
+
draft.enable();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const response = redirectNoStore(new URL(target.path, resolveRequestOrigin(request)));
|
|
102
|
+
|
|
103
|
+
if (locale) {
|
|
104
|
+
// Session-scoped on purpose: previewing French shouldn't pin the editor's own
|
|
105
|
+
// browsing language for a year. The proxy leaves a matching cookie alone, so
|
|
106
|
+
// this survives the preview and expires with the browser session.
|
|
107
|
+
response.cookies.set(LANGUAGE_COOKIE_KEY, locale, {
|
|
108
|
+
path: "/",
|
|
109
|
+
sameSite: "lax",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return response;
|
|
114
|
+
}
|
|
@@ -84,14 +84,13 @@ function applyDraftToPost(post: any, draft: ContentDraftRow) {
|
|
|
84
84
|
slug: draftString(draft, "slug", post.slug),
|
|
85
85
|
language_id: languageId,
|
|
86
86
|
languages: languageId === post.language_id ? post.languages : null,
|
|
87
|
-
|
|
87
|
+
// Visibility is never taken from a draft — it lives on the row.
|
|
88
88
|
meta_title: draftNullableString(draft, "meta_title", post.meta_title),
|
|
89
89
|
meta_description: draftNullableString(draft, "meta_description", post.meta_description),
|
|
90
90
|
custom_canonical: draftNullableString(draft, "custom_canonical", post.custom_canonical),
|
|
91
91
|
label: draftNullableString(draft, "label", post.label),
|
|
92
92
|
excerpt: draftNullableString(draft, "excerpt", post.excerpt),
|
|
93
93
|
subtitle: draftNullableString(draft, "subtitle", post.subtitle),
|
|
94
|
-
published_at: draftNullableString(draft, "published_at", post.published_at),
|
|
95
94
|
feature_image_id: draftNullableString(draft, "feature_image_id", post.feature_image_id),
|
|
96
95
|
translation_group_id: draftString(
|
|
97
96
|
draft,
|
|
@@ -45,8 +45,18 @@ export default function DraftStatusActions({
|
|
|
45
45
|
);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
const warning = res && "success" in res ? res.warning : undefined;
|
|
49
|
+
|
|
48
50
|
if (res && "error" in res && res.error) {
|
|
49
51
|
toast.error(`Publish failed: ${res.error}`, { id: toastId });
|
|
52
|
+
} else if (warning) {
|
|
53
|
+
// The content IS live — only the revision failed to record. Say so plainly
|
|
54
|
+
// rather than claiming an unqualified success.
|
|
55
|
+
toast.error(warning, { id: toastId, duration: 8000 });
|
|
56
|
+
router.refresh();
|
|
57
|
+
setTimeout(() => {
|
|
58
|
+
window.location.reload();
|
|
59
|
+
}, 800);
|
|
50
60
|
} else {
|
|
51
61
|
toast.success("Changes published live successfully!", { id: toastId });
|
|
52
62
|
router.refresh();
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Badge } from "@nextblock-cms/ui";
|
|
2
|
+
import {
|
|
3
|
+
LIVE_STATUS,
|
|
4
|
+
resolveVisibilityState,
|
|
5
|
+
type PublishableType,
|
|
6
|
+
type VisibilityState,
|
|
7
|
+
} from "@nextblock-cms/utils";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Status badge for the CMS list views.
|
|
11
|
+
*
|
|
12
|
+
* Reads the same (status, published_at) pair as the editor's top-bar control, so a
|
|
13
|
+
* scheduled row is labelled "Scheduled" here instead of claiming to be published
|
|
14
|
+
* while its URL still 404s.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const LABEL: Record<VisibilityState, string> = {
|
|
18
|
+
draft: "Draft",
|
|
19
|
+
scheduled: "Scheduled",
|
|
20
|
+
published: "Published",
|
|
21
|
+
archived: "Archived",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const CLASS_NAME: Record<VisibilityState, string> = {
|
|
25
|
+
published:
|
|
26
|
+
"bg-green-100 text-green-700 dark:bg-green-700/30 dark:text-green-300 dark:border-green-700/50",
|
|
27
|
+
scheduled:
|
|
28
|
+
"bg-amber-100 text-amber-700 dark:bg-amber-700/30 dark:text-amber-300 dark:border-amber-700/50",
|
|
29
|
+
draft:
|
|
30
|
+
"bg-yellow-100 text-yellow-700 dark:bg-yellow-700/30 dark:text-yellow-300 dark:border-yellow-700/50",
|
|
31
|
+
archived:
|
|
32
|
+
"bg-slate-100 text-slate-700 dark:bg-slate-700/30 dark:text-slate-300 dark:border-slate-600",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const VARIANT: Record<VisibilityState, "default" | "secondary" | "destructive"> = {
|
|
36
|
+
published: "default",
|
|
37
|
+
scheduled: "secondary",
|
|
38
|
+
draft: "secondary",
|
|
39
|
+
archived: "destructive",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default function VisibilityBadge({
|
|
43
|
+
type,
|
|
44
|
+
status,
|
|
45
|
+
publishedAt,
|
|
46
|
+
}: {
|
|
47
|
+
type: PublishableType;
|
|
48
|
+
status: string;
|
|
49
|
+
publishedAt?: string | null;
|
|
50
|
+
}) {
|
|
51
|
+
const state = resolveVisibilityState({
|
|
52
|
+
status,
|
|
53
|
+
publishedAt,
|
|
54
|
+
liveStatus: LIVE_STATUS[type],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<Badge variant={VARIANT[state]} className={CLASS_NAME[state]}>
|
|
59
|
+
{LABEL[state]}
|
|
60
|
+
</Badge>
|
|
61
|
+
);
|
|
62
|
+
}
|