From 923a9af83d804326ab41fa04eacdf1e18b2e2f56 Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 7 Apr 2026 15:03:30 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A4=EC=A0=95=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=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 | 34 +- backend/src/routes/auth.js | 22 +- docs/convention.md | 1 + docs/history.md | 4 + docs/map.md | 2 +- docs/spec.md | 3 + docs/todo.md | 5 + docs/update.md | 7 + frontend/src/App.vue | 3 +- frontend/src/views/ProfileView.vue | 729 +++++++++++++++++++++-------- 10 files changed, 588 insertions(+), 222 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 56dc8ff..339188e 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -90,6 +90,7 @@ function mapUserRow(row) { id: row.id, email: row.email, nickname: row.nickname || '', + nicknameUpdatedAt: Number(row.nickname_updated_at || 0), emailVerified: row.email_verified == null ? true : !!row.email_verified, isAdmin: !!row.is_admin, avatarSrc: row.avatar_src || '', @@ -412,6 +413,7 @@ async function ensureSchema() { id VARCHAR(64) PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, nickname VARCHAR(80) NOT NULL DEFAULT '', + nickname_updated_at BIGINT NOT NULL DEFAULT 0, password_hash VARCHAR(255) NOT NULL, email_verified TINYINT(1) NOT NULL DEFAULT 1, is_admin TINYINT(1) NOT NULL DEFAULT 0, @@ -494,6 +496,8 @@ async function ensureSchema() { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + await query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS nickname_updated_at BIGINT NOT NULL DEFAULT 0 AFTER nickname`) + await query(`UPDATE users SET nickname_updated_at = created_at WHERE nickname_updated_at = 0`) await query(`ALTER TABLE topics ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER thumbnail_src`) await query(`ALTER TABLE topic_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`) await query(`ALTER TABLE custom_items ADD COLUMN IF NOT EXISTS tags_json LONGTEXT NULL AFTER label`) @@ -696,7 +700,7 @@ async function countUsers() { async function findUserByEmail(email) { const rows = await query( - 'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1', + 'SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1', [email] ) const row = rows[0] @@ -710,7 +714,7 @@ async function findUserByNickname(nickname, excludeUserId = '') { const rows = excludeUserId ? await query( ` - SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at + SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ? LIMIT 1 @@ -719,7 +723,7 @@ async function findUserByNickname(nickname, excludeUserId = '') { ) : await query( ` - SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at + SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) LIMIT 1 @@ -733,7 +737,7 @@ async function findUserByNickname(nickname, excludeUserId = '') { async function findUserById(id) { const rows = await query( - 'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1', + 'SELECT id, email, nickname, nickname_updated_at, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1', [id] ) return mapUserRow(rows[0]) @@ -743,10 +747,10 @@ async function createUser({ id, email, nickname, passwordHash, emailVerified = t const createdAt = now() await query( ` - INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO users (id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - [id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt] + [id, email, nickname || '', createdAt, passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt] ) return findUserById(id) } @@ -875,11 +879,21 @@ 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 }) { +async function updateUserProfile({ id, nickname, avatarSrc, touchNicknameUpdatedAt = false }) { if (typeof avatarSrc === 'string') { - await query('UPDATE users SET nickname = ?, avatar_src = ? WHERE id = ?', [nickname || '', avatarSrc, id]) + await query( + `UPDATE users + SET nickname = ?, avatar_src = ?, nickname_updated_at = CASE WHEN ? = 1 THEN ? ELSE nickname_updated_at END + WHERE id = ?`, + [nickname || '', avatarSrc, touchNicknameUpdatedAt ? 1 : 0, touchNicknameUpdatedAt ? now() : 0, id] + ) } else { - await query('UPDATE users SET nickname = ? WHERE id = ?', [nickname || '', id]) + await query( + `UPDATE users + SET nickname = ?, nickname_updated_at = CASE WHEN ? = 1 THEN ? ELSE nickname_updated_at END + WHERE id = ?`, + [nickname || '', touchNicknameUpdatedAt ? 1 : 0, touchNicknameUpdatedAt ? now() : 0, id] + ) } return findUserById(id) } diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 6634255..a8c9719 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -30,6 +30,7 @@ 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 NICKNAME_CHANGE_INTERVAL_MS = 14 * 24 * 60 * 60 * 1000 const signupSchema = z.object({ email: z.string().email(), @@ -60,7 +61,7 @@ const changePasswordSchema = z.object({ }) const profileSchema = z.object({ - nickname: z.string().trim().min(1).max(40), + nickname: z.string().trim().min(2).max(40), removeAvatar: z.union([z.string(), z.undefined()]).optional(), }) @@ -87,6 +88,8 @@ async function serializeUser(user) { id: user.id, email: user.email, nickname: user.nickname || '', + nicknameUpdatedAt: user.nicknameUpdatedAt || 0, + nicknameChangeAvailableAt: (user.nicknameUpdatedAt || 0) + NICKNAME_CHANGE_INTERVAL_MS, isAdmin: !!user.isAdmin, isPrimaryAdmin, isOperator: !!user.isAdmin && !isPrimaryAdmin, @@ -358,8 +361,18 @@ 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) + const normalizedNickname = parsed.data.nickname.trim() + const nicknameChanged = normalizedNickname !== (user.nickname || '').trim() + + if (isReservedNickname(normalizedNickname)) return res.status(400).json({ error: 'nickname_reserved' }) + if (nicknameChanged && user.nicknameUpdatedAt && Date.now() < user.nicknameUpdatedAt + NICKNAME_CHANGE_INTERVAL_MS) { + return res.status(429).json({ + error: 'nickname_change_locked', + nicknameChangeAvailableAt: user.nicknameUpdatedAt + NICKNAME_CHANGE_INTERVAL_MS, + }) + } + + const nicknameExists = await findUserByNickname(normalizedNickname, user.id) if (nicknameExists) return res.status(409).json({ error: 'nickname_taken' }) const optimized = req.file @@ -377,8 +390,9 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) = const nextAvatarSrc = shouldRemoveAvatar ? '' : optimized?.src || user.avatarSrc || '' const updated = await updateUserProfile({ id: user.id, - nickname: parsed.data.nickname, + nickname: normalizedNickname, avatarSrc: nextAvatarSrc, + touchNicknameUpdatedAt: nicknameChanged, }) res.json({ user: await serializeUser(updated) }) diff --git a/docs/convention.md b/docs/convention.md index 08240c1..67b782d 100644 --- a/docs/convention.md +++ b/docs/convention.md @@ -13,6 +13,7 @@ - API 호출은 `frontend/src/lib/api.js` 또는 런타임 유틸을 통해 통합한다. - 정적 파일 URL 조합은 `toApiUrl()`로 처리한다. - 화면 상태는 `ref`, `computed`, `onMounted` 중심의 단순한 Composition API 패턴을 유지한다. +- 설정/계정처럼 자주 바꾸지 않는 정보는 상시 입력 폼보다 `현재 상태 요약 + 필요 시 모달 편집` 흐름을 우선한다. ## 백엔드 - 라우트 검증은 `zod`로 처리한다. diff --git a/docs/history.md b/docs/history.md index c990057..1e54c24 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-07 v1.1.18 +- 설정 화면은 자주 바꾸지 않는 계정 정보를 상시 입력 폼으로 펼쳐두기보다, 현재 상태를 먼저 보여주고 필요할 때만 모달로 수정하는 편이 더 차분하고 완성도 높게 보인다고 정리했다. +- 닉네임은 공개 작성자 이름에 직접 반영되는 정보라 악용 가능성을 줄이기 위해 2주 제한을 두는 편이 맞다고 판단했다. 초기 가입 시점의 닉네임도 같은 규칙에 포함되도록 가입 시각을 기본 기준선으로 삼는다. + ## 2026-04-07 v1.1.17 - 가이드 모달은 같은 기능의 이동 수단을 중복으로 두기보다, 화살표와 점 네비게이션만 유지하는 편이 더 깔끔하다고 정리했다. - 설명 길이 때문에 페이지 전환마다 미디어 영역이 출렁이면 완성도가 떨어져 보이므로, 설명 블록에 최소 높이를 두는 방식으로 페이지 간 높이를 최대한 통일하기로 했다. diff --git a/docs/map.md b/docs/map.md index 5de18b9..5067ec2 100644 --- a/docs/map.md +++ b/docs/map.md @@ -62,7 +62,7 @@ ## `/profile` - 화면 파일: `frontend/src/views/ProfileView.vue` -- 역할: 넓은 화면에서는 왼쪽 프로필 정보 카드와 오른쪽 비밀번호 변경 카드로 나뉘는 설정 화면, 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 중복/예약어 닉네임 오류 안내, 현재 비밀번호 확인 기반 비밀번호 변경, 설정 화면 로그아웃 처리 +- 역할: `settingsThemePanel` 계열 톤의 요약 카드 중심 설정 화면, 프로필 이미지 선택/저장, 닉네임 현재 상태와 2주 제한 안내, 닉네임 변경 모달, 비밀번호 변경 모달, 이메일 읽기 전용 표시, 설정 화면 로그아웃 처리 - 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password` ## 공통 레이아웃 diff --git a/docs/spec.md b/docs/spec.md index e69eca5..1928765 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -55,6 +55,8 @@ - `L/ㅣ`: 리스트 보기 - `A/ㅁ`: 관리자 계정일 때 관리자 화면으로 이동 - 설정의 가이드 모달은 좌우 화살표, 점 네비게이션, 좌측 단계 목록으로만 이동하고, 설명 영역은 최소 4줄 높이를 유지해 페이지별 높이 차이를 줄인다. +- 설정(`/profile`) 화면은 상시 입력 폼보다 `현재 상태 요약 카드 + 필요 시 모달 편집` 흐름을 기본으로 한다. 닉네임과 비밀번호는 작은 액션 버튼으로만 모달을 열어 변경하고, 이메일은 현재 로그인 계정 정보로 읽기 전용 표시를 유지한다. +- 닉네임은 2주(14일)에 한 번만 변경할 수 있다. 인증 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`를 함께 포함하고, 프로필 저장 API는 제한 기간 안의 닉네임 변경 요청에 `nickname_change_locked` 오류를 반환한다. - 왼쪽 공통 검색창은 현재 화면 범위만 검색한다. - 홈: 전체 공개 티어표 - 템플릿: 공개 템플릿 @@ -98,6 +100,7 @@ - `id`: string - `email`: string - `nickname`: string + - `nicknameUpdatedAt`: number - `passwordHash`: string - `emailVerified`: boolean - `isAdmin`: boolean diff --git a/docs/todo.md b/docs/todo.md index 0d4ecff..f179481 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,11 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.1.18` 이후 설정 화면이 데스크톱/태블릿/모바일에서 너무 넓게 퍼지지 않고, 요약 카드 간 간격과 제목/설명 밀도가 다른 대시보드 화면과 자연스럽게 맞는지 확인한다. +- 프로필 이미지 변경 후 저장, 이미지 제거 후 저장, 저장하지 않고 페이지 이탈 세 경우가 모두 의도대로 동작하는지 확인한다. +- 닉네임 변경 모달에서 2주 제한 안내 문구와 실제 저장 차단 시점이 일치하는지, 제한 중에는 버튼이 비활성화되고 다음 가능 시각이 자연스럽게 보이는지 확인한다. +- 오래전에 가입한 기존 계정은 `nickname_updated_at` 백필 후에도 바로 변경 가능하고, 최근 가입/최근 변경 계정은 정확히 14일 제한이 걸리는지 서버 기준으로 확인한다. +- 비밀번호 변경이 요약 카드의 작은 액션으로만 열리더라도 접근성이 떨어지지 않는지, 모달 `Esc` 닫기와 포커스 이동이 자연스러운지 확인한다. - `v1.1.17` 이후 설정의 가이드 모달에서 페이지를 넘길 때 썸네일 영역 위치가 이전보다 안정적으로 유지되는지 확인한다. - `v1.1.17` 이후 가이드 하단 `다음` 버튼이 사라지고, 좌우 화살표/점 네비게이션만으로도 단계 이동이 충분히 자연스러운지 확인한다. - `v1.1.16` 이후 `S/ㄴ`이 편집 화면에서는 아이템 검색, 일반 목록 화면에서는 왼쪽 공통 검색창 포커스로 정확히 나뉘어 동작하는지 확인한다. diff --git a/docs/update.md b/docs/update.md index b4b8acb..0f01b25 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 로그 +## 2026-04-07 v1.1.18 +- 설정(`/profile`) 화면을 전면 재구성했다. 기존처럼 넓은 2단 입력 폼을 상시 노출하지 않고, `settingsThemePanel` 톤을 참고한 요약 카드 레이아웃으로 바꿔 더 차분하고 통일된 계정 화면으로 정리했다. +- 프로필 영역은 `닉네임 / 이메일 / 프로필 이미지`의 현재 상태를 먼저 보여주고, 자주 바꾸지 않는 정보는 필요할 때만 모달을 열어 변경하도록 바꿨다. 비밀번호 변경도 별도 카드 전체를 차지하지 않고 작은 액션으로 열리는 모달 흐름으로 정리했다. +- 닉네임 변경에는 2주 제한을 추가했다. 백엔드 `users` 테이블에 `nickname_updated_at`을 저장하고, 기존 DB는 서버 시작 시 자동 보강·백필한다. 닉네임이 실제로 바뀐 경우에만 갱신 시각을 다시 찍고, 14일이 지나기 전에는 `nickname_change_locked` 오류와 다음 변경 가능 시각을 돌려준다. +- 인증 직렬화 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`를 포함해 프런트가 설정 화면에서 남은 제한 상태를 직접 보여줄 수 있게 했다. +- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/auth.js`, `npm run build` + ## 2026-04-07 v1.1.17 - 설정의 `가이드 보기` 모달 9페이지 단축키 설명은 최근 추가된 전역 단축키 기준으로 최신 상태를 유지하도록 다시 확인했다. - 가이드 하단 `다음` 버튼은 좌우 화살표와 역할이 겹쳐 제거했다. 이제 단계 이동은 좌우 화살표, 점 네비게이션, 좌측 단계 목록만 사용한다. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ff497fd..d57d094 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -157,7 +157,7 @@ const guideSteps = [ title: '단축키로 빠른 조작', summary: '사이드 패널과 전체 화면을 키보드로 빠르게 전환합니다.', description: - '[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F/ㄹ은 전체 화면, S/ㄴ은 검색 포커스(편집 화면에서는 아이템 검색), G/ㅎ은 그리드 보기, L/ㅣ는 리스트 보기, A/ㅁ은 관리자 계정일 때 관리자 화면으로 이동합니다. 각종 모달은 Esc 키로 닫을 수 있고, 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있어요.', + '[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F/ㄹ은 전체 화면, S/ㄴ은 검색 포커스(편집 화면에서는 아이템 검색), G/ㅎ은 그리드 보기, L/ㅣ는 리스트 보기입니다. 각종 모달은 Esc 키로 닫을 수 있고, 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있습니다.', }, ] const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0]) @@ -1779,6 +1779,7 @@ function reloadApp() { font-weight: 900; line-height: 1.1; letter-spacing: -0.04em; + word-break: keep-all; } .guideModal__mobilePicker { diff --git a/frontend/src/views/ProfileView.vue b/frontend/src/views/ProfileView.vue index 429fc66..ae6d89e 100644 --- a/frontend/src/views/ProfileView.vue +++ b/frontend/src/views/ProfileView.vue @@ -1,8 +1,9 @@