Files
tier-maker/frontend/src/views/LoginView.vue

570 lines
17 KiB
Vue

<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { api } from '../lib/api'
import { homePath, mePath } from '../lib/paths'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const email = ref('')
const nickname = ref('')
const password = ref('')
const passwordConfirm = ref('')
const mode = ref('login')
const error = ref('')
const notice = ref('')
const hasUsers = ref(true)
const emailError = ref('')
const nicknameError = ref('')
const pendingVerificationEmail = ref('')
const isSubmitting = ref(false)
const title = computed(() => {
if (mode.value === 'signup') return '회원가입'
if (mode.value === 'reset-request') return '비밀번호 재설정'
if (mode.value === 'reset-confirm') return '새 비밀번호 설정'
return '로그인'
})
const description = computed(() => {
if (mode.value === 'signup') return '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.'
if (mode.value === 'reset-request') return '가입한 이메일로 비밀번호 재설정 링크를 보내드릴게요.'
if (mode.value === 'reset-confirm') return '메일로 받은 재설정 링크를 확인했어요. 새 비밀번호를 입력해주세요.'
return '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
})
const submitLabel = computed(() => {
if (mode.value === 'signup') return '가입하기'
if (mode.value === 'reset-request') return '재설정 메일 보내기'
if (mode.value === 'reset-confirm') return '새 비밀번호 저장'
return '로그인'
})
const authReady = computed(() => auth.hydrated)
const checkingSession = computed(() => !authReady.value || auth.status === 'loading')
const resetToken = computed(() => (typeof route.query.resetToken === 'string' ? route.query.resetToken : ''))
const verifyToken = computed(() => (typeof route.query.verifyToken === 'string' ? route.query.verifyToken : ''))
const redirectPath = computed(() => (typeof route.query.redirect === 'string' ? route.query.redirect : mePath()))
function clearFormFeedback() {
error.value = ''
emailError.value = ''
nicknameError.value = ''
}
function clearAuthQueryTokens() {
if (!resetToken.value && !verifyToken.value) return
const nextQuery = { ...route.query }
delete nextQuery.resetToken
delete nextQuery.verifyToken
router.replace({ path: route.path, query: nextQuery })
}
function switchMode(nextMode) {
if (mode.value === nextMode) return
mode.value = nextMode
clearFormFeedback()
notice.value = ''
pendingVerificationEmail.value = ''
password.value = ''
passwordConfirm.value = ''
if (nextMode !== 'signup') nickname.value = ''
if (nextMode !== 'reset-confirm') clearAuthQueryTokens()
}
async function completeEmailVerification(token) {
isSubmitting.value = true
try {
await auth.verifyEmail(token)
notice.value = '이메일 인증이 완료됐어요. 내 티어표 화면으로 이동합니다.'
router.replace(redirectPath.value)
} catch (e) {
mode.value = 'login'
error.value = '인증 링크가 만료되었거나 유효하지 않아요. 다시 로그인하거나 인증 메일을 재전송해주세요.'
clearAuthQueryTokens()
} finally {
isSubmitting.value = false
}
}
onMounted(async () => {
if (!auth.hydrated) await auth.refresh()
if (verifyToken.value) {
await completeEmailVerification(verifyToken.value)
return
}
if (resetToken.value) {
mode.value = 'reset-confirm'
password.value = ''
passwordConfirm.value = ''
return
}
if (auth.user) {
router.replace(redirectPath.value)
return
}
try {
const meta = await api.authMeta()
hasUsers.value = !!meta.hasUsers
} catch (e) {
hasUsers.value = true
}
})
watch(
() => [auth.hydrated, auth.user],
([hydrated, user]) => {
if (!hydrated || !user) return
if (verifyToken.value || resetToken.value) return
router.replace(redirectPath.value)
},
{ immediate: true }
)
watch(mode, () => {
clearFormFeedback()
})
watch(email, () => {
emailError.value = ''
if (error.value === '이메일이 이미 사용 중이에요.') error.value = ''
})
watch(nickname, () => {
nicknameError.value = ''
if (error.value === '닉네임이 이미 사용 중이에요.' || error.value === '사용할 수 없는 닉네임이에요.') error.value = ''
})
watch(
() => route.query.resetToken,
(value) => {
if (typeof value === 'string' && value) {
switchMode('reset-confirm')
}
}
)
async function resendVerificationEmail() {
const targetEmail = email.value.trim() || pendingVerificationEmail.value
if (!targetEmail) {
emailError.value = '이메일을 먼저 입력해주세요.'
error.value = '인증 메일을 다시 받을 이메일이 필요해요.'
return
}
clearFormFeedback()
isSubmitting.value = true
try {
await api.resendVerificationEmail({ email: targetEmail })
pendingVerificationEmail.value = targetEmail
notice.value = `${targetEmail} 주소로 인증 메일을 다시 보냈어요. 메일함과 스팸함을 함께 확인해주세요.`
} catch (e) {
const code = e?.data?.error
error.value = code === 'mail_not_configured'
? '메일 발송 설정이 아직 완료되지 않았어요. 잠시 후 다시 시도해주세요.'
: '인증 메일 재전송에 실패했어요.'
} finally {
isSubmitting.value = false
}
}
async function submit() {
clearFormFeedback()
notice.value = ''
if (mode.value === 'signup' && nickname.value.trim().length < 2) {
nicknameError.value = '닉네임은 2자 이상 입력해주세요.'
error.value = '닉네임을 확인해주세요.'
return
}
if ((mode.value === 'signup' || mode.value === 'reset-confirm') && password.value !== passwordConfirm.value) {
error.value = '비밀번호 확인이 일치하지 않아요.'
return
}
if (mode.value === 'reset-confirm' && !resetToken.value) {
error.value = '재설정 토큰이 없어 비밀번호를 바꿀 수 없어요. 메일 링크를 다시 확인해주세요.'
return
}
isSubmitting.value = true
try {
if (mode.value === 'signup') {
const result = await auth.signup(email.value, nickname.value, password.value)
if (result?.verificationRequired) {
pendingVerificationEmail.value = result.email || email.value.trim()
mode.value = 'login'
password.value = ''
passwordConfirm.value = ''
notice.value = `${pendingVerificationEmail.value} 주소로 인증 메일을 보냈어요. 인증 후 로그인해주세요.`
return
}
} else if (mode.value === 'reset-request') {
const targetEmail = email.value.trim()
await api.requestPasswordReset({ email: targetEmail })
switchMode('login')
notice.value = `${targetEmail} 주소로 비밀번호 재설정 메일을 보냈어요. 메일함과 스팸함을 함께 확인해주세요.`
return
} else if (mode.value === 'reset-confirm') {
await auth.confirmPasswordReset(resetToken.value, password.value)
clearAuthQueryTokens()
} else {
await auth.login(email.value, password.value)
}
router.push(redirectPath.value)
} catch (e) {
const code = e?.data?.error
if (mode.value === 'signup') {
if (code === 'email_taken') {
emailError.value = '이미 사용 중인 이메일입니다.'
error.value = '이메일이 이미 사용 중이에요.'
return
}
if (code === 'nickname_taken') {
nicknameError.value = '이미 사용 중인 닉네임입니다.'
error.value = '닉네임이 이미 사용 중이에요.'
return
}
if (code === 'nickname_reserved') {
nicknameError.value = '운영자/관리자처럼 혼동될 수 있는 닉네임은 사용할 수 없어요.'
error.value = '사용할 수 없는 닉네임이에요.'
return
}
if (code === 'mail_not_configured') {
error.value = '메일 발송 설정이 아직 완료되지 않아 이메일 인증을 보낼 수 없어요.'
return
}
if (code === 'mail_send_failed') {
error.value = '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.'
return
}
}
if (mode.value === 'login' && code === 'email_unverified') {
pendingVerificationEmail.value = e?.data?.email || email.value.trim()
error.value = '이메일 인증이 아직 완료되지 않았어요. 아래 버튼으로 인증 메일을 다시 받을 수 있어요.'
return
}
if (mode.value === 'reset-request') {
error.value = code === 'mail_not_configured'
? '메일 발송 설정이 아직 완료되지 않아 재설정 메일을 보낼 수 없어요.'
: '재설정 메일 발송에 실패했어요.'
return
}
if (mode.value === 'reset-confirm') {
error.value = code === 'invalid_or_expired_token'
? '재설정 링크가 만료되었거나 유효하지 않아요. 비밀번호 재설정을 다시 요청해주세요.'
: '새 비밀번호 저장에 실패했어요.'
return
}
error.value = '로그인에 실패했어요.'
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<section class="pageWrap">
<header class="pageHead">
<div class="pageHead__main">
<div class="pageHead__eyebrow">Account</div>
<h2 class="pageHead__title">{{ title }}</h2>
<div class="pageHead__desc">{{ description }}</div>
</div>
</header>
<section v-if="checkingSession" class="authScreen authScreen--loading">
<div class="authLoading">로그인 상태를 확인하고 있어요.</div>
</section>
<section v-else class="authScreen">
<div class="authTabs" :class="{ 'authTabs--signup': mode === 'signup' }" role="tablist" aria-label="로그인 또는 회원가입">
<span class="authTabs__indicator" aria-hidden="true"></span>
<button
type="button"
class="authTabs__button"
:class="{ 'authTabs__button--active': mode === 'login' || mode === 'reset-request' || mode === 'reset-confirm' }"
@click="switchMode('login')"
>
로그인
</button>
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="switchMode('signup')">
회원가입
</button>
</div>
<form class="authFields" @submit.prevent="submit">
<label v-if="mode !== 'reset-confirm'" class="field">
<span class="field__label">이메일</span>
<input v-model="email" class="field__input" type="email" placeholder="you@example.com" autocomplete="email" maxlength="255" />
<span v-if="emailError" class="field__error">{{ emailError }}</span>
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255</span>
</label>
<label v-if="mode === 'signup'" class="field">
<span class="field__label">닉네임</span>
<input v-model="nickname" class="field__input" placeholder="사용할 닉네임" autocomplete="nickname" maxlength="40" />
<span v-if="nicknameError" class="field__error">{{ nicknameError }}</span>
<span class="field__hint">다른 사용자와 구분되는 이름으로 2~40자까지 입력할 있어요.</span>
</label>
<label v-if="mode !== 'reset-request'" class="field">
<span class="field__label">{{ mode === 'reset-confirm' ? '새 비밀번호' : '비밀번호' }}</span>
<input
v-model="password"
class="field__input"
type="password"
placeholder="********"
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
maxlength="120"
/>
<span class="field__hint">6~120 입력 가능 · {{ password.length }}/120</span>
</label>
<label v-if="mode === 'signup' || mode === 'reset-confirm'" class="field">
<span class="field__label">비밀번호 확인</span>
<input
v-model="passwordConfirm"
class="field__input"
type="password"
placeholder="********"
autocomplete="new-password"
maxlength="120"
/>
<span class="field__hint">같은 비밀번호를 입력해주세요. {{ passwordConfirm.length }}/120</span>
</label>
<div v-if="mode === 'signup' && !hasUsers" class="roleBadge"> 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
<div v-if="notice" class="authNotice">{{ notice }}</div>
<div v-if="error" class="authError">{{ error }}</div>
<div v-if="mode === 'login'" class="authHelpRow">
<button type="button" class="linkAction" @click="switchMode('reset-request')">비밀번호를 잊으셨나요?</button>
<button
v-if="pendingVerificationEmail || error === '이메일 인증이 아직 완료되지 않았어요. 아래 버튼으로 인증 메일을 다시 받을 수 있어요.'"
type="button"
class="linkAction"
:disabled="isSubmitting"
@click="resendVerificationEmail"
>
인증 메일 재전송
</button>
</div>
<div class="authActions">
<button class="secondaryAction" type="button" @click="mode === 'reset-request' || mode === 'reset-confirm' ? switchMode('login') : router.push(homePath())">
{{ mode === 'reset-request' || mode === 'reset-confirm' ? '로그인으로 돌아가기' : '취소' }}
</button>
<button class="primaryAction" type="submit" :disabled="isSubmitting">{{ isSubmitting ? '처리 중...' : submitLabel }}</button>
</div>
</form>
</section>
</section>
</template>
<style scoped>
.authScreen {
display: grid;
gap: 28px;
max-width: 620px;
padding-top: 4px;
}
.authScreen--loading {
min-height: 220px;
align-items: center;
}
.authLoading {
color: var(--theme-text-muted);
font-size: 15px;
}
.authTabs {
position: relative;
display: inline-flex;
gap: 0;
width: fit-content;
padding: 6px;
border-radius: 999px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
isolation: isolate;
}
.authTabs__indicator {
position: absolute;
top: 6px;
left: 6px;
width: calc(50% - 6px);
height: calc(100% - 12px);
border-radius: 999px;
background: rgba(76, 133, 245, 0.22);
box-shadow: inset 0 0 0 1px rgba(120, 169, 255, 0.1);
transform: translateX(0);
transition: transform 220ms ease, background-color 220ms ease, box-shadow 220ms ease;
z-index: 0;
}
.authTabs--signup .authTabs__indicator {
transform: translateX(100%);
}
.authTabs__button {
position: relative;
min-width: 112px;
padding: 10px 16px;
border: 0;
border-radius: 999px;
background: transparent;
color: var(--theme-text-muted);
font-weight: 700;
cursor: pointer;
transition: color 180ms ease;
z-index: 1;
}
.authTabs__button--active {
color: var(--theme-text-strong);
}
.authFields {
display: grid;
gap: 20px;
}
.field {
display: grid;
gap: 8px;
}
.field__label {
font-size: 13px;
color: var(--theme-text-muted);
}
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid var(--theme-border-strong);
background: transparent;
color: var(--theme-text);
outline: none;
font-size: 18px;
letter-spacing: -0.02em;
}
.field__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.field__hint {
font-size: 12px;
color: var(--theme-text-soft);
}
.field__error {
font-size: 12px;
color: #ff7b7b;
font-weight: 700;
}
.roleBadge {
width: fit-content;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(96, 165, 250, 0.28);
background: rgba(96, 165, 250, 0.1);
color: var(--theme-text);
font-size: 12px;
font-weight: 700;
}
.authError {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(239, 68, 68, 0.28);
background: rgba(239, 68, 68, 0.1);
color: #ff9b9b;
font-size: 13px;
font-weight: 700;
}
.authNotice {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(34, 197, 94, 0.28);
background: rgba(34, 197, 94, 0.1);
color: #7ddf97;
font-size: 13px;
font-weight: 700;
}
.authHelpRow {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-top: -6px;
}
.linkAction {
border: 0;
background: transparent;
color: var(--theme-text-muted);
font-size: 13px;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
}
.linkAction:disabled {
opacity: 0.5;
cursor: progress;
}
.authActions {
display: flex;
gap: 12px;
flex-wrap: wrap;
padding-top: 8px;
}
.primaryAction,
.secondaryAction {
padding: 12px 18px;
border-radius: 999px;
font-weight: 700;
cursor: pointer;
}
.primaryAction {
border: 1px solid rgba(76, 133, 245, 0.96);
background: var(--theme-accent-bg);
color: var(--theme-accent-text);
}
.primaryAction:disabled {
opacity: 0.65;
cursor: progress;
}
.secondaryAction {
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
}
@media (max-width: 720px) {
.authTabs {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.authTabs__button {
min-width: 0;
}
}
</style>