v0.0.54: 사용자 인증 화면 UX 보정

회원가입/로그인 공개 화면의 모바일 가독성과 입력 피드백을 다듬고, 비밀번호 보기 토글과 상태 메시지 분리로 인증 전환 흐름을 명확히 했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 11:29:09 +09:00
parent f3f971ab1b
commit 3916bcb284
6 changed files with 74 additions and 23 deletions

View File

@@ -1,5 +1,13 @@
# 의사결정 이력 # 의사결정 이력
## 2026-05-11 v0.0.54
### 공개 인증 화면 가독성과 입력 피드백 보정
회원가입/로그인은 현재 백엔드 인증 연동 전 단계이므로, 사용자가 실제 동작 상태를 오해하지 않도록 화면 피드백을 더 명확히 보여주는 것이 우선이라고 판단했다. 회원가입은 모바일 우선 여백과 카드 패널 레이아웃으로 읽기 흐름을 정리하고, 로그인 화면은 오류 메시지와 안내 메시지를 분리해 의미가 섞이지 않게 했다.
로그인 입력에서는 비밀번호 보기/숨기기 토글을 추가해 모바일 환경에서도 오입력을 줄일 수 있게 했다. 회원가입 2단계와 로그인 화면에는 상호 이동 링크를 보강해 사용자 흐름이 한 화면에서 끊기지 않도록 정리했다.
## 2026-05-11 v0.0.53 ## 2026-05-11 v0.0.53
### 게시물 공유 모달 UI ### 게시물 공유 모달 UI

View File

