From 85c925008af1f7ba00865597eeb46b73a44dc6d7 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sat, 11 Jul 2026 08:59:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=BD=98=EC=86=94=20=E2=80=94=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20+=20=ED=9A=8C=EC=9B=90=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=20(=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=B8=EC=A6=9D=EA=B3=BC?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자 전용 store(adminAuth)·API(adminAuth·adminMembers)로 사용자 auth와 분리, 공용 저수준(http·tokenStore)만 재사용 - 관리자 레이아웃(헤더+좌측 사이드메뉴+바디+풋터), 웹 전용 - 구글 로그인(GIS) + 개발자 로그인(dev, admin@dog.com) — /admin/login - 회원관리 q-table: 검색·상태/구독/권한 인라인 변경·삭제, 본인 행 잠금 - 라우터 /admin 경로 + 별도 관리자 가드, http에 put/del 추가, 401 분기(관리자→admin-login), Dialog 등록 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/api/adminAuth.ts | 8 + src/api/adminMembers.ts | 22 +++ src/api/http.ts | 2 + src/layouts/AdminLayout.vue | 67 ++++++++ src/main.ts | 13 +- src/pages/admin/AdminLoginPage.vue | 143 +++++++++++++++ src/pages/admin/AdminMembersPage.vue | 248 +++++++++++++++++++++++++++ src/router/index.ts | 42 +++++ src/stores/adminAuth.ts | 62 +++++++ src/types/google-gsi.d.ts | 32 ++++ 10 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 src/api/adminAuth.ts create mode 100644 src/api/adminMembers.ts create mode 100644 src/layouts/AdminLayout.vue create mode 100644 src/pages/admin/AdminLoginPage.vue create mode 100644 src/pages/admin/AdminMembersPage.vue create mode 100644 src/stores/adminAuth.ts create mode 100644 src/types/google-gsi.d.ts diff --git a/src/api/adminAuth.ts b/src/api/adminAuth.ts new file mode 100644 index 0000000..0d5b880 --- /dev/null +++ b/src/api/adminAuth.ts @@ -0,0 +1,8 @@ +import { http } from './http' +import type { LoginResponse } from './auth' + +/** 관리자 콘솔 전용 인증 API (사용자 authApi 와 분리). */ +export const adminAuthApi = { + /** 개발자 로그인 — local 백엔드 전용(구글 없이 이메일로 세션 발급). */ + devLogin: (email: string) => http.post('/api/auth/dev-login', { email }), +} diff --git a/src/api/adminMembers.ts b/src/api/adminMembers.ts new file mode 100644 index 0000000..4dce6ff --- /dev/null +++ b/src/api/adminMembers.ts @@ -0,0 +1,22 @@ +import { http } from './http' +import type { Member } from './auth' + +export type MemberStatus = 'ACTIVE' | 'DORMANT' | 'BANNED' +export type MemberTier = 'FREE' | 'AD_FREE' | 'PREMIUM' +export type MemberRole = 'USER' | 'ADMIN' + +/** 관리자 회원관리 API — /api/admin/members (ADMIN 전용) */ +export const adminMembersApi = { + list: () => http.get('/api/admin/members'), + + updateStatus: (id: number, status: MemberStatus) => + http.put(`/api/admin/members/${id}/status`, { status }), + + updateTier: (id: number, tier: MemberTier) => + http.put(`/api/admin/members/${id}/tier`, { tier }), + + updateRole: (id: number, role: MemberRole) => + http.put(`/api/admin/members/${id}/role`, { role }), + + remove: (id: number) => http.del(`/api/admin/members/${id}`), +} diff --git a/src/api/http.ts b/src/api/http.ts index 923f120..a922498 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -51,4 +51,6 @@ async function request(method: string, path: string, body?: unknown): Promise export const http = { get: (path: string) => request('GET', path), post: (path: string, body?: unknown) => request('POST', path, body), + put: (path: string, body?: unknown) => request('PUT', path, body), + del: (path: string) => request('DELETE', path), } diff --git a/src/layouts/AdminLayout.vue b/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..89a1913 --- /dev/null +++ b/src/layouts/AdminLayout.vue @@ -0,0 +1,67 @@ + + + diff --git a/src/main.ts b/src/main.ts index ed23b07..a5cca2b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,5 @@ import { createApp } from 'vue' -import { Quasar, Notify } from 'quasar' +import { Quasar, Notify, Dialog } from 'quasar' import { createPinia } from 'pinia' // Quasar 필수 스타일 & 아이콘 @@ -18,17 +18,20 @@ const app = createApp(App) const pinia = createPinia() app.use(pinia) app.use(Quasar, { - plugins: { Notify }, + plugins: { Notify, Dialog }, config: { brand: { primary: '#6DB3E8' }, }, }) app.use(router) -// 401 응답 시 로그인 화면으로 +// 401 응답 시 로그인 화면으로 (관리자 콘솔은 관리자 로그인으로 분기) setUnauthorizedHandler(() => { - if (router.currentRoute.value.name !== 'login') { - router.replace({ name: 'login' }) + const route = router.currentRoute.value + const inAdmin = route.path.startsWith('/admin') + const target = inAdmin ? 'admin-login' : 'login' + if (route.name !== target) { + router.replace({ name: target }) } }) diff --git a/src/pages/admin/AdminLoginPage.vue b/src/pages/admin/AdminLoginPage.vue new file mode 100644 index 0000000..a42f068 --- /dev/null +++ b/src/pages/admin/AdminLoginPage.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/src/pages/admin/AdminMembersPage.vue b/src/pages/admin/AdminMembersPage.vue new file mode 100644 index 0000000..a451f3e --- /dev/null +++ b/src/pages/admin/AdminMembersPage.vue @@ -0,0 +1,248 @@ + + + diff --git a/src/router/index.ts b/src/router/index.ts index 0ebd452..dbeeb1e 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,6 +1,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import MainLayout from '@/layouts/MainLayout.vue' import { useAuthStore } from '@/stores/auth' +import { useAdminAuthStore } from '@/stores/adminAuth' const routes: RouteRecordRaw[] = [ { @@ -17,6 +18,25 @@ const routes: RouteRecordRaw[] = [ { path: 'profile', name: 'profile', component: () => import('@/pages/ProfilePage.vue') }, ], }, + // 관리자 콘솔 (웹 전용) — 사용자 앱과 분리 + { + path: '/admin/login', + name: 'admin-login', + component: () => import('@/pages/admin/AdminLoginPage.vue'), + }, + { + path: '/admin', + component: () => import('@/layouts/AdminLayout.vue'), + meta: { requiresAdmin: true }, + children: [ + { path: '', redirect: { name: 'admin-members' } }, + { + path: 'members', + name: 'admin-members', + component: () => import('@/pages/admin/AdminMembersPage.vue'), + }, + ], + }, ] const router = createRouter({ @@ -26,6 +46,10 @@ const router = createRouter({ // 소셜 로그인 전용 앱: 미로그인 시 보호 경로 접근 → 로그인으로 router.beforeEach((to) => { + // 관리자 경로는 아래 관리자 가드가 처리 (사용자 가드는 관여하지 않음) + if (to.matched.some((r) => r.meta.requiresAdmin) || to.name === 'admin-login') { + return true + } const auth = useAuthStore() if (to.meta.requiresAuth && !auth.isAuthenticated) { return { name: 'login', query: { redirect: to.fullPath } } @@ -35,4 +59,22 @@ router.beforeEach((to) => { } }) +// 관리자 콘솔 가드 — 토큰/회원 복원 후 ADMIN 여부 확인 (사용자 인증과 분리) +router.beforeEach(async (to) => { + const needsAdmin = to.matched.some((r) => r.meta.requiresAdmin) + if (!needsAdmin && to.name !== 'admin-login') return true + + const admin = useAdminAuthStore() + if (admin.hasToken() && !admin.member) { + await admin.fetchMe() + } + if (needsAdmin && !admin.isAdmin) { + return { name: 'admin-login' } + } + if (to.name === 'admin-login' && admin.isAdmin) { + return { name: 'admin-members' } + } + return true +}) + export default router diff --git a/src/stores/adminAuth.ts b/src/stores/adminAuth.ts new file mode 100644 index 0000000..bfe8f57 --- /dev/null +++ b/src/stores/adminAuth.ts @@ -0,0 +1,62 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { authApi, type Member } from '@/api/auth' +import { adminAuthApi } from '@/api/adminAuth' +import { tokenStore } from '@/lib/token' + +/** + * 관리자 콘솔 전용 인증 스토어 (사용자 auth 스토어와 분리). + * 저수준 토큰 저장소(tokenStore)와 소셜 로그인/조회(authApi)는 공용을 재사용한다. + */ +export const useAdminAuthStore = defineStore('adminAuth', () => { + const member = ref(null) + const isAdmin = computed(() => member.value?.role === 'ADMIN') + + function hasToken(): boolean { + return !!tokenStore.get() + } + + /** 구글 로그인 → 토큰 저장 + 회원 세팅. */ + async function loginWithGoogle(idToken: string): Promise { + const res = await authApi.google(idToken) + tokenStore.set(res.token) + member.value = res.member + return res.member + } + + /** 개발자 로그인 (local 백엔드 전용). */ + async function loginDev(email: string): Promise { + const res = await adminAuthApi.devLogin(email) + tokenStore.set(res.token) + member.value = res.member + return res.member + } + + /** 토큰으로 회원 복원(새로고침/가드). 실패 시 세션 정리. */ + async function fetchMe(): Promise { + if (!tokenStore.get()) return null + try { + member.value = await authApi.me() + return member.value + } catch { + clear() + return null + } + } + + async function logout(): Promise { + try { + await authApi.logout() + } catch { + /* 서버 실패해도 로컬 세션은 정리 */ + } + clear() + } + + function clear(): void { + tokenStore.clear() + member.value = null + } + + return { member, isAdmin, hasToken, loginWithGoogle, loginDev, fetchMe, logout, clear } +}) diff --git a/src/types/google-gsi.d.ts b/src/types/google-gsi.d.ts new file mode 100644 index 0000000..5fcc288 --- /dev/null +++ b/src/types/google-gsi.d.ts @@ -0,0 +1,32 @@ +// Google Identity Services (GIS) 최소 타입 선언 — 웹 구글 로그인 버튼용 +interface GsiCredentialResponse { + credential: string +} + +interface GsiButtonConfig { + type?: 'standard' | 'icon' + theme?: 'outline' | 'filled_blue' | 'filled_black' + size?: 'large' | 'medium' | 'small' + text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin' + shape?: 'rectangular' | 'pill' | 'circle' | 'square' + width?: number + locale?: string +} + +interface GoogleAccountsId { + initialize(config: { + client_id: string + callback: (response: GsiCredentialResponse) => void + auto_select?: boolean + }): void + renderButton(parent: HTMLElement, options: GsiButtonConfig): void + prompt(): void +} + +interface Window { + google?: { + accounts: { + id: GoogleAccountsId + } + } +}