From a38714dfe40d5ea251d1e95117d7e1d2cdcf5fa2 Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 24 Apr 2026 10:04:44 +0900 Subject: [PATCH] =?UTF-8?q?v0.1.43=20-=20=EC=9D=B8=EC=A6=9D=20=EA=B0=95?= =?UTF-8?q?=EC=A0=9C=EC=99=80=20=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=ED=9D=90=EB=A6=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 7 +- README.md | 3 +- backend/src/lib/authSession.js | 5 ++ backend/src/routes/auth.js | 64 ++++++++++++++-- package-lock.json | 4 +- package.json | 2 +- src/App.vue | 107 ++++++++++++++++++++++++++- src/components/SettingsDashboard.vue | 57 ++++++++++++++ src/lib/authClient.js | 15 ++++ 9 files changed, 250 insertions(+), 14 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index e1f2bfa..935f9dd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.42` 준비 중 +- 현재 기준 버전: `v0.1.43` 준비 중 - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -223,6 +223,11 @@ - 회원가입과 프로필 수정 시 이메일뿐 아니라 닉네임 중복도 서버에서 409로 막는다. 비밀번호 재설정 API는 로그인 모달의 `비밀번호 찾기` 흐름과 연결되어 있고, `/reset-password?token=...` URL로 들어오면 새 비밀번호 설정 모드가 열린다. 실제 메일 발송 전까지는 백엔드 응답의 `resetPreviewUrl`을 개발용 링크로 표시한다. - Resend 메일러가 추가되었다. `RESEND_API_KEY`, `MAIL_FROM_EMAIL`, `MAIL_FROM_NAME`, `APP_BASE_URL` 환경변수를 설정하면 이메일 인증과 비밀번호 재설정 메일을 실제로 발송한다. API 키는 저장소에 커밋하지 말고 루트 `.env`/`.env.dev`에만 넣는다. - 인증/비밀번호 재설정 개발용 링크는 `AUTH_PREVIEW_LINKS=true`일 때만 API 응답에 포함된다. 운영 `.env`는 반드시 `AUTH_PREVIEW_LINKS=false`로 두어야 하며, 현재 예시 파일도 false가 기본값이다. +- 회원가입은 이제 자동 로그인되지 않는다. 인증 메일 발송 후 `이메일 인증 후 로그인` 안내만 보여주고, 일반 사용자는 이메일 인증 전까지 로그인할 수 없다. +- 기존에 발급된 세션이라도 일반 사용자 이메일 인증이 안 되어 있으면 `/api/auth/me` 단계에서 세션을 즉시 폐기한다. 운영 중 인증 정책을 켠 뒤에도 미인증 세션이 남지 않게 하기 위한 장치다. +- 비밀번호 재설정/이메일 인증용 개발 링크는 `AUTH_PREVIEW_LINKS=true`여도 `APP_BASE_URL`이 `localhost` 또는 `127.0.0.1`일 때만 응답에 포함한다. 상용 서버에서 링크가 그대로 보이면 이 조건부터 확인한다. +- 프론트는 `/verify-email?token=...` 진입 시 인증을 바로 확정하고 로그인 모달에 결과 메시지를 띄운다. `/reset-password?token=...`은 기존처럼 비밀번호 재설정 모달을 연다. +- SETTINGS 화면에 일반 사용자 전용 `회원 탈퇴` 카드가 추가되었다. 현재 비밀번호 확인 후 계정, 플래너 기록, 목표, 세션, 인증 토큰이 함께 삭제된다. 기본 관리자 계정은 이 경로에서 삭제하지 못하게 막는다. - `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다. - 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. diff --git a/README.md b/README.md index 2f35f55..46b2e28 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,8 @@ docker compose up -d --build 관리자 계정은 백엔드 시작 시 `.env`의 `ADMIN_ACCOUNT_*` 값으로 자동 생성된다. 관리자 아이디와 비밀번호는 저장소 문서에 적지 말고, 운영자만 접근 가능한 비공개 환경변수 파일에서 관리한다. -일반 사용자는 기존처럼 회원가입 후 이메일로 로그인하면 된다. +일반 사용자는 회원가입 후 이메일 인증을 완료해야 로그인할 수 있다. +운영 서버에서는 비밀번호 재설정/이메일 인증용 미리보기 링크가 API 응답에 노출되지 않도록 유지한다. 현재 `docker-compose.yml` 기준 내부 구성: diff --git a/backend/src/lib/authSession.js b/backend/src/lib/authSession.js index efd5a62..f8a9a8b 100644 --- a/backend/src/lib/authSession.js +++ b/backend/src/lib/authSession.js @@ -61,5 +61,10 @@ export async function findAuthenticatedUser(request) { .where(eq(users.id, session.userId)) .limit(1) + if (user && user.role !== 'admin' && !user.emailVerifiedAt) { + await db.delete(authSessions).where(eq(authSessions.id, session.id)) + return null + } + return user ?? null } diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 54f143a..0fcc23b 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -28,6 +28,10 @@ const passwordSchema = z.object({ newPassword: z.string().min(8).max(72), }) +const deleteAccountSchema = z.object({ + currentPassword: z.string().min(1).max(72), +}) + const verificationRequestSchema = z.object({ email: z.string().trim().email().optional(), }) @@ -110,7 +114,11 @@ function sanitizeUser(user) { } function withPreviewUrl(payload, key, previewUrl) { - if (!env.AUTH_PREVIEW_LINKS) { + const allowPreviewLinks = + env.AUTH_PREVIEW_LINKS && + /localhost|127\.0\.0\.1/i.test(env.APP_BASE_URL) + + if (!allowPreviewLinks) { return payload } @@ -175,13 +183,12 @@ export async function registerAuthRoutes(app) { nickname, role: 'user', emailVerifiedAt: null, - lastLoginAt: now, + lastLoginAt: null, createdAt: now, updatedAt: now, }) .returning() - const { token } = await createSession(user.id) const verification = await createEmailVerificationToken(user.id) await sendVerificationEmail({ to: user.email, @@ -189,9 +196,8 @@ export async function registerAuthRoutes(app) { }) return reply.code(201).send(withPreviewUrl({ - message: '회원가입이 완료되었습니다.', - token, - user: sanitizeUser(user), + message: '회원가입이 완료되었습니다. 이메일 인증 후 로그인해 주세요.', + requiresEmailVerification: true, }, 'verificationPreviewUrl', verification.previewUrl)) }) @@ -233,6 +239,12 @@ export async function registerAuthRoutes(app) { }) } + if (user.role !== 'admin' && !user.emailVerifiedAt) { + return reply.code(403).send({ + message: '이메일 인증을 완료한 뒤 로그인해 주세요.', + }) + } + const now = new Date() const [updatedUser] = await db @@ -364,6 +376,46 @@ export async function registerAuthRoutes(app) { } }) + app.delete('/api/auth/account', async (request, reply) => { + const user = await findAuthenticatedUser(request) + + if (!user) { + return reply.code(401).send({ + message: '인증이 필요합니다.', + }) + } + + if (user.role === 'admin') { + return reply.code(403).send({ + message: '기본 관리자 계정은 여기서 삭제할 수 없습니다.', + }) + } + + const payload = deleteAccountSchema.safeParse(request.body) + + if (!payload.success) { + return reply.code(400).send({ + message: '회원 탈퇴 확인 비밀번호를 입력해 주세요.', + issues: payload.error.flatten(), + }) + } + + const passwordMatches = await verifyPassword(payload.data.currentPassword, user.passwordHash) + + if (!passwordMatches) { + return reply.code(401).send({ + message: '현재 비밀번호가 올바르지 않습니다.', + }) + } + + await db.delete(authSessions).where(eq(authSessions.userId, user.id)) + await db.delete(users).where(eq(users.id, user.id)) + + return { + message: '회원 탈퇴가 완료되었습니다.', + } + }) + app.post('/api/auth/verification/request', async (request, reply) => { const authenticatedUser = await findAuthenticatedUser(request) const payload = verificationRequestSchema.safeParse(request.body ?? {}) diff --git a/package-lock.json b/package-lock.json index 0d8ff49..97b477f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.42", + "version": "0.1.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.42", + "version": "0.1.43", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 5ef15d1..6969a16 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.42", + "version": "0.1.43", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index ec8a58e..673d3de 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,6 +11,8 @@ import StatsDashboard from './components/StatsDashboard.vue' import { fetchAdminOverview } from './lib/adminApi' import { clearAuthState, + confirmVerification, + deleteAccount, fetchCurrentUser, confirmPasswordReset, login, @@ -88,10 +90,15 @@ const passwordForm = reactive({ newPassword: '', confirmPassword: '', }) +const accountDeleteForm = reactive({ + currentPassword: '', +}) const profileBusy = ref(false) const passwordBusy = ref(false) const profileMessage = ref('') const passwordMessage = ref('') +const accountDeleteBusy = ref(false) +const accountDeleteMessage = ref('') const carryoverMessage = ref('') const carryoverCheckPolicy = ref(readCarryoverCheckPolicy()) const carryoverCheckPrompt = ref(null) @@ -1443,6 +1450,10 @@ function resetPasswordForm() { passwordForm.confirmPassword = '' } +function resetAccountDeleteForm() { + accountDeleteForm.currentPassword = '' +} + function openAuthDialog(mode = 'login') { authMode.value = mode authMessage.value = '' @@ -1472,6 +1483,43 @@ function openPasswordResetFromUrl() { authDialogOpen.value = true } +async function openVerificationFromUrl() { + if (typeof window === 'undefined') { + return + } + + const url = new URL(window.location.href) + + if (!url.pathname.includes('verify-email')) { + return + } + + const token = url.searchParams.get('token') ?? '' + + if (!token) { + authMode.value = 'login' + authMessage.value = '이메일 인증 링크가 올바르지 않습니다.' + authDialogOpen.value = true + return + } + + authBusy.value = true + authMode.value = 'login' + authDialogOpen.value = true + + try { + const result = await confirmVerification({ token }) + authMessage.value = result.message || '이메일 인증이 완료되었습니다. 이제 로그인할 수 있습니다.' + url.pathname = '/' + url.search = '' + window.history.replaceState({}, '', url.toString()) + } catch (error) { + authMessage.value = toUserFacingApiError(error, '이메일 인증 링크를 처리하지 못했습니다.') + } finally { + authBusy.value = false + } +} + function closeAuthDialog() { authDialogOpen.value = false authMessage.value = '' @@ -1537,7 +1585,14 @@ async function submitAuthForm() { password: authForm.password, }) - await applyAuthSuccess(result, authMode.value === 'login' && authForm.rememberSession) + if (authMode.value === 'signup') { + authMode.value = 'login' + authForm.password = '' + authMessage.value = result.message + return + } + + await applyAuthSuccess(result, authForm.rememberSession) } catch (error) { authMessage.value = toUserFacingApiError(error, '인증 처리 중 문제가 발생했습니다.') } finally { @@ -1588,6 +1643,7 @@ function logout() { adminMessage.value = '' adminUsers.value = [] adminRecentLogins.value = [] + accountDeleteMessage.value = '' adminOverview.value = { totalUsers: 0, totalAdmins: 0, @@ -1607,6 +1663,7 @@ function logout() { restoreLocalPlannerRecords() resetGoalForm() resetPasswordForm() + resetAccountDeleteForm() } async function loadAdminDashboard() { @@ -1806,6 +1863,10 @@ function updatePasswordField({ field, value }) { passwordForm[field] = value } +function updateAccountDeleteField({ field, value }) { + accountDeleteForm[field] = value +} + async function submitProfileForm() { profileBusy.value = true profileMessage.value = '' @@ -1862,6 +1923,36 @@ async function submitPasswordForm() { } } +async function submitDeleteAccount() { + if (!accountDeleteForm.currentPassword) { + accountDeleteMessage.value = '회원 탈퇴를 위해 현재 비밀번호를 입력해 주세요.' + return + } + + const confirmed = window.confirm('정말로 회원 탈퇴하시겠습니까? 작성한 플래너와 목표 데이터도 함께 삭제됩니다.') + + if (!confirmed) { + return + } + + accountDeleteBusy.value = true + accountDeleteMessage.value = '' + + try { + const result = await deleteAccount(authToken.value, { + currentPassword: accountDeleteForm.currentPassword, + }) + resetAccountDeleteForm() + logout() + authMessage.value = '' + window.alert(result.message || '회원 탈퇴가 완료되었습니다.') + } catch (error) { + accountDeleteMessage.value = error.message || '회원 탈퇴를 처리하지 못했습니다.' + } finally { + accountDeleteBusy.value = false + } +} + function replacePlannerRecords(nextRecords) { Object.keys(plannerRecords).forEach((key) => { delete plannerRecords[key] @@ -2026,6 +2117,12 @@ async function printPlannerRange() { window.print() } +async function initializeAppSession() { + await openVerificationFromUrl() + openPasswordResetFromUrl() + await restoreAuthSession() +} + onMounted(() => { resetGoalForm() setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', { @@ -2034,8 +2131,7 @@ onMounted(() => { updateWindowWidth() window.addEventListener('resize', updateWindowWidth) window.addEventListener('keydown', handleGlobalKeydown) - openPasswordResetFromUrl() - restoreAuthSession() + initializeAppSession() }) onBeforeUnmount(() => { @@ -2961,13 +3057,18 @@ onBeforeUnmount(() => { :password-busy="passwordBusy" :profile-message="profileMessage" :password-message="passwordMessage" + :account-delete-form="accountDeleteForm" + :account-delete-busy="accountDeleteBusy" + :account-delete-message="accountDeleteMessage" :guide-tooltip-reset-message="guideTooltipResetMessage" :carryover-check-policy="carryoverCheckPolicy" @update:profile-field="updateProfileField" @update:password-field="updatePasswordField" + @update:account-delete-field="updateAccountDeleteField" @update:carryover-check-policy="updateCarryoverCheckPolicy" @submit:profile="submitProfileForm" @submit:password="submitPasswordForm" + @submit:delete-account="submitDeleteAccount" @reset-guide-tooltips="resetGuideTooltips" /> diff --git a/src/components/SettingsDashboard.vue b/src/components/SettingsDashboard.vue index 7460cc0..ecb7df9 100644 --- a/src/components/SettingsDashboard.vue +++ b/src/components/SettingsDashboard.vue @@ -14,6 +14,10 @@ const props = defineProps({ type: Object, required: true, }, + accountDeleteForm: { + type: Object, + required: true, + }, profileBusy: { type: Boolean, default: false, @@ -30,6 +34,14 @@ const props = defineProps({ type: String, default: '', }, + accountDeleteBusy: { + type: Boolean, + default: false, + }, + accountDeleteMessage: { + type: String, + default: '', + }, guideTooltipResetMessage: { type: String, default: '', @@ -45,6 +57,8 @@ const emit = defineEmits([ 'update:password-field', 'submit:profile', 'submit:password', + 'update:account-delete-field', + 'submit:delete-account', 'reset-guide-tooltips', 'update:carryover-check-policy', ]) @@ -66,6 +80,13 @@ function updatePasswordField(field, event) { value: event.target.value, }) } + +function updateAccountDeleteField(field, event) { + emit('update:account-delete-field', { + field, + value: event.target.value, + }) +} diff --git a/src/lib/authClient.js b/src/lib/authClient.js index 5503572..a777e8d 100644 --- a/src/lib/authClient.js +++ b/src/lib/authClient.js @@ -108,6 +108,14 @@ export async function updatePassword(token, { currentPassword, newPassword }) { }) } +export async function deleteAccount(token, { currentPassword }) { + return request('/api/auth/account', { + method: 'DELETE', + token, + body: { currentPassword }, + }) +} + export async function requestPasswordReset({ email }) { return request('/api/auth/password-reset/request', { method: 'POST', @@ -121,3 +129,10 @@ export async function confirmPasswordReset({ token, newPassword }) { body: { token, newPassword }, }) } + +export async function confirmVerification({ token }) { + return request('/api/auth/verification/confirm', { + method: 'POST', + body: { token }, + }) +}