From 672d17849b1af93ec7755b648d1e1fac9c6cd70d Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 2 Apr 2026 22:35:14 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.4.33=20?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EA=B2=80=EC=A6=9D=EA=B3=BC=20=ED=85=8C?= =?UTF-8?q?=EB=A7=88=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 28 +++++++ backend/src/lib/user-validation.js | 48 +++++++++++ backend/src/routes/admin.js | 15 ++++ backend/src/routes/auth.js | 18 ++++- docs/history.md | 5 ++ docs/todo.md | 3 + docs/update.md | 6 ++ frontend/src/App.vue | 4 +- .../admin/AdminTemplatesSection.vue | 3 - frontend/src/lib/api.js | 2 +- frontend/src/stores/auth.js | 4 +- frontend/src/views/AdminView.vue | 16 ++++ frontend/src/views/LoginView.vue | 81 ++++++++++++++++--- 13 files changed, 212 insertions(+), 21 deletions(-) create mode 100644 backend/src/lib/user-validation.js diff --git a/backend/src/db.js b/backend/src/db.js index 4fe05c7..1c929d0 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -575,6 +575,33 @@ async function findUserByEmail(email) { return { ...mapUserRow(row), passwordHash: row.password_hash } } +async function findUserByNickname(nickname, excludeUserId = '') { + const normalized = String(nickname || '').trim() + if (!normalized) return null + const rows = excludeUserId + ? await query( + ` + SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at + FROM users + WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ? + LIMIT 1 + `, + [normalized, excludeUserId] + ) + : await query( + ` + SELECT id, email, nickname, password_hash, is_admin, avatar_src, created_at + FROM users + WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) + LIMIT 1 + `, + [normalized] + ) + const row = rows[0] + if (!row) return null + return { ...mapUserRow(row), passwordHash: row.password_hash } +} + async function findUserById(id) { const rows = await query( 'SELECT id, email, nickname, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1', @@ -2471,6 +2498,7 @@ module.exports = { closePool, countUsers, findUserByEmail, + findUserByNickname, findUserById, createUser, updateUserProfile, diff --git a/backend/src/lib/user-validation.js b/backend/src/lib/user-validation.js new file mode 100644 index 0000000..06e91c7 --- /dev/null +++ b/backend/src/lib/user-validation.js @@ -0,0 +1,48 @@ +const RESERVED_NICKNAME_KEYWORDS = [ + 'admin', + 'administrator', + 'operator', + 'owner', + 'master', + 'staff', + 'system', + 'root', + 'support', + 'manager', + 'mod', + 'moderator', + 'official', + 'service', + 'team', + 'zenn', + '운영자', + '관리자', + '오너', + '마스터', + '스태프', + '시스템', + '루트', + '서포트', + '매니저', + '모더레이터', + '공식', +] + +function normalizeNickname(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, '') +} + +function isReservedNickname(value) { + const normalized = normalizeNickname(value) + if (!normalized) return false + return RESERVED_NICKNAME_KEYWORDS.some((keyword) => normalized.includes(normalizeNickname(keyword))) +} + +module.exports = { + RESERVED_NICKNAME_KEYWORDS, + normalizeNickname, + isReservedNickname, +} diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 9e03dc7..0212d59 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -7,6 +7,8 @@ const { z } = require('zod') const { nanoid } = require('nanoid') const { findUserById, + findUserByEmail, + findUserByNickname, findTopicById, findTopicItemById, listTopicItems, @@ -52,6 +54,7 @@ const { } = require('../db') const { requireAdmin } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage') +const { isReservedNickname } = require('../lib/user-validation') const router = express.Router() @@ -962,6 +965,18 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => { return res.status(403).json({ error: 'primary_admin_only' }) } + if (isReservedNickname(parsed.data.nickname)) { + return res.status(400).json({ error: 'nickname_reserved' }) + } + const duplicateEmail = await findUserByEmail(parsed.data.email) + if (duplicateEmail && duplicateEmail.id !== targetUser.id) { + return res.status(409).json({ error: 'email_taken' }) + } + const duplicateNickname = await findUserByNickname(parsed.data.nickname, targetUser.id) + if (duplicateNickname) { + return res.status(409).json({ error: 'nickname_taken' }) + } + try { const updated = await adminUpdateUser({ id: targetUser.id, diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 4721d29..ef13b51 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -6,6 +6,7 @@ const multer = require('multer') const { countUsers, findUserByEmail, + findUserByNickname, findUserById, createUser, updateUserProfile, @@ -13,11 +14,13 @@ const { } = require('../db') const { requireAuth } = require('../middleware/auth') const { createMemoryUpload, writeOptimizedImage } = require('../lib/image-storage') +const { isReservedNickname } = require('../lib/user-validation') const router = express.Router() const signupSchema = z.object({ email: z.string().email(), + nickname: z.string().trim().min(2).max(40), password: z.string().min(6), }) @@ -62,13 +65,16 @@ router.post('/signup', async (req, res) => { const parsed = signupSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const { email, password } = parsed.data + const { email, nickname, password } = parsed.data const exists = await findUserByEmail(email) if (exists) return res.status(409).json({ error: 'email_taken' }) + if (isReservedNickname(nickname)) return res.status(400).json({ error: 'nickname_reserved' }) + const nicknameExists = await findUserByNickname(nickname) + if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' }) const passwordHash = await bcrypt.hash(password, 10) const isAdmin = (await countUsers()) === 0 - const user = await createUser({ id: nanoid(), email, nickname: '', passwordHash, isAdmin }) + const user = await createUser({ id: nanoid(), email, nickname, passwordHash, isAdmin }) try { await establishSession(req, user) @@ -79,7 +85,10 @@ router.post('/signup', async (req, res) => { }) router.post('/login', async (req, res) => { - const parsed = signupSchema.safeParse(req.body) + const parsed = z.object({ + email: z.string().email(), + password: z.string().min(6), + }).safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) const { email, password } = parsed.data @@ -121,6 +130,9 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) = const user = await findUserById(req.session.userId) if (!user) return res.status(404).json({ error: 'not_found' }) + if (isReservedNickname(parsed.data.nickname)) return res.status(400).json({ error: 'nickname_reserved' }) + const nicknameExists = await findUserByNickname(parsed.data.nickname, user.id) + if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' }) const optimized = req.file ? await writeOptimizedImage({ diff --git a/docs/history.md b/docs/history.md index e688957..976dd08 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-02 v1.4.33 +- 서비스 공개 전 단계에서는 가입 자체를 열어두는 것보다, 이메일/닉네임 중복과 운영자 사칭성 닉네임을 먼저 막아두는 편이 훨씬 중요하다고 판단했다. +- 닉네임 제한은 회원가입 한 곳에만 두면 이후 프로필 수정이나 관리자 수정으로 쉽게 우회되므로, auth/profile/admin 수정 흐름 전부가 같은 예약어 정책을 공유하도록 정리했다. +- 라이트 모드는 취향상 필요한 사용자가 있을 수 있으므로 완전히 제거하기보다, 기본값만 다크로 고정하고 설정 화면에서만 직접 토글하도록 두는 편이 더 균형 잡힌 선택이라고 정리했다. + ## 2026-04-02 v1.4.32 - 서비스 공개 전 마감 단계에서는 사용자 노출 텍스트만이 아니라 파일명·composable 이름·관리자 CSS 클래스·백엔드 헬퍼 함수명까지 같이 정리해 두는 편이 이후 유지보수 비용을 확실히 낮춘다고 판단했다. - 이 시점부터는 `game`이 데이터 호환층도 아닌 단순 내부 이름으로 남아 있는 것조차 혼란을 만들 수 있으므로, 실제 기능을 바꾸지 않는 선에서 이름층까지 끝까지 정리해 코드 검색 결과 자체를 깨끗하게 만드는 방향으로 마감했다. diff --git a/docs/todo.md b/docs/todo.md index 35c59aa..98e9ab0 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,9 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.33`에서 회원가입에 닉네임 입력과 중복/예약어 검사를 붙였으므로, 실제 QA에서는 이메일 중복, 닉네임 중복, 예약 닉네임, 프로필 닉네임 변경, 관리자 회원 수정 흐름이 같은 규칙으로 막히는지 확인한다. +- 테마는 저장값이 없을 때 무조건 다크로 시작하게 바꿨고 설정 화면 토글도 다시 열었으므로, 첫 접속/새 브라우저/다른 운영체제에서 기본 다크 시작과 수동 토글 저장이 그대로 정상인지 확인한다. +- 관리자 템플릿 썸네일 드롭존 빈 상태 아이콘 제거와 아이템 상세 모달 썸네일 프리뷰가 들어갔으므로, 관리자 화면에서 썸네일 교체와 아이템 선택 모달 가독성을 한 번 더 QA한다. - `v1.4.32`에서 파일명·composable·관리자 클래스명·백엔드 헬퍼 함수명까지 `topic/template` 기준으로 끝까지 정리했으므로, 다음 실제 QA는 기능 동작 확인에 집중하고 이름층 회귀는 별도 체크만 하면 된다. - 현재 `backend/src`, `frontend/src`, `backend/scripts`, `backend/index.js` 기준 `game/Game` 검색은 0건이므로, 이후 남는 확인 작업은 서비스 동작과 배포 환경 쪽에만 집중한다. - `v1.4.31`에서 `/games` redirect와 legacy DB 마이그레이션까지 제거했으므로, 실제 QA에서는 오직 현재 주소(`/topics`, `/admin/templates`)와 새 DB 기준 흐름만 집중적으로 확인하면 된다. diff --git a/docs/update.md b/docs/update.md index de820ee..dffc9e3 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 로그 +## 2026-04-02 v1.4.33 +- 회원가입 시 닉네임 입력을 함께 받도록 바꾸고, 이메일 중복과 닉네임 중복을 서버에서 명확히 차단하도록 정리했다. +- `admin`, `운영자`, `관리자`, `official`, `zenn`처럼 운영자·공식 계정으로 오해될 수 있는 닉네임은 예약어로 막고, 프로필 수정/관리자 회원 수정에서도 같은 규칙을 공유하도록 맞췄다. +- 로그인·회원가입 화면은 중복된 이메일/닉네임일 때 빨간색 오류 메시지를 바로 보여주도록 보강했고, 테마는 저장값이 없을 때 무조건 다크로 시작하면서 설정 화면에서만 라이트/다크 토글을 다시 노출하도록 정리했다. +- 관리자 템플릿 썸네일 드롭존의 빈 상태 아이콘은 제거했고, 아이템 상세 모달에는 선택한 썸네일 프리뷰를 추가해 현재 선택한 이미지가 더 잘 보이게 했다. + ## 2026-04-02 v1.4.32 - 파일명과 내부 심볼 이름까지 `topic/template` 기준으로 마감했다. `GameHubView`는 `TopicHubView`, `AdminGamesSection`은 `AdminTemplatesSection`, `useAdminGameManager`와 `useAdminFeaturedGames`는 각각 `useAdminTemplateManager`, `useAdminFeaturedTemplates`로 정리했다. - 관리자 화면 내부 상태와 스타일 클래스도 `adminTemplatePicker`, `templateManagerGrid`, `templateSettingsCard` 기준으로 바꿔, 사용자에게는 안 보이지만 코드 검색에서 남던 `Game` 흔적을 더 걷어냈다. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c1274db..a0e2f44 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -137,7 +137,7 @@ const isGuidePrevDisabled = computed(() => guideStepIndex.value <= 0) const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.length - 1) const isLightTheme = computed(() => themeMode.value === 'light') const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드')) -const showSettingsThemePanel = computed(() => false && route.name === 'profile') +const showSettingsThemePanel = computed(() => route.name === 'profile') const showTopicViewToggle = computed(() => route.name === 'topicHub') const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid')) const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value)) @@ -275,7 +275,7 @@ onMounted(async () => { if (typeof window !== 'undefined') { const savedTheme = window.localStorage.getItem('tier-maker:theme') if (savedTheme === 'light' || savedTheme === 'dark') applyTheme(savedTheme) - else applyTheme(window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark') + else applyTheme('dark') } await auth.refresh() if (typeof window !== 'undefined') { diff --git a/frontend/src/components/admin/AdminTemplatesSection.vue b/frontend/src/components/admin/AdminTemplatesSection.vue index fe5970b..4312cb4 100644 --- a/frontend/src/components/admin/AdminTemplatesSection.vue +++ b/frontend/src/components/admin/AdminTemplatesSection.vue @@ -125,9 +125,6 @@ function setThumbFileElement(el) {
대표 썸네일
-
- -
{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index d4a40fc..339c469 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -55,7 +55,7 @@ async function request(path, { method = 'GET', body, headers } = {}) { export const api = { me: () => request('/api/auth/me'), authMeta: () => request('/api/auth/meta'), - signup: ({ email, password }) => request('/api/auth/signup', { method: 'POST', body: { email, password } }), + 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 } }), logout: () => request('/api/auth/logout', { method: 'POST' }), diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 39787c3..3f1be55 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -29,8 +29,8 @@ export const useAuthStore = defineStore('auth', { })() return refreshPromise }, - async signup(email, password) { - const user = await api.signup({ email, password }) + async signup(email, nickname, password) { + const user = await api.signup({ email, nickname, password }) this.user = user this.hydrated = true return user diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index e4058bd..1896da7 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1987,6 +1987,9 @@ function userAvatarFallback(user) {
+
+ +
{{ modalTargetCustomItem.label }}
@@ -3517,6 +3520,19 @@ function userAvatarFallback(user) { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.18) transparent; } +.adminUiScope .customItemModal__preview { + display: flex; + justify-content: flex-start; +} +.adminUiScope .customItemModal__previewImage { + width: 88px; + height: 88px; + object-fit: cover; + border-radius: 18px; + border: 1px solid var(--theme-border); + background: var(--theme-surface-soft); + flex: 0 0 auto; +} .adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar, .adminUiScope .customItemModal__content::-webkit-scrollbar { width: 8px; diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index d84edde..62cc586 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -4,25 +4,20 @@ import { useRoute, useRouter } from 'vue-router' import { useAuthStore } from '../stores/auth' import { api } from '../lib/api' import { homePath, mePath } from '../lib/paths' -import { useToast } from '../composables/useToast' const router = useRouter() const route = useRoute() const auth = useAuthStore() -const toast = useToast() const email = ref('') +const nickname = ref('') const password = ref('') const passwordConfirm = ref('') const mode = ref('login') const error = ref('') const hasUsers = ref(true) - -watch(error, (message) => { - if (!message) return - toast.error(message) - error.value = '' -}) +const emailError = ref('') +const nicknameError = ref('') const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인')) const description = computed(() => @@ -57,18 +52,59 @@ watch( { immediate: true } ) +watch(mode, () => { + error.value = '' + emailError.value = '' + nicknameError.value = '' +}) + +watch(email, () => { + emailError.value = '' + if (error.value === '이메일이 이미 사용 중이에요.') error.value = '' +}) + +watch(nickname, () => { + nicknameError.value = '' + if (error.value === '닉네임이 이미 사용 중이에요.' || error.value === '사용할 수 없는 닉네임이에요.') error.value = '' +}) + async function submit() { error.value = '' + emailError.value = '' + nicknameError.value = '' + if (mode.value === 'signup' && nickname.value.trim().length < 2) { + nicknameError.value = '닉네임은 2자 이상 입력해주세요.' + error.value = '닉네임을 확인해주세요.' + return + } if (mode.value === 'signup' && password.value !== passwordConfirm.value) { error.value = '비밀번호 확인이 일치하지 않아요.' return } try { - if (mode.value === 'signup') await auth.signup(email.value, password.value) + 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()) } catch (e) { - error.value = '로그인/회원가입에 실패했어요.' + const code = e?.data?.error + if (mode.value === 'signup') { + if (code === 'email_taken') { + emailError.value = '이미 사용 중인 이메일입니다.' + error.value = '이메일이 이미 사용 중이에요.' + return + } + if (code === 'nickname_taken') { + nicknameError.value = '이미 사용 중인 닉네임입니다.' + error.value = '닉네임이 이미 사용 중이에요.' + return + } + if (code === 'nickname_reserved') { + nicknameError.value = '운영자/관리자처럼 혼동될 수 있는 닉네임은 사용할 수 없어요.' + error.value = '사용할 수 없는 닉네임이에요.' + return + } + } + error.value = mode.value === 'signup' ? '회원가입에 실패했어요.' : '로그인에 실패했어요.' } } @@ -102,9 +138,17 @@ async function submit() { + +