diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..b6c01e6 --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,15 @@ +// 목적: 로그인/회원가입 API만 담당(다른 리소스와 분리) +import axios from '@/lib/axios'; +import type { LoginRequest, LoginResponse, UserRequest } from '@/types/user'; + +// 로그인: POST /token +export async function apiLogin(body: LoginRequest) { + const { data } = await axios.post('/token', body); + return data; // data.item.token, data.item.user.item.id 사용 +} + +// 회원가입: POST /users (알바생이면 type: 'employee') +export async function apiSignup(body: UserRequest) { + const { data } = await axios.post('/users', body); + return data; +} diff --git a/src/api/users.ts b/src/api/users.ts new file mode 100644 index 0000000..9e6a128 --- /dev/null +++ b/src/api/users.ts @@ -0,0 +1,15 @@ +// 목적: 유저 리소스 전용(내 정보 조회/수정) +import axios from '@/lib/axios'; +import type { User } from '@/types/user'; + +// 내 정보 조회: GET /users/{user_id} +export async function apiGetUser(userId: string) { + const { data } = await axios.get<{ item: User }>(`/users/${userId}`); + return data.item; +} + +// 내 정보 수정: PUT /users/{user_id} +export async function apiUpdateUser(userId: string, patch: Partial) { + const { data } = await axios.put<{ item: User }>(`/users/${userId}`, patch); + return data.item; +} diff --git a/src/context/authProvider.tsx b/src/context/authProvider.tsx index b5cc670..3b7b06b 100644 --- a/src/context/authProvider.tsx +++ b/src/context/authProvider.tsx @@ -1,79 +1,121 @@ -import { LoginRequest, User, UserRequest, UserRole } from '@/types/user'; -import { createContext, ReactNode, useState } from 'react'; - -/** - * @TODO - * token은 AuthProvider 내부에서만 관리하고 외부에는 파생된 상태(isLogin, user 같은 권한)만 소비하는 설계 - * 컴포넌트에서 토큰 문자열을 직접 다룰일을 막을 수 있음 (보안/ 유지보수측면) - * 토큰 저장 위치(Storage, cookie 등)를 변경해야 할때도 컨텍스트 안에서만 토큰이 사용되기 때문에 외부 코드를 건드릴 일이 없음 - * token을 간접적으로 사용하는 저장,삭제,업데이트와 같은 액션은 내부 함수로 작성하고 Provider 외부로 노출하면 관리에 용이함 - * 외부는 파생된 상태값만 받아서 적절한 권한 처리만 하면 된다. - * - * AuthContextValue의 함수는 전부 void 값으로 지정했으나 구현에 따라 - * return 값이 필요할 경우 type/user 에서 맞는 타입 지정 - * - * 초반 설계는 AuthContext에서 구현을 하다가 - * 추후 AuthContext (토큰,로그인 상태 관리) / UserContext (프로필 전용) 로 관심사 분리 리팩토링 고려 - * - */ - -type AuthState = { - user: User | null; - isPending: boolean; -}; -interface AuthContextValue extends AuthState { +// 목적: 앱 전역에서 로그인/유저 상태를 관리하고, 인증 관련 표준 함수를 제공한다. +// 특징: 토큰과 사용자 ID는 내부에서만 관리하고, 외부는 파생 상태와 동작 함수만 사용한다. + +import { apiLogin, apiSignup } from '@/api/auth'; +import { apiGetUser, apiUpdateUser } from '@/api/users'; +import type { LoginRequest, User, UserRequest, UserRole } from '@/types/user'; +import { createContext, ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; + +type AuthContextValue = { + // 파생 상태 isLogin: boolean; - role: UserRole; + role: UserRole; // 'guest' | 'employee' | 'employer' + // 데이터 + user: User | null; + // 동작 login: (credentials: LoginRequest) => Promise; logout: () => void; signup: (data: UserRequest) => Promise; getUser: () => Promise; - updateUser: (data: Partial) => Promise; -} -const initialState: AuthState = { - user: null, - isPending: true, + updateUser: (patch: Partial) => Promise; +}; + +export const AuthContext = createContext(null); + +// 로컬 스토리지 키 (고정) +const TOKEN_KEY = 'thejulge_token'; +const USER_ID_KEY = 'thejulge_user_id'; + +// 브라우저에서만 동작하도록 가드된 유틸 +const setStorage = (key: string, value: string) => { + if (typeof window !== 'undefined') localStorage.setItem(key, value); +}; +const getStorage = (key: string) => + typeof window !== 'undefined' ? localStorage.getItem(key) : null; +const removeStorage = (key: string) => { + if (typeof window !== 'undefined') localStorage.removeItem(key); }; -export const AuthContext = createContext(undefined); const AuthProvider = ({ children }: { children: ReactNode }) => { - const [values, setValues] = useState(initialState); + // 핵심 상태: 토큰, 사용자 ID, 사용자 정보 const [token, setToken] = useState(null); + const [userId, setUserId] = useState(null); + const [user, setUser] = useState(null); + // 파생 상태 const isLogin = !!token; - const role: UserRole = !isLogin - ? 'guest' - : values.user?.type === 'employer' - ? 'employer' - : 'employee'; - - const login: AuthContextValue['login'] = async credentials => { - // TODO: 로그인 구현 (API 요청 후 setValues, setToken) - }; - const logout: AuthContextValue['logout'] = () => { - // TODO: 로그아웃 구현 (setValues, setToken 초기화) - }; - const signup: AuthContextValue['signup'] = async data => { - // TODO: 회원가입 구현 - }; - const getUser: AuthContextValue['getUser'] = async () => { - // TODO: 유저 조회 구현 - }; - const updateUser: AuthContextValue['updateUser'] = async data => { - // TODO: 유저 업데이트 구현 - }; - - const value: AuthContextValue = { - ...values, - isLogin, - role, - login, - logout, - signup, - getUser, - updateUser, - }; + const role: UserRole = useMemo(() => (user ? user.type : 'guest'), [user]); + + // 앱 시작 시 저장소에서 복원 + useEffect(() => { + const storedToken = getStorage(TOKEN_KEY); + const storedUserId = getStorage(USER_ID_KEY); + if (storedToken) setToken(storedToken); + if (storedUserId) setUserId(storedUserId); + }, []); + + // 로그인: /token → 토큰/사용자 ID 저장 → /users/{id}로 내 정보 동기화 + const login = useCallback(async (credentials: LoginRequest) => { + const res = await apiLogin(credentials); + const newToken = res.item.token; + const newUserId = res.item.user.item.id; + + setToken(newToken); + setUserId(newUserId); + setStorage(TOKEN_KEY, newToken); + setStorage(USER_ID_KEY, newUserId); + + const me = await apiGetUser(newUserId); + setUser(me); + }, []); + + // 로그아웃: 상태와 저장소 초기화 + const logout = useCallback(() => { + setToken(null); + setUser(null); + setUserId(null); + removeStorage(TOKEN_KEY); + removeStorage(USER_ID_KEY); + }, []); + + // 회원가입: /users 성공만 확인 (라우팅은 화면에서 처리) + const signup = useCallback(async (data: UserRequest) => { + await apiSignup(data); + }, []); + + // 내 정보 재조회 + const getUser = useCallback(async () => { + if (!userId) throw new Error('로그인이 필요합니다'); + const me = await apiGetUser(userId); + setUser(me); + }, [userId]); + + // 내 정보 수정 + const updateUser = useCallback( + async (patch: Partial) => { + if (!userId) throw new Error('로그인이 필요합니다'); + const updated = await apiUpdateUser(userId, patch); + setUser(updated); + }, + [userId] + ); + + // 컨텍스트 값 메모이즈 (리렌더 최소화) + const value = useMemo( + () => ({ + isLogin, + role, + user, + login, + logout, + signup, + getUser, + updateUser, + }), + [isLogin, role, user, login, logout, signup, getUser, updateUser] + ); return {children}; }; + export default AuthProvider; diff --git a/src/lib/axios/index.ts b/src/lib/axios/index.ts index d961096..2e4cf72 100644 --- a/src/lib/axios/index.ts +++ b/src/lib/axios/index.ts @@ -1,9 +1,34 @@ -import baseAxios, { AxiosInstance } from 'axios'; +import axios, { AxiosHeaders, AxiosInstance, InternalAxiosRequestConfig } from 'axios'; -const axiosInstance: AxiosInstance = baseAxios.create({ +const api: AxiosInstance = axios.create({ baseURL: process.env.NEXT_PUBLIC_API_URL, - headers: { - 'Content-Type': 'application/json', - }, + headers: new AxiosHeaders({ 'Content-Type': 'application/json' }), // 타입 안전 }); -export default axiosInstance; + +// "토큰을 붙이지 않을" 요청인지 판단 +// - 쿼리스트링이 있어도 경로 부분만 비교한다. +function isAuthFree(config: InternalAxiosRequestConfig) { + const method = (config.method || 'get').toLowerCase(); + const url = String(config.url || ''); + const pathOnly = url.split('?')[0]; // '/token?x=1' → '/token' + const isLoginOrSignup = pathOnly.endsWith('/token') || pathOnly.endsWith('/users'); + return method === 'post' && isLoginOrSignup; +} + +api.interceptors.request.use((config: InternalAxiosRequestConfig) => { + // 브라우저가 아니면(SSR) 스킵 + if (typeof window === 'undefined') return config; + + // 로그인/회원가입 요청이면 스킵 + if (isAuthFree(config)) return config; + + // localStorage에서 토큰을 읽어 Authorization에 붙이기 + const token = localStorage.getItem('thejulge_token'); + if (token) { + // headers는 AxiosHeaders 타입이므로 set()을 안전하게 쓸 수 있다. + (config.headers as AxiosHeaders).set('Authorization', `Bearer ${token}`); + } + return config; +}); + +export default api; diff --git a/src/pages/testAuth.tsx b/src/pages/testAuth.tsx new file mode 100644 index 0000000..56ecd28 --- /dev/null +++ b/src/pages/testAuth.tsx @@ -0,0 +1,220 @@ +import useAuth from '@/hooks/useAuth'; +import axios from '@/lib/axios'; +import { useEffect, useState } from 'react'; + +export default function TestAuthPage() { + const { isLogin, user, getUser, logout, login } = useAuth(); + + // 하이드레이션 에러 방지: 마운트 이후에만 localStorage 값을 렌더 + const [mounted, setMounted] = useState(false); + const [lsToken, setLsToken] = useState(''); + const [lsUserId, setLsUserId] = useState(''); + + // 폼 상태(테스트 계정) + const [email, setEmail] = useState('test.employee@example.com'); + const [password, setPassword] = useState('Passw0rd!'); + const [type, setType] = useState<'employee' | 'employer'>('employee'); + + const [notices, setNotices] = useState(null); + const [msg, setMsg] = useState(''); + const [loading, setLoading] = useState(false); + + // 마운트 후에만 localStorage 접근 + useEffect(() => { + setMounted(true); + if (typeof window !== 'undefined') { + setLsToken(localStorage.getItem('thejulge_token') ?? ''); + setLsUserId(localStorage.getItem('thejulge_user_id') ?? ''); + } + }, []); + + // 에러 메시지 안전 추출 + const pickErrorMessage = (e: unknown) => + (e as { response?: { data?: { message?: string } } })?.response?.data?.message || + (e instanceof Error ? e.message : '요청 실패'); + + // 1) 회원가입: POST /users (인터셉터가 Authorization 안 붙임) + const signup = async () => { + setLoading(true); + setMsg(''); + try { + const { data } = await axios.post<{ item?: unknown }>('/users', { + email, + password, + type, + }); + setMsg('회원가입 성공: ' + JSON.stringify(data?.item ?? data)); + } catch (e: unknown) { + setMsg('회원가입 실패: ' + pickErrorMessage(e)); + } finally { + setLoading(false); + } + }; + + // 2) 로그인: Provider의 login 사용 → /token(헤더 없이) → 저장 → /users/{id} 동기화 + const loginViaProvider = async () => { + setLoading(true); + setMsg(''); + try { + await login({ email, password }); + if (typeof window !== 'undefined') { + setLsToken(localStorage.getItem('thejulge_token') ?? ''); + setLsUserId(localStorage.getItem('thejulge_user_id') ?? ''); + } + setMsg('login() 성공: 토큰/ID 저장 및 user 동기화 완료'); + } catch (e: unknown) { + setMsg('login() 실패: ' + pickErrorMessage(e)); + } finally { + setLoading(false); + } + }; + + // 3) getUser(): 전역 user 재동기화(보호 API → 인터셉터가 헤더 자동 첨부) + const callGetUser = async () => { + setLoading(true); + setMsg(''); + try { + await getUser(); + setMsg('getUser() 호출 성공: user 상태 갱신'); + } catch (e: unknown) { + setMsg('getUser() 실패: ' + pickErrorMessage(e)); + } finally { + setLoading(false); + } + }; + + // 4) /notices: 보호 API 예시 → Authorization 자동 첨부 확인 + const callNotices = async () => { + setLoading(true); + setMsg(''); + try { + const { data } = await axios.get('/notices', { + params: { limit: 1, _: Date.now() }, // 캐시 방지(선택) + }); + setNotices(data); + setMsg('/notices 호출 성공(네트워크 탭에서 Authorization 헤더 확인)'); + } catch (e: unknown) { + setMsg('/notices 실패: ' + pickErrorMessage(e)); + } finally { + setLoading(false); + } + }; + + // 5) 로그아웃: Provider 상태/스토리지 초기화 + const doLogout = () => { + logout(); + if (typeof window !== 'undefined') { + localStorage.removeItem('thejulge_token'); + localStorage.removeItem('thejulge_user_id'); + setLsToken(''); + setLsUserId(''); + } + setMsg('로그아웃 및 localStorage 초기화 완료'); + }; + + // 6) 저장소만 비우기(새로고침 시 로그아웃 효과) + const clearStorageOnly = () => { + if (typeof window !== 'undefined') { + localStorage.removeItem('thejulge_token'); + localStorage.removeItem('thejulge_user_id'); + setLsToken(''); + setLsUserId(''); + } + setMsg('localStorage만 비움(새로고침 시 로그아웃 효과)'); + }; + + return ( +
+

Test Auth (임시 테스트 전용)

+ +
+

현재 상태

+
isLogin: {String(isLogin)}
+
user: {user ? JSON.stringify(user, null, 2) : 'null'}
+
localStorage token: {mounted ? lsToken : ''}
+
localStorage userId: {mounted ? lsUserId : ''}
+
+ +
+

테스트 계정

+
+ ) => setEmail(e.target.value)} + /> + ) => setPassword(e.target.value)} + /> +
+ + +
+
+
+ +
+ + + + + + +
+ + {msg &&

{msg}

} + {loading &&

요청 중…

} + +
+

/notices 응답(샘플)

+
+          {notices ? JSON.stringify(notices, null, 2) : '아직 호출 안 함'}
+        
+
+ + {/* 확인 포인트 */} +
+

확인 포인트

+
    +
  1. + 회원가입 → 로그인(login)localStorage에 token/userId 저장 +
  2. +
  3. + getUser() 클릭 시 전역 user 상태가 채워지는지 +
  4. +
  5. + /notices 호출 후 DevTools Network → Authorization: Bearer … 확인 +
  6. +
  7. + 로그아웃 시 localStorageuser 상태 초기화 +
  8. +
+
+
+ ); +}