Skip to content
Open
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
23 changes: 23 additions & 0 deletions e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
Binary file added public/images/account-deleted-background.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions src/apis/authApi.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,3 +19,22 @@ export const logout = async (): Promise<void> => {
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<void> => {
const clientId = (await getGaClientId().catch(() => null)) ?? createFallbackClientId();

await clientApiClient('/api/member', {
method: 'DELETE',
body: JSON.stringify({ confirmed: true, deleteReason, clientId }),
});
};
26 changes: 26 additions & 0 deletions src/app/(route)/account-deleted/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="bg-gray50 relative flex min-h-screen items-center justify-center overflow-hidden px-4 py-12">
<div
className="absolute inset-x-0 bottom-0 h-[34%] bg-[url('/images/account-deleted-background.webp')] bg-cover bg-center"
aria-hidden="true"
/>
<section className="border-gray100 relative z-10 flex w-full max-w-[520px] flex-col items-center rounded-2xl border bg-white px-8 py-11 text-center">
<SVGIcon icon="IC_Complete" size="2xl" className="text-blue400" aria-hidden="true" />
<h1 className="font-title-md text-gray900 mt-6">회원 탈퇴가 완료되었습니다.</h1>
<p className="font-body-md text-gray600 mt-4">
그동안 링카이빙을 이용해 주셔서 진심으로 감사드립니다.
<br />
앞으로 더 좋은 모습으로 만나뵐 수 있기를 바랍니다.
</p>
<Button asChild label="링카이빙 홈으로 이동하기" className="mt-10 w-full">
<Link href="/">링카이빙 홈으로 이동하기</Link>
</Button>
</section>
</main>
);
}
71 changes: 71 additions & 0 deletions src/app/api/member/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
2 changes: 1 addition & 1 deletion src/app/layout-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
15 changes: 11 additions & 4 deletions src/components/basics/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ interface ModalProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
type: keyof typeof MODAL_TYPE;
ariaLabel?: string;
closeDisabled?: boolean;
}

const Modal = forwardRef<HTMLDivElement, ModalProps>(function Modal(
{ className, children, type, ariaLabel, ...rest },
{ className, children, type, ariaLabel, closeDisabled = false, ...rest },
ref
) {
const { modal, close } = useModalStore();
Expand Down Expand Up @@ -77,7 +78,7 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(function Modal(
// 다른 레이어(Dropdown, Popover 등)가 먼저 처리하도록 우선순위 확인
// 또는 stopImmediatePropagation으로 후속 핸들러 차단
e.stopImmediatePropagation();
close();
if (!closeDisabled) close();
return;
}

Expand Down Expand Up @@ -107,13 +108,18 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(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(
<div ref={ref} className={modalOverlayStyle()} onClick={close} {...rest}>
<div
ref={ref}
className={modalOverlayStyle()}
{...rest}
onClick={closeDisabled ? undefined : close}
>
<div
ref={contentRef}
role="dialog"
Expand All @@ -129,6 +135,7 @@ const Modal = forwardRef<HTMLDivElement, ModalProps>(function Modal(
variant="tertiary_subtle"
ariaLabel="모달 닫기 버튼"
onClick={close}
disabled={closeDisabled}
/>
</div>
<Divider color="gray200" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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 (
<div className="flex shrink-0 items-center justify-center p-2">
Expand Down Expand Up @@ -72,6 +75,9 @@ const SideNavigationBottom = () => {
}}
/>
))}
<div className="py-1">
<Divider color="gray200" />
</div>
<Button
variant="tertiary_subtle"
contextStyle="onPanel"
Expand All @@ -89,6 +95,21 @@ const SideNavigationBottom = () => {
}}
disabled={isLoggingOut}
/>
<Button
variant="tertiary_subtle"
contextStyle="onPanel"
label="회원 탈퇴"
disabled={isLoggingOut}
size="sm"
icon="IC_Delete"
radius="full"
className="w-full justify-start"
onClick={() => {
close();
open('ACCOUNT_DELETE');
closeSideNav();
}}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
)}
</PopoverContent>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import AccountDeleteModal from '@/components/wrappers/AccountDeleteModal/AccountDeleteModal';
import { useModalStore } from '@/stores/modalStore';

import DeleteChatModal from './ChatRoomSection/DeleteChatModal';
Expand All @@ -20,6 +21,7 @@ export default function SideNavModals() {
{modal.type === 'DELETE_CHAT' && (
<DeleteChatModal chatId={modal.props.chatId} title={modal.props.title} />
)}
{modal.type === 'ACCOUNT_DELETE' && <AccountDeleteModal />}
</>
);
}
Loading
Loading