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(
+ ,
+ 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}
-