feat: 프론트 인증 연동 (소셜 로그인 + Bearer 세션)

- 로그인 화면: 구글(GIS) 버튼 + 애플 버튼 + 개발용 로그인(local)
- 인증 스토어(Pinia): 토큰 복구/로그인/로그아웃, 회원 티어를 광고·매칭 제어에 반영
- HTTP 클라이언트: Authorization Bearer 자동 첨부, 401 시 토큰 폐기 후 로그인 이동
- 라우터 가드: 미로그인 시 보호 경로 → /login (소셜 로그인 전용 앱)
- 헤더 회원 메뉴/로그아웃, 체크인 주체를 로그인 회원으로 전환
- 토큰 localStorage 저장(웹/Capacitor 공용)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 08:35:00 +09:00
parent f089ba2dfe
commit 0c538e0658
10 changed files with 352 additions and 9 deletions
+6 -1
View File
@@ -75,10 +75,15 @@ import { spotsApi, type Mate, type Review, type Spot } from '@/api/spots'
import { matchesApi } from '@/api/matches'
import { ApiError } from '@/api/http'
import { useSessionStore } from '@/stores/session'
import { useAuthStore } from '@/stores/auth'
import NaverMap from '@/components/NaverMap.vue'
const $q = useQuasar()
const session = useSessionStore()
const auth = useAuthStore()
// 체크인/매칭 주체: 로그인한 회원. 강아지 ID 는 아직 견 관리 API 전이라 세션 기본값 사용.
const currentUserId = () => auth.member?.id ?? session.userId
const spots = ref<Spot[]>([])
const currentSpot = ref<Spot | null>(null)
@@ -123,7 +128,7 @@ async function onCheckIn() {
if (!currentSpot.value) return
checkingIn.value = true
try {
await spotsApi.checkIn(currentSpot.value.id, session.userId, session.dogId)
await spotsApi.checkIn(currentSpot.value.id, currentUserId(), session.dogId)
checkedIn.value = true
await refreshSpot(currentSpot.value.id)
$q.notify({ color: 'primary', message: '체크인 완료! 스팟 채팅이 열렸어요.', icon: 'place', position: 'top' })
+166
View File
@@ -0,0 +1,166 @@
<template>
<q-page class="flex flex-center column q-pa-lg bg-brand">
<div class="column items-center q-mb-xl">
<q-avatar size="88px" color="white" text-color="primary" icon="pets" />
<div class="text-h4 text-weight-bold text-white q-mt-md">산책갈개</div>
<div class="text-white q-mt-xs" style="opacity: 0.9">
우리 성향에 맞는 동네 산책 메이트
</div>
</div>
<q-card flat class="login-card q-pa-lg">
<!-- 구글 로그인 버튼(GIS 렌더링 영역) -->
<div ref="googleBtn" class="flex flex-center q-mb-md"></div>
<div v-if="googleError" class="text-caption text-negative text-center q-mb-md">
{{ googleError }}
</div>
<!-- 애플 로그인 -->
<q-btn
class="full-width q-py-sm"
color="black"
text-color="white"
unelevated
icon="mdi-apple"
label="Apple로 계속하기"
:loading="appleLoading"
@click="onApple"
/>
<!-- 개발용 로그인 (dev 빌드 전용) -->
<template v-if="isDev">
<q-separator class="q-my-md" />
<q-btn
class="full-width"
color="secondary"
text-color="accent"
flat
icon="build"
label="개발용 로그인 (local)"
:loading="devLoading"
@click="onDevLogin"
/>
</template>
</q-card>
<div class="text-caption text-white q-mt-lg" style="opacity: 0.8">
계속 진행하면 이용약관 개인정보 처리방침에 동의하게 됩니다.
</div>
</q-page>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { useAuthStore } from '@/stores/auth'
import { authApi } from '@/api/auth'
import { ApiError } from '@/api/http'
const router = useRouter()
const route = useRoute()
const $q = useQuasar()
const auth = useAuthStore()
const googleBtn = ref<HTMLElement | null>(null)
const googleError = ref('')
const appleLoading = ref(false)
const devLoading = ref(false)
const isDev = import.meta.env.DEV
onMounted(initGoogle)
async function initGoogle() {
try {
const { clientId } = await authApi.googleClientId()
if (!clientId) {
googleError.value = '구글 로그인이 아직 설정되지 않았습니다.'
return
}
await loadGsi()
const google = (window as unknown as { google: any }).google
google.accounts.id.initialize({
client_id: clientId,
callback: async (res: { credential: string }) => {
try {
await auth.loginGoogle(res.credential)
redirectAfterLogin()
} catch (e) {
notifyError(e)
}
},
})
if (googleBtn.value) {
google.accounts.id.renderButton(googleBtn.value, {
theme: 'outline',
size: 'large',
width: 260,
text: 'continue_with',
locale: 'ko',
})
}
} catch {
googleError.value = '구글 로그인 초기화에 실패했습니다. (서버 연결 확인)'
}
}
function loadGsi(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as unknown as { google?: unknown }).google) return resolve()
const s = document.createElement('script')
s.src = 'https://accounts.google.com/gsi/client'
s.async = true
s.onload = () => resolve()
s.onerror = () => reject(new Error('GSI load failed'))
document.head.appendChild(s)
})
}
async function onApple() {
// 네이티브(iOS)에서는 Capacitor Sign in with Apple 플러그인으로 identityToken 을 받아
// auth.loginApple(identityToken, name) 을 호출하도록 연동 예정.
appleLoading.value = true
$q.notify({
color: 'grey-8',
message: '애플 로그인은 네이티브(iOS) 플러그인 연동이 필요합니다.',
icon: 'mdi-apple',
position: 'top',
})
appleLoading.value = false
}
async function onDevLogin() {
devLoading.value = true
try {
const token = import.meta.env.VITE_DEV_LOGIN_TOKEN || 'dev-local-token'
await auth.devLogin(token)
redirectAfterLogin()
} catch (e) {
notifyError(e)
} finally {
devLoading.value = false
}
}
function redirectAfterLogin() {
const redirect = (route.query.redirect as string) || '/'
router.replace(redirect)
}
function notifyError(e: unknown) {
const message = e instanceof ApiError ? e.message : '로그인에 실패했습니다.'
$q.notify({ color: 'negative', message, icon: 'error', position: 'top' })
}
</script>
<style scoped>
.bg-brand {
background: linear-gradient(160deg, #6db3e8 0%, #3e92cc 100%);
min-height: 100vh;
}
.login-card {
width: 100%;
max-width: 320px;
border-radius: 16px;
}
</style>