diff --git a/frontend/src/components/LoginAndSignUpForm.module.css b/frontend/src/components/LoginAndSignUpForm.module.css index 90e8ee2d..89a9c7ad 100644 --- a/frontend/src/components/LoginAndSignUpForm.module.css +++ b/frontend/src/components/LoginAndSignUpForm.module.css @@ -159,3 +159,19 @@ background-color: #bce5ff; cursor: not-allowed; } + +.passwordPolicy { + list-style: none; + padding: 0; + margin: 8px 0px 0px 8px; + font-size: 13px; + color: #868e96; +} + +.passwordPolicy li { + transition: color 0.2s ease-in-out; +} + +.passwordPolicy li.valid { + color: #28a745; +} diff --git a/frontend/src/components/login/LoginForm.jsx b/frontend/src/components/login/LoginForm.jsx index 5f93630c..3a00b383 100644 --- a/frontend/src/components/login/LoginForm.jsx +++ b/frontend/src/components/login/LoginForm.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useNavigate, NavLink } from 'react-router-dom'; import styles from '../LoginAndSignUpForm.module.css'; import sejong_logo from '../../assets/sejong_logo.png'; @@ -8,6 +8,8 @@ import VerificationModal from './../VerificationModal'; import ResetPasswordModal from './ResetPasswordModal'; import FindEmailResultModal from './FindEmailResultModal'; +import { login } from '../../utils/auth'; + const LoginForm = () => { const nav = useNavigate(); @@ -17,7 +19,7 @@ const LoginForm = () => { const [foundEmail, setFoundEmail] = useState(''); // 전화번호 인증 성공 시 호출하는 함수 - const handlePhoneVerificationSuccess = (result) => { + const handlePhoneVerificationSuccess = () => { if (modalStep === 'verifyPhoneForEmail') { setFoundEmail('example@google.com'); setModalStep('showEmail'); @@ -32,17 +34,32 @@ const LoginForm = () => { const isFormValid = email.trim() !== '' && password.trim() !== ''; - const handleLogin = (e) => { + const abortRef = useRef(null); + + const handleLogin = async (e) => { e.preventDefault(); - // 안전장치 - if (!email || !password) { - alert('이메일과 비밀번호를 모두 입력해주세요.'); - return; - } - // 로그인 성공 시 로직 - localStorage.setItem('authToken', 'dummy-token-12345'); - nav('/'); + // 도중에 요청 시 전 요청 취소 + abortRef.current?.abort(); + abortRef.current = new AbortController(); + + try { + await login( + { + email, + password, + }, + abortRef.current.signal + ); + + nav('/'); + } catch (err) { + console.dir(err); + alert( + err.data?.errorMessage || + '로그인에 실패하였습니다. 이메일과 비밀번호를 확인해주세요.' + ); + } }; return ( @@ -69,6 +86,7 @@ const LoginForm = () => { value={email} onChange={(e) => setEmail(e.target.value)} placeholder="이메일을 입력하세요" + autoComplete="email" />
@@ -79,6 +97,7 @@ const LoginForm = () => { value={password} onChange={(e) => setPassword(e.target.value)} placeholder="비밀번호를 입력하세요" + autoComplete="current-password" />
- setVerificationNumber(e.target.value)} - placeholder="인증번호를 입력해주세요" - /> +
+ setVerificationNumber(e.target.value)} + placeholder="인증번호를 입력해주세요" + /> + +
- + setEmail(e.target.value)} - placeholder="이메일을 입력해주세요" + type="text" + id="phoneNumber" + value={phoneNumber} + onChange={(e) => setPhoneNumber(e.target.value)} + placeholder="ex) 01012345678" + autoComplete="tel" />
@@ -124,9 +222,20 @@ const SignUpForm = () => { type="password" id="password" value={password} - onChange={(e) => setPassword(e.target.value)} + onChange={handlePasswordChange} placeholder="비밀번호를 입력해주세요" + autoComplete="new-password" /> +
diff --git a/frontend/src/utils/auth.js b/frontend/src/utils/auth.js new file mode 100644 index 00000000..f32fa21d --- /dev/null +++ b/frontend/src/utils/auth.js @@ -0,0 +1,53 @@ +import { api } from './axios.js'; + +const DEFAULT_ROLE = 'TEAM_MEMBER'; + +export const signUp = async ( + { nickname, email, password, phoneNumber }, + signal +) => { + const payload = { + name: nickname.trim(), + email: email.trim(), + password: password, + role: DEFAULT_ROLE, + phoneNumber: phoneNumber.trim(), + }; + const res = await api.post('/api/auth/signup', payload, { signal }); + return res.data; +}; + +export const login = async ({ email, password }, signal) => { + const paylaod = { email, password }; + + const res = await api.post('/api/auth/login', paylaod, { signal }); + + const { accessToken, refreshToken } = res.data; + if (accessToken && refreshToken) { + // 3. 로컬 스토리지에 각 토큰을 저장 + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', refreshToken); + } + + return res.data; +}; + +export const sendVerificationNumber = async ({ email }, signal) => { + const res = await api.post('/api/email/send', null, { + params: { email }, + signal, + }); + + return res.data; +}; +export const checkVerificationNumber = async ( + { email, verificationNumber }, + signal +) => { + const res = await api.post('/api/email/verify', null, { + params: { email, code: verificationNumber }, + signal, + }); + + return res.data; +}; diff --git a/frontend/src/utils/axios.js b/frontend/src/utils/axios.js new file mode 100644 index 00000000..20005fb1 --- /dev/null +++ b/frontend/src/utils/axios.js @@ -0,0 +1,55 @@ +import axios from 'axios'; + +export const api = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + withCredentials: true, +}); + +api.interceptors.response.use( + (res) => res, + async (err) => { + // 토큰 로직 + const originRequest = err.config; + + // 액세스 토큰 만료 확인 + if (err.response?.status === 401 && !originRequest._retry) { + originRequest._retry = true; + + try { + const rt = localStorage.getItem('refreshToken'); + const res = await axios.post( + `${import.meta.env.VITE_API_URL}/api/auth/reissue`, + { refreshToken: rt }, + { withCredentials: true } + ); + + const newAccessToken = res.data.accessToken; + + localStorage.setItem('accessToken', newAccessToken); + originRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + return api(originRequest); + } catch (refreshError) { + console.error('Token refresh failed: ', refreshError); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + + const status = err.response?.status; + const message = + err.response?.data?.message || + err.response?.statusText || + '오류가 발생했습니다.'; + return Promise.reject({ status, message, data: err.response?.data }); + } +); + +api.interceptors.request.use((config) => { + const at = localStorage.getItem('accessToken'); + if (at) config.headers.Authorization = `Bearer ${at}`; + return config; +});