From 5cc3cea55e3e27105a37f1b686c459367c1120b9 Mon Sep 17 00:00:00 2001 From: mark wang Date: Wed, 13 Aug 2025 18:44:40 +1000 Subject: [PATCH 1/8] httponly cookie frontend --- .../(public)/blogs/components/BlogList.tsx | 2 +- src/app/(public)/blogs/page.tsx | 1 + .../(public)/login/component/LoginForm.tsx | 6 +-- src/app/(public)/reduxtest/page.tsx | 9 +++-- .../(public)/signup/component/SignupForm.tsx | 6 +-- src/app/admin/layout.tsx | 27 ++++++++++--- src/app/auth/callback/AuthCallbackContent.tsx | 7 ++-- src/app/onboarding/layout.tsx | 12 ++++-- src/features/auth/authApi.ts | 39 +++++++++++++++---- src/features/auth/authSlice.ts | 20 +++++++--- src/lib/axiosBaseQuery.ts | 19 +++++++-- 11 files changed, 111 insertions(+), 37 deletions(-) diff --git a/src/app/(public)/blogs/components/BlogList.tsx b/src/app/(public)/blogs/components/BlogList.tsx index efe3d5b..8e4cb41 100644 --- a/src/app/(public)/blogs/components/BlogList.tsx +++ b/src/app/(public)/blogs/components/BlogList.tsx @@ -36,7 +36,7 @@ export default function BlogList() { const topic = searchParams.get('topic') ?? ''; const limit = 9; const page = Number(searchParams.get('page') ?? '1'); - const [total, setTotal] = useState(0); + const [, setTotal] = useState(0); useEffect(() => { const fetchBlogs = async () => { diff --git a/src/app/(public)/blogs/page.tsx b/src/app/(public)/blogs/page.tsx index ac519d6..f0d97c0 100644 --- a/src/app/(public)/blogs/page.tsx +++ b/src/app/(public)/blogs/page.tsx @@ -34,6 +34,7 @@ export default function BlogsPage() { setHighlightBlogs(res.data); } catch (error) { if (!axios.isCancel(error)) { + // eslint-disable-next-line no-console console.error('Failed to fetch highlight blogs:', error); } } diff --git a/src/app/(public)/login/component/LoginForm.tsx b/src/app/(public)/login/component/LoginForm.tsx index f5bb4b1..57f73e0 100644 --- a/src/app/(public)/login/component/LoginForm.tsx +++ b/src/app/(public)/login/component/LoginForm.tsx @@ -108,14 +108,14 @@ export default function LoginForm() { const [loginUser, { isLoading, error }] = useLoginUserMutation(); const [showPassword, setShowPassword] = useState(false); - const token = useAppSelector(s => s.auth.token); + const isAuthenticated = useAppSelector(s => s.auth.isAuthenticated); const router = useRouter(); useEffect(() => { - if (token) { + if (isAuthenticated) { router.replace('/admin/overview'); } - }, [token, router]); + }, [isAuthenticated, router]); const onSubmit = async (data: LoginFormData) => { await loginUser({ email: data.workEmail, password: data.password }); diff --git a/src/app/(public)/reduxtest/page.tsx b/src/app/(public)/reduxtest/page.tsx index 2e715d5..8ea53fc 100644 --- a/src/app/(public)/reduxtest/page.tsx +++ b/src/app/(public)/reduxtest/page.tsx @@ -9,19 +9,22 @@ import { useAppDispatch, useAppSelector } from '@/redux/hooks'; export default function ReduxTestPage() { const dispatch = useAppDispatch(); - const token = useAppSelector(state => state.auth.token); + const isAuthenticated = useAppSelector(state => state.auth.isAuthenticated); const user = useAppSelector(state => state.auth.user); + const csrfToken = useAppSelector(state => state.auth.csrfToken); useEffect(() => { // eslint-disable-next-line no-console console.groupCollapsed('🔑 Auth State'); // eslint-disable-next-line no-console - console.log('Token:', token); + console.log('Authenticated:', isAuthenticated); // eslint-disable-next-line no-console console.log('User:', user); // eslint-disable-next-line no-console + console.log('CSRF Token:', csrfToken); + // eslint-disable-next-line no-console console.groupEnd(); - }, [token, user]); + }, [isAuthenticated, user, csrfToken]); const [triggerUnauthorized, { isFetching, error }] = useLazyGetUnauthorizedQuery(); diff --git a/src/app/(public)/signup/component/SignupForm.tsx b/src/app/(public)/signup/component/SignupForm.tsx index 1614bbe..08b68bd 100644 --- a/src/app/(public)/signup/component/SignupForm.tsx +++ b/src/app/(public)/signup/component/SignupForm.tsx @@ -132,7 +132,7 @@ const ErrorMessage = styled.div` export default function SignupForm() { const router = useRouter(); - const token = useAppSelector(s => s.auth.token); + const isAuthenticated = useAppSelector(s => s.auth.isAuthenticated); const [signupUser, { isLoading, error }] = useSignupUserMutation(); const [showPassword, setShowPassword] = useState(false); @@ -143,10 +143,10 @@ export default function SignupForm() { }); useEffect(() => { - if (token) { + if (isAuthenticated) { router.replace('/admin/overview'); } - }, [token, router]); + }, [isAuthenticated, router]); const onSubmit = async (vals: SignupFormData) => { const payload = { diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 03dd7f9..7b2c11d 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -6,6 +6,7 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { usePathname, useRouter } from 'next/navigation'; import { type ReactNode, useEffect, useState } from 'react'; +import { useCheckAuthStatusQuery } from '@/features/auth/authApi'; import { useGetProgressQuery, // ← RTK-Query hook } from '@/features/onboarding/onboardingApi'; @@ -13,20 +14,28 @@ import { useAppSelector } from '@/redux/hooks'; export default function ProtectedLayout({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); - const token = useAppSelector(s => s.auth.token); + const isAuthenticated = useAppSelector(s => s.auth.isAuthenticated); const user = useAppSelector(s => s.auth.user); const userId = user?._id; + + // Check authentication status using cookies + const { isLoading: isCheckingAuth } = useCheckAuthStatusQuery(); + const { data: progress, // { currentStep, answers, status } isFetching, } = useGetProgressQuery(userId ?? skipToken); const router = useRouter(); const pathname = usePathname(); + useEffect(() => { - // Wait for hydration and then check auth status + // Wait for hydration and auth check, then check auth status const timer = setTimeout(() => { + // Don't proceed if still checking authentication + if (isCheckingAuth) return; + // check if logged in - if (!token || !user) { + if (!isAuthenticated || !user) { router.replace('/login'); return; } @@ -43,9 +52,17 @@ export default function ProtectedLayout({ children }: { children: ReactNode }) { }, 0); return () => clearTimeout(timer); - }, [token, user, router, pathname, isFetching, progress]); + }, [ + isAuthenticated, + user, + router, + pathname, + isFetching, + progress, + isCheckingAuth, + ]); - if (!ready) { + if (!ready || isCheckingAuth) { return ( { - const token = searchParams.get('token'); + // Now we expect csrfToken and user (JWT token is in httpOnly cookie) + const csrfToken = searchParams.get('csrfToken'); const userString = searchParams.get('user'); - if (token && userString) { + if (csrfToken && userString) { try { const parsedUser = JSON.parse( decodeURIComponent(userString), @@ -47,7 +48,7 @@ export default function AuthCallbackContent() { dispatch( setCredentials({ - token, + csrfToken, user: { _id: parsedUser._id, email: parsedUser.email, diff --git a/src/app/onboarding/layout.tsx b/src/app/onboarding/layout.tsx index 3a04cf2..92055c9 100644 --- a/src/app/onboarding/layout.tsx +++ b/src/app/onboarding/layout.tsx @@ -4,6 +4,7 @@ import { usePathname, useRouter } from 'next/navigation'; import { type ReactNode, useEffect, useState } from 'react'; import OnboardingLayout from '@/components/layout/onboarding-layout'; +import { useCheckAuthStatusQuery } from '@/features/auth/authApi'; import { useAppSelector } from '@/redux/hooks'; export default function OnboardingProtectedLayout({ @@ -11,7 +12,10 @@ export default function OnboardingProtectedLayout({ }: { children: ReactNode; }) { - const token = useAppSelector(s => s.auth.token); + const isAuthenticated = useAppSelector(s => s.auth.isAuthenticated); + + // Check authentication status using cookies + const { isLoading: isCheckingAuth } = useCheckAuthStatusQuery(); const router = useRouter(); const pathname = usePathname(); @@ -22,12 +26,12 @@ export default function OnboardingProtectedLayout({ }, []); useEffect(() => { - if (ready && !token) { + if (ready && !isCheckingAuth && !isAuthenticated) { router.replace('/login'); } - }, [ready, token, pathname, router]); + }, [ready, isAuthenticated, pathname, router, isCheckingAuth]); - if (!ready || !token) return null; + if (!ready || isCheckingAuth || !isAuthenticated) return null; return {children}; } diff --git a/src/features/auth/authApi.ts b/src/features/auth/authApi.ts index 526e293..1a32359 100644 --- a/src/features/auth/authApi.ts +++ b/src/features/auth/authApi.ts @@ -9,9 +9,9 @@ interface LoginDTO { password: string; } -interface LoginResp { - token: string; +interface AuthResponse { user: UserInfo; + csrfToken: string; } interface SignupDTO { @@ -19,16 +19,17 @@ interface SignupDTO { email: string; password: string; } -interface SignupResp { - token: string; + +interface AuthStatusResponse { user: UserInfo; } export const authApi = createApi({ reducerPath: 'authApi', baseQuery: axiosBaseQuery(), + tagTypes: ['User'], endpoints: builder => ({ - loginUser: builder.mutation({ + loginUser: builder.mutation({ query: body => ({ url: '/auth/login', method: 'POST', @@ -42,8 +43,9 @@ export const authApi = createApi({ return; } }, + invalidatesTags: ['User'], }), - signupUser: builder.mutation({ + signupUser: builder.mutation({ query: body => ({ url: '/auth/signup', method: 'POST', @@ -57,12 +59,33 @@ export const authApi = createApi({ return; } }, + invalidatesTags: ['User'], }), - logoutUser: builder.mutation<{ message: string }, null>({ + logoutUser: builder.mutation<{ message: string }, void>({ query: () => ({ url: '/auth/logout', method: 'POST' }), onQueryStarted(_, { dispatch }) { dispatch(logout()); }, + invalidatesTags: ['User'], + }), + checkAuthStatus: builder.query({ + query: () => ({ url: '/auth/me', method: 'GET' }), + async onQueryStarted(_, { dispatch, queryFulfilled }) { + try { + const { data } = await queryFulfilled; + // If we can get user data, we're authenticated (cookie is valid) + dispatch( + setCredentials({ + user: data.user, + csrfToken: '', // CSRF token will be set from login/signup + }), + ); + } catch { + // If request fails, user is not authenticated + dispatch(logout()); + } + }, + providesTags: ['User'], }), }), }); @@ -71,4 +94,6 @@ export const { useLoginUserMutation, useLogoutUserMutation, useSignupUserMutation, + useCheckAuthStatusQuery, + useLazyCheckAuthStatusQuery, } = authApi; diff --git a/src/features/auth/authSlice.ts b/src/features/auth/authSlice.ts index 41f35b0..9a7187c 100644 --- a/src/features/auth/authSlice.ts +++ b/src/features/auth/authSlice.ts @@ -5,14 +5,20 @@ import { createSlice } from '@reduxjs/toolkit'; import type { UserInfo } from '@/types/user.d'; interface AuthState { - token: string | null; user: UserInfo | null; + isAuthenticated: boolean; + csrfToken: string | null; } -const initialState: AuthState = { token: null, user: null }; + +const initialState: AuthState = { + user: null, + isAuthenticated: false, + csrfToken: null, +}; interface Credentials { - token: string; user: UserInfo; + csrfToken: string; } const authSlice = createSlice({ @@ -20,12 +26,16 @@ const authSlice = createSlice({ initialState, reducers: { setCredentials: (state, action: PayloadAction) => { - state.token = action.payload.token; state.user = action.payload.user; + state.isAuthenticated = true; + state.csrfToken = action.payload.csrfToken; + }, + updateCSRFToken: (state, action: PayloadAction) => { + state.csrfToken = action.payload; }, logout: () => ({ ...initialState }), }, }); -export const { setCredentials, logout } = authSlice.actions; +export const { setCredentials, updateCSRFToken, logout } = authSlice.actions; export default authSlice.reducer; diff --git a/src/lib/axiosBaseQuery.ts b/src/lib/axiosBaseQuery.ts index f605e10..ef662fa 100644 --- a/src/lib/axiosBaseQuery.ts +++ b/src/lib/axiosBaseQuery.ts @@ -16,16 +16,17 @@ export const axiosBaseQuery = (): BaseQueryFn< method?: AxiosRequestConfig['method']; data?: unknown; params?: Record; + headers?: Record; }, unknown, { status?: number; data?: string } > => { return async ( - { url, method = 'GET', data, params }, + { url, method = 'GET', data, params, headers }, { dispatch, getState }, ) => { try { - const token = (getState() as RootState).auth.token; + const { csrfToken } = (getState() as RootState).auth; const result = await axios({ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, @@ -33,7 +34,19 @@ export const axiosBaseQuery = (): BaseQueryFn< method, data, params, - headers: token ? { Authorization: `Bearer ${token}` } : undefined, + headers: { + 'Content-Type': 'application/json', + // Add CSRF token for state-changing requests + ...(csrfToken && + ['POST', 'PUT', 'DELETE', 'PATCH'].includes( + method?.toUpperCase() || 'GET', + ) && { + 'X-CSRF-Token': csrfToken, + }), + ...headers, + }, + // Enable credentials to send httpOnly cookies + withCredentials: true, }); return { data: result.data }; From 0dda3313b90d95c3b58f5cebb1ea99315a422b55 Mon Sep 17 00:00:00 2001 From: mark wang Date: Wed, 13 Aug 2025 20:06:39 +1000 Subject: [PATCH 2/8] Bypass onboarding for now --- src/app/StoreProvider.tsx | 11 +++++++++-- src/app/admin/layout.tsx | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/app/StoreProvider.tsx b/src/app/StoreProvider.tsx index f1eadeb..cb03679 100644 --- a/src/app/StoreProvider.tsx +++ b/src/app/StoreProvider.tsx @@ -1,13 +1,20 @@ 'use client'; import { Provider } from 'react-redux'; +import { PersistGate } from 'redux-persist/integration/react'; -import { store } from '@/redux/store'; +import { persistor, store } from '@/redux/store'; export default function StoreProvider({ children, }: { children: React.ReactNode; }) { - return {children}; + return ( + + + {children} + + + ); } diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 7b2c11d..80caeed 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -32,7 +32,9 @@ export default function ProtectedLayout({ children }: { children: ReactNode }) { // Wait for hydration and auth check, then check auth status const timer = setTimeout(() => { // Don't proceed if still checking authentication - if (isCheckingAuth) return; + if (isCheckingAuth) { + return; + } // check if logged in if (!isAuthenticated || !user) { @@ -41,7 +43,15 @@ export default function ProtectedLayout({ children }: { children: ReactNode }) { } // check if onboarding finished - if (isFetching || !progress) return; + if (isFetching) { + return; + } + + // If no progress data, assume onboarding is not required or completed + if (!progress) { + setReady(true); + return; + } if (progress.status !== 'completed' && pathname !== '/onboarding') { router.replace('/onboarding'); @@ -69,7 +79,6 @@ export default function ProtectedLayout({ children }: { children: ReactNode }) { justifyContent="center" alignItems="center" height="100vh" - sx={{ visibility: 'hidden' }} > Initializing Admin Panel... From e0f06bd3d30cc42d622e5a9503d2506fc97b6216 Mon Sep 17 00:00:00 2001 From: mark wang Date: Wed, 13 Aug 2025 20:44:56 +1000 Subject: [PATCH 3/8] corrected csrf header name --- src/lib/axiosBaseQuery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/axiosBaseQuery.ts b/src/lib/axiosBaseQuery.ts index ef662fa..c46bbf8 100644 --- a/src/lib/axiosBaseQuery.ts +++ b/src/lib/axiosBaseQuery.ts @@ -41,7 +41,7 @@ export const axiosBaseQuery = (): BaseQueryFn< ['POST', 'PUT', 'DELETE', 'PATCH'].includes( method?.toUpperCase() || 'GET', ) && { - 'X-CSRF-Token': csrfToken, + 'x-csrf-token': csrfToken, }), ...headers, }, From 51f3c512a9503728433695388aa5ea03d096e168 Mon Sep 17 00:00:00 2001 From: mark wang Date: Wed, 13 Aug 2025 23:37:47 +1000 Subject: [PATCH 4/8] added user status & service forms refactor --- .../components/EditServiceModal.tsx | 69 ++++++++++++++++--- src/app/auth/callback/AuthCallbackContent.tsx | 7 +- src/types/user.d.ts | 1 + 3 files changed, 66 insertions(+), 11 deletions(-) diff --git a/src/app/admin/service-management/components/EditServiceModal.tsx b/src/app/admin/service-management/components/EditServiceModal.tsx index b134e08..5b85e4b 100644 --- a/src/app/admin/service-management/components/EditServiceModal.tsx +++ b/src/app/admin/service-management/components/EditServiceModal.tsx @@ -28,6 +28,9 @@ interface FormField { label: string; required: boolean; } +import { useRouter } from 'next/navigation'; + +import { useCheckAuthStatusQuery } from '@/features/auth/authApi'; import { useCreateServiceMutation, useUpdateServiceMutation, @@ -204,6 +207,7 @@ export default function EditServiceModal({ service: ServiceManagement | null; onClose: () => void; }) { + const router = useRouter(); const [formData, setFormData] = useState({ name: '', description: '', @@ -218,6 +222,14 @@ export default function EditServiceModal({ useMediaQuery(theme.breakpoints.down('xs')); const user = useAppSelector(state => state.auth.user); + // Fallback: try to get user from auth check if Redux state is empty + const { data: authCheckData } = useCheckAuthStatusQuery(undefined, { + skip: !!user, // Only run if we don't have user in Redux + }); + + // Get user from either Redux or auth check + const currentUser = user ?? authCheckData?.user; + const [createService, { isLoading: isCreating }] = useCreateServiceMutation(); const [updateService, { isLoading: isUpdating }] = useUpdateServiceMutation(); @@ -232,30 +244,32 @@ export default function EditServiceModal({ }); setPriceInput(service.price?.toString() ?? '0'); } else { - setFormData({ + const newFormData = { name: '', description: '', price: 0, isAvailable: true, - userId: user?._id ?? '', - }); + userId: currentUser?._id ?? '', + }; + setFormData(newFormData); setPriceInput('0'); } - }, [service, user?._id]); + }, [service, currentUser?._id]); // Reset form when modal opens for create mode useEffect(() => { if (open && !service) { - setFormData({ + const resetFormData = { name: '', description: '', price: 0, isAvailable: true, - userId: user?._id ?? '', - }); + userId: currentUser?._id ?? '', + }; + setFormData(resetFormData); setPriceInput('0'); } - }, [open, service, user?._id]); + }, [open, service, currentUser?._id]); const handleInputChange = ( field: string, @@ -266,6 +280,28 @@ export default function EditServiceModal({ const handleSubmit = async (): Promise => { try { + // Validation before submission + if (!formData.name.trim()) { + alert('Please enter a service name'); + return; + } + + // If formData.userId is missing but we have currentUser._id, update it + if (!formData.userId && currentUser?._id) { + setFormData(prev => ({ ...prev, userId: currentUser._id })); + // Re-submit with updated data + setTimeout(() => { + void handleSubmit(); + }, 100); + return; + } + + if (!formData.userId) { + alert('Authentication required. Redirecting to login...'); + router.push('/login'); + return; + } + if (service) { // Update service const updateData: UpdateServiceManagementDto = { @@ -280,8 +316,10 @@ export default function EditServiceModal({ await createService(formData).unwrap(); } onClose(); - } catch { - // Error handling can be added here + } catch (error) { + // eslint-disable-next-line no-console + console.error('Service creation/update error:', error); + alert('Failed to save service. Please check the console for details.'); } }; @@ -413,6 +451,17 @@ export default function EditServiceModal({ Cancel + {!currentUser && ( + + )} { void handleSubmit(); diff --git a/src/app/auth/callback/AuthCallbackContent.tsx b/src/app/auth/callback/AuthCallbackContent.tsx index 59adbac..c9edb44 100644 --- a/src/app/auth/callback/AuthCallbackContent.tsx +++ b/src/app/auth/callback/AuthCallbackContent.tsx @@ -55,15 +55,20 @@ export default function AuthCallbackContent() { firstName: parsedUser.firstName, lastName: parsedUser.lastName, role: parsedUser.role, + status: parsedUser.status, }, }), ); router.replace('/admin/overview'); - } catch { + } catch (error) { + // eslint-disable-next-line no-console + console.error('Auth callback - parsing error:', error); router.replace('/login?error=oauth_error'); } } else { + // eslint-disable-next-line no-console + console.error('Auth callback - missing csrfToken or userString'); router.replace('/login?error=oauth_error'); } }, [searchParams, dispatch, router]); diff --git a/src/types/user.d.ts b/src/types/user.d.ts index 9f670e3..a93d2dd 100644 --- a/src/types/user.d.ts +++ b/src/types/user.d.ts @@ -7,6 +7,7 @@ export interface UserInfo { firstName?: string; lastName?: string; role: Role; + status?: string; googleId?: string; avatar?: string; provider?: string; From dcc29e50a47dcdf97689726d81fc10780303bf40 Mon Sep 17 00:00:00 2001 From: mark wang Date: Mon, 18 Aug 2025 19:21:51 +1000 Subject: [PATCH 5/8] refresh crsf token --- .../(public)/blogs/components/BlogList.tsx | 4 +- src/app/(public)/reduxtest/page.tsx | 75 ++++++++++-- src/features/auth/authApi.ts | 42 ++++++- src/features/auth/hooks/useCSRFToken.ts | 113 ++++++++++++++++++ src/lib/axiosBaseQuery.ts | 68 ++++++++++- 5 files changed, 283 insertions(+), 19 deletions(-) create mode 100644 src/features/auth/hooks/useCSRFToken.ts diff --git a/src/app/(public)/blogs/components/BlogList.tsx b/src/app/(public)/blogs/components/BlogList.tsx index be0d6bb..68ecf80 100644 --- a/src/app/(public)/blogs/components/BlogList.tsx +++ b/src/app/(public)/blogs/components/BlogList.tsx @@ -3,7 +3,7 @@ import { Box, Button, Grid, Snackbar, styled, Typography } from '@mui/material'; import axios from 'axios'; import type { AppRouterInstance } from 'next/dist/shared/lib/app-router-context.shared-runtime'; import { useRouter, useSearchParams } from 'next/navigation'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import theme from '@/theme'; import type { Blog } from '@/types/blog'; @@ -63,6 +63,7 @@ export default function BlogList() { setBlogs(fetched); setTotal(res.data.total); } catch (err) { + // eslint-disable-next-line no-console console.error('Failed to fetch blogs:', err); } }; @@ -93,6 +94,7 @@ export default function BlogList() { scroll: true, }); } catch (e) { + // eslint-disable-next-line no-console console.error('check next page failed', e); } }; diff --git a/src/app/(public)/reduxtest/page.tsx b/src/app/(public)/reduxtest/page.tsx index 8ea53fc..c3e54a9 100644 --- a/src/app/(public)/reduxtest/page.tsx +++ b/src/app/(public)/reduxtest/page.tsx @@ -3,6 +3,7 @@ import { useEffect } from 'react'; import { logout } from '@/features/auth/authSlice'; +import { useCSRFToken } from '@/features/auth/hooks/useCSRFToken'; import { useLazyGetUnauthorizedQuery } from '@/features/test/testApiSlice'; import { useAppDispatch, useAppSelector } from '@/redux/hooks'; @@ -13,6 +14,8 @@ export default function ReduxTestPage() { const user = useAppSelector(state => state.auth.user); const csrfToken = useAppSelector(state => state.auth.csrfToken); + const { isTokenValid, refreshToken, getToken } = useCSRFToken(); + useEffect(() => { // eslint-disable-next-line no-console console.groupCollapsed('🔑 Auth State'); @@ -23,8 +26,10 @@ export default function ReduxTestPage() { // eslint-disable-next-line no-console console.log('CSRF Token:', csrfToken); // eslint-disable-next-line no-console + console.log('CSRF Token Valid:', isTokenValid()); + // eslint-disable-next-line no-console console.groupEnd(); - }, [isAuthenticated, user, csrfToken]); + }, [isAuthenticated, user, csrfToken, isTokenValid]); const [triggerUnauthorized, { isFetching, error }] = useLazyGetUnauthorizedQuery(); @@ -38,23 +43,69 @@ export default function ReduxTestPage() { } }; + const handleRefreshCSRF = async () => { + try { + const success = await refreshToken(); + // eslint-disable-next-line no-console + console.log('🔄 CSRF Token Refresh:', success ? 'Success' : 'Failed'); + } catch (err) { + // eslint-disable-next-line no-console + console.error('❌ CSRF Refresh Error:', err); + } + }; + + const handleGetCSRF = async () => { + try { + const token = await getToken(); + // eslint-disable-next-line no-console + console.log('📋 Get CSRF Token:', token ? 'Success' : 'Failed'); + } catch (err) { + // eslint-disable-next-line no-console + console.error('❌ Get CSRF Error:', err); + } + }; + return (

Redux Key State Test (see console)

login success, see token and user info in console

- - - + +
+ + + + + + + +
{error &&

401 Error Triggered

} + +
+

CSRF Token Status:

+

Token: {csrfToken ? `${csrfToken.substring(0, 20)}...` : 'None'}

+

Valid: {isTokenValid() ? '✅ Yes' : '❌ No'}

+

Authenticated: {isAuthenticated ? '✅ Yes' : '❌ No'}

+
); } diff --git a/src/features/auth/authApi.ts b/src/features/auth/authApi.ts index 1a32359..7a957a8 100644 --- a/src/features/auth/authApi.ts +++ b/src/features/auth/authApi.ts @@ -1,6 +1,10 @@ import { createApi } from '@reduxjs/toolkit/query/react'; -import { logout, setCredentials } from '@/features/auth/authSlice'; +import { + logout, + setCredentials, + updateCSRFToken, +} from '@/features/auth/authSlice'; import { axiosBaseQuery } from '@/lib/axiosBaseQuery'; import type { UserInfo } from '@/types/user.d'; @@ -24,10 +28,14 @@ interface AuthStatusResponse { user: UserInfo; } +interface CSRFTokenResponse { + csrfToken: string; +} + export const authApi = createApi({ reducerPath: 'authApi', baseQuery: axiosBaseQuery(), - tagTypes: ['User'], + tagTypes: ['User', 'CSRFToken'], endpoints: builder => ({ loginUser: builder.mutation({ query: body => ({ @@ -87,6 +95,33 @@ export const authApi = createApi({ }, providesTags: ['User'], }), + // New endpoints for CSRF token management + refreshCSRFToken: builder.mutation<{ message: string }, void>({ + query: () => ({ url: '/auth/refresh-csrf', method: 'POST' }), + async onQueryStarted(_, { dispatch, queryFulfilled }) { + try { + await queryFulfilled; + // After refresh, get the new token + dispatch(authApi.util.invalidateTags(['CSRFToken'])); + } catch { + // If refresh fails, logout user + dispatch(logout()); + } + }, + }), + getCSRFToken: builder.query({ + query: () => ({ url: '/auth/csrf-token', method: 'GET' }), + async onQueryStarted(_, { dispatch, queryFulfilled }) { + try { + const { data } = await queryFulfilled; + dispatch(updateCSRFToken(data.csrfToken)); + } catch { + // If getting token fails, user might not be authenticated + dispatch(logout()); + } + }, + providesTags: ['CSRFToken'], + }), }), }); @@ -96,4 +131,7 @@ export const { useSignupUserMutation, useCheckAuthStatusQuery, useLazyCheckAuthStatusQuery, + useRefreshCSRFTokenMutation, + useGetCSRFTokenQuery, + useLazyGetCSRFTokenQuery, } = authApi; diff --git a/src/features/auth/hooks/useCSRFToken.ts b/src/features/auth/hooks/useCSRFToken.ts new file mode 100644 index 0000000..81239f2 --- /dev/null +++ b/src/features/auth/hooks/useCSRFToken.ts @@ -0,0 +1,113 @@ +import { useCallback, useEffect } from 'react'; + +import { useAppDispatch, useAppSelector } from '@/redux/hooks'; + +import { + useLazyGetCSRFTokenQuery, + useRefreshCSRFTokenMutation, +} from '../authApi'; +import { updateCSRFToken } from '../authSlice'; + +/** + * Hook for managing CSRF token lifecycle + * Handles token refresh, validation, and automatic renewal + */ +export const useCSRFToken = () => { + const dispatch = useAppDispatch(); + const { csrfToken, isAuthenticated } = useAppSelector(state => state.auth); + + const [refreshCSRF] = useRefreshCSRFTokenMutation(); + const [getCSRFToken] = useLazyGetCSRFTokenQuery(); + + /** + * Refresh CSRF token manually + */ + const refreshToken = useCallback(async () => { + if (!isAuthenticated) { + // eslint-disable-next-line no-console + console.warn('Cannot refresh CSRF token: user not authenticated'); + return false; + } + + try { + await refreshCSRF().unwrap(); + // After refresh, get the new token + const result = await getCSRFToken().unwrap(); + dispatch(updateCSRFToken(result.csrfToken)); + return true; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to refresh CSRF token:', error); + return false; + } + }, [isAuthenticated, refreshCSRF, getCSRFToken, dispatch]); + + /** + * Get current CSRF token, refresh if needed + */ + const getToken = useCallback(async () => { + if (!isAuthenticated) { + return null; + } + + try { + const result = await getCSRFToken().unwrap(); + dispatch(updateCSRFToken(result.csrfToken)); + return result.csrfToken; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to get CSRF token:', error); + return null; + } + }, [isAuthenticated, getCSRFToken, dispatch]); + + /** + * Validate if current CSRF token is still valid + * This is a client-side check based on token format + */ + const isTokenValid = useCallback(() => { + if (!csrfToken) { + return false; + } + + // Basic validation: check if token has the expected format + const parts = csrfToken.split('.'); + if (parts.length !== 3) { + return false; + } + + // Check if token is not too old (client-side estimation) + try { + const timestamp = parseInt(parts[0], 10); + const now = Date.now(); + + // If token is older than 50 minutes, consider it close to expiration + return now - timestamp < 50 * 60 * 1000; + } catch { + return false; + } + }, [csrfToken]); + + /** + * Proactive token refresh before expiration + */ + useEffect(() => { + if (!isAuthenticated || !csrfToken) { + return; + } + + // Check if token is close to expiration (50 minutes) + if (!isTokenValid()) { + // eslint-disable-next-line no-console + console.log('CSRF token is close to expiration, refreshing...'); + void refreshToken(); + } + }, [isAuthenticated, csrfToken, isTokenValid, refreshToken]); + + return { + csrfToken, + isTokenValid, + refreshToken, + getToken, + }; +}; diff --git a/src/lib/axiosBaseQuery.ts b/src/lib/axiosBaseQuery.ts index c46bbf8..58b6495 100644 --- a/src/lib/axiosBaseQuery.ts +++ b/src/lib/axiosBaseQuery.ts @@ -3,8 +3,8 @@ import type { BaseQueryFn } from '@reduxjs/toolkit/query'; import type { AxiosError, AxiosRequestConfig } from 'axios'; import axios from 'axios'; -import { logout } from '@/features/auth/authSlice'; -import type { AppDispatch, RootState } from '@/redux/store'; +import { logout, updateCSRFToken } from '@/features/auth/authSlice'; +import type { RootState } from '@/redux/store'; interface ErrorResponse { message: string; @@ -52,17 +52,77 @@ export const axiosBaseQuery = (): BaseQueryFn< return { data: result.data }; } catch (e) { const err = e as AxiosError; + + // Handle CSRF token expiration (403 Forbidden) + if ( + err.response?.status === 403 && + err.response?.data?.message?.includes('CSRF') + ) { + try { + // Try to refresh CSRF token + await axios({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + url: '/auth/refresh-csrf', + method: 'POST', + withCredentials: true, + }); + + // Get new CSRF token from cookie + const newCsrfTokenResponse = await axios<{ csrfToken: string }>({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + url: '/auth/csrf-token', + method: 'GET', + withCredentials: true, + }); + + if (newCsrfTokenResponse.data?.csrfToken) { + // Update Redux state with new CSRF token + dispatch(updateCSRFToken(newCsrfTokenResponse.data.csrfToken)); + + // Retry the original request with new CSRF token + const retryResult = await axios({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + url, + method, + data, + params, + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': newCsrfTokenResponse.data.csrfToken, + ...headers, + }, + withCredentials: true, + }); + + return { data: retryResult.data }; + } + } catch (refreshError) { + // If refresh fails, logout user + // eslint-disable-next-line no-console + console.error('CSRF token refresh failed:', refreshError); + dispatch(logout() as unknown as never); + return { + error: { + status: 403, + data: 'CSRF token expired and refresh failed. Please log in again.', + }, + }; + } + } + + // Handle authentication errors (401 Unauthorized) if ( err.response?.status === 401 && url !== '/auth/signup' && url !== '/auth/login' ) { - dispatch(logout() as unknown as AppDispatch); + dispatch(logout() as unknown as never); } + return { error: { status: err.response?.status, - data: err.response?.data.message ?? err.message, + data: err.response?.data?.message ?? err.message, }, }; } From 6ae5e9ecbf4780d4832531f44dbc324256d8288e Mon Sep 17 00:00:00 2001 From: mark wang Date: Mon, 18 Aug 2025 19:34:07 +1000 Subject: [PATCH 6/8] revised refresh method --- src/features/auth/authApi.ts | 33 ++++---------------- src/features/auth/hooks/useCSRFToken.ts | 35 +++++++--------------- src/lib/axiosBaseQuery.ts | 40 +++++++++---------------- 3 files changed, 30 insertions(+), 78 deletions(-) diff --git a/src/features/auth/authApi.ts b/src/features/auth/authApi.ts index 7a957a8..9bf687e 100644 --- a/src/features/auth/authApi.ts +++ b/src/features/auth/authApi.ts @@ -1,10 +1,6 @@ import { createApi } from '@reduxjs/toolkit/query/react'; -import { - logout, - setCredentials, - updateCSRFToken, -} from '@/features/auth/authSlice'; +import { logout, setCredentials } from '@/features/auth/authSlice'; import { axiosBaseQuery } from '@/lib/axiosBaseQuery'; import type { UserInfo } from '@/types/user.d'; @@ -28,14 +24,10 @@ interface AuthStatusResponse { user: UserInfo; } -interface CSRFTokenResponse { - csrfToken: string; -} - export const authApi = createApi({ reducerPath: 'authApi', baseQuery: axiosBaseQuery(), - tagTypes: ['User', 'CSRFToken'], + tagTypes: ['User'], endpoints: builder => ({ loginUser: builder.mutation({ query: body => ({ @@ -95,33 +87,20 @@ export const authApi = createApi({ }, providesTags: ['User'], }), - // New endpoints for CSRF token management + // CSRF token management - refresh only refreshCSRFToken: builder.mutation<{ message: string }, void>({ query: () => ({ url: '/auth/refresh-csrf', method: 'POST' }), async onQueryStarted(_, { dispatch, queryFulfilled }) { try { await queryFulfilled; - // After refresh, get the new token - dispatch(authApi.util.invalidateTags(['CSRFToken'])); + // After refresh, the new token is automatically set in httpOnly cookie + // No need to manually get it via API } catch { // If refresh fails, logout user dispatch(logout()); } }, }), - getCSRFToken: builder.query({ - query: () => ({ url: '/auth/csrf-token', method: 'GET' }), - async onQueryStarted(_, { dispatch, queryFulfilled }) { - try { - const { data } = await queryFulfilled; - dispatch(updateCSRFToken(data.csrfToken)); - } catch { - // If getting token fails, user might not be authenticated - dispatch(logout()); - } - }, - providesTags: ['CSRFToken'], - }), }), }); @@ -132,6 +111,4 @@ export const { useCheckAuthStatusQuery, useLazyCheckAuthStatusQuery, useRefreshCSRFTokenMutation, - useGetCSRFTokenQuery, - useLazyGetCSRFTokenQuery, } = authApi; diff --git a/src/features/auth/hooks/useCSRFToken.ts b/src/features/auth/hooks/useCSRFToken.ts index 81239f2..78af703 100644 --- a/src/features/auth/hooks/useCSRFToken.ts +++ b/src/features/auth/hooks/useCSRFToken.ts @@ -1,23 +1,17 @@ import { useCallback, useEffect } from 'react'; -import { useAppDispatch, useAppSelector } from '@/redux/hooks'; +import { useAppSelector } from '@/redux/hooks'; -import { - useLazyGetCSRFTokenQuery, - useRefreshCSRFTokenMutation, -} from '../authApi'; -import { updateCSRFToken } from '../authSlice'; +import { useRefreshCSRFTokenMutation } from '../authApi'; /** * Hook for managing CSRF token lifecycle * Handles token refresh, validation, and automatic renewal */ export const useCSRFToken = () => { - const dispatch = useAppDispatch(); const { csrfToken, isAuthenticated } = useAppSelector(state => state.auth); const [refreshCSRF] = useRefreshCSRFTokenMutation(); - const [getCSRFToken] = useLazyGetCSRFTokenQuery(); /** * Refresh CSRF token manually @@ -31,35 +25,28 @@ export const useCSRFToken = () => { try { await refreshCSRF().unwrap(); - // After refresh, get the new token - const result = await getCSRFToken().unwrap(); - dispatch(updateCSRFToken(result.csrfToken)); + // After refresh, the new token is automatically set in httpOnly cookie + // We don't need to manually get it via API return true; } catch (error) { // eslint-disable-next-line no-console console.error('Failed to refresh CSRF token:', error); return false; } - }, [isAuthenticated, refreshCSRF, getCSRFToken, dispatch]); + }, [isAuthenticated, refreshCSRF]); /** - * Get current CSRF token, refresh if needed + * Get current CSRF token from Redux state + * Note: This is only available if set during login/signup */ - const getToken = useCallback(async () => { + const getToken = useCallback(() => { if (!isAuthenticated) { return null; } - try { - const result = await getCSRFToken().unwrap(); - dispatch(updateCSRFToken(result.csrfToken)); - return result.csrfToken; - } catch (error) { - // eslint-disable-next-line no-console - console.error('Failed to get CSRF token:', error); - return null; - } - }, [isAuthenticated, getCSRFToken, dispatch]); + // Return the token from Redux state (set during login/signup) + return csrfToken; + }, [isAuthenticated, csrfToken]); /** * Validate if current CSRF token is still valid diff --git a/src/lib/axiosBaseQuery.ts b/src/lib/axiosBaseQuery.ts index 58b6495..a12e5df 100644 --- a/src/lib/axiosBaseQuery.ts +++ b/src/lib/axiosBaseQuery.ts @@ -3,7 +3,7 @@ import type { BaseQueryFn } from '@reduxjs/toolkit/query'; import type { AxiosError, AxiosRequestConfig } from 'axios'; import axios from 'axios'; -import { logout, updateCSRFToken } from '@/features/auth/authSlice'; +import { logout } from '@/features/auth/authSlice'; import type { RootState } from '@/redux/store'; interface ErrorResponse { @@ -67,35 +67,23 @@ export const axiosBaseQuery = (): BaseQueryFn< withCredentials: true, }); - // Get new CSRF token from cookie - const newCsrfTokenResponse = await axios<{ csrfToken: string }>({ + // After refresh, the new CSRF token is automatically set in httpOnly cookie + // We can retry the original request - the browser will send the new token + const retryResult = await axios({ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, - url: '/auth/csrf-token', - method: 'GET', + url, + method, + data, + params, + headers: { + 'Content-Type': 'application/json', + // Don't set x-csrf-token header - let the browser send it from cookie + ...headers, + }, withCredentials: true, }); - if (newCsrfTokenResponse.data?.csrfToken) { - // Update Redux state with new CSRF token - dispatch(updateCSRFToken(newCsrfTokenResponse.data.csrfToken)); - - // Retry the original request with new CSRF token - const retryResult = await axios({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, - url, - method, - data, - params, - headers: { - 'Content-Type': 'application/json', - 'x-csrf-token': newCsrfTokenResponse.data.csrfToken, - ...headers, - }, - withCredentials: true, - }); - - return { data: retryResult.data }; - } + return { data: retryResult.data }; } catch (refreshError) { // If refresh fails, logout user // eslint-disable-next-line no-console From 41ac505c4e0d1df9e614844c77e24746048c346f Mon Sep 17 00:00:00 2001 From: mark wang Date: Mon, 18 Aug 2025 19:47:48 +1000 Subject: [PATCH 7/8] resolve csrf cookie in frontend --- src/features/auth/authApi.ts | 33 +++++++++++++++--- src/features/auth/hooks/useCSRFToken.ts | 35 +++++++++++++------ src/lib/axiosBaseQuery.ts | 45 +++++++++++++++---------- 3 files changed, 80 insertions(+), 33 deletions(-) diff --git a/src/features/auth/authApi.ts b/src/features/auth/authApi.ts index 9bf687e..7a957a8 100644 --- a/src/features/auth/authApi.ts +++ b/src/features/auth/authApi.ts @@ -1,6 +1,10 @@ import { createApi } from '@reduxjs/toolkit/query/react'; -import { logout, setCredentials } from '@/features/auth/authSlice'; +import { + logout, + setCredentials, + updateCSRFToken, +} from '@/features/auth/authSlice'; import { axiosBaseQuery } from '@/lib/axiosBaseQuery'; import type { UserInfo } from '@/types/user.d'; @@ -24,10 +28,14 @@ interface AuthStatusResponse { user: UserInfo; } +interface CSRFTokenResponse { + csrfToken: string; +} + export const authApi = createApi({ reducerPath: 'authApi', baseQuery: axiosBaseQuery(), - tagTypes: ['User'], + tagTypes: ['User', 'CSRFToken'], endpoints: builder => ({ loginUser: builder.mutation({ query: body => ({ @@ -87,20 +95,33 @@ export const authApi = createApi({ }, providesTags: ['User'], }), - // CSRF token management - refresh only + // New endpoints for CSRF token management refreshCSRFToken: builder.mutation<{ message: string }, void>({ query: () => ({ url: '/auth/refresh-csrf', method: 'POST' }), async onQueryStarted(_, { dispatch, queryFulfilled }) { try { await queryFulfilled; - // After refresh, the new token is automatically set in httpOnly cookie - // No need to manually get it via API + // After refresh, get the new token + dispatch(authApi.util.invalidateTags(['CSRFToken'])); } catch { // If refresh fails, logout user dispatch(logout()); } }, }), + getCSRFToken: builder.query({ + query: () => ({ url: '/auth/csrf-token', method: 'GET' }), + async onQueryStarted(_, { dispatch, queryFulfilled }) { + try { + const { data } = await queryFulfilled; + dispatch(updateCSRFToken(data.csrfToken)); + } catch { + // If getting token fails, user might not be authenticated + dispatch(logout()); + } + }, + providesTags: ['CSRFToken'], + }), }), }); @@ -111,4 +132,6 @@ export const { useCheckAuthStatusQuery, useLazyCheckAuthStatusQuery, useRefreshCSRFTokenMutation, + useGetCSRFTokenQuery, + useLazyGetCSRFTokenQuery, } = authApi; diff --git a/src/features/auth/hooks/useCSRFToken.ts b/src/features/auth/hooks/useCSRFToken.ts index 78af703..81239f2 100644 --- a/src/features/auth/hooks/useCSRFToken.ts +++ b/src/features/auth/hooks/useCSRFToken.ts @@ -1,17 +1,23 @@ import { useCallback, useEffect } from 'react'; -import { useAppSelector } from '@/redux/hooks'; +import { useAppDispatch, useAppSelector } from '@/redux/hooks'; -import { useRefreshCSRFTokenMutation } from '../authApi'; +import { + useLazyGetCSRFTokenQuery, + useRefreshCSRFTokenMutation, +} from '../authApi'; +import { updateCSRFToken } from '../authSlice'; /** * Hook for managing CSRF token lifecycle * Handles token refresh, validation, and automatic renewal */ export const useCSRFToken = () => { + const dispatch = useAppDispatch(); const { csrfToken, isAuthenticated } = useAppSelector(state => state.auth); const [refreshCSRF] = useRefreshCSRFTokenMutation(); + const [getCSRFToken] = useLazyGetCSRFTokenQuery(); /** * Refresh CSRF token manually @@ -25,28 +31,35 @@ export const useCSRFToken = () => { try { await refreshCSRF().unwrap(); - // After refresh, the new token is automatically set in httpOnly cookie - // We don't need to manually get it via API + // After refresh, get the new token + const result = await getCSRFToken().unwrap(); + dispatch(updateCSRFToken(result.csrfToken)); return true; } catch (error) { // eslint-disable-next-line no-console console.error('Failed to refresh CSRF token:', error); return false; } - }, [isAuthenticated, refreshCSRF]); + }, [isAuthenticated, refreshCSRF, getCSRFToken, dispatch]); /** - * Get current CSRF token from Redux state - * Note: This is only available if set during login/signup + * Get current CSRF token, refresh if needed */ - const getToken = useCallback(() => { + const getToken = useCallback(async () => { if (!isAuthenticated) { return null; } - // Return the token from Redux state (set during login/signup) - return csrfToken; - }, [isAuthenticated, csrfToken]); + try { + const result = await getCSRFToken().unwrap(); + dispatch(updateCSRFToken(result.csrfToken)); + return result.csrfToken; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Failed to get CSRF token:', error); + return null; + } + }, [isAuthenticated, getCSRFToken, dispatch]); /** * Validate if current CSRF token is still valid diff --git a/src/lib/axiosBaseQuery.ts b/src/lib/axiosBaseQuery.ts index a12e5df..c05e4a0 100644 --- a/src/lib/axiosBaseQuery.ts +++ b/src/lib/axiosBaseQuery.ts @@ -3,7 +3,7 @@ import type { BaseQueryFn } from '@reduxjs/toolkit/query'; import type { AxiosError, AxiosRequestConfig } from 'axios'; import axios from 'axios'; -import { logout } from '@/features/auth/authSlice'; +import { logout, updateCSRFToken } from '@/features/auth/authSlice'; import type { RootState } from '@/redux/store'; interface ErrorResponse { @@ -67,23 +67,34 @@ export const axiosBaseQuery = (): BaseQueryFn< withCredentials: true, }); - // After refresh, the new CSRF token is automatically set in httpOnly cookie - // We can retry the original request - the browser will send the new token - const retryResult = await axios({ - baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, - url, - method, - data, - params, - headers: { - 'Content-Type': 'application/json', - // Don't set x-csrf-token header - let the browser send it from cookie - ...headers, - }, - withCredentials: true, - }); + // After refresh, get the new token from cookie and update Redux + // The new token is now available in the cookie + const newCsrfToken = document.cookie + .split('; ') + .find(row => row.startsWith('csrfToken=')) + ?.split('=')[1]; + + if (newCsrfToken) { + // Update Redux state with new CSRF token + dispatch(updateCSRFToken(newCsrfToken)); + + // Retry the original request with new CSRF token + const retryResult = await axios({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + url, + method, + data, + params, + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': newCsrfToken, + ...headers, + }, + withCredentials: true, + }); - return { data: retryResult.data }; + return { data: retryResult.data }; + } } catch (refreshError) { // If refresh fails, logout user // eslint-disable-next-line no-console From 44ba93f6caa4f204be05f5c93ffb9bb59ff9ffd5 Mon Sep 17 00:00:00 2001 From: mark wang Date: Mon, 25 Aug 2025 18:21:16 +1000 Subject: [PATCH 8/8] double submit token & lint fix --- .../(public)/about/components/AboutBanner.tsx | 1 - .../pricing/components/PricingSection.tsx | 2 +- .../components/TaskManager/BookingModal.tsx | 2 + .../TaskManager/EditBookingModal.tsx | 3 +- .../overview/components/ActivitySection.tsx | 2 +- .../components/CustomFormModal.tsx | 1 + .../components/EditServiceModal.tsx | 5 ++ src/lib/axiosBaseQuery.ts | 48 +++++++++++++++---- 8 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/app/(public)/about/components/AboutBanner.tsx b/src/app/(public)/about/components/AboutBanner.tsx index 946c9e9..cfefc29 100644 --- a/src/app/(public)/about/components/AboutBanner.tsx +++ b/src/app/(public)/about/components/AboutBanner.tsx @@ -1,6 +1,5 @@ import { Box, Container } from '@mui/material'; import { styled } from '@mui/material/styles'; -import { minWidth } from '@mui/system'; export const AboutHeader = styled(Box)(({ theme }) => ({ display: 'flex', diff --git a/src/app/(public)/pricing/components/PricingSection.tsx b/src/app/(public)/pricing/components/PricingSection.tsx index f20deef..671a79d 100644 --- a/src/app/(public)/pricing/components/PricingSection.tsx +++ b/src/app/(public)/pricing/components/PricingSection.tsx @@ -57,7 +57,7 @@ export default function PricingSection() { (a, b) => tierOrder[a.tier] - tierOrder[b.tier], ); - const [currentSlide, setCurrentSlide] = useState(0); + const [, setCurrentSlide] = useState(0); const [sliderRef, slider] = useKeenSlider({ slides: { perView: 1, diff --git a/src/app/admin/booking/components/TaskManager/BookingModal.tsx b/src/app/admin/booking/components/TaskManager/BookingModal.tsx index f7edb71..41b69b0 100644 --- a/src/app/admin/booking/components/TaskManager/BookingModal.tsx +++ b/src/app/admin/booking/components/TaskManager/BookingModal.tsx @@ -309,6 +309,7 @@ const BookingModal: React.FC = ({ // Reduce tolerance to 1 minute return selectedMinutes < nowMinutes - 1; } catch (error) { + // eslint-disable-next-line no-console console.error('Error in isDateTimeInPast:', error); return false; } @@ -452,6 +453,7 @@ const BookingModal: React.FC = ({ onClose(); } catch (error) { + // eslint-disable-next-line no-console console.error('Failed to create booking:', error); } }; diff --git a/src/app/admin/booking/components/TaskManager/EditBookingModal.tsx b/src/app/admin/booking/components/TaskManager/EditBookingModal.tsx index ee42507..819d6e4 100644 --- a/src/app/admin/booking/components/TaskManager/EditBookingModal.tsx +++ b/src/app/admin/booking/components/TaskManager/EditBookingModal.tsx @@ -299,7 +299,7 @@ const formatForDateTimeLocal = (isoString: string) => { const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return `${year}-${month}-${day}T${hours}:${minutes}`; - } catch (error) { + } catch { // If exception occurs, also use current device time const now = new Date(); const year = now.getFullYear(); @@ -363,6 +363,7 @@ const EditBookingModal: React.FC = ({ // Reduce tolerance to 1 minute return selectedMinutes < nowMinutes - 1; } catch { + // eslint-disable-next-line no-console console.error('Error in isDateTimeInPast'); return false; } diff --git a/src/app/admin/overview/components/ActivitySection.tsx b/src/app/admin/overview/components/ActivitySection.tsx index f2e2930..100b373 100644 --- a/src/app/admin/overview/components/ActivitySection.tsx +++ b/src/app/admin/overview/components/ActivitySection.tsx @@ -1,7 +1,7 @@ 'use client'; import { Avatar, Box, Typography, useMediaQuery } from '@mui/material'; -import { styled, width } from '@mui/system'; +import { styled } from '@mui/system'; import { format, isToday, parseISO } from 'date-fns'; import Image from 'next/image'; diff --git a/src/app/admin/service-management/components/CustomFormModal.tsx b/src/app/admin/service-management/components/CustomFormModal.tsx index 80dae9e..8cd46fc 100644 --- a/src/app/admin/service-management/components/CustomFormModal.tsx +++ b/src/app/admin/service-management/components/CustomFormModal.tsx @@ -321,6 +321,7 @@ export default function CustomFormModal({ const currentField = fields.find(field => field.id === fieldId); if (currentField && currentField.label.trim() === '') { // label 为空时,不允许删除 + // eslint-disable-next-line no-console console.log( 'Cannot delete field with empty label. Please fill in the label first.', ); diff --git a/src/app/admin/service-management/components/EditServiceModal.tsx b/src/app/admin/service-management/components/EditServiceModal.tsx index 136254f..9688607 100644 --- a/src/app/admin/service-management/components/EditServiceModal.tsx +++ b/src/app/admin/service-management/components/EditServiceModal.tsx @@ -371,9 +371,11 @@ export default function EditServiceModal({ onClose(); } catch (error) { + // eslint-disable-next-line no-console console.error('Failed to save service:', error); // 提供更详细的错误信息 if (error && typeof error === 'object' && 'data' in error) { + // eslint-disable-next-line no-console console.error('Error details:', error.data); } // Error handling can be added here @@ -412,6 +414,7 @@ export default function EditServiceModal({ }).unwrap(); } } else { + // eslint-disable-next-line no-console console.log( 'No service ID, form fields will be saved when service is created', ); @@ -419,9 +422,11 @@ export default function EditServiceModal({ setIsCustomFormModalOpen(false); } catch (error) { + // eslint-disable-next-line no-console console.error('Failed to save custom form fields:', error); // 提供更详细的错误信息 if (error && typeof error === 'object' && 'data' in error) { + // eslint-disable-next-line no-console console.error('Error details:', error.data); } } diff --git a/src/lib/axiosBaseQuery.ts b/src/lib/axiosBaseQuery.ts index c05e4a0..58311bb 100644 --- a/src/lib/axiosBaseQuery.ts +++ b/src/lib/axiosBaseQuery.ts @@ -26,7 +26,34 @@ export const axiosBaseQuery = (): BaseQueryFn< { dispatch, getState }, ) => { try { - const { csrfToken } = (getState() as RootState).auth; + let { csrfToken } = (getState() as RootState).auth; + const { isAuthenticated } = (getState() as RootState).auth; + + // If authenticated but no CSRF token, try to get it + if ( + isAuthenticated && + !csrfToken && + ['POST', 'PUT', 'DELETE', 'PATCH'].includes( + method?.toUpperCase() || 'GET', + ) + ) { + try { + const csrfResponse = await axios({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, + url: '/auth/csrf-token', + method: 'GET', + withCredentials: true, + }); + + csrfToken = (csrfResponse.data as { csrfToken: string }).csrfToken; + if (csrfToken) { + dispatch({ type: 'auth/updateCSRFToken', payload: csrfToken }); + } + } catch (csrfError) { + // eslint-disable-next-line no-console + console.error('Failed to fetch CSRF token:', csrfError); + } + } const result = await axios({ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, @@ -59,20 +86,23 @@ export const axiosBaseQuery = (): BaseQueryFn< err.response?.data?.message?.includes('CSRF') ) { try { - // Try to refresh CSRF token - await axios({ + // Try to refresh CSRF token using current token + const { csrfToken: currentToken } = (getState() as RootState).auth; + + const refreshResponse = await axios({ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, url: '/auth/refresh-csrf', method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(currentToken && { 'x-csrf-token': currentToken }), + }, withCredentials: true, }); - // After refresh, get the new token from cookie and update Redux - // The new token is now available in the cookie - const newCsrfToken = document.cookie - .split('; ') - .find(row => row.startsWith('csrfToken=')) - ?.split('=')[1]; + // Get the new token from refresh response + const newCsrfToken = (refreshResponse.data as { csrfToken: string }) + .csrfToken; if (newCsrfToken) { // Update Redux state with new CSRF token