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:
ByungCheol
2026-07-11 08:59:20 +09:00
parent 0c538e0658
commit 85c925008a
10 changed files with 634 additions and 5 deletions
+143
View File
@@ -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>
+248
View File
@@ -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>