Compare commits

...

2 Commits

9 changed files with 132 additions and 15 deletions

View File

@@ -4,7 +4,7 @@
- 프로젝트명: 10 Minute Planner 웹 UI
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
- 현재 기준 버전: `v0.1.44` 준비 중
- 현재 기준 버전: `v0.1.46` 준비 중
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
## 기준 디자인
@@ -196,7 +196,7 @@
- 모바일 대응 이후 인쇄에서 `TIME TABLE`이 사라지던 문제를 막기 위해, print 시에는 `PlannerPage` 내부 레이아웃을 다시 가로 배치로 고정하고 타임테이블 오버플로를 해제하도록 보정했다.
- 인쇄 레이아웃은 추가로 미세 조정해 `COMMENT` 영역이 잘리지 않도록 textarea 높이/행간을 print 전용으로 풀고, `1-UP` / `2-UP` 배율도 프레임 실측 기준으로 다시 계산했다.
- `TODO.md`는 중복 체크 항목을 정리했고, 인증 확장을 위해 `이메일 인증 / 비밀번호 재설정 / rate limit / 메일 인프라` 작업을 별도 항목으로 추가했다.
- Resend 무료 플랜은 도메인 1개 제약이 있어 현재 프로젝트 인증 메일에는 바로 쓰기 어렵다. 다음 단계에서는 AWS SES 또는 범용 SMTP 공급자 기준으로 메일 발송 추상화를 붙이는 쪽이 적합하다.
- 메일 발송은 현재 Resend 기준으로 운영한다. 도메인 정책이나 플랜 조건이 실제 운영과 어긋나는 시점이 오면 그때 대체 수단을 재검토한다.
- 현재 인증 메일/재설정 메일은 실제 발송 대신 개발용 `previewUrl`을 응답으로 돌려주는 단계다. 프론트 UI 연결과 실제 메일러 연결은 다음 단계에서 마무리하면 된다.
- 미니 달력 날짜 버튼은 원형 비율이 흔들리지 않도록 고정 `width/height` 기준으로 다시 맞췄다.
- 플래너 본문 D-DAY 텍스트는 3줄까지만 보이고, 넘치면 말줄임 처리되도록 정리했다.
@@ -223,12 +223,16 @@
- 왼쪽 사이드바의 인쇄 영역은 `PRINT` 버튼 하나로 줄이고, 클릭 시 모달에서 시작일/종료일과 `1페이지씩` 또는 `2페이지씩` 출력 방식을 선택한다. 인쇄 전용 렌더링은 선택 기간의 날짜를 순서대로 여러 장 생성하며, 2페이지씩 출력에서 홀수 날짜가 남으면 오른쪽은 빈 페이지 프레임으로 둔다.
- 회원가입과 프로필 수정 시 이메일뿐 아니라 닉네임 중복도 서버에서 409로 막는다. 비밀번호 재설정 API는 로그인 모달의 `비밀번호 찾기` 흐름과 연결되어 있고, `/reset-password?token=...` URL로 들어오면 새 비밀번호 설정 모드가 열린다. 실제 메일 발송 전까지는 백엔드 응답의 `resetPreviewUrl`을 개발용 링크로 표시한다.
- Resend 메일러가 추가되었다. `RESEND_API_KEY`, `MAIL_FROM_EMAIL`, `MAIL_FROM_NAME`, `APP_BASE_URL` 환경변수를 설정하면 이메일 인증과 비밀번호 재설정 메일을 실제로 발송한다. API 키는 저장소에 커밋하지 말고 루트 `.env`/`.env.dev`에만 넣는다.
- 메일 발송 인프라는 현재 Resend 기준으로 확정했다. 자동 로그아웃 옵션은 플래너를 오래 띄워두는 사용 흐름과 맞지 않아 TODO 대상에서 제외했다.
- 인증/비밀번호 재설정 개발용 링크는 `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 화면에 일반 사용자 전용 `회원 탈퇴` 카드가 추가되었다. 현재 비밀번호 확인 후 계정, 플래너 기록, 목표, 세션, 인증 토큰이 함께 삭제된다. 기본 관리자 계정은 이 경로에서 삭제하지 못하게 막는다.
- `/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 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
@@ -238,7 +242,7 @@
- 날짜에 적용되는 목표가 새로 생기면 D-DAY는 기본 표시된다. 사용자가 해당 날짜에서 직접 `D-DAY 사용`을 끈 경우에만 로컬 숨김 목록에 저장해 다시 숨긴다.
- 비로그인 랜딩은 모바일에서 `카드 안 카드`처럼 보이지 않도록 기능 설명 카드를 얇은 리스트로 단순화했고, `LOGIN` / `SIGN UP` 버튼은 같은 너비와 높이로 맞췄다. 로그인/회원가입 모달도 하단 전환 영역을 별도 카드 대신 구분선 형태로 정리했다.
- 로그인 모달에 `로그인 유지` 체크박스를 추가했다. 기본값은 OFF이며, OFF 상태에서는 인증 토큰을 `sessionStorage`에 저장해 브라우저 세션이 끝나면 사라지고, ON 상태에서만 `localStorage`에 저장한다.
- 현재 로그아웃은 프론트 저장 토큰을 지우는 수준이다. 개인 기록 서비스 성격을 고려하면 다음 단계에서 서버 세션 폐기 API와 미사용 자동 로그아웃 옵션을 추가하는 편이 좋다.
- 현재 로그아웃은 `/api/auth/logout`으로 서버 세션까지 함께 폐기한다. 자동 로그아웃 옵션은 플래너를 오래 열어두는 사용 흐름과 맞지 않아 추가 대상에서 제외했다.
- 비로그인 랜딩에 `DEMO VIEW`를 추가했다. 데모는 실제 저장/로그인 상태와 분리된 읽기 전용 샘플이며, 어제/오늘/내일 3일치 플래너를 전환해서 제품 감각을 먼저 볼 수 있다.
- 플래너 본문 `MEMO``TIME TABLE` 하단 높이를 맞추기 위해 TASK/MEMO 리스트 간격과 행 높이를 조정했다. TASK 드래그 선택 피드백은 레이아웃 흔들림을 줄이도록 ring 대신 배경색만 사용한다.
- 이월된 할 일은 `carryoverFrom` 날짜를 가진다. TASK 본문에는 `이월` 배지를 표시하고, 클릭하면 오른쪽 `READ NEXT` 영역에 원래 시작 날짜를 안내한다.

