릴리스: v1.4.49 설정 화면 비밀번호 변경 및 닉네임 오류 안내 보강

This commit is contained in:
2026-04-03 11:53:43 +09:00
parent cd41a6caa1
commit 8922c62f58
9 changed files with 194 additions and 4 deletions

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;