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
4 changes: 2 additions & 2 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ public ResponseEntity<UserProfileResponse> 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 = "탈퇴 처리됨"),
Expand Down
5 changes: 3 additions & 2 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 분석 위해)
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/app/router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ export const router = createBrowserRouter([
</RequireAuth>
),
},
{
path: '/workspace/account',
element: (
<RequireAuth>
<WorkspacePage />
</RequireAuth>
),
},
{ path: '/history', element: <Navigate to="/workspace/history" replace /> },
{
path: '/design-system/*',
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/features/auth/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export async function logout(): Promise<void> {
await apiClient.delete('/api/auth/logout')
}

// 회원 탈퇴. 204 를 받으면 이 계정으로는 더 이상 로그인할 수 없다.
export async function deleteAccount(): Promise<void> {
await apiClient.delete('/api/users/me')
}

export async function createStreamToken(): Promise<string> {
const response = await apiClient.post<{ streamToken: string }>(
'/api/auth/stream-token',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/features/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -14,6 +15,7 @@ export {
fetchCurrentUser,
logout,
createStreamToken,
deleteAccount,
} from './api/auth'
export type {
AuthUser,
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/features/auth/model/useDeleteAccount.ts
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>성공하면 곧바로 로컬 인증 상태를 비운다. `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 }
}
73 changes: 73 additions & 0 deletions frontend/src/pages/Workspace/ui/AccountView.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AccountView />)

await userEvent.click(screen.getByRole('button', { name: '회원 탈퇴' }))

expect(remove).not.toHaveBeenCalled()
expect(screen.getByRole('dialog')).toBeInTheDocument()
})

it('다이얼로그에서 확인해야 탈퇴가 실행된다', async () => {
render(<AccountView />)

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(<AccountView />)

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(<AccountView />)

expect(screen.getByRole('link', { name: /Authorized OAuth Apps/ })).toHaveAttribute(
'href',
'https://github.com/settings/applications',
)
})

it('탈퇴로 무엇이 사라지는지 미리 알려준다', () => {
render(<AccountView />)

expect(screen.getByText(/공유 링크가 즉시 만료/)).toBeInTheDocument()
expect(screen.getByText(/GitHub 접근 권한\(access token\)을 즉시 폐기/)).toBeInTheDocument()
expect(screen.getByText(/이전 데이터는 복구되지 않습니다/)).toBeInTheDocument()
})
})
94 changes: 94 additions & 0 deletions frontend/src/pages/Workspace/ui/AccountView.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-10">
<section className="flex flex-col gap-3">
<h2 className="text-body font-bold tracking-[-0.02em] text-fg">계정</h2>
<dl className="flex flex-col gap-2 rounded-xl border border-border bg-surface-raised p-5">
<div className="flex items-baseline justify-between gap-4">
<dt className="text-caption text-fg-subtle">이름</dt>
<dd className="text-body text-fg">{user?.displayName ?? '—'}</dd>
</div>
<div className="flex items-baseline justify-between gap-4">
<dt className="text-caption text-fg-subtle">이메일</dt>
<dd className="text-body text-fg">{user?.email ?? '비공개'}</dd>
</div>
</dl>
<div>
<Button variant="secondary" onClick={logout} loading={loggingOut}>
로그아웃
</Button>
</div>
</section>

<section className="flex flex-col gap-3">
<h2 className="text-body font-bold tracking-[-0.02em] text-fg">회원 탈퇴</h2>
<div className="flex flex-col gap-4 rounded-xl border border-danger bg-surface-raised p-5">
<div className="flex flex-col gap-2">
<p className="text-caption text-fg-muted">탈퇴하면 다음과 같이 처리됩니다.</p>
<ul className="flex list-disc flex-col gap-1 pl-5 text-caption text-fg-muted">
{CONSEQUENCES.map((line) => (
<li key={line} style={{ wordBreak: 'keep-all' }}>
{line}
</li>
))}
</ul>
</div>
{/* 우리가 지우는 건 우리가 가진 사본뿐이다. GitHub 계정 쪽 승인은 사용자만
해제할 수 있으므로 어디서 하는지까지 알려준다. */}
<p className="text-caption text-fg-subtle" style={{ wordBreak: 'keep-all' }}>
StackUp 에 준 GitHub 앱 권한 자체를 취소하려면{' '}
<a
href="https://github.com/settings/applications"
target="_blank"
rel="noreferrer noopener"
className="text-primary-fg underline underline-offset-2"
>
GitHub 설정 &gt; Authorized OAuth Apps
</a>
에서 함께 해제해 주세요.
</p>
<div>
<Button variant="danger" onClick={() => setConfirmOpen(true)} loading={deleting}>
회원 탈퇴
</Button>
</div>
</div>
</section>

<ConfirmDialog
open={confirmOpen}
danger
loading={deleting}
title="정말 탈퇴하시겠어요?"
description="면접 기록과 피드백을 포함한 모든 데이터에 더 이상 접근할 수 없습니다. 이 작업은 되돌릴 수 없습니다."
confirmLabel="탈퇴하기"
cancelLabel="취소"
onCancel={() => setConfirmOpen(false)}
onConfirm={() => {
void remove().then((done) => {
if (!done) setConfirmOpen(false)
})
}}
/>
</div>
)
}
10 changes: 9 additions & 1 deletion frontend/src/pages/Workspace/ui/WorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -60,6 +62,11 @@ export default function WorkspacePage() {
title: '오답노트',
description: '다시 볼 질문을 모아 복습하세요.',
},
account: {
eyebrow: '워크스페이스',
title: '계정 설정',
description: '로그인 정보를 확인하고 계정을 관리하세요.',
},
}[view]

return (
Expand All @@ -80,6 +87,7 @@ export default function WorkspacePage() {
{view === 'cover-letters' && <CoverLettersView />}
{view === 'history' && <HistoryView />}
{view === 'bookmarks' && <BookmarksView />}
{view === 'account' && <AccountView />}
</div>
</main>
</div>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/shared/api/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/widgets/workspace-sidebar/ui/WorkspaceSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const navItems: NavItem[] = [
{ to: '/workspace/cover-letters', label: '자소서', icon: <CoverLetterIcon /> },
{ to: '/workspace/history', label: '히스토리', icon: <HistoryIcon /> },
{ to: '/workspace/bookmarks', label: '오답노트', icon: <BookmarkIcon /> },
{ to: '/workspace/account', label: '계정 설정', icon: <AccountIcon /> },
]

export function WorkspaceSidebar() {
Expand Down Expand Up @@ -213,6 +214,25 @@ function RepoIcon() {
)
}

function AccountIcon() {
return (
<svg
viewBox="0 0 20 20"
width="18"
height="18"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="10" cy="7" r="3.2" />
<path d="M4 16.5c0-2.6 2.7-4.2 6-4.2s6 1.6 6 4.2" />
</svg>
)
}

function BookmarkIcon() {
return (
<svg
Expand Down
Loading