10
TODO.md
View File

@@ -98,11 +98,9 @@
- [x] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다.
- [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다.
- [x] 로그인 유지 여부를 사용자가 선택할 수 있게 한다.
- [ ] 시간 미사용 시 자동 로그아웃 옵션을 추가한다.
- [ ] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다.
- [ ] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
- [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다.
- [ ] Resend 무료 플랜 대체 수단으로 SES 또는 일반 SMTP 연동 방식을 확정한다.
- [x] 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다.
- [x] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
- [x] 메일 발송 인프라와 발신 도메인 정책을 Resend 기준으로 확정한다.
- [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.
- [ ] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다.
- [ ] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다.
@@ -135,6 +133,6 @@
- 앱을 새로 열면 마지막 열람 날짜가 아니라 항상 오늘 날짜부터 시작하고, Docker/NAS 컨테이너 시간대는 `Asia/Seoul` 기준으로 맞춘다.
- TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다.
- 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다.
- Resend 무료 플랜도메인 제약이 있으므로, 실제 인증 메일은 AWS SES 또는 별도 SMTP 서비스 전환을 전제로 설계하는 편이 안전하다.
- 메일 발송은 현재 Resend 기준으로 운영한다. 무료 플랜/도메인 제약이 바뀌는 시점에만 별도 대체 수단을 다시 검토한다.
- 관리자 기능은 읽기 전용 대시보드부터 시작하고, 실제 정리 액션은 권한/감사 로그 정책을 정한 뒤 추가하는 편이 안전하다.
- 관리자 아이디/비밀번호는 README나 HANDOFF에 실제 값으로 남기지 않고, Docker 배포용 비공개 `.env`에서만 관리한다.

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.46",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ten-minute-planner",
"version": "0.1.44",
"version": "0.1.46",
"dependencies": {
"vue": "^3.5.13"
},

View File

@@ -1,7 +1,7 @@
{
"name": "ten-minute-planner",
"private": true,
"version": "0.1.44",
"version": "0.1.46",
"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',