create-brainerce-store 1.6.1 → 1.8.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 +1 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +3 -1
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +14 -0
- package/templates/nextjs/base/src/app/api/auth/me/route.ts +49 -0
- package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +59 -0
- package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +41 -0
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +68 -0
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +190 -0
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +92 -90
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +112 -111
- package/templates/nextjs/base/src/app/login/page.tsx +59 -58
- package/templates/nextjs/base/src/app/products/page.tsx +204 -16
- package/templates/nextjs/base/src/app/register/page.tsx +64 -68
- package/templates/nextjs/base/src/app/reset-password/page.tsx +132 -161
- package/templates/nextjs/base/src/app/verify-email/page.tsx +258 -293
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +379 -372
- package/templates/nextjs/base/src/lib/auth.ts +148 -0
- package/templates/nextjs/base/src/lib/brainerce.ts.ejs +6 -26
- package/templates/nextjs/base/src/middleware.ts +25 -0
- package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +50 -27
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side auth helpers that call the BFF proxy API routes.
|
|
3
|
+
* All mutating requests include the CSRF header.
|
|
4
|
+
* The token is managed server-side via httpOnly cookies — never exposed to JS.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '';
|
|
8
|
+
|
|
9
|
+
const CSRF_HEADERS: Record<string, string> = {
|
|
10
|
+
'Content-Type': 'application/json',
|
|
11
|
+
'X-Requested-With': 'brainerce',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
interface LoginResult {
|
|
15
|
+
customer: {
|
|
16
|
+
id: string;
|
|
17
|
+
email: string;
|
|
18
|
+
firstName?: string;
|
|
19
|
+
lastName?: string;
|
|
20
|
+
emailVerified: boolean;
|
|
21
|
+
};
|
|
22
|
+
expiresAt: string;
|
|
23
|
+
requiresVerification?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RegisterResult {
|
|
27
|
+
customer: {
|
|
28
|
+
id: string;
|
|
29
|
+
email: string;
|
|
30
|
+
firstName?: string;
|
|
31
|
+
lastName?: string;
|
|
32
|
+
emailVerified: boolean;
|
|
33
|
+
};
|
|
34
|
+
expiresAt: string;
|
|
35
|
+
requiresVerification?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface AuthStatus {
|
|
39
|
+
isLoggedIn: boolean;
|
|
40
|
+
customer?: {
|
|
41
|
+
id: string;
|
|
42
|
+
email: string;
|
|
43
|
+
firstName?: string;
|
|
44
|
+
lastName?: string;
|
|
45
|
+
phone?: string;
|
|
46
|
+
emailVerified: boolean;
|
|
47
|
+
};
|
|
48
|
+
error?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface VerifyEmailResult {
|
|
52
|
+
verified: boolean;
|
|
53
|
+
message?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function handleResponse<T>(response: Response): Promise<T> {
|
|
57
|
+
const data = await response.json();
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new Error(data.message || data.error || `Request failed (${response.status})`);
|
|
60
|
+
}
|
|
61
|
+
return data as T;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Login via BFF proxy. The proxy sets the httpOnly cookie on success.
|
|
66
|
+
*/
|
|
67
|
+
export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
|
|
68
|
+
const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/login`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: CSRF_HEADERS,
|
|
71
|
+
body: JSON.stringify({ email, password }),
|
|
72
|
+
});
|
|
73
|
+
return handleResponse<LoginResult>(response);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Register via BFF proxy. The proxy sets the httpOnly cookie on success.
|
|
78
|
+
*/
|
|
79
|
+
export async function proxyRegister(data: {
|
|
80
|
+
firstName: string;
|
|
81
|
+
lastName: string;
|
|
82
|
+
email: string;
|
|
83
|
+
password: string;
|
|
84
|
+
}): Promise<RegisterResult> {
|
|
85
|
+
const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/register`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: CSRF_HEADERS,
|
|
88
|
+
body: JSON.stringify(data),
|
|
89
|
+
});
|
|
90
|
+
return handleResponse<RegisterResult>(response);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Check auth status. Reads httpOnly cookie server-side and validates with backend.
|
|
95
|
+
*/
|
|
96
|
+
export async function checkAuthStatus(): Promise<AuthStatus> {
|
|
97
|
+
const response = await fetch('/api/auth/me');
|
|
98
|
+
return response.json();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Logout. Clears httpOnly auth cookies server-side.
|
|
103
|
+
*/
|
|
104
|
+
export async function proxyLogout(): Promise<void> {
|
|
105
|
+
await fetch('/api/auth/logout', {
|
|
106
|
+
method: 'POST',
|
|
107
|
+
headers: { 'X-Requested-With': 'brainerce' },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Verify email via BFF proxy. The auth token is in the httpOnly cookie (set during login/register).
|
|
113
|
+
* The proxy adds the Authorization header automatically.
|
|
114
|
+
*/
|
|
115
|
+
export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
|
|
116
|
+
const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/verify-email`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: CSRF_HEADERS,
|
|
119
|
+
body: JSON.stringify({ code }),
|
|
120
|
+
});
|
|
121
|
+
return handleResponse<VerifyEmailResult>(response);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resend verification email via BFF proxy.
|
|
126
|
+
* Uses the auth token from the httpOnly cookie.
|
|
127
|
+
*/
|
|
128
|
+
export async function proxyResendVerification(): Promise<{ message: string }> {
|
|
129
|
+
const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/resend-verification`, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: CSRF_HEADERS,
|
|
132
|
+
});
|
|
133
|
+
return handleResponse<{ message: string }>(response);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Reset password via BFF proxy.
|
|
138
|
+
* The reset token is in an httpOnly cookie (set by /api/auth/reset-callback when the user
|
|
139
|
+
* clicked the email link). The proxy reads it server-side — the token never reaches client JS.
|
|
140
|
+
*/
|
|
141
|
+
export async function proxyResetPassword(newPassword: string): Promise<{ message: string }> {
|
|
142
|
+
const response = await fetch('/api/auth/reset-password', {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: CSRF_HEADERS,
|
|
145
|
+
body: JSON.stringify({ newPassword }),
|
|
146
|
+
});
|
|
147
|
+
return handleResponse<{ message: string }>(response);
|
|
148
|
+
}
|
|
@@ -1,39 +1,24 @@
|
|
|
1
1
|
import { BrainerceClient } from 'brainerce';
|
|
2
2
|
|
|
3
3
|
const CONNECTION_ID = process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID || '<%= connectionId %>';
|
|
4
|
-
const API_URL = process.env.NEXT_PUBLIC_BRAINERCE_API_URL || '<%= apiBaseUrl %>';
|
|
5
4
|
|
|
6
|
-
// Singleton SDK client
|
|
5
|
+
// Singleton SDK client — routes through same-origin BFF proxy for httpOnly cookie auth
|
|
7
6
|
let clientInstance: BrainerceClient | null = null;
|
|
8
7
|
|
|
9
8
|
export function getClient(): BrainerceClient {
|
|
10
9
|
if (!clientInstance) {
|
|
11
10
|
clientInstance = new BrainerceClient({
|
|
12
11
|
connectionId: CONNECTION_ID,
|
|
13
|
-
baseUrl:
|
|
12
|
+
baseUrl: '/api/store', // same-origin proxy handles auth via httpOnly cookie
|
|
13
|
+
proxyMode: true, // skip client-side token checks; proxy adds Authorization header
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
16
|
return clientInstance;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
//
|
|
20
|
-
const TOKEN_KEY = 'brainerce_customer_token';
|
|
19
|
+
// Cart ID helpers (not a security token — safe in localStorage)
|
|
21
20
|
const CART_ID_KEY = 'brainerce_cart_id';
|
|
22
21
|
|
|
23
|
-
export function getStoredToken(): string | null {
|
|
24
|
-
if (typeof window === 'undefined') return null;
|
|
25
|
-
return localStorage.getItem(TOKEN_KEY);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function setStoredToken(token: string | null): void {
|
|
29
|
-
if (typeof window === 'undefined') return;
|
|
30
|
-
if (token) {
|
|
31
|
-
localStorage.setItem(TOKEN_KEY, token);
|
|
32
|
-
} else {
|
|
33
|
-
localStorage.removeItem(TOKEN_KEY);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
22
|
export function getStoredCartId(): string | null {
|
|
38
23
|
if (typeof window === 'undefined') return null;
|
|
39
24
|
return localStorage.getItem(CART_ID_KEY);
|
|
@@ -48,12 +33,7 @@ export function setStoredCartId(cartId: string | null): void {
|
|
|
48
33
|
}
|
|
49
34
|
}
|
|
50
35
|
|
|
51
|
-
// Initialize client
|
|
36
|
+
// Initialize client (no token hydration — auth handled by httpOnly cookie)
|
|
52
37
|
export function initClient(): BrainerceClient {
|
|
53
|
-
|
|
54
|
-
const token = getStoredToken();
|
|
55
|
-
if (token) {
|
|
56
|
-
client.setCustomerToken(token);
|
|
57
|
-
}
|
|
58
|
-
return client;
|
|
38
|
+
return getClient();
|
|
59
39
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
2
|
+
|
|
3
|
+
const TOKEN_COOKIE = 'brainerce_customer_token';
|
|
4
|
+
|
|
5
|
+
/** Routes that require customer authentication */
|
|
6
|
+
const PROTECTED_PATHS = ['/account'];
|
|
7
|
+
|
|
8
|
+
export function middleware(request: NextRequest) {
|
|
9
|
+
const { pathname } = request.nextUrl;
|
|
10
|
+
const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p));
|
|
11
|
+
|
|
12
|
+
if (isProtected) {
|
|
13
|
+
const token = request.cookies.get(TOKEN_COOKIE);
|
|
14
|
+
if (!token?.value) {
|
|
15
|
+
const loginUrl = new URL('/login', request.url);
|
|
16
|
+
return NextResponse.redirect(loginUrl);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return NextResponse.next();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const config = {
|
|
24
|
+
matcher: ['/account/:path*'],
|
|
25
|
+
};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
4
|
-
import type { StoreInfo, Cart } from 'brainerce';
|
|
4
|
+
import type { StoreInfo, Cart, CustomerProfile } from 'brainerce';
|
|
5
5
|
import { getCartTotals } from 'brainerce';
|
|
6
|
-
import { getClient, initClient,
|
|
6
|
+
import { getClient, initClient, setStoredCartId } from '@/lib/brainerce';
|
|
7
|
+
import { checkAuthStatus, proxyLogout } from '@/lib/auth';
|
|
7
8
|
|
|
8
9
|
// ---- Store Info Context ----
|
|
9
10
|
interface StoreInfoContextValue {
|
|
@@ -24,17 +25,17 @@ export function useStoreInfo() {
|
|
|
24
25
|
interface AuthContextValue {
|
|
25
26
|
isLoggedIn: boolean;
|
|
26
27
|
authLoading: boolean;
|
|
27
|
-
|
|
28
|
-
login: (
|
|
29
|
-
logout: () => void
|
|
28
|
+
customer: CustomerProfile | null;
|
|
29
|
+
login: () => Promise<void>;
|
|
30
|
+
logout: () => Promise<void>;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
const AuthContext = createContext<AuthContextValue>({
|
|
33
34
|
isLoggedIn: false,
|
|
34
35
|
authLoading: true,
|
|
35
|
-
|
|
36
|
-
login: () => {},
|
|
37
|
-
logout: () => {},
|
|
36
|
+
customer: null,
|
|
37
|
+
login: async () => {},
|
|
38
|
+
logout: async () => {},
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
export function useAuth() {
|
|
@@ -66,26 +67,46 @@ export function useCart() {
|
|
|
66
67
|
export function StoreProvider({ children }: { children: React.ReactNode }) {
|
|
67
68
|
const [storeInfo, setStoreInfo] = useState<StoreInfo | null>(null);
|
|
68
69
|
const [storeLoading, setStoreLoading] = useState(true);
|
|
69
|
-
const [
|
|
70
|
+
const [isLoggedIn, setIsLoggedIn] = useState(false);
|
|
71
|
+
const [customer, setCustomer] = useState<CustomerProfile | null>(null);
|
|
70
72
|
const [authLoading, setAuthLoading] = useState(true);
|
|
71
73
|
const [cart, setCart] = useState<Cart | null>(null);
|
|
72
74
|
const [cartLoading, setCartLoading] = useState(true);
|
|
73
75
|
|
|
74
|
-
//
|
|
76
|
+
// Check auth status via httpOnly cookie (server-side validation)
|
|
77
|
+
const refreshAuth = useCallback(async () => {
|
|
78
|
+
try {
|
|
79
|
+
const status = await checkAuthStatus();
|
|
80
|
+
setIsLoggedIn(status.isLoggedIn);
|
|
81
|
+
setCustomer(status.isLoggedIn ? (status.customer as CustomerProfile) : null);
|
|
82
|
+
} catch {
|
|
83
|
+
setIsLoggedIn(false);
|
|
84
|
+
setCustomer(null);
|
|
85
|
+
} finally {
|
|
86
|
+
setAuthLoading(false);
|
|
87
|
+
}
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
// Initialize client, check auth, and fetch store info
|
|
75
91
|
useEffect(() => {
|
|
76
92
|
const client = initClient();
|
|
77
|
-
|
|
78
|
-
if
|
|
79
|
-
|
|
93
|
+
|
|
94
|
+
// Optimistic check: if brainerce_logged_in cookie exists, assume logged in
|
|
95
|
+
// while we validate the actual token server-side
|
|
96
|
+
if (typeof document !== 'undefined' && document.cookie.includes('brainerce_logged_in=1')) {
|
|
97
|
+
setIsLoggedIn(true);
|
|
80
98
|
}
|
|
81
|
-
setAuthLoading(false);
|
|
82
99
|
|
|
100
|
+
// Validate auth token server-side
|
|
101
|
+
refreshAuth();
|
|
102
|
+
|
|
103
|
+
// Fetch store info (public, no auth needed)
|
|
83
104
|
client
|
|
84
105
|
.getStoreInfo()
|
|
85
106
|
.then(setStoreInfo)
|
|
86
107
|
.catch(console.error)
|
|
87
108
|
.finally(() => setStoreLoading(false));
|
|
88
|
-
}, []);
|
|
109
|
+
}, [refreshAuth]);
|
|
89
110
|
|
|
90
111
|
// Cart management
|
|
91
112
|
const refreshCart = useCallback(async () => {
|
|
@@ -108,24 +129,26 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
|
|
|
108
129
|
|
|
109
130
|
useEffect(() => {
|
|
110
131
|
refreshCart();
|
|
111
|
-
}, [refreshCart,
|
|
132
|
+
}, [refreshCart, isLoggedIn]);
|
|
112
133
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
setToken(newToken);
|
|
134
|
+
// Called after successful login (cookie already set by proxy)
|
|
135
|
+
const login = useCallback(async () => {
|
|
136
|
+
// Refresh auth state from server (reads httpOnly cookie)
|
|
137
|
+
await refreshAuth();
|
|
118
138
|
|
|
119
139
|
// Merge guest session cart into customer cart
|
|
140
|
+
const client = getClient();
|
|
120
141
|
client.syncCartOnLogin().catch(console.error);
|
|
121
|
-
}, []);
|
|
142
|
+
}, [refreshAuth]);
|
|
143
|
+
|
|
144
|
+
const logout = useCallback(async () => {
|
|
145
|
+
// Clear httpOnly cookie server-side
|
|
146
|
+
await proxyLogout();
|
|
122
147
|
|
|
123
|
-
const logout = useCallback(() => {
|
|
124
148
|
const client = getClient();
|
|
125
|
-
client.clearCustomerToken();
|
|
126
149
|
client.onLogout();
|
|
127
|
-
|
|
128
|
-
|
|
150
|
+
setIsLoggedIn(false);
|
|
151
|
+
setCustomer(null);
|
|
129
152
|
setCart(null);
|
|
130
153
|
refreshCart();
|
|
131
154
|
}, [refreshCart]);
|
|
@@ -138,7 +161,7 @@ export function StoreProvider({ children }: { children: React.ReactNode }) {
|
|
|
138
161
|
|
|
139
162
|
return (
|
|
140
163
|
<StoreInfoContext.Provider value={{ storeInfo, loading: storeLoading }}>
|
|
141
|
-
<AuthContext.Provider value={{ isLoggedIn
|
|
164
|
+
<AuthContext.Provider value={{ isLoggedIn, authLoading, customer, login, logout }}>
|
|
142
165
|
<CartContext.Provider
|
|
143
166
|
value={{ cart, cartLoading, refreshCart, itemCount, totals }}
|
|
144
167
|
>
|