create-brainerce-store 1.27.5 → 1.28.0
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/dist/index.js +95 -22
- package/messages/en.json +12 -1
- package/messages/he.json +12 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +3 -3
- package/templates/nextjs/base/next.config.ts +13 -12
- package/templates/nextjs/base/package.json.ejs +2 -1
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +15 -14
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +66 -59
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +76 -77
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +229 -198
- package/templates/nextjs/base/src/app/checkout/page.tsx +975 -972
- package/templates/nextjs/base/src/app/layout.tsx.ejs +29 -13
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +271 -271
- package/templates/nextjs/base/src/app/payment-complete/page.tsx +59 -59
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +501 -486
- package/templates/nextjs/base/src/app/products/page.tsx +475 -475
- package/templates/nextjs/base/src/app/reset-password/page.tsx +138 -131
- package/templates/nextjs/base/src/components/auth/register-form.tsx +245 -232
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +416 -415
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +258 -184
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +84 -20
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +86 -72
- package/templates/nextjs/base/src/lib/csrf.ts +11 -0
- package/templates/nextjs/base/src/lib/navigation.tsx.ejs +60 -60
- package/templates/nextjs/base/src/lib/nonce.ts +10 -0
- package/templates/nextjs/base/src/lib/safe-redirect.ts +45 -0
- package/templates/nextjs/base/src/lib/sanitize-html.ts +93 -0
- package/templates/nextjs/base/src/lib/validation.ts +37 -0
- package/templates/nextjs/base/src/middleware.ts.ejs +91 -8
- package/templates/nextjs/base/tsconfig.tsbuildinfo +1 -0
- package/templates/nextjs/themes/luxury/globals.css +399 -399
- package/templates/nextjs/themes/luxury/theme.json +23 -23
- package/templates/nextjs/themes/playful/globals.css +400 -400
- package/templates/nextjs/themes/playful/theme.json +23 -23
package/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.
|
|
34
|
+
version: "1.28.0",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
|
@@ -96,6 +96,9 @@ function validateConnectionId(id) {
|
|
|
96
96
|
if (id.length < 6) {
|
|
97
97
|
return "Connection ID is too short";
|
|
98
98
|
}
|
|
99
|
+
if (id.length > 100) {
|
|
100
|
+
return "Connection ID is too long (max 100 characters)";
|
|
101
|
+
}
|
|
99
102
|
if (!/^vc_[a-zA-Z0-9]+$/.test(id)) {
|
|
100
103
|
return "Connection ID contains invalid characters";
|
|
101
104
|
}
|
|
@@ -119,6 +122,23 @@ function validateProjectName(name) {
|
|
|
119
122
|
}
|
|
120
123
|
return null;
|
|
121
124
|
}
|
|
125
|
+
function validateApiUrl(url) {
|
|
126
|
+
if (!url) return null;
|
|
127
|
+
let parsed;
|
|
128
|
+
try {
|
|
129
|
+
parsed = new URL(url);
|
|
130
|
+
} catch {
|
|
131
|
+
return `Invalid --api-url "${url}": not a valid URL`;
|
|
132
|
+
}
|
|
133
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
134
|
+
return `Invalid --api-url "${url}": protocol must be http(s)`;
|
|
135
|
+
}
|
|
136
|
+
const isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname.endsWith(".localhost");
|
|
137
|
+
if (parsed.protocol === "http:" && !isLocal) {
|
|
138
|
+
return `Invalid --api-url "${url}": http:// is only allowed for localhost`;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
122
142
|
|
|
123
143
|
// src/utils/package-manager.ts
|
|
124
144
|
var import_child_process = require("child_process");
|
|
@@ -138,14 +158,17 @@ function detectPackageManager() {
|
|
|
138
158
|
}
|
|
139
159
|
return "npm";
|
|
140
160
|
}
|
|
161
|
+
var ALLOWED_PACKAGE_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
|
|
141
162
|
async function installDependencies(projectDir, pkgManager) {
|
|
163
|
+
if (!ALLOWED_PACKAGE_MANAGERS.includes(pkgManager)) {
|
|
164
|
+
throw new Error(`Unsupported package manager: ${pkgManager}`);
|
|
165
|
+
}
|
|
142
166
|
return new Promise((resolve, reject) => {
|
|
143
|
-
const
|
|
167
|
+
const cmd = process.platform === "win32" ? `${pkgManager}.cmd` : pkgManager;
|
|
144
168
|
const args = pkgManager === "yarn" ? [] : ["install"];
|
|
145
|
-
const child = spawn(
|
|
169
|
+
const child = (0, import_child_process.spawn)(cmd, args, {
|
|
146
170
|
cwd: projectDir,
|
|
147
|
-
stdio: "ignore"
|
|
148
|
-
shell: true
|
|
171
|
+
stdio: "ignore"
|
|
149
172
|
});
|
|
150
173
|
child.on("close", (code) => {
|
|
151
174
|
if (code === 0) {
|
|
@@ -267,6 +290,19 @@ var import_ejs = __toESM(require("ejs"));
|
|
|
267
290
|
function getDirection(language) {
|
|
268
291
|
return language === "he" ? "rtl" : "ltr";
|
|
269
292
|
}
|
|
293
|
+
function stripControlChars(value) {
|
|
294
|
+
return value.replace(/\p{Cc}/gu, "");
|
|
295
|
+
}
|
|
296
|
+
function toJsStringLiteral(value) {
|
|
297
|
+
return JSON.stringify(value);
|
|
298
|
+
}
|
|
299
|
+
function toEnvLiteral(value) {
|
|
300
|
+
const clean = stripControlChars(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, "\\$");
|
|
301
|
+
return `"${clean}"`;
|
|
302
|
+
}
|
|
303
|
+
function isValidLocale(locale) {
|
|
304
|
+
return typeof locale === "string" && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$/.test(locale);
|
|
305
|
+
}
|
|
270
306
|
function getFontConfig(language, theme) {
|
|
271
307
|
const isHebrew = language === "he";
|
|
272
308
|
if (theme === "luxury") {
|
|
@@ -312,20 +348,30 @@ async function scaffold(options) {
|
|
|
312
348
|
const direction = getDirection(options.language);
|
|
313
349
|
const fontConfig = getFontConfig(options.language, theme);
|
|
314
350
|
const ogLocale = options.language === "he" ? "he_IL" : "en_US";
|
|
315
|
-
const
|
|
316
|
-
const supportedLocales =
|
|
317
|
-
|
|
351
|
+
const rawSupportedLocales = options.i18n?.supportedLocales || [options.language];
|
|
352
|
+
const supportedLocales = rawSupportedLocales.filter(isValidLocale);
|
|
353
|
+
if (supportedLocales.length === 0) {
|
|
354
|
+
throw new Error("No valid locales provided");
|
|
355
|
+
}
|
|
356
|
+
const rawDefaultLocale = options.i18n?.defaultLocale || options.language;
|
|
357
|
+
const defaultLocale = isValidLocale(rawDefaultLocale) ? rawDefaultLocale : options.language;
|
|
358
|
+
const isMultiLocale = options.i18n?.enabled === true && supportedLocales.length > 1;
|
|
359
|
+
const cleanStoreName = stripControlChars(options.storeName);
|
|
360
|
+
const cleanCurrency = stripControlChars(options.currency);
|
|
361
|
+
const cleanApiBaseUrl = stripControlChars(options.apiBaseUrl || "https://api.brainerce.com");
|
|
318
362
|
const templateVars = {
|
|
319
363
|
projectName: options.projectName,
|
|
320
364
|
connectionId: options.connectionId,
|
|
321
|
-
|
|
322
|
-
|
|
365
|
+
storeNameJs: toJsStringLiteral(cleanStoreName),
|
|
366
|
+
titleTemplateJs: toJsStringLiteral(`%s | ${cleanStoreName}`),
|
|
367
|
+
storeNameEnv: toEnvLiteral(cleanStoreName),
|
|
368
|
+
currencyEnv: toEnvLiteral(cleanCurrency),
|
|
369
|
+
apiBaseUrlEnv: toEnvLiteral(cleanApiBaseUrl),
|
|
323
370
|
language: options.language,
|
|
324
371
|
direction,
|
|
325
372
|
fontImport: fontConfig.fontImport,
|
|
326
373
|
fontVariable: fontConfig.fontVariable,
|
|
327
374
|
ogLocale,
|
|
328
|
-
apiBaseUrl: options.apiBaseUrl || "https://api.brainerce.com",
|
|
329
375
|
i18nEnabled: isMultiLocale,
|
|
330
376
|
supportedLocales: JSON.stringify(supportedLocales),
|
|
331
377
|
defaultLocale
|
|
@@ -355,20 +401,11 @@ async function scaffold(options) {
|
|
|
355
401
|
const appDir = import_path.default.join(targetDir, "src", "app");
|
|
356
402
|
const localeDir = import_path.default.join(appDir, "[locale]");
|
|
357
403
|
await import_fs_extra.default.ensureDir(localeDir);
|
|
358
|
-
const keepInAppRoot = /* @__PURE__ */ new Set([
|
|
359
|
-
"globals.css",
|
|
360
|
-
"robots.ts",
|
|
361
|
-
"sitemap.ts",
|
|
362
|
-
"api",
|
|
363
|
-
".well-known"
|
|
364
|
-
]);
|
|
404
|
+
const keepInAppRoot = /* @__PURE__ */ new Set(["globals.css", "robots.ts", "sitemap.ts", "api", ".well-known"]);
|
|
365
405
|
const appEntries = await import_fs_extra.default.readdir(appDir, { withFileTypes: true });
|
|
366
406
|
for (const entry of appEntries) {
|
|
367
407
|
if (keepInAppRoot.has(entry.name) || entry.name === "[locale]") continue;
|
|
368
|
-
await import_fs_extra.default.move(
|
|
369
|
-
import_path.default.join(appDir, entry.name),
|
|
370
|
-
import_path.default.join(localeDir, entry.name)
|
|
371
|
-
);
|
|
408
|
+
await import_fs_extra.default.move(import_path.default.join(appDir, entry.name), import_path.default.join(localeDir, entry.name));
|
|
372
409
|
}
|
|
373
410
|
}
|
|
374
411
|
if (await import_fs_extra.default.pathExists(themeDir)) {
|
|
@@ -597,11 +634,47 @@ program.name("create-brainerce-store").description("Scaffold a production-ready
|
|
|
597
634
|
}
|
|
598
635
|
let connectionId = options.connectionId;
|
|
599
636
|
const explicitApiUrl = (options.apiUrl || process.env.BRAINERCE_API_URL || "").replace(/\/$/, "");
|
|
637
|
+
const apiUrlError = validateApiUrl(explicitApiUrl);
|
|
638
|
+
if (apiUrlError) {
|
|
639
|
+
logger.error(apiUrlError);
|
|
640
|
+
process.exit(1);
|
|
641
|
+
}
|
|
642
|
+
if (explicitApiUrl && explicitApiUrl !== KNOWN_API_URLS.production && explicitApiUrl !== KNOWN_API_URLS.staging) {
|
|
643
|
+
logger.warn(
|
|
644
|
+
`Using non-standard API URL: ${explicitApiUrl} \u2014 make sure you trust this endpoint.`
|
|
645
|
+
);
|
|
646
|
+
}
|
|
600
647
|
const candidateApiUrls = explicitApiUrl ? [explicitApiUrl] : [KNOWN_API_URLS.production, KNOWN_API_URLS.staging];
|
|
648
|
+
const VALID_LANGUAGES = ["en", "he"];
|
|
649
|
+
const VALID_PKG_MANAGERS = ["npm", "pnpm", "yarn", "bun"];
|
|
650
|
+
const VALID_FRAMEWORKS = ["nextjs"];
|
|
651
|
+
const VALID_THEMES = ["minimal", "luxury", "playful"];
|
|
601
652
|
let language = options.language;
|
|
653
|
+
if (language && !VALID_LANGUAGES.includes(language)) {
|
|
654
|
+
logger.error(
|
|
655
|
+
`Invalid --language "${language}". Expected one of: ${VALID_LANGUAGES.join(", ")}`
|
|
656
|
+
);
|
|
657
|
+
process.exit(1);
|
|
658
|
+
}
|
|
602
659
|
let framework = options.framework;
|
|
660
|
+
if (!VALID_FRAMEWORKS.includes(framework)) {
|
|
661
|
+
logger.error(
|
|
662
|
+
`Invalid --framework "${framework}". Expected one of: ${VALID_FRAMEWORKS.join(", ")}`
|
|
663
|
+
);
|
|
664
|
+
process.exit(1);
|
|
665
|
+
}
|
|
603
666
|
let theme = options.theme;
|
|
667
|
+
if (!VALID_THEMES.includes(theme)) {
|
|
668
|
+
logger.error(`Invalid --theme "${theme}". Expected one of: ${VALID_THEMES.join(", ")}`);
|
|
669
|
+
process.exit(1);
|
|
670
|
+
}
|
|
604
671
|
let pkgManager = options.pkgManager;
|
|
672
|
+
if (pkgManager && !VALID_PKG_MANAGERS.includes(pkgManager)) {
|
|
673
|
+
logger.error(
|
|
674
|
+
`Invalid --pkg-manager "${pkgManager}". Expected one of: ${VALID_PKG_MANAGERS.join(", ")}`
|
|
675
|
+
);
|
|
676
|
+
process.exit(1);
|
|
677
|
+
}
|
|
605
678
|
const skipGit = options.git === false;
|
|
606
679
|
const skipInstall = options.install === false;
|
|
607
680
|
let storeInfo = null;
|
package/messages/en.json
CHANGED
|
@@ -154,11 +154,13 @@
|
|
|
154
154
|
"changePickup": "Change pickup",
|
|
155
155
|
"changeShipping": "Change shipping",
|
|
156
156
|
"payment": "Payment",
|
|
157
|
+
"securePayment": "Secure payment",
|
|
157
158
|
"preparingPayment": "Preparing payment...",
|
|
158
159
|
"loadingPaymentOptions": "Loading payment options...",
|
|
159
160
|
"paymentNotConfigured": "Payment Not Configured",
|
|
160
161
|
"paymentNotConfiguredDesc": "Payment has not been set up for this store yet. Please contact the store owner.",
|
|
161
162
|
"paymentError": "Payment Error",
|
|
163
|
+
"paymentRedirectBlocked": "Payment redirect blocked for security reasons. Please contact support.",
|
|
162
164
|
"sandboxTitle": "Sandbox Mode",
|
|
163
165
|
"sandboxDescription": "This is a test order. No real payment will be processed.",
|
|
164
166
|
"completeTestOrder": "Complete Test Order",
|
|
@@ -187,6 +189,10 @@
|
|
|
187
189
|
"customFieldsFailed": "Failed to save selections",
|
|
188
190
|
"customFieldsRequired": "Required",
|
|
189
191
|
"customFieldsSelectPlaceholder": "— Select —",
|
|
192
|
+
"customFieldsImageUpload": "Click to upload an image",
|
|
193
|
+
"customFieldsImageRemove": "Remove",
|
|
194
|
+
"customFieldsImageUploading": "Uploading...",
|
|
195
|
+
"customFieldsImageTooLarge": "File must be under 5MB",
|
|
190
196
|
"changeOptions": "Change options",
|
|
191
197
|
"surcharges": "Additional charges"
|
|
192
198
|
},
|
|
@@ -243,13 +249,18 @@
|
|
|
243
249
|
"passwordPlaceholder": "Enter your password",
|
|
244
250
|
"firstNamePlaceholder": "Jane",
|
|
245
251
|
"lastNamePlaceholder": "Doe",
|
|
246
|
-
"atLeastChars": "At least
|
|
252
|
+
"atLeastChars": "At least 8 characters",
|
|
247
253
|
"orContinueWith": "or continue with",
|
|
248
254
|
"tooShort": "Too short",
|
|
249
255
|
"weak": "Weak",
|
|
250
256
|
"fair": "Fair",
|
|
251
257
|
"good": "Good",
|
|
252
258
|
"strong": "Strong",
|
|
259
|
+
"passwordRequired": "Password is required",
|
|
260
|
+
"passwordTooShort": "Password must be at least 8 characters",
|
|
261
|
+
"passwordNoUppercase": "Password must contain at least one uppercase letter",
|
|
262
|
+
"passwordNoNumber": "Password must contain at least one number",
|
|
263
|
+
"passwordNoSymbol": "Password must contain at least one special character",
|
|
253
264
|
"google": "Google",
|
|
254
265
|
"facebook": "Facebook",
|
|
255
266
|
"github": "GitHub",
|
package/messages/he.json
CHANGED
|
@@ -154,11 +154,13 @@
|
|
|
154
154
|
"changePickup": "שנה איסוף",
|
|
155
155
|
"changeShipping": "שנה משלוח",
|
|
156
156
|
"payment": "תשלום",
|
|
157
|
+
"securePayment": "תשלום מאובטח",
|
|
157
158
|
"preparingPayment": "מכין תשלום...",
|
|
158
159
|
"loadingPaymentOptions": "טוען אפשרויות תשלום...",
|
|
159
160
|
"paymentNotConfigured": "תשלום לא מוגדר",
|
|
160
161
|
"paymentNotConfiguredDesc": "התשלום עדיין לא הוגדר לחנות זו. אנא פנו לבעל החנות.",
|
|
161
162
|
"paymentError": "שגיאת תשלום",
|
|
163
|
+
"paymentRedirectBlocked": "הפניית התשלום נחסמה מטעמי אבטחה. אנא פנו לתמיכה.",
|
|
162
164
|
"sandboxTitle": "מצב בדיקה",
|
|
163
165
|
"sandboxDescription": "זוהי הזמנת בדיקה. לא יבוצע תשלום אמיתי.",
|
|
164
166
|
"completeTestOrder": "השלם הזמנת בדיקה",
|
|
@@ -187,6 +189,10 @@
|
|
|
187
189
|
"customFieldsFailed": "שגיאה בשמירת הבחירות",
|
|
188
190
|
"customFieldsRequired": "חובה",
|
|
189
191
|
"customFieldsSelectPlaceholder": "— בחר —",
|
|
192
|
+
"customFieldsImageUpload": "לחץ להעלאת תמונה",
|
|
193
|
+
"customFieldsImageRemove": "הסר",
|
|
194
|
+
"customFieldsImageUploading": "...מעלה",
|
|
195
|
+
"customFieldsImageTooLarge": "הקובץ חייב להיות מתחת ל-5MB",
|
|
190
196
|
"changeOptions": "שנה אפשרויות",
|
|
191
197
|
"surcharges": "תוספות"
|
|
192
198
|
},
|
|
@@ -243,13 +249,18 @@
|
|
|
243
249
|
"passwordPlaceholder": "הזינו סיסמה",
|
|
244
250
|
"firstNamePlaceholder": "ישראל",
|
|
245
251
|
"lastNamePlaceholder": "ישראלי",
|
|
246
|
-
"atLeastChars": "לפחות
|
|
252
|
+
"atLeastChars": "לפחות 8 תווים",
|
|
247
253
|
"orContinueWith": "או המשיכו עם",
|
|
248
254
|
"tooShort": "קצרה מדי",
|
|
249
255
|
"weak": "חלשה",
|
|
250
256
|
"fair": "סבירה",
|
|
251
257
|
"good": "טובה",
|
|
252
258
|
"strong": "חזקה",
|
|
259
|
+
"passwordRequired": "סיסמה נדרשת",
|
|
260
|
+
"passwordTooShort": "הסיסמה חייבת להכיל לפחות 8 תווים",
|
|
261
|
+
"passwordNoUppercase": "הסיסמה חייבת להכיל לפחות אות גדולה אחת",
|
|
262
|
+
"passwordNoNumber": "הסיסמה חייבת להכיל לפחות ספרה אחת",
|
|
263
|
+
"passwordNoSymbol": "הסיסמה חייבת להכיל לפחות סימן מיוחד אחד",
|
|
253
264
|
"google": "Google",
|
|
254
265
|
"facebook": "Facebook",
|
|
255
266
|
"github": "GitHub",
|
package/package.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
NEXT_PUBLIC_BRAINERCE_CONNECTION_ID=<%= connectionId %>
|
|
3
3
|
|
|
4
4
|
# Store info (pre-fetched during setup to avoid flash on first load)
|
|
5
|
-
NEXT_PUBLIC_STORE_NAME
|
|
6
|
-
NEXT_PUBLIC_STORE_CURRENCY
|
|
5
|
+
NEXT_PUBLIC_STORE_NAME=<%- storeNameEnv %>
|
|
6
|
+
NEXT_PUBLIC_STORE_CURRENCY=<%- currencyEnv %>
|
|
7
7
|
|
|
8
8
|
# Backend API URL (server-side only — used by BFF proxy and SSR, never exposed to browser)
|
|
9
|
-
BRAINERCE_API_URL
|
|
9
|
+
BRAINERCE_API_URL=<%- apiBaseUrlEnv %>
|
|
10
10
|
|
|
11
11
|
# Public site URL — used for sitemap, robots.txt, and SEO metadata
|
|
12
12
|
NEXT_PUBLIC_SITE_URL=http://localhost:3000
|
|
@@ -2,7 +2,10 @@ import type { NextConfig } from 'next';
|
|
|
2
2
|
|
|
3
3
|
const nextConfig: NextConfig = {
|
|
4
4
|
images: {
|
|
5
|
-
remotePatterns: [
|
|
5
|
+
remotePatterns: [
|
|
6
|
+
{ protocol: 'https', hostname: 'cdn.brainerce.com' },
|
|
7
|
+
{ protocol: 'https', hostname: '*.brainerce.com' },
|
|
8
|
+
],
|
|
6
9
|
},
|
|
7
10
|
async headers() {
|
|
8
11
|
return [
|
|
@@ -10,17 +13,15 @@ const nextConfig: NextConfig = {
|
|
|
10
13
|
source: '/(.*)',
|
|
11
14
|
headers: [
|
|
12
15
|
{
|
|
13
|
-
key: '
|
|
14
|
-
value:
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"worker-src 'self' blob:",
|
|
23
|
-
].join('; '),
|
|
16
|
+
key: 'Strict-Transport-Security',
|
|
17
|
+
value: 'max-age=63072000; includeSubDomains; preload',
|
|
18
|
+
},
|
|
19
|
+
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
|
20
|
+
{ key: 'X-Frame-Options', value: 'DENY' },
|
|
21
|
+
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
|
22
|
+
{
|
|
23
|
+
key: 'Permissions-Policy',
|
|
24
|
+
value: 'camera=(), microphone=(), geolocation=(), interest-cohort=()',
|
|
24
25
|
},
|
|
25
26
|
],
|
|
26
27
|
},
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { NextResponse } from 'next/server';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
response.
|
|
12
|
-
response.cookies.delete(
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
import { checkCsrf } from '@/lib/csrf';
|
|
3
|
+
|
|
4
|
+
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
5
|
+
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
6
|
+
|
|
7
|
+
export async function POST(request: NextRequest) {
|
|
8
|
+
const csrfError = checkCsrf(request);
|
|
9
|
+
if (csrfError) return csrfError;
|
|
10
|
+
|
|
11
|
+
const response = NextResponse.json({ success: true });
|
|
12
|
+
response.cookies.delete(TOKEN_COOKIE);
|
|
13
|
+
response.cookies.delete(LOGGED_IN_COOKIE);
|
|
14
|
+
return response;
|
|
15
|
+
}
|
|
@@ -1,59 +1,66 @@
|
|
|
1
|
-
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
-
|
|
3
|
-
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
4
|
-
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
5
|
-
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
|
|
6
|
-
|
|
7
|
-
function isSecure(): boolean {
|
|
8
|
-
return process.env.NODE_ENV === 'production';
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* OAuth callback handler.
|
|
13
|
-
* The backend redirects here with ?token=jwt&oauth_success=true after OAuth code exchange.
|
|
14
|
-
* We set the httpOnly cookie and redirect to the client-side callback page (without the token).
|
|
15
|
-
*/
|
|
16
|
-
export async function GET(request: NextRequest) {
|
|
17
|
-
const { searchParams } = request.nextUrl;
|
|
18
|
-
const token = searchParams.get('token');
|
|
19
|
-
const oauthSuccess = searchParams.get('oauth_success');
|
|
20
|
-
const oauthError = searchParams.get('oauth_error');
|
|
21
|
-
|
|
22
|
-
// Build redirect URL to client-side callback page
|
|
23
|
-
const redirectUrl = new URL('/auth/callback', request.url);
|
|
24
|
-
|
|
25
|
-
if (oauthError) {
|
|
26
|
-
redirectUrl.searchParams.set('oauth_error', oauthError);
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
4
|
+
const LOGGED_IN_COOKIE = 'brainerce_logged_in';
|
|
5
|
+
const COOKIE_MAX_AGE = 7 * 24 * 60 * 60; // 7 days
|
|
6
|
+
|
|
7
|
+
function isSecure(): boolean {
|
|
8
|
+
return process.env.NODE_ENV === 'production';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* OAuth callback handler.
|
|
13
|
+
* The backend redirects here with ?token=jwt&oauth_success=true after OAuth code exchange.
|
|
14
|
+
* We set the httpOnly cookie and redirect to the client-side callback page (without the token).
|
|
15
|
+
*/
|
|
16
|
+
export async function GET(request: NextRequest) {
|
|
17
|
+
const { searchParams } = request.nextUrl;
|
|
18
|
+
const token = searchParams.get('token');
|
|
19
|
+
const oauthSuccess = searchParams.get('oauth_success');
|
|
20
|
+
const oauthError = searchParams.get('oauth_error');
|
|
21
|
+
|
|
22
|
+
// Build redirect URL to client-side callback page
|
|
23
|
+
const redirectUrl = new URL('/auth/callback', request.url);
|
|
24
|
+
|
|
25
|
+
if (oauthError) {
|
|
26
|
+
redirectUrl.searchParams.set('oauth_error', oauthError);
|
|
27
|
+
const response = NextResponse.redirect(redirectUrl);
|
|
28
|
+
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
29
|
+
return response;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (oauthSuccess === 'true' && token) {
|
|
33
|
+
redirectUrl.searchParams.set('oauth_success', 'true');
|
|
34
|
+
|
|
35
|
+
const response = NextResponse.redirect(redirectUrl);
|
|
36
|
+
|
|
37
|
+
// Set httpOnly cookie with the token
|
|
38
|
+
response.cookies.set(TOKEN_COOKIE, token, {
|
|
39
|
+
httpOnly: true,
|
|
40
|
+
secure: isSecure(),
|
|
41
|
+
sameSite: 'lax',
|
|
42
|
+
path: '/',
|
|
43
|
+
maxAge: COOKIE_MAX_AGE,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Set indicator cookie (readable by client JS)
|
|
47
|
+
response.cookies.set(LOGGED_IN_COOKIE, '1', {
|
|
48
|
+
httpOnly: false,
|
|
49
|
+
secure: isSecure(),
|
|
50
|
+
sameSite: 'lax',
|
|
51
|
+
path: '/',
|
|
52
|
+
maxAge: COOKIE_MAX_AGE,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Prevent token leaking via Referer header on the downstream navigation
|
|
56
|
+
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
57
|
+
|
|
58
|
+
return response;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fallback: no token or success flag
|
|
62
|
+
redirectUrl.searchParams.set('oauth_error', 'Authentication failed');
|
|
63
|
+
const response = NextResponse.redirect(redirectUrl);
|
|
64
|
+
response.headers.set('Referrer-Policy', 'no-referrer');
|
|
65
|
+
return response;
|
|
66
|
+
}
|