@@ -77,6 +77,8 @@
- 3단계는 인증 메일 발송 안내와 재전송 버튼(쿨다운)을 제공한다. - 3단계는 인증 메일 발송 안내와 재전송 버튼(쿨다운)을 제공한다.
- 이메일 링크 확인 전에는 회원가입이 완료되지 않으며, 인증 완료 액션 이후 로그인 화면으로 이동한다. - 이메일 링크 확인 전에는 회원가입이 완료되지 않으며, 인증 완료 액션 이후 로그인 화면으로 이동한다.
- 로그인 화면은 동일한 다크 톤 폼 레이아웃을 사용한다. - 로그인 화면은 동일한 다크 톤 폼 레이아웃을 사용한다.
- 로그인 비밀번호 입력은 보기/숨기기 토글을 제공한다.
- 인증 화면 상태 메시지는 오류/안내를 분리해 `aria-live`로 노출한다.
- 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings``title`, `description` 값을 우선 사용한다. - 회원가입 1단계의 타이틀/설명은 `GET /api/site-settings``title`, `description` 값을 우선 사용한다.
### 레이아웃 파일 ### 레이아웃 파일
@@ -510,6 +512,6 @@ APP_PORT=43118
## 버전 관리 ## 버전 관리
- 현재 버전: v0.0.53 - 현재 버전: v0.0.54
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가 - 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정 - 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정

View File

@@ -1,5 +1,13 @@
# 업데이트 이력 # 업데이트 이력
## v0.0.54
- 회원가입 화면(`signup`)을 모바일 우선 여백·카드 패널 구조로 보정해 가독성 개선.
- 회원가입 2단계에 로그인 진입 링크 추가.
- 회원가입 3단계 상태 메시지(`aria-live`)와 재전송 안내 문구를 정리.
- 로그인 화면(`signin`) 비밀번호 보기/숨기기 토글 추가.
- 로그인 화면 상태 메시지를 오류/안내로 분리하고 홈 이동 링크 추가.
## v0.0.53 ## v0.0.53
- 게시물 상세 제목 우측 공유 버튼에 공유 모달 추가(X/Bluesky/Facebook/LinkedIn/Email/링크복사). - 게시물 상세 제목 우측 공유 버튼에 공유 모달 추가(X/Bluesky/Facebook/LinkedIn/Email/링크복사).

View File

@@ -1,6 +1,6 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "0.0.53", "version": "0.0.54",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -5,6 +5,8 @@ definePageMeta({
const isSubmitting = ref(false) const isSubmitting = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const statusMessage = ref('')
const showPassword = ref(false)
const form = reactive({ const form = reactive({
email: '', email: '',
@@ -17,6 +19,7 @@ const form = reactive({
*/ */
const validateSignIn = () => { const validateSignIn = () => {
errorMessage.value = '' errorMessage.value = ''
statusMessage.value = ''
if (!form.email.trim() || !form.password) { if (!form.email.trim() || !form.password) {
errorMessage.value = '이메일과 비밀번호를 입력해 주세요.' errorMessage.value = '이메일과 비밀번호를 입력해 주세요.'
@@ -43,14 +46,14 @@ const submitSignIn = async () => {
isSubmitting.value = true isSubmitting.value = true
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
isSubmitting.value = false isSubmitting.value = false
errorMessage.value = '현재 로그인 API 연결 전입니다. 관리자 로그인은 /admin 을 사용해 주세요.' statusMessage.value = '현재 로그인 API 연결 전입니다. 관리자 로그인은 /admin 을 사용해 주세요.'
} }
</script> </script>
<template> <template>
<section class="auth-signin min-h-screen bg-[#0a0b0d] text-[#f5f7fa]"> <section class="auth-signin min-h-screen bg-[#0a0b0d] text-[#f5f7fa]">
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-center px-8 py-12 sm:px-16"> <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="w-full max-w-[430px]"> <div class="w-full max-w-[430px] rounded-2xl border border-[#1a212a] bg-[#0d1116] p-5 sm:p-8">
<p class="text-2xl font-semibold leading-tight"> <p class="text-2xl font-semibold leading-tight">
로그인 로그인
</p> </p>
@@ -71,12 +74,21 @@ const submitSignIn = async () => {
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">비밀번호</label> <label class="text-xs text-[#d8dee6]">비밀번호</label>
<input <div class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
v-model="form.password" <input
class="h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm outline-none transition-colors focus:border-[#2f6feb]" v-model="form.password"
type="password" class="h-10 w-full bg-transparent px-3 text-sm outline-none"
autocomplete="current-password" :type="showPassword ? 'text' : 'password'"
> autocomplete="current-password"
>
<button
class="px-3 text-xs text-[#9ba3af] transition-opacity hover:opacity-80"
type="button"
@click="showPassword = !showPassword"
>
{{ showPassword ? '숨기기' : '보기' }}
</button>
</div>
</div> </div>
<button <button
@@ -88,16 +100,22 @@ const submitSignIn = async () => {
</button> </button>
</form> </form>
<p v-if="errorMessage" class="mt-4 text-xs text-[#e5acb1]"> <p v-if="errorMessage" class="mt-4 text-xs text-[#e5acb1]" aria-live="polite">
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<p v-if="statusMessage" class="mt-4 text-xs text-[#9fc4ff]" aria-live="polite">
{{ statusMessage }}
</p>
<p class="mt-6 text-sm text-[#9ba3af]"> <p class="mt-6 text-sm text-[#9ba3af]">
계정이 없으신가요? 계정이 없으신가요?
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/signup/"> <NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/signup">
회원가입 회원가입
</NuxtLink> </NuxtLink>
</p> </p>
<NuxtLink class="mt-2 inline-flex text-xs text-[#9ba3af] hover:opacity-80" to="/">
홈으로 돌아가기
</NuxtLink>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -7,6 +7,7 @@ const currentStep = ref(1)
const resendCooldown = ref(0) const resendCooldown = ref(0)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const signupCompleted = ref(false) const signupCompleted = ref(false)
const statusMessage = ref('')
const { data: siteSettings } = await useFetch('/api/site-settings', { const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({ default: () => ({
title: 'AFFiNE', title: 'AFFiNE',
@@ -29,8 +30,8 @@ const errors = reactive({
}) })
const canResend = computed(() => resendCooldown.value <= 0) const canResend = computed(() => resendCooldown.value <= 0)
const welcomeTitle = computed(() => `Welcome to ${siteSettings.value.title || 'AFFiNE'}`) 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 welcomeDescription = computed(() => siteSettings.value?.description || 'Configure your Self Host AFFiNE with a few simple settings.')
/** /**
* 필드 에러 메시지를 초기화한다. * 필드 에러 메시지를 초기화한다.
@@ -88,6 +89,8 @@ const validateStepTwo = () => {
* @returns {void} * @returns {void}
*/ */
const goNextStep = () => { const goNextStep = () => {
statusMessage.value = ''
if (currentStep.value === 1) { if (currentStep.value === 1) {
currentStep.value = 2 currentStep.value = 2
return return
@@ -110,6 +113,7 @@ const goNextStep = () => {
const goPreviousStep = () => { const goPreviousStep = () => {
if (currentStep.value > 1 && !isSubmitting.value) { if (currentStep.value > 1 && !isSubmitting.value) {
currentStep.value -= 1 currentStep.value -= 1
statusMessage.value = ''
} }
} }
@@ -126,6 +130,7 @@ const resendVerificationEmail = async () => {
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 500))
isSubmitting.value = false isSubmitting.value = false
resendCooldown.value = 30 resendCooldown.value = 30
statusMessage.value = '인증 메일을 다시 보냈습니다. 메일함을 확인해 주세요.'
} }
/** /**
@@ -137,6 +142,7 @@ const completeSignup = async () => {
await new Promise((resolve) => setTimeout(resolve, 600)) await new Promise((resolve) => setTimeout(resolve, 600))
isSubmitting.value = false isSubmitting.value = false
signupCompleted.value = true signupCompleted.value = true
statusMessage.value = '이메일 인증이 완료되었습니다. 로그인 페이지로 이동할 수 있습니다.'
} }
const countdownTimer = ref(/** @type {ReturnType<typeof setInterval> | null} */ (null)) const countdownTimer = ref(/** @type {ReturnType<typeof setInterval> | null} */ (null))
@@ -158,11 +164,11 @@ onBeforeUnmount(() => {
<template> <template>
<section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa]"> <section class="auth-signup min-h-screen bg-[#0a0b0d] text-[#f5f7fa]">
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-start px-8 py-24 sm:px-16"> <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="flex min-h-[calc(100vh-12rem)] w-full max-w-[430px] flex-col"> <div class="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> <div>
<template v-if="currentStep === 1"> <template v-if="currentStep === 1">
<p class="text-[40px] font-semibold leading-tight"> <p class="text-[32px] font-semibold leading-tight sm:text-[40px]">
{{ welcomeTitle }} {{ welcomeTitle }}
</p> </p>
<p class="mt-2 text-sm text-[#9ba3af]"> <p class="mt-2 text-sm text-[#9ba3af]">
@@ -240,6 +246,14 @@ onBeforeUnmount(() => {
비밀번호는 8~32자로 설정해 주세요.<br> 비밀번호는 8~32자로 설정해 주세요.<br>
권장사항: 대문자, 소문자, 숫자, 기호 2개를 포함해 주세요. 권장사항: 대문자, 소문자, 숫자, 기호 2개를 포함해 주세요.
</p> </p>
<p class="mt-4 text-xs text-[#9ba3af]">
이미 계정이 있다면
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/signin">
로그인
</NuxtLink>
하세요.
</p>
</template> </template>
<template v-else> <template v-else>
@@ -265,14 +279,14 @@ onBeforeUnmount(() => {
</button> </button>
</div> </div>
<p v-if="signupCompleted" class="mt-4 text-sm text-[#7ccf90]"> <p v-if="statusMessage" class="mt-4 text-sm text-[#7ccf90]" aria-live="polite">
이메일 인증이 완료되었습니다. 로그인 화면으로 이동해 주세요. {{ statusMessage }}
</p> </p>
</template> </template>
</div> </div>
<div class="mt-auto pt-10"> <div class="mt-auto pt-10">
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<button <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" 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" type="button"
@@ -284,8 +298,9 @@ onBeforeUnmount(() => {
<button <button
v-if="currentStep < 3" v-if="currentStep < 3"
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90" 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" type="button"
:disabled="isSubmitting"
@click="goNextStep" @click="goNextStep"
> >
다음으로 다음으로
@@ -304,7 +319,7 @@ onBeforeUnmount(() => {
<NuxtLink <NuxtLink
v-else 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" 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/" to="/signin"
> >
로그인으로 이동 로그인으로 이동
</NuxtLink> </NuxtLink>