From 2023855cf63c5216af8d72c593f1d742af85cf16 Mon Sep 17 00:00:00 2001 From: sooloin Date: Tue, 23 Jun 2026 13:33:48 +0900 Subject: [PATCH 01/13] =?UTF-8?q?:sparkles:=20Feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=20API=20=ED=95=A8=EC=88=98=20=EB=B0=8F=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /auth/logout 호출 함수(logout) 추가 - LogoutRequest/LogoutResponse 타입 정의 - 로그아웃 상태 코드별 안내 메시지 맵 추가 `#51` --- src/features/auth/api/auth.ts | 11 +++++++++++ src/features/auth/index.ts | 5 ++++- src/features/auth/lib/apiErrorMessages.ts | 8 ++++++++ src/features/auth/model/types.ts | 11 +++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts index 750611c..1825c8d 100644 --- a/src/features/auth/api/auth.ts +++ b/src/features/auth/api/auth.ts @@ -2,6 +2,7 @@ import { apiClient } from '@/shared/api/axios'; import type { + LogoutResponse, NicknameCheckResponse, NotificationUpdateResponse, RegisterProfileRequest, @@ -35,6 +36,16 @@ export async function socialLogin(provider: SocialProvider, code: string): Promi return { kind: 'LOGIN', data: response.data as SocialLoginSuccess }; } +/** + * 로그아웃 (POST /auth/logout) + * - refreshToken 삭제 + accessToken 블랙리스트 처리 + * - accessToken은 apiClient 인터셉터가 Authorization 헤더로 자동 첨부 + */ +export async function logout(refreshToken: string): Promise { + const response = await apiClient.post('/auth/logout', { refreshToken }); + return response.data; +} + /** * 추가 정보 입력 → 가입 완료 (PUT /users/me/profile) * - 202 응답으로 받은 registrationToken을 Authorization 헤더로 전달 diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index aee7130..8ef6dd0 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -19,7 +19,7 @@ export { export type { AuthErrorPresentation, AuthClientErrorCode, AuthErrorContext } from './lib/authErrorPresentation'; export { AuthLoadingScreen } from './ui/status/AuthLoadingScreen'; export { AuthErrorScreen } from './ui/status/AuthErrorScreen'; -export { socialLogin, registerProfile, checkNicknameAvailability, updateNotificationSetting } from './api/auth'; +export { socialLogin, logout, registerProfile, checkNicknameAvailability, updateNotificationSetting } from './api/auth'; export { getMyProfile, updateMyProfile } from './api/users'; export { useCreatePet } from './model/useCreatePet'; export { useCreatePetInvitationCode } from './model/useCreatePetInvitationCode'; @@ -50,6 +50,8 @@ export type { SocialLoginSuccess, SocialSignupRequired, SocialLoginResult, + LogoutRequest, + LogoutResponse, CreatePetRequest, CreatePetResponse, CreatePetSpecialNoteRequest, @@ -102,6 +104,7 @@ export type { export { getApiErrorMessage, getErrorBodyMessage } from '@/shared/lib/api/errorMessage'; export { SOCIAL_LOGIN_STATUS_MESSAGES, + LOGOUT_STATUS_MESSAGES, REGISTER_PROFILE_STATUS_MESSAGES, NOTIFICATION_SETTING_STATUS_MESSAGES, PROFILE_UPDATE_STATUS_MESSAGES, diff --git a/src/features/auth/lib/apiErrorMessages.ts b/src/features/auth/lib/apiErrorMessages.ts index 81dbf44..45d6259 100644 --- a/src/features/auth/lib/apiErrorMessages.ts +++ b/src/features/auth/lib/apiErrorMessages.ts @@ -8,6 +8,14 @@ export const SOCIAL_LOGIN_STATUS_MESSAGES: Partial> = { 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', }; +/** 로그아웃 POST /auth/logout */ +export const LOGOUT_STATUS_MESSAGES: Partial> = { + 400: '잘못된 요청이에요. 잠시 후 다시 시도해주세요.', + 401: '인증 정보가 유효하지 않아요. 다시 로그인해주세요.', + 404: '로그인 정보를 찾을 수 없어요.', + 500: '로그아웃에 실패했어요. 잠시 후 다시 시도해주세요.', +}; + /** 회원가입 완료 PUT /users/me/profile */ export const REGISTER_PROFILE_STATUS_MESSAGES: Partial> = { 400: '입력 정보를 다시 확인해주세요.', diff --git a/src/features/auth/model/types.ts b/src/features/auth/model/types.ts index d6e58a6..23ae35c 100644 --- a/src/features/auth/model/types.ts +++ b/src/features/auth/model/types.ts @@ -22,6 +22,17 @@ export interface TokenReissueResponse { accessTokenExpiresIn: number; } +// ---- 로그아웃 (POST /auth/logout) ---- + +export interface LogoutRequest { + /** 삭제할 리프레시 토큰 */ + refreshToken: string; +} + +export interface LogoutResponse { + message: string; +} + // ---- 소셜 로그인 (POST /auth/social-login) ---- export interface SocialLoginRequest { From 20e56a94ccbba2698bd86652e1895c9190a04660 Mon Sep 17 00:00:00 2001 From: sooloin Date: Tue, 23 Jun 2026 14:26:38 +0900 Subject: [PATCH 02/13] =?UTF-8?q?:sparkles:=20Feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=ED=99=95=EC=9D=B8=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=B8=EC=85=98=20=EC=A0=95=EB=A6=AC=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사이드바 로그아웃 버튼 클릭 시 확인 모달 노출 - 확인 시 POST /auth/logout 호출 후 홈 이동 및 토큰 정리(clearTokens) - 인증 페이지 이탈 후 토큰을 정리해 잔여 인증 쿼리의 401 리다이렉트 방지 - 사이드바 메뉴에 link/action 타입 구분 추가 `#51` --- src/features/auth/index.ts | 1 + src/features/auth/model/useLogout.ts | 21 ++++++++ src/pages/my/model/menu.ts | 20 ++++--- src/pages/my/ui/LogoutConfirmDialog.tsx | 72 +++++++++++++++++++++++++ src/pages/my/ui/MyDodoSidebar.tsx | 36 ++++++++++--- 5 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 src/features/auth/model/useLogout.ts create mode 100644 src/pages/my/ui/LogoutConfirmDialog.tsx diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index 8ef6dd0..ade48a0 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -29,6 +29,7 @@ export { useFamilyApplications } from './model/useFamilyApplications'; export { useFamilyBlockedUsers } from './model/useFamilyBlockedUsers'; export { useFamilyPendingUsers } from './model/useFamilyPendingUsers'; export { useCurrentUser } from './model/useCurrentUser'; +export { useLogout } from './model/useLogout'; export { useApprovePetFamilyRequest } from './model/useApprovePetFamilyRequest'; export { useDeletePetWeight } from './model/useDeletePetWeight'; export { useDeletePetSpecialNote } from './model/useDeletePetSpecialNote'; diff --git a/src/features/auth/model/useLogout.ts b/src/features/auth/model/useLogout.ts new file mode 100644 index 0000000..7a3a85f --- /dev/null +++ b/src/features/auth/model/useLogout.ts @@ -0,0 +1,21 @@ +import { useMutation } from '@tanstack/react-query'; + +import { logout } from '@/features/auth/api/auth'; +import type { LogoutResponse } from '@/features/auth/model/types'; +import { getRefreshToken } from '@/shared/lib/auth/token'; + +/** + * 로그아웃 (POST /auth/logout) + * - refreshToken이 없으면 서버 호출 없이 로컬 세션만 정리하도록 null 반환 + * - 토큰/캐시 정리·이동은 호출 측에서 처리 + */ +export function useLogout() { + return useMutation({ + mutationFn: async () => { + const refreshToken = getRefreshToken(); + if (!refreshToken) return null; + + return logout(refreshToken); + }, + }); +} diff --git a/src/pages/my/model/menu.ts b/src/pages/my/model/menu.ts index f77232c..48793ea 100644 --- a/src/pages/my/model/menu.ts +++ b/src/pages/my/model/menu.ts @@ -10,10 +10,14 @@ export type MyDodoMenuKey = | 'notifications' | 'logout'; +/** link: 콘텐츠 패널/페이지로 이동, action: 클릭 시 동작(모달 등) 실행 */ +export type MyDodoMenuType = 'link' | 'action'; + export interface MyDodoMenuItem { key: MyDodoMenuKey; label: string; section: MyDodoMenuSection; + type: MyDodoMenuType; } export interface MyDodoContent { @@ -31,14 +35,14 @@ export const MY_DODO_SECTION_LABELS: Record = { }; export const MY_DODO_MENU_ITEMS: MyDodoMenuItem[] = [ - { key: 'pet-list', label: '반려동물 리스트', section: 'pet' }, - { key: 'device', label: '디바이스 관리', section: 'pet' }, - { key: 'family', label: '가족 관리', section: 'pet' }, - { key: 'walk-history', label: '산책 기록', section: 'pet' }, - { key: 'ai-report', label: 'AI 레포트', section: 'pet' }, - { key: 'profile-edit', label: '회원정보 수정', section: 'account' }, - { key: 'notifications', label: '알림함', section: 'account' }, - { key: 'logout', label: '로그아웃', section: 'account' }, + { key: 'pet-list', label: '반려동물 리스트', section: 'pet', type: 'link' }, + { key: 'device', label: '디바이스 관리', section: 'pet', type: 'link' }, + { key: 'family', label: '가족 관리', section: 'pet', type: 'link' }, + { key: 'walk-history', label: '산책 기록', section: 'pet', type: 'link' }, + { key: 'ai-report', label: 'AI 레포트', section: 'pet', type: 'link' }, + { key: 'profile-edit', label: '회원정보 수정', section: 'account', type: 'link' }, + { key: 'notifications', label: '알림함', section: 'account', type: 'link' }, + { key: 'logout', label: '로그아웃', section: 'account', type: 'action' }, ]; export const MY_DODO_CONTENT_BY_KEY: Record = { diff --git a/src/pages/my/ui/LogoutConfirmDialog.tsx b/src/pages/my/ui/LogoutConfirmDialog.tsx new file mode 100644 index 0000000..bd2ee55 --- /dev/null +++ b/src/pages/my/ui/LogoutConfirmDialog.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import { LOGOUT_STATUS_MESSAGES, getApiErrorMessage, useLogout } from '@/features/auth'; +import { clearTokens } from '@/shared/lib/auth/token'; +import { Modal } from '@/shared/ui'; + +interface LogoutConfirmDialogProps { + open: boolean; + onClose: () => void; +} + +export function LogoutConfirmDialog({ open, onClose }: LogoutConfirmDialogProps) { + const navigate = useNavigate(); + const { mutateAsync, isPending } = useLogout(); + const [errorMessage, setErrorMessage] = useState(''); + + const handleClose = () => { + if (isPending) return; + setErrorMessage(''); + onClose(); + }; + + const handleConfirm = async () => { + setErrorMessage(''); + + try { + await mutateAsync(); + // 인증 페이지(마이도도)에서 먼저 빠져나간 뒤 토큰을 정리해야 + // 잔여 인증 쿼리의 재요청 → 401 → 세션 만료 리다이렉트를 피할 수 있다. + navigate('/', { replace: true }); + clearTokens(); + } catch (error) { + setErrorMessage( + getApiErrorMessage(error, '로그아웃에 실패했어요. 잠시 후 다시 시도해주세요.', LOGOUT_STATUS_MESSAGES), + ); + } + }; + + return ( + +
+

LOGOUT

+

로그아웃 하시겠습니까?

+

+ 로그아웃하면 현재 기기에서 로그인 정보가 정리돼요. 다시 이용하려면 로그인이 필요해요. +

+ + {errorMessage ?

{errorMessage}

: null} + +
+ + +
+
+
+ ); +} diff --git a/src/pages/my/ui/MyDodoSidebar.tsx b/src/pages/my/ui/MyDodoSidebar.tsx index 9f967b7..3f35959 100644 --- a/src/pages/my/ui/MyDodoSidebar.tsx +++ b/src/pages/my/ui/MyDodoSidebar.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; import { @@ -7,6 +8,7 @@ import { type MyDodoMenuKey, type MyDodoMenuSection, } from '@/pages/my/model/menu'; +import { LogoutConfirmDialog } from '@/pages/my/ui/LogoutConfirmDialog'; interface MyDodoSidebarProps { activeKey: MyDodoMenuKey; @@ -21,30 +23,48 @@ function menuItemClass(active: boolean) { ].join(' '); } -function SidebarSection({ section, activeKey }: { section: MyDodoMenuSection; activeKey: MyDodoMenuKey }) { +function SidebarSection({ + section, + activeKey, + onLogout, +}: { + section: MyDodoMenuSection; + activeKey: MyDodoMenuKey; + onLogout: () => void; +}) { const items = MY_DODO_MENU_ITEMS.filter((item) => item.section === section); return (

{MY_DODO_SECTION_LABELS[section]}

- {items.map((item) => ( - - {item.label} - - ))} + {items.map((item) => + item.type === 'action' ? ( + + ) : ( + + {item.label} + + ), + )}
); } export function MyDodoSidebar({ activeKey }: MyDodoSidebarProps) { + const [logoutOpen, setLogoutOpen] = useState(false); + return (
- - + setLogoutOpen(true)} /> + setLogoutOpen(true)} />
+ + setLogoutOpen(false)} />
); } From 32edd333a49f60a236cf6823e42559c018f40203 Mon Sep 17 00:00:00 2001 From: sooloin Date: Tue, 23 Jun 2026 14:32:58 +0900 Subject: [PATCH 03/13] =?UTF-8?q?:sparkles:=20Feat:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=ED=83=88=ED=87=B4=20API=20=ED=95=A8=EC=88=98=20=EB=B0=8F=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A0=95=EC=9D=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 탈퇴 인증 메일 발송(POST /users/me/withdrawal/email) 함수 추가 - 최종 회원 탈퇴(DELETE /users/me) 함수 추가 - 관련 요청/응답 타입 및 상태 코드별 안내 메시지 맵 추가 - 메일 재요청 제한(429) 안내 문구 포함 `#51` --- src/features/auth/api/users.ts | 29 ++++++++++++++++++++++- src/features/auth/index.ts | 7 +++++- src/features/auth/lib/apiErrorMessages.ts | 16 +++++++++++++ src/features/auth/model/types.ts | 17 +++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/features/auth/api/users.ts b/src/features/auth/api/users.ts index b807adc..1afac11 100644 --- a/src/features/auth/api/users.ts +++ b/src/features/auth/api/users.ts @@ -1,6 +1,12 @@ import { apiClient } from '@/shared/api/axios'; -import type { UpdateMyProfileRequest, UpdateMyProfileResponse, UserProfile } from '../model/types'; +import type { + UpdateMyProfileRequest, + UpdateMyProfileResponse, + UserProfile, + WithdrawUserResponse, + WithdrawalEmailResponse, +} from '../model/types'; /** * 내 정보 조회 (GET /users/me) @@ -19,3 +25,24 @@ export async function updateMyProfile(body: UpdateMyProfileRequest): Promise('/users/me', body); return response.data; } + +/** + * 탈퇴 인증 메일 발송 (POST /users/me/withdrawal/email) + * - 현재 로그인한 유저 이메일로 인증번호 발송 + * - 1분 이내 재요청 시 429 응답 + */ +export async function sendWithdrawalEmail(): Promise { + const response = await apiClient.post('/users/me/withdrawal/email'); + return response.data; +} + +/** + * 최종 회원 탈퇴 (DELETE /users/me) + * - 메일로 받은 6자리 인증번호(authCode)로 계정 삭제 + */ +export async function withdrawUser(authCode: string): Promise { + const response = await apiClient.delete('/users/me', { + data: { authCode }, + }); + return response.data; +} diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index ade48a0..3778276 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -20,7 +20,7 @@ export type { AuthErrorPresentation, AuthClientErrorCode, AuthErrorContext } fro export { AuthLoadingScreen } from './ui/status/AuthLoadingScreen'; export { AuthErrorScreen } from './ui/status/AuthErrorScreen'; export { socialLogin, logout, registerProfile, checkNicknameAvailability, updateNotificationSetting } from './api/auth'; -export { getMyProfile, updateMyProfile } from './api/users'; +export { getMyProfile, updateMyProfile, sendWithdrawalEmail, withdrawUser } from './api/users'; export { useCreatePet } from './model/useCreatePet'; export { useCreatePetInvitationCode } from './model/useCreatePetInvitationCode'; export { useCreatePetSpecialNote } from './model/useCreatePetSpecialNote'; @@ -81,6 +81,9 @@ export type { NotificationUpdateResponse, UpdateMyProfileRequest, UpdateMyProfileResponse, + WithdrawalEmailResponse, + WithdrawUserRequest, + WithdrawUserResponse, PetDetailResponse, PetFamilyApprovalAction, PetFamilyApprovalRequest, @@ -110,4 +113,6 @@ export { NOTIFICATION_SETTING_STATUS_MESSAGES, PROFILE_UPDATE_STATUS_MESSAGES, NICKNAME_CHECK_STATUS_MESSAGES, + WITHDRAWAL_EMAIL_STATUS_MESSAGES, + WITHDRAW_USER_STATUS_MESSAGES, } from './lib/apiErrorMessages'; diff --git a/src/features/auth/lib/apiErrorMessages.ts b/src/features/auth/lib/apiErrorMessages.ts index 45d6259..3db72ed 100644 --- a/src/features/auth/lib/apiErrorMessages.ts +++ b/src/features/auth/lib/apiErrorMessages.ts @@ -39,6 +39,22 @@ export const PROFILE_UPDATE_STATUS_MESSAGES: Partial> = { 500: '회원정보 수정에 실패했어요. 잠시 후 다시 시도해주세요.', }; +/** 탈퇴 인증 메일 발송 POST /users/me/withdrawal/email */ +export const WITHDRAWAL_EMAIL_STATUS_MESSAGES: Partial> = { + 401: '로그인이 필요한 기능이에요. 다시 로그인해주세요.', + 404: '사용자를 찾을 수 없어요.', + 429: '잠시 후 다시 시도해주세요. (1분 이내 재요청은 불가해요)', + 500: '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.', +}; + +/** 최종 회원 탈퇴 DELETE /users/me */ +export const WITHDRAW_USER_STATUS_MESSAGES: Partial> = { + 400: '인증번호를 다시 확인해주세요.', + 401: '인증번호가 올바르지 않거나 만료되었어요. 다시 시도해주세요.', + 404: '사용자를 찾을 수 없어요.', + 500: '회원 탈퇴에 실패했어요. 잠시 후 다시 시도해주세요.', +}; + /** 닉네임 중복 확인 GET /users/nickname/check */ export const NICKNAME_CHECK_STATUS_MESSAGES: Partial> = { 500: '중복 확인에 실패했어요. 잠시 후 다시 시도해주세요.', diff --git a/src/features/auth/model/types.ts b/src/features/auth/model/types.ts index 23ae35c..ea0bd47 100644 --- a/src/features/auth/model/types.ts +++ b/src/features/auth/model/types.ts @@ -419,6 +419,23 @@ export interface UpdateMyProfileResponse { userCreatedAt: string; } +// ---- 회원 탈퇴 ---- + +/** 탈퇴 인증 메일 발송 (POST /users/me/withdrawal/email) */ +export interface WithdrawalEmailResponse { + message: string; +} + +/** 최종 회원 탈퇴 (DELETE /users/me) */ +export interface WithdrawUserRequest { + /** 메일로 받은 6자리 인증번호 */ + authCode: string; +} + +export interface WithdrawUserResponse { + message: string; +} + export interface UserProfile { message: string; userId?: string; From faf1f1553155ad38e8e16f9fbf39aaf5cc4a6ba1 Mon Sep 17 00:00:00 2001 From: sooloin Date: Tue, 23 Jun 2026 14:44:57 +0900 Subject: [PATCH 04/13] =?UTF-8?q?:sparkles:=20Feat:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=9D=BC?= =?UTF-8?q?=EC=9A=B0=ED=8A=B8=20=EB=B0=8F=20=EC=A7=84=EC=9E=85=EC=A0=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /my/withdrawal 라우트 추가 (인증 보호) - 회원탈퇴 페이지 스캐폴드 및 안내/뒤로가기 동선 구성 - 회원정보 수정 화면 하단에 탈퇴 진입 영역(danger zone) 배치 `#51` --- src/app/router.tsx | 9 +++++++ src/pages/my/WithdrawalPage.tsx | 30 ++++++++++++++++++++++++ src/pages/my/ui/MyProfileEditContent.tsx | 18 ++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 src/pages/my/WithdrawalPage.tsx diff --git a/src/app/router.tsx b/src/app/router.tsx index db4919c..93d19e0 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -20,6 +20,7 @@ import { NotificationSettingsPage } from '@/pages/my/NotificationSettingsPage'; import { PetRegistrationPage } from '@/pages/my/PetRegistrationPage'; import { PetSpecialNotesPage } from '@/pages/my/PetSpecialNotesPage'; import { PetWeightPage } from '@/pages/my/PetWeightPage'; +import { WithdrawalPage } from '@/pages/my/WithdrawalPage'; import { NotFoundPage } from '@/pages/not-found/NotFoundPage'; import { WalkPage } from '@/pages/walk/WalkPage'; @@ -78,6 +79,14 @@ export const router = createBrowserRouter([ ), }, + { + path: '/my/withdrawal', + element: ( + + + + ), + }, { path: '/my/pets/new', element: ( diff --git a/src/pages/my/WithdrawalPage.tsx b/src/pages/my/WithdrawalPage.tsx new file mode 100644 index 0000000..37b732a --- /dev/null +++ b/src/pages/my/WithdrawalPage.tsx @@ -0,0 +1,30 @@ +import { Link } from 'react-router-dom'; + +export function WithdrawalPage() { + return ( +
+
+ + ← 회원정보 수정으로 돌아가기 + +
+ +
+
+

WITHDRAWAL

+

회원 탈퇴

+

+ 탈퇴를 진행하려면 본인 확인이 필요해요. 가입하신 이메일로 인증번호를 보내드릴게요. +

+
+ +
+ {/* TODO(Step 5): 인증 메일 발송 → 6자리 인증번호 입력 → 최종 탈퇴 플로우 */} +
+
+
+ ); +} diff --git a/src/pages/my/ui/MyProfileEditContent.tsx b/src/pages/my/ui/MyProfileEditContent.tsx index 36bc020..d818c96 100644 --- a/src/pages/my/ui/MyProfileEditContent.tsx +++ b/src/pages/my/ui/MyProfileEditContent.tsx @@ -1,4 +1,5 @@ import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; import profileDefaultIllustration from '@/features/auth/assets/profile-default.svg'; import { PROFILE_UPDATE_STATUS_MESSAGES, getApiErrorMessage, updateMyProfile, type UserProfile } from '@/features/auth'; @@ -320,6 +321,23 @@ export function MyProfileEditContent({ user, isLoading = false }: MyProfileEditC +
+
+
+

회원 탈퇴

+

+ 탈퇴하면 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요. +

+
+ + 회원 탈퇴 + +
+
+ Date: Tue, 23 Jun 2026 15:10:30 +0900 Subject: [PATCH 05/13] =?UTF-8?q?:sparkles:=20Feat:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=9D=B8=EC=A6=9D=20=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EB=B0=8F=20=EC=B5=9C=EC=A2=85=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 탈퇴 인증 메일 발송 후 6자리 인증번호(OTP 박스) 입력 UI 노출 - 인증번호 입력 후 DELETE /users/me로 최종 탈퇴 처리 - 탈퇴 성공 시 토큰/세션 정리(clearTokens) - 메일 발송 결과를 자동 사라짐 토스트로 안내 - 인증번호 불일치(400/401) 시 '인증번호가 틀렸습니다' 안내 - 인증 메일 재발송 60초 쿨다운 및 카운트다운 처리 - 공용 Toast/useCooldown/AuthCodeInput 추가 `#51` --- src/features/auth/index.ts | 2 + .../auth/model/useSendWithdrawalEmail.ts | 11 ++ src/features/auth/model/useWithdrawUser.ts | 11 ++ src/pages/my/WithdrawalPage.tsx | 34 +++- src/pages/my/ui/AuthCodeInput.tsx | 86 ++++++++++ src/pages/my/ui/WithdrawalFlow.tsx | 150 ++++++++++++++++++ src/shared/lib/useCooldown.ts | 25 +++ src/shared/ui/Toast.tsx | 48 ++++++ src/shared/ui/index.ts | 1 + 9 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 src/features/auth/model/useSendWithdrawalEmail.ts create mode 100644 src/features/auth/model/useWithdrawUser.ts create mode 100644 src/pages/my/ui/AuthCodeInput.tsx create mode 100644 src/pages/my/ui/WithdrawalFlow.tsx create mode 100644 src/shared/lib/useCooldown.ts create mode 100644 src/shared/ui/Toast.tsx diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index 3778276..c30e743 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -30,6 +30,8 @@ export { useFamilyBlockedUsers } from './model/useFamilyBlockedUsers'; export { useFamilyPendingUsers } from './model/useFamilyPendingUsers'; export { useCurrentUser } from './model/useCurrentUser'; export { useLogout } from './model/useLogout'; +export { useSendWithdrawalEmail } from './model/useSendWithdrawalEmail'; +export { useWithdrawUser } from './model/useWithdrawUser'; export { useApprovePetFamilyRequest } from './model/useApprovePetFamilyRequest'; export { useDeletePetWeight } from './model/useDeletePetWeight'; export { useDeletePetSpecialNote } from './model/useDeletePetSpecialNote'; diff --git a/src/features/auth/model/useSendWithdrawalEmail.ts b/src/features/auth/model/useSendWithdrawalEmail.ts new file mode 100644 index 0000000..18bd0ef --- /dev/null +++ b/src/features/auth/model/useSendWithdrawalEmail.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; + +import { sendWithdrawalEmail } from '@/features/auth/api/users'; +import type { WithdrawalEmailResponse } from '@/features/auth/model/types'; + +/** 탈퇴 인증 메일 발송 (POST /users/me/withdrawal/email) */ +export function useSendWithdrawalEmail() { + return useMutation({ + mutationFn: () => sendWithdrawalEmail(), + }); +} diff --git a/src/features/auth/model/useWithdrawUser.ts b/src/features/auth/model/useWithdrawUser.ts new file mode 100644 index 0000000..0251efa --- /dev/null +++ b/src/features/auth/model/useWithdrawUser.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; + +import { withdrawUser } from '@/features/auth/api/users'; +import type { WithdrawUserResponse } from '@/features/auth/model/types'; + +/** 최종 회원 탈퇴 (DELETE /users/me) — 메일로 받은 6자리 인증번호로 계정 삭제 */ +export function useWithdrawUser() { + return useMutation({ + mutationFn: (authCode) => withdrawUser(authCode), + }); +} diff --git a/src/pages/my/WithdrawalPage.tsx b/src/pages/my/WithdrawalPage.tsx index 37b732a..fdad6f5 100644 --- a/src/pages/my/WithdrawalPage.tsx +++ b/src/pages/my/WithdrawalPage.tsx @@ -1,9 +1,35 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; +import { WithdrawalFlow } from '@/pages/my/ui/WithdrawalFlow'; +import { clearTokens } from '@/shared/lib/auth/token'; + export function WithdrawalPage() { + const [completed, setCompleted] = useState(false); + + const handleCompleted = () => { + setCompleted(true); + clearTokens(); + }; + + // TODO(Step 6): 탈퇴 완료 화면을 회원가입 완료 화면과 대칭으로 구현 + if (completed) { + return ( +
+

회원 탈퇴가 완료되었어요.

+ + 홈으로 + +
+ ); + } + return (
-
+
-

WITHDRAWAL

-

회원 탈퇴

+

회원 탈퇴

탈퇴를 진행하려면 본인 확인이 필요해요. 가입하신 이메일로 인증번호를 보내드릴게요.

+ {/*

탈퇴 시 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요.

*/}
- {/* TODO(Step 5): 인증 메일 발송 → 6자리 인증번호 입력 → 최종 탈퇴 플로우 */} +
diff --git a/src/pages/my/ui/AuthCodeInput.tsx b/src/pages/my/ui/AuthCodeInput.tsx new file mode 100644 index 0000000..f1f5692 --- /dev/null +++ b/src/pages/my/ui/AuthCodeInput.tsx @@ -0,0 +1,86 @@ +import { useRef, type ClipboardEvent, type KeyboardEvent } from 'react'; + +interface AuthCodeInputProps { + length: number; + value: string; + onChange: (value: string) => void; + disabled?: boolean; +} + +export function AuthCodeInput({ length, value, onChange, disabled = false }: AuthCodeInputProps) { + const inputsRef = useRef>([]); + + const focusInput = (index: number) => { + inputsRef.current[Math.max(0, Math.min(index, length - 1))]?.focus(); + }; + + const handleChange = (index: number, raw: string) => { + const chars = raw.replace(/\D/g, '').split(''); + if (chars.length === 0) { + const next = value.split(''); + next[index] = ''; + onChange(next.join('')); + return; + } + + const next = value.padEnd(length, ' ').split(''); + let cursor = index; + for (const ch of chars) { + if (cursor >= length) break; + next[cursor] = ch; + cursor += 1; + } + + onChange(next.join('').replace(/ /g, '').slice(0, length)); + focusInput(cursor); + }; + + const handleKeyDown = (index: number, event: KeyboardEvent) => { + if (event.key === 'Backspace') { + const next = value.split(''); + if (value[index]) { + next[index] = ''; + onChange(next.join('')); + } else if (index > 0) { + next[index - 1] = ''; + onChange(next.join('')); + focusInput(index - 1); + } + } else if (event.key === 'ArrowLeft') { + focusInput(index - 1); + } else if (event.key === 'ArrowRight') { + focusInput(index + 1); + } + }; + + const handlePaste = (event: ClipboardEvent) => { + event.preventDefault(); + const pasted = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, length); + if (!pasted) return; + + onChange(pasted); + focusInput(pasted.length); + }; + + return ( +
+ {Array.from({ length }).map((_, index) => ( + { + inputsRef.current[index] = el; + }} + type="text" + inputMode="numeric" + autoComplete={index === 0 ? 'one-time-code' : 'off'} + maxLength={1} + value={value[index] ?? ''} + disabled={disabled} + onChange={(event) => handleChange(index, event.target.value)} + onKeyDown={(event) => handleKeyDown(index, event)} + className="h-20 w-full rounded-xl border border-neutral-200 bg-white text-center text-2xl font-semibold text-neutral-900 outline-none transition-colors focus:border-brand disabled:bg-neutral-50 disabled:opacity-60" + /> + ))} +
+ ); +} diff --git a/src/pages/my/ui/WithdrawalFlow.tsx b/src/pages/my/ui/WithdrawalFlow.tsx new file mode 100644 index 0000000..c850624 --- /dev/null +++ b/src/pages/my/ui/WithdrawalFlow.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; + +import { + WITHDRAWAL_EMAIL_STATUS_MESSAGES, + WITHDRAW_USER_STATUS_MESSAGES, + getApiErrorMessage, + useSendWithdrawalEmail, + useWithdrawUser, +} from '@/features/auth'; +import { getApiErrorStatus } from '@/shared/lib/api/errorMessage'; +import { useCooldown } from '@/shared/lib/useCooldown'; +import { Toast } from '@/shared/ui'; +import { AuthCodeInput } from '@/pages/my/ui/AuthCodeInput'; + +const AUTH_CODE_LENGTH = 6; +const RESEND_COOLDOWN_SECONDS = 60; +/** 인증번호 불일치로 간주하는 상태 코드 */ +const INVALID_CODE_STATUSES = new Set([400, 401]); + +interface WithdrawalFlowProps { + /** 최종 탈퇴 성공 시 호출 (토큰 정리·완료 화면 전환은 상위에서 처리) */ + onCompleted: () => void; +} + +interface ToastState { + message: string; + tone: 'success' | 'error'; +} + +export function WithdrawalFlow({ onCompleted }: WithdrawalFlowProps) { + const [emailSent, setEmailSent] = useState(false); + const [authCode, setAuthCode] = useState(''); + const [withdrawError, setWithdrawError] = useState(''); + const [toast, setToast] = useState(null); + const { seconds: cooldown, start: startCooldown } = useCooldown(); + + const { mutateAsync: sendEmail, isPending: isSending } = useSendWithdrawalEmail(); + const { mutateAsync: withdraw, isPending: isWithdrawing } = useWithdrawUser(); + + const handleSendEmail = async () => { + if (isSending || cooldown > 0) return; + + try { + await sendEmail(); + setEmailSent(true); + startCooldown(RESEND_COOLDOWN_SECONDS); + setToast({ message: '인증번호를 메일로 보냈어요. 메일함을 확인해주세요.', tone: 'success' }); + } catch (error) { + setToast({ + message: getApiErrorMessage( + error, + '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.', + WITHDRAWAL_EMAIL_STATUS_MESSAGES, + ), + tone: 'error', + }); + } + }; + + const handleCodeChange = (value: string) => { + setAuthCode(value); + setWithdrawError(''); + }; + + const handleWithdraw = async () => { + if (isWithdrawing) return; + + if (authCode.length !== AUTH_CODE_LENGTH) { + setWithdrawError('6자리 인증번호를 입력해주세요.'); + return; + } + + setWithdrawError(''); + + try { + await withdraw(authCode); + onCompleted(); + } catch (error) { + const status = getApiErrorStatus(error); + if (status !== null && INVALID_CODE_STATUSES.has(status)) { + setWithdrawError('인증번호가 틀렸습니다. 다시 확인해주세요.'); + return; + } + + setWithdrawError( + getApiErrorMessage(error, '회원 탈퇴에 실패했어요. 잠시 후 다시 시도해주세요.', WITHDRAW_USER_STATUS_MESSAGES), + ); + } + }; + + return ( + <> + setToast(null)} + /> + + {!emailSent ? ( + + ) : ( +
+
+

인증번호

+

메일로 받은 6자리 숫자를 입력해주세요.

+
+ +
+
+ +
+ 인증번호를 받지 못하셨나요? + +
+ + {withdrawError ?

{withdrawError}

: null} + + +
+ )} + + ); +} diff --git a/src/shared/lib/useCooldown.ts b/src/shared/lib/useCooldown.ts new file mode 100644 index 0000000..8a0c246 --- /dev/null +++ b/src/shared/lib/useCooldown.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; + +interface UseCooldownResult { + /** 남은 초 (0이면 쿨다운 종료) */ + seconds: number; + /** 지정한 초만큼 쿨다운 시작 */ + start: (durationSeconds: number) => void; +} + +/** 초 단위 카운트다운 쿨다운 (재발송 제한 등) */ +export function useCooldown(): UseCooldownResult { + const [seconds, setSeconds] = useState(0); + + useEffect(() => { + if (seconds <= 0) return; + + const timer = setInterval(() => { + setSeconds((prev) => (prev <= 1 ? 0 : prev - 1)); + }, 1000); + + return () => clearInterval(timer); + }, [seconds]); + + return { seconds, start: setSeconds }; +} diff --git a/src/shared/ui/Toast.tsx b/src/shared/ui/Toast.tsx new file mode 100644 index 0000000..990bbce --- /dev/null +++ b/src/shared/ui/Toast.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +type ToastTone = 'default' | 'success' | 'error'; + +interface ToastProps { + open: boolean; + message: string; + onClose: () => void; + tone?: ToastTone; + /** 자동 사라짐 시간(ms) */ + duration?: number; +} + +const TONE_CLASS: Record = { + default: 'bg-neutral-950 text-white', + success: 'bg-neutral-950 text-white', + error: 'bg-red-500 text-white', +}; + +export function Toast({ open, message, onClose, tone = 'default', duration = 3000 }: ToastProps) { + const onCloseRef = useRef(onClose); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + if (!open) return; + + const timer = setTimeout(() => onCloseRef.current(), duration); + return () => clearTimeout(timer); + }, [open, duration, message]); + + if (!open) return null; + + return createPortal( +
+
+ {message} +
+
, + document.body, + ); +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 97d5cb7..abc7fbb 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -2,3 +2,4 @@ export { CloseButton } from './CloseButton'; export { LoadingSpinner } from './LoadingSpinner'; export { Modal } from './Modal'; export { Skeleton } from './Skeleton'; +export { Toast } from './Toast'; From d50e02e940238c16f6e586885e6b418dcca72c68 Mon Sep 17 00:00:00 2001 From: sooloin Date: Tue, 23 Jun 2026 15:27:01 +0900 Subject: [PATCH 06/13] =?UTF-8?q?:sparkles:=20Feat:=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EC=99=84=EB=A3=8C=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최종 탈퇴 성공 후 탈퇴 완료 안내 화면 노출 - 회원가입 완료 화면과 대칭되는 레이아웃 적용 - 완료 화면에서 홈으로 이동(replace) 동선 제공 `#51` --- src/pages/my/WithdrawalPage.tsx | 18 ++--------- src/pages/my/ui/WithdrawalCompleteModal.tsx | 34 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 src/pages/my/ui/WithdrawalCompleteModal.tsx diff --git a/src/pages/my/WithdrawalPage.tsx b/src/pages/my/WithdrawalPage.tsx index fdad6f5..96ebe2f 100644 --- a/src/pages/my/WithdrawalPage.tsx +++ b/src/pages/my/WithdrawalPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Link } from 'react-router-dom'; +import { WithdrawalCompleteModal } from '@/pages/my/ui/WithdrawalCompleteModal'; import { WithdrawalFlow } from '@/pages/my/ui/WithdrawalFlow'; import { clearTokens } from '@/shared/lib/auth/token'; @@ -12,21 +13,6 @@ export function WithdrawalPage() { clearTokens(); }; - // TODO(Step 6): 탈퇴 완료 화면을 회원가입 완료 화면과 대칭으로 구현 - if (completed) { - return ( -
-

