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.
@@ -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: API_URL,
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
- // Auth token helpers
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 with stored auth
36
+ // Initialize client (no token hydration — auth handled by httpOnly cookie)
52
37
  export function initClient(): BrainerceClient {
53
- const client = getClient();
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, getStoredToken, setStoredToken, setStoredCartId } from '@/lib/brainerce';
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
- token: string | null;
28
- login: (token: string) => void;
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
- token: null,
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 [token, setToken] = useState<string | null>(null);
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
- // Initialize client and auth
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
- const stored = getStoredToken();
78
- if (stored) {
79
- setToken(stored);
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, token]);
132
+ }, [refreshCart, isLoggedIn]);
112
133
 
113
- const login = useCallback((newToken: string) => {
114
- const client = getClient();
115
- client.setCustomerToken(newToken);
116
- setStoredToken(newToken);
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
- setStoredToken(null);
128
- setToken(null);
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: !!token, authLoading, token, login, logout }}>
164
+ <AuthContext.Provider value={{ isLoggedIn, authLoading, customer, login, logout }}>
142
165
  <CartContext.Provider
143
166
  value={{ cart, cartLoading, refreshCart, itemCount, totals }}
144
167
  >