diff --git a/HANDOFF.md b/HANDOFF.md index 394bf3d..4f5a419 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.37` 준비 중 +- 현재 기준 버전: `v0.1.38` 준비 중 - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -80,6 +80,8 @@ - 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다. - 백엔드 초안은 `Fastify + Drizzle + PostgreSQL` 조합으로 전환되었다. - 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다. +- 백엔드에는 `/api/auth/verification/request`, `/api/auth/verification/confirm` 이메일 인증 토큰 API가 추가되었다. +- 백엔드에는 `/api/auth/password-reset/request`, `/api/auth/password-reset/confirm` 비밀번호 재설정 토큰 API가 추가되었다. - 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다. - 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다. - 초기 실행 시 `backend/src/db/init.js`에서 테이블이 없으면 자동 생성하도록 맞춰두었다. @@ -190,7 +192,10 @@ - 달력 날짜 버튼은 의도상 원형이며, 현재는 `size` 고정 기준으로 다시 맞춰서 타원처럼 보이는 인상을 줄이는 방향으로 정리했다. - 플래너 본문, 2페이지 펼침, 인쇄 전용 `PlannerPage` 모두 `총 시간` 값은 `00시간 00분` 한글 포맷으로 통일했다. - 모바일 대응 이후 인쇄에서 `TIME TABLE`이 사라지던 문제를 막기 위해, print 시에는 `PlannerPage` 내부 레이아웃을 다시 가로 배치로 고정하고 타임테이블 오버플로를 해제하도록 보정했다. +- 인쇄 레이아웃은 추가로 미세 조정해 `COMMENT` 영역이 잘리지 않도록 textarea 높이/행간을 print 전용으로 풀고, `1-UP` / `2-UP` 배율도 프레임 실측 기준으로 다시 계산했다. - `TODO.md`는 중복 체크 항목을 정리했고, 인증 확장을 위해 `이메일 인증 / 비밀번호 재설정 / rate limit / 메일 인프라` 작업을 별도 항목으로 추가했다. +- Resend 무료 플랜은 도메인 1개 제약이 있어 현재 프로젝트 인증 메일에는 바로 쓰기 어렵다. 다음 단계에서는 AWS SES 또는 범용 SMTP 공급자 기준으로 메일 발송 추상화를 붙이는 쪽이 적합하다. +- 현재 인증 메일/재설정 메일은 실제 발송 대신 개발용 `previewUrl`을 응답으로 돌려주는 단계다. 프론트 UI 연결과 실제 메일러 연결은 다음 단계에서 마무리하면 된다. - 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다. - 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. diff --git a/TODO.md b/TODO.md index 12976bd..215818d 100644 --- a/TODO.md +++ b/TODO.md @@ -93,6 +93,7 @@ - [ ] 비밀번호 찾기 / 재설정 토큰 흐름을 추가한다. - [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다. - [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다. +- [ ] Resend 무료 플랜 대체 수단으로 SES 또는 일반 SMTP 연동 방식을 확정한다. ## 메모 @@ -123,3 +124,4 @@ - 표시 기간이 다른 진행 중 목표와 겹치면 프론트와 백엔드 모두 저장을 막는다. - TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. +- Resend 무료 플랜은 도메인 수 제약이 있으므로, 실제 인증 메일은 AWS SES 또는 별도 SMTP 서비스 전환을 전제로 설계하는 편이 안전하다. diff --git a/backend/src/config.js b/backend/src/config.js index 38ee907..a8cccc3 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -13,6 +13,7 @@ const envSchema = z.object({ DATABASE_URL: z.string().min(1).default('postgresql://planner:planner1234@localhost:5432/ten_minute_planner'), CORS_ORIGIN: z.string().default('http://localhost:5173'), SESSION_TTL_DAYS: z.coerce.number().default(30), + APP_BASE_URL: z.string().default('http://localhost:5173'), }) export const env = envSchema.parse(process.env) diff --git a/backend/src/db/init.js b/backend/src/db/init.js index a63a325..75c79e2 100644 --- a/backend/src/db/init.js +++ b/backend/src/db/init.js @@ -7,10 +7,14 @@ export async function ensureDatabaseSchema() { email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, nickname VARCHAR(60) NOT NULL, + email_verified_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); + ALTER TABLE users + ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ; + CREATE TABLE IF NOT EXISTS auth_sessions ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -51,5 +55,29 @@ export async function ensureDatabaseSchema() { CREATE INDEX IF NOT EXISTS goals_user_id_idx ON goals (user_id); + + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL + ); + + CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx + ON email_verification_tokens (user_id); + + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL + ); + + CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx + ON password_reset_tokens (user_id); `) } diff --git a/backend/src/db/schema.js b/backend/src/db/schema.js index c5cbc44..0ae7c61 100644 --- a/backend/src/db/schema.js +++ b/backend/src/db/schema.js @@ -14,6 +14,7 @@ export const users = pgTable('users', { email: varchar('email', { length: 255 }).notNull().unique(), passwordHash: varchar('password_hash', { length: 255 }).notNull(), nickname: varchar('nickname', { length: 60 }).notNull(), + emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(), }) @@ -65,3 +66,33 @@ export const goals = pgTable( userIndex: index('goals_user_id_idx').on(table.userId), }), ) + +export const emailVerificationTokens = pgTable( + 'email_verification_tokens', + { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + usedAt: timestamp('used_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + }, + (table) => ({ + userIndex: index('email_verification_tokens_user_id_idx').on(table.userId), + }), +) + +export const passwordResetTokens = pgTable( + 'password_reset_tokens', + { + id: serial('id').primaryKey(), + userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + usedAt: timestamp('used_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull(), + }, + (table) => ({ + userIndex: index('password_reset_tokens_user_id_idx').on(table.userId), + }), +) diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 303673b..6b7e902 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,8 +1,9 @@ -import { eq } from 'drizzle-orm' +import { and, eq, gt, isNull } from 'drizzle-orm' +import { env } from '../config.js' import { z } from 'zod' import { db } from '../db/client.js' -import { users } from '../db/schema.js' -import { hashPassword, verifyPassword } from '../lib/password.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' const signupSchema = z.object({ @@ -26,11 +27,79 @@ const passwordSchema = z.object({ newPassword: z.string().min(8).max(72), }) +const verificationRequestSchema = z.object({ + email: z.string().trim().email().optional(), +}) + +const verificationConfirmSchema = z.object({ + token: z.string().trim().min(20).max(255), +}) + +const passwordResetRequestSchema = z.object({ + email: z.string().trim().email(), +}) + +const passwordResetConfirmSchema = z.object({ + token: z.string().trim().min(20).max(255), + newPassword: z.string().min(8).max(72), +}) + +const TOKEN_TTL_MS = 1000 * 60 * 30 + +function buildPreviewUrl(pathname, token) { + const url = new URL(pathname, env.APP_BASE_URL) + url.searchParams.set('token', token) + return url.toString() +} + +async function createEmailVerificationToken(userId) { + const token = createSessionToken() + const tokenHash = hashSessionToken(token) + const now = new Date() + const expiresAt = new Date(now.getTime() + TOKEN_TTL_MS) + + await db.delete(emailVerificationTokens).where(eq(emailVerificationTokens.userId, userId)) + + await db.insert(emailVerificationTokens).values({ + userId, + tokenHash, + expiresAt, + createdAt: now, + }) + + return { + token, + previewUrl: buildPreviewUrl('/verify-email', token), + } +} + +async function createPasswordResetToken(userId) { + const token = createSessionToken() + const tokenHash = hashSessionToken(token) + const now = new Date() + const expiresAt = new Date(now.getTime() + TOKEN_TTL_MS) + + await db.delete(passwordResetTokens).where(eq(passwordResetTokens.userId, userId)) + + await db.insert(passwordResetTokens).values({ + userId, + tokenHash, + expiresAt, + createdAt: now, + }) + + return { + token, + previewUrl: buildPreviewUrl('/reset-password', token), + } +} + function sanitizeUser(user) { return { id: user.id, email: user.email, nickname: user.nickname, + emailVerifiedAt: user.emailVerifiedAt, createdAt: user.createdAt, updatedAt: user.updatedAt, } @@ -71,17 +140,20 @@ export async function registerAuthRoutes(app) { email: normalizedEmail, passwordHash, nickname, + emailVerifiedAt: null, createdAt: now, updatedAt: now, }) .returning() const { token } = await createSession(user.id) + const verification = await createEmailVerificationToken(user.id) return reply.code(201).send({ message: '회원가입이 완료되었습니다.', token, user: sanitizeUser(user), + verificationPreviewUrl: verification.previewUrl, }) }) @@ -228,4 +300,188 @@ export async function registerAuthRoutes(app) { message: '비밀번호가 변경되었습니다.', } }) + + app.post('/api/auth/verification/request', async (request, reply) => { + const authenticatedUser = await findAuthenticatedUser(request) + const payload = verificationRequestSchema.safeParse(request.body ?? {}) + + if (!payload.success) { + return reply.code(400).send({ + message: '이메일 입력값이 올바르지 않습니다.', + issues: payload.error.flatten(), + }) + } + + const normalizedEmail = (payload.data.email || authenticatedUser?.email || '').toLowerCase() + + if (!normalizedEmail) { + return reply.code(400).send({ + message: '인증 메일을 받을 이메일이 필요합니다.', + }) + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.email, normalizedEmail)) + .limit(1) + + if (!user) { + return { + message: '입력한 이메일로 인증 안내를 보낼 준비가 되면 처리됩니다.', + } + } + + if (user.emailVerifiedAt) { + return { + message: '이미 이메일 인증이 완료된 계정입니다.', + } + } + + const verification = await createEmailVerificationToken(user.id) + + return { + message: '이메일 인증 링크를 준비했습니다.', + verificationPreviewUrl: verification.previewUrl, + } + }) + + app.post('/api/auth/verification/confirm', async (request, reply) => { + const payload = verificationConfirmSchema.safeParse(request.body) + + if (!payload.success) { + return reply.code(400).send({ + message: '인증 토큰이 올바르지 않습니다.', + issues: payload.error.flatten(), + }) + } + + const tokenHash = hashSessionToken(payload.data.token) + + const [verification] = await db + .select() + .from(emailVerificationTokens) + .where( + and( + eq(emailVerificationTokens.tokenHash, tokenHash), + isNull(emailVerificationTokens.usedAt), + gt(emailVerificationTokens.expiresAt, new Date()), + ), + ) + .limit(1) + + if (!verification) { + return reply.code(400).send({ + message: '이미 사용했거나 만료된 인증 링크입니다.', + }) + } + + const now = new Date() + + await db + .update(users) + .set({ + emailVerifiedAt: now, + updatedAt: now, + }) + .where(eq(users.id, verification.userId)) + + await db + .update(emailVerificationTokens) + .set({ + usedAt: now, + }) + .where(eq(emailVerificationTokens.id, verification.id)) + + return { + message: '이메일 인증이 완료되었습니다.', + } + }) + + app.post('/api/auth/password-reset/request', async (request, reply) => { + const payload = passwordResetRequestSchema.safeParse(request.body) + + if (!payload.success) { + return reply.code(400).send({ + message: '이메일 입력값이 올바르지 않습니다.', + issues: payload.error.flatten(), + }) + } + + const normalizedEmail = payload.data.email.toLowerCase() + + const [user] = await db + .select() + .from(users) + .where(eq(users.email, normalizedEmail)) + .limit(1) + + if (!user) { + return { + message: '입력한 이메일로 재설정 안내를 보낼 준비가 되면 처리됩니다.', + } + } + + const reset = await createPasswordResetToken(user.id) + + return { + message: '비밀번호 재설정 링크를 준비했습니다.', + resetPreviewUrl: reset.previewUrl, + } + }) + + app.post('/api/auth/password-reset/confirm', async (request, reply) => { + const payload = passwordResetConfirmSchema.safeParse(request.body) + + if (!payload.success) { + return reply.code(400).send({ + message: '비밀번호 재설정 입력값이 올바르지 않습니다.', + issues: payload.error.flatten(), + }) + } + + const tokenHash = hashSessionToken(payload.data.token) + + const [resetToken] = await db + .select() + .from(passwordResetTokens) + .where( + and( + eq(passwordResetTokens.tokenHash, tokenHash), + isNull(passwordResetTokens.usedAt), + gt(passwordResetTokens.expiresAt, new Date()), + ), + ) + .limit(1) + + if (!resetToken) { + return reply.code(400).send({ + message: '이미 사용했거나 만료된 재설정 링크입니다.', + }) + } + + const now = new Date() + const passwordHash = await hashPassword(payload.data.newPassword) + + await db + .update(users) + .set({ + passwordHash, + updatedAt: now, + }) + .where(eq(users.id, resetToken.userId)) + + await db + .update(passwordResetTokens) + .set({ + usedAt: now, + }) + .where(eq(passwordResetTokens.id, resetToken.id)) + + await db.delete(authSessions).where(eq(authSessions.userId, resetToken.userId)) + + return { + message: '비밀번호가 재설정되었습니다. 다시 로그인해 주세요.', + } + }) } diff --git a/package-lock.json b/package-lock.json index 7f4d2ee..22e0822 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.37", + "version": "0.1.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.37", + "version": "0.1.38", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 83061a2..67b721c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.37", + "version": "0.1.38", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/PlannerPage.vue b/src/components/PlannerPage.vue index 7efbe04..a43e208 100644 --- a/src/components/PlannerPage.vue +++ b/src/components/PlannerPage.vue @@ -147,7 +147,7 @@ onBeforeUnmount(() => { />
- FOCUSED TIME + 총 시간

{{ totalTime }}

diff --git a/src/style.css b/src/style.css index 178c5de..892af5d 100644 --- a/src/style.css +++ b/src/style.css @@ -22,6 +22,10 @@ } @media print { + @page { + margin: 0; + } + html, body { background: #ffffff !important; @@ -125,7 +129,7 @@ width: 762px !important; max-width: none !important; transform-origin: top left; - transform: scale(0.978); + transform: scale(0.982); box-shadow: none !important; background: #ffffff !important; } @@ -134,7 +138,7 @@ width: 762px !important; max-width: none !important; transform-origin: top left; - transform: scale(0.684); + transform: scale(0.6894); box-shadow: none !important; background: #ffffff !important; } @@ -152,6 +156,17 @@ gap: 16px !important; } + .planner-sheet__meta { + gap: 14px !important; + padding-top: 10px !important; + padding-bottom: 10px !important; + } + + .planner-sheet__meta-top > div, + .planner-sheet__meta-bottom > div { + min-height: 78px !important; + } + .planner-sheet__lists { width: 394px !important; flex: 1 1 auto !important; @@ -170,6 +185,19 @@ .planner-sheet__timetable-grid { min-width: 210px !important; } + + .planner-sheet textarea { + height: auto !important; + min-height: 48px !important; + overflow: visible !important; + padding-top: 4px !important; + line-height: 1.55 !important; + } + + .print-paper--double .print-sheet-frame { + align-items: flex-start !important; + justify-content: flex-start !important; + } } @media screen {