feat: 로그인 페이지에 이메일/비밀번호 인증 및 회원가입 기능 추가

- 매직 링크 방식 → 이메일/비밀번호 직접 인증으로 전환
- 로그인/회원가입 탭 UI 추가
- 비밀번호 재설정(forgot-password) 뷰 추가
- Supabase 에러 메시지 한국어 변환 처리
This commit is contained in:
hyeonggil
2026-03-08 21:25:34 +09:00
parent 6784786262
commit 3f20022062

View File

@@ -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">
&copy; 2026 CampGear. All rights reserved.
</p>
<p class="text-green-200 text-sm">&copy; 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>