diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 70a21206..fb821149 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -38,6 +38,29 @@ test.describe('회원가입 페이지', () => { }); test.describe('인증 가드', () => { + test('탈퇴 완료 상태 없이 완료 페이지에 접근하면 랜딩으로 이동한다', async ({ page }) => { + await page.goto('/account-deleted'); + + await expect(page).toHaveURL(/\/(\?.*)?$/); + }); + + test('탈퇴 완료 페이지는 성공 상태로 한 번만 접근할 수 있다', async ({ page, context }) => { + await context.addCookies([ + { + name: 'account_deleted', + value: 'true', + domain: 'localhost', + path: '/', + }, + ]); + + await page.goto('/account-deleted'); + await expect(page.getByRole('heading', { name: '회원 탈퇴가 완료되었습니다.' })).toBeVisible(); + + await page.reload(); + await expect(page).toHaveURL(/\/(\?.*)?$/); + }); + test('/home 미인증 접근 시 랜딩으로 리다이렉트된다', async ({ page }) => { await page.goto('/home'); diff --git a/public/images/account-deleted-background.webp b/public/images/account-deleted-background.webp new file mode 100644 index 00000000..f0a5573e Binary files /dev/null and b/public/images/account-deleted-background.webp differ diff --git a/src/apis/authApi.ts b/src/apis/authApi.ts index a5212fa4..61ae4565 100644 --- a/src/apis/authApi.ts +++ b/src/apis/authApi.ts @@ -1,3 +1,4 @@ +import { getGaClientId } from '@/lib/client/analytics'; import { clientApiClient } from '@/lib/client/apiClient'; import { ApiError } from '@/lib/errors/ApiError'; import { UserInfoResponse } from '@/types/api/authApi'; @@ -18,3 +19,22 @@ export const logout = async (): Promise => { method: 'POST', }); }; + +export type MemberDeleteReason = + | 'NO_USEFUL_LINKS' + | 'POOR_SEARCH' + | 'NO_REVISIT' + | 'SWITCHED_SERVICE' + | 'PRIVACY_CONCERN' + | 'OTHER'; + +const createFallbackClientId = () => `${Date.now()}.${Math.floor(Math.random() * 1_000_000_000)}`; + +export const deleteAccount = async (deleteReason: MemberDeleteReason): Promise => { + const clientId = (await getGaClientId().catch(() => null)) ?? createFallbackClientId(); + + await clientApiClient('/api/member', { + method: 'DELETE', + body: JSON.stringify({ confirmed: true, deleteReason, clientId }), + }); +}; diff --git a/src/app/(route)/account-deleted/page.tsx b/src/app/(route)/account-deleted/page.tsx new file mode 100644 index 00000000..a5655f5f --- /dev/null +++ b/src/app/(route)/account-deleted/page.tsx @@ -0,0 +1,26 @@ +import SVGIcon from '@/components/Icons/SVGIcon'; +import Button from '@/components/basics/Button/Button'; +import Link from 'next/link'; + +export default function AccountDeletedPage() { + return ( +
+
+ ); +} diff --git a/src/app/api/member/route.ts b/src/app/api/member/route.ts new file mode 100644 index 00000000..95ebc11f --- /dev/null +++ b/src/app/api/member/route.ts @@ -0,0 +1,71 @@ +import { handleApiError } from '@/hooks/util/api'; +import { COOKIES_KEYS } from '@/lib/constants/cookies'; +import { serverApiClient } from '@/lib/server/apiClient'; +import { NextResponse } from 'next/server'; + +const DELETE_REASONS = new Set([ + 'NO_USEFUL_LINKS', + 'POOR_SEARCH', + 'NO_REVISIT', + 'SWITCHED_SERVICE', + 'PRIVACY_CONCERN', + 'OTHER', +]); + +const isCrossSiteRequest = (request: Request) => { + if (request.headers.get('sec-fetch-site') === 'cross-site') return true; + + const origin = request.headers.get('origin'); + return origin ? origin !== new URL(request.url).origin : false; +}; + +export async function DELETE(request: Request) { + if (isCrossSiteRequest(request)) { + return NextResponse.json({ success: false, message: 'Forbidden' }, { status: 403 }); + } + + try { + const body = await request.json().catch(() => null); + if ( + !body || + body.confirmed !== true || + !DELETE_REASONS.has(body.deleteReason) || + typeof body.clientId !== 'string' || + !/^\d+\.\d+$/.test(body.clientId) + ) { + return NextResponse.json({ success: false, message: 'Invalid request' }, { status: 400 }); + } + + await serverApiClient('/v1/member', { + method: 'DELETE', + body: JSON.stringify({ + confirmed: true, + deleteReason: body.deleteReason, + clientId: body.clientId, + }), + }); + + const response = NextResponse.json({ success: true }); + const cookieOptions = { + path: '/', + ...(process.env.COOKIE_DOMAIN ? { domain: process.env.COOKIE_DOMAIN } : {}), + expires: new Date(0), + sameSite: 'lax' as const, + }; + + response.cookies.set(COOKIES_KEYS.ACCESS_TOKEN, '', cookieOptions); + response.cookies.set(COOKIES_KEYS.REFRESH_TOKEN, '', cookieOptions); + response.cookies.set(COOKIES_KEYS.USER_INFO, '', cookieOptions); + response.cookies.set(COOKIES_KEYS.ACCOUNT_DELETED, 'true', { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + maxAge: 60, + }); + + return response; + } catch (error) { + return handleApiError(error); + } +} diff --git a/src/app/layout-client.tsx b/src/app/layout-client.tsx index 1dd4b026..944821ef 100644 --- a/src/app/layout-client.tsx +++ b/src/app/layout-client.tsx @@ -15,7 +15,7 @@ export default function LayoutClient({ children }: { children: React.ReactNode } const isSideNavOpen = useSideNavStore(state => state.isOpen); // 랜딩에서는 SideNavigation 숨김 - const showSideNav = !['/', '/signup', '/terms'].includes(pathname); + const showSideNav = !['/', '/signup', '/terms', '/account-deleted'].includes(pathname); const isDrawerOpen = showSideNav && isMobile && isSideNavOpen; return ( diff --git a/src/components/basics/Modal/Modal.tsx b/src/components/basics/Modal/Modal.tsx index 5f0c2a86..643b9356 100644 --- a/src/components/basics/Modal/Modal.tsx +++ b/src/components/basics/Modal/Modal.tsx @@ -28,10 +28,11 @@ interface ModalProps extends HTMLAttributes { children: ReactNode; type: keyof typeof MODAL_TYPE; ariaLabel?: string; + closeDisabled?: boolean; } const Modal = forwardRef(function Modal( - { className, children, type, ariaLabel, ...rest }, + { className, children, type, ariaLabel, closeDisabled = false, ...rest }, ref ) { const { modal, close } = useModalStore(); @@ -77,7 +78,7 @@ const Modal = forwardRef(function Modal( // 다른 레이어(Dropdown, Popover 등)가 먼저 처리하도록 우선순위 확인 // 또는 stopImmediatePropagation으로 후속 핸들러 차단 e.stopImmediatePropagation(); - close(); + if (!closeDisabled) close(); return; } @@ -107,13 +108,18 @@ const Modal = forwardRef(function Modal( document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [modal.type, type, close]); + }, [modal.type, type, close, closeDisabled]); if (modal.type !== type) return null; if (!portalElement) return null; return createPortal( -
+
(function Modal( variant="tertiary_subtle" ariaLabel="모달 닫기 버튼" onClick={close} + disabled={closeDisabled} />
diff --git a/src/components/layout/SideNavigation/components/Bottom/SideNavigationBottom.tsx b/src/components/layout/SideNavigation/components/Bottom/SideNavigationBottom.tsx index bbfc9a25..c753b503 100644 --- a/src/components/layout/SideNavigation/components/Bottom/SideNavigationBottom.tsx +++ b/src/components/layout/SideNavigation/components/Bottom/SideNavigationBottom.tsx @@ -1,9 +1,11 @@ import Button from '@/components/basics/Button/Button'; +import Divider from '@/components/basics/Divider/Divider'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/basics/Popover'; import Spinner from '@/components/basics/Spinner/Spinner'; import { useLogout } from '@/hooks/useLogout'; import { useUserInfo } from '@/hooks/useUserInfo'; import { useCloseSideNavOnSelect } from '@/hooks/util/useCloseSideNavOnSelect'; +import { useModalStore } from '@/stores/modalStore'; import NavItem from '../NavItem/NavItem'; @@ -29,6 +31,7 @@ const SideNavigationBottom = () => { const { data: user, isLoading } = useUserInfo(); const { mutate: handleLogout, isPending: isLoggingOut } = useLogout(); const closeSideNav = useCloseSideNavOnSelect(); + const { open } = useModalStore(); if (isLoading) { return (
@@ -72,6 +75,9 @@ const SideNavigationBottom = () => { }} /> ))} +
+ +
)} diff --git a/src/components/layout/SideNavigation/components/SideNavModals.tsx b/src/components/layout/SideNavigation/components/SideNavModals.tsx index 48f8e44d..472b59e4 100644 --- a/src/components/layout/SideNavigation/components/SideNavModals.tsx +++ b/src/components/layout/SideNavigation/components/SideNavModals.tsx @@ -1,5 +1,6 @@ 'use client'; +import AccountDeleteModal from '@/components/wrappers/AccountDeleteModal/AccountDeleteModal'; import { useModalStore } from '@/stores/modalStore'; import DeleteChatModal from './ChatRoomSection/DeleteChatModal'; @@ -20,6 +21,7 @@ export default function SideNavModals() { {modal.type === 'DELETE_CHAT' && ( )} + {modal.type === 'ACCOUNT_DELETE' && } ); } diff --git a/src/components/wrappers/AccountDeleteModal/AccountDeleteModal.tsx b/src/components/wrappers/AccountDeleteModal/AccountDeleteModal.tsx new file mode 100644 index 00000000..3f00e157 --- /dev/null +++ b/src/components/wrappers/AccountDeleteModal/AccountDeleteModal.tsx @@ -0,0 +1,122 @@ +'use client'; + +import type { MemberDeleteReason } from '@/apis/authApi'; +import SVGIcon from '@/components/Icons/SVGIcon'; +import Button from '@/components/basics/Button/Button'; +import Modal from '@/components/basics/Modal/Modal'; +import { useDeleteAccount } from '@/hooks/useDeleteAccount'; +import { useModalStore } from '@/stores/modalStore'; +import { useState } from 'react'; + +const DELETE_REASONS: ReadonlyArray<{ label: string; value: MemberDeleteReason }> = [ + { label: '저장할 만한 링크가 별로 없었어요.', value: 'NO_USEFUL_LINKS' }, + { label: '찾으려는 링크를 잘 못 찾았어요.', value: 'POOR_SEARCH' }, + { label: '저장한 링크를 다시 찾아볼 일이 없었어요.', value: 'NO_REVISIT' }, + { label: '다른 서비스를 쓰기로 했어요.', value: 'SWITCHED_SERVICE' }, + { label: '개인 정보가 유출될까봐 걱정돼요.', value: 'PRIVACY_CONCERN' }, + { label: '기타', value: 'OTHER' }, +] as const; + +const AccountDeleteModal = () => { + const { close } = useModalStore(); + const [step, setStep] = useState<'notice' | 'reason'>('notice'); + const [reason, setReason] = useState(null); + const { mutate: deleteAccount, isPending, isError } = useDeleteAccount(); + + const handleClose = () => { + if (isPending) return; + close(); + }; + + return ( + +
+ {step === 'notice' ? ( + <> +

회원 탈퇴

+
+
+

+ 탈퇴 시, 저장한 링크, 메모, AI 채팅 내역과 같은 모든 정보가 삭제되며, +
+ 한번 삭제된 정보와 계정은 복구할 수 없습니다. 회원 탈퇴를 진행하시겠습니까? +

+
+
+ + ) : ( + <> +

탈퇴 사유 (선택)

+

+ 서비스를 아껴주신 고객님의 마음에 감사드리며, 충분한 만족을 드리지 못해 죄송합니다. +
+ 탈퇴 사유를 남겨 주시면 서비스 개선에 더욱 힘쓰겠습니다. +

+
+ 탈퇴 사유 + {DELETE_REASONS.map(item => ( + + ))} +
+ {isError && ( +

+ 회원 탈퇴에 실패했습니다. 잠시 후 다시 시도해주세요. +

+ )} +
+
+ + )} +
+
+ ); +}; + +export default AccountDeleteModal; diff --git a/src/hooks/useDeleteAccount.ts b/src/hooks/useDeleteAccount.ts new file mode 100644 index 00000000..9e72a847 --- /dev/null +++ b/src/hooks/useDeleteAccount.ts @@ -0,0 +1,27 @@ +import { type MemberDeleteReason, deleteAccount } from '@/apis/authApi'; +import { setIntentionalSessionTermination } from '@/lib/client/apiClient'; +import { COOKIES_KEYS } from '@/lib/constants/cookies'; +import { useModalStore } from '@/stores/modalStore'; +import { clearTokens } from '@/stores/tokenStore'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { deleteCookieUtil } from './useCookie'; + +export function useDeleteAccount() { + const queryClient = useQueryClient(); + const closeModal = useModalStore(state => state.close); + + return useMutation({ + mutationFn: (deleteReason: MemberDeleteReason) => deleteAccount(deleteReason), + onSuccess: () => { + setIntentionalSessionTermination(true); + deleteCookieUtil(COOKIES_KEYS.ACCESS_TOKEN); + deleteCookieUtil(COOKIES_KEYS.REFRESH_TOKEN); + deleteCookieUtil(COOKIES_KEYS.USER_INFO); + clearTokens(); + queryClient.clear(); + closeModal(); + window.location.replace('/account-deleted'); + }, + }); +} diff --git a/src/lib/client/apiClient.ts b/src/lib/client/apiClient.ts index 0876c301..9a9222cd 100644 --- a/src/lib/client/apiClient.ts +++ b/src/lib/client/apiClient.ts @@ -1,5 +1,37 @@ import { ApiError } from '../errors/ApiError'; +let sessionCleanupPromise: Promise | null = null; +let intentionalSessionTermination = false; +let deferredInvalidSessionRedirect = false; + +export const setIntentionalSessionTermination = (value: boolean) => { + intentionalSessionTermination = value; + + if (!value && deferredInvalidSessionRedirect) { + deferredInvalidSessionRedirect = false; + clearInvalidSessionAndRedirect(); + } +}; + +export const clearInvalidSessionAndRedirect = () => { + if (typeof window === 'undefined') return; + if (intentionalSessionTermination) { + deferredInvalidSessionRedirect = true; + return; + } + if (sessionCleanupPromise) return; + + sessionCleanupPromise = fetch('/api/member/logout', { + method: 'POST', + credentials: 'same-origin', + cache: 'no-store', + }) + .catch(() => undefined) + .then(() => { + window.location.replace('/'); + }); +}; + /** * 클라이언트 API 클라이언트 (인증용) * 클라이언트 사이드 API 클라이언트 @@ -34,6 +66,11 @@ export async function clientApiClient( if (!res.ok) { const errorData = await res.json().catch(() => ({})); + + if (res.status === 401 || res.status === 403) { + clearInvalidSessionAndRedirect(); + } + throw new ApiError( res.status, errorData.error || errorData.message || `Request failed with status ${res.status}`, diff --git a/src/lib/client/backendClient.ts b/src/lib/client/backendClient.ts index 4880b298..3e37df9f 100644 --- a/src/lib/client/backendClient.ts +++ b/src/lib/client/backendClient.ts @@ -1,6 +1,8 @@ // TODO: sentry 중복 보고 발생 여부 확인 필요 import * as Sentry from '@sentry/nextjs'; +import { clearInvalidSessionAndRedirect } from './apiClient'; + const API_BASE_URL = process.env.NEXT_PUBLIC_BASE_API_URL; // TODO: 환경변수 논의 후 BASE_API_URL로 변경 export class BackendApiError extends Error { @@ -27,12 +29,14 @@ export async function backendApiClient(endpoint: string, options: RequestInit }, }); - if (res.status === 401) { - if (typeof window !== 'undefined') { - window.location.href = '/landing'; - } + if (res.status === 401 || res.status === 403) { + clearInvalidSessionAndRedirect(); const errorData = await res.json().catch(() => ({})); - const err = new BackendApiError(401, errorData.message || 'Unauthorized', errorData); + const err = new BackendApiError( + res.status, + errorData.message || 'Authentication required', + errorData + ); Sentry.captureException(err, { extra: { endpoint } }); throw err; } diff --git a/src/lib/constants/cookies.ts b/src/lib/constants/cookies.ts index 446c3215..64898361 100644 --- a/src/lib/constants/cookies.ts +++ b/src/lib/constants/cookies.ts @@ -2,4 +2,5 @@ export const COOKIES_KEYS = { ACCESS_TOKEN: 'accessToken', REFRESH_TOKEN: 'refreshToken', USER_INFO: 'user_info', + ACCOUNT_DELETED: 'account_deleted', } as const; diff --git a/src/lib/server/apiClient.ts b/src/lib/server/apiClient.ts index 78aeadd2..20f2528f 100644 --- a/src/lib/server/apiClient.ts +++ b/src/lib/server/apiClient.ts @@ -133,5 +133,10 @@ export async function serverApiClient(endpoint: string, options: RequestInit }); } - return response.json(); + if (response.status === 204 || response.headers.get('content-length') === '0') { + return undefined as T; + } + + const body = await response.text(); + return body ? (JSON.parse(body) as T) : (undefined as T); } diff --git a/src/middleware.ts b/src/middleware.ts index 6bdbe7f9..b05bd747 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -11,6 +11,7 @@ import type { NextRequest } from 'next/server'; const publicRoutes = ['/', '/signup']; const TERMS_ROUTE = '/terms'; +const ACCOUNT_DELETED_ROUTE = '/account-deleted'; const API_BASE_URL = process.env.NEXT_PUBLIC_BASE_API_URL; const AUTH_REFRESH_ENDPOINT = process.env.AUTH_REFRESH_ENDPOINT ?? '/v1/auth/reissue'; const DEV_BYPASS_LOGIN = @@ -59,6 +60,23 @@ export async function middleware(req: NextRequest) { const refreshToken = req.cookies.get(COOKIES_KEYS.REFRESH_TOKEN)?.value; const { pathname } = req.nextUrl; const isTermsRoute = pathname === TERMS_ROUTE; + const isAccountDeletedRoute = pathname === ACCOUNT_DELETED_ROUTE; + + if (isAccountDeletedRoute) { + const completed = req.cookies.get(COOKIES_KEYS.ACCOUNT_DELETED)?.value === 'true'; + + if (!completed) { + return NextResponse.redirect(new URL(token ? '/home' : '/', req.url)); + } + + const response = NextResponse.next(); + response.cookies.set(COOKIES_KEYS.ACCOUNT_DELETED, '', { + path: '/', + expires: new Date(0), + sameSite: 'lax', + }); + return response; + } if (DEV_BYPASS_LOGIN) { if (publicRoutes.includes(pathname)) { diff --git a/src/stores/modalStore.ts b/src/stores/modalStore.ts index e3bd66db..577db474 100644 --- a/src/stores/modalStore.ts +++ b/src/stores/modalStore.ts @@ -7,6 +7,7 @@ export const MODAL_TYPE = { REPORT: 'REPORT', DELETE_CHAT: 'DELETE_CHAT', DELETE_LINK: 'DELETE_LINK', + ACCOUNT_DELETE: 'ACCOUNT_DELETE', } as const; export type ModalType = keyof typeof MODAL_TYPE | null; @@ -16,7 +17,8 @@ type ModalState = | { type: 'RE_SUMMARY'; props: { linkId: EntityId } } | { type: 'REPORT'; props?: Record } | { type: 'DELETE_CHAT'; props: { chatId: EntityId; title: string } } - | { type: 'DELETE_LINK'; props: { linkIds: EntityId[] } }; + | { type: 'DELETE_LINK'; props: { linkIds: EntityId[] } } + | { type: 'ACCOUNT_DELETE'; props?: Record }; type NonNullModalState = Exclude; type ModalProps = Extract<