From 2a39ee03e5cad4bf7d2d72ded0a1963db68475c5 Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 3 Apr 2026 11:35:10 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.4.45=20?= =?UTF-8?q?=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=9E=AC=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/package-lock.json | 10 ++ backend/package.json | 1 + backend/src/db.js | 179 ++++++++++++++++++-- backend/src/lib/mailer.js | 113 +++++++++++++ backend/src/routes/auth.js | 206 ++++++++++++++++++++++- docker-compose.prod.yml | 7 + docs/history.md | 5 + docs/map.md | 5 +- docs/spec.md | 39 +++++ docs/todo.md | 7 + docs/update.md | 5 + frontend/src/lib/api.js | 5 + frontend/src/stores/auth.js | 24 ++- frontend/src/views/LoginView.vue | 273 +++++++++++++++++++++++++++---- 14 files changed, 827 insertions(+), 52 deletions(-) create mode 100644 backend/src/lib/mailer.js diff --git a/backend/package-lock.json b/backend/package-lock.json index a811ffc..6017748 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "multer": "^2.1.1", "mysql2": "^3.20.0", "nanoid": "^5.1.7", + "nodemailer": "^8.0.4", "session-file-store": "^1.5.0", "sharp": "^0.34.5", "zod": "^4.3.6" @@ -1594,6 +1595,15 @@ "node": ">= 0.6" } }, + "node_modules/nodemailer": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz", + "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/nodemon": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", diff --git a/backend/package.json b/backend/package.json index 37e0aa0..85eb687 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,7 @@ "multer": "^2.1.1", "mysql2": "^3.20.0", "nanoid": "^5.1.7", + "nodemailer": "^8.0.4", "session-file-store": "^1.5.0", "sharp": "^0.34.5", "zod": "^4.3.6" diff --git a/backend/src/db.js b/backend/src/db.js index 1c929d0..f329632 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -59,6 +59,7 @@ function mapUserRow(row) { id: row.id, email: row.email, nickname: row.nickname || '', + emailVerified: row.email_verified == null ? true : !!row.email_verified, isAdmin: !!row.is_admin, avatarSrc: row.avatar_src || '', createdAt: Number(row.created_at), @@ -275,12 +276,47 @@ async function ensureSchema() { email VARCHAR(255) NOT NULL UNIQUE, nickname VARCHAR(80) NOT NULL DEFAULT '', password_hash VARCHAR(255) NOT NULL, + email_verified TINYINT(1) NOT NULL DEFAULT 1, is_admin TINYINT(1) NOT NULL DEFAULT 0, avatar_src VARCHAR(255) NOT NULL DEFAULT '', created_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + const userEmailVerifiedColumns = await query("SHOW COLUMNS FROM users LIKE 'email_verified'") + if (!userEmailVerifiedColumns.length) { + await query('ALTER TABLE users ADD COLUMN email_verified TINYINT(1) NOT NULL DEFAULT 1 AFTER password_hash') + await query('UPDATE users SET email_verified = 1 WHERE email_verified IS NULL') + } + + await query(` + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expires_at BIGINT NOT NULL, + consumed_at BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + INDEX idx_email_verification_user (user_id, consumed_at, expires_at), + INDEX idx_email_verification_expires (expires_at), + CONSTRAINT fk_email_verification_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) + + await query(` + CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + token_hash CHAR(64) NOT NULL UNIQUE, + expires_at BIGINT NOT NULL, + consumed_at BIGINT NOT NULL DEFAULT 0, + created_at BIGINT NOT NULL, + INDEX idx_password_reset_user (user_id, consumed_at, expires_at), + INDEX idx_password_reset_expires (expires_at), + CONSTRAINT fk_password_reset_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + `) + await query(` CREATE TABLE IF NOT EXISTS topics ( id VARCHAR(120) PRIMARY KEY, @@ -567,7 +603,7 @@ async function countUsers() { async function findUserByEmail(email) { const rows = await query( - 'SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1', + 'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1', [email] ) const row = rows[0] @@ -581,7 +617,7 @@ async function findUserByNickname(nickname, excludeUserId = '') { const rows = excludeUserId ? await query( ` - SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at + SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at FROM users WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ? LIMIT 1 @@ -590,7 +626,7 @@ async function findUserByNickname(nickname, excludeUserId = '') { ) : await query( ` - SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at + SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at FROM users WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) LIMIT 1 @@ -604,24 +640,138 @@ async function findUserByNickname(nickname, excludeUserId = '') { async function findUserById(id) { const rows = await query( - 'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1', + 'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1', [id] ) return mapUserRow(rows[0]) } -async function createUser({ id, email, nickname, passwordHash, isAdmin }) { +async function createUser({ id, email, nickname, passwordHash, emailVerified = true, isAdmin }) { const createdAt = now() await query( ` - INSERT INTO users (id, email, nickname, password_hash, is_admin, avatar_src, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) `, - [id, email, nickname || '', passwordHash, isAdmin ? 1 : 0, '', createdAt] + [id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', createdAt] ) return findUserById(id) } +async function updateUserPassword({ id, passwordHash }) { + await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id]) + return findUserById(id) +} + +async function verifyUserEmail(userId) { + await query('UPDATE users SET email_verified = 1 WHERE id = ?', [userId]) + return findUserById(userId) +} + +async function createEmailVerificationToken({ id, userId, tokenHash, expiresAt }) { + const createdAt = now() + await query('UPDATE email_verification_tokens SET consumed_at = ? WHERE user_id = ? AND consumed_at = 0', [createdAt, userId]) + await query( + ` + INSERT INTO email_verification_tokens (id, user_id, token_hash, expires_at, consumed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `, + [id, userId, tokenHash, expiresAt, 0, createdAt] + ) + return { + id, + userId, + tokenHash, + expiresAt, + consumedAt: 0, + createdAt, + } +} + +async function findEmailVerificationTokenByHash(tokenHash) { + const rows = await query( + ` + SELECT + id, + user_id, + token_hash, + expires_at, + consumed_at, + created_at + FROM email_verification_tokens + WHERE token_hash = ? + LIMIT 1 + `, + [tokenHash] + ) + const row = rows[0] + if (!row) return null + return { + id: row.id, + userId: row.user_id, + tokenHash: row.token_hash, + expiresAt: Number(row.expires_at || 0), + consumedAt: Number(row.consumed_at || 0), + createdAt: Number(row.created_at || 0), + } +} + +async function consumeEmailVerificationToken(tokenId) { + await query('UPDATE email_verification_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId]) +} + +async function createPasswordResetToken({ id, userId, tokenHash, expiresAt }) { + const createdAt = now() + await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE user_id = ? AND consumed_at = 0', [createdAt, userId]) + await query( + ` + INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at, consumed_at, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `, + [id, userId, tokenHash, expiresAt, 0, createdAt] + ) + return { + id, + userId, + tokenHash, + expiresAt, + consumedAt: 0, + createdAt, + } +} + +async function findPasswordResetTokenByHash(tokenHash) { + const rows = await query( + ` + SELECT + id, + user_id, + token_hash, + expires_at, + consumed_at, + created_at + FROM password_reset_tokens + WHERE token_hash = ? + LIMIT 1 + `, + [tokenHash] + ) + const row = rows[0] + if (!row) return null + return { + id: row.id, + userId: row.user_id, + tokenHash: row.token_hash, + expiresAt: Number(row.expires_at || 0), + consumedAt: Number(row.consumed_at || 0), + createdAt: Number(row.created_at || 0), + } +} + +async function consumePasswordResetToken(tokenId) { + await query('UPDATE password_reset_tokens SET consumed_at = ? WHERE id = ? AND consumed_at = 0', [now(), tokenId]) +} + async function updateUserProfile({ id, nickname, avatarSrc }) { if (typeof avatarSrc === 'string') { await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id]) @@ -633,7 +783,7 @@ async function updateUserProfile({ id, nickname, avatarSrc }) { async function findPrimaryAdminUser() { const rows = await query( - 'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1' + 'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1' ) return mapUserRow(rows[0]) } @@ -668,6 +818,7 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' } u.id, u.email, u.nickname, + u.email_verified, u.is_admin, u.avatar_src, u.created_at, @@ -679,7 +830,7 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' } FROM users u LEFT JOIN tierlists t ON t.author_id = u.id ${where.length ? `WHERE ${where.join(' AND ')}` : ''} - GROUP BY u.id, u.email, u.nickname, u.is_admin, u.avatar_src, u.created_at + GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.created_at ORDER BY ${orderBy} `, params @@ -2501,6 +2652,14 @@ module.exports = { findUserByNickname, findUserById, createUser, + updateUserPassword, + verifyUserEmail, + createEmailVerificationToken, + findEmailVerificationTokenByHash, + consumeEmailVerificationToken, + createPasswordResetToken, + findPasswordResetTokenByHash, + consumePasswordResetToken, updateUserProfile, findPrimaryAdminUser, listUsers, diff --git a/backend/src/lib/mailer.js b/backend/src/lib/mailer.js new file mode 100644 index 0000000..f4a766e --- /dev/null +++ b/backend/src/lib/mailer.js @@ -0,0 +1,113 @@ +const nodemailer = require('nodemailer') + +const SMTP_HOST = process.env.SMTP_HOST || 'smtp.gmail.com' +const SMTP_PORT = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 465 +const SMTP_SECURE = process.env.SMTP_SECURE ? process.env.SMTP_SECURE === 'true' : SMTP_PORT === 465 +const SMTP_USER = process.env.SMTP_USER || '' +const SMTP_PASS = process.env.SMTP_PASS || '' +const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER + +let transporterPromise = null + +function isMailerConfigured() { + return !!SMTP_USER && !!SMTP_PASS && !!SMTP_FROM +} + +async function getTransporter() { + if (!isMailerConfigured()) { + const error = new Error('mail_not_configured') + error.code = 'mail_not_configured' + throw error + } + + if (!transporterPromise) { + transporterPromise = (async () => { + const transporter = nodemailer.createTransport({ + host: SMTP_HOST, + port: SMTP_PORT, + secure: SMTP_SECURE, + auth: { + user: SMTP_USER, + pass: SMTP_PASS, + }, + }) + await transporter.verify() + return transporter + })() + } + + return transporterPromise +} + +async function sendMail({ to, subject, text, html }) { + const transporter = await getTransporter() + await transporter.sendMail({ + from: SMTP_FROM, + to, + subject, + text, + html, + }) +} + +async function sendEmailVerificationMail({ to, nickname, verificationUrl }) { + const displayName = nickname || to.split('@')[0] || '사용자' + await sendMail({ + to, + subject: '[Tier Maker] 이메일 인증을 완료해주세요', + text: [ + `${displayName}님, Tier Maker 가입을 완료하려면 아래 링크로 이메일 인증을 진행해주세요.`, + '', + verificationUrl, + '', + '이 링크는 24시간 동안 유효합니다.', + '직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.', + ].join('\n'), + html: ` +
+

Tier Maker 이메일 인증

+

${displayName}님, Tier Maker 가입을 완료하려면 아래 버튼으로 이메일 인증을 진행해주세요.

+

+ 이메일 인증하기 +

+

버튼이 열리지 않으면 아래 주소를 브라우저에 직접 붙여넣어주세요.

+

${verificationUrl}

+

이 링크는 24시간 동안 유효합니다. 직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.

+
+ `, + }) +} + +async function sendPasswordResetMail({ to, nickname, resetUrl }) { + const displayName = nickname || to.split('@')[0] || '사용자' + await sendMail({ + to, + subject: '[Tier Maker] 비밀번호 재설정 안내', + text: [ + `${displayName}님, Tier Maker 비밀번호를 다시 설정하려면 아래 링크를 열어주세요.`, + '', + resetUrl, + '', + '이 링크는 1시간 동안 유효합니다.', + '직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.', + ].join('\n'), + html: ` +
+

Tier Maker 비밀번호 재설정

+

${displayName}님, 비밀번호를 다시 설정하려면 아래 버튼을 눌러주세요.

+

+ 비밀번호 재설정 +

+

버튼이 열리지 않으면 아래 주소를 브라우저에 직접 붙여넣어주세요.

+

${resetUrl}

+

이 링크는 1시간 동안 유효합니다. 직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.

+
+ `, + }) +} + +module.exports = { + isMailerConfigured, + sendEmailVerificationMail, + sendPasswordResetMail, +} diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index ef13b51..847bf6a 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,5 +1,6 @@ const express = require('express') const bcrypt = require('bcryptjs') +const crypto = require('crypto') const { z } = require('zod') const { nanoid } = require('nanoid') const multer = require('multer') @@ -9,14 +10,25 @@ const { findUserByNickname, findUserById, createUser, + updateUserPassword, + verifyUserEmail, + createEmailVerificationToken, + findEmailVerificationTokenByHash, + consumeEmailVerificationToken, + createPasswordResetToken, + findPasswordResetTokenByHash, + consumePasswordResetToken, updateUserProfile, findPrimaryAdminUser, } = require('../db') const { requireAuth } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') +const { isMailerConfigured, sendEmailVerificationMail, sendPasswordResetMail } = require('../lib/mailer') const { isReservedNickname } = require('../lib/user-validation') const router = express.Router() +const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000 +const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000 const signupSchema = z.object({ email: z.string().email(), @@ -24,6 +36,23 @@ const signupSchema = z.object({ password: z.string().min(6), }) +const verifyEmailSchema = z.object({ + token: z.string().min(16).max(256), +}) + +const resendVerificationSchema = z.object({ + email: z.string().email(), +}) + +const requestPasswordResetSchema = z.object({ + email: z.string().email(), +}) + +const confirmPasswordResetSchema = z.object({ + token: z.string().min(16).max(256), + password: z.string().min(6), +}) + const profileSchema = z.object({ nickname: z.string().trim().min(1).max(40), removeAvatar: z.union([z.string(), z.undefined()]).optional(), @@ -57,10 +86,77 @@ async function serializeUser(user) { isOperator: !!user.isAdmin && !isPrimaryAdmin, role: isPrimaryAdmin ? 'owner' : user.isAdmin ? 'operator' : 'user', avatarSrc: user.avatarSrc || '', + emailVerified: user.emailVerified !== false, createdAt: user.createdAt, } } +function createRawToken() { + return crypto.randomBytes(32).toString('hex') +} + +function hashToken(token) { + return crypto.createHash('sha256').update(String(token || '')).digest('hex') +} + +function resolveAppOrigin(req) { + const envOrigin = String(process.env.APP_ORIGIN || process.env.PUBLIC_APP_ORIGIN || '').trim() + if (envOrigin) return envOrigin.replace(/\/+$/, '') + + const forwardedProto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() + const protocol = forwardedProto || req.protocol || 'http' + const host = req.get('host') + return host ? `${protocol}://${host}` : '' +} + +async function issueEmailVerificationMail(req, user) { + if (!isMailerConfigured()) { + const error = new Error('mail_not_configured') + error.code = 'mail_not_configured' + throw error + } + + const rawToken = createRawToken() + await createEmailVerificationToken({ + id: nanoid(), + userId: user.id, + tokenHash: hashToken(rawToken), + expiresAt: Date.now() + EMAIL_VERIFICATION_TTL_MS, + }) + + const appOrigin = resolveAppOrigin(req) + const verificationUrl = `${appOrigin}/login?verifyToken=${encodeURIComponent(rawToken)}` + await sendEmailVerificationMail({ + to: user.email, + nickname: user.nickname, + verificationUrl, + }) +} + +async function issuePasswordResetMail(req, user) { + if (!isMailerConfigured()) { + const error = new Error('mail_not_configured') + error.code = 'mail_not_configured' + throw error + } + + const rawToken = createRawToken() + await createPasswordResetToken({ + id: nanoid(), + userId: user.id, + tokenHash: hashToken(rawToken), + expiresAt: Date.now() + PASSWORD_RESET_TTL_MS, + }) + + const appOrigin = resolveAppOrigin(req) + const resetUrl = `${appOrigin}/login?resetToken=${encodeURIComponent(rawToken)}` + await sendPasswordResetMail({ + to: user.email, + nickname: user.nickname, + resetUrl, + }) +} + router.post('/signup', async (req, res) => { const parsed = signupSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) @@ -74,11 +170,36 @@ router.post('/signup', async (req, res) => { const passwordHash = await bcrypt.hash(password, 10) const isAdmin = (await countUsers()) === 0 - const user = await createUser({ id: nanoid(), email, nickname, passwordHash, isAdmin }) + if (!isAdmin && !isMailerConfigured()) { + return res.status(503).json({ error: 'mail_not_configured' }) + } + + const user = await createUser({ + id: nanoid(), + email, + nickname, + passwordHash, + emailVerified: isAdmin, + isAdmin, + }) + + if (!isAdmin) { + try { + await issueEmailVerificationMail(req, user) + return res.json({ + user: null, + verificationRequired: true, + email: user.email, + }) + } catch (err) { + const statusCode = err?.code === 'mail_not_configured' ? 503 : 502 + return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' }) + } + } try { await establishSession(req, user) - res.json(await serializeUser(user)) + res.json({ user: await serializeUser(user), verificationRequired: false }) } catch (err) { return res.status(500).json({ error: 'session_save_failed' }) } @@ -97,10 +218,11 @@ router.post('/login', async (req, res) => { const ok = await bcrypt.compare(password, user.passwordHash) if (!ok) return res.status(401).json({ error: 'invalid_credentials' }) + if (!user.emailVerified) return res.status(403).json({ error: 'email_unverified', email: user.email }) try { await establishSession(req, user) - res.json(await serializeUser(user)) + res.json({ user: await serializeUser(user) }) } catch (err) { return res.status(500).json({ error: 'session_save_failed' }) } @@ -122,6 +244,84 @@ router.get('/meta', async (req, res) => { res.json({ hasUsers: (await countUsers()) > 0 }) }) +router.post('/email/verify', async (req, res) => { + const parsed = verifyEmailSchema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const tokenRow = await findEmailVerificationTokenByHash(hashToken(parsed.data.token)) + if (!tokenRow || tokenRow.consumedAt || tokenRow.expiresAt < Date.now()) { + return res.status(400).json({ error: 'invalid_or_expired_token' }) + } + + const user = await verifyUserEmail(tokenRow.userId) + if (!user) return res.status(404).json({ error: 'not_found' }) + + await consumeEmailVerificationToken(tokenRow.id) + + try { + await establishSession(req, user) + res.json({ user: await serializeUser(user) }) + } catch (err) { + return res.status(500).json({ error: 'session_save_failed' }) + } +}) + +router.post('/email/resend', async (req, res) => { + const parsed = resendVerificationSchema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const user = await findUserByEmail(parsed.data.email) + if (!user || user.emailVerified) return res.json({ ok: true }) + + try { + await issueEmailVerificationMail(req, user) + res.json({ ok: true }) + } catch (err) { + const statusCode = err?.code === 'mail_not_configured' ? 503 : 502 + return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' }) + } +}) + +router.post('/password-reset/request', async (req, res) => { + const parsed = requestPasswordResetSchema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const user = await findUserByEmail(parsed.data.email) + if (!user) return res.json({ ok: true }) + + try { + await issuePasswordResetMail(req, user) + res.json({ ok: true }) + } catch (err) { + const statusCode = err?.code === 'mail_not_configured' ? 503 : 502 + return res.status(statusCode).json({ error: err?.code || 'mail_send_failed' }) + } +}) + +router.post('/password-reset/confirm', async (req, res) => { + const parsed = confirmPasswordResetSchema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const tokenRow = await findPasswordResetTokenByHash(hashToken(parsed.data.token)) + if (!tokenRow || tokenRow.consumedAt || tokenRow.expiresAt < Date.now()) { + return res.status(400).json({ error: 'invalid_or_expired_token' }) + } + + const passwordHash = await bcrypt.hash(parsed.data.password, 10) + const updatedUser = await updateUserPassword({ id: tokenRow.userId, passwordHash }) + if (!updatedUser) return res.status(404).json({ error: 'not_found' }) + + const verifiedUser = updatedUser.emailVerified ? updatedUser : await verifyUserEmail(updatedUser.id) + await consumePasswordResetToken(tokenRow.id) + + try { + await establishSession(req, verifiedUser) + res.json({ user: await serializeUser(verifiedUser) }) + } catch (err) { + return res.status(500).json({ error: 'session_save_failed' }) + } +}) + const upload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 }) router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => { diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 6a52834..e1bbe8a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -41,6 +41,13 @@ services: SESSION_COOKIE_SAME_SITE: "lax" CORS_ORIGINS: https://tmaker.sori.studio TRUST_PROXY: 1 + APP_ORIGIN: https://tmaker.sori.studio + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT} + SMTP_SECURE: ${SMTP_SECURE} + SMTP_USER: ${SMTP_USER} + SMTP_PASS: ${SMTP_PASS} + SMTP_FROM: ${SMTP_FROM} volumes: - tmaker_uploads:/app/uploads - tmaker_sessions:/app/.sessions diff --git a/docs/history.md b/docs/history.md index b634a69..93c540d 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-03 v1.4.45 +- 실제 서비스에서는 남의 이메일 주소로 가입만 먼저 해두는 문제가 생길 수 있으므로, 일반 회원은 가입 직후 인증 메일을 거쳐야 로그인할 수 있게 하고 비밀번호 분실도 메일 토큰 기반으로 복구하는 구조가 필요하다고 판단했다. +- 다만 초기 운영자가 바로 서비스를 띄울 수 있어야 하므로, 첫 번째 가입 계정만은 기존처럼 이메일 인증 없이 바로 최고 관리자 계정으로 활성화하는 예외를 유지하는 편이 맞다고 정리했다. +- 발신 인프라는 우선 사용자가 준비한 Gmail 계정과 앱 비밀번호로 SMTP를 먼저 붙이고, 도메인 발신 주소와 SPF/DKIM/DMARC는 실제 발송 품질을 본 뒤 Cloudflare DNS에서 후속 정리하는 단계적 접근이 더 현실적이라고 판단했다. + ## 2026-04-03 v1.4.44 - 공통 카피라이트 링크 색을 고정 민트색으로 두면 다크 모드에서는 잘 보이지만 라이트 모드에서 대비가 부족해질 수 있으므로, 테마 텍스트 색을 따라가게 하고 굵기로 링크 인지를 보완하는 편이 더 안정적이라고 판단했다. diff --git a/docs/map.md b/docs/map.md index 2e124ee..df46261 100644 --- a/docs/map.md +++ b/docs/map.md @@ -17,8 +17,8 @@ ## `/login` - 화면 파일: `frontend/src/views/LoginView.vue` -- 역할: 로그인/회원가입 전환, 첫 가입 안내 -- 연동 API: `GET /api/auth/meta`, `POST /api/auth/login`, `POST /api/auth/signup` +- 역할: 로그인/회원가입 전환, 첫 가입 안내, 일반 회원가입 후 이메일 인증 안내와 인증 메일 재전송, 비밀번호 재설정 메일 요청, `?verifyToken=...` 인증 링크 처리, `?resetToken=...` 새 비밀번호 설정 처리 +- 연동 API: `GET /api/auth/meta`, `POST /api/auth/login`, `POST /api/auth/signup`, `POST /api/auth/email/verify`, `POST /api/auth/email/resend`, `POST /api/auth/password-reset/request`, `POST /api/auth/password-reset/confirm` ## `/me` - 화면 파일: `frontend/src/views/MyTierListsView.vue` @@ -56,6 +56,7 @@ - 로컬 DB 실행 설정: `docker-compose.yml` - 로컬 MariaDB 가이드: `docs/local-mariadb.md` - 인증 라우트: `backend/src/routes/auth.js` +- 메일 발송 유틸: `backend/src/lib/mailer.js` - 주제 라우트: `backend/src/routes/topics.js` - 티어표 라우트: `backend/src/routes/tierlists.js` - 관리자 라우트: `backend/src/routes/admin.js` diff --git a/docs/spec.md b/docs/spec.md index 81a4c8e..a5428f9 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -64,9 +64,24 @@ - `email`: string - `nickname`: string - `passwordHash`: string + - `emailVerified`: boolean - `isAdmin`: boolean - `avatarSrc`: string - `createdAt`: number +- `emailVerificationTokens` + - `id`: string + - `userId`: string + - `tokenHash`: string + - `expiresAt`: number + - `consumedAt`: number + - `createdAt`: number +- `passwordResetTokens` + - `id`: string + - `userId`: string + - `tokenHash`: string + - `expiresAt`: number + - `consumedAt`: number + - `createdAt`: number - `games` - `id`: string - `name`: string @@ -110,11 +125,21 @@ ## 주요 API - 인증 - `POST /api/auth/signup` + - 첫 관리자 계정은 바로 로그인 세션을 만들고, 이후 일반 계정은 인증 메일 발송 후 `verificationRequired` 상태를 반환한다. - `POST /api/auth/login` + - 이메일 인증이 끝나지 않은 계정은 `email_unverified`로 차단한다. - `POST /api/auth/logout` - `GET /api/auth/me` - `GET /api/auth/meta` - `POST /api/auth/profile` + - `POST /api/auth/email/verify` + - `login?verifyToken=...` 링크에서 받은 토큰으로 이메일 인증을 완료하고 바로 로그인 세션을 만든다. + - `POST /api/auth/email/resend` + - 미인증 계정의 인증 메일을 다시 발송한다. + - `POST /api/auth/password-reset/request` + - 입력한 이메일로 비밀번호 재설정 링크를 발송한다. + - `POST /api/auth/password-reset/confirm` + - `login?resetToken=...` 링크의 토큰과 새 비밀번호로 비밀번호를 재설정하고 바로 로그인 세션을 만든다. - 주제 - `GET /api/topics` - `GET /api/topics/:topicId` @@ -245,6 +270,20 @@ - `TRUST_PROXY`: 프록시 홉 수 - `SESSION_COOKIE_SECURE`: `true`면 HTTPS 전용 쿠키 - `SESSION_COOKIE_SAME_SITE`: 기본 `lax` + - `APP_ORIGIN`: 이메일 인증/비밀번호 재설정 링크를 만들 때 사용할 서비스 기준 주소 + - `SMTP_HOST`: 메일 서버 호스트, Gmail SMTP 사용 시 보통 `smtp.gmail.com` + - `SMTP_PORT`: 메일 서버 포트, Gmail SSL SMTP 기준 보통 `465` + - `SMTP_SECURE`: `true`면 SMTP SSL/TLS 연결을 사용 + - `SMTP_USER`: 발신용 Gmail 계정 + - `SMTP_PASS`: Gmail 앱 비밀번호 + - `SMTP_FROM`: 실제 메일 From 주소, 비워두면 `SMTP_USER`를 기본값으로 사용한다 + +## 회원 인증 메모 +- 첫 번째 가입 계정은 운영 초기 부트스트랩을 위해 이메일 인증 없이 바로 최고 관리자 계정으로 활성화한다. +- 두 번째 이후 일반 회원가입은 가입 직후 로그인 세션을 만들지 않고, 인증 메일 링크를 눌러 `email_verified=1`이 된 뒤에만 로그인할 수 있게 한다. +- 인증 메일/비밀번호 재설정 메일 토큰은 원문을 DB에 저장하지 않고 SHA-256 해시만 저장하며, 새 토큰을 발급할 때는 같은 사용자의 이전 미사용 토큰을 먼저 만료 처리한다. +- 이메일 인증 토큰은 24시간, 비밀번호 재설정 토큰은 1시간 유효 기간을 사용한다. +- 비밀번호 재설정 링크로 새 비밀번호를 저장한 사용자는 같은 메일 주소를 확인한 것으로 보고, 기존에 미인증 상태였더라도 저장과 함께 이메일 인증을 완료 처리한다. ## 운영 배포 메모 - 프로덕션 컴포즈 파일은 [docker-compose.prod.yml](/Users/bicute/Desktop/zenn.dev/tier-cursor/docker-compose.prod.yml)이다. diff --git a/docs/todo.md b/docs/todo.md index ed35b20..8ebb2e5 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,11 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.45`에서 이메일 인증/비밀번호 재설정 메일 발송을 Gmail SMTP로 붙였으므로, 운영 `.env`에 `SMTP_USER`, `SMTP_PASS`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_FROM`, `APP_ORIGIN`을 넣은 뒤 실제 회원가입 인증 메일과 비밀번호 재설정 메일이 도착하는지 확인한다. +- 일반 회원가입 직후에는 자동 로그인되지 않고 인증 안내 문구가 떠야 하며, 메일의 `login?verifyToken=...` 링크를 누르면 이메일 인증과 로그인 세션 생성이 함께 끝나는지 QA한다. +- 미인증 계정으로 로그인하면 `email_unverified` 상태 안내가 뜨고, `인증 메일 재전송` 버튼으로 같은 메일 주소에 인증 링크를 다시 보낼 수 있는지 확인한다. +- `비밀번호를 잊으셨나요?`에서 재설정 메일을 요청한 뒤 `login?resetToken=...` 링크로 들어가면 새 비밀번호 입력 화면이 열리고, 저장 후 바로 로그인 상태로 내 티어표 화면으로 이동하는지 확인한다. +- Gmail 주소 그대로 발송하는 1차 단계에서는 도메인 DNS 인증을 당장 쓰지 않지만, 이후 `noreply@sori.studio` 같은 도메인 발신 주소로 바꿀 경우 Cloudflare DNS에 SPF/DKIM/DMARC를 설정하는 후속 작업이 필요하다. - `v1.4.44`에서 공통 카피라이트 `zenn` 링크를 테마 텍스트 색으로 바꿨으므로, 다크/라이트 모드 양쪽에서 하단 링크가 배경에 묻히지 않고 hover 상태도 자연스러운지 확인한다. - `v1.4.43`에서 같은 `TierEditorView` 라우트 안에서도 `topicId / tierListId / preview`가 바뀌면 상태를 다시 불러오게 했으므로, 타인 티어표 복사 직후 화면이 내 복사본으로 바뀌는지와 상단 원본 링크 클릭 시 실제 원본 티어표 내용으로 전환되는지 확인한다. - 편집 중 미저장 변경이 있는 상태에서 상단 원본 링크를 눌렀을 때는 경고 모달이 뜨고, `계속 편집`은 현재 화면 유지, `저장 없이 이동`은 원본으로 이동하면서 변경분을 버리는지 QA한다. @@ -120,6 +125,8 @@ - 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다. ## 중기 개선 +- 이메일 인증/비밀번호 재설정 1차 구현이 들어갔으므로, 다음 단계에서는 Gmail 발신 기반이 실제 운영에서 스팸함으로 얼마나 가는지 보고 필요하면 Cloudflare DNS의 SPF/DKIM/DMARC와 도메인 발신 주소 전환을 정리한다. +- 구글 계정 로그인은 아직 붙이지 않았으므로, 이메일 인증 안정화 후 Google OAuth 클라이언트를 만들고 일반 이메일 계정과 같은 이메일의 구글 계정을 자동 연결할지 정책을 먼저 결정한다. - 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다. - 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다. - 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다. diff --git a/docs/update.md b/docs/update.md index be66b3a..aac5d05 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-03 v1.4.45 +- Gmail SMTP를 사용하는 이메일 인증/비밀번호 재설정 1차 흐름을 추가했다. 첫 관리자 계정은 기존처럼 바로 활성화되지만, 일반 회원은 가입 직후 인증 메일을 받고 `login?verifyToken=...` 링크로 인증을 마쳐야 로그인할 수 있게 바꿨다. +- 로그인 화면에 `인증 메일 재전송`, `비밀번호를 잊으셨나요?`, `login?resetToken=...` 기반 새 비밀번호 설정 UI를 추가해, 메일 링크를 받은 사용자가 같은 `/login` 화면에서 인증 완료와 비밀번호 재설정을 이어서 처리할 수 있게 했다. +- 백엔드 `users`에 `email_verified`를 추가하고, 이메일 인증/비밀번호 재설정 토큰을 해시로 저장하는 전용 테이블과 API를 추가했다. 운영 배포용 `docker-compose.prod.yml`에는 `APP_ORIGIN`, `SMTP_*` 환경변수 자리를 열어 Gmail 앱 비밀번호를 코드에 넣지 않고 주입할 수 있게 정리했다. + ## 2026-04-03 v1.4.44 - 오른쪽 레일 공통 카피라이트의 `zenn` 링크가 민트 단색이라 라이트 모드에서 배경과 충분히 분리되지 않을 수 있었으므로, 테마 텍스트 색 기반의 굵은 링크 스타일로 바꿔 다크/라이트 양쪽에서 읽히도록 조정했다. diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 339c469..60ff491 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -57,6 +57,11 @@ export const api = { authMeta: () => request('/api/auth/meta'), signup: ({ email, nickname, password }) => request('/api/auth/signup', { method: 'POST', body: { email, nickname, password } }), login: ({ email, password }) => request('/api/auth/login', { method: 'POST', body: { email, password } }), + verifyEmail: ({ token }) => request('/api/auth/email/verify', { method: 'POST', body: { token } }), + resendVerificationEmail: ({ email }) => request('/api/auth/email/resend', { method: 'POST', body: { email } }), + requestPasswordReset: ({ email }) => request('/api/auth/password-reset/request', { method: 'POST', body: { email } }), + confirmPasswordReset: ({ token, password }) => + request('/api/auth/password-reset/confirm', { method: 'POST', body: { token, password } }), logout: () => request('/api/auth/logout', { method: 'POST' }), listTopics: () => request('/api/topics'), diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 3f1be55..5bd18f1 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -30,16 +30,28 @@ export const useAuthStore = defineStore('auth', { return refreshPromise }, async signup(email, nickname, password) { - const user = await api.signup({ email, nickname, password }) - this.user = user + const data = await api.signup({ email, nickname, password }) + this.user = data?.user || null this.hydrated = true - return user + return data }, async login(email, password) { - const user = await api.login({ email, password }) - this.user = user + const data = await api.login({ email, password }) + this.user = data?.user || null this.hydrated = true - return user + return data?.user || null + }, + async verifyEmail(token) { + const data = await api.verifyEmail({ token }) + this.user = data?.user || null + this.hydrated = true + return this.user + }, + async confirmPasswordReset(token, password) { + const data = await api.confirmPasswordReset({ token, password }) + this.user = data?.user || null + this.hydrated = true + return this.user }, async logout() { await api.logout() diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index 62cc586..18f400c 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -15,24 +15,92 @@ const password = ref('') const passwordConfirm = ref('') const mode = ref('login') const error = ref('') +const notice = ref('') const hasUsers = ref(true) const emailError = ref('') const nicknameError = ref('') +const pendingVerificationEmail = ref('') +const isSubmitting = ref(false) -const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인')) -const description = computed(() => - mode.value === 'signup' - ? '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.' - : '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.' -) -const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인')) +const title = computed(() => { + if (mode.value === 'signup') return '회원가입' + if (mode.value === 'reset-request') return '비밀번호 재설정' + if (mode.value === 'reset-confirm') return '새 비밀번호 설정' + return '로그인' +}) +const description = computed(() => { + if (mode.value === 'signup') return '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.' + if (mode.value === 'reset-request') return '가입한 이메일로 비밀번호 재설정 링크를 보내드릴게요.' + if (mode.value === 'reset-confirm') return '메일로 받은 재설정 링크를 확인했어요. 새 비밀번호를 입력해주세요.' + return '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.' +}) +const submitLabel = computed(() => { + if (mode.value === 'signup') return '가입하기' + if (mode.value === 'reset-request') return '재설정 메일 보내기' + if (mode.value === 'reset-confirm') return '새 비밀번호 저장' + return '로그인' +}) const authReady = computed(() => auth.hydrated) const checkingSession = computed(() => !authReady.value || auth.status === 'loading') +const resetToken = computed(() => (typeof route.query.resetToken === 'string' ? route.query.resetToken : '')) +const verifyToken = computed(() => (typeof route.query.verifyToken === 'string' ? route.query.verifyToken : '')) +const redirectPath = computed(() => (typeof route.query.redirect === 'string' ? route.query.redirect : mePath())) + +function clearFormFeedback() { + error.value = '' + emailError.value = '' + nicknameError.value = '' +} + +function clearAuthQueryTokens() { + if (!resetToken.value && !verifyToken.value) return + const nextQuery = { ...route.query } + delete nextQuery.resetToken + delete nextQuery.verifyToken + router.replace({ path: route.path, query: nextQuery }) +} + +function switchMode(nextMode) { + if (mode.value === nextMode) return + mode.value = nextMode + clearFormFeedback() + notice.value = '' + pendingVerificationEmail.value = '' + password.value = '' + passwordConfirm.value = '' + if (nextMode !== 'signup') nickname.value = '' + if (nextMode !== 'reset-confirm') clearAuthQueryTokens() +} + +async function completeEmailVerification(token) { + isSubmitting.value = true + try { + await auth.verifyEmail(token) + notice.value = '이메일 인증이 완료됐어요. 내 티어표 화면으로 이동합니다.' + router.replace(redirectPath.value) + } catch (e) { + mode.value = 'login' + error.value = '인증 링크가 만료되었거나 유효하지 않아요. 다시 로그인하거나 인증 메일을 재전송해주세요.' + clearAuthQueryTokens() + } finally { + isSubmitting.value = false + } +} onMounted(async () => { if (!auth.hydrated) await auth.refresh() + if (verifyToken.value) { + await completeEmailVerification(verifyToken.value) + return + } + if (resetToken.value) { + mode.value = 'reset-confirm' + password.value = '' + passwordConfirm.value = '' + return + } if (auth.user) { - router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath()) + router.replace(redirectPath.value) return } try { @@ -47,15 +115,13 @@ watch( () => [auth.hydrated, auth.user], ([hydrated, user]) => { if (!hydrated || !user) return - router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath()) + router.replace(redirectPath.value) }, { immediate: true } ) watch(mode, () => { - error.value = '' - emailError.value = '' - nicknameError.value = '' + clearFormFeedback() }) watch(email, () => { @@ -68,23 +134,81 @@ watch(nickname, () => { if (error.value === '닉네임이 이미 사용 중이에요.' || error.value === '사용할 수 없는 닉네임이에요.') error.value = '' }) +watch( + () => route.query.resetToken, + (value) => { + if (typeof value === 'string' && value) { + switchMode('reset-confirm') + } + } +) + +async function resendVerificationEmail() { + const targetEmail = email.value.trim() || pendingVerificationEmail.value + if (!targetEmail) { + emailError.value = '이메일을 먼저 입력해주세요.' + error.value = '인증 메일을 다시 받을 이메일이 필요해요.' + return + } + + clearFormFeedback() + isSubmitting.value = true + try { + await api.resendVerificationEmail({ email: targetEmail }) + pendingVerificationEmail.value = targetEmail + notice.value = `${targetEmail} 주소로 인증 메일을 다시 보냈어요. 메일함과 스팸함을 함께 확인해주세요.` + } catch (e) { + const code = e?.data?.error + error.value = code === 'mail_not_configured' + ? '메일 발송 설정이 아직 완료되지 않았어요. 잠시 후 다시 시도해주세요.' + : '인증 메일 재전송에 실패했어요.' + } finally { + isSubmitting.value = false + } +} + async function submit() { - error.value = '' - emailError.value = '' - nicknameError.value = '' + clearFormFeedback() + notice.value = '' if (mode.value === 'signup' && nickname.value.trim().length < 2) { nicknameError.value = '닉네임은 2자 이상 입력해주세요.' error.value = '닉네임을 확인해주세요.' return } - if (mode.value === 'signup' && password.value !== passwordConfirm.value) { + if ((mode.value === 'signup' || mode.value === 'reset-confirm') && password.value !== passwordConfirm.value) { error.value = '비밀번호 확인이 일치하지 않아요.' return } + if (mode.value === 'reset-confirm' && !resetToken.value) { + error.value = '재설정 토큰이 없어 비밀번호를 바꿀 수 없어요. 메일 링크를 다시 확인해주세요.' + return + } + + isSubmitting.value = true try { - if (mode.value === 'signup') await auth.signup(email.value, nickname.value, password.value) - else await auth.login(email.value, password.value) - router.push(typeof route.query.redirect === 'string' ? route.query.redirect : mePath()) + if (mode.value === 'signup') { + const result = await auth.signup(email.value, nickname.value, password.value) + if (result?.verificationRequired) { + pendingVerificationEmail.value = result.email || email.value.trim() + mode.value = 'login' + password.value = '' + passwordConfirm.value = '' + notice.value = `${pendingVerificationEmail.value} 주소로 인증 메일을 보냈어요. 인증 후 로그인해주세요.` + return + } + } else if (mode.value === 'reset-request') { + const targetEmail = email.value.trim() + await api.requestPasswordReset({ email: targetEmail }) + switchMode('login') + notice.value = `${targetEmail} 주소로 비밀번호 재설정 메일을 보냈어요. 메일함과 스팸함을 함께 확인해주세요.` + return + } else if (mode.value === 'reset-confirm') { + await auth.confirmPasswordReset(resetToken.value, password.value) + clearAuthQueryTokens() + } else { + await auth.login(email.value, password.value) + } + router.push(redirectPath.value) } catch (e) { const code = e?.data?.error if (mode.value === 'signup') { @@ -103,8 +227,35 @@ async function submit() { error.value = '사용할 수 없는 닉네임이에요.' return } + if (code === 'mail_not_configured') { + error.value = '메일 발송 설정이 아직 완료되지 않아 이메일 인증을 보낼 수 없어요.' + return + } + if (code === 'mail_send_failed') { + error.value = '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.' + return + } } - error.value = mode.value === 'signup' ? '회원가입에 실패했어요.' : '로그인에 실패했어요.' + if (mode.value === 'login' && code === 'email_unverified') { + pendingVerificationEmail.value = e?.data?.email || email.value.trim() + error.value = '이메일 인증이 아직 완료되지 않았어요. 아래 버튼으로 인증 메일을 다시 받을 수 있어요.' + return + } + if (mode.value === 'reset-request') { + error.value = code === 'mail_not_configured' + ? '메일 발송 설정이 아직 완료되지 않아 재설정 메일을 보낼 수 없어요.' + : '재설정 메일 발송에 실패했어요.' + return + } + if (mode.value === 'reset-confirm') { + error.value = code === 'invalid_or_expired_token' + ? '재설정 링크가 만료되었거나 유효하지 않아요. 비밀번호 재설정을 다시 요청해주세요.' + : '새 비밀번호 저장에 실패했어요.' + return + } + error.value = '로그인에 실패했어요.' + } finally { + isSubmitting.value = false } } @@ -126,18 +277,23 @@ async function submit() {
- -
- -
@@ -316,6 +488,40 @@ async function submit() { font-weight: 700; } +.authNotice { + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(34, 197, 94, 0.28); + background: rgba(34, 197, 94, 0.1); + color: #7ddf97; + font-size: 13px; + font-weight: 700; +} + +.authHelpRow { + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-top: -6px; +} + +.linkAction { + border: 0; + background: transparent; + color: var(--theme-text-muted); + font-size: 13px; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +.linkAction:disabled { + opacity: 0.5; + cursor: progress; +} + .authActions { display: flex; gap: 12px; @@ -337,6 +543,11 @@ async function submit() { color: var(--theme-accent-text); } +.primaryAction:disabled { + opacity: 0.65; + cursor: progress; +} + .secondaryAction { border: 1px solid var(--theme-border-strong); background: var(--theme-surface-soft);