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
|
@@ -1,72 +1,86 @@
|
|
|
1
|
-
import type { Product } from 'brainerce';
|
|
2
|
-
import { getProductPriceInfo } from 'brainerce';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
1
|
+
import type { Product } from 'brainerce';
|
|
2
|
+
import { getProductPriceInfo } from 'brainerce';
|
|
3
|
+
import { getNonce } from '@/lib/nonce';
|
|
4
|
+
|
|
5
|
+
interface ProductJsonLdProps {
|
|
6
|
+
product: Product;
|
|
7
|
+
url: string;
|
|
8
|
+
currency?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function ProductJsonLd({ product, url, currency = 'USD' }: ProductJsonLdProps) {
|
|
12
|
+
const nonce = await getNonce();
|
|
13
|
+
const priceInfo = getProductPriceInfo(product);
|
|
14
|
+
const imageUrl = product.images?.[0]?.url;
|
|
15
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
16
|
+
|
|
17
|
+
const productJsonLd = {
|
|
18
|
+
'@context': 'https://schema.org',
|
|
19
|
+
'@type': 'Product',
|
|
20
|
+
name: product.name,
|
|
21
|
+
description: product.description || product.name,
|
|
22
|
+
image: imageUrl,
|
|
23
|
+
url,
|
|
24
|
+
sku: product.sku || product.id,
|
|
25
|
+
offers: {
|
|
26
|
+
'@type': 'Offer',
|
|
27
|
+
price: priceInfo.price,
|
|
28
|
+
priceCurrency: currency,
|
|
29
|
+
availability:
|
|
30
|
+
product.inventory?.canPurchase !== false
|
|
31
|
+
? 'https://schema.org/InStock'
|
|
32
|
+
: 'https://schema.org/OutOfStock',
|
|
33
|
+
url,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const breadcrumbJsonLd = {
|
|
38
|
+
'@context': 'https://schema.org',
|
|
39
|
+
'@type': 'BreadcrumbList',
|
|
40
|
+
itemListElement: [
|
|
41
|
+
{
|
|
42
|
+
'@type': 'ListItem',
|
|
43
|
+
position: 1,
|
|
44
|
+
name: 'Home',
|
|
45
|
+
item: baseUrl || '/',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
'@type': 'ListItem',
|
|
49
|
+
position: 2,
|
|
50
|
+
name: 'Products',
|
|
51
|
+
item: `${baseUrl}/products`,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
'@type': 'ListItem',
|
|
55
|
+
position: 3,
|
|
56
|
+
name: product.name,
|
|
57
|
+
item: url,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
<script
|
|
65
|
+
type="application/ld+json"
|
|
66
|
+
nonce={nonce}
|
|
67
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(productJsonLd) }}
|
|
68
|
+
/>
|
|
69
|
+
<script
|
|
70
|
+
type="application/ld+json"
|
|
71
|
+
nonce={nonce}
|
|
72
|
+
dangerouslySetInnerHTML={{ __html: serializeJsonLd(breadcrumbJsonLd) }}
|
|
73
|
+
/>
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Serialize a JSON-LD object for embedding in a <script> tag. Escapes
|
|
79
|
+
// `<`, `>`, and `&` to \uXXXX so seller-controlled product fields cannot
|
|
80
|
+
// break out of the script element with `</script>` or inject HTML.
|
|
81
|
+
function serializeJsonLd(value: unknown): string {
|
|
82
|
+
return JSON.stringify(value)
|
|
83
|
+
.replace(/</g, '\\u003c')
|
|
84
|
+
.replace(/>/g, '\\u003e')
|
|
85
|
+
.replace(/&/g, '\\u0026');
|
|
86
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
export const CSRF_HEADER = 'x-requested-with';
|
|
4
|
+
export const CSRF_VALUE = 'brainerce';
|
|
5
|
+
|
|
6
|
+
export function checkCsrf(request: NextRequest): NextResponse | null {
|
|
7
|
+
if (request.headers.get(CSRF_HEADER) !== CSRF_VALUE) {
|
|
8
|
+
return NextResponse.json({ error: 'CSRF validation failed' }, { status: 403 });
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
<% if (i18nEnabled) { %>
|
|
2
|
-
'use client';
|
|
3
|
-
|
|
4
|
-
import React from 'react';
|
|
5
|
-
import NextLink from 'next/link';
|
|
6
|
-
import { useRouter as useNextRouter, usePathname } from 'next/navigation';
|
|
7
|
-
import type { ComponentProps } from 'react';
|
|
8
|
-
|
|
9
|
-
const supportedLocales = <%- supportedLocales %>;
|
|
10
|
-
const defaultLocale = '<%= defaultLocale %>';
|
|
11
|
-
|
|
12
|
-
function getLocale(pathname: string): string {
|
|
13
|
-
const segment = pathname.split('/')[1];
|
|
14
|
-
return supportedLocales.includes(segment) ? segment : defaultLocale;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function localizePath(path: string, locale: string): string {
|
|
18
|
-
if (!path.startsWith('/')) return path;
|
|
19
|
-
// Don't double-prefix
|
|
20
|
-
const firstSeg = path.split('/')[1];
|
|
21
|
-
if (supportedLocales.includes(firstSeg)) return path;
|
|
22
|
-
return `/${locale}${path}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Locale-aware Link — automatically prepends current locale to href.
|
|
27
|
-
*/
|
|
28
|
-
export function Link({ href, ...props }: ComponentProps<typeof NextLink>) {
|
|
29
|
-
const pathname = usePathname();
|
|
30
|
-
const locale = getLocale(pathname);
|
|
31
|
-
const localizedHref =
|
|
32
|
-
typeof href === 'string' ? localizePath(href, locale) : href;
|
|
33
|
-
return <NextLink href={localizedHref} {...props} />;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Locale-aware useRouter — push/replace automatically prepend locale.
|
|
38
|
-
*/
|
|
39
|
-
export function useRouter() {
|
|
40
|
-
const router = useNextRouter();
|
|
41
|
-
const pathname = usePathname();
|
|
42
|
-
const locale = getLocale(pathname);
|
|
43
|
-
|
|
44
|
-
return {
|
|
45
|
-
...router,
|
|
46
|
-
push: (url: string, options?: Parameters<typeof router.push>[1]) => {
|
|
47
|
-
return router.push(localizePath(url, locale), options);
|
|
48
|
-
},
|
|
49
|
-
replace: (url: string, options?: Parameters<typeof router.replace>[1]) => {
|
|
50
|
-
return router.replace(localizePath(url, locale), options);
|
|
51
|
-
},
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
<% } else { %>
|
|
55
|
-
/* Single-language store — re-export Next.js navigation as-is */
|
|
56
|
-
'use client';
|
|
57
|
-
|
|
58
|
-
export { default as Link } from 'next/link';
|
|
59
|
-
export { useRouter } from 'next/navigation';
|
|
60
|
-
<% } %>
|
|
1
|
+
<% if (i18nEnabled) { %>
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import NextLink from 'next/link';
|
|
6
|
+
import { useRouter as useNextRouter, usePathname } from 'next/navigation';
|
|
7
|
+
import type { ComponentProps } from 'react';
|
|
8
|
+
|
|
9
|
+
const supportedLocales = <%- supportedLocales %>;
|
|
10
|
+
const defaultLocale = '<%= defaultLocale %>';
|
|
11
|
+
|
|
12
|
+
function getLocale(pathname: string): string {
|
|
13
|
+
const segment = pathname.split('/')[1];
|
|
14
|
+
return supportedLocales.includes(segment) ? segment : defaultLocale;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function localizePath(path: string, locale: string): string {
|
|
18
|
+
if (!path.startsWith('/')) return path;
|
|
19
|
+
// Don't double-prefix
|
|
20
|
+
const firstSeg = path.split('/')[1];
|
|
21
|
+
if (supportedLocales.includes(firstSeg)) return path;
|
|
22
|
+
return `/${locale}${path}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Locale-aware Link — automatically prepends current locale to href.
|
|
27
|
+
*/
|
|
28
|
+
export function Link({ href, ...props }: ComponentProps<typeof NextLink>) {
|
|
29
|
+
const pathname = usePathname();
|
|
30
|
+
const locale = getLocale(pathname);
|
|
31
|
+
const localizedHref =
|
|
32
|
+
typeof href === 'string' ? localizePath(href, locale) : href;
|
|
33
|
+
return <NextLink href={localizedHref} {...props} />;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Locale-aware useRouter — push/replace automatically prepend locale.
|
|
38
|
+
*/
|
|
39
|
+
export function useRouter() {
|
|
40
|
+
const router = useNextRouter();
|
|
41
|
+
const pathname = usePathname();
|
|
42
|
+
const locale = getLocale(pathname);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
...router,
|
|
46
|
+
push: (url: string, options?: Parameters<typeof router.push>[1]) => {
|
|
47
|
+
return router.push(localizePath(url, locale), options);
|
|
48
|
+
},
|
|
49
|
+
replace: (url: string, options?: Parameters<typeof router.replace>[1]) => {
|
|
50
|
+
return router.replace(localizePath(url, locale), options);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
<% } else { %>
|
|
55
|
+
/* Single-language store — re-export Next.js navigation as-is */
|
|
56
|
+
'use client';
|
|
57
|
+
|
|
58
|
+
export { default as Link } from 'next/link';
|
|
59
|
+
export { useRouter } from 'next/navigation';
|
|
60
|
+
<% } %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { headers } from 'next/headers';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reads the per-request CSP nonce set by middleware.
|
|
5
|
+
* Use this in server components that render inline `<script>` tags so
|
|
6
|
+
* they comply with the strict CSP (no `unsafe-inline`).
|
|
7
|
+
*/
|
|
8
|
+
export async function getNonce(): Promise<string | undefined> {
|
|
9
|
+
return (await headers()).get('x-nonce') ?? undefined;
|
|
10
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const ALLOWED_PAYMENT_HOSTS: readonly string[] = [
|
|
2
|
+
'checkout.stripe.com',
|
|
3
|
+
'js.stripe.com',
|
|
4
|
+
'hooks.stripe.com',
|
|
5
|
+
'www.paypal.com',
|
|
6
|
+
'www.sandbox.paypal.com',
|
|
7
|
+
'secure.cardcom.solutions',
|
|
8
|
+
'meshulam.co.il',
|
|
9
|
+
'grow.link',
|
|
10
|
+
'grow.security',
|
|
11
|
+
'creditguard.co.il',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function isAllowedPaymentUrl(url: string): boolean {
|
|
15
|
+
if (!url || typeof url !== 'string') return false;
|
|
16
|
+
|
|
17
|
+
let parsed: URL;
|
|
18
|
+
try {
|
|
19
|
+
parsed = new URL(url);
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (parsed.protocol !== 'https:') return false;
|
|
25
|
+
|
|
26
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
27
|
+
return ALLOWED_PAYMENT_HOSTS.some((host) => hostname === host || hostname.endsWith('.' + host));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function safePaymentRedirect(url: string): void {
|
|
31
|
+
if (!isAllowedPaymentUrl(url)) {
|
|
32
|
+
throw new Error('Payment redirect URL is not in the allowlist');
|
|
33
|
+
}
|
|
34
|
+
if (typeof window !== 'undefined') {
|
|
35
|
+
window.location.href = url;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// CUID format used by Prisma for Checkout.id — c + 24 lowercase alphanumeric chars.
|
|
40
|
+
// Allow a small range to tolerate cuid2 (slightly different length).
|
|
41
|
+
const CHECKOUT_ID_RE = /^c[a-z0-9]{20,30}$/;
|
|
42
|
+
|
|
43
|
+
export function isValidCheckoutId(id: unknown): id is string {
|
|
44
|
+
return typeof id === 'string' && CHECKOUT_ID_RE.test(id);
|
|
45
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import DOMPurify from 'isomorphic-dompurify';
|
|
2
|
+
|
|
3
|
+
const TRUSTED_IFRAME_PREFIXES = [
|
|
4
|
+
'https://www.youtube.com/embed/',
|
|
5
|
+
'https://www.youtube-nocookie.com/embed/',
|
|
6
|
+
'https://player.vimeo.com/video/',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
let hooksRegistered = false;
|
|
10
|
+
|
|
11
|
+
function registerHooksOnce() {
|
|
12
|
+
if (hooksRegistered) return;
|
|
13
|
+
hooksRegistered = true;
|
|
14
|
+
|
|
15
|
+
DOMPurify.addHook('uponSanitizeElement', (node, data) => {
|
|
16
|
+
if (data.tagName !== 'iframe') return;
|
|
17
|
+
const el = node as Element;
|
|
18
|
+
const src = el.getAttribute('src') || '';
|
|
19
|
+
const isTrusted = TRUSTED_IFRAME_PREFIXES.some((prefix) => src.startsWith(prefix));
|
|
20
|
+
if (!isTrusted) {
|
|
21
|
+
el.parentNode?.removeChild(el);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
|
26
|
+
if (!('tagName' in node)) return;
|
|
27
|
+
const el = node as Element;
|
|
28
|
+
if (el.tagName === 'A' && el.getAttribute('target') === '_blank') {
|
|
29
|
+
el.setAttribute('rel', 'noopener noreferrer nofollow');
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function sanitizeProductHtml(html: string): string {
|
|
35
|
+
if (!html) return '';
|
|
36
|
+
registerHooksOnce();
|
|
37
|
+
|
|
38
|
+
return DOMPurify.sanitize(html, {
|
|
39
|
+
ALLOWED_TAGS: [
|
|
40
|
+
'p',
|
|
41
|
+
'br',
|
|
42
|
+
'strong',
|
|
43
|
+
'b',
|
|
44
|
+
'em',
|
|
45
|
+
'i',
|
|
46
|
+
'u',
|
|
47
|
+
's',
|
|
48
|
+
'strike',
|
|
49
|
+
'h1',
|
|
50
|
+
'h2',
|
|
51
|
+
'h3',
|
|
52
|
+
'h4',
|
|
53
|
+
'h5',
|
|
54
|
+
'h6',
|
|
55
|
+
'ul',
|
|
56
|
+
'ol',
|
|
57
|
+
'li',
|
|
58
|
+
'a',
|
|
59
|
+
'blockquote',
|
|
60
|
+
'code',
|
|
61
|
+
'pre',
|
|
62
|
+
'div',
|
|
63
|
+
'span',
|
|
64
|
+
'img',
|
|
65
|
+
'table',
|
|
66
|
+
'thead',
|
|
67
|
+
'tbody',
|
|
68
|
+
'tr',
|
|
69
|
+
'th',
|
|
70
|
+
'td',
|
|
71
|
+
'colgroup',
|
|
72
|
+
'col',
|
|
73
|
+
'iframe',
|
|
74
|
+
],
|
|
75
|
+
ALLOWED_ATTR: [
|
|
76
|
+
'href',
|
|
77
|
+
'target',
|
|
78
|
+
'rel',
|
|
79
|
+
'class',
|
|
80
|
+
'src',
|
|
81
|
+
'alt',
|
|
82
|
+
'width',
|
|
83
|
+
'height',
|
|
84
|
+
'colspan',
|
|
85
|
+
'rowspan',
|
|
86
|
+
'allow',
|
|
87
|
+
'allowfullscreen',
|
|
88
|
+
'frameborder',
|
|
89
|
+
'loading',
|
|
90
|
+
],
|
|
91
|
+
ALLOW_DATA_ATTR: false,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const EMAIL_REGEX =
|
|
2
|
+
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
|
|
3
|
+
|
|
4
|
+
export function isValidEmail(email: string): boolean {
|
|
5
|
+
if (!email || typeof email !== 'string') return false;
|
|
6
|
+
if (email.length > 254) return false;
|
|
7
|
+
return EMAIL_REGEX.test(email);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type PasswordErrorCode =
|
|
11
|
+
| 'passwordRequired'
|
|
12
|
+
| 'passwordTooShort'
|
|
13
|
+
| 'passwordNoUppercase'
|
|
14
|
+
| 'passwordNoNumber'
|
|
15
|
+
| 'passwordNoSymbol';
|
|
16
|
+
|
|
17
|
+
export function getPasswordError(password: string): PasswordErrorCode | null {
|
|
18
|
+
if (!password || typeof password !== 'string') return 'passwordRequired';
|
|
19
|
+
if (password.length < 8) return 'passwordTooShort';
|
|
20
|
+
if (!/[A-Z]/.test(password)) return 'passwordNoUppercase';
|
|
21
|
+
if (!/[0-9]/.test(password)) return 'passwordNoNumber';
|
|
22
|
+
if (!/[^A-Za-z0-9]/.test(password)) return 'passwordNoSymbol';
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PASSWORD_ERROR_MESSAGES: Record<PasswordErrorCode, string> = {
|
|
27
|
+
passwordRequired: 'Password is required',
|
|
28
|
+
passwordTooShort: 'Password must be at least 8 characters',
|
|
29
|
+
passwordNoUppercase: 'Password must contain at least one uppercase letter',
|
|
30
|
+
passwordNoNumber: 'Password must contain at least one number',
|
|
31
|
+
passwordNoSymbol: 'Password must contain at least one special character',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function validatePassword(password: string): string | null {
|
|
35
|
+
const code = getPasswordError(password);
|
|
36
|
+
return code ? PASSWORD_ERROR_MESSAGES[code] : null;
|
|
37
|
+
}
|
|
@@ -11,6 +11,38 @@ function getLocaleFromPath(pathname: string): string | null {
|
|
|
11
11
|
return supportedLocales.includes(segment) ? segment : null;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function generateNonce(): string {
|
|
15
|
+
const bytes = new Uint8Array(16);
|
|
16
|
+
crypto.getRandomValues(bytes);
|
|
17
|
+
let binary = '';
|
|
18
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
19
|
+
return btoa(binary);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildCsp(nonce: string): string {
|
|
23
|
+
return [
|
|
24
|
+
"default-src 'self'",
|
|
25
|
+
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
|
26
|
+
"style-src 'self' 'unsafe-inline' https://cdn.meshulam.co.il",
|
|
27
|
+
"img-src 'self' data: blob: https:",
|
|
28
|
+
"font-src 'self' data:",
|
|
29
|
+
"frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com",
|
|
30
|
+
"connect-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://pay.google.com https://*.stripe.com https://*.creditguard.co.il",
|
|
31
|
+
"worker-src 'self' blob:",
|
|
32
|
+
"frame-ancestors 'none'",
|
|
33
|
+
"base-uri 'self'",
|
|
34
|
+
"form-action 'self'",
|
|
35
|
+
"object-src 'none'",
|
|
36
|
+
'upgrade-insecure-requests',
|
|
37
|
+
].join('; ');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function applyCspHeaders(response: NextResponse, nonce: string): NextResponse {
|
|
41
|
+
response.headers.set('Content-Security-Policy', buildCsp(nonce));
|
|
42
|
+
response.headers.set('x-nonce', nonce);
|
|
43
|
+
return response;
|
|
44
|
+
}
|
|
45
|
+
|
|
14
46
|
export function middleware(request: NextRequest) {
|
|
15
47
|
const { pathname } = request.nextUrl;
|
|
16
48
|
|
|
@@ -23,13 +55,17 @@ export function middleware(request: NextRequest) {
|
|
|
23
55
|
return NextResponse.next();
|
|
24
56
|
}
|
|
25
57
|
|
|
58
|
+
const nonce = generateNonce();
|
|
59
|
+
const requestHeaders = new Headers(request.headers);
|
|
60
|
+
requestHeaders.set('x-nonce', nonce);
|
|
61
|
+
|
|
26
62
|
const pathnameLocale = getLocaleFromPath(pathname);
|
|
27
63
|
|
|
28
64
|
// Redirect to default locale if no locale prefix
|
|
29
65
|
if (!pathnameLocale) {
|
|
30
66
|
const url = request.nextUrl.clone();
|
|
31
67
|
url.pathname = `/${defaultLocale}${pathname}`;
|
|
32
|
-
return NextResponse.redirect(url);
|
|
68
|
+
return applyCspHeaders(NextResponse.redirect(url), nonce);
|
|
33
69
|
}
|
|
34
70
|
|
|
35
71
|
// Auth protection (with locale prefix)
|
|
@@ -39,14 +75,14 @@ export function middleware(request: NextRequest) {
|
|
|
39
75
|
const token = request.cookies.get(TOKEN_COOKIE);
|
|
40
76
|
if (!token?.value) {
|
|
41
77
|
const loginUrl = new URL(`/${pathnameLocale}/login`, request.url);
|
|
42
|
-
return NextResponse.redirect(loginUrl);
|
|
78
|
+
return applyCspHeaders(NextResponse.redirect(loginUrl), nonce);
|
|
43
79
|
}
|
|
44
80
|
}
|
|
45
81
|
|
|
46
|
-
// Set locale header for server components
|
|
47
|
-
const response = NextResponse.next();
|
|
82
|
+
// Set locale header for server components + forward nonce
|
|
83
|
+
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
48
84
|
response.headers.set('x-locale', pathnameLocale);
|
|
49
|
-
return response;
|
|
85
|
+
return applyCspHeaders(response, nonce);
|
|
50
86
|
}
|
|
51
87
|
|
|
52
88
|
export const config = {
|
|
@@ -60,22 +96,69 @@ const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
|
60
96
|
/** Routes that require customer authentication */
|
|
61
97
|
const PROTECTED_PATHS = ['/account'];
|
|
62
98
|
|
|
99
|
+
function generateNonce(): string {
|
|
100
|
+
const bytes = new Uint8Array(16);
|
|
101
|
+
crypto.getRandomValues(bytes);
|
|
102
|
+
let binary = '';
|
|
103
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
104
|
+
return btoa(binary);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function buildCsp(nonce: string): string {
|
|
108
|
+
return [
|
|
109
|
+
"default-src 'self'",
|
|
110
|
+
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
|
111
|
+
"style-src 'self' 'unsafe-inline' https://cdn.meshulam.co.il",
|
|
112
|
+
"img-src 'self' data: blob: https:",
|
|
113
|
+
"font-src 'self' data:",
|
|
114
|
+
"frame-src 'self' https://meshulam.co.il https://*.meshulam.co.il https://grow.link https://*.grow.link https://grow.security https://*.grow.security https://creditguard.co.il https://*.creditguard.co.il https://js.stripe.com https://hooks.stripe.com https://pay.google.com https://secure.cardcom.solutions https://checkout.stripe.com https://www.paypal.com https://www.sandbox.paypal.com",
|
|
115
|
+
"connect-src 'self' https://*.meshulam.co.il https://grow.link https://*.grow.link https://*.grow.security https://pay.google.com https://*.stripe.com https://*.creditguard.co.il",
|
|
116
|
+
"worker-src 'self' blob:",
|
|
117
|
+
"frame-ancestors 'none'",
|
|
118
|
+
"base-uri 'self'",
|
|
119
|
+
"form-action 'self'",
|
|
120
|
+
"object-src 'none'",
|
|
121
|
+
'upgrade-insecure-requests',
|
|
122
|
+
].join('; ');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function applyCspHeaders(response: NextResponse, nonce: string): NextResponse {
|
|
126
|
+
response.headers.set('Content-Security-Policy', buildCsp(nonce));
|
|
127
|
+
response.headers.set('x-nonce', nonce);
|
|
128
|
+
return response;
|
|
129
|
+
}
|
|
130
|
+
|
|
63
131
|
export function middleware(request: NextRequest) {
|
|
64
132
|
const { pathname } = request.nextUrl;
|
|
133
|
+
|
|
134
|
+
// Skip static files and API routes
|
|
135
|
+
if (
|
|
136
|
+
pathname.startsWith('/api/') ||
|
|
137
|
+
pathname.startsWith('/_next/') ||
|
|
138
|
+
pathname.includes('.')
|
|
139
|
+
) {
|
|
140
|
+
return NextResponse.next();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const nonce = generateNonce();
|
|
144
|
+
const requestHeaders = new Headers(request.headers);
|
|
145
|
+
requestHeaders.set('x-nonce', nonce);
|
|
146
|
+
|
|
65
147
|
const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p));
|
|
66
148
|
|
|
67
149
|
if (isProtected) {
|
|
68
150
|
const token = request.cookies.get(TOKEN_COOKIE);
|
|
69
151
|
if (!token?.value) {
|
|
70
152
|
const loginUrl = new URL('/login', request.url);
|
|
71
|
-
return NextResponse.redirect(loginUrl);
|
|
153
|
+
return applyCspHeaders(NextResponse.redirect(loginUrl), nonce);
|
|
72
154
|
}
|
|
73
155
|
}
|
|
74
156
|
|
|
75
|
-
|
|
157
|
+
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
158
|
+
return applyCspHeaders(response, nonce);
|
|
76
159
|
}
|
|
77
160
|
|
|
78
161
|
export const config = {
|
|
79
|
-
matcher: ['/
|
|
162
|
+
matcher: ['/((?!_next|api|.*\\..*).*)'],
|
|
80
163
|
};
|
|
81
164
|
<% } %>
|