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
29 changes: 29 additions & 0 deletions src/features/auth/api/deleteAccount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest';
import { httpClient } from '@/shared/http/client';
import { deleteAccount } from './deleteAccount';

vi.mock('@/shared/http/client', () => ({
httpClient: { delete: vi.fn() },
}));

describe('deleteAccount', () => {
it('응답의 authorizationUrl을 돌려준다', async () => {
// 이 값을 버리면 왕복을 시작할 수 없다. 화면은 "탈퇴 완료"를 띄우고 이동하지만
// 서버는 아무것도 지우지 않은 상태라, 사용자에게 거짓을 말하게 된다.
vi.mocked(httpClient.delete).mockResolvedValue({
data: { authorizationUrl: '/api/core/v1/auth/authorize/google?ticket=stub' },
});

await expect(deleteAccount()).resolves.toEqual({
authorizationUrl: '/api/core/v1/auth/authorize/google?ticket=stub',
});
});

it('DELETE /me를 호출한다', async () => {
vi.mocked(httpClient.delete).mockResolvedValue({ data: { authorizationUrl: '/x' } });

await deleteAccount();

expect(httpClient.delete).toHaveBeenCalledWith('/me');
});
});
21 changes: 18 additions & 3 deletions src/features/auth/api/deleteAccount.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { httpClient } from '@/shared/http/client';

export interface WithdrawalStart {
/**
* 공급자 인가 진입 주소.
* 서버 상대경로일 수도, 공급자 절대 URL일 수도 있다 — **해석하지 않고 이동만 한다.**
*/
authorizationUrl: string;
}

