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:
2026-06-19 17:04:30 +09:00
parent 7a2c70a260
commit 8766a4f5b6
21 changed files with 961 additions and 84 deletions
+15 -5
View File
@@ -1,13 +1,23 @@
import { useApi } from './useApi'
import type { AuthUser, LoginPayload, SignupPayload } from '@/types/auth'
import type {
AuthUser,
LoginPayload,
SignupPayload,
SignupResult,
} from '@/types/auth'
// 인증 도메인 API Composable — 인터셉터가 success/data 를 언래핑하므로 data 타입으로 캐스팅
export function useAuth() {
const api = useApi()
// 회원가입 (성공 시 백엔드가 인증 쿠키를 설정해 자동 로그인됨)
async function signup(payload: SignupPayload): Promise<AuthUser> {
return (await api.post('/auth/signup', payload)) as unknown as AuthUser
// 회원가입 — 인증 메일 발송 후 대기(자동 로그인 X). 인증 완료 전까지 로그인 불가.
async function signup(payload: SignupPayload): Promise<SignupResult> {
return (await api.post('/auth/signup', payload)) as unknown as SignupResult
}
// 인증 메일 재발송
async function resendVerification(email: string): Promise<void> {
await api.post('/auth/resend-verification', { email })
}
// 로그인
@@ -26,5 +36,5 @@ export function useAuth() {
return (await api.get('/auth/me', { silent: true })) as unknown as AuthUser
}
return { signup, login, logout, fetchMe }
return { signup, resendVerification, login, logout, fetchMe }
}
+81 -7
View File
@@ -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;
+61 -10
View File
@@ -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;
+31 -6
View File
@@ -1,7 +1,12 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useAuth } from '@/composables/useAuth'
import type { AuthUser, LoginPayload, SignupPayload } from '@/types/auth'
import type {
AuthUser,
LoginPayload,
SignupPayload,
SignupResult,
} from '@/types/auth'
// 인증 상태 스토어 — 현재 사용자/로그인 여부 + 인증 액션
export const useAuthStore = defineStore('auth', () => {
@@ -11,16 +16,27 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed<boolean>(() => user.value !== null)
const { signup: signupApi, login: loginApi, logout: logoutApi, fetchMe } = useAuth()
const {
signup: signupApi,
resendVerification: resendApi,
login: loginApi,
logout: logoutApi,
fetchMe,
} = useAuth()
// 로그인
async function login(payload: LoginPayload): Promise<void> {
user.value = await loginApi(payload)
}
// 회원가입 (성공 시 자동 로그인 상태)
async function signup(payload: SignupPayload): Promise<void> {
user.value = await signupApi(payload)
// 회원가입 — 인증 메일 발송 후 대기. 자동 로그인하지 않으므로 user 는 갱신하지 않는다.
async function signup(payload: SignupPayload): Promise<SignupResult> {
return signupApi(payload)
}
// 인증 메일 재발송
async function resendVerification(email: string): Promise<void> {
await resendApi(email)
}
// 로그아웃
@@ -44,5 +60,14 @@ export const useAuthStore = defineStore('auth', () => {
}
}
return { user, isLoggedIn, bootstrapped, login, signup, logout, bootstrap }
return {
user,
isLoggedIn,
bootstrapped,
login,
signup,
resendVerification,
logout,
bootstrap,
}
})
+6
View File
@@ -21,3 +21,9 @@ export interface SignupPayload {
name: string
role?: string
}
// 회원가입 응답 — 인증 메일 발송 후 대기 상태(자동 로그인하지 않음)
export interface SignupResult {
email: string
verificationPending: true
}