회원 탈퇴가 완료되었어요.

- - 홈으로 - -
- ); - } - return (
@@ -51,6 +37,8 @@ export function WithdrawalPage() {
+ +
); } diff --git a/src/pages/my/ui/WithdrawalCompleteModal.tsx b/src/pages/my/ui/WithdrawalCompleteModal.tsx new file mode 100644 index 0000000..0bdad77 --- /dev/null +++ b/src/pages/my/ui/WithdrawalCompleteModal.tsx @@ -0,0 +1,34 @@ +import { useNavigate } from 'react-router-dom'; + +import DoDoLogo from '@/shared/assets/images/Logo_light.svg?react'; +import { Modal } from '@/shared/ui'; + +interface WithdrawalCompleteModalProps { + open: boolean; +} + +export function WithdrawalCompleteModal({ open }: WithdrawalCompleteModalProps) { + const navigate = useNavigate(); + const goHome = () => navigate('/', { replace: true }); + + return ( + +
+ +

회원 탈퇴 완료

+

+ 그동안 DoDo를 이용해 주셔서 감사합니다. +
더 좋은 모습으로 다시 만날 수 있기를 바라요. +

+ + +
+
+ ); +} From 6b88f4e5ba5417b47b123872916f1d50bcd99d78 Mon Sep 17 00:00:00 2001 From: sooloin Date: Tue, 23 Jun 2026 16:27:29 +0900 Subject: [PATCH 07/13] =?UTF-8?q?:bug:=20Fix:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EB=A1=9C=EC=BB=AC=20=EC=84=B8=EC=85=98=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 로그아웃 실패 여부와 무관하게 finally에서 토큰 정리·홈 이동 처리 - 서버 실패는 콘솔 로깅으로 대체하고 로컬 로그아웃은 항상 진행 - 화면 이탈로 불필요해진 에러 메시지 상태/표시 제거 `#51` --- src/pages/my/ui/LogoutConfirmDialog.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/pages/my/ui/LogoutConfirmDialog.tsx b/src/pages/my/ui/LogoutConfirmDialog.tsx index bd2ee55..202cee1 100644 --- a/src/pages/my/ui/LogoutConfirmDialog.tsx +++ b/src/pages/my/ui/LogoutConfirmDialog.tsx @@ -1,7 +1,6 @@ -import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { LOGOUT_STATUS_MESSAGES, getApiErrorMessage, useLogout } from '@/features/auth'; +import { useLogout } from '@/features/auth'; import { clearTokens } from '@/shared/lib/auth/token'; import { Modal } from '@/shared/ui'; @@ -13,27 +12,23 @@ interface LogoutConfirmDialogProps { export function LogoutConfirmDialog({ open, onClose }: LogoutConfirmDialogProps) { const navigate = useNavigate(); const { mutateAsync, isPending } = useLogout(); - const [errorMessage, setErrorMessage] = useState(''); const handleClose = () => { if (isPending) return; - setErrorMessage(''); onClose(); }; const handleConfirm = async () => { - setErrorMessage(''); - try { await mutateAsync(); + } catch (error) { + // 서버 로그아웃이 실패해도 로컬 세션은 반드시 정리해 사용자가 갇히지 않도록 한다. + console.error('[auth/logout] 서버 로그아웃 실패', error); + } finally { // 인증 페이지(마이도도)에서 먼저 빠져나간 뒤 토큰을 정리해야 // 잔여 인증 쿼리의 재요청 → 401 → 세션 만료 리다이렉트를 피할 수 있다. navigate('/', { replace: true }); clearTokens(); - } catch (error) { - setErrorMessage( - getApiErrorMessage(error, '로그아웃에 실패했어요. 잠시 후 다시 시도해주세요.', LOGOUT_STATUS_MESSAGES), - ); } }; @@ -46,8 +41,6 @@ export function LogoutConfirmDialog({ open, onClose }: LogoutConfirmDialogProps) 로그아웃하면 현재 기기에서 로그인 정보가 정리돼요. 다시 이용하려면 로그인이 필요해요.

- {errorMessage ?

{errorMessage}

: null} -