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,29 @@
|
||||
import { http } from './http'
|
||||
|
||||
export interface Member {
|
||||
id: number
|
||||
email: string
|
||||
nickname: string
|
||||
role: string
|
||||
provider: string
|
||||
subscriptionTier: 'FREE' | 'AD_FREE' | 'PREMIUM'
|
||||
status: string
|
||||
profileImage: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
expiresInSeconds: number
|
||||
member: Member
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
googleClientId: () => http.get<{ clientId: string }>('/api/auth/google-client-id'),
|
||||
google: (idToken: string, rememberMe = true) =>
|
||||
http.post<LoginResponse>('/api/auth/google', { idToken, rememberMe }),
|
||||
apple: (identityToken: string, name: string | null, rememberMe = true) =>
|
||||
http.post<LoginResponse>('/api/auth/apple', { identityToken, name, rememberMe }),
|
||||
me: () => http.get<Member>('/api/auth/me'),
|
||||
logout: () => http.post<void>('/api/auth/logout'),
|
||||
}
|
||||
+19
-2
@@ -1,4 +1,6 @@
|
||||
// 공통 HTTP 클라이언트 — VITE_API_BASE_URL 기준
|
||||
import { tokenStore } from '@/lib/token'
|
||||
|
||||
// 공통 HTTP 클라이언트 — VITE_API_BASE_URL 기준, Bearer 토큰 자동 첨부
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -12,13 +14,28 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// 401 발생 시 호출될 핸들러(로그인 화면 이동 등). 순환참조 방지용 주입.
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
export function setUnauthorizedHandler(fn: () => void): void {
|
||||
onUnauthorized = fn
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const token = tokenStore.get()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
tokenStore.clear()
|
||||
onUnauthorized?.()
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
const data = text ? JSON.parse(text) : null
|
||||
|
||||
|
||||
@@ -8,6 +8,23 @@
|
||||
<q-chip dense color="white" text-color="primary" icon="bolt">
|
||||
{{ tierLabel }}
|
||||
</q-chip>
|
||||
<q-btn flat round dense icon="account_circle">
|
||||
<q-menu>
|
||||
<q-list style="min-width: 160px">
|
||||
<q-item>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">{{ auth.member?.nickname }}</q-item-label>
|
||||
<q-item-label caption>{{ auth.member?.email }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<q-item clickable v-close-popup @click="onLogout">
|
||||
<q-item-section avatar><q-icon name="logout" /></q-item-section>
|
||||
<q-item-section>로그아웃</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
@@ -34,11 +51,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdBanner from '@/components/AdBanner.vue'
|
||||
import { useSubscriptionStore } from '@/stores/subscription'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const tab = ref('home')
|
||||
const subscription = useSubscriptionStore()
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const tierLabel = computed(() => {
|
||||
switch (subscription.tier) {
|
||||
@@ -50,4 +71,9 @@ const tierLabel = computed(() => {
|
||||
return 'FREE'
|
||||
}
|
||||
})
|
||||
|
||||
async function onLogout() {
|
||||
await auth.logout()
|
||||
router.replace({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// 세션 토큰 저장소. localStorage 기반 (Capacitor 웹뷰/웹 공용).
|
||||
// http 클라이언트와 인증 스토어의 순환참조를 피하기 위해 분리.
|
||||
const KEY = 'dognation.token'
|
||||
|
||||
export const tokenStore = {
|
||||
get: (): string | null => localStorage.getItem(KEY),
|
||||
set: (token: string): void => localStorage.setItem(KEY, token),
|
||||
clear: (): void => localStorage.removeItem(KEY),
|
||||
}
|
||||
+15
-6
@@ -10,19 +10,28 @@ import 'quasar/src/css/index.sass'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './css/app.scss'
|
||||
import { setUnauthorizedHandler } from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
app.use(Quasar, {
|
||||
plugins: { Notify },
|
||||
config: {
|
||||
brand: {
|
||||
// quasar-variables.scss 와 일치 (런타임 참고용)
|
||||
primary: '#6DB3E8',
|
||||
},
|
||||
brand: { primary: '#6DB3E8' },
|
||||
},
|
||||
})
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
// 401 응답 시 로그인 화면으로
|
||||
setUnauthorizedHandler(() => {
|
||||
if (router.currentRoute.value.name !== 'login') {
|
||||
router.replace({ name: 'login' })
|
||||
}
|
||||
})
|
||||
|
||||
// 저장된 토큰으로 세션 복구 후 마운트 (라우터 가드가 올바른 상태를 보도록)
|
||||
const auth = useAuthStore(pinia)
|
||||
auth.init().finally(() => app.mount('#app'))
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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>
|
||||
@@ -1,10 +1,17 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import MainLayout from '@/layouts/MainLayout.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/pages/LoginPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{ path: '', name: 'home', component: () => import('@/pages/HomePage.vue') },
|
||||
{ path: 'profile', name: 'profile', component: () => import('@/pages/ProfilePage.vue') },
|
||||
@@ -17,4 +24,15 @@ const router = createRouter({
|
||||
routes,
|
||||
})
|
||||
|
||||
// 소셜 로그인 전용 앱: 미로그인 시 보호 경로 접근 → 로그인으로
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore()
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.name === 'login' && auth.isAuthenticated) {
|
||||
return { name: 'home' }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { authApi, type LoginResponse, type Member } from '@/api/auth'
|
||||
import { tokenStore } from '@/lib/token'
|
||||
import { useSubscriptionStore } from '@/stores/subscription'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const member = ref<Member | null>(null)
|
||||
const isAuthenticated = computed(() => member.value !== null)
|
||||
|
||||
/** 앱 시작 시 저장된 토큰으로 세션 복구. 실패 시 토큰 폐기. */
|
||||
async function init(): Promise<void> {
|
||||
if (!tokenStore.get()) return
|
||||
try {
|
||||
member.value = await authApi.me()
|
||||
syncTier()
|
||||
} catch {
|
||||
tokenStore.clear()
|
||||
member.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function apply(res: LoginResponse): void {
|
||||
tokenStore.set(res.token)
|
||||
member.value = res.member
|
||||
syncTier()
|
||||
}
|
||||
|
||||
async function loginGoogle(idToken: string, rememberMe = true): Promise<void> {
|
||||
apply(await authApi.google(idToken, rememberMe))
|
||||
}
|
||||
|
||||
async function loginApple(identityToken: string, name: string | null, rememberMe = true): Promise<void> {
|
||||
apply(await authApi.apple(identityToken, name, rememberMe))
|
||||
}
|
||||
|
||||
/** 개발용: 시드된 세션 토큰으로 바로 로그인 (local 환경 검증용). */
|
||||
async function devLogin(token: string): Promise<void> {
|
||||
tokenStore.set(token)
|
||||
member.value = await authApi.me()
|
||||
syncTier()
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} finally {
|
||||
tokenStore.clear()
|
||||
member.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** 회원의 실제 구독 티어를 광고/매칭 제어 스토어에 반영. */
|
||||
function syncTier(): void {
|
||||
if (member.value) {
|
||||
useSubscriptionStore().setTier(member.value.subscriptionTier)
|
||||
}
|
||||
}
|
||||
|
||||
return { member, isAuthenticated, init, apply, loginGoogle, loginApple, devLogin, logout }
|
||||
})
|
||||
Reference in New Issue
Block a user