Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -78,6 +79,14 @@ export const router = createBrowserRouter([
</RequireAuth>
),
},
{
path: '/my/withdrawal',
element: (
<RequireAuth>
<WithdrawalPage />
</RequireAuth>
),
},
{
path: '/my/pets/new',
element: (
Expand Down
11 changes: 11 additions & 0 deletions src/features/auth/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { apiClient } from '@/shared/api/axios';

import type {
LogoutResponse,
NicknameCheckResponse,
NotificationUpdateResponse,
RegisterProfileRequest,
Expand Down Expand Up @@ -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<LogoutResponse> {
const response = await apiClient.post<LogoutResponse>('/auth/logout', { refreshToken });
return response.data;
}

/**
* 추가 정보 입력 → 가입 완료 (PUT /users/me/profile)
* - 202 응답으로 받은 registrationToken을 Authorization 헤더로 전달
Expand Down
29 changes: 28 additions & 1 deletion src/features/auth/api/users.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -19,3 +25,24 @@ export async function updateMyProfile(body: UpdateMyProfileRequest): Promise<Upd
const response = await apiClient.patch<UpdateMyProfileResponse>('/users/me', body);
return response.data;
}

/**
* 탈퇴 인증 메일 발송 (POST /users/me/withdrawal/email)
* - 현재 로그인한 유저 이메일로 인증번호 발송
* - 1분 이내 재요청 시 429 응답
*/
export async function sendWithdrawalEmail(): Promise<WithdrawalEmailResponse> {
const response = await apiClient.post<WithdrawalEmailResponse>('/users/me/withdrawal/email');
return response.data;
}

/**
* 최종 회원 탈퇴 (DELETE /users/me)
* - 메일로 받은 6자리 인증번호(authCode)로 계정 삭제
*/
export async function withdrawUser(authCode: string): Promise<WithdrawUserResponse> {
const response = await apiClient.delete<WithdrawUserResponse>('/users/me', {
data: { authCode },
});
return response.data;
}
15 changes: 13 additions & 2 deletions src/features/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ 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 { getMyProfile, updateMyProfile } from './api/users';
export { socialLogin, logout, registerProfile, checkNicknameAvailability, updateNotificationSetting } from './api/auth';
export { getMyProfile, updateMyProfile, sendWithdrawalEmail, withdrawUser } from './api/users';
export { useCreatePet } from './model/useCreatePet';
export { useCreatePetInvitationCode } from './model/useCreatePetInvitationCode';
export { useCreatePetSpecialNote } from './model/useCreatePetSpecialNote';
Expand All @@ -29,6 +29,9 @@ 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 { useSendWithdrawalEmail } from './model/useSendWithdrawalEmail';
export { useWithdrawUser } from './model/useWithdrawUser';
export { useApprovePetFamilyRequest } from './model/useApprovePetFamilyRequest';
export { useDeletePetWeight } from './model/useDeletePetWeight';
export { useDeletePetSpecialNote } from './model/useDeletePetSpecialNote';
Expand All @@ -50,6 +53,8 @@ export type {
SocialLoginSuccess,
SocialSignupRequired,
SocialLoginResult,
LogoutRequest,
LogoutResponse,
CreatePetRequest,
CreatePetResponse,
CreatePetSpecialNoteRequest,
Expand Down Expand Up @@ -78,6 +83,9 @@ export type {
NotificationUpdateResponse,
UpdateMyProfileRequest,
UpdateMyProfileResponse,
WithdrawalEmailResponse,
WithdrawUserRequest,
WithdrawUserResponse,
PetDetailResponse,
PetFamilyApprovalAction,
PetFamilyApprovalRequest,
Expand All @@ -102,8 +110,11 @@ 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,
NICKNAME_CHECK_STATUS_MESSAGES,
WITHDRAWAL_EMAIL_STATUS_MESSAGES,
WITHDRAW_USER_STATUS_MESSAGES,
} from './lib/apiErrorMessages';
24 changes: 24 additions & 0 deletions src/features/auth/lib/apiErrorMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ export const SOCIAL_LOGIN_STATUS_MESSAGES: Partial<Record<number, string>> = {
500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.',
};

/** 로그아웃 POST /auth/logout */
export const LOGOUT_STATUS_MESSAGES: Partial<Record<number, string>> = {
400: '잘못된 요청이에요. 잠시 후 다시 시도해주세요.',
401: '인증 정보가 유효하지 않아요. 다시 로그인해주세요.',
404: '로그인 정보를 찾을 수 없어요.',
500: '로그아웃에 실패했어요. 잠시 후 다시 시도해주세요.',
};

/** 회원가입 완료 PUT /users/me/profile */
export const REGISTER_PROFILE_STATUS_MESSAGES: Partial<Record<number, string>> = {
400: '입력 정보를 다시 확인해주세요.',
Expand All @@ -31,6 +39,22 @@ export const PROFILE_UPDATE_STATUS_MESSAGES: Partial<Record<number, string>> = {
500: '회원정보 수정에 실패했어요. 잠시 후 다시 시도해주세요.',
};

/** 탈퇴 인증 메일 발송 POST /users/me/withdrawal/email */
export const WITHDRAWAL_EMAIL_STATUS_MESSAGES: Partial<Record<number, string>> = {
401: '로그인이 필요한 기능이에요. 다시 로그인해주세요.',
404: '사용자를 찾을 수 없어요.',
429: '잠시 후 다시 시도해주세요. (1분 이내 재요청은 불가해요)',
500: '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.',
};

/** 최종 회원 탈퇴 DELETE /users/me */
export const WITHDRAW_USER_STATUS_MESSAGES: Partial<Record<number, string>> = {
400: '인증번호를 다시 확인해주세요.',
401: '인증번호가 올바르지 않거나 만료되었어요. 다시 시도해주세요.',
404: '사용자를 찾을 수 없어요.',
500: '회원 탈퇴에 실패했어요. 잠시 후 다시 시도해주세요.',
};

/** 닉네임 중복 확인 GET /users/nickname/check */
export const NICKNAME_CHECK_STATUS_MESSAGES: Partial<Record<number, string>> = {
500: '중복 확인에 실패했어요. 잠시 후 다시 시도해주세요.',
Expand Down
28 changes: 28 additions & 0 deletions src/features/auth/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -408,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;
Expand Down
21 changes: 21 additions & 0 deletions src/features/auth/model/useLogout.ts
Original file line number Diff line number Diff line change
@@ -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<LogoutResponse | null, unknown, void>({
mutationFn: async () => {
const refreshToken = getRefreshToken();
if (!refreshToken) return null;

return logout(refreshToken);
},
});
}
11 changes: 11 additions & 0 deletions src/features/auth/model/useSendWithdrawalEmail.ts
Original file line number Diff line number Diff line change
@@ -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<WithdrawalEmailResponse, unknown, void>({
mutationFn: () => sendWithdrawalEmail(),
});
}
11 changes: 11 additions & 0 deletions src/features/auth/model/useWithdrawUser.ts
Original file line number Diff line number Diff line change
@@ -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<WithdrawUserResponse, unknown, string>({
mutationFn: (authCode) => withdrawUser(authCode),
});
}
44 changes: 44 additions & 0 deletions src/pages/my/WithdrawalPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';

import { WithdrawalCompleteModal } from '@/pages/my/ui/WithdrawalCompleteModal';
import { WithdrawalFlow } from '@/pages/my/ui/WithdrawalFlow';

export function WithdrawalPage() {
const [completed, setCompleted] = useState(false);

// 토큰 정리는 완료 모달을 닫고 홈으로 이동하는 시점(WithdrawalCompleteModal)으로 미룬다.
// RequireAuth 가드 아래에서 즉시 clearTokens 하면 모달 노출 전에 /auth로 리다이렉트될 수 있다.
const handleCompleted = () => {
setCompleted(true);
};
Comment on lines +12 to +14

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

WithdrawalPageRequireAuth 가드로 보호되어 있습니다. 따라서 handleCompleted에서 즉시 clearTokens()를 호출하면 인증 상태가 해제되어, WithdrawalCompleteModal이 표시되기도 전에 로그인 페이지(/auth)로 강제 리다이렉트되는 UX 버그가 발생할 수 있습니다.

토큰 삭제는 탈퇴 완료 모달을 닫고 홈으로 이동하는 시점(WithdrawalCompleteModal 내부)으로 미루는 것이 안전합니다.

  const handleCompleted = () => {
    setCompleted(true);
  };


return (
<div className="mx-auto w-full max-w-xl px-4 py-10 sm:py-14">
<div className="mb-2">
<Link
to="/my?menu=profile-edit"
className="text-sm text-neutral-500 underline-offset-2 hover:text-brand hover:underline"
>
← 회원정보 수정으로 돌아가기
</Link>
</div>

<div className="overflow-hidden rounded-[24px] border border-neutral-200 bg-white shadow-sm">
<div className="border-b border-neutral-100 px-6 py-6 sm:px-8">
<h1 className="text-[20px] font-semibold tracking-[-0.02em] text-neutral-950">회원 탈퇴</h1>
<p className="mt-2 text-sm leading-7 text-neutral-600">
탈퇴를 진행하려면 본인 확인이 필요해요. 가입하신 이메일로 인증번호를 보내드릴게요.
</p>
{/* <p className="text-sm leading-7 text-red-500">탈퇴 시 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요.</p> */}
</div>

<div className="px-6 py-6 sm:px-8">
<WithdrawalFlow onCompleted={handleCompleted} />
</div>
</div>

<WithdrawalCompleteModal open={completed} />
</div>
);
}
20 changes: 12 additions & 8 deletions src/pages/my/model/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -31,14 +35,14 @@ export const MY_DODO_SECTION_LABELS: Record<MyDodoMenuSection, string> = {
};

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<MyDodoMenuKey, MyDodoContent> = {
Expand Down
Loading
Loading