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:
@@ -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>
|
||||
Reference in New Issue
Block a user