Files
sori.studio/pages/signup.vue
zenn b77f37a94e v1.3.1: 어나운스 바·가입 금지 닉네임·설정 UI 개선
공개 상단 어나운스 바와 관리자 맞춤 설정을 추가하고, 스팸 필터에서 가입 금지 닉네임을 관리·검증한다. POST 설정 읽기 모드 비활성 토글과 설정 내비 아이콘 틀을 반영한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 15:50:47 +09:00

456 lines
16 KiB
Vue

<script setup>
import {
formatSignupBlockedUsernameMessage,
getSignupBlockedUsernameMatch
} from '~/lib/signup-blocked-usernames.js'
definePageMeta({
layout: 'page'
})
const currentStep = ref(1)
const isSubmitting = ref(false)
const signupCompleted = ref(false)
const createdAdmin = ref(false)
const statusMessage = ref('')
const submitErrorMessage = ref('')
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'AFFiNE',
description: 'Configure your Self Host AFFiNE with a few simple settings.',
signupBlockedUsernames: []
})
})
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
default: () => ({
hasUsers: true,
needsAdminSetup: false,
emailOtpConfigured: false
})
})
const form = reactive({
username: '',
email: '',
emailOtp: '',
password: '',
passwordConfirm: ''
})
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 ? '관리자 등록' : '회원 가입'))
const stepTwoDescription = computed(() => (isAdminBootstrapMode.value
? '첫 번째 사용자이므로 소유자 권한이 부여됩니다. 관리 작업 및 사용자 생성이 가능합니다.'
: '서비스 이용을 위한 회원 정보를 입력해 주세요.'))
/**
* 필드 에러 메시지를 초기화한다.
* @returns {void}
*/
const resetErrors = () => {
errors.username = ''
errors.email = ''
errors.emailOtp = ''
errors.password = ''
errors.passwordConfirm = ''
}
/**
* 회원가입 입력값을 검증한다.
* @returns {boolean} 검증 통과 여부
*/
const validateStepTwo = () => {
resetErrors()
let valid = true
if (!form.username.trim()) {
errors.username = '사용자명을 입력해 주세요.'
valid = false
} else {
const blockedMatch = getSignupBlockedUsernameMatch(
form.username,
siteSettings.value?.signupBlockedUsernames || []
)
if (blockedMatch) {
errors.username = formatSignupBlockedUsernameMessage(blockedMatch)
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
}
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>}
*/
const goNextStep = async () => {
statusMessage.value = ''
submitErrorMessage.value = ''
if (currentStep.value === 1) {
currentStep.value = 2
return
}
if (currentStep.value === 2) {
if (!validateStepTwo()) {
return
}
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: signupBody
})
createdAdmin.value = Boolean(signupResult?.isAdmin)
signupCompleted.value = true
currentStep.value = 3
statusMessage.value = createdAdmin.value
? '관리자 등록이 완료되었습니다. 관리자 화면으로 이동합니다.'
: '회원가입이 완료되었습니다. 잠시 후 홈으로 이동합니다.'
await navigateTo(createdAdmin.value ? '/admin' : '/')
} catch (error) {
const message = error?.data?.message || '회원가입에 실패했습니다.'
submitErrorMessage.value = message
if (message.includes('사용할 수 없는 단어')) {
errors.username = message
}
} finally {
isSubmitting.value = false
}
}
}
/**
* 이전 단계로 이동한다.
* @returns {void}
*/
const goPreviousStep = () => {
if (currentStep.value > 1 && !isSubmitting.value) {
currentStep.value -= 1
statusMessage.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">
{{ stepTwoTitle }}
</p>
<p class="mt-2 text-sm text-[#9ba3af]">
{{ stepTwoDescription }}
</p>
<form class="mt-8 space-y-5" @submit.prevent="goNextStep">
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 이름' : '사용자명' }}</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]">{{ isAdminBootstrapMode ? '관리자 이메일' : '이메일' }}</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 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
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]">{{ isAdminBootstrapMode ? '관리자 비밀번호 확인' : '비밀번호 확인' }}</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">
{{ createdAdmin ? '관리자 등록 완료' : '회원가입 완료' }}
</p>
<p class="mt-2 text-sm text-[#9ba3af]">
{{ form.email }} 계정으로 {{ createdAdmin ? '관리자 등록' : '가입' }}되었습니다.<br>
{{ createdAdmin ? '이제 관리자 화면에서 사이트 운영을 시작할 수 있습니다.' : '이제 로그인 후 댓글을 작성할 수 있습니다.' }}
</p>
<div class="mt-8 rounded-[10px] border border-[#1a212a] bg-[#0d1116] p-4">
<p class="text-sm text-[#d8dee6]">
가입이 완료되면 자동으로 홈으로 이동합니다.
</p>
</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="goNextStep"
>
가입 처리
</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>
<p v-if="submitErrorMessage" class="mt-3 text-xs text-[#e5acb1]" aria-live="polite">
{{ submitErrorMessage }}
</p>
<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>