feat: 소셜 로그인(Google/Kakao) 및 회원가입 이메일 인증 추가
- User 엔티티 확장: passwordHash nullable, provider, emailVerified, 인증 토큰 해시/만료(select:false) - 회원가입: 자동 로그인 폐지 → 미인증 계정 생성 + 인증 메일 발송(MailService 플레이스홀더, 백엔드 로그로 링크 출력) - 이메일 인증: GET /auth/verify-email(해시 매칭+만료 검사) → 프론트로 리다이렉트, 재발송 엔드포인트 - 로그인 게이트: 미인증 로컬 403 차단, 소셜 전용 계정은 소셜 로그인 안내 - 소셜 OAuth: passport google/kakao 전략(키 미설정 시 자동 비활성), 이메일로 계정 매칭/생성 - 기존 로컬 계정 그랜드페더링(부팅 시 인증 완료 보정) - 프론트: 가입 후 인증 메일 안내 화면, 로그인 소셜 버튼 실연동 + 인증 결과 배너 - env(APP_API_URL/FRONTEND_URL/GOOGLE_*/KAKAO_*) 문서화, 문서·계약 갱신 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 로그인 — 좌측 브랜드 패널 + 우측 로그인 폼
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -14,6 +15,18 @@ const showPassword = ref(false)
|
||||
const keepLogin = ref(true)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 이메일 인증 결과 배너 — 백엔드 인증 링크가 /login?verified=1|0 으로 리다이렉트한다
|
||||
const verifyBanner = computed<{ ok: boolean; text: string } | null>(() => {
|
||||
const v = route.query.verified
|
||||
if (v === '1') return { ok: true, text: '이메일 인증이 완료되었습니다. 로그인해 주세요.' }
|
||||
if (v === '0')
|
||||
return {
|
||||
ok: false,
|
||||
text: '인증 링크가 만료되었거나 유효하지 않습니다. 인증 메일을 다시 받아 주세요.',
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 로그인 처리 — 성공 시 항상 저장소 목록으로 진입(이전 화면/딥링크로 복귀하지 않음)
|
||||
async function onLogin() {
|
||||
if (submitting.value) return
|
||||
@@ -32,9 +45,10 @@ async function onLogin() {
|
||||
}
|
||||
}
|
||||
|
||||
// 소셜 로그인 — 8단계에서 구현 예정
|
||||
function onSocial() {
|
||||
window.alert('소셜 로그인은 준비 중입니다. 이메일로 로그인해 주세요.')
|
||||
// 소셜 로그인 — 백엔드 OAuth 엔드포인트로 전체 페이지 이동(쿠키 기반 세션이라 새 창 X)
|
||||
const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
function goSocial(provider: 'google' | 'kakao') {
|
||||
window.location.href = `${apiBase}/auth/${provider}`
|
||||
}
|
||||
|
||||
// 브랜드 패널 강조 포인트
|
||||
@@ -103,12 +117,48 @@ const points = [
|
||||
업무 지시를 시작하려면 로그인하세요.
|
||||
</p>
|
||||
|
||||
<!-- 이메일 인증 결과 배너 -->
|
||||
<div
|
||||
v-if="verifyBanner"
|
||||
class="verify-banner"
|
||||
:class="{ err: !verifyBanner.ok }"
|
||||
role="status"
|
||||
>
|
||||
<svg
|
||||
v-if="verifyBanner.ok"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/><path d="M15 9l-6 6M9 9l6 6" />
|
||||
</svg>
|
||||
{{ verifyBanner.text }}
|
||||
</div>
|
||||
|
||||
<!-- 소셜 로그인 -->
|
||||
<div class="socials">
|
||||
<button
|
||||
class="sbtn"
|
||||
type="button"
|
||||
@click="onSocial"
|
||||
@click="goSocial('google')"
|
||||
>
|
||||
<span class="sico">
|
||||
<svg
|
||||
@@ -139,7 +189,7 @@ const points = [
|
||||
<button
|
||||
class="sbtn kakao"
|
||||
type="button"
|
||||
@click="onSocial"
|
||||
@click="goSocial('kakao')"
|
||||
>
|
||||
<span class="sico">
|
||||
<svg
|
||||
@@ -439,6 +489,30 @@ const points = [
|
||||
margin-top: 0.4375rem;
|
||||
}
|
||||
|
||||
.verify-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
padding: 0.6875rem 0.875rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
color: #1b7a3d;
|
||||
background: var(--green-weak);
|
||||
border: 1px solid var(--green-border);
|
||||
}
|
||||
.verify-banner.err {
|
||||
color: var(--red);
|
||||
background: var(--red-weak);
|
||||
border-color: var(--red-border);
|
||||
}
|
||||
.verify-banner svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.socials {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// 회원가입 — 가입 폼 (1단계: 이메일+JWT. 이메일 인증은 8단계 예정 → 가입 즉시 자동 로그인)
|
||||
// 회원가입 — 가입 폼 → 인증 메일 발송 안내. 인증 메일의 링크를 눌러야 가입이 완료된다.
|
||||
import { computed, ref } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 폼 상태
|
||||
@@ -17,11 +16,15 @@ const showPasswordConfirm = ref(false)
|
||||
const agreeTerms = ref(true)
|
||||
const submitting = ref(false)
|
||||
|
||||
// 화면 단계: 'form'(가입 폼) → 'verify'(인증 메일 전송됨, 8단계에서 활성화)
|
||||
// 화면 단계: 'form'(가입 폼) → 'verify'(인증 메일 전송됨)
|
||||
const step = ref<'form' | 'verify'>('form')
|
||||
const sentEmail = computed(() => email.value.trim() || 'name@acme.co')
|
||||
// 인증 메일이 실제 발송된 주소(폼 입력과 분리 — 재발송/표시에 사용)
|
||||
const sentEmail = ref('')
|
||||
const resending = ref(false)
|
||||
const resendDone = ref(false)
|
||||
const displayEmail = computed(() => sentEmail.value || email.value.trim() || 'name@acme.co')
|
||||
|
||||
// 가입 처리 — 검증 후 API 호출, 성공 시 자동 로그인되어 저장소 목록으로 이동
|
||||
// 가입 처리 — 검증 후 API 호출, 성공 시 인증 메일 안내 화면으로 전환
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
|
||||
@@ -45,18 +48,35 @@ async function submit() {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await authStore.signup({
|
||||
const result = await authStore.signup({
|
||||
name: name.value.trim(),
|
||||
email: email.value.trim(),
|
||||
password: password.value,
|
||||
})
|
||||
await router.push('/repos')
|
||||
sentEmail.value = result.email
|
||||
resendDone.value = false
|
||||
step.value = 'verify'
|
||||
} catch {
|
||||
// 에러 메시지는 API 인터셉터에서 전역 처리됨
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 인증 메일 재발송 — 존재 여부는 서버가 노출하지 않으므로 항상 완료 안내
|
||||
async function resend() {
|
||||
if (resending.value || !sentEmail.value) return
|
||||
resending.value = true
|
||||
try {
|
||||
await authStore.resendVerification(sentEmail.value)
|
||||
resendDone.value = true
|
||||
} catch {
|
||||
// 에러 메시지는 API 인터셉터에서 전역 처리됨
|
||||
} finally {
|
||||
resending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToForm() {
|
||||
step.value = 'form'
|
||||
}
|
||||
@@ -377,15 +397,33 @@ const points = [
|
||||
rx="2"
|
||||
/><path d="m22 6-10 7L2 6" />
|
||||
</svg>
|
||||
<span>{{ sentEmail }}</span>
|
||||
<span>{{ displayEmail }}</span>
|
||||
</div>
|
||||
<div class="vspam">
|
||||
메일이 보이지 않나요? 스팸함을 확인하거나 잠시 후 다시 시도해주세요.
|
||||
</div>
|
||||
<div
|
||||
v-if="resendDone"
|
||||
class="vresent"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
인증 메일을 다시 보냈습니다.
|
||||
</div>
|
||||
<div class="vactions">
|
||||
<button
|
||||
class="btn-ghost"
|
||||
type="button"
|
||||
:disabled="resending"
|
||||
@click="resend"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -397,7 +435,7 @@ const points = [
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
인증 메일 다시 보내기
|
||||
{{ resending ? '보내는 중…' : '인증 메일 다시 보내기' }}
|
||||
</button>
|
||||
<button
|
||||
class="vchange"
|
||||
@@ -790,6 +828,19 @@ const points = [
|
||||
margin-top: 1.125rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.verify .vresent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
}
|
||||
.verify .vresent svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
.verify .vactions {
|
||||
margin-top: 1.625rem;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user