Files
sori.studio/pages/signup.vue
zenn 5141a63294 fix(auth): 다크 폼 입력·비밀번호 토글 스타일 보정
- .auth-form-input 전역 클래스(글자색·캐럿·placeholder·autofill)
- 토글 버튼 scoped CSS로 고정, signup 패널 보더·배경·color-scheme
- v0.0.62 문서 반영

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 13:50:12 +09:00

351 lines
12 KiB
Vue

<script setup>
definePageMeta({
layout: 'page'
})
const currentStep = ref(1)
const resendCooldown = ref(0)
const isSubmitting = ref(false)
const signupCompleted = ref(false)
const statusMessage = ref('')
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'AFFiNE',
description: 'Configure your Self Host AFFiNE with a few simple settings.'
})
})
const form = reactive({
username: '',
email: '',
password: '',
passwordConfirm: ''
})
const errors = reactive({
username: '',
email: '',
password: '',
passwordConfirm: ''
})
const showSignupPassword = ref(false)
const showSignupPasswordConfirm = ref(false)
const canResend = computed(() => resendCooldown.value <= 0)
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.')
/**
* 필드 에러 메시지를 초기화한다.
* @returns {void}
*/
const resetErrors = () => {
errors.username = ''
errors.email = ''
errors.password = ''
errors.passwordConfirm = ''
}
/**
* 회원가입 입력값을 검증한다.
* @returns {boolean} 검증 통과 여부
*/
const validateStepTwo = () => {
resetErrors()
let valid = true
if (!form.username.trim()) {
errors.username = '사용자명을 입력해 주세요.'
valid = false
}
if (!form.email.trim()) {
errors.email = '이메일을 입력해 주세요.'
valid = false
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errors.email = '이메일 주소가 유효하지 않습니다.'
valid = false
}
if (!form.password) {
errors.password = '비밀번호를 입력해 주세요.'
valid = false
} else if (form.password.length < 8 || form.password.length > 32) {
errors.password = '비밀번호는 8~32자로 입력해 주세요.'
valid = false
}
if (!form.passwordConfirm) {
errors.passwordConfirm = '비밀번호 확인을 입력해 주세요.'
valid = false
} else if (form.password !== form.passwordConfirm) {
errors.passwordConfirm = '비밀번호가 일치하지 않습니다.'
valid = false
}
return valid
}
/**
* 다음 단계로 이동한다.
* @returns {void}
*/
const goNextStep = () => {
statusMessage.value = ''
if (currentStep.value === 1) {
currentStep.value = 2
return
}
if (currentStep.value === 2) {
if (!validateStepTwo()) {
return
}
currentStep.value = 3
resendCooldown.value = 30
}
}
/**
* 이전 단계로 이동한다.
* @returns {void}
*/
const goPreviousStep = () => {
if (currentStep.value > 1 && !isSubmitting.value) {
currentStep.value -= 1
statusMessage.value = ''
}
}
/**
* 인증 메일 재전송을 시뮬레이션한다.
* @returns {Promise<void>}
*/
const resendVerificationEmail = async () => {
if (!canResend.value || isSubmitting.value) {
return
}
isSubmitting.value = true
await new Promise((resolve) => setTimeout(resolve, 500))
isSubmitting.value = false
resendCooldown.value = 30
statusMessage.value = '인증 메일을 다시 보냈습니다. 메일함을 확인해 주세요.'
}
/**
* 이메일 인증 완료를 시뮬레이션한다.
* @returns {Promise<void>}
*/
const completeSignup = async () => {
isSubmitting.value = true
await new Promise((resolve) => setTimeout(resolve, 600))
isSubmitting.value = false
signupCompleted.value = true
statusMessage.value = '이메일 인증이 완료되었습니다. 로그인 페이지로 이동할 수 있습니다.'
}
const countdownTimer = ref(/** @type {ReturnType<typeof setInterval> | null} */ (null))
onMounted(() => {
countdownTimer.value = setInterval(() => {
if (resendCooldown.value > 0) {
resendCooldown.value -= 1
}
}, 1000)
})
onBeforeUnmount(() => {
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
}
})
</script>
<template>
<section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-start px-5 py-12 sm:px-10 sm:py-16 lg:px-16 lg:py-24">
<div class="auth-signup__panel flex min-h-[calc(100vh-6rem)] w-full max-w-[430px] flex-col rounded-2xl border border-[#1a212a] bg-[#0d1116] p-5 sm:min-h-[calc(100vh-8rem)] sm:p-8 lg:min-h-[calc(100vh-12rem)]">
<div>
<template v-if="currentStep === 1">
<p class="text-[32px] font-semibold leading-tight sm:text-[40px]">
{{ welcomeTitle }}
</p>
<p class="mt-2 text-sm text-[#9ba3af]">
{{ welcomeDescription }}
</p>
</template>
<template v-else-if="currentStep === 2">
<p class="text-2xl font-semibold leading-tight">
회원 가입
</p>
<p class="mt-2 text-sm text-[#9ba3af]">
처음 생성하는 계정은 관리자 계정으로 자동 생성됩니다.
</p>
<form class="mt-8 space-y-5" @submit.prevent="goNextStep">
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">사용자명</label>
<input
v-model="form.username"
class="auth-form-input h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
:class="errors.username ? 'border-[#b03b43]' : 'border-[#1a212a] focus:border-[#2f6feb]'"
type="text"
autocomplete="username"
>
<p v-if="errors.username" class="text-xs text-[#e05d67]">
{{ errors.username }}
</p>
</div>
<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 bg-transparent px-3 text-sm outline-none transition-colors"
:class="errors.email ? 'border-[#b03b43]' : 'border-[#1a212a] focus:border-[#2f6feb]'"
type="email"
autocomplete="email"
>
<p v-if="errors.email" class="text-xs text-[#e05d67]">
{{ errors.email }}
</p>
</div>
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">비밀번호</label>
<div
class="flex items-center rounded-[8px] border transition-colors focus-within:border-[#2f6feb]"
:class="errors.password ? 'border-[#b03b43]' : 'border-[#1a212a]'"
>
<input
v-model="form.password"
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
:type="showSignupPassword ? 'text' : 'password'"
autocomplete="new-password"
>
<AuthPasswordVisibilityToggle v-model="showSignupPassword" />
</div>
<p v-if="errors.password" class="text-xs text-[#e05d67]">
{{ errors.password }}
</p>
</div>
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">비밀번호 확인</label>
<div
class="flex items-center rounded-[8px] border transition-colors focus-within:border-[#2f6feb]"
:class="errors.passwordConfirm ? 'border-[#b03b43]' : 'border-[#1a212a]'"
>
<input
v-model="form.passwordConfirm"
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
:type="showSignupPasswordConfirm ? 'text' : 'password'"
autocomplete="new-password"
>
<AuthPasswordVisibilityToggle v-model="showSignupPasswordConfirm" field-name="비밀번호 확인" />
</div>
<p v-if="errors.passwordConfirm" class="text-xs text-[#e05d67]">
{{ errors.passwordConfirm }}
</p>
</div>
</form>
<p class="mt-8 text-xs leading-relaxed text-[#8c95a3]">
비밀번호는 8~32자로 설정해 주세요.<br>
권장사항: 대문자, 소문자, 숫자, 기호 2개를 포함해 주세요.
</p>
<p class="mt-4 text-xs text-[#9ba3af]">
이미 계정이 있다면
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/signin">
로그인
</NuxtLink>
하세요.
</p>
</template>
<template v-else>
<p class="text-2xl font-semibold leading-tight">
이메일 확인
</p>
<p class="mt-2 text-sm text-[#9ba3af]">
{{ form.email }} 주소로 인증 메일을 보냈습니다.<br>
이메일 링크를 확인해야 회원가입이 확정됩니다.
</p>
<div class="mt-8 rounded-[10px] border border-[#1a212a] bg-[#0d1116] p-4">
<p class="text-sm text-[#d8dee6]">
메일이 오지 않았다면 인증 메일을 재전송해 주세요.
</p>
<button
class="mt-3 h-9 rounded-[8px] border border-[#2f6feb] px-4 text-xs font-medium text-[#7eb8ff] transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="!canResend || isSubmitting"
@click="resendVerificationEmail"
>
{{ canResend ? '인증 메일 재전송' : `${resendCooldown}초 후 재전송` }}
</button>
</div>
<p v-if="statusMessage" class="mt-4 text-sm text-[#7ccf90]" aria-live="polite">
{{ statusMessage }}
</p>
</template>
</div>
<div class="mt-auto pt-10">
<div class="flex flex-wrap items-center gap-3">
<button
class="h-9 rounded-[8px] border border-[#1a212a] px-4 text-xs text-[#d8dee6] transition-opacity hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="currentStep === 1 || isSubmitting"
@click="goPreviousStep"
>
뒤로
</button>
<button
v-if="currentStep < 3"
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-60"
type="button"
:disabled="isSubmitting"
@click="goNextStep"
>
다음으로
</button>
<button
v-else-if="!signupCompleted"
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-60"
type="button"
:disabled="isSubmitting"
@click="completeSignup"
>
인증 완료
</button>
<NuxtLink
v-else
class="inline-flex h-9 items-center rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90"
to="/signin"
>
로그인으로 이동
</NuxtLink>
</div>
<div class="mt-8 flex items-center gap-1.5">
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 1 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 2 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 3 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
</div>
</div>
</div>
</div>
</section>
</template>