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 }); definePageMeta({ layout: false });
const supabase = useSupabaseClient(); 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 loading = ref(false);
const message = ref(""); const message = ref('');
const error = ref(""); const error = ref('');
const activeTab = ref<'login' | 'signup'>('login');
const viewMode = ref<ViewMode>('auth');
async function sendMagicLink() { function resetForm() {
if (!email.value) return; 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; loading.value = true;
error.value = ""; error.value = '';
message.value = ""; message.value = '';
const { error: err } = await supabase.auth.signInWithOtp({ const { error: err } = await supabase.auth.signInWithPassword({
email: email.value, email: email.value,
options: { password: password.value,
emailRedirectTo: `${window.location.origin}/confirm`,
},
}); });
loading.value = false; loading.value = false;
if (err) { if (err) {
error.value = err.message; error.value = translateAuthError(err.message);
} else { } 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() { async function signInWithGoogle() {
await supabase.auth.signInWithOAuth({ await supabase.auth.signInWithOAuth({
provider: "google", provider: 'google',
options: { options: {
redirectTo: `${window.location.origin}/confirm`, redirectTo: `${window.location.origin}/confirm`,
}, },
}); });
} }
const tabs = [
{ label: '로그인', value: 'login' },
{ label: '회원가입', value: 'signup' },
];
</script> </script>
<template> <template>
@@ -65,9 +135,7 @@ async function signInWithGoogle() {
> >
<UIcon name="i-lucide-tent" class="text-white text-xl" /> <UIcon name="i-lucide-tent" class="text-white text-xl" />
</div> </div>
<span class="text-white text-xl font-bold tracking-tight" <span class="text-white text-xl font-bold tracking-tight">CampGear</span>
>CampGear</span
>
</div> </div>
<!-- 메인 카피 --> <!-- 메인 카피 -->
@@ -77,8 +145,7 @@ async function signInWithGoogle() {
캠핑의 모든 ,<br /> 곳에서 관리하세요 캠핑의 모든 ,<br /> 곳에서 관리하세요
</h2> </h2>
<p class="text-green-100 text-lg leading-relaxed"> <p class="text-green-100 text-lg leading-relaxed">
장비 구매부터 중고 거래까지,<br />스마트한 캠핑 장비 관리를 장비 구매부터 중고 거래까지,<br />스마트한 캠핑 장비 관리를 경험하세요.
경험하세요.
</p> </p>
</div> </div>
@@ -98,25 +165,19 @@ async function signInWithGoogle() {
> >
<UIcon :name="feature.icon" class="text-white text-sm" /> <UIcon :name="feature.icon" class="text-white text-sm" />
</div> </div>
<span class="text-green-50 text-sm font-medium">{{ <span class="text-green-50 text-sm font-medium">{{ feature.text }}</span>
feature.text
}}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 하단 문구 --> <!-- 하단 문구 -->
<div class="relative z-10"> <div class="relative z-10">
<p class="text-green-200 text-sm"> <p class="text-green-200 text-sm">&copy; 2026 CampGear. All rights reserved.</p>
&copy; 2026 CampGear. All rights reserved.
</p>
</div> </div>
</div> </div>
<!-- 우측 로그인 폼 --> <!-- 우측 로그인 폼 -->
<div <div class="flex-1 flex items-center justify-center p-6 sm:p-12 bg-white dark:bg-gray-950">
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="w-full max-w-sm space-y-8">
<!-- 모바일용 로고 --> <!-- 모바일용 로고 -->
<div class="lg:hidden text-center space-y-2"> <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" /> <UIcon name="i-lucide-tent" class="text-white text-2xl" />
</div> </div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white"> <h1 class="text-2xl font-bold text-gray-900 dark:text-white">CampGear</h1>
CampGear <p class="text-gray-500 dark:text-gray-400 text-sm">캠핑 장비 관리 앱</p>
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm">
캠핑 장비 관리 앱
</p>
</div> </div>
<!-- 폼 헤더 --> <!-- 비밀번호 재설정 뷰 -->
<div class="space-y-1"> <template v-if="viewMode === 'forgot-password'">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white"> <div class="space-y-1">
로그인 <h2 class="text-2xl font-bold text-gray-900 dark:text-white">비밀번호 재설정</h2>
</h2> <p class="text-gray-500 dark:text-gray-400 text-sm">
<p class="text-gray-500 dark:text-gray-400 text-sm"> 가입한 이메일을 입력하면 재설정 링크를 보내드립니다.
계속하려면 이메일을 입력하거나 Google 계정으로 로그인하세요. </p>
</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" />
</div> </div>
<!-- 이메일 입력 --> <div class="space-y-4">
<div class="space-y-3">
<UFormField name="email"> <UFormField name="email">
<UInput <UInput
v-model="email" v-model="email"
@@ -178,7 +208,7 @@ async function signInWithGoogle() {
size="lg" size="lg"
class="w-full" class="w-full"
:ui="{ base: 'w-full' }" :ui="{ base: 'w-full' }"
@keyup.enter="sendMagicLink" @keyup.enter="sendResetEmail"
> >
<template #leading> <template #leading>
<UIcon name="i-lucide-mail" class="text-gray-400" /> <UIcon name="i-lucide-mail" class="text-gray-400" />
@@ -191,64 +221,236 @@ async function signInWithGoogle() {
class="w-full" class="w-full"
size="lg" size="lg"
:loading="loading" :loading="loading"
@click="sendMagicLink" @click="sendResetEmail"
> >
<template #leading> 재설정 이메일 발송
<UIcon
v-if="!loading"
name="i-lucide-send"
class="text-base"
/>
</template>
매직 링크 전송
</UButton> </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> </div>
<!-- 피드백 메시지 --> <div class="space-y-4">
<Transition <!-- Google 로그인 -->
enter-active-class="transition duration-300 ease-out" <UButton
enter-from-class="opacity-0 -translate-y-2" color="neutral"
enter-to-class="opacity-100 translate-y-0" variant="outline"
> class="w-full"
<UAlert size="lg"
v-if="message" @click="signInWithGoogle"
color="success" >
icon="i-lucide-check-circle" <template #leading>
:description="message" <UIcon name="i-simple-icons-google" class="text-base" />
class="mt-2" </template>
/> Google로 계속하기
</Transition> </UButton>
<Transition
enter-active-class="transition duration-300 ease-out" <!-- 구분선 -->
enter-from-class="opacity-0 -translate-y-2" <div class="relative flex items-center gap-3">
enter-to-class="opacity-100 translate-y-0" <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>
<UAlert <div class="flex-1 h-px bg-gray-200 dark:bg-gray-800" />
v-if="error" </div>
color="error"
icon="i-lucide-alert-circle" <!-- 탭 (로그인/회원가입) -->
:description="error" <UTabs
class="mt-2" v-model="activeTab"
/> :items="tabs"
</Transition> @update:model-value="resetForm"
</div> >
<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 <p class="text-center text-xs text-gray-400 dark:text-gray-500 leading-relaxed">
class="text-center text-xs text-gray-400 dark:text-gray-500 leading-relaxed"
>
로그인 시 CampGear의 로그인 시 CampGear의
<a <a href="#" class="text-green-600 dark:text-green-400 hover:underline">이용약관</a>
href="#"
class="text-green-600 dark:text-green-400 hover:underline"
>이용약관</a
>
<a <a href="#" class="text-green-600 dark:text-green-400 hover:underline">개인정보처리방침</a> 동의하게 됩니다.
href="#"
class="text-green-600 dark:text-green-400 hover:underline"
>개인정보처리방침</a
> 동의하게 됩니다.
</p> </p>
</div> </div>
</div> </div>