Compare commits

..

1 Commits

Author SHA1 Message Date
6d4f2228cc v0.1.45 - 로그아웃 API와 세션 상태 안내 추가 2026-04-24 10:39:42 +09:00
9 changed files with 127 additions and 9 deletions

View File

@@ -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 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.

View File

@@ -99,8 +99,8 @@
- [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다.
- [x] 로그인 유지 여부를 사용자가 선택할 수 있게 한다.
- [ ] 일정 시간 미사용 시 자동 로그아웃 옵션을 추가한다.
- [ ] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다.
- [ ] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
- [x] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다.
- [x] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
- [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다.
- [ ] Resend 무료 플랜 대체 수단으로 SES 또는 일반 SMTP 연동 방식을 확정한다.
- [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.

View File

@@ -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
}

View File

@@ -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)

4
package-lock.json generated
View File

@@ -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"
},

View File

@@ -1,7 +1,7 @@
{
"name": "ten-minute-planner",
"private": true,
"version": "0.1.44",
"version": "0.1.45",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -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"

View File

@@ -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) {
</button>
</div>
</div>
<div class="mt-4 rounded-[24px] border border-stone-200 bg-white/80 p-4">
<p class="text-[10px] font-bold tracking-[0.18em] text-stone-500">LOGIN STATUS</p>
<div class="mt-3 space-y-3">
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">현재 기기 저장 방식</p>
<p class="mt-2 text-sm font-semibold leading-6 text-stone-800">{{ authSessionInfo.storageLabel }}</p>
<p class="mt-1 text-xs font-semibold leading-5 text-stone-500">{{ authSessionInfo.storageDescription }}</p>
</div>
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3">
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">최근 로그인</p>
<p class="mt-2 text-sm font-semibold leading-6 text-stone-800">{{ authSessionInfo.lastLoginLabel }}</p>
</div>
<div
v-if="authSessionInfo.verificationLabel"
class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-3"
>
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">이메일 인증</p>
<p class="mt-2 text-sm font-semibold leading-6 text-stone-800">{{ authSessionInfo.verificationLabel }}</p>
</div>
</div>
</div>
</aside>
<div class="grid gap-6">

View File

@@ -92,6 +92,13 @@ export async function fetchCurrentUser(token) {
})
}
export async function logout(token) {
return request('/api/auth/logout', {
method: 'POST',
token,
})
}
export async function updateProfile(token, { email, nickname }) {
return request('/api/auth/profile', {
method: 'PUT',