헤더 검색 중앙 정렬·Resend 이메일 OTP·비밀번호 찾기 (v0.0.99)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 13:34:21 +09:00
parent 996965740f
commit 6a059a9a59
22 changed files with 984 additions and 34 deletions

233
pages/forgot-password.vue Normal file
View File

@@ -0,0 +1,233 @@
<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>

View File

@@ -126,6 +126,11 @@ const submitSignIn = async () => {
회원가입
</NuxtLink>
</p>
<p class="mt-3 text-sm text-[#9ba3af]">
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/forgot-password">
비밀번호를 잊으셨나요?
</NuxtLink>
</p>
<NuxtLink class="mt-2 inline-flex text-xs text-[#9ba3af] hover:opacity-80" to="/">
홈으로 돌아가기
</NuxtLink>

View File

@@ -18,13 +18,15 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
default: () => ({
hasUsers: true,
needsAdminSetup: false
needsAdminSetup: false,
emailOtpConfigured: false
})
})
const form = reactive({
username: '',
email: '',
emailOtp: '',
password: '',
passwordConfirm: ''
})
@@ -32,14 +34,20 @@ const form = reactive({
const errors = reactive({
username: '',
email: '',
emailOtp: '',
password: '',
passwordConfirm: ''
})
const showSignupPassword = ref(false)
const showSignupPasswordConfirm = ref(false)
const otpRequestLoading = ref(false)
const otpCooldownSeconds = ref(0)
let otpCooldownTimerId = null
const isAdminBootstrapMode = computed(() => Boolean(bootstrapStatus.value?.needsAdminSetup))
/** Resend 등 설정이 되어 있고 최초 관리자 모드가 아닐 때만 이메일 OTP 필요 */
const emailOtpRequired = computed(() => Boolean(bootstrapStatus.value?.emailOtpConfigured) && !isAdminBootstrapMode.value)
const welcomeTitle = computed(() => `Welcome to ${siteSettings.value?.title || 'AFFiNE'}`)
const welcomeDescription = computed(() => siteSettings.value?.description || 'Configure your Self Host AFFiNE with a few simple settings.')
const stepTwoTitle = computed(() => (isAdminBootstrapMode.value ? '관리자 등록' : '회원 가입'))
@@ -54,6 +62,7 @@ const stepTwoDescription = computed(() => (isAdminBootstrapMode.value
const resetErrors = () => {
errors.username = ''
errors.email = ''
errors.emailOtp = ''
errors.password = ''
errors.passwordConfirm = ''
}
@@ -95,9 +104,65 @@ const validateStepTwo = () => {
valid = false
}
if (emailOtpRequired.value) {
const digits = String(form.emailOtp || '').replace(/\D/g, '')
if (digits.length !== 6) {
errors.emailOtp = '이메일로 받은 6자리 인증번호를 입력해 주세요.'
valid = false
}
}
return valid
}
/**
* 회원가입용 이메일 인증번호를 요청한다.
* @returns {Promise<void>}
*/
const requestSignupEmailOtp = async () => {
submitErrorMessage.value = ''
errors.email = ''
errors.emailOtp = ''
if (!form.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
errors.email = '인증번호를 받으려면 유효한 이메일을 먼저 입력해 주세요.'
return
}
if (otpCooldownSeconds.value > 0 || otpRequestLoading.value) {
return
}
otpRequestLoading.value = true
try {
await $fetch('/api/auth/email-otp/request', {
method: 'POST',
body: {
email: form.email.trim(),
purpose: 'signup'
}
})
otpCooldownSeconds.value = 60
if (otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
}
otpCooldownTimerId = setInterval(() => {
otpCooldownSeconds.value -= 1
if (otpCooldownSeconds.value <= 0 && otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
otpCooldownTimerId = null
}
}, 1000)
} catch (error) {
submitErrorMessage.value = error?.data?.message || '인증번호 요청에 실패했습니다.'
} finally {
otpRequestLoading.value = false
}
}
onBeforeUnmount(() => {
if (otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
}
})
/**
* 다음 단계로 이동한다.
* @returns {Promise<void>}
@@ -119,13 +184,17 @@ const goNextStep = async () => {
isSubmitting.value = true
try {
const signupBody = {
username: form.username.trim(),
email: form.email.trim(),
password: form.password
}
if (emailOtpRequired.value) {
signupBody.emailOtp = String(form.emailOtp || '').replace(/\D/g, '')
}
const signupResult = await $fetch('/api/auth/signup', {
method: 'POST',
body: {
username: form.username.trim(),
email: form.email.trim(),
password: form.password
}
body: signupBody
})
createdAdmin.value = Boolean(signupResult?.isAdmin)
@@ -207,6 +276,36 @@ const goPreviousStep = () => {
</p>
</div>
<div v-if="emailOtpRequired" class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">이메일 인증번호</label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<input
v-model="form.emailOtp"
class="auth-form-input h-10 min-w-0 flex-1 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"
pattern="[0-9]*"
placeholder="6자리"
autocomplete="one-time-code"
>
<button
class="auth-signup__otp-send h-10 shrink-0 rounded-[8px] border border-[#1a212a] px-4 text-xs text-[#d8dee6] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="otpRequestLoading || otpCooldownSeconds > 0"
@click="requestSignupEmailOtp"
>
{{ otpCooldownSeconds > 0 ? `${otpCooldownSeconds}초 후 재요청` : (otpRequestLoading ? '발송 중…' : '인증번호 받기') }}
</button>
</div>
<p v-if="errors.emailOtp" class="text-xs text-[#e05d67]">
{{ errors.emailOtp }}
</p>
<p class="text-xs text-[#8c95a3]">
Resend로 발송됩니다. 메일이 보이지 않으면 스팸함을 확인해 주세요.
</p>
</div>
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 비밀번호' : '비밀번호' }}</label>
<div