273 lines
7.0 KiB
Vue
273 lines
7.0 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 { useToast } from '../composables/useToast'
|
|
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
const auth = useAuthStore()
|
|
const toast = useToast()
|
|
|
|
const email = ref('')
|
|
const password = ref('')
|
|
const passwordConfirm = ref('')
|
|
const mode = ref('login')
|
|
const error = ref('')
|
|
const hasUsers = ref(true)
|
|
|
|
watch(error, (message) => {
|
|
if (!message) return
|
|
toast.error(message)
|
|
error.value = ''
|
|
})
|
|
|
|
const title = computed(() => (mode.value === 'signup' ? '회원가입' : '로그인'))
|
|
const description = computed(() =>
|
|
mode.value === 'signup'
|
|
? '티어표를 저장하고 즐겨찾기, 개인 설정을 관리할 수 있도록 계정을 만들어요.'
|
|
: '저장한 티어표와 즐겨찾기, 프로필 설정을 이어서 관리할 수 있어요.'
|
|
)
|
|
const submitLabel = computed(() => (mode.value === 'signup' ? '가입하기' : '로그인'))
|
|
const authReady = computed(() => auth.hydrated)
|
|
const checkingSession = computed(() => !authReady.value || auth.status === 'loading')
|
|
|
|
onMounted(async () => {
|
|
if (!auth.hydrated) await auth.refresh()
|
|
if (auth.user) {
|
|
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
|
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
|
|
router.replace(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
|
},
|
|
{ immediate: true }
|
|
)
|
|
|
|
async function submit() {
|
|
error.value = ''
|
|
if (mode.value === 'signup' && password.value !== passwordConfirm.value) {
|
|
error.value = '비밀번호 확인이 일치하지 않아요.'
|
|
return
|
|
}
|
|
try {
|
|
if (mode.value === 'signup') await auth.signup(email.value, password.value)
|
|
else await auth.login(email.value, password.value)
|
|
router.push(typeof route.query.redirect === 'string' ? route.query.redirect : '/me')
|
|
} catch (e) {
|
|
error.value = '로그인/회원가입에 실패했어요.'
|
|
}
|
|
}
|
|
</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" role="tablist" aria-label="로그인 또는 회원가입">
|
|
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'login' }" @click="mode = 'login'">
|
|
로그인
|
|
</button>
|
|
<button type="button" class="authTabs__button" :class="{ 'authTabs__button--active': mode === 'signup' }" @click="mode = 'signup'">
|
|
회원가입
|
|
</button>
|
|
</div>
|
|
|
|
<form class="authFields" @submit.prevent="submit">
|
|
<label class="field">
|
|
<span class="field__label">이메일</span>
|
|
<input v-model="email" class="field__input" placeholder="you@example.com" autocomplete="email" maxlength="255" />
|
|
<span class="field__hint">로그인과 알림에 사용되는 계정 이메일입니다. {{ email.length }}/255자</span>
|
|
</label>
|
|
|
|
<label class="field">
|
|
<span class="field__label">비밀번호</span>
|
|
<input
|
|
v-model="password"
|
|
class="field__input"
|
|
type="password"
|
|
placeholder="********"
|
|
autocomplete="current-password"
|
|
maxlength="120"
|
|
/>
|
|
<span class="field__hint">6~120자 입력 가능 · {{ password.length }}/120자</span>
|
|
</label>
|
|
|
|
<label v-if="mode === 'signup'" 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="!hasUsers" class="roleBadge">첫 회원가입 계정은 자동으로 관리자 권한이 부여됩니다.</div>
|
|
|
|
<div class="authActions">
|
|
<button class="secondaryAction" type="button" @click="router.push('/')">취소</button>
|
|
<button class="primaryAction" type="submit">{{ 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 {
|
|
display: inline-flex;
|
|
gap: 8px;
|
|
width: fit-content;
|
|
padding: 6px;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--theme-border);
|
|
background: var(--theme-pill-bg);
|
|
}
|
|
|
|
.authTabs__button {
|
|
min-width: 112px;
|
|
padding: 10px 16px;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
background: transparent;
|
|
color: var(--theme-text-muted);
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.authTabs__button--active {
|
|
background: rgba(76, 133, 245, 0.22);
|
|
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);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.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>
|