diff --git a/src/components/common/ValidationInput.tsx b/src/components/common/ValidationInput.tsx index 66ff43bc..950f6991 100644 --- a/src/components/common/ValidationInput.tsx +++ b/src/components/common/ValidationInput.tsx @@ -15,6 +15,7 @@ import { RiEyeFill, RiEyeOffFill } from 'react-icons/ri'; * @params {string} [placeholder] - 입력 필드의 placeholder 텍스트 * @params {boolean} [isButtonInput] - 버튼이 포함된 입력 필드인지 여부. 기본값은 'false' * @params {React.ReactNode} [buttonLabel] - 버튼에 표시할 텍스트 또는 아이콘 + * @params {boolean} [buttonDisabled] - 버튼의 비활성화 여부 * @params {() => void} [onButtonClick] - 버튼 클릭 시 호출할 함수 * * 예시) @@ -39,6 +40,7 @@ type ValidationInputProps = { placeholder?: string; isButtonInput?: boolean; buttonLabel?: React.ReactNode; + buttonDisabled?: boolean; onButtonClick?: () => void; }; @@ -52,6 +54,7 @@ export default function ValidationInput({ placeholder, isButtonInput = false, buttonLabel, + buttonDisabled = false, onButtonClick, }: ValidationInputProps) { const [showPassword, setShowPassword] = useState(false); @@ -91,7 +94,12 @@ export default function ValidationInput({ )} {isButtonInput && ( - )} diff --git a/src/components/user/auth-form/LinkContainer.tsx b/src/components/user/auth-form/LinkContainer.tsx index 02a3e059..e912fcf6 100644 --- a/src/components/user/auth-form/LinkContainer.tsx +++ b/src/components/user/auth-form/LinkContainer.tsx @@ -49,23 +49,27 @@ export default function LinkContainer({ initialLinks }: LinkContainerProps) { 링크
- {links.map((linkItem) => ( -
-
- - {linkItem} - -
- -
- ))} +
+ + {linkItem} + +
+ +
+ ))}
diff --git a/src/hooks/useEmailVerification.ts b/src/hooks/useEmailVerification.ts index 08b8c9f8..ac3028da 100644 --- a/src/hooks/useEmailVerification.ts +++ b/src/hooks/useEmailVerification.ts @@ -25,9 +25,9 @@ export default function useEmailVerification() { }; // 인증 코드 만료 - const expireVerificationCode = () => { + const expireVerificationCode = (showMessage = true) => { setIsVerificationRequested(false); - toastError('인증 시간이 만료되었습니다. 다시 시도해 주세요.'); + if (showMessage) toastError('인증 시간이 만료되었습니다. 다시 시도해 주세요.'); }; return { diff --git a/src/layouts/page/SettingLayout.tsx b/src/layouts/page/SettingLayout.tsx index 190c3fed..464eb68f 100644 --- a/src/layouts/page/SettingLayout.tsx +++ b/src/layouts/page/SettingLayout.tsx @@ -1,7 +1,7 @@ import { Outlet, useLocation } from 'react-router-dom'; import ListSidebar from '@components/sidebar/ListSidebar'; import ListSetting from '@components/sidebar/ListSetting'; -import { USER_INFO_DUMMY } from '@mocks/mockData'; +import useStore from '@stores/useStore'; const navList = [ { @@ -20,6 +20,7 @@ const navList = [ export default function SettingLayout() { const location = useLocation(); + const { userInfo } = useStore(); const getTitle = () => { const currentPath = location.pathname.split('/')[2]; @@ -30,7 +31,7 @@ export default function SettingLayout() { return (
- +
diff --git a/src/mocks/mockData.ts b/src/mocks/mockData.ts index f2dfa092..0bf183fa 100644 --- a/src/mocks/mockData.ts +++ b/src/mocks/mockData.ts @@ -1,6 +1,6 @@ import { PROJECT_STATUS_COLORS } from '@constants/projectStatus'; -import type { User } from '@/types/UserType'; +import type { UserInfo } from '@/types/UserType'; import type { Team } from '@/types/TeamType'; import type { Project } from '@/types/ProjectType'; import type { ProjectStatus } from '@/types/ProjectStatusType'; @@ -35,24 +35,14 @@ type TaskFile = { export const JWT_TOKEN_DUMMY = 'mocked-header.mocked-payload-4.mocked-signature'; export const VERIFICATION_CODE_DUMMY = '1234'; - -export const USER_INFO_DUMMY = { - provider: 'LOCAL', - userId: 4, - username: 'test123', - password: 'qwer@1234', - email: 'momoco@gmail.com', - nickname: 'momoco', - profileImageName: null, - bio: "Hi, I'm Momoco!", - links: ['momoco@github.com'], -}; +export const TEMP_PASSWORD_DUMMY = '!1p2l3nqlz'; // 사용자 테이블 Mock (사용자 링크 테이블 포함) -export const USER_DUMMY: User[] = [ +export const USER_DUMMY: UserInfo[] = [ { userId: 1, - username: null, + username: 'panda_dev@gmail.com', + password: 'password@1', email: 'one@naver.com', provider: 'GOOGLE', nickname: '판다', @@ -62,17 +52,19 @@ export const USER_DUMMY: User[] = [ }, { userId: 2, - username: null, + username: 'two@kakao.com', + password: 'password@2', email: 'two@naver.com', provider: 'KAKAO', - nickname: '카멜레온', + nickname: 'kakao_oauth_2', bio: '디자이너 + 프론트엔드 육각형 인재', links: [], profileImageName: null, }, { userId: 3, - username: null, + username: 'three@naver.com', + password: 'password@3', email: 'three@naver.com', provider: 'GOOGLE', nickname: '랫서판다', @@ -82,7 +74,8 @@ export const USER_DUMMY: User[] = [ }, { userId: 4, - username: null, + username: 'four@kakao.com', + password: 'password@4', email: 'four@naver.com', provider: 'KAKAO', nickname: '북금곰', @@ -92,7 +85,8 @@ export const USER_DUMMY: User[] = [ }, { userId: 5, - username: null, + username: 'five@kakao.com', + password: 'password@5', email: 'five@naver.com', provider: 'KAKAO', nickname: '호랑이', @@ -102,7 +96,8 @@ export const USER_DUMMY: User[] = [ }, { userId: 6, - username: null, + username: 'six@gmail.com', + password: 'password@6', email: 'six@naver.com', provider: 'GOOGLE', nickname: '나무늘보', @@ -112,17 +107,19 @@ export const USER_DUMMY: User[] = [ }, { userId: 7, - username: null, + username: 'seven@kakao.com', + password: 'password@7', email: 'seven@naver.com', provider: 'KAKAO', - nickname: '웜뱃', + nickname: 'kakao_oauth_7', bio: '초럭키비키 백엔드 개발자', links: [], profileImageName: null, }, { userId: 8, - username: null, + username: 'eight@gmail.com', + password: 'password@8', email: 'eight@naver.com', provider: 'GOOGLE', nickname: '벨루가', @@ -132,7 +129,8 @@ export const USER_DUMMY: User[] = [ }, { userId: 9, - username: null, + username: 'nine@kakao.com', + password: 'password@9', email: 'nine@naver.com', provider: 'KAKAO', nickname: '펭귄', @@ -142,10 +140,11 @@ export const USER_DUMMY: User[] = [ }, { userId: 10, - username: null, + username: 'ten@gmail.com', + password: 'password@10', email: 'ten@naver.com', provider: 'GOOGLE', - nickname: '비버', + nickname: 'google_oauth_10', bio: 'DevOps 3년차', links: [], profileImageName: null, @@ -153,6 +152,7 @@ export const USER_DUMMY: User[] = [ { userId: 11, username: 'eleven', + password: 'password@11', email: 'eleven@naver.com', provider: 'LOCAL', nickname: '판다아빠', @@ -163,6 +163,7 @@ export const USER_DUMMY: User[] = [ { userId: 12, username: 'twelve', + password: 'password@12', email: 'twelve@naver.com', provider: 'LOCAL', nickname: '판다엄마', @@ -173,6 +174,7 @@ export const USER_DUMMY: User[] = [ { userId: 13, username: 'thirteen', + password: 'password@13', email: 'thirteen@naver.com', provider: 'LOCAL', nickname: '판다형', @@ -183,6 +185,7 @@ export const USER_DUMMY: User[] = [ { userId: 14, username: 'fourteen', + password: 'password@14', email: 'fourteen@naver.com', provider: 'LOCAL', nickname: '판다누나', @@ -193,6 +196,7 @@ export const USER_DUMMY: User[] = [ { userId: 15, username: 'fifteen', + password: 'password@15', email: 'fifteen@naver.com', provider: 'LOCAL', nickname: '판다동생', @@ -200,7 +204,106 @@ export const USER_DUMMY: User[] = [ links: [], profileImageName: null, }, -] as const; + { + userId: 16, + username: 'test123', + password: 'qwer@1234', + email: 'momoco@gmail.com', + provider: 'LOCAL', + nickname: 'momoco', + profileImageName: null, + bio: "Hi, I'm Momoco!", + links: ['momoco@github.com'], + }, + { + userId: 17, + username: 'brown', + password: 'test1234!', + email: 'brown@example.com', + provider: 'LOCAL', + nickname: '브라운', + profileImageName: 'Image1.jpg', + bio: '게임을 좋아하는 개발자', + links: ['brown@example.com'], + }, + { + userId: 18, + username: 'cony@kakao.com', + password: 'test1234!', + email: 'cony@example.com', + provider: 'KAKAO', + nickname: '코니', + profileImageName: 'Image2.png', + bio: '커피와 책을 사랑하는 디자이너', + links: ['cony@example.com'], + }, + { + userId: 19, + username: 'leonard@gmail.com', + password: 'test1234!', + email: 'leonard@example.com', + provider: 'GOOGLE', + nickname: '레너드', + profileImageName: 'Image3.jpeg', + bio: '자연을 사랑하는 사진작가', + links: ['leonard@example.com'], + }, + { + userId: 20, + username: 'sally', + password: 'test1234!', + email: 'sally@example.com', + provider: 'LOCAL', + nickname: '샐리', + profileImageName: 'Image1.webp', + bio: '24시간이 모자란 워커홀릭 개발자', + links: ['sally@example.com'], + }, + { + userId: 21, + username: 'james@kakao.com', + password: 'test1234!', + email: 'james@example.com', + provider: 'KAKAO', + nickname: '제임스', + profileImageName: 'Image2.png', + bio: '커피를 코드로 바꾸는 마법사', + links: ['james@example.com'], + }, + { + userId: 22, + username: 'edward@gmail.com', + password: 'test1234!', + email: 'edward@example.com', + provider: 'GOOGLE', + nickname: '에드워드', + profileImageName: 'Image3.jpeg', + bio: '버그를 춤추게 하는 디버깅의 달인', + links: ['edward@example.com'], + }, + { + userId: 23, + username: 'mary', + password: 'test1234!', + email: 'mary@example.com', + provider: 'LOCAL', + nickname: '메리', + profileImageName: 'Image4.jpg', + bio: '픽셀을 요리하는 디자인 셰프', + links: ['mary@example.com'], + }, + { + userId: 24, + username: 'tom', + password: 'test1234!', + email: 'tom@example.com', + provider: 'LOCAL', + nickname: '톰', + profileImageName: 'Image5.jpeg', + bio: '알고리즘으로 세상을 정복하려는 꿈나무', + links: ['tom@example.com'], + }, +]; // 역할 테이블 Mock export const ROLE_DUMMY: Role[] = [ diff --git a/src/mocks/services/authServiceHandler.ts b/src/mocks/services/authServiceHandler.ts index f6c61d77..48946c9c 100644 --- a/src/mocks/services/authServiceHandler.ts +++ b/src/mocks/services/authServiceHandler.ts @@ -1,45 +1,92 @@ import Cookies from 'js-cookie'; import { http, HttpResponse } from 'msw'; import { AUTH_SETTINGS } from '@constants/settings'; -import { VERIFICATION_CODE_DUMMY, USER_INFO_DUMMY } from '@mocks/mockData'; +import { JWT_TOKEN_DUMMY, TEMP_PASSWORD_DUMMY, USER_DUMMY, VERIFICATION_CODE_DUMMY } from '@mocks/mockData'; +import { EMAIL_REGEX } from '@constants/regex'; +import { convertTokenToUserId, generateDummyToken } from '@utils/converter'; import { + CheckNicknameForm, EmailVerificationForm, RequestEmailCode, SearchPasswordForm, UpdatePasswordRequest, + UserInfo, UserSignInForm, + UserSignUpRequest, } from '@/types/UserType'; const BASE_URL = import.meta.env.VITE_BASE_URL; -const refreshTokenExpiryDate = new Date(Date.now() + AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION).toISOString(); const authServiceHandler = [ + // 회원가입 API + http.post(`${BASE_URL}/user`, async ({ request }) => { + const { verificationCode, email, ...restSignUpData } = (await request.json()) as UserSignUpRequest; + + if (verificationCode !== VERIFICATION_CODE_DUMMY) { + return HttpResponse.json( + { message: '이메일 인증 번호가 일치하지 않습니다. 다시 확인해 주세요.' }, + { status: 400 }, + ); + } + + const existingUser = USER_DUMMY.find((user) => user.email === email); + if (existingUser) + return HttpResponse.json( + { message: '해당 이메일을 사용할 수 없습니다. 다른 이메일을 등록해 주세요.' }, + { status: 400 }, + ); + + const newUser: UserInfo = { + userId: USER_DUMMY.length + 1, + provider: 'LOCAL', + email, + profileImageName: null, + ...restSignUpData, + }; + USER_DUMMY.push(newUser); + + return HttpResponse.json(null, { status: 200 }); + }), + + // 닉네임 중복 확인 API + http.post(`${BASE_URL}/user/nickname`, async ({ request }) => { + const { nickname } = (await request.json()) as CheckNicknameForm; + + const nicknameExists = USER_DUMMY.some((user) => user.nickname === nickname); + if (nicknameExists) return HttpResponse.json({ message: '사용할 수 없는 닉네임입니다.' }, { status: 400 }); + + return HttpResponse.json(null, { status: 200 }); + }), + // 로그인 API http.post(`${BASE_URL}/user/login`, async ({ request }) => { const { username, password } = (await request.json()) as UserSignInForm; - if (username === USER_INFO_DUMMY.username && password === USER_INFO_DUMMY.password) { - const accessToken = 'mockedAccessToken'; - const refreshToken = 'mockedRefreshToken'; + const foundUser = USER_DUMMY.find((user) => user.username === username && user.password === password); + if (!foundUser) return HttpResponse.json({ message: '아이디 또는 비밀번호가 잘못되었습니다.' }, { status: 401 }); - return new HttpResponse(null, { - status: 200, - headers: { - Authorization: `Bearer ${accessToken}`, - 'Set-Cookie': [ - `refreshToken=${refreshToken}; SameSite=Strict; Secure; Path=/; Expires=${refreshTokenExpiryDate}; Max-Age=${AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION / 1000}`, - `refreshTokenExpiresAt=${refreshTokenExpiryDate}; SameSite=Strict; Secure; Path=/; Expires=${refreshTokenExpiryDate}; Max-Age=${AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION / 1000}`, - ].join(', '), - }, - }); - } - return HttpResponse.json({ message: '아이디 또는 비밀번호가 잘못되었습니다.' }, { status: 401 }); + const accessToken = generateDummyToken(foundUser.userId); + const refreshToken = generateDummyToken(foundUser.userId); + const refreshTokenExpiryDate = new Date(Date.now() + AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION).toISOString(); + + return new HttpResponse(null, { + status: 200, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Set-Cookie': [ + `refreshToken=${refreshToken}; SameSite=Strict; Secure; Path=/; Expires=${refreshTokenExpiryDate}; Max-Age=${AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION / 1000}`, + `refreshTokenExpiresAt=${refreshTokenExpiryDate}; SameSite=Strict; Secure; Path=/; Expires=${refreshTokenExpiryDate}; Max-Age=${AUTH_SETTINGS.REFRESH_TOKEN_EXPIRATION / 1000}`, + ].join(', '), + }, + }); }), // 액세스 토큰 갱신 API - http.post(`${BASE_URL}/user/refresh`, async ({ cookies }) => { - const { refreshToken, refreshTokenExpiresAt } = cookies; + http.post(`${BASE_URL}/user/refresh`, async ({ cookies, request }) => { + const accessToken = request.headers.get('Authorization'); + if (!accessToken) return new HttpResponse(null, { status: 401 }); + const { refreshToken, refreshTokenExpiresAt } = cookies; const cookieRefreshToken = Cookies.get('refreshToken'); if (!refreshToken || !refreshTokenExpiresAt) { @@ -55,8 +102,20 @@ const authServiceHandler = [ return HttpResponse.json({ message: '리프레시 토큰이 만료되었습니다.' }, { status: 401 }); } + let newAccessToken; + + // ToDo: 추후 삭제 + if (accessToken === JWT_TOKEN_DUMMY) { + newAccessToken = 'newMockedAccessToken'; + } else { + // 토큰에서 userId 추출하도록 수정 + const userId = convertTokenToUserId(accessToken); + if (!userId) return new HttpResponse(null, { status: 401 }); + + newAccessToken = generateDummyToken(userId); + } + // 액세스 토큰 갱신 - const newAccessToken = 'newMockedAccessToken'; return new HttpResponse(null, { status: 200, headers: { @@ -64,15 +123,31 @@ const authServiceHandler = [ }, }); } + return HttpResponse.json({ message: '리프레시 토큰이 유효하지 않습니다.' }, { status: 401 }); }), // 로그인 한 사용자 정보 조회 API http.get(`${BASE_URL}/user/me`, async ({ request }) => { const accessToken = request.headers.get('Authorization'); + if (!accessToken) return new HttpResponse(null, { status: 401 }); - return HttpResponse.json(USER_INFO_DUMMY, { status: 200 }); + let userId; + // ToDo: 추후 삭제 + if (accessToken === JWT_TOKEN_DUMMY) { + const payload = JWT_TOKEN_DUMMY.split('.')[1]; + userId = Number(payload.replace('mocked-payload-', '')); + } else { + // 토큰에서 userId 추출 + userId = convertTokenToUserId(accessToken); + if (!userId) return new HttpResponse(null, { status: 401 }); + } + + const foundUser = USER_DUMMY.find((user) => user.userId === userId); + if (!foundUser) return new HttpResponse(null, { status: 404 }); + + return HttpResponse.json(foundUser, { status: 200 }); }), // 로그아웃 API @@ -122,18 +197,24 @@ const authServiceHandler = [ http.post(`${BASE_URL}/user/verify/send`, async ({ request }) => { const { email } = (await request.json()) as RequestEmailCode; - if (email !== USER_INFO_DUMMY.email) + if (!email || !EMAIL_REGEX.test(email)) return HttpResponse.json({ message: '이메일을 다시 확인해 주세요.' }, { status: 400 }); return HttpResponse.json(null, { status: 200 }); }), + // ToDo: API 확정 후 수정 // 이메일 인증 번호 확인 API http.post(`${BASE_URL}/user/verify/code`, async ({ request }) => { const { email, verificationCode } = (await request.json()) as EmailVerificationForm; - if (email !== USER_INFO_DUMMY.email || verificationCode !== VERIFICATION_CODE_DUMMY) + const verifyUserEmailAndCode = (userEmail: string, code: string) => { + return USER_DUMMY.some((user) => user.email === userEmail) && code === VERIFICATION_CODE_DUMMY; + }; + + if (!verifyUserEmailAndCode(email, verificationCode)) { return HttpResponse.json({ message: '인증번호가 일치하지 않습니다.' }, { status: 401 }); + } return HttpResponse.json(null, { status: 200 }); }), @@ -149,18 +230,16 @@ const authServiceHandler = [ ); } - if (email !== USER_INFO_DUMMY.email) - return HttpResponse.json({ message: '이메일을 다시 확인해 주세요.' }, { status: 400 }); + const existingUser = USER_DUMMY.find((user) => user.email === email); + if (!existingUser) return HttpResponse.json({ message: '이메일을 다시 확인해 주세요.' }, { status: 400 }); - return HttpResponse.json({ username: USER_INFO_DUMMY.username }, { status: 200 }); + return HttpResponse.json({ username: existingUser.username }, { status: 200 }); }), // 비밀번호 찾기 API http.post(`${BASE_URL}/user/recover/password`, async ({ request }) => { const { username, email, verificationCode } = (await request.json()) as SearchPasswordForm; - const tempPassword = '!1p2l3nqlz'; - if (verificationCode !== VERIFICATION_CODE_DUMMY) { return HttpResponse.json( { message: '이메일 인증 번호가 일치하지 않습니다. 다시 확인해 주세요.' }, @@ -168,11 +247,11 @@ const authServiceHandler = [ ); } - if (username !== USER_INFO_DUMMY.username || email !== USER_INFO_DUMMY.email) { - return HttpResponse.json({ message: '이메일과 아이디를 다시 확인해 주세요.' }, { status: 400 }); - } + const existingUser = USER_DUMMY.find((user) => user.username === username && user.email === email); + if (!existingUser) return HttpResponse.json({ message: '이메일과 아이디를 다시 확인해 주세요.' }, { status: 400 }); - return HttpResponse.json({ password: tempPassword }, { status: 200 }); + existingUser.password = TEMP_PASSWORD_DUMMY; + return HttpResponse.json({ password: TEMP_PASSWORD_DUMMY }, { status: 200 }); }), // 비밀번호 변경 API @@ -180,15 +259,26 @@ const authServiceHandler = [ const accessToken = request.headers.get('Authorization'); if (!accessToken) return HttpResponse.json({ message: '인증 정보가 존재하지 않습니다.' }, { status: 401 }); - const userId = accessToken.split('.')[1].replace('mocked-payload-', ''); - if (Number(userId) !== USER_INFO_DUMMY.userId) - return HttpResponse.json({ message: '해당 사용자를 찾을 수 없습니다.' }, { status: 404 }); + let userId; + // ToDo: 추후 삭제 + if (accessToken === JWT_TOKEN_DUMMY) { + const payload = JWT_TOKEN_DUMMY.split('.')[1]; + userId = Number(payload.replace('mocked-payload-', '')); + } else { + // 토큰에서 userId 추출 + userId = convertTokenToUserId(accessToken); + if (!userId) return new HttpResponse(null, { status: 401 }); + } + + const existingUser = USER_DUMMY.find((user) => user.userId === Number(userId)); + if (!existingUser) return HttpResponse.json({ message: '해당 사용자를 찾을 수 없습니다.' }, { status: 404 }); + // 비밀번호 변경 const { password, newPassword } = (await request.json()) as UpdatePasswordRequest; - if (password !== USER_INFO_DUMMY.password) + if (password !== existingUser.password) return HttpResponse.json({ message: '비밀번호를 다시 확인해주세요.' }, { status: 400 }); - USER_INFO_DUMMY.password = newPassword; + existingUser.password = newPassword; return HttpResponse.json(null, { status: 200 }); }), ]; diff --git a/src/pages/setting/UserSettingPage.tsx b/src/pages/setting/UserSettingPage.tsx index af04fda0..cf491348 100644 --- a/src/pages/setting/UserSettingPage.tsx +++ b/src/pages/setting/UserSettingPage.tsx @@ -3,7 +3,6 @@ import { USER_AUTH_VALIDATION_RULES } from '@constants/formValidationRules'; import ValidationInput from '@components/common/ValidationInput'; import ProfileImageContainer from '@components/user/auth-form/ProfileImageContainer'; import LinkContainer from '@components/user/auth-form/LinkContainer'; -import { USER_INFO_DUMMY } from '@mocks/mockData'; import { useStore } from '@stores/useStore'; import type { EditUserInfoForm } from '@/types/UserType'; @@ -82,7 +81,7 @@ export default function UserSettingPage() {
{/* 링크 */} - + {/* 개인정보 수정 버튼 */}