diff --git a/.gitignore b/.gitignore index f940a99..1ab3c71 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ dist dist-ssr *.local +# Environment variables +.env + # Editor directories and files .vscode/* !.vscode/extensions.json diff --git a/package.json b/package.json index 6312a28..a233820 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,15 @@ "build-storybook": "storybook build" }, "dependencies": { + "@heroicons/react": "^2.2.0", "@reduxjs/toolkit": "^2.4.0", "@tanstack/react-query": "^5.62.2", "@types/react-redux": "^7.1.34", "axios": "^1.7.9", + "jwt-decode": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hook-form": "^7.54.1", "react-redux": "^9.1.2", "react-router": "^7.0.2", "styled-components": "^6.1.13" @@ -33,6 +36,7 @@ "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", "@storybook/test": "^8.4.7", + "@types/jwt-decode": "^3.1.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18933c8..c0049ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@heroicons/react': + specifier: ^2.2.0 + version: 2.2.0(react@18.3.1) '@reduxjs/toolkit': specifier: ^2.4.0 version: 2.4.0(react-redux@9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1))(react@18.3.1) @@ -20,12 +23,18 @@ importers: axios: specifier: ^1.7.9 version: 1.7.9 + jwt-decode: + specifier: ^4.0.0 + version: 4.0.0 react: specifier: ^18.3.1 version: 18.3.1 react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) + react-hook-form: + specifier: ^7.54.1 + version: 7.54.1(react@18.3.1) react-redux: specifier: ^9.1.2 version: 9.1.2(@types/react@18.3.14)(react@18.3.1)(redux@5.0.1) @@ -63,6 +72,9 @@ importers: '@storybook/test': specifier: ^8.4.7 version: 8.4.7(storybook@8.4.7) + '@types/jwt-decode': + specifier: ^3.1.0 + version: 3.1.0 '@types/react': specifier: ^18.3.12 version: 18.3.14 @@ -541,6 +553,11 @@ packages: resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@heroicons/react@2.2.0': + resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} + peerDependencies: + react: '>= 16 || ^19.0.0-rc' + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -966,6 +983,10 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/jwt-decode@3.1.0': + resolution: {integrity: sha512-tthwik7TKkou3mVnBnvVuHnHElbjtdbM63pdBCbZTirCt3WAdM73Y79mOri7+ljsS99ZVwUFZHLMxJuJnv/z1w==} + deprecated: This is a stub types definition. jwt-decode provides its own type definitions, so you do not need this installed. + '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} @@ -1702,6 +1723,10 @@ packages: jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + jwt-decode@4.0.0: + resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==} + engines: {node: '>=18'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1926,6 +1951,12 @@ packages: peerDependencies: react: ^18.3.1 + react-hook-form@7.54.1: + resolution: {integrity: sha512-PUNzFwQeQ5oHiiTUO7GO/EJXGEtuun2Y1A59rLnZBBj+vNEOWt/3ERTiG1/zt7dVeJEM+4vDX/7XQ/qanuvPMg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -2753,6 +2784,10 @@ snapshots: dependencies: levn: 0.4.1 + '@heroicons/react@2.2.0(react@18.3.1)': + dependencies: + react: 18.3.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': @@ -3215,6 +3250,10 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/jwt-decode@3.1.0': + dependencies: + jwt-decode: 4.0.0 + '@types/mdx@2.0.13': {} '@types/node@22.10.2': @@ -3975,6 +4014,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jwt-decode@4.0.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -4197,6 +4238,10 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-hook-form@7.54.1(react@18.3.1): + dependencies: + react: 18.3.1 + react-is@16.13.1: {} react-is@17.0.2: {} diff --git a/src/App.tsx b/src/App.tsx index 93b98f4..8f202cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,8 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import Button from '@/components/atoms/Button'; import { RouterProvider, createBrowserRouter } from 'react-router'; import router from '@/routes/router'; +import { Provider } from 'react-redux'; // Redux Provider 임포트 +import { store } from '@/store/store'; // 설정된 Redux 스토어 임포트 const queryClient = new QueryClient({ defaultOptions: { @@ -16,10 +17,11 @@ const queryClient = new QueryClient({ const App = () => { const appRouter = createBrowserRouter(router); return ( - - - - + + + + + ); }; diff --git a/src/apis/auth.api.ts b/src/apis/auth.api.ts new file mode 100644 index 0000000..8c22891 --- /dev/null +++ b/src/apis/auth.api.ts @@ -0,0 +1,70 @@ +import httpClient from './http.api'; // Axios 인스턴스 가져오기 +import { JoinProps } from '@/pages/JoinPage'; +import { LoginProps } from '@/pages/LoginPage'; + +// 회원가입 요청 +export const join = async (userData: JoinProps) => { + try { + const { data, status } = await httpClient.post('/api/users/join', userData); + + if (status === 200) { + console.log('회원가입 성공:', data); + return data; + } else if (status === 400 && data.message.includes('입력해주세요')) { + console.error('필수 입력값 없음:', data.message); + throw new Error(data.message); + } else if (status === 400 && data.message.includes('중복된')) { + console.error('중복 오류:', data.message); + throw new Error(data.message); + } else { + throw new Error('알 수 없는 회원가입 에러가 발생했습니다.'); + } + } catch (error) { + console.error('회원가입 요청 중 에러 발생:', error); + throw new Error('회원가입 요청에 실패했습니다. 관리자에게 문의하세요.'); + } +}; + +interface LoginResponse { + message: any; + success: boolean; + token?: string; +} + +// 로그인 요청 +export const login = async (data: LoginProps): Promise => { + try { + const { data: responseData, status } = await httpClient.post( + '/api/users/login', + data + ); + + if (status === 200 && responseData.success) { + console.log('로그인 성공:', responseData); + return responseData; + } else if ( + status === 400 && + responseData.message.includes('입력해주세요') + ) { + console.error('필수 입력값 없음:', responseData.message); + throw new Error(responseData.message); + } else if ( + status === 404 && + responseData.message.includes('등록되지 않은 이메일') + ) { + console.error('등록되지 않은 이메일:', responseData.message); + throw new Error(responseData.message); + } else if ( + status === 401 && + responseData.message.includes('비밀번호가 틀렸습니다') + ) { + console.error('비밀번호 오류:', responseData.message); + throw new Error(responseData.message); + } else { + throw new Error('알 수 없는 로그인 에러가 발생했습니다.'); + } + } catch (error) { + console.error('로그인 요청 중 에러 발생:', error); + throw new Error('로그인 요청에 실패했습니다. 관리자에게 문의하세요.'); + } +}; diff --git a/src/apis/http.api.ts b/src/apis/http.api.ts new file mode 100644 index 0000000..67909a3 --- /dev/null +++ b/src/apis/http.api.ts @@ -0,0 +1,105 @@ +import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import { getToken, removeToken } from '@/store/slices/authSlice'; // 토큰 유틸리티 함수 import +import { logout } from '@/store/slices/authSlice'; +import { store } from '@/store/store'; + +const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3333'; +const DEFAULT_TIMEOUT = 30000; // 요청 제한 시간 + +// Axios 인스턴스 생성 함수 +export const createClient = (config?: AxiosRequestConfig): AxiosInstance => { + const axiosInstance = axios.create({ + baseURL: BASE_URL, + timeout: DEFAULT_TIMEOUT, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, + ...config, + }); + + // 요청 인터셉터: Authorization 헤더 동적 설정 + axiosInstance.interceptors.request.use( + (config) => { + const accessToken = getToken(); + if (accessToken) { + if (config.headers && typeof config.headers.set === 'function') { + // AxiosHeaders 객체 처리 + config.headers.set('Authorization', `Bearer ${accessToken}`); + } else { + // 일반 객체 초기화 + config.headers = { + ...config.headers, // 기존 헤더 유지 + Authorization: `Bearer ${accessToken}`, + } as any; + } + } + console.log('Request Headers:', config.headers); // 디버깅용 로그 + return config; + }, + (error) => { + console.error('Request Error:', error); + return Promise.reject(error); + } + ); + + // 응답 인터셉터: 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(); + +// API 요청 함수 +export const authApi = { + join: async (data: { email: string; password: string; nickname: string }) => { + try { + const response = await httpClient.post('/api/signup', data); + return response.data; + } catch (error) { + console.error('Signup API Error:', error); + throw error; + } + }, + login: async (data: { email: string; password: string }) => { + try { + const response = await httpClient.post('/api/login', data); + const { token } = response.data; + + // 토큰 저장 + localStorage.setItem('accessToken', token); // 필요 시 유틸리티 함수 사용 가능 + return response.data; + } catch (error) { + console.error('Login API Error:', error); + throw error; + } + }, + logout: async () => { + try { + await httpClient.post('/api/logout'); + removeToken(); // 토큰 삭제 + store.dispatch(logout()); // Redux 상태 초기화 + window.location.href = '/login'; // 로그인 페이지로 리다이렉트 + } catch (error) { + console.error('Logout API Error:', error); + throw error; + } + }, +}; + +// 기본 Axios 인스턴스 내보내기 +export default httpClient; diff --git a/src/assets/qublogo.svg b/src/assets/qublogo.svg new file mode 100644 index 0000000..3ae79fb --- /dev/null +++ b/src/assets/qublogo.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/atoms/Input.tsx b/src/components/atoms/Input.tsx new file mode 100644 index 0000000..22af179 --- /dev/null +++ b/src/components/atoms/Input.tsx @@ -0,0 +1,89 @@ +import React, { ForwardedRef, useState } from 'react'; +import styled from 'styled-components'; + +interface InputFieldProps extends React.InputHTMLAttributes { + label: string; + inputType?: 'text' | 'email' | 'password' | 'number' | 'nickname'; +} + +const Container = styled.div` + position: relative; + width: 100%; + align-items: center; + margin-top: 1.5rem; + justify-content: center; +`; + +interface LabelProps { + isActive: boolean; +} + +const StyledLabel = styled.label` + position: absolute; + left: 1rem; + top: 50%; + transform: translateY(-50%); + color: #727272; + font-family: sans-serif; + transition: all 0.2s ease-in-out; + + ${({ isActive }) => + isActive && + ` + top: 25%; + font-size: 0.75rem; /* text-xs */ + color: #6b7280; /* text-gray-500 */ + `} +`; + +const StyledInput = styled.input` + align-items: center; + width: 100%; + height: 70px; + padding: 0.1rem 1rem; + background-color: #ffffff; + border-radius: 0.375rem; + border: 0.5px solid #000000; + color: #727272; + font-size: 1.25rem; + + &:focus { + outline: none; + } + + ::placeholder { + color: transparent; + } +`; + +const Input = React.forwardRef( + ( + { label, inputType = 'text', onChange, ...props }: InputFieldProps, + ref: ForwardedRef + ) => { + const [isFocused, setIsFocused] = useState(false); + const [hasValue, setHasValue] = useState(false); + + const handleInputFocus = () => setIsFocused(true); + const handleInputBlur = (e: React.ChangeEvent) => { + setIsFocused(false); + setHasValue(e.target.value !== ''); + }; + + return ( + + {label} + + + ); + } +); + +export default Input; diff --git a/src/components/atoms/PasswordGuideLines.tsx b/src/components/atoms/PasswordGuideLines.tsx new file mode 100644 index 0000000..f5f03be --- /dev/null +++ b/src/components/atoms/PasswordGuideLines.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; +import { CheckCircleIcon } from '@heroicons/react/20/solid'; +import styled from 'styled-components'; + +interface Guideline { + label: string; + isValid: boolean; + check: (password: string) => boolean; +} + +const Container = styled.div` + display: flex; + flex-direction: column; + > * + * { + margin-top: 0.5rem; + } +`; + +const GuidelineItem = styled.div` + display: flex; + align-items: center; +`; + +const StyledCheckCircleIcon = styled(CheckCircleIcon)` + width: 1.25rem; + height: 1.25rem; + color: #32c040; +`; + +const EmptyCircle = styled.span` + margin-left: 0.15rem; + width: 0.85rem; + height: 0.85rem; + border: 1px solid #374151; + border-radius: 9999px; + display: inline-block; +`; + +const GuidelineLabel = styled.span<{ isValid: boolean }>` + margin-left: 0.5rem; + font-size: 0.875rem; + color: ${(props) => (props.isValid ? '#32C040' : '#6b7280')}; +`; + +const PasswordGuideLines = ({ password }: { password: string }) => { + const [guidelines, setGuidelines] = useState([ + { + label: '8자 이상, 15자 이하로 설정해 주세요', + isValid: false, + check: (password: string) => + password.length >= 8 && password.length <= 15, + }, + { + label: '특수 문자를 사용해 주세요', + isValid: false, + check: (password: string) => /[!@#$%^&*(),.?":{}|<>]/.test(password), + }, + { + label: '동일한 문자가 4번 반복되면 안돼요', + isValid: false, + check: (password: string) => + !/(.)\1{3}/.test(password.replace(/\s/g, '')), + }, + ]); + + useEffect(() => { + setGuidelines((prevGuidelines) => + prevGuidelines.map((guideline) => ({ + ...guideline, + isValid: guideline.check(password), + })) + ); + }, [password]); + + return ( + + {guidelines.map(({ label, isValid }, index) => ( + + {isValid ? : } + {label} + + ))} + + ); +}; + +export default PasswordGuideLines; diff --git a/src/main.tsx b/src/main.tsx index 74566ee..4ee7573 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,22 +3,6 @@ import { createRoot } from 'react-dom/client'; import App from './App.tsx'; import '@/styles/reset.css'; -// if (process.env.NODE_ENV === 'development') { -// worker.start().then(() => { -// createRoot(document.getElementById('root')!).render( -// -// -// -// ); -// }); -// } else { -// createRoot(document.getElementById('root')!).render( -// -// -// -// ); -// } - createRoot(document.getElementById('root')!).render( diff --git a/src/pages/JoinPage.tsx b/src/pages/JoinPage.tsx new file mode 100644 index 0000000..4cf756d --- /dev/null +++ b/src/pages/JoinPage.tsx @@ -0,0 +1,228 @@ +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import styled from 'styled-components'; +import { useNavigate } from 'react-router'; +import qublogo from '@/assets/qublogo.svg'; +import Input from '@/components/atoms/Input'; +import PasswordGuideLines from '@/components/atoms/PasswordGuideLines'; +import { join } from '@/apis/auth.api'; + +export interface JoinProps { + email: string; + nickname: string; + password: string; + confirmPassword: string; +} + +function JoinPage() { + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + watch, + } = useForm(); + + const password = watch('password'); // PasswordGuideLines에서 실시간 사용 + + const onSubmit = async (data: JoinProps) => { + setIsLoading(true); + try { + await join(data); // 회원가입 요청 + alert('회원가입이 완료되었습니다!'); + navigate('/login'); // 로그인 페이지로 이동 + } catch (error: any) { + console.error('회원가입 중 에러 발생:', error); + if (error.response?.data?.message) { + alert(error.response.data.message); // 서버에서 반환된 에러 메시지 표시 + } else { + alert('회원가입에 실패했습니다. 다시 시도해주세요.'); + } + } finally { + setIsLoading(false); + } + }; + + return ( + + + navigate('/')}> + + + +
+ {/* 이메일 */} + + + + {errors.email && {errors.email.message}} + + {/* 닉네임 */} + + + + {errors.nickname && {errors.nickname.message}} + + {/* 비밀번호 */} + + + + {errors.password && {errors.password.message}} + + {/* 비밀번호 확인 */} + + + value === password || '비밀번호가 일치하지 않습니다.', + })} + /> + + {errors.confirmPassword && ( + {errors.confirmPassword.message} + )} + + {/* 비밀번호 가이드라인 */} + + + + + {/* 제출 버튼 */} + + {isLoading ? '처리 중...' : '회원가입'} + +
+
+
+ ); +} + +const Container = styled.div` + background-color: #ffffff; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100vh; +`; + +const InnerWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 24rem; +`; + +const LogoWrapper = styled.div` + margin-bottom: 2.5rem; + cursor: pointer; +`; + +const LogoImage = styled.img` + width: 150px; + height: 80px; + object-fit: contain; +`; + +const Form = styled.form` + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +`; + +const InputWrapper = styled.div` + width: 100%; + display: flex; + justify-content: center; + margin-bottom: 1rem; +`; + +const StyledInput = styled(Input)` + max-width: 350px; + width: 100%; + margin: 0 auto; + display: block; +`; + +const PasswordGuideLinesWrapper = styled.div` + width: 100%; + text-align: left; + margin-left: 1rem; + margin-top: 1rem; + margin-bottom: 1rem; +`; + +const SubmitButton = styled.button` + width: 300px; + height: 70px; + background-color: #ffffff; + border: 1px solid #000000; + border-radius: 9999px; + display: flex; + justify-content: center; + align-items: center; + color: #000000; + font-size: 15px; + font-weight: 400; + cursor: pointer; + margin: 0 auto; + margin-top: 2rem; + + &:hover { + background-color: #e5e7eb; + } + + &:disabled { + background-color: #e5e7eb; + cursor: not-allowed; + } +`; + +const ErrorText = styled.p` + color: #ef4444; + font-size: 0.875rem; + margin-bottom: 1rem; + text-align: left; + width: 100%; + max-width: 350px; +`; + +export default JoinPage; diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..2575c59 --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,183 @@ +import styled from 'styled-components'; +import { useNavigate } from 'react-router'; +import qublogo from '@/assets/qublogo.svg'; +import Input from '@/components/atoms/Input'; +import { useForm } from 'react-hook-form'; +import { login } from '@/apis/auth.api'; +import { useState } from 'react'; + +export interface LoginProps { + email: string; + password: string; +} + +function LoginPage() { + const navigate = useNavigate(); + const { + register, + handleSubmit, + formState: { errors }, + } = useForm(); + + const [isLoading, setIsLoading] = useState(false); // 로딩 상태 추가 + + const onSubmit = async (data: LoginProps) => { + setIsLoading(true); + try { + await login(data); + alert('로그인에 성공했습니다!'); + navigate('/'); // 로그인 성공 시 메인 페이지로 이동 + } catch (error: any) { + console.error('로그인 실패:', error); + alert(error.message || '로그인에 실패했습니다. 다시 시도해주세요.'); + } finally { + setIsLoading(false); + } + }; + + return ( + + + navigate('/')}> + + + +
+ + + + {errors.email && {errors.email.message}} + + + + + {errors.password && {errors.password.message}} + + + {isLoading ? '로그인 중...' : '로그인'} + +
+ + + 아직 회원이 아니신가요? + navigate('/join')}>이메일 회원가입 + +
+
+ ); +} + +const Container = styled.div` + background-color: #ffffff; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + height: 100vh; +`; + +const InnerWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + max-width: 24rem; +`; + +const LogoWrapper = styled.div` + margin-bottom: 2.5rem; + cursor: pointer; +`; + +const LogoImage = styled.img` + width: 150px; + height: 80px; + object-fit: contain; +`; + +const Form = styled.form` + width: 100%; + display: flex; + flex-direction: column; + align-items: center; +`; + +const InputWrapper = styled.div` + width: 100%; + display: flex; + justify-content: center; + margin-bottom: 1rem; +`; + +const StyledInput = styled(Input)` + max-width: 350px; + width: 100%; + margin: 0 auto; + display: block; +`; + +const SubmitButton = styled.button` + width: 300px; + height: 70px; + background-color: #ffffff; + border: 1px solid #000000; + border-radius: 9999px; + display: flex; + justify-content: center; + align-items: center; + color: #000000; + font-size: 18px; + font-weight: 400; + cursor: pointer; + margin: 0 auto; + margin-top: 2rem; + + &:hover { + background-color: #e5e7eb; + } + + &:disabled { + background-color: #e5e7eb; + cursor: not-allowed; + } +`; + +const BottomSection = styled.div` + width: 100%; + display: flex; + justify-content: center; + align-items: center; + margin-top: 3.5rem; + gap: 1rem; +`; + +const InfoText = styled.span` + color: #a7a7a7; + font-size: 12px; +`; + +const LinkText = styled.span` + color: #000000; + font-size: 12px; + cursor: pointer; +`; + +const ErrorText = styled.p` + color: #ef4444; + font-size: 0.875rem; + margin-bottom: 1rem; + text-align: left; + width: 100%; + max-width: 350px; +`; + +export default LoginPage; diff --git a/src/routes/router.tsx b/src/routes/router.tsx index b3db9b9..dae66f1 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -1,10 +1,20 @@ import HomePage from '@/pages/HomePage'; +import JoinPage from '@/pages/JoinPage'; +import LoginPage from '@/pages/LoginPage'; const router = [ { path: '/', element: , }, + { + path: '/login', + element: , + }, + { + path: '/join', + element: , + }, ]; export default router; diff --git a/src/store/slices/authSlice.ts b/src/store/slices/authSlice.ts new file mode 100644 index 0000000..cad3317 --- /dev/null +++ b/src/store/slices/authSlice.ts @@ -0,0 +1,208 @@ +// src/store/slices/authSlice.ts + +import { createSlice, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { jwtDecode } from 'jwt-decode'; + +// 사용자 정보 인터페이스 +interface User { + email: string; + nickname: string; + role: 'user' | 'admin'; +} + +// 토큰 인터페이스 +interface Tokens { + accessToken: string; +} + +// 인증 상태 인터페이스 +export interface AuthState { + isLoggedIn: boolean; + user: User | null; + tokens: Tokens | null; + error: string | null; +} + +// 로그인 요청 페이로드 인터페이스 +interface LoginPayload { + email: string; + password: string; +} + +// 회원가입 요청 페이로드 인터페이스 +interface SignupPayload { + email: string; + password: string; + nickname: string; +} + +// JWT 디코딩 인터페이스 +interface DecodedToken { + email: string; + nickname: string; + roles: string[]; + exp: number; +} + +// LocalStorage 관련 유틸리티 함수 +export const getToken = (): string | null => + localStorage.getItem('accessToken'); +export const setToken = (token: string): void => + localStorage.setItem('accessToken', token); +export const removeToken = (): void => localStorage.removeItem('accessToken'); + +export const getNickname = (): string | null => + localStorage.getItem('nickname'); +export const setNickname = (nickname: string): void => + localStorage.setItem('nickname', nickname); +export const removeNickname = (): void => localStorage.removeItem('nickname'); + +// 비동기 Thunk 액션 생성 + +// 회원가입 Thunk +export const signup = createAsyncThunk< + { success: boolean }, + SignupPayload, + { rejectValue: string } +>('auth/signup', async (data, thunkAPI) => { + try { + console.log('회원가입 요청 데이터:', data); + const response = await axios.post('/api/signup', data); + console.log('회원가입 성공 응답:', response.data); + return response.data; + } catch (error: any) { + console.error( + '회원가입 실패:', + error.response?.data?.message || error.message + ); + return thunkAPI.rejectWithValue( + error.response?.data?.message || '회원가입 실패' + ); + } +}); + +// 로그인 Thunk +export const login = createAsyncThunk< + { user: User; tokens: Tokens }, + LoginPayload, + { rejectValue: string } +>('auth/login', async (credentials, thunkAPI) => { + try { + console.log('로그인 요청 데이터:', credentials); + const response = await axios.post('/api/login', credentials); + const { token } = response.data; + + // JWT 디코딩하여 사용자 정보 추출 + const decoded: DecodedToken = jwtDecode(token); + const role = decoded.roles.includes('admin') ? 'admin' : 'user'; + + const userData: User = { + email: decoded.email, + nickname: decoded.nickname, + role, + }; + + const tokensData: Tokens = { + accessToken: token, + }; + + // 로컬스토리지에 토큰 저장 + setToken(tokensData.accessToken); + setNickname(userData.nickname); + + console.log('로그인 성공:', { user: userData, tokens: tokensData }); + + return { user: userData, tokens: tokensData }; + } catch (error: any) { + console.error( + '로그인 실패:', + error.response?.data?.message || error.message + ); + return thunkAPI.rejectWithValue( + error.response?.data?.message || '로그인 실패' + ); + } +}); + +// 초기 상태 설정 +const initialState: AuthState = { + isLoggedIn: !!getToken(), + user: getNickname() + ? { + email: '', + nickname: getNickname() || '', + role: 'user', + } + : null, + tokens: getToken() + ? { + accessToken: getToken()!, + } + : null, + error: null, +}; + +// 슬라이스 생성 +const authSlice = createSlice({ + name: 'auth', + initialState, + reducers: { + // 로그아웃 액션 + logout: (state) => { + console.log('로그아웃 처리'); + state.isLoggedIn = false; + state.user = null; + state.tokens = null; + state.error = null; + removeToken(); + removeNickname(); + }, + // 에러 설정 액션 + setError: (state, action: PayloadAction) => { + state.error = action.payload; + }, + // 에러 클리어 액션 + clearError: (state) => { + state.error = null; + }, + }, + extraReducers: (builder) => { + // 회원가입 + builder.addCase(signup.pending, (state) => { + console.log('회원가입 진행 중...'); + state.error = null; + }); + builder.addCase(signup.fulfilled, (state) => { + console.log('회원가입 성공'); + state.error = null; + }); + builder.addCase(signup.rejected, (state, action) => { + console.log('회원가입 실패:', action.payload || '알 수 없는 오류'); + state.error = action.payload || '회원가입 실패'; + }); + + // 로그인 + builder.addCase(login.pending, (state) => { + console.log('로그인 진행 중...'); + state.error = null; + }); + builder.addCase( + login.fulfilled, + (state, action: PayloadAction<{ user: User; tokens: Tokens }>) => { + console.log('로그인 성공'); + state.isLoggedIn = true; + state.user = action.payload.user; + state.tokens = action.payload.tokens; + } + ); + builder.addCase(login.rejected, (state, action) => { + console.log('로그인 실패:', action.payload || '알 수 없는 오류'); + state.error = action.payload || '로그인 실패'; + }); + }, +}); + +// 액션 및 리듀서 내보내기 +export const { logout, setError, clearError } = authSlice.actions; +export default authSlice.reducer; diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..5db4517 --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,11 @@ +import { configureStore } from '@reduxjs/toolkit'; +import authReducer from '@/store/slices/authSlice'; + +export const store = configureStore({ + reducer: { + auth: authReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch;