diff --git a/HANDOFF.md b/HANDOFF.md index 42ed120..a957e99 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.44` 준비 중 +- 현재 기준 버전: `v0.1.45` 준비 중 - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -229,6 +229,9 @@ - 비밀번호 재설정/이메일 인증용 개발 링크는 `AUTH_PREVIEW_LINKS=true`여도 `APP_BASE_URL`이 `localhost` 또는 `127.0.0.1`일 때만 응답에 포함한다. 상용 서버에서 링크가 그대로 보이면 이 조건부터 확인한다. - 프론트는 `/verify-email?token=...` 진입 시 인증을 바로 확정하고 로그인 모달에 결과 메시지를 띄운다. `/reset-password?token=...`은 기존처럼 비밀번호 재설정 모달을 연다. - SETTINGS 화면에 일반 사용자 전용 `회원 탈퇴` 카드가 추가되었다. 현재 비밀번호 확인 후 계정, 플래너 기록, 목표, 세션, 인증 토큰이 함께 삭제된다. 기본 관리자 계정은 이 경로에서 삭제하지 못하게 막는다. +- `/api/auth/logout`이 추가되어 로그아웃 시 프론트 저장 토큰만 지우는 것이 아니라 서버 세션도 함께 폐기한다. +- SETTINGS 화면 왼쪽 카드에 현재 기기 로그인 유지 방식(`로그인 유지` 또는 브라우저 세션만 유지), 최근 로그인 시각, 이메일 인증 상태를 보여준다. +- 현재 로그인 유지 방식은 `authPersist` 상태로 함께 들고 가며, 프로필 저장 후에도 원래 저장 방식(localStorage/sessionStorage)을 유지하도록 정리했다. - `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/TODO.md b/TODO.md index 637a28e..199ce42 100644 --- a/TODO.md +++ b/TODO.md @@ -99,8 +99,8 @@ - [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다. - [x] 로그인 유지 여부를 사용자가 선택할 수 있게 한다. - [ ] 일정 시간 미사용 시 자동 로그아웃 옵션을 추가한다. -- [ ] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다. -- [ ] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다. +- [x] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다. +- [x] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다. - [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다. - [ ] Resend 무료 플랜 대체 수단으로 SES 또는 일반 SMTP 연동 방식을 확정한다. - [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다. diff --git a/backend/src/lib/authSession.js b/backend/src/lib/authSession.js index f8a9a8b..a905a1e 100644 --- a/backend/src/lib/authSession.js +++ b/backend/src/lib/authSession.js @@ -14,6 +14,10 @@ function getBearerToken(request) { return authorization.slice('Bearer '.length).trim() } +export function getSessionTokenFromRequest(request) { + return getBearerToken(request) +} + export async function createSession(userId) { const token = createSessionToken() const tokenHash = hashSessionToken(token) @@ -68,3 +72,17 @@ export async function findAuthenticatedUser(request) { return user ?? null } + +export async function revokeSessionByToken(token) { + if (!token) { + return false + } + + const tokenHash = hashSessionToken(token) + const deletedSessions = await db + .delete(authSessions) + .where(eq(authSessions.tokenHash, tokenHash)) + .returning({ id: authSessions.id }) + + return deletedSessions.length > 0 +} diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 0fcc23b..4a25fb0 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -3,7 +3,7 @@ import { z } from 'zod' import { db } from '../db/client.js' import { authSessions, emailVerificationTokens, passwordResetTokens, users } from '../db/schema.js' import { createSessionToken, hashSessionToken, hashPassword, verifyPassword } from '../lib/password.js' -import { createSession, findAuthenticatedUser } from '../lib/authSession.js' +import { createSession, findAuthenticatedUser, getSessionTokenFromRequest, revokeSessionByToken } from '../lib/authSession.js' import { sendPasswordResetEmail, sendVerificationEmail } from '../lib/mailer.js' import { env } from '../config.js' @@ -279,6 +279,22 @@ export async function registerAuthRoutes(app) { } }) + app.post('/api/auth/logout', async (request, reply) => { + const token = getSessionTokenFromRequest(request) + + if (!token) { + return reply.code(401).send({ + message: '인증이 필요합니다.', + }) + } + + await revokeSessionByToken(token) + + return { + message: '로그아웃되었습니다.', + } + }) + app.put('/api/auth/profile', async (request, reply) => { const user = await findAuthenticatedUser(request) diff --git a/package-lock.json b/package-lock.json index 60f4492..2f39197 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.44", + "version": "0.1.45", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.44", + "version": "0.1.45", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index b367bb7..299394b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.44", + "version": "0.1.45", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index e7c8aac..34f6933 100644 --- a/src/App.vue +++ b/src/App.vue @@ -14,6 +14,7 @@ import { confirmVerification, deleteAccount, fetchCurrentUser, + logout as logoutRequest, confirmPasswordReset, login, persistAuthState, @@ -47,6 +48,7 @@ const authMode = ref('login') const authBusy = ref(false) const authMessage = ref('') const authToken = ref('') +const authPersist = ref(false) const currentUser = ref(null) const goals = ref([]) const goalQuery = ref('') @@ -622,6 +624,18 @@ const markedDateKeys = computed(() => const isAuthenticated = computed(() => Boolean(authToken.value && currentUser.value)) const isAdmin = computed(() => currentUser.value?.role === 'admin') +const authSessionInfo = computed(() => ({ + storageLabel: authPersist.value ? '이 기기에서 로그인 유지 중' : '브라우저를 닫으면 로그아웃', + storageDescription: authPersist.value + ? '현재 기기에서는 localStorage에 로그인 상태를 저장합니다.' + : '현재 기기에서는 sessionStorage에만 로그인 상태를 유지합니다.', + lastLoginLabel: formatSessionDate(currentUser.value?.lastLoginAt), + verificationLabel: currentUser.value?.role === 'admin' + ? '관리자 기본 계정' + : currentUser.value?.emailVerifiedAt + ? '이메일 인증 완료' + : '이메일 인증 필요', +})) const filteredGoals = computed(() => { const query = goalQuery.value.trim().toLowerCase() return goals.value.filter((goal) => { @@ -1443,6 +1457,20 @@ function syncProfileForm() { profileForm.email = currentUser.value?.email ?? '' } +function formatSessionDate(value) { + if (!value) { + return '기록이 없습니다.' + } + + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return '기록이 없습니다.' + } + + return `${date.getFullYear()}. ${`${date.getMonth() + 1}`.padStart(2, '0')}. ${`${date.getDate()}`.padStart(2, '0')} ${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(2, '0')}` +} + function resetPasswordForm() { passwordForm.currentPassword = '' passwordForm.newPassword = '' @@ -1531,6 +1559,7 @@ function updateAuthField({ field, value }) { async function applyAuthSuccess(data, persist = false) { authToken.value = data.token + authPersist.value = persist currentUser.value = data.user setSyncFeedback('cloud', '클라우드 동기화 연결됨') persistAuthState({ @@ -1607,6 +1636,7 @@ async function restoreAuthSession() { } authToken.value = savedAuth.token + authPersist.value = savedAuth.persist currentUser.value = savedAuth.user ?? null try { @@ -1624,6 +1654,7 @@ async function restoreAuthSession() { syncProfileForm() } catch (error) { authToken.value = '' + authPersist.value = false currentUser.value = null setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', { visible: false, @@ -1632,9 +1663,10 @@ async function restoreAuthSession() { } } -function logout() { +function clearAuthenticatedState() { clearPendingSyncTimers() authToken.value = '' + authPersist.value = false currentUser.value = null goals.value = [] goalQuery.value = '' @@ -1665,6 +1697,20 @@ function logout() { resetAccountDeleteForm() } +async function logout() { + const token = authToken.value + + if (token) { + try { + await logoutRequest(token) + } catch (error) { + console.warn('서버 로그아웃 처리에 실패했습니다.', error) + } + } + + clearAuthenticatedState() +} + async function loadAdminDashboard() { if (!authToken.value || !isAdmin.value) { adminUsers.value = [] @@ -1880,6 +1926,7 @@ async function submitProfileForm() { persistAuthState({ token: authToken.value, user: result.user, + persist: authPersist.value, }) syncProfileForm() await loadAdminDashboard() @@ -1942,7 +1989,7 @@ async function submitDeleteAccount() { currentPassword: accountDeleteForm.currentPassword, }) resetAccountDeleteForm() - logout() + clearAuthenticatedState() authMessage.value = '' window.alert(result.message || '회원 탈퇴가 완료되었습니다.') } catch (error) { @@ -3051,6 +3098,7 @@ onBeforeUnmount(() => { :account-delete-form="accountDeleteForm" :account-delete-busy="accountDeleteBusy" :account-delete-message="accountDeleteMessage" + :auth-session-info="authSessionInfo" :guide-tooltip-reset-message="guideTooltipResetMessage" :carryover-check-policy="carryoverCheckPolicy" @update:profile-field="updateProfileField" diff --git a/src/components/SettingsDashboard.vue b/src/components/SettingsDashboard.vue index ecb7df9..0a0ae7b 100644 --- a/src/components/SettingsDashboard.vue +++ b/src/components/SettingsDashboard.vue @@ -18,6 +18,10 @@ const props = defineProps({ type: Object, required: true, }, + authSessionInfo: { + type: Object, + required: true, + }, profileBusy: { type: Boolean, default: false, @@ -151,6 +155,28 @@ function updateAccountDeleteField(field, event) { + +
LOGIN STATUS
+현재 기기 저장 방식
+{{ authSessionInfo.storageLabel }}
+{{ authSessionInfo.storageDescription }}
+최근 로그인
+{{ authSessionInfo.lastLoginLabel }}
+이메일 인증
+{{ authSessionInfo.verificationLabel }}
+