설정 제한 보정

This commit is contained in:
2026-04-07 15:10:43 +09:00
parent 923a9af83d
commit 51170b2ff7
7 changed files with 63 additions and 21 deletions

View File

@@ -30,7 +30,35 @@ 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
function resolveNicknameChangeIntervalMs() {
const rawMs = String(process.env.NICKNAME_CHANGE_INTERVAL_MS || '').trim()
if (rawMs) {
const parsed = Number(rawMs)
if (Number.isFinite(parsed) && parsed >= 0) return parsed
}
const rawDays = String(process.env.NICKNAME_CHANGE_INTERVAL_DAYS || '').trim()
if (rawDays) {
const parsed = Number(rawDays)
if (Number.isFinite(parsed) && parsed >= 0) return parsed * 24 * 60 * 60 * 1000
}
return 14 * 24 * 60 * 60 * 1000
}
function formatNicknameChangeIntervalLabel(intervalMs) {
if (!intervalMs || intervalMs <= 0) return '제한 없음'
const oneDayMs = 24 * 60 * 60 * 1000
const wholeDays = intervalMs / oneDayMs
if (Number.isInteger(wholeDays) && wholeDays >= 7 && wholeDays % 7 === 0) {
return `${wholeDays / 7}`
}
if (Number.isInteger(wholeDays)) {
return `${wholeDays}`
}
return `${Math.ceil(wholeDays)}`
}
const signupSchema = z.object({
email: z.string().email(),
@@ -83,13 +111,17 @@ async function serializeUser(user) {
if (!user) return null
const primaryAdmin = await findPrimaryAdminUser()
const isPrimaryAdmin = !!user.isAdmin && primaryAdmin?.id === user.id
const nicknameChangeIntervalMs = resolveNicknameChangeIntervalMs()
const nicknameChangeAvailableAt = nicknameChangeIntervalMs > 0 ? (user.nicknameUpdatedAt || 0) + nicknameChangeIntervalMs : 0
return {
id: user.id,
email: user.email,
nickname: user.nickname || '',
nicknameUpdatedAt: user.nicknameUpdatedAt || 0,
nicknameChangeAvailableAt: (user.nicknameUpdatedAt || 0) + NICKNAME_CHANGE_INTERVAL_MS,
nicknameChangeAvailableAt,
nicknameChangeIntervalMs,
nicknameChangeIntervalLabel: formatNicknameChangeIntervalLabel(nicknameChangeIntervalMs),
isAdmin: !!user.isAdmin,
isPrimaryAdmin,
isOperator: !!user.isAdmin && !isPrimaryAdmin,
@@ -363,12 +395,15 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
if (!user) return res.status(404).json({ error: 'not_found' })
const normalizedNickname = parsed.data.nickname.trim()
const nicknameChanged = normalizedNickname !== (user.nickname || '').trim()
const nicknameChangeIntervalMs = resolveNicknameChangeIntervalMs()
if (isReservedNickname(normalizedNickname)) return res.status(400).json({ error: 'nickname_reserved' })
if (nicknameChanged && user.nicknameUpdatedAt && Date.now() < user.nicknameUpdatedAt + NICKNAME_CHANGE_INTERVAL_MS) {
if (nicknameChanged && nicknameChangeIntervalMs > 0 && user.nicknameUpdatedAt && Date.now() < user.nicknameUpdatedAt + nicknameChangeIntervalMs) {
return res.status(429).json({
error: 'nickname_change_locked',
nicknameChangeAvailableAt: user.nicknameUpdatedAt + NICKNAME_CHANGE_INTERVAL_MS,
nicknameChangeAvailableAt: user.nicknameUpdatedAt + nicknameChangeIntervalMs,
nicknameChangeIntervalMs,
nicknameChangeIntervalLabel: formatNicknameChangeIntervalLabel(nicknameChangeIntervalMs),
})
}

View File

@@ -3,6 +3,7 @@
## 2026-04-07 v1.1.18
- 설정 화면은 자주 바꾸지 않는 계정 정보를 상시 입력 폼으로 펼쳐두기보다, 현재 상태를 먼저 보여주고 필요할 때만 모달로 수정하는 편이 더 차분하고 완성도 높게 보인다고 정리했다.
- 닉네임은 공개 작성자 이름에 직접 반영되는 정보라 악용 가능성을 줄이기 위해 2주 제한을 두는 편이 맞다고 판단했다. 초기 가입 시점의 닉네임도 같은 규칙에 포함되도록 가입 시각을 기본 기준선으로 삼는다.
- 다만 이 제한은 테스트와 운영 상황에 따라 조절할 수 있어야 하므로, 기간 자체는 코드 고정보다 환경변수로 바꾸는 편이 맞다고 정리했다. `0`으로 꺼서 QA하거나, `1일` 같은 짧은 값으로 운영 실험을 할 수 있어야 한다.
## 2026-04-07 v1.1.17
- 가이드 모달은 같은 기능의 이동 수단을 중복으로 두기보다, 화살표와 점 네비게이션만 유지하는 편이 더 깔끔하다고 정리했다.

View File

@@ -62,7 +62,7 @@
## `/profile`
- 화면 파일: `frontend/src/views/ProfileView.vue`
- 역할: `settingsThemePanel` 계열 톤의 요약 카드 중심 설정 화면, 프로필 이미지 선택/저장, 닉네임 현재 상태와 2주 제한 안내, 닉네임 변경 모달, 비밀번호 변경 모달, 이메일 읽기 전용 표시, 설정 화면 로그아웃 처리
- 역할: `settingsThemePanel` 계열 톤의 요약 카드 중심 설정 화면, 아바타 원형 버튼 클릭 기반 프로필 이미지 선택/저장, 닉네임 현재 상태와 변경 제한 안내, 닉네임 변경 모달, 비밀번호 변경 모달, 이메일 읽기 전용 표시, 설정 화면 로그아웃 처리
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`, `POST /api/auth/password`
## 공통 레이아웃

View File

@@ -55,8 +55,8 @@
- `L/ㅣ`: 리스트 보기
- `A/ㅁ`: 관리자 계정일 때 관리자 화면으로 이동
- 설정의 가이드 모달은 좌우 화살표, 점 네비게이션, 좌측 단계 목록으로만 이동하고, 설명 영역은 최소 4줄 높이를 유지해 페이지별 높이 차이를 줄인다.
- 설정(`/profile`) 화면은 상시 입력 폼보다 `현재 상태 요약 카드 + 필요 시 모달 편집` 흐름을 기본으로 한다. 닉네임과 비밀번호는 작은 액션 버튼으로만 모달을 열어 변경하고, 이메일은 현재 로그인 계정 정보로 읽기 전용 표시를 유지한다.
- 닉네임은 2주(14일)에 한 번만 변경할 수 있다. 인증 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`를 함께 포함하고, 프로필 저장 API는 제한 기간 안의 닉네임 변경 요청에 `nickname_change_locked` 오류를 반환한다.
- 설정(`/profile`) 화면은 상시 입력 폼보다 `현재 상태 요약 카드 + 필요 시 모달 편집` 흐름을 기본으로 한다. 닉네임과 비밀번호는 작은 액션 버튼으로만 모달을 열어 변경하고, 이메일은 현재 로그인 계정 정보로 읽기 전용 표시를 유지한다. 프로필 이미지는 아바타 원형 버튼 자체를 눌러 변경한다.
- 닉네임 변경 제한 기간은 기본 14일이지만, 서버 환경변수 `NICKNAME_CHANGE_INTERVAL_MS` 또는 `NICKNAME_CHANGE_INTERVAL_DAYS`로 조절할 수 있다. `0`이면 제한을 끈다. 인증 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`, `nicknameChangeIntervalMs`, `nicknameChangeIntervalLabel`를 함께 포함하고, 프로필 저장 API는 제한 기간 안의 닉네임 변경 요청에 `nickname_change_locked` 오류를 반환한다.
- 왼쪽 공통 검색창은 현재 화면 범위만 검색한다.
- 홈: 전체 공개 티어표
- 템플릿: 공개 템플릿
@@ -101,6 +101,9 @@
- `email`: string
- `nickname`: string
- `nicknameUpdatedAt`: number
- `nicknameChangeAvailableAt`: number
- `nicknameChangeIntervalMs`: number
- `nicknameChangeIntervalLabel`: string
- `passwordHash`: string
- `emailVerified`: boolean
- `isAdmin`: boolean

View File

@@ -3,7 +3,8 @@
## 단기 확인
- `v1.1.18` 이후 설정 화면이 데스크톱/태블릿/모바일에서 너무 넓게 퍼지지 않고, 요약 카드 간 간격과 제목/설명 밀도가 다른 대시보드 화면과 자연스럽게 맞는지 확인한다.
- 프로필 이미지 변경 후 저장, 이미지 제거 후 저장, 저장하지 않고 페이지 이탈 세 경우가 모두 의도대로 동작하는지 확인한다.
- 닉네임 변경 모달에서 2주 제한 안내 문구와 실제 저장 차단 시점이 일치하는지, 제한 중에는 버튼이 비활성화되고 다음 가능 시각이 자연스럽게 보이는지 확인한다.
- 닉네임 변경 모달에서 제한 안내 문구와 실제 저장 차단 시점이 일치하는지, 제한 중에는 버튼이 비활성화되고 다음 가능 시각이 자연스럽게 보이는지 확인한다.
- `NICKNAME_CHANGE_INTERVAL_DAYS=1`, `NICKNAME_CHANGE_INTERVAL_MS=0` 같은 운영/테스트 값에서 설정 화면 문구와 백엔드 차단 동작이 함께 바뀌는지 확인한다.
- 오래전에 가입한 기존 계정은 `nickname_updated_at` 백필 후에도 바로 변경 가능하고, 최근 가입/최근 변경 계정은 정확히 14일 제한이 걸리는지 서버 기준으로 확인한다.
- 비밀번호 변경이 요약 카드의 작은 액션으로만 열리더라도 접근성이 떨어지지 않는지, 모달 `Esc` 닫기와 포커스 이동이 자연스러운지 확인한다.
- `v1.1.17` 이후 설정의 가이드 모달에서 페이지를 넘길 때 썸네일 영역 위치가 이전보다 안정적으로 유지되는지 확인한다.

View File

@@ -5,6 +5,8 @@
- 프로필 영역은 `닉네임 / 이메일 / 프로필 이미지`의 현재 상태를 먼저 보여주고, 자주 바꾸지 않는 정보는 필요할 때만 모달을 열어 변경하도록 바꿨다. 비밀번호 변경도 별도 카드 전체를 차지하지 않고 작은 액션으로 열리는 모달 흐름으로 정리했다.
- 닉네임 변경에는 2주 제한을 추가했다. 백엔드 `users` 테이블에 `nickname_updated_at`을 저장하고, 기존 DB는 서버 시작 시 자동 보강·백필한다. 닉네임이 실제로 바뀐 경우에만 갱신 시각을 다시 찍고, 14일이 지나기 전에는 `nickname_change_locked` 오류와 다음 변경 가능 시각을 돌려준다.
- 인증 직렬화 응답에는 `nicknameUpdatedAt`, `nicknameChangeAvailableAt`를 포함해 프런트가 설정 화면에서 남은 제한 상태를 직접 보여줄 수 있게 했다.
- 프로필 이미지는 아바타 원형 버튼 자체를 누르면 파일 선택기가 열리므로, 중복 동작이던 별도 `프로필 이미지 변경` 버튼은 제거했다.
- 닉네임 변경 제한 기간은 고정 14일 상수가 아니라 환경변수로 바꿨다. `NICKNAME_CHANGE_INTERVAL_MS` 또는 `NICKNAME_CHANGE_INTERVAL_DAYS`로 조절할 수 있고, `0`이면 제한을 끌 수 있다. 프런트 문구도 응답의 `nicknameChangeIntervalLabel`을 따라가도록 맞췄다.
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/auth.js`, `npm run build`
## 2026-04-07 v1.1.17

View File

@@ -61,13 +61,17 @@ const displayInitial = computed(() => displayInitialFrom(auth.user?.nickname, au
const authEmail = computed(() => auth.user?.email || '')
const nicknameUpdatedAt = computed(() => Number(auth.user?.nicknameUpdatedAt || 0))
const nicknameChangeAvailableAt = computed(() => Number(auth.user?.nicknameChangeAvailableAt || 0))
const nicknameChangeIntervalMs = computed(() => Number(auth.user?.nicknameChangeIntervalMs || 0))
const nicknameChangeIntervalLabel = computed(() => String(auth.user?.nicknameChangeIntervalLabel || '2주'))
const hasPendingAvatarChange = computed(() => !!avatarFile.value || removeAvatar.value)
const canChangeNicknameNow = computed(() => {
if (nicknameChangeIntervalMs.value <= 0) return true
if (!nicknameUpdatedAt.value) return true
return Date.now() >= nicknameChangeAvailableAt.value
})
const nicknameCooldownText = computed(() => {
if (!nicknameUpdatedAt.value || canChangeNicknameNow.value) return '닉네임은 2주에 한 번만 변경할 수 있어요.'
if (nicknameChangeIntervalMs.value <= 0) return '닉네임 변경 제한이 없습니다.'
if (!nicknameUpdatedAt.value || canChangeNicknameNow.value) return `닉네임은 ${nicknameChangeIntervalLabel.value}에 한 번만 변경할 수 있어요.`
return `다음 변경 가능 시점: ${formatDateTime(nicknameChangeAvailableAt.value)}`
})
const profileImageSummary = computed(() => {
@@ -203,7 +207,8 @@ async function saveProfile(nextNickname = nickname.value) {
nicknameDraftError.value = nicknameError.value
error.value = '사용할 수 없는 닉네임이에요.'
} else if (code === 'nickname_change_locked') {
nicknameDraftError.value = `닉네임은 2주에 한 번만 바꿀 수 있어요. ${formatDateTime(e2?.data?.nicknameChangeAvailableAt)} 이후 다시 시도해주세요.`
const intervalLabel = String(e2?.data?.nicknameChangeIntervalLabel || nicknameChangeIntervalLabel.value || '2주')
nicknameDraftError.value = `닉네임은 ${intervalLabel}에 한 번만 바꿀 수 있어요. ${formatDateTime(e2?.data?.nicknameChangeAvailableAt)} 이후 다시 시도해주세요.`
error.value = '닉네임 변경 가능 시점이 아직 아니에요.'
} else {
error.value = '프로필 저장에 실패했어요.'
@@ -322,7 +327,6 @@ async function logout() {
<article class="settingsThemeCard settingsThemeCard--hero">
<div class="settingsThemeCard__eyebrow">Profile</div>
<div class="settingsThemeCard__title">기본 계정 정보</div>
<div class="settingsThemeCard__desc">아바타, 닉네임, 이메일처럼 자주 바뀌지 않는 정보는 현재 상태만 먼저 보여주고 필요한 순간에만 열어 수정합니다.</div>
<div class="settingsHero">
<div class="settingsAvatarColumn">
@@ -346,8 +350,8 @@ async function logout() {
</svg>
</button>
</div>
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
<input ref="fileInput" class="hiddenInput" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
<button class="btn btn--ghost settingsInlineAction" type="button" @click="openAvatarPicker">프로필 이미지 변경</button>
</div>
<div class="settingsHero__body">
@@ -378,7 +382,7 @@ async function logout() {
<button v-if="hasPendingAvatarChange" class="btn btn--save" type="button" :disabled="saving" @click="saveAvatarChanges">
{{ saving ? '저장 중...' : '프로필 이미지 저장' }}
</button>
<div v-if="auth.user.isAdmin" class="roleBadge">Administrator</div>
</div>
</div>
</div>
@@ -387,7 +391,6 @@ async function logout() {
<article class="settingsThemeCard">
<div class="settingsThemeCard__eyebrow">Security</div>
<div class="settingsThemeCard__title">보안 설정</div>
<div class="settingsThemeCard__desc">비밀번호는 자주 노출할 필요가 없으니, 필요할 때만 열리는 작은 액션으로 정리했습니다.</div>
<div class="settingsCompactRow">
<div>
<div class="settingsCompactRow__title">비밀번호</div>
@@ -400,7 +403,6 @@ async function logout() {
<article class="settingsThemeCard">
<div class="settingsThemeCard__eyebrow">Session</div>
<div class="settingsThemeCard__title">계정 상태</div>
<div class="settingsThemeCard__desc">지금 로그인한 계정 정보를 확인하고 세션을 정리할 있어요.</div>
<div class="settingsCompactList">
<div class="settingsCompactRow">
<div>
@@ -423,7 +425,10 @@ async function logout() {
<div v-if="activeModal === 'nickname'" class="settingsModalOverlay" @click.self="closeNicknameModal">
<div class="settingsModalCard" role="dialog" aria-modal="true" aria-labelledby="nicknameModalTitle">
<div id="nicknameModalTitle" class="settingsModalCard__title">닉네임 변경</div>
<div class="settingsModalCard__desc">닉네임은 공개 티어표의 작성자 이름으로 보이며, 악용 방지를 위해 2주에 번만 변경할 있어요.</div>
<div class="settingsModalCard__desc">
닉네임은 공개 티어표의 작성자 이름으로 보이며,
{{ nicknameChangeIntervalMs > 0 ? `악용 방지를 위해 ${nicknameChangeIntervalLabel}에 한 번만 변경할 수 있어요.` : '현재는 변경 주기 제한이 없습니다.' }}
</div>
<label class="field">
<span class="field__label"> 닉네임</span>
<input ref="nicknameInput" v-model="nicknameDraft" class="field__input" maxlength="40" placeholder="표시용 닉네임" />
@@ -781,11 +786,6 @@ async function logout() {
cursor: default;
}
.settingsInlineAction {
width: 100%;
justify-content: center;
}
.btn {
display: inline-flex;
align-items: center;