diff --git a/backend/openapi.json b/backend/openapi.json index 31ba2cc..2289ce4 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -2121,8 +2121,8 @@ }, "delete" : { "tags" : [ "Users" ], - "summary" : "회원 탈퇴 (soft delete + 모든 refresh token revoke)", - "description" : "User row 의 is_deleted=true. 모든 refresh_token revoke. GitHub access token 무효화는 사용자가 GitHub Settings 에서 별도 수행.", + "summary" : "회원 탈퇴 (soft delete + 토큰 폐기)", + "description" : "User row 의 is_deleted=true. 보관 중이던 GitHub access token 폐기. 모든 refresh_token 과 피드백 공유 토큰 revoke. GitHub 쪽 grant 무효화는 사용자가 GitHub Settings 에서 별도 수행.", "operationId" : "deleteCurrentUser", "responses" : { "204" : { diff --git a/backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java b/backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java index e427f88..708d6b1 100644 --- a/backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java +++ b/backend/src/main/java/com/stackup/stackup/user/presentation/UserController.java @@ -39,8 +39,8 @@ public ResponseEntity getCurrentUser( @Operation( operationId = "deleteCurrentUser", - summary = "회원 탈퇴 (soft delete + 모든 refresh token revoke)", - description = "User row 의 is_deleted=true. 모든 refresh_token revoke. GitHub access token 무효화는 사용자가 GitHub Settings 에서 별도 수행." + summary = "회원 탈퇴 (soft delete + 토큰 폐기)", + description = "User row 의 is_deleted=true. 보관 중이던 GitHub access token 폐기. 모든 refresh_token 과 피드백 공유 토큰 revoke. GitHub 쪽 grant 무효화는 사용자가 GitHub Settings 에서 별도 수행." ) @ApiResponses({ @ApiResponse(responseCode = "204", description = "탈퇴 처리됨"), diff --git a/docs/security.md b/docs/security.md index 0bcb14a..ffbe88a 100644 --- a/docs/security.md +++ b/docs/security.md @@ -154,8 +154,9 @@ public class GithubTokenCipher { - 동일 GitHub 계정 재가입 시 신규 사용자로 생성 (기존 데이터 복구 X) — 유니크 인덱스가 `WHERE is_deleted = FALSE` 부분 인덱스라 가능하다(V3·V22) -> **미구현**: 탈퇴를 실행할 프론트엔드 화면이 없다. `DELETE /api/users/me` 는 동작하지만 -> 사용자가 도달할 경로가 없다. +화면: `/workspace/account` (계정 설정). 탈퇴 전에 무엇이 사라지는지 명시하고, 되돌릴 수 없는 +액션이므로 확인 다이얼로그를 거친다. GitHub 앱 승인 자체는 우리가 취소할 수 없으므로 +`github.com/settings/applications` 경로를 함께 안내한다. ### 5.4 데이터 최소 수집 - GitHub OAuth 시 요청 scope 최소화: `read:user`, `user:email`, `repo` (private 분석 위해) diff --git a/frontend/src/app/router/index.tsx b/frontend/src/app/router/index.tsx index 6d3b033..de5205e 100644 --- a/frontend/src/app/router/index.tsx +++ b/frontend/src/app/router/index.tsx @@ -97,6 +97,14 @@ export const router = createBrowserRouter([ ), }, + { + path: '/workspace/account', + element: ( + + + + ), + }, { path: '/history', element: }, { path: '/design-system/*', diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts index cf1d8f8..4c6a9a0 100644 --- a/frontend/src/features/auth/api/auth.ts +++ b/frontend/src/features/auth/api/auth.ts @@ -56,6 +56,11 @@ export async function logout(): Promise { await apiClient.delete('/api/auth/logout') } +// 회원 탈퇴. 204 를 받으면 이 계정으로는 더 이상 로그인할 수 없다. +export async function deleteAccount(): Promise { + await apiClient.delete('/api/users/me') +} + export async function createStreamToken(): Promise { const response = await apiClient.post<{ streamToken: string }>( '/api/auth/stream-token', diff --git a/frontend/src/features/auth/index.ts b/frontend/src/features/auth/index.ts index 1c41f6f..f93942d 100644 --- a/frontend/src/features/auth/index.ts +++ b/frontend/src/features/auth/index.ts @@ -2,6 +2,7 @@ export { AuthProvider } from './model/AuthProvider' export { useAuth } from './model/useAuth' export { useGetStartedTarget } from './model/useGetStartedTarget' export { useLogout } from './model/useLogout' +export { useDeleteAccount } from './model/useDeleteAccount' export type { AuthContextValue, AuthStatus } from './model/AuthContext' export { GithubLoginButton } from './ui/GithubLoginButton' export { GoogleLoginButton } from './ui/GoogleLoginButton' @@ -14,6 +15,7 @@ export { fetchCurrentUser, logout, createStreamToken, + deleteAccount, } from './api/auth' export type { AuthUser, diff --git a/frontend/src/features/auth/model/useDeleteAccount.ts b/frontend/src/features/auth/model/useDeleteAccount.ts new file mode 100644 index 0000000..6fa69d4 --- /dev/null +++ b/frontend/src/features/auth/model/useDeleteAccount.ts @@ -0,0 +1,41 @@ +import { useCallback, useState } from 'react' +import { isApiError } from '@/shared/api' +import { toast } from '@/shared/ui' +import { deleteAccount } from '../api/auth' +import { useAuth } from './useAuth' + +/** + * 회원 탈퇴 (US-04). + * + *

성공하면 곧바로 로컬 인증 상태를 비운다. `logout()` 은 서버 호출이 실패해도 + * finally 에서 상태를 정리하므로, 이미 지워진 계정이라 로그아웃 API 가 401 을 줘도 + * 화면은 정상적으로 로그아웃된 상태가 된다. + */ +export function useDeleteAccount() { + const { logout } = useAuth() + const [deleting, setDeleting] = useState(false) + + const remove = useCallback(async () => { + if (deleting) return false + setDeleting(true) + try { + await deleteAccount() + } catch (error) { + setDeleting(false) + // 410 = 이미 탈퇴한 계정. 사용자가 원한 결과는 이미 이뤄졌으니 실패로 알리지 않고 + // 로그아웃까지 마무리한다(다른 탭에서 먼저 탈퇴한 경우). + if (!(isApiError(error) && error.status === 410)) { + toast.error('탈퇴 처리에 실패했어요. 잠시 후 다시 시도해 주세요.') + return false + } + } + try { + await logout() + } catch { + // 이미 계정이 사라져 로그아웃 API 가 실패할 수 있다 — 로컬 상태는 이미 정리됐다. + } + return true + }, [deleting, logout]) + + return { remove, deleting } +} diff --git a/frontend/src/pages/Workspace/ui/AccountView.test.tsx b/frontend/src/pages/Workspace/ui/AccountView.test.tsx new file mode 100644 index 0000000..1659db2 --- /dev/null +++ b/frontend/src/pages/Workspace/ui/AccountView.test.tsx @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AccountView } from './AccountView' + +const remove = vi.fn(async () => true) +const logout = vi.fn(async () => {}) + +vi.mock('@/features/auth', () => ({ + useAuth: () => ({ + user: { displayName: '홍길동', email: 'hong@example.com', avatarUrl: null }, + status: 'authenticated', + }), + useLogout: () => ({ logout, loggingOut: false }), + useDeleteAccount: () => ({ remove, deleting: false }), +})) + +describe('AccountView', () => { + beforeEach(() => { + // 모듈 스코프 mock 이라 테스트끼리 호출 횟수가 새어 나간다. + remove.mockClear() + logout.mockClear() + }) + + // 되돌릴 수 없는 액션이라 확인 다이얼로그를 거쳐야 한다 (docs/ui-patterns.md). + it('탈퇴 버튼만 눌러서는 탈퇴되지 않는다', async () => { + render() + + await userEvent.click(screen.getByRole('button', { name: '회원 탈퇴' })) + + expect(remove).not.toHaveBeenCalled() + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('다이얼로그에서 확인해야 탈퇴가 실행된다', async () => { + render() + + await userEvent.click(screen.getByRole('button', { name: '회원 탈퇴' })) + const dialog = screen.getByRole('dialog') + await userEvent.click(within(dialog).getByRole('button', { name: '탈퇴하기' })) + + await waitFor(() => expect(remove).toHaveBeenCalledTimes(1)) + }) + + it('취소하면 아무 일도 일어나지 않는다', async () => { + render() + + await userEvent.click(screen.getByRole('button', { name: '회원 탈퇴' })) + await userEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: '취소' })) + + expect(remove).not.toHaveBeenCalled() + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + }) + + // 우리가 지우는 건 우리가 가진 사본뿐이다 — GitHub 앱 승인 해제 경로를 알려주지 않으면 + // 사용자는 권한이 남아 있다는 사실 자체를 모른다. + it('GitHub 앱 권한 해제 경로를 안내한다', () => { + render() + + expect(screen.getByRole('link', { name: /Authorized OAuth Apps/ })).toHaveAttribute( + 'href', + 'https://github.com/settings/applications', + ) + }) + + it('탈퇴로 무엇이 사라지는지 미리 알려준다', () => { + render() + + expect(screen.getByText(/공유 링크가 즉시 만료/)).toBeInTheDocument() + expect(screen.getByText(/GitHub 접근 권한\(access token\)을 즉시 폐기/)).toBeInTheDocument() + expect(screen.getByText(/이전 데이터는 복구되지 않습니다/)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/pages/Workspace/ui/AccountView.tsx b/frontend/src/pages/Workspace/ui/AccountView.tsx new file mode 100644 index 0000000..18acb99 --- /dev/null +++ b/frontend/src/pages/Workspace/ui/AccountView.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react' +import { useAuth, useDeleteAccount, useLogout } from '@/features/auth' +import { Button } from '@/shared/ui/Button' +import { ConfirmDialog } from '@/shared/ui' + +// 탈퇴가 실제로 무엇을 하고 무엇을 하지 않는지 (docs/security.md §5.3). +// "정말 삭제할까요?" 만 묻고 넘어가면 사용자는 GitHub 권한이 남는다는 걸 끝내 모른다. +const CONSEQUENCES = [ + '면접 기록·피드백·오답노트를 포함한 모든 데이터에 더 이상 접근할 수 없습니다.', + '발급했던 피드백 공유 링크가 즉시 만료됩니다.', + '보관 중이던 GitHub 접근 권한(access token)을 즉시 폐기합니다.', + '같은 계정으로 다시 가입할 수 있지만, 이전 데이터는 복구되지 않습니다.', +] + +export function AccountView() { + const { user } = useAuth() + const { logout, loggingOut } = useLogout() + const { remove, deleting } = useDeleteAccount() + const [confirmOpen, setConfirmOpen] = useState(false) + + return ( +

+
+

계정

+
+
+
이름
+
{user?.displayName ?? '—'}
+
+
+
이메일
+
{user?.email ?? '비공개'}
+
+
+
+ +
+
+ +
+

회원 탈퇴

+
+
+

탈퇴하면 다음과 같이 처리됩니다.

+
    + {CONSEQUENCES.map((line) => ( +
  • + {line} +
  • + ))} +
+
+ {/* 우리가 지우는 건 우리가 가진 사본뿐이다. GitHub 계정 쪽 승인은 사용자만 + 해제할 수 있으므로 어디서 하는지까지 알려준다. */} +

+ StackUp 에 준 GitHub 앱 권한 자체를 취소하려면{' '} + + GitHub 설정 > Authorized OAuth Apps + + 에서 함께 해제해 주세요. +

+
+ +
+
+
+ + setConfirmOpen(false)} + onConfirm={() => { + void remove().then((done) => { + if (!done) setConfirmOpen(false) + }) + }} + /> +
+ ) +} diff --git a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx index aae3b2c..227ffde 100644 --- a/frontend/src/pages/Workspace/ui/WorkspacePage.tsx +++ b/frontend/src/pages/Workspace/ui/WorkspacePage.tsx @@ -9,12 +9,14 @@ import { ReposView } from './ReposView' import { CoverLettersView } from './CoverLettersView' import { HistoryView } from './HistoryView' import { BookmarksView } from './BookmarksView' +import { AccountView } from './AccountView' -type View = 'home' | 'resumes' | 'repos' | 'cover-letters' | 'history' | 'bookmarks' +type View = 'home' | 'resumes' | 'repos' | 'cover-letters' | 'history' | 'bookmarks' | 'account' function resolveView(pathname: string): View { if (pathname.startsWith('/workspace/history')) return 'history' if (pathname.startsWith('/workspace/bookmarks')) return 'bookmarks' + if (pathname.startsWith('/workspace/account')) return 'account' if (pathname.startsWith('/workspace/resumes')) return 'resumes' if (pathname.startsWith('/workspace/repos')) return 'repos' if (pathname.startsWith('/workspace/cover-letters')) return 'cover-letters' @@ -60,6 +62,11 @@ export default function WorkspacePage() { title: '오답노트', description: '다시 볼 질문을 모아 복습하세요.', }, + account: { + eyebrow: '워크스페이스', + title: '계정 설정', + description: '로그인 정보를 확인하고 계정을 관리하세요.', + }, }[view] return ( @@ -80,6 +87,7 @@ export default function WorkspacePage() { {view === 'cover-letters' && } {view === 'history' && } {view === 'bookmarks' && } + {view === 'account' && } diff --git a/frontend/src/shared/api/generated.ts b/frontend/src/shared/api/generated.ts index 7d07171..c9f000d 100644 --- a/frontend/src/shared/api/generated.ts +++ b/frontend/src/shared/api/generated.ts @@ -594,8 +594,8 @@ export interface paths { put?: never; post?: never; /** - * 회원 탈퇴 (soft delete + 모든 refresh token revoke) - * @description User row 의 is_deleted=true. 모든 refresh_token revoke. GitHub access token 무효화는 사용자가 GitHub Settings 에서 별도 수행. + * 회원 탈퇴 (soft delete + 토큰 폐기) + * @description User row 의 is_deleted=true. 보관 중이던 GitHub access token 폐기. 모든 refresh_token 과 피드백 공유 토큰 revoke. GitHub 쪽 grant 무효화는 사용자가 GitHub Settings 에서 별도 수행. */ delete: operations["deleteCurrentUser"]; options?: never; diff --git a/frontend/src/widgets/workspace-sidebar/ui/WorkspaceSidebar.tsx b/frontend/src/widgets/workspace-sidebar/ui/WorkspaceSidebar.tsx index f65414d..bbbb8b7 100644 --- a/frontend/src/widgets/workspace-sidebar/ui/WorkspaceSidebar.tsx +++ b/frontend/src/widgets/workspace-sidebar/ui/WorkspaceSidebar.tsx @@ -17,6 +17,7 @@ const navItems: NavItem[] = [ { to: '/workspace/cover-letters', label: '자소서', icon: }, { to: '/workspace/history', label: '히스토리', icon: }, { to: '/workspace/bookmarks', label: '오답노트', icon: }, + { to: '/workspace/account', label: '계정 설정', icon: }, ] export function WorkspaceSidebar() { @@ -213,6 +214,25 @@ function RepoIcon() { ) } +function AccountIcon() { + return ( + + + + + ) +} + function BookmarkIcon() { return (