릴리스: v1.4.45 이메일 인증 및 비밀번호 재설정 추가
This commit is contained in:
@@ -57,6 +57,11 @@ export const api = {
|
||||
authMeta: () => request('/api/auth/meta'),
|
||||
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 } }),
|
||||
verifyEmail: ({ token }) => request('/api/auth/email/verify', { method: 'POST', body: { token } }),
|
||||
resendVerificationEmail: ({ email }) => request('/api/auth/email/resend', { method: 'POST', body: { email } }),
|
||||
requestPasswordReset: ({ email }) => request('/api/auth/password-reset/request', { method: 'POST', body: { email } }),
|
||||
confirmPasswordReset: ({ token, password }) =>
|
||||
request('/api/auth/password-reset/confirm', { method: 'POST', body: { token, password } }),
|
||||
logout: () => request('/api/auth/logout', { method: 'POST' }),
|
||||
|
||||
listTopics: () => request('/api/topics'),
|
||||
|
||||
@@ -30,16 +30,28 @@ export const useAuthStore = defineStore('auth', {
|
||||
return refreshPromise
|
||||
},
|
||||
async signup(email, nickname, password) {
|
||||
const user = await api.signup({ email, nickname, password })
|
||||
this.user = user
|
||||
const data = await api.signup({ email, nickname, password })
|
||||
this.user = data?.user || null
|
||||
this.hydrated = true
|
||||
return user
|
||||
return data
|
||||
},
|
||||
async login(email, password) {
|
||||
const user = await api.login({ email, password })
|
||||
this.user = user
|
||||
const data = await api.login({ email, password })
|
||||
this.user = data?.user || null
|
||||
this.hydrated = true
|
||||
return user
|
||||
return data?.user || null
|
||||
},
|
||||
async verifyEmail(token) {
|
||||
const data = await api.verifyEmail({ token })
|
||||
this.user = data?.user || null
|
||||
this.hydrated = true
|
||||
return this.user
|
||||
},
|
||||
async confirmPasswordReset(token, password) {
|
||||
const data = await api.confirmPasswordReset({ token, password })
|
||||
this.user = data?.user || null
|
||||
this.hydrated = true
|
||||
return this.user
|
||||
},
|
||||
async logout() {
|
||||
await api.logout()
|
||||
|
||||
@@ -15,24 +15,92 @@ const password = ref('')
|
||||
const passwordConfirm = ref('')
|
||||
const mode = ref('login')
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
const hasUsers = ref(true)
|
||||
const emailError = ref('')
|
||||
const nicknameError = ref('')
|
||||
const pendingVerificationEmail = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
|
||||
const description = computed(() =>
|
||||
mode.value === 'signup'
|
||||
? '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.'
|
||||
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
|
||||
)
|
||||
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
|
||||
const title = computed(() => {
|
||||
if (mode.value === 'signup') return '회원가입'
|
||||
if (mode.value === 'reset-request') return '비밀번호 재설정'
|
||||
if (mode.value === 'reset-confirm') return '새 비밀번호 설정'
|
||||
return '로그인'
|
||||
})
|
||||
const description = computed(() => {
|
||||
if (mode.value === 'signup') return '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.'
|
||||
if (mode.value === 'reset-request') return '가입한 이메일로 비밀번호 재설정 링크를 보내드릴게요.'
|
||||
if (mode.value === 'reset-confirm') return '메일로 받은 재설정 링크를 확인했어요. 새 비밀번호를 입력해주세요.'
|
||||
return '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
|
||||
})
|
||||
const submitLabel = computed(() => {
|
||||
if (mode.value === 'signup') return '가입하기'
|
||||
if (mode.value === 'reset-request') return '재설정 메일 보내기'
|
||||
if (mode.value === 'reset-confirm') return '새 비밀번호 저장'
|
||||
return '로그인'
|
||||
})
|
||||
const authReady = computed(() => auth.hydrated)
|
||||
const checkingSession = computed(() => !authReady.value || auth.status === 'loading')
|
||||
const resetToken = computed(() => (typeof route.query.resetToken === 'string' ? route.query.resetToken : ''))
|
||||
const verifyToken = computed(() => (typeof route.query.verifyToken === 'string' ? route.query.verifyToken : ''))
|
||||
const redirectPath = computed(() => (typeof route.query.redirect === 'string' ? route.query.redirect : mePath()))
|
||||
|
||||
function clearFormFeedback() {
|
||||
error.value = ''
|
||||
emailError.value = ''
|
||||
nicknameError.value = ''
|
||||
}
|
||||
|
||||
function clearAuthQueryTokens() {
|
||||
if (!resetToken.value && !verifyToken.value) return
|
||||
const nextQuery = { ...route.query }
|
||||
delete nextQuery.resetToken
|
||||
delete nextQuery.verifyToken
|
||||
router.replace({ path: route.path, query: nextQuery })
|
||||
}
|
||||
|
||||
function switchMode(nextMode) {
|
||||
if (mode.value === nextMode) return
|
||||
mode.value = nextMode
|
||||
clearFormFeedback()
|
||||
notice.value = ''
|
||||
pendingVerificationEmail.value = ''
|
||||
password.value = ''
|
||||
passwordConfirm.value = ''
|
||||
if (nextMode !== 'signup') nickname.value = ''
|
||||
if (nextMode !== 'reset-confirm') clearAuthQueryTokens()
|
||||
}
|
||||
|
||||
async function completeEmailVerification(token) {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await auth.verifyEmail(token)
|
||||
notice.value = '이메일 인증이 완료됐어요. 내 티어표 화면으로 이동합니다.'
|
||||
router.replace(redirectPath.value)
|
||||
} catch (e) {
|
||||
mode.value = 'login'
|
||||
error.value = '인증 링크가 만료되었거나 유효하지 않아요. 다시 로그인하거나 인증 메일을 재전송해주세요.'
|
||||
clearAuthQueryTokens()
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.hydrated) await auth.refresh()
|
||||
if (verifyToken.value) {
|
||||
await completeEmailVerification(verifyToken.value)
|
||||
return
|
||||
}
|
||||
if (resetToken.value) {
|
||||
mode.value = 'reset-confirm'
|
||||
password.value = ''
|
||||
passwordConfirm.value = ''
|
||||
return
|
||||
}
|
||||
if (auth.user) {
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
|
||||
router.replace(redirectPath.value)
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -47,15 +115,13 @@ watch(
|
||||
() => [auth.hydrated, auth.user],
|
||||
([hydrated, user]) => {
|
||||
if (!hydrated || !user) return
|
||||
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : mePath())
|
||||
router.replace(redirectPath.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(mode, () => {
|
||||
error.value = ''
|
||||
emailError.value = ''
|
||||
nicknameError.value = ''
|
||||
clearFormFeedback()
|
||||
})
|
||||
|
||||
watch(email, () => {
|
||||
@@ -68,23 +134,81 @@ watch(nickname, () => {
|
||||
if (error.value === '닉네임이 이미 사용 중이에요.' || error.value === '사용할 수 없는 닉네임이에요.') error.value = ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.query.resetToken,
|
||||
(value) => {
|
||||
if (typeof value === 'string' && value) {
|
||||
switchMode('reset-confirm')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function resendVerificationEmail() {
|
||||
const targetEmail = email.value.trim() || pendingVerificationEmail.value
|
||||
if (!targetEmail) {
|
||||
emailError.value = '이메일을 먼저 입력해주세요.'
|
||||
error.value = '인증 메일을 다시 받을 이메일이 필요해요.'
|
||||
return
|
||||
}
|
||||
|
||||
clearFormFeedback()
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await api.resendVerificationEmail({ email: targetEmail })
|
||||
pendingVerificationEmail.value = targetEmail
|
||||
notice.value = `${targetEmail} 주소로 인증 메일을 다시 보냈어요. 메일함과 스팸함을 함께 확인해주세요.`
|
||||
} catch (e) {
|
||||
const code = e?.data?.error
|
||||
error.value = code === 'mail_not_configured'
|
||||
? '메일 발송 설정이 아직 완료되지 않았어요. 잠시 후 다시 시도해주세요.'
|
||||
: '인증 메일 재전송에 실패했어요.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
emailError.value = ''
|
||||
nicknameError.value = ''
|
||||
clearFormFeedback()
|
||||
notice.value = ''
|
||||
if (mode.value === 'signup' && nickname.value.trim().length < 2) {
|
||||
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
|
||||
error.value = '닉네임을 확인해주세요.'
|
||||
return
|
||||
}
|
||||
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
|
||||
if ((mode.value === 'signup' || mode.value === 'reset-confirm') && password.value !== passwordConfirm.value) {
|
||||
error.value = '비밀번호 확인이 일치하지 않아요.'
|
||||
return
|
||||
}
|
||||
if (mode.value === 'reset-confirm' && !resetToken.value) {
|
||||
error.value = '재설정 토큰이 없어 비밀번호를 바꿀 수 없어요. 메일 링크를 다시 확인해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
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())
|
||||
if (mode.value === 'signup') {
|
||||
const result = await auth.signup(email.value, nickname.value, password.value)
|
||||
if (result?.verificationRequired) {
|
||||
pendingVerificationEmail.value = result.email || email.value.trim()
|
||||
mode.value = 'login'
|
||||
password.value = ''
|
||||
passwordConfirm.value = ''
|
||||
notice.value = `${pendingVerificationEmail.value} 주소로 인증 메일을 보냈어요. 인증 후 로그인해주세요.`
|
||||
return
|
||||
}
|
||||
} else if (mode.value === 'reset-request') {
|
||||
const targetEmail = email.value.trim()
|
||||
await api.requestPasswordReset({ email: targetEmail })
|
||||
switchMode('login')
|
||||
notice.value = `${targetEmail} 주소로 비밀번호 재설정 메일을 보냈어요. 메일함과 스팸함을 함께 확인해주세요.`
|
||||
return
|
||||
} else if (mode.value === 'reset-confirm') {
|
||||
await auth.confirmPasswordReset(resetToken.value, password.value)
|
||||
clearAuthQueryTokens()
|
||||
} else {
|
||||
await auth.login(email.value, password.value)
|
||||
}
|
||||
router.push(redirectPath.value)
|
||||
} catch (e) {
|
||||
const code = e?.data?.error
|
||||
if (mode.value === 'signup') {
|
||||
@@ -103,8 +227,35 @@ async function submit() {
|
||||
error.value = '사용할 수 없는 닉네임이에요.'
|
||||
return
|
||||
}
|
||||
if (code === 'mail_not_configured') {
|
||||
error.value = '메일 발송 설정이 아직 완료되지 않아 이메일 인증을 보낼 수 없어요.'
|
||||
return
|
||||
}
|
||||
if (code === 'mail_send_failed') {
|
||||
error.value = '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.'
|
||||
return
|
||||
}
|
||||
}
|
||||
error.value = mode.value === 'signup' ? '회원가입에 실패했어요.' : '로그인에 실패했어요.'
|
||||
if (mode.value === 'login' && code === 'email_unverified') {
|
||||
pendingVerificationEmail.value = e?.data?.email || email.value.trim()
|
||||
error.value = '이메일 인증이 아직 완료되지 않았어요. 아래 버튼으로 인증 메일을 다시 받을 수 있어요.'
|
||||
return
|
||||
}
|
||||
if (mode.value === 'reset-request') {
|
||||
error.value = code === 'mail_not_configured'
|
||||
? '메일 발송 설정이 아직 완료되지 않아 재설정 메일을 보낼 수 없어요.'
|
||||
: '재설정 메일 발송에 실패했어요.'
|
||||
return
|
||||
}
|
||||
if (mode.value === 'reset-confirm') {
|
||||
error.value = code === 'invalid_or_expired_token'
|
||||
? '재설정 링크가 만료되었거나 유효하지 않아요. 비밀번호 재설정을 다시 요청해주세요.'
|
||||
: '새 비밀번호 저장에 실패했어요.'
|
||||
return
|
||||
}
|
||||
error.value = '로그인에 실패했어요.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -126,18 +277,23 @@ async function submit() {
|
||||
<section v-else class="authScreen">
|
||||
<div class="authTabs" :class="{ 'authTabs--signup': mode === 'signup' }" role="tablist" aria-label="로그인 또는 회원가입">
|
||||
<span class="authTabs__indicator" aria-hidden="true"></span>
|
||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
|
||||
<button
|
||||
type="button"
|
||||
class="authTabs__button"
|
||||
:class="{ 'authTabs__button--active': mode === 'login' || mode === 'reset-request' || mode === 'reset-confirm' }"
|
||||
@click="switchMode('login')"
|
||||
>
|
||||
로그인
|
||||
</button>
|
||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="mode = 'signup'">
|
||||
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="switchMode('signup')">
|
||||
회원가입
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="authFields" @submit.prevent="submit">
|
||||
<label class="field">
|
||||
<label v-if="mode !== 'reset-confirm'" class="field">
|
||||
<span class="field__label">이메일</span>
|
||||
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
|
||||
<input v-model="email" class="field__input" type="email" placeholder="you@example.com" autocomplete="email" maxlength="255" />
|
||||
<span v-if="emailError" class="field__error">{{ emailError }}</span>
|
||||
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255자</span>
|
||||
</label>
|
||||
@@ -149,20 +305,20 @@ async function submit() {
|
||||
<span class="field__hint">다른 사용자와 구분되는 이름으로 2~40자까지 입력할 수 있어요.</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">비밀번호</span>
|
||||
<label v-if="mode !== 'reset-request'" class="field">
|
||||
<span class="field__label">{{ mode === 'reset-confirm' ? '새 비밀번호' : '비밀번호' }}</span>
|
||||
<input
|
||||
v-model="password"
|
||||
class="field__input"
|
||||
type="password"
|
||||
placeholder="********"
|
||||
autocomplete="current-password"
|
||||
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
|
||||
maxlength="120"
|
||||
/>
|
||||
<span class="field__hint">6~120자 입력 가능 · {{ password.length }}/120자</span>
|
||||
</label>
|
||||
|
||||
<label v-if="mode === 'signup'" class="field">
|
||||
<label v-if="mode === 'signup' || mode === 'reset-confirm'" class="field">
|
||||
<span class="field__label">비밀번호 확인</span>
|
||||
<input
|
||||
v-model="passwordConfirm"
|
||||
@@ -175,12 +331,28 @@ async function submit() {
|
||||
<span class="field__hint">같은 비밀번호를 한 번 더 입력해주세요. {{ passwordConfirm.length }}/120자</span>
|
||||
</label>
|
||||
|
||||
<div v-if="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||
<div v-if="mode === 'signup' && !hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
||||
<div v-if="notice" class="authNotice">{{ notice }}</div>
|
||||
<div v-if="error" class="authError">{{ error }}</div>
|
||||
|
||||
<div v-if="mode === 'login'" class="authHelpRow">
|
||||
<button type="button" class="linkAction" @click="switchMode('reset-request')">비밀번호를 잊으셨나요?</button>
|
||||
<button
|
||||
v-if="pendingVerificationEmail || error === '이메일 인증이 아직 완료되지 않았어요. 아래 버튼으로 인증 메일을 다시 받을 수 있어요.'"
|
||||
type="button"
|
||||
class="linkAction"
|
||||
:disabled="isSubmitting"
|
||||
@click="resendVerificationEmail"
|
||||
>
|
||||
인증 메일 재전송
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="authActions">
|
||||
<button class="secondaryAction" type="button" @click="router.push(homePath())">취소</button>
|
||||
<button class="primaryAction" type="submit">{{ submitLabel }}</button>
|
||||
<button class="secondaryAction" type="button" @click="mode === 'reset-request' || mode === 'reset-confirm' ? switchMode('login') : router.push(homePath())">
|
||||
{{ mode === 'reset-request' || mode === 'reset-confirm' ? '로그인으로 돌아가기' : '취소' }}
|
||||
</button>
|
||||
<button class="primaryAction" type="submit" :disabled="isSubmitting">{{ isSubmitting ? '처리 중...' : submitLabel }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -316,6 +488,40 @@ async function submit() {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.authNotice {
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(34, 197, 94, 0.28);
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #7ddf97;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.authHelpRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: -6px;
|
||||
}
|
||||
|
||||
.linkAction {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.linkAction:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.authActions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -337,6 +543,11 @@ async function submit() {
|
||||
color: var(--theme-accent-text);
|
||||
}
|
||||
|
||||
.primaryAction:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
border: 1px solid var(--theme-border-strong);
|
||||
background: var(--theme-surface-soft);
|
||||
|
||||
Reference in New Issue
Block a user