/**
* ⭐ 표준 패턴: 화면(Component) → Hook → API 함수(여기) → httpClient.
* 근거: docs/reference/08_API_명세.md 3.6 — 회원 탈퇴, 204(본문 없음). 되돌릴 수 없다.
*
* 근거: docs/reference/08_API_명세.md 3.6 — 회원 탈퇴.
*
* **이 요청은 아직 아무것도 지우지 않는다.** 탈퇴는 두 단계다. 서버가 공급자 연결을 끊으려면
* 공급자가 발급한 토큰이 필요한데 로그인 시 그것을 보관하지 않으므로, 탈퇴 시점에 인가를 한 번
* 더 받는다. 여기서 받은 주소로 **페이지를 이동**하면 그 왕복이 시작되고, 콜백에서 연결 해제가
* 성공한 경우에만 삭제가 일어난다.
*/
export async function deleteAccount(): Promise<void> {
await httpClient.delete('/me');
export async function deleteAccount(): Promise<WithdrawalStart> {
const response = await httpClient.delete<WithdrawalStart>('/me');
return response.data;
}
16 changes: 7 additions & 9 deletions src/features/auth/hooks/useDeleteAccountMutation.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import type { ApiError } from '@/shared/http/types';
import { deleteAccount } from '../api/deleteAccount';
import { deleteAccount, type WithdrawalStart } from '../api/deleteAccount';

// ⭐ 표준 패턴: 컴포넌트는 이 Hook만 호출한다. API 함수·httpClient를 직접 부르지 않는다.
// 성공 시 캐시를 통째로 비운다 — 다음 사용자 로그인 시 이전 사용자 데이터가 남아있지 않도록.
//
// 캐시를 여기서 비우지 않는다. 이 요청은 왕복을 시작할 뿐 아직 아무것도 지우지 않으므로,
// 지금 비우면 사용자가 공급자 화면에서 취소하고 돌아왔을 때 멀쩡한 세션의 캐시만 날린 셈이 된다.
// 탈퇴가 확정되면 서버가 인증 쿠키를 만료시키고 전체 페이지 이동이 일어나 캐시는 자연히 사라진다.
export function useDeleteAccountMutation() {
const queryClient = useQueryClient();

return useMutation<void, ApiError, void>({
return useMutation<WithdrawalStart, ApiError, void>({
mutationFn: deleteAccount,
onSuccess: () => {
queryClient.clear();
},
});
}
51 changes: 51 additions & 0 deletions src/features/auth/lib/handleOAuthCallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,55 @@ describe('handleOAuthCallback', () => {
expect(getPreLoginPath()).toBe('/collections/42');
});
});

// 탈퇴 왕복의 실패는 로그인 실패와 성격이 다르다 — 회원이 그대로 살아 있다.
// 서버가 연결 해제에 성공한 경우에만 삭제하고, 실패하면 인증 쿠키도 지우지 않는다(08 §3.6.2).
describe('탈퇴 왕복 실패(error=WITHDRAWAL_*)', () => {
let alertSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
vi.mocked(logoutRequest).mockReset();
});

afterEach(() => {
alertSpy.mockRestore();
});

it.each([
['WITHDRAWAL_CANCELLED', '탈퇴를 취소했습니다.'],
['WITHDRAWAL_FAILED', '탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.'],
['WITHDRAWAL_UNLINK_FAILED', '탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.'],
['WITHDRAWAL_ACCOUNT_MISMATCH', '가입에 사용한 계정으로 인증해야 탈퇴할 수 있습니다.'],
])('%s 이면 그 문구를 보여주고 홈으로 되돌린다', async (error, message) => {
expect.assertions(3);
try {
await handleOAuthCallback({ search: { error } });
} catch (thrown) {
expect(isRedirect(thrown)).toBe(true);
expect(getRedirectTarget(thrown)).toBe('/');
}
expect(alertSpy).toHaveBeenCalledWith(message);
});

it('로그아웃 API를 호출하지 않는다 — 세션이 살아 있어야 다시 시도할 수 있다', async () => {
expect.assertions(1);
try {
await handleOAuthCallback({ search: { error: 'WITHDRAWAL_CANCELLED' } });
} catch {
// 리다이렉트만 확인하면 되므로 무시한다.
}
expect(logoutRequest).not.toHaveBeenCalled();
});

it('로그인 실패 문구를 쓰지 않는다', async () => {
expect.assertions(1);
try {
await handleOAuthCallback({ search: { error: 'WITHDRAWAL_CANCELLED' } });
} catch {
// 리다이렉트만 확인하면 되므로 무시한다.
}
expect(alertSpy).not.toHaveBeenCalledWith('로그인에 실패했습니다. 다시 시도해 주세요.');
});
});
});
25 changes: 25 additions & 0 deletions src/features/auth/lib/handleOAuthCallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ interface HandleOAuthCallbackArgs {

const OAUTH_FAILURE_MESSAGE = '로그인에 실패했습니다. 다시 시도해 주세요.';

/**
* 탈퇴 왕복의 실패 어휘(08_API_명세 3.6.2). 값마다 사용자가 할 일이 달라 문구를 나눈다.
*
* **이 경우들은 회원이 그대로 살아 있다** — 서버가 연결 해제에 성공한 경우에만 삭제하고,
* 실패하면 인증 쿠키도 지우지 않는다. 그래서 아래 로그아웃 정리를 하지 않는다. 로그아웃시키면
* 다시 시도하려는 사용자가 로그인부터 해야 한다.
*/
const WITHDRAWAL_FAILURE_MESSAGES: Record<string, string> = {
WITHDRAWAL_CANCELLED: '탈퇴를 취소했습니다.',
WITHDRAWAL_FAILED: '탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.',
WITHDRAWAL_UNLINK_FAILED: '탈퇴에 실패했습니다. 잠시 후 다시 시도해 주세요.',
WITHDRAWAL_ACCOUNT_MISMATCH: '가입에 사용한 계정으로 인증해야 탈퇴할 수 있습니다.',
};

/**
* `/auth/callback`의 `beforeLoad`에 붙여 쓰는 착지 처리.
* 성공(`error` 없음): 로그인 시작 전 경로(없으면 메인)로 이동.
Expand All @@ -22,6 +36,15 @@ const OAUTH_FAILURE_MESSAGE = '로그인에 실패했습니다. 다시 시도해
*/
export async function handleOAuthCallback({ search }: HandleOAuthCallbackArgs): Promise<never> {
if (search.error) {
// 탈퇴 왕복의 실패는 회원이 살아 있는 상태다. 로그아웃 정리를 하지 않고 되돌려보낸다.
const withdrawalMessage = WITHDRAWAL_FAILURE_MESSAGES[search.error];
if (withdrawalMessage) {
window.alert(withdrawalMessage);
// 설정은 라우트가 아니라 AppLayout 안의 패널이라 홈으로 보낸다. 세션이 살아 있으므로
// 사용자는 거기서 다시 시도할 수 있다.
throw redirect({ to: '/' });
}

window.alert(OAUTH_FAILURE_MESSAGE);
try {
await logoutRequest();
Expand All @@ -31,5 +54,7 @@ export async function handleOAuthCallback({ search }: HandleOAuthCallbackArgs):
throw redirect({ to: '/login' });
}

// 탈퇴가 확정된 경우도 여기로 온다 — 성공에는 별도 파라미터가 없다(08 §3.6.2).
// 서버가 인증 쿠키를 만료시켰으므로 보호 라우트에 들어가려다 로그인으로 밀려난다.
throw redirect({ to: resolvePostLoginRedirect() });
}
4 changes: 4 additions & 0 deletions src/features/auth/lib/oauthCallbackSearchSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { z } from 'zod';
/**
* /auth/callback 쿼리 파라미터.
* 근거: docs/reference/11_인증_설계.md 21행 — 성공 `/auth/callback`, 실패 `/auth/callback?error=OAUTH_FAILED`.
* 탈퇴 왕복의 실패 어휘는 08_API_명세 3.6.2에 있다(`WITHDRAWAL_*`).
*
* ⚠️ `z.object`는 **선언하지 않은 쿼리를 버린다.** 서버가 새 파라미터를 추가하면 여기에도
* 적어야 `beforeLoad`까지 도달한다.
*/
export const oauthCallbackSearchSchema = z.object({
error: z.string().optional(),
Expand Down
69 changes: 30 additions & 39 deletions src/features/layout/components/WithdrawConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,19 @@
import { useEffect, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import { useWithdrawConfirm } from '@/contexts/useWithdrawConfirm';
import { useDeleteAccountMutation } from '@/features/auth/hooks/useDeleteAccountMutation';

// 성공 메시지를 잠깐 보여준 뒤 이동한다(토스트 컴포넌트가 없어 인라인 메시지로 대체).
const SUCCESS_MESSAGE_DURATION_MS = 1200;

/**
* 회원 탈퇴 확인 다이얼로그.
* 근거: Jira S15P11A705-162, docs/reference/08_API_명세.md 3.6.
* 목업(withdraw-confirm)과 동일하게 "아니오"(취소, 강조 스타일)를 먼저, "예"(탈퇴 진행, 약한 스타일)를
* 나중에 배치한다 — 일반적인 좌우 배치(취소-오른쪽/확인-왼쪽)와 반대다.
*
* **성공 메시지를 여기서 보여주지 않는다.** 「예」를 눌러 받는 것은 완료가 아니라 공급자 인가
* 진입 주소이고, 이 시점에는 아직 아무것도 지워지지 않았다. 사용자는 공급자 화면에서 취소할 수도
* 있다. 탈퇴 완료는 왕복이 끝나고 인증 쿠키가 만료된 채 `/auth/callback`에 착지하는 것으로 드러난다.
*/
export function WithdrawConfirmDialog() {
const withdrawConfirm = useWithdrawConfirm();
const deleteAccountMutation = useDeleteAccountMutation();
const navigate = useNavigate();
const [isSucceeded, setIsSucceeded] = useState(false);

useEffect(() => {
if (!isSucceeded) {
return;
}
const timer = setTimeout(() => {
void navigate({ to: '/login' });
}, SUCCESS_MESSAGE_DURATION_MS);
return () => clearTimeout(timer);
}, [isSucceeded, navigate]);

if (!withdrawConfirm.isOpen) {
return null;
Expand All @@ -39,7 +26,11 @@ export function WithdrawConfirmDialog() {

const handleConfirm = () => {
deleteAccountMutation.mutate(undefined, {
onSuccess: () => setIsSucceeded(true),
onSuccess: ({ authorizationUrl }) => {
// 라우터 이동이 아니라 전체 페이지 이동이어야 한다 — 목적지가 공급자 화면이다.
// fetch·axios로 부르면 사용자에게 그 화면이 보이지 않는다(08 §3.6.1).
window.location.href = authorizationUrl;
},
});
};

Expand All @@ -52,32 +43,32 @@ export function WithdrawConfirmDialog() {
탈퇴하시겠습니까?
</p>

{isSucceeded && <p className="mt-3 text-xs text-log-mint">탈퇴가 완료되었습니다</p>}
<p className="mt-3 text-xs text-ink-gray">
계속하려면 가입에 사용한 소셜 계정으로 한 번 더 인증해야 합니다.
</p>

{deleteAccountMutation.isError && (
<p className="mt-3 text-xs text-red-600">{deleteAccountMutation.error.message}</p>
)}

{!isSucceeded && (
<div className="mt-6 flex gap-2">
<button
type="button"
onClick={handleClose}
disabled={deleteAccountMutation.isPending}
className="h-11 flex-1 rounded-lg bg-log-mint text-sm font-bold text-pin-navy disabled:opacity-40"
>
아니오
</button>
<button
type="button"
onClick={handleConfirm}
disabled={deleteAccountMutation.isPending}
className="h-11 flex-1 rounded-lg border border-pin-navy/15 text-sm font-bold text-ink-gray disabled:opacity-40"
>
{deleteAccountMutation.isPending ? '탈퇴 처리 중…' : '예'}
</button>
</div>
)}
<div className="mt-6 flex gap-2">
<button
type="button"
onClick={handleClose}
disabled={deleteAccountMutation.isPending}
className="h-11 flex-1 rounded-lg bg-log-mint text-sm font-bold text-pin-navy disabled:opacity-40"
>
아니오
</button>
<button
type="button"
onClick={handleConfirm}
disabled={deleteAccountMutation.isPending}
className="h-11 flex-1 rounded-lg border border-pin-navy/15 text-sm font-bold text-ink-gray disabled:opacity-40"
>
{deleteAccountMutation.isPending ? '이동 중…' : '예'}
</button>
</div>
</div>
</div>
);
Expand Down
Loading