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) {