From 0c538e0658389bdd5f0d18e87e22e7056171a809 Mon Sep 17 00:00:00 2001 From: sb Date: Sat, 11 Jul 2026 08:35:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=97=B0=EB=8F=99=20(=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20+=20Bearer=20=EC=84=B8=EC=85=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로그인 화면: 구글(GIS) 버튼 + 애플 버튼 + 개발용 로그인(local) - 인증 스토어(Pinia): 토큰 복구/로그인/로그아웃, 회원 티어를 광고·매칭 제어에 반영 - HTTP 클라이언트: Authorization Bearer 자동 첨부, 401 시 토큰 폐기 후 로그인 이동 - 라우터 가드: 미로그인 시 보호 경로 → /login (소셜 로그인 전용 앱) - 헤더 회원 메뉴/로그아웃, 체크인 주체를 로그인 회원으로 전환 - 토큰 localStorage 저장(웹/Capacitor 공용) Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 3 + src/api/auth.ts | 29 +++++++ src/api/http.ts | 21 ++++- src/layouts/MainLayout.vue | 26 ++++++ src/lib/token.ts | 9 ++ src/main.ts | 21 +++-- src/pages/HomePage.vue | 7 +- src/pages/LoginPage.vue | 166 +++++++++++++++++++++++++++++++++++++ src/router/index.ts | 18 ++++ src/stores/auth.ts | 61 ++++++++++++++ 10 files changed, 352 insertions(+), 9 deletions(-) create mode 100644 src/api/auth.ts create mode 100644 src/lib/token.ts create mode 100644 src/pages/LoginPage.vue create mode 100644 src/stores/auth.ts diff --git a/.env.example b/.env.example index 7b342e9..b1ae6da 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,6 @@ VITE_API_BASE_URL=http://localhost:8080 VITE_NAVER_MAP_CLIENT_ID=your_ncp_key_id # 신규 키는 ncpKeyId(기본), 구형 키면 ncpClientId 로 변경 # VITE_NAVER_MAP_AUTH_PARAM=ncpKeyId + +# 개발용 로그인 토큰 (백엔드 local 프로파일 시드와 일치). dev 빌드에서만 노출. +VITE_DEV_LOGIN_TOKEN=dev-local-token diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..115c585 --- /dev/null +++ b/src/api/auth.ts @@ -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('/api/auth/google', { idToken, rememberMe }), + apple: (identityToken: string, name: string | null, rememberMe = true) => + http.post('/api/auth/apple', { identityToken, name, rememberMe }), + me: () => http.get('/api/auth/me'), + logout: () => http.post('/api/auth/logout'), +} diff --git a/src/api/http.ts b/src/api/http.ts index 3f5ed4c..923f120 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -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(method: string, path: string, body?: unknown): Promise { + const headers: Record = { '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 diff --git a/src/layouts/MainLayout.vue b/src/layouts/MainLayout.vue index ca7cf6c..10d3bdb 100644 --- a/src/layouts/MainLayout.vue +++ b/src/layouts/MainLayout.vue @@ -8,6 +8,23 @@ {{ tierLabel }} + + + + + + {{ auth.member?.nickname }} + {{ auth.member?.email }} + + + + + + 로그아웃 + + + + @@ -34,11 +51,15 @@ diff --git a/src/lib/token.ts b/src/lib/token.ts new file mode 100644 index 0000000..4d5eeec --- /dev/null +++ b/src/lib/token.ts @@ -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), +} diff --git a/src/main.ts b/src/main.ts index 17b51c9..ed23b07 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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')) diff --git a/src/pages/HomePage.vue b/src/pages/HomePage.vue index 1083b22..800ccc9 100644 --- a/src/pages/HomePage.vue +++ b/src/pages/HomePage.vue @@ -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([]) const currentSpot = ref(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' }) diff --git a/src/pages/LoginPage.vue b/src/pages/LoginPage.vue new file mode 100644 index 0000000..ea5856e --- /dev/null +++ b/src/pages/LoginPage.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/src/router/index.ts b/src/router/index.ts index c9bfd2d..0ebd452 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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 diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..a6034a9 --- /dev/null +++ b/src/stores/auth.ts @@ -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(null) + const isAuthenticated = computed(() => member.value !== null) + + /** 앱 시작 시 저장된 토큰으로 세션 복구. 실패 시 토큰 폐기. */ + async function init(): Promise { + 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 { + apply(await authApi.google(idToken, rememberMe)) + } + + async function loginApple(identityToken: string, name: string | null, rememberMe = true): Promise { + apply(await authApi.apple(identityToken, name, rememberMe)) + } + + /** 개발용: 시드된 세션 토큰으로 바로 로그인 (local 환경 검증용). */ + async function devLogin(token: string): Promise { + tokenStore.set(token) + member.value = await authApi.me() + syncTier() + } + + async function logout(): Promise { + 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 } +})