feat(admin): 관리자 콘솔 — 소셜 로그인 + 회원관리 (사용자 인증과 분리)
- 관리자 전용 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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import type { LoginResponse } from './auth'
|
||||||
|
|
||||||
|
/** 관리자 콘솔 전용 인증 API (사용자 authApi 와 분리). */
|
||||||
|
export const adminAuthApi = {
|
||||||
|
/** 개발자 로그인 — local 백엔드 전용(구글 없이 이메일로 세션 발급). */
|
||||||
|
devLogin: (email: string) => http.post<LoginResponse>('/api/auth/dev-login', { email }),
|
||||||
|
}
|
||||||
@@ -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<Member[]>('/api/admin/members'),
|
||||||
|
|
||||||
|
updateStatus: (id: number, status: MemberStatus) =>
|
||||||
|
http.put<Member>(`/api/admin/members/${id}/status`, { status }),
|
||||||
|
|
||||||
|
updateTier: (id: number, tier: MemberTier) =>
|
||||||
|
http.put<Member>(`/api/admin/members/${id}/tier`, { tier }),
|
||||||
|
|
||||||
|
updateRole: (id: number, role: MemberRole) =>
|
||||||
|
http.put<Member>(`/api/admin/members/${id}/role`, { role }),
|
||||||
|
|
||||||
|
remove: (id: number) => http.del<void>(`/api/admin/members/${id}`),
|
||||||
|
}
|
||||||
@@ -51,4 +51,6 @@ async function request<T>(method: string, path: string, body?: unknown): Promise
|
|||||||
export const http = {
|
export const http = {
|
||||||
get: <T>(path: string) => request<T>('GET', path),
|
get: <T>(path: string) => request<T>('GET', path),
|
||||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||||
|
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||||
|
del: <T>(path: string) => request<T>('DELETE', path),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<q-layout view="hHh Lpr lFf">
|
||||||
|
<!-- 헤더 -->
|
||||||
|
<q-header elevated class="bg-primary text-white">
|
||||||
|
<q-toolbar>
|
||||||
|
<q-btn flat dense round icon="menu" aria-label="메뉴" @click="drawer = !drawer" />
|
||||||
|
<q-toolbar-title class="text-weight-bold">
|
||||||
|
<q-icon name="pets" class="q-mr-sm" />산책갈개 관리자
|
||||||
|
</q-toolbar-title>
|
||||||
|
|
||||||
|
<q-chip dense color="white" text-color="primary" icon="admin_panel_settings">
|
||||||
|
{{ admin.member?.nickname ?? '관리자' }}
|
||||||
|
</q-chip>
|
||||||
|
<q-btn flat dense round icon="logout" aria-label="로그아웃" @click="onLogout" />
|
||||||
|
</q-toolbar>
|
||||||
|
</q-header>
|
||||||
|
|
||||||
|
<!-- 좌측 사이드 메뉴 -->
|
||||||
|
<q-drawer v-model="drawer" show-if-above bordered :width="240" class="bg-grey-1">
|
||||||
|
<q-list padding>
|
||||||
|
<q-item-label header class="text-grey-7">관리 메뉴</q-item-label>
|
||||||
|
|
||||||
|
<q-item
|
||||||
|
v-for="item in menu"
|
||||||
|
:key="item.name"
|
||||||
|
clickable
|
||||||
|
:to="{ name: item.name }"
|
||||||
|
active-class="text-primary bg-blue-1"
|
||||||
|
exact
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon :name="item.icon" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>{{ item.label }}</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-drawer>
|
||||||
|
|
||||||
|
<!-- 바디 -->
|
||||||
|
<q-page-container>
|
||||||
|
<router-view />
|
||||||
|
</q-page-container>
|
||||||
|
|
||||||
|
<!-- 풋터 -->
|
||||||
|
<q-footer class="bg-white text-grey-6">
|
||||||
|
<div class="text-caption text-center q-py-sm">© 2026 산책갈개 · 관리자 콘솔</div>
|
||||||
|
</q-footer>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAdminAuthStore } from '@/stores/adminAuth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const admin = useAdminAuthStore()
|
||||||
|
|
||||||
|
const drawer = ref(true)
|
||||||
|
|
||||||
|
const menu = [{ name: 'admin-members', label: '회원 관리', icon: 'group' }]
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
await admin.logout()
|
||||||
|
await router.replace({ name: 'admin-login' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+8
-5
@@ -1,5 +1,5 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { Quasar, Notify } from 'quasar'
|
import { Quasar, Notify, Dialog } from 'quasar'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
// Quasar 필수 스타일 & 아이콘
|
// Quasar 필수 스타일 & 아이콘
|
||||||
@@ -18,17 +18,20 @@ const app = createApp(App)
|
|||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
app.use(Quasar, {
|
app.use(Quasar, {
|
||||||
plugins: { Notify },
|
plugins: { Notify, Dialog },
|
||||||
config: {
|
config: {
|
||||||
brand: { primary: '#6DB3E8' },
|
brand: { primary: '#6DB3E8' },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
// 401 응답 시 로그인 화면으로
|
// 401 응답 시 로그인 화면으로 (관리자 콘솔은 관리자 로그인으로 분기)
|
||||||
setUnauthorizedHandler(() => {
|
setUnauthorizedHandler(() => {
|
||||||
if (router.currentRoute.value.name !== 'login') {
|
const route = router.currentRoute.value
|
||||||
router.replace({ name: 'login' })
|
const inAdmin = route.path.startsWith('/admin')
|
||||||
|
const target = inAdmin ? 'admin-login' : 'login'
|
||||||
|
if (route.name !== target) {
|
||||||
|
router.replace({ name: target })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<q-layout view="lHh Lpr lFf">
|
||||||
|
<q-page-container>
|
||||||
|
<q-page class="flex flex-center bg-grey-2">
|
||||||
|
<q-card flat bordered class="login-card q-pa-lg">
|
||||||
|
<div class="text-center q-mb-lg">
|
||||||
|
<q-icon name="pets" size="42px" color="primary" />
|
||||||
|
<div class="text-h6 text-weight-bold q-mt-sm">산책갈개 관리자</div>
|
||||||
|
<div class="text-caption text-grey-6">관리자 계정으로 로그인하세요</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 구글 로그인 버튼 렌더 위치 -->
|
||||||
|
<div class="flex flex-center">
|
||||||
|
<div ref="googleBtn" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 개발자 로그인 (local 백엔드 전용, dev 빌드에서만 노출) -->
|
||||||
|
<div v-if="isDev" class="q-mt-lg">
|
||||||
|
<q-separator class="q-mb-md" />
|
||||||
|
<div class="text-caption text-grey-6 text-center q-mb-sm">개발용 (local 백엔드)</div>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
class="full-width"
|
||||||
|
icon="developer_mode"
|
||||||
|
label="개발자 로그인 (admin@dog.com)"
|
||||||
|
:loading="loading"
|
||||||
|
@click="onDevLogin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-inner-loading :showing="loading" />
|
||||||
|
|
||||||
|
<q-banner v-if="error" dense class="bg-red-1 text-red-8 q-mt-md rounded-borders">
|
||||||
|
<template #avatar><q-icon name="error" /></template>
|
||||||
|
{{ error }}
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<div v-if="!clientId && ready" class="text-caption text-grey-6 text-center q-mt-md">
|
||||||
|
구글 로그인이 설정되지 않았습니다. (서버 GOOGLE_CLIENT_ID 확인)
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-page>
|
||||||
|
</q-page-container>
|
||||||
|
</q-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { authApi } from '@/api/auth'
|
||||||
|
import { ApiError } from '@/api/http'
|
||||||
|
import { useAdminAuthStore } from '@/stores/adminAuth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const admin = useAdminAuthStore()
|
||||||
|
|
||||||
|
const googleBtn = ref<HTMLElement | null>(null)
|
||||||
|
const clientId = ref('')
|
||||||
|
const ready = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
const GSI_SRC = 'https://accounts.google.com/gsi/client'
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
clientId.value = (await authApi.googleClientId()).clientId
|
||||||
|
} catch {
|
||||||
|
error.value = '서버에 연결하지 못했습니다.'
|
||||||
|
ready.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ready.value = true
|
||||||
|
if (!clientId.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadGsi()
|
||||||
|
window.google!.accounts.id.initialize({
|
||||||
|
client_id: clientId.value,
|
||||||
|
callback: onCredential,
|
||||||
|
})
|
||||||
|
window.google!.accounts.id.renderButton(googleBtn.value as HTMLElement, {
|
||||||
|
type: 'standard',
|
||||||
|
theme: 'outline',
|
||||||
|
size: 'large',
|
||||||
|
text: 'signin_with',
|
||||||
|
shape: 'pill',
|
||||||
|
width: 260,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
error.value = '구글 로그인 초기화에 실패했습니다.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadGsi(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (window.google?.accounts?.id) return resolve()
|
||||||
|
const el = document.createElement('script')
|
||||||
|
el.src = GSI_SRC
|
||||||
|
el.async = true
|
||||||
|
el.defer = true
|
||||||
|
el.onload = () => resolve()
|
||||||
|
el.onerror = () => reject(new Error('GSI load failed'))
|
||||||
|
document.head.appendChild(el)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCredential(response: GsiCredentialResponse) {
|
||||||
|
finishLogin(() => admin.loginWithGoogle(response.credential))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDevLogin() {
|
||||||
|
finishLogin(() => admin.loginDev('admin@dog.com'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그인 실행 → ADMIN 확인 → 회원관리로 이동. 공용. */
|
||||||
|
async function finishLogin(login: () => Promise<{ role: string }>) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const member = await login()
|
||||||
|
if (member.role !== 'ADMIN') {
|
||||||
|
await admin.logout()
|
||||||
|
error.value = '관리자 권한이 없는 계정입니다.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await router.replace({ name: 'admin-members' })
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : '로그인 중 오류가 발생했습니다.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-card {
|
||||||
|
width: 360px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
<template>
|
||||||
|
<q-page class="q-pa-md">
|
||||||
|
<!-- 상단 툴바 -->
|
||||||
|
<div class="row items-center q-mb-md">
|
||||||
|
<div class="text-h6 text-weight-bold">회원 관리</div>
|
||||||
|
<q-chip dense color="blue-1" text-color="primary" class="q-ml-sm">
|
||||||
|
총 {{ members.length }}명
|
||||||
|
</q-chip>
|
||||||
|
<q-space />
|
||||||
|
<q-input
|
||||||
|
v-model="filter"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
debounce="200"
|
||||||
|
placeholder="이메일·닉네임 검색"
|
||||||
|
class="q-mr-sm"
|
||||||
|
style="min-width: 220px"
|
||||||
|
>
|
||||||
|
<template #prepend><q-icon name="search" /></template>
|
||||||
|
</q-input>
|
||||||
|
<q-btn flat round icon="refresh" :loading="loading" @click="load" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-banner v-if="loadError" dense class="bg-red-1 text-red-8 q-mb-md rounded-borders">
|
||||||
|
<template #avatar><q-icon name="wifi_off" /></template>
|
||||||
|
회원 목록을 불러오지 못했습니다.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
:rows="members"
|
||||||
|
:columns="columns"
|
||||||
|
row-key="id"
|
||||||
|
:loading="loading"
|
||||||
|
:filter="filter"
|
||||||
|
:pagination="{ rowsPerPage: 20 }"
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
>
|
||||||
|
<!-- 회원(이메일+닉네임+아바타) -->
|
||||||
|
<template #body-cell-member="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<q-avatar size="32px" color="blue-1" text-color="primary">
|
||||||
|
<img v-if="props.row.profileImage" :src="props.row.profileImage" alt="" />
|
||||||
|
<q-icon v-else name="person" />
|
||||||
|
</q-avatar>
|
||||||
|
<div class="q-ml-sm">
|
||||||
|
<div class="text-weight-medium">
|
||||||
|
{{ props.row.nickname }}
|
||||||
|
<q-chip v-if="isSelf(props.row)" dense size="sm" color="grey-3" text-color="grey-8">본인</q-chip>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-6">{{ props.row.email }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 제공자 -->
|
||||||
|
<template #body-cell-provider="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-chip dense square size="sm" :color="props.row.provider === 'APPLE' ? 'grey-9' : 'red-2'"
|
||||||
|
:text-color="props.row.provider === 'APPLE' ? 'white' : 'red-9'">
|
||||||
|
{{ props.row.provider }}
|
||||||
|
</q-chip>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 상태 -->
|
||||||
|
<template #body-cell-status="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-select
|
||||||
|
dense options-dense outlined emit-value map-options
|
||||||
|
:model-value="props.row.status"
|
||||||
|
:options="statusOptions"
|
||||||
|
:disable="isSelf(props.row) || busyId === props.row.id"
|
||||||
|
style="min-width: 110px"
|
||||||
|
@update:model-value="(v: MemberStatus) => onStatus(props.row, v)"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 구독 티어 -->
|
||||||
|
<template #body-cell-tier="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-select
|
||||||
|
dense options-dense outlined emit-value map-options
|
||||||
|
:model-value="props.row.subscriptionTier"
|
||||||
|
:options="tierOptions"
|
||||||
|
:disable="busyId === props.row.id"
|
||||||
|
style="min-width: 120px"
|
||||||
|
@update:model-value="(v: MemberTier) => onTier(props.row, v)"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 권한 -->
|
||||||
|
<template #body-cell-role="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-select
|
||||||
|
dense options-dense outlined emit-value map-options
|
||||||
|
:model-value="props.row.role"
|
||||||
|
:options="roleOptions"
|
||||||
|
:disable="isSelf(props.row) || busyId === props.row.id"
|
||||||
|
style="min-width: 110px"
|
||||||
|
@update:model-value="(v: MemberRole) => onRole(props.row, v)"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 삭제 -->
|
||||||
|
<template #body-cell-actions="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-btn
|
||||||
|
flat dense round icon="delete" color="negative"
|
||||||
|
:disable="isSelf(props.row) || busyId === props.row.id"
|
||||||
|
@click="onDelete(props.row)"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useQuasar, type QTableColumn } from 'quasar'
|
||||||
|
import {
|
||||||
|
adminMembersApi,
|
||||||
|
type MemberRole,
|
||||||
|
type MemberStatus,
|
||||||
|
type MemberTier,
|
||||||
|
} from '@/api/adminMembers'
|
||||||
|
import type { Member } from '@/api/auth'
|
||||||
|
import { ApiError } from '@/api/http'
|
||||||
|
import { useAdminAuthStore } from '@/stores/adminAuth'
|
||||||
|
|
||||||
|
const $q = useQuasar()
|
||||||
|
const admin = useAdminAuthStore()
|
||||||
|
|
||||||
|
const members = ref<Member[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref(false)
|
||||||
|
const filter = ref('')
|
||||||
|
const busyId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: '활성', value: 'ACTIVE' },
|
||||||
|
{ label: '휴면', value: 'DORMANT' },
|
||||||
|
{ label: '정지', value: 'BANNED' },
|
||||||
|
]
|
||||||
|
const tierOptions = [
|
||||||
|
{ label: 'FREE', value: 'FREE' },
|
||||||
|
{ label: '광고제거', value: 'AD_FREE' },
|
||||||
|
{ label: 'PREMIUM', value: 'PREMIUM' },
|
||||||
|
]
|
||||||
|
const roleOptions = [
|
||||||
|
{ label: '일반', value: 'USER' },
|
||||||
|
{ label: '관리자', value: 'ADMIN' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const columns: QTableColumn<Member>[] = [
|
||||||
|
{ name: 'member', label: '회원', field: 'email', align: 'left', sortable: true },
|
||||||
|
{ name: 'provider', label: '가입', field: 'provider', align: 'center' },
|
||||||
|
{ name: 'status', label: '상태', field: 'status', align: 'center' },
|
||||||
|
{ name: 'tier', label: '구독', field: 'subscriptionTier', align: 'center' },
|
||||||
|
{ name: 'role', label: '권한', field: 'role', align: 'center' },
|
||||||
|
{
|
||||||
|
name: 'createdAt',
|
||||||
|
label: '가입일',
|
||||||
|
field: 'createdAt',
|
||||||
|
align: 'center',
|
||||||
|
sortable: true,
|
||||||
|
format: (v: string) => new Date(v).toLocaleDateString('ko-KR'),
|
||||||
|
},
|
||||||
|
{ name: 'actions', label: '', field: 'id', align: 'center' },
|
||||||
|
]
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
function isSelf(m: Member): boolean {
|
||||||
|
return m.id === admin.member?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = false
|
||||||
|
try {
|
||||||
|
members.value = await adminMembersApi.list()
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = true
|
||||||
|
console.error(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 변경 API 공통 실행 — 성공 시 해당 행 갱신, 실패 시 알림. */
|
||||||
|
async function run(id: number, fn: () => Promise<Member>, done: string) {
|
||||||
|
busyId.value = id
|
||||||
|
try {
|
||||||
|
const updated = await fn()
|
||||||
|
const i = members.value.findIndex((m) => m.id === id)
|
||||||
|
if (i >= 0) members.value[i] = updated
|
||||||
|
$q.notify({ color: 'positive', message: done, icon: 'check', position: 'top' })
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof ApiError ? e.message : '변경에 실패했습니다.'
|
||||||
|
$q.notify({ color: 'negative', message: msg, icon: 'error', position: 'top' })
|
||||||
|
} finally {
|
||||||
|
busyId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onStatus(m: Member, status: MemberStatus) {
|
||||||
|
if (status === m.status) return
|
||||||
|
run(m.id, () => adminMembersApi.updateStatus(m.id, status), '상태를 변경했습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTier(m: Member, tier: MemberTier) {
|
||||||
|
if (tier === m.subscriptionTier) return
|
||||||
|
run(m.id, () => adminMembersApi.updateTier(m.id, tier), '구독 티어를 변경했습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRole(m: Member, role: MemberRole) {
|
||||||
|
if (role === m.role) return
|
||||||
|
run(m.id, () => adminMembersApi.updateRole(m.id, role), '권한을 변경했습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete(m: Member) {
|
||||||
|
$q.dialog({
|
||||||
|
title: '회원 삭제',
|
||||||
|
message: `${m.nickname}(${m.email}) 회원을 삭제할까요? 되돌릴 수 없습니다.`,
|
||||||
|
cancel: true,
|
||||||
|
ok: { label: '삭제', color: 'negative' },
|
||||||
|
}).onOk(async () => {
|
||||||
|
busyId.value = m.id
|
||||||
|
try {
|
||||||
|
await adminMembersApi.remove(m.id)
|
||||||
|
members.value = members.value.filter((x) => x.id !== m.id)
|
||||||
|
$q.notify({ color: 'positive', message: '회원을 삭제했습니다.', icon: 'check', position: 'top' })
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof ApiError ? e.message : '삭제에 실패했습니다.'
|
||||||
|
$q.notify({ color: 'negative', message: msg, icon: 'error', position: 'top' })
|
||||||
|
} finally {
|
||||||
|
busyId.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import MainLayout from '@/layouts/MainLayout.vue'
|
import MainLayout from '@/layouts/MainLayout.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useAdminAuthStore } from '@/stores/adminAuth'
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -17,6 +18,25 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'profile', name: 'profile', component: () => import('@/pages/ProfilePage.vue') },
|
{ 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({
|
const router = createRouter({
|
||||||
@@ -26,6 +46,10 @@ const router = createRouter({
|
|||||||
|
|
||||||
// 소셜 로그인 전용 앱: 미로그인 시 보호 경로 접근 → 로그인으로
|
// 소셜 로그인 전용 앱: 미로그인 시 보호 경로 접근 → 로그인으로
|
||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
|
// 관리자 경로는 아래 관리자 가드가 처리 (사용자 가드는 관여하지 않음)
|
||||||
|
if (to.matched.some((r) => r.meta.requiresAdmin) || to.name === 'admin-login') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||||
return { name: 'login', query: { redirect: to.fullPath } }
|
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
|
export default router
|
||||||
|
|||||||
@@ -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<Member | null>(null)
|
||||||
|
const isAdmin = computed(() => member.value?.role === 'ADMIN')
|
||||||
|
|
||||||
|
function hasToken(): boolean {
|
||||||
|
return !!tokenStore.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구글 로그인 → 토큰 저장 + 회원 세팅. */
|
||||||
|
async function loginWithGoogle(idToken: string): Promise<Member> {
|
||||||
|
const res = await authApi.google(idToken)
|
||||||
|
tokenStore.set(res.token)
|
||||||
|
member.value = res.member
|
||||||
|
return res.member
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 개발자 로그인 (local 백엔드 전용). */
|
||||||
|
async function loginDev(email: string): Promise<Member> {
|
||||||
|
const res = await adminAuthApi.devLogin(email)
|
||||||
|
tokenStore.set(res.token)
|
||||||
|
member.value = res.member
|
||||||
|
return res.member
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 토큰으로 회원 복원(새로고침/가드). 실패 시 세션 정리. */
|
||||||
|
async function fetchMe(): Promise<Member | null> {
|
||||||
|
if (!tokenStore.get()) return null
|
||||||
|
try {
|
||||||
|
member.value = await authApi.me()
|
||||||
|
return member.value
|
||||||
|
} catch {
|
||||||
|
clear()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await authApi.logout()
|
||||||
|
} catch {
|
||||||
|
/* 서버 실패해도 로컬 세션은 정리 */
|
||||||
|
}
|
||||||
|
clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(): void {
|
||||||
|
tokenStore.clear()
|
||||||
|
member.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { member, isAdmin, hasToken, loginWithGoogle, loginDev, fetchMe, logout, clear }
|
||||||
|
})
|
||||||
Vendored
+32
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user