✨ feat: 로그인 페이지에 이메일/비밀번호 인증 및 회원가입 기능 추가
- 매직 링크 방식 → 이메일/비밀번호 직접 인증으로 전환 - 로그인/회원가입 탭 UI 추가 - 비밀번호 재설정(forgot-password) 뷰 추가 - Supabase 에러 메시지 한국어 변환 처리
This commit is contained in:
@@ -2,40 +2,110 @@
|
||||
definePageMeta({ layout: false });
|
||||
|
||||
const supabase = useSupabaseClient();
|
||||
const email = ref("");
|
||||
|
||||
type ViewMode = 'auth' | 'forgot-password';
|
||||
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const passwordConfirm = ref('');
|
||||
const loading = ref(false);
|
||||
const message = ref("");
|
||||
const error = ref("");
|
||||
const message = ref('');
|
||||
const error = ref('');
|
||||
const activeTab = ref<'login' | 'signup'>('login');
|
||||
const viewMode = ref<ViewMode>('auth');
|
||||
|
||||
async function sendMagicLink() {
|
||||
if (!email.value) return;
|
||||
function resetForm() {
|
||||
email.value = '';
|
||||
password.value = '';
|
||||
passwordConfirm.value = '';
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
}
|
||||
|
||||
function translateAuthError(msg: string): string {
|
||||
if (msg.includes('Invalid login credentials')) return '이메일 또는 비밀번호가 올바르지 않습니다.';
|
||||
if (msg.includes('Email not confirmed')) return '이메일 확인이 완료되지 않았습니다. 받은 편지함을 확인해주세요.';
|
||||
if (msg.includes('User already registered')) return '이미 가입된 이메일입니다. 로그인을 시도해주세요.';
|
||||
if (msg.includes('Password should be at least 6 characters')) return '비밀번호는 최소 6자 이상이어야 합니다.';
|
||||
return msg;
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
if (!email.value || !password.value) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
message.value = "";
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
|
||||
const { error: err } = await supabase.auth.signInWithOtp({
|
||||
const { error: err } = await supabase.auth.signInWithPassword({
|
||||
email: email.value,
|
||||
options: {
|
||||
emailRedirectTo: `${window.location.origin}/confirm`,
|
||||
},
|
||||
password: password.value,
|
||||
});
|
||||
|
||||
loading.value = false;
|
||||
if (err) {
|
||||
error.value = err.message;
|
||||
error.value = translateAuthError(err.message);
|
||||
} else {
|
||||
message.value = `${email.value}로 로그인 링크를 전송했습니다. 이메일을 확인해주세요.`;
|
||||
await navigateTo('/');
|
||||
}
|
||||
}
|
||||
|
||||
async function signUp() {
|
||||
if (!email.value || !password.value) return;
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
error.value = '비밀번호가 일치하지 않습니다.';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
|
||||
const { data, error: err } = await supabase.auth.signUp({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
});
|
||||
|
||||
loading.value = false;
|
||||
if (err) {
|
||||
error.value = translateAuthError(err.message);
|
||||
} else if (data.session) {
|
||||
await navigateTo('/');
|
||||
} else {
|
||||
message.value = `${email.value}로 확인 이메일을 전송했습니다. 이메일을 확인한 후 로그인해주세요.`;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendResetEmail() {
|
||||
if (!email.value) return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
|
||||
const { error: err } = await supabase.auth.resetPasswordForEmail(email.value, {
|
||||
redirectTo: `${window.location.origin}/confirm`,
|
||||
});
|
||||
|
||||
loading.value = false;
|
||||
if (err) {
|
||||
error.value = translateAuthError(err.message);
|
||||
} else {
|
||||
message.value = `${email.value}로 비밀번호 재설정 링크를 전송했습니다.`;
|
||||
}
|
||||
}
|
||||
|
||||
async function signInWithGoogle() {
|
||||
await supabase.auth.signInWithOAuth({
|
||||
provider: "google",
|
||||
provider: 'google',
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/confirm`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ label: '로그인', value: 'login' },
|
||||
{ label: '회원가입', value: 'signup' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,9 +135,7 @@ async function signInWithGoogle() {
|
||||
>
|
||||
<UIcon name="i-lucide-tent" class="text-white text-xl" />
|
||||
</div>
|
||||
<span class="text-white text-xl font-bold tracking-tight"
|
||||
>CampGear</span
|
||||
>
|
||||
<span class="text-white text-xl font-bold tracking-tight">CampGear</span>
|
||||
</div>
|
||||
|
||||
<!-- 메인 카피 -->
|
||||
@@ -77,8 +145,7 @@ async function signInWithGoogle() {
|
||||
캠핑의 모든 것,<br />한 곳에서 관리하세요
|
||||
</h2>
|
||||
<p class="text-green-100 text-lg leading-relaxed">
|
||||
장비 구매부터 중고 거래까지,<br />스마트한 캠핑 장비 관리를
|
||||
경험하세요.
|
||||
장비 구매부터 중고 거래까지,<br />스마트한 캠핑 장비 관리를 경험하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -98,25 +165,19 @@ async function signInWithGoogle() {
|
||||
>
|
||||
<UIcon :name="feature.icon" class="text-white text-sm" />
|
||||
</div>
|
||||
<span class="text-green-50 text-sm font-medium">{{
|
||||
feature.text
|
||||
}}</span>
|
||||
<span class="text-green-50 text-sm font-medium">{{ feature.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 하단 문구 -->
|
||||
<div class="relative z-10">
|
||||
<p class="text-green-200 text-sm">
|
||||
© 2026 CampGear. All rights reserved.
|
||||
</p>
|
||||
<p class="text-green-200 text-sm">© 2026 CampGear. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 우측 로그인 폼 -->
|
||||
<div
|
||||
class="flex-1 flex items-center justify-center p-6 sm:p-12 bg-white dark:bg-gray-950"
|
||||
>
|
||||
<div class="flex-1 flex items-center justify-center p-6 sm:p-12 bg-white dark:bg-gray-950">
|
||||
<div class="w-full max-w-sm space-y-8">
|
||||
<!-- 모바일용 로고 -->
|
||||
<div class="lg:hidden text-center space-y-2">
|
||||
@@ -125,51 +186,20 @@ async function signInWithGoogle() {
|
||||
>
|
||||
<UIcon name="i-lucide-tent" class="text-white text-2xl" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
CampGear
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||
캠핑 장비 관리 앱
|
||||
</p>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">CampGear</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">캠핑 장비 관리 앱</p>
|
||||
</div>
|
||||
|
||||
<!-- 폼 헤더 -->
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
로그인
|
||||
</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||
계속하려면 이메일을 입력하거나 Google 계정으로 로그인하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 폼 -->
|
||||
<div class="space-y-4">
|
||||
<!-- Google 로그인 -->
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-simple-icons-google" class="text-base" />
|
||||
</template>
|
||||
Google로 계속하기
|
||||
</UButton>
|
||||
|
||||
<!-- 구분선 -->
|
||||
<div class="relative flex items-center gap-3">
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-800" />
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 font-medium"
|
||||
>또는 이메일로</span
|
||||
>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-800" />
|
||||
<!-- 비밀번호 재설정 뷰 -->
|
||||
<template v-if="viewMode === 'forgot-password'">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">비밀번호 재설정</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||
가입한 이메일을 입력하면 재설정 링크를 보내드립니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 이메일 입력 -->
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-4">
|
||||
<UFormField name="email">
|
||||
<UInput
|
||||
v-model="email"
|
||||
@@ -178,7 +208,7 @@ async function signInWithGoogle() {
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:ui="{ base: 'w-full' }"
|
||||
@keyup.enter="sendMagicLink"
|
||||
@keyup.enter="sendResetEmail"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-mail" class="text-gray-400" />
|
||||
@@ -191,64 +221,236 @@ async function signInWithGoogle() {
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:loading="loading"
|
||||
@click="sendMagicLink"
|
||||
@click="sendResetEmail"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon
|
||||
v-if="!loading"
|
||||
name="i-lucide-send"
|
||||
class="text-base"
|
||||
/>
|
||||
</template>
|
||||
매직 링크 전송
|
||||
재설정 이메일 발송
|
||||
</UButton>
|
||||
|
||||
<button
|
||||
class="w-full text-sm text-center text-green-600 dark:text-green-400 hover:underline"
|
||||
@click="() => { viewMode = 'auth'; resetForm(); }"
|
||||
>
|
||||
로그인으로 돌아가기
|
||||
</button>
|
||||
|
||||
<!-- 피드백 메시지 -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<UAlert
|
||||
v-if="message"
|
||||
color="success"
|
||||
icon="i-lucide-check-circle"
|
||||
:description="message"
|
||||
class="mt-2"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="error"
|
||||
icon="i-lucide-alert-circle"
|
||||
:description="error"
|
||||
class="mt-2"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 인증 뷰 (로그인/회원가입) -->
|
||||
<template v-else>
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">시작하기</h2>
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||
이메일 또는 Google 계정으로 로그인하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 피드백 메시지 -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<UAlert
|
||||
v-if="message"
|
||||
color="success"
|
||||
icon="i-lucide-check-circle"
|
||||
:description="message"
|
||||
class="mt-2"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="error"
|
||||
icon="i-lucide-alert-circle"
|
||||
:description="error"
|
||||
class="mt-2"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<!-- Google 로그인 -->
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
@click="signInWithGoogle"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-simple-icons-google" class="text-base" />
|
||||
</template>
|
||||
Google로 계속하기
|
||||
</UButton>
|
||||
|
||||
<!-- 구분선 -->
|
||||
<div class="relative flex items-center gap-3">
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-800" />
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500 font-medium">또는 이메일로</span>
|
||||
<div class="flex-1 h-px bg-gray-200 dark:bg-gray-800" />
|
||||
</div>
|
||||
|
||||
<!-- 탭 (로그인/회원가입) -->
|
||||
<UTabs
|
||||
v-model="activeTab"
|
||||
:items="tabs"
|
||||
@update:model-value="resetForm"
|
||||
>
|
||||
<template #content="{ item }">
|
||||
<!-- 로그인 탭 -->
|
||||
<div v-if="item.value === 'login'" class="space-y-3 pt-4">
|
||||
<UFormField name="email">
|
||||
<UInput
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:ui="{ base: 'w-full' }"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-mail" class="text-gray-400" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="password">
|
||||
<UInput
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:ui="{ base: 'w-full' }"
|
||||
@keyup.enter="signIn"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-lock" class="text-gray-400" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UButton
|
||||
color="primary"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:loading="loading"
|
||||
@click="signIn"
|
||||
>
|
||||
로그인
|
||||
</UButton>
|
||||
|
||||
<div class="text-center">
|
||||
<button
|
||||
class="text-sm text-green-600 dark:text-green-400 hover:underline"
|
||||
@click="() => { viewMode = 'forgot-password'; resetForm(); }"
|
||||
>
|
||||
비밀번호를 잊으셨나요?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 회원가입 탭 -->
|
||||
<div v-else-if="item.value === 'signup'" class="space-y-3 pt-4">
|
||||
<UFormField name="email">
|
||||
<UInput
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:ui="{ base: 'w-full' }"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-mail" class="text-gray-400" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="password">
|
||||
<UInput
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="비밀번호 (최소 6자)"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:ui="{ base: 'w-full' }"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-lock" class="text-gray-400" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UFormField name="passwordConfirm">
|
||||
<UInput
|
||||
v-model="passwordConfirm"
|
||||
type="password"
|
||||
placeholder="비밀번호 확인"
|
||||
size="lg"
|
||||
class="w-full"
|
||||
:ui="{ base: 'w-full' }"
|
||||
@keyup.enter="signUp"
|
||||
>
|
||||
<template #leading>
|
||||
<UIcon name="i-lucide-lock" class="text-gray-400" />
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UButton
|
||||
color="primary"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
:loading="loading"
|
||||
@click="signUp"
|
||||
>
|
||||
회원가입
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UTabs>
|
||||
|
||||
<!-- 피드백 메시지 -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<UAlert
|
||||
v-if="message"
|
||||
color="success"
|
||||
icon="i-lucide-check-circle"
|
||||
:description="message"
|
||||
class="mt-2"
|
||||
/>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
>
|
||||
<UAlert
|
||||
v-if="error"
|
||||
color="error"
|
||||
icon="i-lucide-alert-circle"
|
||||
:description="error"
|
||||
class="mt-2"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 안내 문구 -->
|
||||
<p
|
||||
class="text-center text-xs text-gray-400 dark:text-gray-500 leading-relaxed"
|
||||
>
|
||||
<p class="text-center text-xs text-gray-400 dark:text-gray-500 leading-relaxed">
|
||||
로그인 시 CampGear의
|
||||
<a
|
||||
href="#"
|
||||
class="text-green-600 dark:text-green-400 hover:underline"
|
||||
>이용약관</a
|
||||
>
|
||||
<a href="#" class="text-green-600 dark:text-green-400 hover:underline">이용약관</a>
|
||||
및
|
||||
<a
|
||||
href="#"
|
||||
class="text-green-600 dark:text-green-400 hover:underline"
|
||||
>개인정보처리방침</a
|
||||
>에 동의하게 됩니다.
|
||||
<a href="#" class="text-green-600 dark:text-green-400 hover:underline">개인정보처리방침</a>에 동의하게 됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user