diff --git a/package.json b/package.json index 028a9a3..632912a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react-hook-form": "^7.54.1", "react-redux": "^9.1.2", "react-router": "^7.0.2", + "redux-persist": "^6.0.0", "styled-components": "^6.1.13" }, "devDependencies": { @@ -39,6 +40,7 @@ "@types/jwt-decode": "^3.1.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/redux-persist": "^4.3.1", "@vitejs/plugin-react": "^4.3.4", "eslint": "^9.15.0", "eslint-plugin-react-hooks": "^5.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86b33c6..b288db6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: react-router: specifier: ^7.0.2 version: 7.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + redux-persist: + specifier: ^6.0.0 + version: 6.0.0(react@18.3.1)(redux@5.0.1) styled-components: specifier: ^6.1.13 version: 6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -81,6 +84,9 @@ importers: '@types/react-dom': specifier: ^18.3.1 version: 18.3.2 + '@types/redux-persist': + specifier: ^4.3.1 + version: 4.3.1(react@18.3.1)(redux@5.0.1) '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.3.4(vite@6.0.3(@types/node@22.10.2)) @@ -1076,6 +1082,10 @@ packages: '@types/react@18.3.14': resolution: {integrity: sha512-NzahNKvjNhVjuPBQ+2G7WlxstQ+47kXZNHlUvFakDViuIEfGY926GqhMueQFZ7woG+sPiQKlF36XfrIUVSUfFg==} + '@types/redux-persist@4.3.1': + resolution: {integrity: sha512-YkMnMUk+4//wPtiSTMfsxST/F9Gh9sPWX0LVxHuOidGjojHtMdpep2cYvQgfiDMnj34orXyZI+QJCQMZDlafKA==} + deprecated: This is a stub types definition for redux-persist (https://github.com/rt2zz/redux-persist). redux-persist provides its own type definitions, so you don't need @types/redux-persist installed! + '@types/resolve@1.20.6': resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} @@ -2118,6 +2128,15 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redux-persist@6.0.0: + resolution: {integrity: sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==} + peerDependencies: + react: '>=16' + redux: '>4.0.0' + peerDependenciesMeta: + react: + optional: true + redux-thunk@3.1.0: resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} peerDependencies: @@ -3476,6 +3495,13 @@ snapshots: '@types/prop-types': 15.7.14 csstype: 3.1.3 + '@types/redux-persist@4.3.1(react@18.3.1)(redux@5.0.1)': + dependencies: + redux-persist: 6.0.0(react@18.3.1)(redux@5.0.1) + transitivePeerDependencies: + - react + - redux + '@types/resolve@1.20.6': {} '@types/statuses@2.0.5': {} @@ -4528,6 +4554,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redux-persist@6.0.0(react@18.3.1)(redux@5.0.1): + dependencies: + redux: 5.0.1 + optionalDependencies: + react: 18.3.1 + redux-thunk@3.1.0(redux@5.0.1): dependencies: redux: 5.0.1 diff --git a/src/App.tsx b/src/App.tsx index 7697f7c..1a9921b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,8 +2,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider, createBrowserRouter } from 'react-router'; import router from '@/routes/router'; import { Provider } from 'react-redux'; // Redux Provider 임포트 -import { store } from '@/store/store'; // 설정된 Redux 스토어 임포트 import GlobalStyle from './styles/GlobalStyle'; +import { PersistGate } from 'redux-persist/integration/react'; +import { persistor, store } from './store/store'; const queryClient = new QueryClient({ defaultOptions: { @@ -19,10 +20,12 @@ const App = () => { const appRouter = createBrowserRouter(router); return ( - - - - + + + + + + ); }; diff --git a/src/apis/http.api.ts b/src/apis/http.api.ts index 30383c7..a532349 100644 --- a/src/apis/http.api.ts +++ b/src/apis/http.api.ts @@ -1,11 +1,26 @@ -import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; -import { getToken, removeToken } from '@/utils/token'; // 토큰 유틸리티 함수 import -import { logout } from '@/store/slices/authSlice'; -import { store } from '@/store/store'; +import axios, { + AxiosInstance, + AxiosRequestConfig, + AxiosHeaders, + AxiosRequestHeaders, +} from 'axios'; const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3333'; const DEFAULT_TIMEOUT = 30000; // 요청 제한 시간 +// 토큰 관리 함수 +function getToken(): string | null { + return localStorage.getItem('token'); +} + +function setToken(token: string) { + localStorage.setItem('token', token); +} + +function removeToken() { + localStorage.removeItem('token'); +} + // Axios 인스턴스 생성 함수 export const createClient = (config?: AxiosRequestConfig): AxiosInstance => { const axiosInstance = axios.create({ @@ -21,17 +36,24 @@ export const createClient = (config?: AxiosRequestConfig): AxiosInstance => { // 요청 인터셉터: Authorization 헤더 동적 설정 axiosInstance.interceptors.request.use( (config) => { - const accessToken = getToken(); + const accessToken = getToken(); // 여기서 getToken()을 호출하여 실제로 사용 if (accessToken) { - if (config.headers && typeof config.headers.set === 'function') { - // AxiosHeaders 객체 처리 - config.headers.set('Authorization', `Bearer ${accessToken}`); + if ( + config.headers && + 'set' in config.headers && + typeof (config.headers as AxiosHeaders).set === 'function' + ) { + // AxiosHeaders 타입으로 헤더를 다루는 경우 + (config.headers as AxiosHeaders).set( + 'Authorization', + `Bearer ${accessToken}` + ); } else { - // 일반 객체 초기화 + // 일반 객체로 헤더를 다루는 경우 config.headers = { - ...config.headers, // 기존 헤더 유지 + ...config.headers, Authorization: `Bearer ${accessToken}`, - } as any; + } as AxiosRequestHeaders; } } console.log('Request Headers:', config.headers); // 디버깅용 로그 @@ -43,26 +65,13 @@ export const createClient = (config?: AxiosRequestConfig): AxiosInstance => { } ); - // 응답 인터셉터: 401 상태 처리 - axiosInstance.interceptors.response.use( - (response) => response, - (error) => { - if (error.response?.status === 401) { - console.warn('401 Unauthorized: Logging out user...'); - removeToken(); // 토큰 삭제 - store.dispatch(logout()); // Redux 상태 초기화 - window.location.href = '/login'; // 로그인 페이지로 리다이렉트 - return; - } - return Promise.reject(error); - } - ); - return axiosInstance; }; // 기본 Axios 인스턴스 생성 export const httpClient = createClient(); -// 기본 Axios 인스턴스 내보내기 +// 토큰 관련 함수 export +export { setToken, removeToken, getToken }; + export default httpClient; diff --git a/src/components/AuthButton.tsx b/src/components/AuthButton.tsx new file mode 100644 index 0000000..305a041 --- /dev/null +++ b/src/components/AuthButton.tsx @@ -0,0 +1,95 @@ +// src/components/Header.tsx +import { useNavigate } from 'react-router'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '@/store/rootReducer'; +import { logout } from '@/hooks/userSlice'; +import { removeToken } from '@/apis/http.api'; +import styled from 'styled-components'; + +function AuthButton() { + const navigate = useNavigate(); + const dispatch = useDispatch(); + const isLoggedIn = useSelector((state: RootState) => state.user.isLoggedIn); + + const handleLogin = () => { + // 로그인 페이지로 이동 + navigate('/login'); + }; + + const handleLogout = () => { + // Redux 상태 초기화 + dispatch(logout()); + // 토큰 제거 + removeToken(); + // 로그아웃 후 메인 페이지 이동(필요하다면) + navigate('/'); + }; + + return isLoggedIn ? ( + +
+
+
로그아웃
+
+
+
+ ) : ( + +
+
+
로그인
+
+
+
+ ); +} + +export default AuthButton; + +// Styled Components 동일 +const ButtonBox = styled.div` + height: 52px; + width: 114px; + + .group { + height: 100%; + position: relative; + } + + .overlap-group { + border-radius: 30px; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + cursor: pointer; + transition: background-color 0.2s ease-in-out; + } + + .text-wrapper { + color: #ffffff; + font-family: 'Pretendard-ExtraBold', Helvetica; + font-size: 15px; + line-height: 12px; + white-space: nowrap; + } +`; + +const LoginBox = styled(ButtonBox)` + .overlap-group { + background-color: #32c040; + } + .overlap-group:hover { + background-color: #28a034; + } +`; + +const LogoutBox = styled(ButtonBox)` + .overlap-group { + background-color: #9a9a9a; + } + .overlap-group:hover { + background-color: #7f7f7f; + } +`; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 69b2b03..24ab86c 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,6 +1,7 @@ import styled from 'styled-components'; import logo from '@/assets/logo.svg'; import Avatar from './ui/atoms/Avator'; +import AuthButton from './AuthButton'; const Header = () => ( @@ -14,6 +15,7 @@ const Header = () => ( /> + ); diff --git a/src/components/ui/atoms/Input.tsx b/src/components/ui/atoms/Input.tsx index 22af179..bc6de15 100644 --- a/src/components/ui/atoms/Input.tsx +++ b/src/components/ui/atoms/Input.tsx @@ -20,7 +20,7 @@ interface LabelProps { const StyledLabel = styled.label` position: absolute; - left: 1rem; + left: 2rem; top: 50%; transform: translateY(-50%); color: #727272; diff --git a/src/hooks/userSlice.ts b/src/hooks/userSlice.ts new file mode 100644 index 0000000..3ce5fd0 --- /dev/null +++ b/src/hooks/userSlice.ts @@ -0,0 +1,97 @@ +// userSlice.ts +import { DecodedToken, UserInfo, UserState } from '@/types/auth'; +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { jwtDecode } from 'jwt-decode'; + +const initialState: UserState = { + isLoggedIn: false, + token: null, + userInfo: null, + loading: false, + error: null, +}; + +// 실제 서버에 로그인 요청을 보내고 토큰을 받아오는 thunk +export const loginAsync = createAsyncThunk( + 'user/loginAsync', + async ( + { email, password }: { email: string; password: string }, + { rejectWithValue } + ) => { + try { + const response = await axios.post( + 'http://localhost:3333/api/users/login', + { + email, + password, + } + ); + + if (response.data.success && response.data.token) { + return response.data.token; + } else { + return rejectWithValue('로그인 실패: 토큰 없음'); + } + } catch (error: any) { + return rejectWithValue( + error.response?.data?.message || '로그인 요청 실패' + ); + } + } +); + +const userSlice = createSlice({ + name: 'user', + initialState, + reducers: { + loginSuccess: ( + state, + action: PayloadAction<{ token: string; userInfo: UserInfo }> + ) => { + state.isLoggedIn = true; + state.token = action.payload.token; + state.userInfo = action.payload.userInfo; + state.loading = false; + state.error = null; + + localStorage.setItem('token', action.payload.token); + }, + logout: (state) => { + state.isLoggedIn = false; + state.token = null; + state.userInfo = null; + state.loading = false; + state.error = null; + }, + }, + extraReducers: (builder) => { + builder + .addCase(loginAsync.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(loginAsync.fulfilled, (state, action) => { + const decoded: DecodedToken = jwtDecode(action.payload); + const userInfo: UserInfo = { + id: decoded.id, + email: decoded.email, + nickname: decoded.nickname, + role: decoded.role, + }; + + state.isLoggedIn = true; + state.token = action.payload; + state.userInfo = userInfo; + state.loading = false; + state.error = null; + }) + .addCase(loginAsync.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }); + }, +}); + +export const { loginSuccess, logout } = userSlice.actions; +export default userSlice.reducer; diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 818312a..d4ff898 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -1,47 +1,43 @@ import styled from 'styled-components'; import { useNavigate } from 'react-router'; import qublogo from '@/assets/qublogo.svg'; - import { useForm } from 'react-hook-form'; import { useDispatch, useSelector } from 'react-redux'; // Redux 추가 -import { AppDispatch, RootState } from '@/store/store'; // Redux 타입 가져오기 -import { loginThunk } from '@/store/slices/authSlice'; // Thunk 가져오기 import Input from '@/components/ui/atoms/Input'; +import { RootState } from '@/store/rootReducer'; +import { loginAsync } from '@/hooks/userSlice'; +import { AppDispatch } from '@/store/store'; -export interface LoginProps { +type LoginFormData = { email: string; password: string; -} +}; function LoginPage() { const navigate = useNavigate(); const dispatch = useDispatch(); - - const { loading, error, isLoggedIn } = useSelector( - (state: RootState) => state.auth - ); + const { loading, error } = useSelector((state: RootState) => state.user); const { register, handleSubmit, formState: { errors }, - } = useForm(); + } = useForm(); - const onSubmit = async (data: LoginProps) => { - const result = await dispatch(loginThunk(data)); + const onSubmit = async (data: LoginFormData) => { + const result = await dispatch(loginAsync(data)); - if (loginThunk.fulfilled.match(result)) { + if (loginAsync.fulfilled.match(result)) { alert('로그인에 성공했습니다!'); - navigate('/'); // 로그인 성공 시 메인 페이지로 이동 + navigate('/'); } else { - alert(result.payload || '로그인에 실패했습니다. 다시 시도해주세요.'); + alert( + (result.payload as string) || + '로그인에 실패했습니다. 다시 시도해주세요.' + ); } }; - // 로그인 상태 확인 후 리다이렉트 처리 - if (isLoggedIn) { - navigate('/'); - } return ( diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts new file mode 100644 index 0000000..41d8039 --- /dev/null +++ b/src/store/rootReducer.ts @@ -0,0 +1,11 @@ +// src/app/rootReducer.ts +import { combineReducers } from '@reduxjs/toolkit'; +import userReducer from '@/hooks/userSlice'; + +const rootReducer = combineReducers({ + user: userReducer, + // 다른 slice reducer가 있다면 여기에 추가 +}); + +export type RootState = ReturnType; +export default rootReducer; diff --git a/src/store/slices/authSlice.ts b/src/store/slices/authSlice.ts deleted file mode 100644 index 4535169..0000000 --- a/src/store/slices/authSlice.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; -import { login } from '@/apis/auth.api'; -import { LoginRequest, LoginResponse } from '@/types/auth'; -import { DecodedToken, LoginThunkResponse } from '@/types/auth'; // 디코딩된 토큰 타입 가져오기 -import { jwtDecode } from 'jwt-decode'; - -// 초기 상태 -const initialState = { - isLoggedIn: false, - token: null as string | null, - decodedToken: null as DecodedToken | null, // 디코딩된 토큰 정보 - loading: false, - error: null as string | null, -}; - -export const loginThunk = createAsyncThunk( - 'auth/login', - async (credentials, thunkAPI) => { - try { - const response: LoginResponse = await login(credentials); - - // 토큰이 없는 경우 처리 - if (!response.token) { - throw new Error('Token is missing from the server response.'); - } - - // JWT 디코딩 - const decodedToken = jwtDecode(response.token); - - return { - token: response.token, - decodedToken, - }; - } catch (error: any) { - return thunkAPI.rejectWithValue( - error.response?.data?.message || '로그인 요청 실패' - ); - } - } -); - -// Slice 생성 -const authSlice = createSlice({ - name: 'auth', - initialState, - reducers: { - logout(state) { - state.isLoggedIn = false; - state.token = null; - state.error = null; - - localStorage.removeItem('token'); - }, - }, - extraReducers: (builder) => { - builder - .addCase(loginThunk.pending, (state) => { - state.loading = true; - state.error = null; - }) - .addCase(loginThunk.fulfilled, (state, action) => { - state.loading = false; - state.isLoggedIn = true; - state.token = action.payload.token; - state.decodedToken = action.payload.decodedToken; // 디코딩된 정보 저장 - - // 로컬스토리지에 토큰 저장 - localStorage.setItem('token', action.payload.token); - }) - .addCase(loginThunk.rejected, (state, action) => { - state.loading = false; - state.error = action.payload as string; // 에러 메시지 저장 - }); - }, -}); - -export const { logout } = authSlice.actions; -export default authSlice.reducer; diff --git a/src/store/store.ts b/src/store/store.ts index b1fe3eb..6d40096 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -1,13 +1,25 @@ import { configureStore } from '@reduxjs/toolkit'; -import authReducer from '@/store/slices/authSlice'; +import { persistStore, persistReducer } from 'redux-persist'; +import storage from 'redux-persist/lib/storage'; // 웹 환경에서 localStorage 사용 +import rootReducer from './rootReducer'; + +const persistConfig = { + key: 'root', + storage, + whitelist: ['user'], // user slice만 localStorage에 저장 +}; + +const persistedReducer = persistReducer(persistConfig, rootReducer); export const store = configureStore({ - reducer: { - auth: authReducer, - }, + reducer: persistedReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: false, // redux-persist 관련 non-serializable 경고 비활성화 + }), }); -export type RootState = ReturnType; -export type AppDispatch = typeof store.dispatch; +export const persistor = persistStore(store); -export default store; +// store의 타입 유추 +export type AppDispatch = typeof store.dispatch; diff --git a/src/types/auth.ts b/src/types/auth.ts index 8f76bf5..ba3e863 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -1,53 +1,23 @@ -export interface User { +export interface UserInfo { + id: number; email: string; nickname: string; - role: 'user' | 'admin'; + role: string; } -// 토큰 인터페이스 -interface Tokens { - accessToken: string; -} - -export interface AuthState { +export interface UserState { isLoggedIn: boolean; - user: User | null; - tokens: Tokens | null; + token: string | null; + userInfo: UserInfo | null; + loading: boolean; error: string | null; } -// 로그인 요청 데이터 타입 -export interface LoginRequest { - email: string; - password: string; -} - -export interface AuthResponse { - success: boolean; - token?: string; - user?: User; -} - -export interface APIError { - message: string; -} - export interface DecodedToken { + id: number; email: string; nickname: string; - role: string | string[]; // 단일 문자열 또는 배열 + role: string; iat: number; exp: number; } - -// Thunk 반환 타입 -export interface LoginThunkResponse { - token: string; // JWT 토큰 - decodedToken: DecodedToken; // 디코딩된 데이터 -} - -export interface LoginResponse { - message: any; - success: boolean; - token?: string; -}