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)/blogs/components/BlogList.tsx b/src/app/(public)/blogs/components/BlogList.tsx index 8428311..7d2cb6c 100644 --- a/src/app/(public)/blogs/components/BlogList.tsx +++ b/src/app/(public)/blogs/components/BlogList.tsx @@ -66,6 +66,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); } }; @@ -96,6 +97,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)/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)/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/(public)/reduxtest/page.tsx b/src/app/(public)/reduxtest/page.tsx index 2e715d5..c3e54a9 100644 --- a/src/app/(public)/reduxtest/page.tsx +++ b/src/app/(public)/reduxtest/page.tsx @@ -3,25 +3,33 @@ 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'; 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); + + const { isTokenValid, refreshToken, getToken } = useCSRFToken(); 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.log('CSRF Token Valid:', isTokenValid()); + // eslint-disable-next-line no-console console.groupEnd(); - }, [token, user]); + }, [isAuthenticated, user, csrfToken, isTokenValid]); const [triggerUnauthorized, { isFetching, error }] = useLazyGetUnauthorizedQuery(); @@ -35,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/app/(public)/signup/component/SignupForm.tsx b/src/app/(public)/signup/component/SignupForm.tsx index d230ecb..c5b025b 100644 --- a/src/app/(public)/signup/component/SignupForm.tsx +++ b/src/app/(public)/signup/component/SignupForm.tsx @@ -130,7 +130,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); @@ -141,10 +141,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/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/booking/components/TaskManager/BookingModal.tsx b/src/app/admin/booking/components/TaskManager/BookingModal.tsx index e9cb954..e59bf76 100644 --- a/src/app/admin/booking/components/TaskManager/BookingModal.tsx +++ b/src/app/admin/booking/components/TaskManager/BookingModal.tsx @@ -319,6 +319,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; } @@ -500,6 +501,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/layout.tsx b/src/app/admin/layout.tsx index 03dd7f9..80caeed 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,26 +14,44 @@ 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; } // 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'); @@ -43,16 +62,23 @@ 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 ( Initializing Admin Panel... 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 b94624e..9688607 100644 --- a/src/app/admin/service-management/components/EditServiceModal.tsx +++ b/src/app/admin/service-management/components/EditServiceModal.tsx @@ -12,8 +12,10 @@ import { useMediaQuery, } from '@mui/material'; import { styled } from '@mui/material/styles'; +import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; +import { useCheckAuthStatusQuery } from '@/features/auth/authApi'; import type { CreateServiceManagementDto, FormField, @@ -200,6 +202,7 @@ export default function EditServiceModal({ service: ServiceManagement | null; onClose: () => void; }) { + const router = useRouter(); const [formData, setFormData] = useState({ name: '', description: '', @@ -215,6 +218,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(); const [saveServiceFormFields] = useSaveServiceFormFieldsMutation(); @@ -259,30 +270,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, @@ -293,6 +306,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; + } + let currentServiceId = service?._id; if (service) { @@ -336,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 @@ -377,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', ); @@ -384,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); } } @@ -507,6 +547,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 9c07baf..c9edb44 100644 --- a/src/app/auth/callback/AuthCallbackContent.tsx +++ b/src/app/auth/callback/AuthCallbackContent.tsx @@ -36,10 +36,11 @@ export default function AuthCallbackContent() { const dispatch = useAppDispatch(); useEffect(() => { - 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,22 +48,27 @@ export default function AuthCallbackContent() { dispatch( setCredentials({ - token, + csrfToken, user: { _id: parsedUser._id, email: parsedUser.email, 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/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..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'; @@ -9,9 +13,9 @@ interface LoginDTO { password: string; } -interface LoginResp { - token: string; +interface AuthResponse { user: UserInfo; + csrfToken: string; } interface SignupDTO { @@ -19,16 +23,21 @@ interface SignupDTO { email: string; password: string; } -interface SignupResp { - token: string; + +interface AuthStatusResponse { user: UserInfo; } +interface CSRFTokenResponse { + csrfToken: string; +} + export const authApi = createApi({ reducerPath: 'authApi', baseQuery: axiosBaseQuery(), + tagTypes: ['User', 'CSRFToken'], endpoints: builder => ({ - loginUser: builder.mutation({ + loginUser: builder.mutation({ query: body => ({ url: '/auth/login', method: 'POST', @@ -42,8 +51,9 @@ export const authApi = createApi({ return; } }, + invalidatesTags: ['User'], }), - signupUser: builder.mutation({ + signupUser: builder.mutation({ query: body => ({ url: '/auth/signup', method: 'POST', @@ -57,12 +67,60 @@ 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'], + }), + // 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'], }), }), }); @@ -71,4 +129,9 @@ export const { useLoginUserMutation, useLogoutUserMutation, useSignupUserMutation, + useCheckAuthStatusQuery, + useLazyCheckAuthStatusQuery, + useRefreshCSRFTokenMutation, + useGetCSRFTokenQuery, + useLazyGetCSRFTokenQuery, } = 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/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 f605e10..58311bb 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; @@ -16,16 +16,44 @@ 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; + 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, @@ -33,23 +61,97 @@ 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 }; } 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 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, + }); + + // Get the new token from refresh response + const newCsrfToken = (refreshResponse.data as { csrfToken: string }) + .csrfToken; + + 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 }; + } + } 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, }, }; } 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;