Compare commits

...

4 Commits

13 changed files with 235 additions and 6 deletions

View File

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

View File

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

View File

@@ -11,6 +11,7 @@
"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",
@@ -854,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",

View File

@@ -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,6 +17,7 @@
"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",

View File

@@ -53,6 +53,11 @@ const confirmPasswordResetSchema = z.object({
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(),
@@ -322,6 +327,24 @@ router.post('/password-reset/confirm', async (req, res) => {
}
})
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) => {

View File

@@ -1,5 +1,10 @@
# 의사결정 이력
## 2026-04-03 v1.4.49
- 프로필 저장 실패를 하나의 일반 실패 메시지로만 보여주면 사용자가 “서버가 고장났나?”라고 오해하기 쉬우므로, 중복 닉네임/예약어 닉네임처럼 사용자가 직접 고칠 수 있는 입력 오류는 원인별 안내를 분리하는 편이 맞다고 판단했다.
- 비밀번호를 잊은 사용자뿐 아니라 로그인 중인 사용자도 보안상 주기적으로 비밀번호를 직접 바꿀 수 있어야 하므로, 설정 화면에 현재 비밀번호 확인 기반 변경 폼을 추가하는 쪽으로 정리했다.
- 비밀번호 재설정 링크는 로그인 세션이 남아 있어도 링크 토큰 자체의 목적이 우선이므로, `/login?resetToken=...` 진입 시에는 기존 자동 리다이렉트보다 재설정 폼 렌더링을 우선하는 편이 맞다고 판단했다.
## 2026-04-03 v1.4.45
- 실제 서비스에서는 남의 이메일 주소로 가입만 먼저 해두는 문제가 생길 수 있으므로, 일반 회원은 가입 직후 인증 메일을 거쳐야 로그인할 수 있게 하고 비밀번호 분실도 메일 토큰 기반으로 복구하는 구조가 필요하다고 판단했다.
- 다만 초기 운영자가 바로 서비스를 띄울 수 있어야 하므로, 첫 번째 가입 계정만은 기존처럼 이메일 인증 없이 바로 최고 관리자 계정으로 활성화하는 예외를 유지하는 편이 맞다고 정리했다.

View File

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

View File

@@ -132,6 +132,8 @@
- `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`
@@ -284,6 +286,8 @@
- 인증 메일/비밀번호 재설정 메일 토큰은 원문을 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)이다.

View File

@@ -1,6 +1,12 @@
# 할 일 및 이슈
## 단기 확인
- `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` 상태 안내가 뜨고, `인증 메일 재전송` 버튼으로 같은 메일 주소에 인증 링크를 다시 보낼 수 있는지 확인한다.

View File

@@ -1,5 +1,22 @@
# 업데이트 로그
## 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` 화면에서 인증 완료와 비밀번호 재설정을 이어서 처리할 수 있게 했다.

View File

@@ -62,6 +62,8 @@ export const api = {
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'),

View File

@@ -115,6 +115,7 @@ watch(
() => [auth.hydrated, auth.user],
([hydrated, user]) => {
if (!hydrated || !user) return
if (verifyToken.value || resetToken.value) return
router.replace(redirectPath.value)
},
{ immediate: true }

View File

@@ -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('로그아웃했어요.')
@@ -167,6 +245,7 @@ async function logout() {
<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>
@@ -183,6 +262,59 @@ async function logout() {
<button class="primaryAction" :disabled="saving" @click="saveProfile">{{ saving ? '저장 중...' : '변경사항 저장' }}</button>
<button class="secondaryAction" type="button" @click="logout">로그아웃</button>
</div>
<div class="passwordPanel">
<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>
</div>
</section>
</section>
</template>
@@ -355,6 +487,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 +511,15 @@ async function logout() {
padding-top: 8px;
}
.settingsFields--password {
padding-top: 20px;
}
.passwordPanel {
padding-top: 6px;
border-top: 1px solid var(--theme-border);
}
.primaryAction,
.secondaryAction {
padding: 12px 18px;