From 985ba75d342769f1d97fbc700ac33795ec998794 Mon Sep 17 00:00:00 2001 From: artwork21c Date: Wed, 31 Dec 2025 19:39:42 +0900 Subject: [PATCH] =?UTF-8?q?jwt=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=A7=84=ED=96=89=20:=20axios=20=EB=A1=9C=20api=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=ED=95=98=EC=97=AC=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EB=90=9C=20=EC=BF=A0=ED=82=A4=EB=A1=9C=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=83=81=ED=83=9C=EA=B0=92=20=EB=B3=80=EA=B2=BD=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + src/_nav.tsx | 18 --- src/axios/authService.ts | 83 +++++++++++ src/axios/axios.ts | 26 ++++ src/components/AppHeader.tsx | 8 ++ src/components/header/AppHeaderDropdown.tsx | 4 +- src/context/AuthContext.tsx | 144 ++++++++++++++++++++ src/hooks/useAuth.ts | 12 ++ src/index.tsx | 5 +- src/views/pages/login/Login.tsx | 65 ++++++++- src/vite-env.d.ts | 0 tsconfig.json | 8 -- 12 files changed, 340 insertions(+), 35 deletions(-) create mode 100644 src/axios/authService.ts create mode 100644 src/axios/axios.ts create mode 100644 src/context/AuthContext.tsx create mode 100644 src/hooks/useAuth.ts create mode 100644 src/vite-env.d.ts diff --git a/package.json b/package.json index 0c47ad7..cea93e6 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,11 @@ "@types/prop-types": "^15.7.15", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "axios": "^1.13.2", "chart.js": "^4.5.1", "classnames": "^2.5.1", "core-js": "^3.47.0", + "jwt-decode": "^4.0.0", "prop-types": "^15.8.1", "react": "^19.2.3", "react-dom": "^19.2.3", diff --git a/src/_nav.tsx b/src/_nav.tsx index 43fd900..1809cc9 100644 --- a/src/_nav.tsx +++ b/src/_nav.tsx @@ -76,24 +76,6 @@ const _nav = [ }, ], }, - { - component: CNavItem, - name: 'Demo', - href: 'https://coreui.io/demos/react/5.5/free/?theme=light#/dashboard', - icon: , - }, - { - component: CNavItem, - name: 'Docs', - href: 'https://coreui.io/react/docs/templates/installation/', - icon: , - }, - { - component: CNavItem, - name: 'Src', - href: 'https://github.com/coreui/coreui-free-react-admin-template', - icon: , - }, ] export default _nav diff --git a/src/axios/authService.ts b/src/axios/authService.ts new file mode 100644 index 0000000..7a7c3e7 --- /dev/null +++ b/src/axios/authService.ts @@ -0,0 +1,83 @@ +import axios from './axios'; +import { jwtDecode } from 'jwt-decode'; + +export const getAccessTokenFromCookie = (): string | null => { + const name = 'access_token'; + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(';').shift() || null; + return null; +}; + +export interface LoginCredentials { + memberId: string; + password: string; +} + +export interface RegisterData { + username: string; + email: string; + password: string; +} + +export interface AuthResponse { + resultCode: string; + resultMessage: string; + resultData: string; +} + +export interface DecodedToken { + encryptedPayload: string; + memberId: string; + memberName: string; + expiration: number; +} + +// 로그인 API +export const login = async (credentials: LoginCredentials): Promise => { + const response = await axios.post('/auth/login', credentials); + return response.data; +}; + +// 회원가입 API +export const register = async (data: RegisterData): Promise<{ message: string }> => { + const response = await axios.post<{ message: string }>('/auth/register', data); + return response.data; +}; + +// 로그아웃 API +export const logout = async (): Promise => { + await axios.post('/auth/logout'); + //localStorage.removeItem('access_token'); + //localStorage.removeItem('refresh_token'); + document.cookie = 'access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/'; + document.cookie = 'refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/'; +}; + +// 토큰이 유효한지 확인 +export const isTokenValid = (token: string): boolean => { + try { + const decoded = jwtDecode(token); + const currentTime = Date.now() / 1000; + + return decoded.expiration > currentTime; + } catch (error) { + return false; + } +}; + +// 토큰에서 사용자 정보 추출 +export const getUserFromToken = (token: string): DecodedToken | null => { + try { + const decoded = jwtDecode(token); + return decoded; + } catch (error) { + return null; + } +}; + +// 현재 사용자 정보 가져오기 +export const getCurrentUser = async () => { + const response = await axios.get('/auth/me'); + return response.data; +}; \ No newline at end of file diff --git a/src/axios/axios.ts b/src/axios/axios.ts new file mode 100644 index 0000000..e077bfb --- /dev/null +++ b/src/axios/axios.ts @@ -0,0 +1,26 @@ +import axios from 'axios'; + +const API_URL = '/api/v1/'; + +// Axios 인스턴스 생성 +const instance = axios.create({ + baseURL: API_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json' + } +}); + +// 요청 인터셉터 +instance.interceptors.request.use( + (config) => { + const accessToken = localStorage.getItem('access_token'); + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + return config; + }, + (error) => Promise.reject(error) +); + +export default instance; \ No newline at end of file diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx index 40db829..16d83ec 100644 --- a/src/components/AppHeader.tsx +++ b/src/components/AppHeader.tsx @@ -28,6 +28,7 @@ import { import { AppBreadcrumb } from './index' import { AppHeaderDropdown } from './header/index' +import { useAuth } from 'src/hooks/useAuth' const AppHeader = () => { const headerRef = useRef(null) @@ -35,6 +36,8 @@ const AppHeader = () => { const dispatch = useDispatch() const sidebarShow = useSelector((state: RootState) => state.sidebarShow) + const { state } = useAuth() + const { isAuthenticated, member } = state useEffect(() => { const handleScroll = () => { @@ -61,6 +64,11 @@ const AppHeader = () => { + {isAuthenticated && member && ( + + {member.memberId} + + )} diff --git a/src/components/header/AppHeaderDropdown.tsx b/src/components/header/AppHeaderDropdown.tsx index 30c0df8..9021a10 100644 --- a/src/components/header/AppHeaderDropdown.tsx +++ b/src/components/header/AppHeaderDropdown.tsx @@ -27,10 +27,10 @@ import avatar8 from './../../assets/images/avatars/8.jpg' const AppHeaderDropdown = () => { return ( - + - + Account diff --git a/src/context/AuthContext.tsx b/src/context/AuthContext.tsx new file mode 100644 index 0000000..440f223 --- /dev/null +++ b/src/context/AuthContext.tsx @@ -0,0 +1,144 @@ +import React, { createContext, useReducer, useEffect } from 'react'; + +import { getCurrentUser, isTokenValid } from 'src/axios/authService'; + +// 사용자 타입 정의 +export interface Member { + memberId: string; + memberName: string; +} + +// 인증 상태 타입 정의 +interface AuthState { + isAuthenticated: boolean; + member: Member | null; + loading: boolean; + error: string | null; +} + +// 액션 타입 정의 +type AuthAction = + | { type: 'LOGIN_SUCCESS'; payload: { member: Member; token: string } } + | { type: 'LOGOUT' } + | { type: 'AUTH_ERROR'; payload: string } + | { type: 'CLEAR_ERROR' } + | { type: 'SET_LOADING' } + | { type: 'MEMBER_LOADED'; payload: Member }; + +// 초기 상태 +const initialState: AuthState = { + isAuthenticated: false, + member: null, + loading: true, + error: null +}; + +// Context 생성 +export const AuthContext = createContext<{ + state: AuthState; + dispatch: React.Dispatch; + login: (token: string, member: Member) => void; + logout: () => void; +}>({ + state: initialState, + dispatch: () => null, + login: () => null, + logout: () => null +}); + +// 리듀서 함수 +const authReducer = (state: AuthState, action: AuthAction): AuthState => { + switch (action.type) { + case 'LOGIN_SUCCESS': + localStorage.setItem('accessToken', action.payload.token); + return { + ...state, + isAuthenticated: true, + member: action.payload.member, + loading: false, + error: null + }; + case 'LOGOUT': + localStorage.removeItem('accessToken'); + return { + ...state, + isAuthenticated: false, + member: null, + loading: false, + error: null + }; + case 'AUTH_ERROR': + localStorage.removeItem('accessToken'); + return { + ...state, + isAuthenticated: false, + member: null, + loading: false, + error: action.payload + }; + case 'CLEAR_ERROR': + return { + ...state, + error: null + }; + case 'SET_LOADING': + return { + ...state, + loading: true + }; + case 'MEMBER_LOADED': + return { + ...state, + isAuthenticated: true, + member: action.payload, + loading: false + }; + default: + return state; + } +}; + +// Provider 컴포넌트 +export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [state, dispatch] = useReducer(authReducer, initialState); + + // 로그인 함수 + const login = (token: string, member: Member) => { + dispatch({ + type: 'LOGIN_SUCCESS', + payload: { token, member } + }); + }; + + // 로그아웃 함수 + const logout = () => { + dispatch({ type: 'LOGOUT' }); + }; + + // 초기 인증 상태 확인 + useEffect(() => { + const loadUser = async () => { + const token = localStorage.getItem('accessToken'); + + if (!token || !isTokenValid(token)) { + dispatch({ type: 'LOGOUT' }); + return; + } + + try { + const member = await getCurrentUser(); + dispatch({ type: 'MEMBER_LOADED', payload: member }); + } catch (error) { + dispatch({ type: 'AUTH_ERROR', payload: 'Authentication failed' }); + } + }; + + loadUser(); + }, []); + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts new file mode 100644 index 0000000..efca210 --- /dev/null +++ b/src/hooks/useAuth.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { AuthContext } from '../context/AuthContext'; + +export const useAuth = () => { + const context = useContext(AuthContext); + + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + + return context; +}; \ No newline at end of file diff --git a/src/index.tsx b/src/index.tsx index 441af3a..14542f6 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -5,9 +5,12 @@ import 'core-js' import App from './App' import store from './store' +import { AuthProvider } from './context/AuthContext' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/views/pages/login/Login.tsx b/src/views/pages/login/Login.tsx index 1b2ee0b..36de7ab 100644 --- a/src/views/pages/login/Login.tsx +++ b/src/views/pages/login/Login.tsx @@ -1,5 +1,5 @@ -import React from 'react' -import { Link } from 'react-router-dom' +import React, { useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' import { CButton, CCard, @@ -15,8 +15,53 @@ import { } from '@coreui/react' import CIcon from '@coreui/icons-react' import { cilLockLocked, cilUser } from '@coreui/icons' +import { login as apiLogin, DecodedToken, getAccessTokenFromCookie } from 'src/axios/authService'; +import { useAuth } from 'src/hooks/useAuth'; +import { jwtDecode } from 'jwt-decode'; const Login = () => { + const [memberId, setMemberId] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const { login } = useAuth(); + const navigate = useNavigate(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const response = await apiLogin({ memberId, password }); + + if (response.resultCode === '200') { + const accessToken = getAccessTokenFromCookie(); + + if (accessToken) { + const decodedToken = jwtDecode(accessToken); + const member = { + memberId: decodedToken.memberId, + memberName: decodedToken.memberName, + }; + + login(accessToken, member); + navigate('/dashboard'); + } else { + setError('쿠키에서 accessToken을 찾을 수 없습니다.'); + } + } else { + setError(response.resultMessage || '로그인에 실패했습니다.'); + } + } catch (err: any) { + setError(err.response?.data?.message || '로그인 중 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + }; + + return (
@@ -25,14 +70,20 @@ const Login = () => { - +

Login

Sign In to your account

+ {error &&
{error}
} - + setMemberId(e.target.value)} + /> @@ -42,12 +93,14 @@ const Login = () => { type="password" placeholder="Password" autoComplete="current-password" + value={password} + onChange={(e) => setPassword(e.target.value)} /> - - Login + + {loading ? 'Logging in...' : 'Login'} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..e69de29 diff --git a/tsconfig.json b/tsconfig.json index c349a5e..f72cea1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,14 +9,6 @@ // Stricter Typechecking Options "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, - // Style Options - // "noImplicitReturns": true, - // "noImplicitOverride": true, - // "noUnusedLocals": true, - // "noUnusedParameters": true, - // "noFallthroughCasesInSwitch": true, - // "noPropertyAccessFromIndexSignature": true, - // Recommended Options "verbatimModuleSyntax": false, "isolatedModules": true, "noUncheckedSideEffectImports": true,