Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b9f5f18e0 | |||
| 8922c62f58 | |||
| cd41a6caa1 | |||
| a60c3c9896 | |||
| eb3fb71f24 | |||
| 2a39ee03e5 |
@@ -3,3 +3,10 @@ MARIADB_DATABASE=tier_cursor
|
||||
MARIADB_USER=tier_cursor
|
||||
MARIADB_PASSWORD=change-this-db-password
|
||||
SESSION_SECRET=change-this-session-secret
|
||||
APP_ORIGIN=https://tmaker.sori.studio
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
SMTP_USER=your-gmail-account@gmail.com
|
||||
SMTP_PASS=change-this-gmail-app-password
|
||||
SMTP_FROM="Tier Maker <your-gmail-account@gmail.com>"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const dotenv = require('dotenv')
|
||||
const express = require('express')
|
||||
const cors = require('cors')
|
||||
const session = require('express-session')
|
||||
const FileStoreFactory = require('session-file-store')
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, '..', '.env.production') })
|
||||
|
||||
const { ensureData } = require('./src/db')
|
||||
const authRoutes = require('./src/routes/auth')
|
||||
const topicsRoutes = require('./src/routes/topics')
|
||||
|
||||
23
backend/package-lock.json
generated
23
backend/package-lock.json
generated
@@ -11,11 +11,13 @@
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.0",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"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"
|
||||
@@ -853,6 +855,18 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.4.0",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz",
|
||||
"integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1594,6 +1608,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",
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js",
|
||||
"start": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
|
||||
"dev": "APP_ORIGIN=http://localhost:5173 DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js",
|
||||
"start": "APP_ORIGIN=http://localhost:5173 DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js",
|
||||
"images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js",
|
||||
"images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js",
|
||||
"uploads:cleanup-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/cleanup-unreferenced-legacy-uploads.js"
|
||||
@@ -17,11 +17,13 @@
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.0",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
113
backend/src/lib/mailer.js
Normal file
113
backend/src/lib/mailer.js
Normal file
@@ -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: `
|
||||
<div style="font-family:Arial,sans-serif;line-height:1.7;color:#111827">
|
||||
<h1 style="font-size:20px;margin:0 0 16px">Tier Maker 이메일 인증</h1>
|
||||
<p style="margin:0 0 16px">${displayName}님, Tier Maker 가입을 완료하려면 아래 버튼으로 이메일 인증을 진행해주세요.</p>
|
||||
<p style="margin:0 0 20px">
|
||||
<a href="${verificationUrl}" style="display:inline-block;padding:12px 18px;border-radius:999px;background:#4c85f5;color:#ffffff;text-decoration:none;font-weight:700">이메일 인증하기</a>
|
||||
</p>
|
||||
<p style="margin:0 0 8px;font-size:13px;color:#6b7280">버튼이 열리지 않으면 아래 주소를 브라우저에 직접 붙여넣어주세요.</p>
|
||||
<p style="margin:0 0 20px;font-size:13px;word-break:break-all;color:#2563eb">${verificationUrl}</p>
|
||||
<p style="margin:0;font-size:13px;color:#6b7280">이 링크는 24시간 동안 유효합니다. 직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
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: `
|
||||
<div style="font-family:Arial,sans-serif;line-height:1.7;color:#111827">
|
||||
<h1 style="font-size:20px;margin:0 0 16px">Tier Maker 비밀번호 재설정</h1>
|
||||
<p style="margin:0 0 16px">${displayName}님, 비밀번호를 다시 설정하려면 아래 버튼을 눌러주세요.</p>
|
||||
<p style="margin:0 0 20px">
|
||||
<a href="${resetUrl}" style="display:inline-block;padding:12px 18px;border-radius:999px;background:#4c85f5;color:#ffffff;text-decoration:none;font-weight:700">비밀번호 재설정</a>
|
||||
</p>
|
||||
<p style="margin:0 0 8px;font-size:13px;color:#6b7280">버튼이 열리지 않으면 아래 주소를 브라우저에 직접 붙여넣어주세요.</p>
|
||||
<p style="margin:0 0 20px;font-size:13px;word-break:break-all;color:#2563eb">${resetUrl}</p>
|
||||
<p style="margin:0;font-size:13px;color:#6b7280">이 링크는 1시간 동안 유효합니다. 직접 요청하지 않았다면 이 메일은 무시하셔도 됩니다.</p>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isMailerConfigured,
|
||||
sendEmailVerificationMail,
|
||||
sendPasswordResetMail,
|
||||
}
|
||||
@@ -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,28 @@ 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 changePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(6),
|
||||
nextPassword: 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 +91,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 +175,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 +223,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 +249,102 @@ 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' })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/password', requireAuth, async (req, res) => {
|
||||
const parsed = changePasswordSchema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const user = await findUserById(req.session.userId)
|
||||
if (!user) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const authUser = await findUserByEmail(user.email)
|
||||
if (!authUser) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const passwordMatched = await bcrypt.compare(parsed.data.currentPassword, authUser.passwordHash)
|
||||
if (!passwordMatched) return res.status(401).json({ error: 'invalid_current_password' })
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsed.data.nextPassword, 10)
|
||||
const updated = await updateUserPassword({ id: authUser.id, passwordHash })
|
||||
res.json({ user: await serializeUser(updated) })
|
||||
})
|
||||
|
||||
const upload = createMemoryUpload(multer, { fileSize: 4 * 1024 * 1024 })
|
||||
|
||||
router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-03 v1.4.49
|
||||
- 프로필 저장 실패를 하나의 일반 실패 메시지로만 보여주면 사용자가 “서버가 고장났나?”라고 오해하기 쉬우므로, 중복 닉네임/예약어 닉네임처럼 사용자가 직접 고칠 수 있는 입력 오류는 원인별 안내를 분리하는 편이 맞다고 판단했다.
|
||||
- 비밀번호를 잊은 사용자뿐 아니라 로그인 중인 사용자도 보안상 주기적으로 비밀번호를 직접 바꿀 수 있어야 하므로, 설정 화면에 현재 비밀번호 확인 기반 변경 폼을 추가하는 쪽으로 정리했다.
|
||||
- 비밀번호 재설정 링크는 로그인 세션이 남아 있어도 링크 토큰 자체의 목적이 우선이므로, `/login?resetToken=...` 진입 시에는 기존 자동 리다이렉트보다 재설정 폼 렌더링을 우선하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.45
|
||||
- 실제 서비스에서는 남의 이메일 주소로 가입만 먼저 해두는 문제가 생길 수 있으므로, 일반 회원은 가입 직후 인증 메일을 거쳐야 로그인할 수 있게 하고 비밀번호 분실도 메일 토큰 기반으로 복구하는 구조가 필요하다고 판단했다.
|
||||
- 다만 초기 운영자가 바로 서비스를 띄울 수 있어야 하므로, 첫 번째 가입 계정만은 기존처럼 이메일 인증 없이 바로 최고 관리자 계정으로 활성화하는 예외를 유지하는 편이 맞다고 정리했다.
|
||||
- 발신 인프라는 우선 사용자가 준비한 Gmail 계정과 앱 비밀번호로 SMTP를 먼저 붙이고, 도메인 발신 주소와 SPF/DKIM/DMARC는 실제 발송 품질을 본 뒤 Cloudflare DNS에서 후속 정리하는 단계적 접근이 더 현실적이라고 판단했다.
|
||||
|
||||
## 2026-04-03 v1.4.44
|
||||
- 공통 카피라이트 링크 색을 고정 민트색으로 두면 다크 모드에서는 잘 보이지만 라이트 모드에서 대비가 부족해질 수 있으므로, 테마 텍스트 색을 따라가게 하고 굵기로 링크 인지를 보완하는 편이 더 안정적이라고 판단했다.
|
||||
|
||||
|
||||
@@ -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`
|
||||
@@ -42,8 +42,8 @@
|
||||
|
||||
## `/profile`
|
||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 설정 화면 하단 로그아웃 처리
|
||||
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`
|
||||
- 역할: 넓은 화면에서는 왼쪽 프로필 정보 카드와 오른쪽 비밀번호 변경 카드로 나뉘는 설정 화면, 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 중복/예약어 닉네임 오류 안내, 현재 비밀번호 확인 기반 비밀번호 변경, 설정 화면 로그아웃 처리
|
||||
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password`
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.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`
|
||||
|
||||
43
docs/spec.md
43
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,23 @@
|
||||
## 주요 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/password`
|
||||
- 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
|
||||
- `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 +272,22 @@
|
||||
- `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시간 유효 기간을 사용한다.
|
||||
- 비밀번호 재설정 링크로 새 비밀번호를 저장한 사용자는 같은 메일 주소를 확인한 것으로 보고, 기존에 미인증 상태였더라도 저장과 함께 이메일 인증을 완료 처리한다.
|
||||
- 로그인한 상태로도 `login?resetToken=...` 재설정 링크를 열 수 있으며, 이때는 기존 로그인 세션이 있어도 자동으로 내 티어표 화면으로 보내지 않고 새 비밀번호 입력 화면을 먼저 보여준다.
|
||||
- 설정 화면의 직접 비밀번호 변경은 현재 비밀번호가 맞는지 먼저 확인하고, 맞지 않으면 `invalid_current_password`로 차단한다.
|
||||
|
||||
## 운영 배포 메모
|
||||
- 프로덕션 컴포즈 파일은 [docker-compose.prod.yml](/Users/bicute/Desktop/zenn.dev/tier-cursor/docker-compose.prod.yml)이다.
|
||||
|
||||
14
docs/todo.md
14
docs/todo.md
@@ -1,6 +1,18 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.4.50`에서 설정 화면을 좌우 2열 카드형으로 나눴으므로, 데스크톱 폭에서는 프로필 정보가 왼쪽, 비밀번호 변경이 오른쪽에 나란히 보이고, 모바일/좁은 폭에서는 두 카드가 자연스럽게 위아래로 쌓이는지 확인한다.
|
||||
- `v1.4.49`에서 설정 화면에 비밀번호 변경 섹션을 추가했으므로, 현재 비밀번호가 틀린 경우 `현재 비밀번호가 일치하지 않아요.`, 새 비밀번호 확인이 다른 경우 `비밀번호 확인이 일치하지 않아요.`, 성공 시 `비밀번호를 변경했어요.` 토스트가 각각 정확히 뜨는지 확인한다.
|
||||
- 설정 화면 닉네임 저장도 중복/예약어 에러를 구체적으로 보여주도록 바꿨으므로, 이미 사용 중인 닉네임과 예약어 닉네임을 각각 넣었을 때 서버 문제처럼 보이지 않고 원인 문구가 정확히 뜨는지 QA한다.
|
||||
- 로그인한 상태로 비밀번호 재설정 메일의 `login?resetToken=...` 링크를 눌렀을 때도 바로 내 티어표 화면으로 튕기지 않고 `새 비밀번호 설정` 화면이 먼저 뜨는지 확인한다.
|
||||
- `v1.4.48`에서 로컬 `APP_ORIGIN`을 `localhost:5173`으로 먼저 주입하도록 바꿨으므로, 백엔드를 다시 띄운 뒤 새 회원가입 인증 메일과 비밀번호 재설정 메일 링크가 운영 도메인이 아니라 로컬 주소로 열리는지 확인한다.
|
||||
- `v1.4.47`에서 로컬 백엔드가 루트 `.env.production`을 읽도록 바꿨으므로, `SMTP_PASS` 교체 후 백엔드를 다시 띄우고 로컬 회원가입이 더 이상 `mail_not_configured` 503으로 떨어지지 않는지 확인한다.
|
||||
- `.env.production`의 `SMTP_PASS=여기에_Gmail_앱_비밀번호_입력` placeholder를 실제 Gmail 앱 비밀번호로 교체한 뒤, 운영 컨테이너를 재기동해서 회원가입 인증 메일과 비밀번호 재설정 메일이 실제로 발송되는지 확인한다.
|
||||
- `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 +132,8 @@
|
||||
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
|
||||
|
||||
## 중기 개선
|
||||
- 이메일 인증/비밀번호 재설정 1차 구현이 들어갔으므로, 다음 단계에서는 Gmail 발신 기반이 실제 운영에서 스팸함으로 얼마나 가는지 보고 필요하면 Cloudflare DNS의 SPF/DKIM/DMARC와 도메인 발신 주소 전환을 정리한다.
|
||||
- 구글 계정 로그인은 아직 붙이지 않았으므로, 이메일 인증 안정화 후 Google OAuth 클라이언트를 만들고 일반 이메일 계정과 같은 이메일의 구글 계정을 자동 연결할지 정책을 먼저 결정한다.
|
||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||
- 관리자 템플릿 요청은 동일 `src` 중복 생성 방지와 `연결된 게임/이미 반영 n개` 표시까지 붙였으므로, 이후에는 요청별 반영 이력(예: 어떤 아이템을 언제 반영했는지)까지 별도로 남길지 검토한다.
|
||||
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-03 v1.4.50
|
||||
- 설정 화면 메인 영역이 `max-width: 620px` 단일 컬럼으로 고정되어 넓은 화면에서 오른쪽 공간이 많이 비어 보였으므로, 프로필 정보 카드와 비밀번호 변경 카드를 좌우 2열 그리드로 나누고 좁은 화면에서만 1열로 내려가도록 레이아웃을 재정리했다.
|
||||
- 왼쪽 카드는 아바타/닉네임/이메일/로그아웃/프로필 저장을, 오른쪽 카드는 현재 비밀번호 확인과 새 비밀번호 저장을 담당하게 분리해, 설정 화면의 정보 묶음이 더 명확하게 읽히도록 맞췄다.
|
||||
|
||||
## 2026-04-03 v1.4.49
|
||||
- 설정 화면에 현재 비밀번호 확인 후 새 비밀번호를 직접 저장하는 `비밀번호 변경` 섹션을 추가하고, 백엔드에는 로그인 사용자용 `POST /api/auth/password` API를 붙였다.
|
||||
- 프로필 닉네임 저장 실패가 모두 `프로필 저장에 실패했어요.`로 뭉뚱그려 보이던 부분을 고쳐, 중복 닉네임은 `닉네임이 이미 사용 중이에요.`, 예약어 닉네임은 `사용할 수 없는 닉네임이에요.`처럼 회원가입 화면과 같은 맥락의 원인 안내로 분리했다.
|
||||
- 로그인한 상태로 `login?resetToken=...` 비밀번호 재설정 링크를 열면 기존 로그인 감시가 바로 내 티어표 화면으로 보내버릴 수 있었으므로, 인증/재설정 토큰이 있는 동안에는 자동 리다이렉트를 멈추고 재설정 입력 화면을 우선 보여주도록 보정했다.
|
||||
|
||||
## 2026-04-03 v1.4.48
|
||||
- 로컬 백엔드도 `.env.production`을 읽는 구조가 되면서 이메일 인증/비밀번호 재설정 링크의 `APP_ORIGIN`이 운영 도메인으로 잡히던 문제를 막기 위해, `backend`의 `dev/start` 스크립트에서 로컬 실행 시 `APP_ORIGIN=http://localhost:5173`을 먼저 주입하도록 분리했다.
|
||||
- 이로써 로컬 개발에서는 인증 메일 링크가 `localhost:5173`으로 열리고, 상용 Docker 배포에서는 `docker-compose.prod.yml`의 `APP_ORIGIN=https://tmaker.sori.studio`를 그대로 사용하도록 환경이 구분된다.
|
||||
|
||||
## 2026-04-03 v1.4.47
|
||||
- 로컬 개발 서버를 `npm run dev:backend`로 띄울 때 루트 `.env.production`의 `SMTP_*` 값이 자동으로 들어가지 않아 일반 회원가입이 `mail_not_configured` 503으로 실패할 수 있었으므로, 백엔드 엔트리에서 `dotenv`로 루트 `.env.production`을 먼저 로드하도록 보강했다.
|
||||
- 이 변경으로 Docker Compose 운영 환경은 기존 컨테이너 환경변수를 그대로 쓰면서, 로컬 개발 서버도 같은 `.env.production`의 Gmail SMTP 설정을 읽어 이메일 인증/비밀번호 재설정 메일 발송을 테스트할 수 있게 됐다.
|
||||
|
||||
## 2026-04-03 v1.4.46
|
||||
- 운영용 `.env.production`에는 Git에 올리지 않는 로컬 비밀값을 유지한 채, Gmail SMTP 발송에 필요한 `APP_ORIGIN`, `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` 항목을 추가했다.
|
||||
- Git에 추적되는 `.env.production.example`에도 같은 SMTP 환경변수 예시를 추가해, 실제 배포 설정에서 어떤 키를 채워야 하는지 파일만 보고도 바로 알 수 있게 정리했다.
|
||||
|
||||
## 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` 링크가 민트 단색이라 라이트 모드에서 배경과 충분히 분리되지 않을 수 있었으므로, 테마 텍스트 색 기반의 굵은 링크 스타일로 바꿔 다크/라이트 양쪽에서 읽히도록 조정했다.
|
||||
|
||||
|
||||
@@ -57,6 +57,13 @@ 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 } }),
|
||||
changePassword: ({ currentPassword, nextPassword }) =>
|
||||
request('/api/auth/password', { method: 'POST', body: { currentPassword, nextPassword } }),
|
||||
logout: () => request('/api/auth/logout', { method: 'POST' }),
|
||||
|
||||
listTopics: () => request('/api/topics'),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,14 @@ watch(
|
||||
() => [auth.hydrated, auth.user],
|
||||
([hydrated, user]) => {
|
||||
if (!hydrated || !user) return
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
|
||||
if (verifyToken.value || resetToken.value) return
|
||||
router.replace(redirectPath.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(mode, () => {
|
||||
error.value = ''
|
||||
emailError.value = ''
|
||||
nicknameError.value = ''
|
||||
clearFormFeedback()
|
||||
})
|
||||
|
||||
watch(email, () => {
|
||||
@@ -68,23 +135,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 +228,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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -126,18 +278,23 @@ async function submit() {
|
||||
<section v-else class="authScreen">
|
||||
<div class="authTabs" :class="{ 'authTabs--signup': mode === 'signup' }" role="tablist" aria-label="로그인 또는 회원가입">
|
||||
<span class="authTabs__indicator" aria-hidden="true"></span>
|
||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
|
||||
<button
|
||||
type="button"
|
||||
class="authTabs__button"
|
||||
:class="{ 'authTabs__button--active': mode === 'login' || mode === 'reset-request' || mode === 'reset-confirm' }"
|
||||
@click="switchMode('login')"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="mode = 'signup'">
|
||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="switchMode('signup')">
|
||||
회원가입
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="authFields" @submit.prevent="submit">
|
||||
<label class="field">
|
||||
<label v-if="mode !== 'reset-confirm'" class="field">
|
||||
<span class="field__label">이메일</span>
|
||||
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
|
||||
<input v-model="email" class="field__input" type="email" placeholder="you@example.com" autocomplete="email" maxlength="255" />
|
||||
<span v-if="emailError" class="field__error">{{ emailError }}</span>
|
||||
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255자</span>
|
||||
</label>
|
||||
@@ -149,20 +306,20 @@ async function submit() {
|
||||
<span class="field__hint">다른 사용자와 구분되는 이름으로 2~40자까지 입력할 수 있어요.</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">비밀번호</span>
|
||||
<label v-if="mode !== 'reset-request'" class="field">
|
||||
<span class="field__label">{{ mode === 'reset-confirm' ? '새 비밀번호' : '비밀번호' }}</span>
|
||||
<input
|
||||
v-model="password"
|
||||
class="field__input"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
autocomplete="current-password"
|
||||
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
|
||||
maxlength="120"
|
||||
/>
|
||||
<span class="field__hint">6~120자 입력 가능 · {{ password.length }}/120자</span>
|
||||
</label>
|
||||
|
||||
<label v-if="mode === 'signup'" class="field">
|
||||
<label v-if="mode === 'signup' || mode === 'reset-confirm'" class="field">
|
||||
<span class="field__label">비밀번호 확인</span>
|
||||
<input
|
||||
v-model="passwordConfirm"
|
||||
@@ -175,12 +332,28 @@ async function submit() {
|
||||
<span class="field__hint">같은 비밀번호를 한 번 더 입력해주세요. {{ passwordConfirm.length }}/120자</span>
|
||||
</label>
|
||||
|
||||
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||
<div v-if="mode === 'signup' && !hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||
<div v-if="notice" class="authNotice">{{ notice }}</div>
|
||||
<div v-if="error" class="authError">{{ error }}</div>
|
||||
|
||||
<div v-if="mode === 'login'" class="authHelpRow">
|
||||
<button type="button" class="linkAction" @click="switchMode('reset-request')">비밀번호를 잊으셨나요?</button>
|
||||
<button
|
||||
v-if="pendingVerificationEmail || error === '이메일 인증이 아직 완료되지 않았어요. 아래 버튼으로 인증 메일을 다시 받을 수 있어요.'"
|
||||
type="button"
|
||||
class="linkAction"
|
||||
:disabled="isSubmitting"
|
||||
@click="resendVerificationEmail"
|
||||
>
|
||||
인증 메일 재전송
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="authActions">
|
||||
<button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button>
|
||||
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
|
||||
<button class="secondaryAction" type="button" @click="mode === 'reset-request' || mode === 'reset-confirm' ? switchMode('login') : router.push(homePath())">
|
||||
{{ mode === 'reset-request' || mode === 'reset-confirm' ? '로그인으로 돌아가기' : '취소' }}
|
||||
</button>
|
||||
<button class="primaryAction" type="submit" :disabled="isSubmitting">{{ isSubmitting ? '처리 중...' : submitLabel }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -316,6 +489,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 +544,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);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { api } from '../lib/api'
|
||||
import { homePath, loginPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -12,11 +13,18 @@ const toast = useToast()
|
||||
|
||||
const error = ref('')
|
||||
const saving = ref(false)
|
||||
const passwordSaving = ref(false)
|
||||
const nickname = ref('')
|
||||
const nicknameError = ref('')
|
||||
const previewUrl = ref('')
|
||||
const avatarFile = ref(null)
|
||||
const removeAvatar = ref(false)
|
||||
const fileInput = ref(null)
|
||||
const currentPassword = ref('')
|
||||
const nextPassword = ref('')
|
||||
const nextPasswordConfirm = ref('')
|
||||
const currentPasswordError = ref('')
|
||||
const nextPasswordError = ref('')
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -67,6 +75,15 @@ function onAvatarChange(e) {
|
||||
previewUrl.value = URL.createObjectURL(file)
|
||||
}
|
||||
|
||||
function clearProfileFieldErrors() {
|
||||
nicknameError.value = ''
|
||||
}
|
||||
|
||||
function clearPasswordFieldErrors() {
|
||||
currentPasswordError.value = ''
|
||||
nextPasswordError.value = ''
|
||||
}
|
||||
|
||||
function clearAvatar() {
|
||||
error.value = ''
|
||||
avatarFile.value = null
|
||||
@@ -80,6 +97,14 @@ function clearAvatar() {
|
||||
|
||||
async function saveProfile() {
|
||||
error.value = ''
|
||||
clearProfileFieldErrors()
|
||||
|
||||
if (nickname.value.trim().length < 2) {
|
||||
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
|
||||
error.value = '닉네임을 확인해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
@@ -92,8 +117,13 @@ async function saveProfile() {
|
||||
credentials: 'include',
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) throw new Error('upload_failed')
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
const requestError = new Error('profile_update_failed')
|
||||
requestError.data = data
|
||||
requestError.status = res.status
|
||||
throw requestError
|
||||
}
|
||||
auth.user = data.user
|
||||
avatarFile.value = null
|
||||
removeAvatar.value = false
|
||||
@@ -104,12 +134,60 @@ async function saveProfile() {
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
toast.success('프로필을 저장했어요.')
|
||||
} catch (e2) {
|
||||
error.value = '프로필 저장에 실패했어요.'
|
||||
const code = e2?.data?.error
|
||||
if (code === 'nickname_taken') {
|
||||
nicknameError.value = '이미 사용 중인 닉네임입니다.'
|
||||
error.value = '닉네임이 이미 사용 중이에요.'
|
||||
} else if (code === 'nickname_reserved') {
|
||||
nicknameError.value = '운영자/관리자처럼 혼동될 수 있는 닉네임은 사용할 수 없어요.'
|
||||
error.value = '사용할 수 없는 닉네임이에요.'
|
||||
} else {
|
||||
error.value = '프로필 저장에 실패했어요.'
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
error.value = ''
|
||||
clearPasswordFieldErrors()
|
||||
|
||||
if (nextPassword.value.length < 6) {
|
||||
nextPasswordError.value = '새 비밀번호는 6자 이상 입력해주세요.'
|
||||
error.value = '새 비밀번호를 확인해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
if (nextPassword.value !== nextPasswordConfirm.value) {
|
||||
nextPasswordError.value = '비밀번호 확인이 일치하지 않아요.'
|
||||
error.value = '비밀번호 확인이 일치하지 않아요.'
|
||||
return
|
||||
}
|
||||
|
||||
passwordSaving.value = true
|
||||
try {
|
||||
const data = await api.changePassword({
|
||||
currentPassword: currentPassword.value,
|
||||
nextPassword: nextPassword.value,
|
||||
})
|
||||
auth.user = data.user
|
||||
currentPassword.value = ''
|
||||
nextPassword.value = ''
|
||||
nextPasswordConfirm.value = ''
|
||||
toast.success('비밀번호를 변경했어요.')
|
||||
} catch (e2) {
|
||||
if (e2?.data?.error === 'invalid_current_password') {
|
||||
currentPasswordError.value = '현재 비밀번호가 일치하지 않아요.'
|
||||
error.value = '현재 비밀번호가 일치하지 않아요.'
|
||||
} else {
|
||||
error.value = '비밀번호 변경에 실패했어요.'
|
||||
}
|
||||
} finally {
|
||||
passwordSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
toast.success('로그아웃했어요.')
|
||||
@@ -132,56 +210,114 @@ async function logout() {
|
||||
</section>
|
||||
|
||||
<section v-else-if="auth.user" class="settingsScreen">
|
||||
<div class="settingsIdentity">
|
||||
<div class="avatarButtonWrap">
|
||||
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
|
||||
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
|
||||
<div class="avatarButton__overlay">
|
||||
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
|
||||
<div class="settingsGrid">
|
||||
<article class="settingsPanel">
|
||||
<div class="settingsIdentity">
|
||||
<div class="avatarButtonWrap">
|
||||
<button class="avatarButton" type="button" @click="openAvatarPicker">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarButton__image" alt="avatar" draggable="false" />
|
||||
<div v-else class="avatarButton__fallback">{{ displayInitial }}</div>
|
||||
<div class="avatarButton__overlay">
|
||||
<span>{{ avatarUrl ? '이미지 변경' : '이미지 추가' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
v-if="avatarUrl || previewUrl"
|
||||
class="avatarButton__remove"
|
||||
type="button"
|
||||
aria-label="프로필 이미지 삭제"
|
||||
@click="clearAvatar"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
v-if="avatarUrl || previewUrl"
|
||||
class="avatarButton__remove"
|
||||
type="button"
|
||||
aria-label="프로필 이미지 삭제"
|
||||
@click="clearAvatar"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="identityMeta">
|
||||
<div class="identityMeta__eyebrow">Profile Photo</div>
|
||||
<div class="identityMeta__title">프로필 이미지</div>
|
||||
<div class="identityMeta__desc">아바타를 클릭해서 이미지를 추가하거나 교체할 수 있습니다.</div>
|
||||
</div>
|
||||
<div class="identityMeta">
|
||||
<div class="identityMeta__eyebrow">Profile Photo</div>
|
||||
<div class="identityMeta__title">프로필 정보</div>
|
||||
<div class="identityMeta__desc">아바타와 닉네임을 정리하고, 현재 계정 이메일을 확인할 수 있어요.</div>
|
||||
</div>
|
||||
|
||||
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
||||
</div>
|
||||
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
||||
</div>
|
||||
|
||||
<div class="settingsFields">
|
||||
<label class="field">
|
||||
<span class="field__label">닉네임</span>
|
||||
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
|
||||
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40자</span>
|
||||
</label>
|
||||
<div class="settingsFields">
|
||||
<label class="field">
|
||||
<span class="field__label">닉네임</span>
|
||||
<input v-model="nickname" class="field__input" maxlength="40" placeholder="작성자 닉네임" />
|
||||
<span v-if="nicknameError" class="field__error">{{ nicknameError }}</span>
|
||||
<span class="field__hint">티어표 작성자 이름으로 표시됩니다. {{ nickname.length }}/40자</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">이메일</span>
|
||||
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
|
||||
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field__label">이메일</span>
|
||||
<input :value="auth.user.email" class="field__input field__input--readonly" readonly />
|
||||
<span class="field__hint">현재 로그인에 사용 중인 계정입니다.</span>
|
||||
</label>
|
||||
|
||||
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
|
||||
</div>
|
||||
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
|
||||
</div>
|
||||
|
||||
<div class="settingsActions">
|
||||
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
|
||||
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
|
||||
<div class="settingsActions">
|
||||
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
|
||||
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="settingsPanel">
|
||||
<div class="identityMeta__eyebrow">Password</div>
|
||||
<div class="identityMeta__title">비밀번호 변경</div>
|
||||
<div class="identityMeta__desc">현재 비밀번호를 확인한 뒤 새 비밀번호로 바꿀 수 있어요.</div>
|
||||
|
||||
<div class="settingsFields settingsFields--password">
|
||||
<label class="field">
|
||||
<span class="field__label">현재 비밀번호</span>
|
||||
<input
|
||||
v-model="currentPassword"
|
||||
class="field__input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
maxlength="120"
|
||||
placeholder="현재 비밀번호"
|
||||
/>
|
||||
<span v-if="currentPasswordError" class="field__error">{{ currentPasswordError }}</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">새 비밀번호</span>
|
||||
<input
|
||||
v-model="nextPassword"
|
||||
class="field__input"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
maxlength="120"
|
||||
placeholder="새 비밀번호"
|
||||
/>
|
||||
<span v-if="nextPasswordError" class="field__error">{{ nextPasswordError }}</span>
|
||||
<span class="field__hint">6~120자 입력 가능 · {{ nextPassword.length }}/120자</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">새 비밀번호 확인</span>
|
||||
<input
|
||||
v-model="nextPasswordConfirm"
|
||||
class="field__input"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
maxlength="120"
|
||||
placeholder="새 비밀번호 확인"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settingsActions">
|
||||
<button class="primaryAction" type="button" :disabled="passwordSaving" @click="savePassword">
|
||||
{{ passwordSaving ? '변경 중...' : '비밀번호 변경' }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -190,8 +326,7 @@ async function logout() {
|
||||
<style scoped>
|
||||
.settingsScreen {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
max-width: 620px;
|
||||
gap: 24px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
@@ -212,6 +347,22 @@ async function logout() {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settingsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 1fr) minmax(360px, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settingsPanel {
|
||||
min-width: 0;
|
||||
padding: 28px;
|
||||
border: 1px solid var(--theme-border);
|
||||
border-radius: 28px;
|
||||
background: var(--theme-surface);
|
||||
box-shadow: var(--theme-card-shadow);
|
||||
}
|
||||
|
||||
.avatarButtonWrap {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
@@ -355,6 +506,12 @@ async function logout() {
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
|
||||
.field__error {
|
||||
font-size: 12px;
|
||||
color: #ff7b7b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.roleBadge {
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
@@ -373,6 +530,10 @@ async function logout() {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.settingsFields--password {
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
padding: 12px 18px;
|
||||
@@ -394,6 +555,15 @@ async function logout() {
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settingsGrid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settingsPanel {
|
||||
padding: 22px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.settingsIdentity {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user