234 lines
8.8 KiB
Vue
234 lines
8.8 KiB
Vue
<script setup>
|
|
definePageMeta({
|
|
layout: 'page'
|
|
})
|
|
|
|
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
|
default: () => ({
|
|
emailOtpConfigured: false
|
|
})
|
|
})
|
|
|
|
const step = ref(1)
|
|
const isSubmitting = ref(false)
|
|
const errorMessage = ref('')
|
|
const statusMessage = ref('')
|
|
const otpRequestLoading = ref(false)
|
|
const otpCooldownSeconds = ref(0)
|
|
let otpCooldownTimerId = null
|
|
|
|
const form = reactive({
|
|
email: '',
|
|
code: '',
|
|
newPassword: '',
|
|
newPasswordConfirm: ''
|
|
})
|
|
|
|
const showPassword = ref(false)
|
|
const showPasswordConfirm = ref(false)
|
|
|
|
const emailOtpAvailable = computed(() => Boolean(bootstrapStatus.value?.emailOtpConfigured))
|
|
|
|
/**
|
|
* 비밀번호 재설정용 인증번호를 요청한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const requestResetOtp = async () => {
|
|
errorMessage.value = ''
|
|
statusMessage.value = ''
|
|
if (!emailOtpAvailable.value) {
|
|
errorMessage.value = '비밀번호 재설정 이메일을 사용하려면 서버에 Resend(RESEND_API_KEY, RESEND_FROM_EMAIL)가 설정되어 있어야 합니다.'
|
|
return
|
|
}
|
|
if (!form.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
|
errorMessage.value = '유효한 이메일을 입력해 주세요.'
|
|
return
|
|
}
|
|
if (otpCooldownSeconds.value > 0 || otpRequestLoading.value) {
|
|
return
|
|
}
|
|
otpRequestLoading.value = true
|
|
try {
|
|
const res = await $fetch('/api/auth/email-otp/request', {
|
|
method: 'POST',
|
|
body: {
|
|
email: form.email.trim(),
|
|
purpose: 'password_reset'
|
|
}
|
|
})
|
|
statusMessage.value = res?.message || '요청을 처리했습니다.'
|
|
step.value = 2
|
|
otpCooldownSeconds.value = 60
|
|
if (otpCooldownTimerId) {
|
|
clearInterval(otpCooldownTimerId)
|
|
}
|
|
otpCooldownTimerId = setInterval(() => {
|
|
otpCooldownSeconds.value -= 1
|
|
if (otpCooldownSeconds.value <= 0 && otpCooldownTimerId) {
|
|
clearInterval(otpCooldownTimerId)
|
|
otpCooldownTimerId = null
|
|
}
|
|
}, 1000)
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '인증번호 요청에 실패했습니다.'
|
|
} finally {
|
|
otpRequestLoading.value = false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 인증번호와 새 비밀번호로 재설정을 완료한다.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
const submitReset = async () => {
|
|
errorMessage.value = ''
|
|
statusMessage.value = ''
|
|
const digits = String(form.code || '').replace(/\D/g, '')
|
|
if (digits.length !== 6) {
|
|
errorMessage.value = '6자리 인증번호를 입력해 주세요.'
|
|
return
|
|
}
|
|
if (!form.newPassword || form.newPassword.length < 8 || form.newPassword.length > 32) {
|
|
errorMessage.value = '새 비밀번호는 8~32자로 입력해 주세요.'
|
|
return
|
|
}
|
|
if (form.newPassword !== form.newPasswordConfirm) {
|
|
errorMessage.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
|
|
return
|
|
}
|
|
isSubmitting.value = true
|
|
try {
|
|
await $fetch('/api/auth/password-reset/confirm', {
|
|
method: 'POST',
|
|
body: {
|
|
email: form.email.trim(),
|
|
code: digits,
|
|
newPassword: form.newPassword
|
|
}
|
|
})
|
|
statusMessage.value = '비밀번호가 변경되었습니다. 로그인 페이지로 이동합니다.'
|
|
await navigateTo('/signin')
|
|
} catch (error) {
|
|
errorMessage.value = error?.data?.message || '비밀번호 재설정에 실패했습니다.'
|
|
} finally {
|
|
isSubmitting.value = false
|
|
}
|
|
}
|
|
|
|
onBeforeUnmount(() => {
|
|
if (otpCooldownTimerId) {
|
|
clearInterval(otpCooldownTimerId)
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<section class="auth-forgot-password min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
|
|
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-center px-5 py-12 sm:px-10 lg:px-16">
|
|
<div class="auth-forgot-password__panel w-full max-w-[430px] p-5 sm:p-8">
|
|
<p class="text-2xl font-semibold leading-tight">
|
|
비밀번호 찾기
|
|
</p>
|
|
<p class="mt-2 text-sm text-[#9ba3af]">
|
|
가입에 사용한 이메일로 인증번호를 보낸 뒤, 새 비밀번호를 설정합니다.
|
|
</p>
|
|
|
|
<div v-if="!emailOtpAvailable" class="auth-forgot-password__warn mt-6 rounded-[10px] border border-[#3d2a1a] bg-[#1a1410] p-4 text-sm text-[#d8dee6]">
|
|
서버에 Resend가 설정되지 않았습니다. 운영 환경에서 <span class="font-mono text-xs">RESEND_API_KEY</span>, <span class="font-mono text-xs">RESEND_FROM_EMAIL</span>, <span class="font-mono text-xs">MEMBER_SESSION_SECRET</span>(또는 <span class="font-mono text-xs">EMAIL_OTP_PEPPER</span>)를 설정한 뒤 다시 시도해 주세요.
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div v-if="step === 1" class="mt-8 space-y-5">
|
|
<div class="space-y-1.5">
|
|
<label class="text-xs text-[#d8dee6]">이메일</label>
|
|
<input
|
|
v-model="form.email"
|
|
class="auth-form-input h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm outline-none transition-colors focus:border-[#2f6feb]"
|
|
type="email"
|
|
autocomplete="email"
|
|
>
|
|
</div>
|
|
<button
|
|
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
|
type="button"
|
|
:disabled="otpRequestLoading"
|
|
@click="requestResetOtp"
|
|
>
|
|
{{ otpRequestLoading ? '처리 중…' : '인증번호 받기' }}
|
|
</button>
|
|
</div>
|
|
|
|
<form v-else class="auth-forgot-password__step2 mt-8 space-y-5" @submit.prevent="submitReset">
|
|
<p class="text-xs text-[#9ba3af]">
|
|
{{ form.email }} 로 발송된 인증번호를 입력해 주세요.
|
|
</p>
|
|
<div class="space-y-1.5">
|
|
<label class="text-xs text-[#d8dee6]">인증번호</label>
|
|
<input
|
|
v-model="form.code"
|
|
class="auth-form-input h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm tracking-widest outline-none transition-colors focus:border-[#2f6feb]"
|
|
type="text"
|
|
inputmode="numeric"
|
|
maxlength="6"
|
|
autocomplete="one-time-code"
|
|
>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<label class="text-xs text-[#d8dee6]">새 비밀번호</label>
|
|
<div class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
|
|
<input
|
|
v-model="form.newPassword"
|
|
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
|
:type="showPassword ? 'text' : 'password'"
|
|
autocomplete="new-password"
|
|
>
|
|
<AuthPasswordVisibilityToggle v-model="showPassword" />
|
|
</div>
|
|
</div>
|
|
<div class="space-y-1.5">
|
|
<label class="text-xs text-[#d8dee6]">새 비밀번호 확인</label>
|
|
<div class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
|
|
<input
|
|
v-model="form.newPasswordConfirm"
|
|
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
|
|
:type="showPasswordConfirm ? 'text' : 'password'"
|
|
autocomplete="new-password"
|
|
>
|
|
<AuthPasswordVisibilityToggle v-model="showPasswordConfirm" field-name="새 비밀번호 확인" />
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
class="h-9 rounded-[8px] border border-[#1a212a] px-4 text-xs text-[#d8dee6] transition-opacity hover:opacity-75"
|
|
type="button"
|
|
:disabled="isSubmitting"
|
|
@click="step = 1"
|
|
>
|
|
이메일 다시 입력
|
|
</button>
|
|
<button
|
|
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
|
type="submit"
|
|
:disabled="isSubmitting"
|
|
>
|
|
비밀번호 변경
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</template>
|
|
|
|
<p v-if="errorMessage" class="mt-4 text-xs text-[#e5acb1]" aria-live="polite">
|
|
{{ errorMessage }}
|
|
</p>
|
|
<p v-if="statusMessage" class="mt-4 text-xs text-[#9fc4ff]" aria-live="polite">
|
|
{{ statusMessage }}
|
|
</p>
|
|
|
|
<p class="mt-8 text-sm text-[#9ba3af]">
|
|
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/signin">로그인으로 돌아가기</NuxtLink>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|