feat(admin): 로그인 소셜 → 아이디/비밀번호

- LoginPage: 아이디/비밀번호 폼 (구글 GIS·개발자로그인 제거)
- auth api/store: /api/admin/auth login·me·logout, Admin 타입
- Member 타입을 api/members로 이전, 회원목록 self-lock(본인 잠금) 제거 — 관리자는 회원 아님
- google-gsi 타입 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-12 00:10:06 +09:00
parent bd5ccb6201
commit d2e3e288cc
9 changed files with 84 additions and 179 deletions
+10 -20
View File
@@ -1,33 +1,23 @@
import { http } from './http'
export interface Member {
/** 관리자 계정 (아이디/비밀번호 로그인). 회원(users)과 분리. */
export interface Admin {
id: number
email: string
nickname: string
role: 'USER' | 'ADMIN'
provider: 'GOOGLE' | 'APPLE'
subscriptionTier: 'FREE' | 'AD_FREE' | 'PREMIUM'
status: 'ACTIVE' | 'DORMANT' | 'BANNED'
profileImage: string | null
createdAt: string
username: string
name: string
}
export interface LoginResponse {
export interface AdminLoginResponse {
token: string
expiresInSeconds: number
member: Member
admin: Admin
}
export const authApi = {
googleClientId: () => http.get<{ clientId: string }>('/api/auth/google-client-id'),
login: (username: string, password: string) =>
http.post<AdminLoginResponse>('/api/admin/auth/login', { username, password }),
googleLogin: (idToken: string, rememberMe = true) =>
http.post<LoginResponse>('/api/auth/google', { idToken, rememberMe }),
me: () => http.get<Admin>('/api/admin/auth/me'),
me: () => http.get<Member>('/api/auth/me'),
logout: () => http.post<void>('/api/auth/logout'),
/** 개발자 로그인 — local 백엔드 전용(구글 없이 이메일로 세션 발급). */
devLogin: (email: string) => http.post<LoginResponse>('/api/auth/dev-login', { email }),
logout: () => http.post<void>('/api/admin/auth/logout'),
}
+13 -1
View File
@@ -1,5 +1,17 @@
import { http } from './http'
import type { Member } from './auth'
/** 앱 회원(users) — 소셜 로그인. 관리자(Admin)와 별개. */
export interface Member {
id: number
email: string
nickname: string
role: 'USER' | 'ADMIN'
provider: 'GOOGLE' | 'APPLE'
subscriptionTier: 'FREE' | 'AD_FREE' | 'PREMIUM'
status: 'ACTIVE' | 'DORMANT' | 'BANNED'
profileImage: string | null
createdAt: string
}
export type MemberStatus = 'ACTIVE' | 'DORMANT' | 'BANNED'
export type MemberTier = 'FREE' | 'AD_FREE' | 'PREMIUM'
+1 -1
View File
@@ -9,7 +9,7 @@
</q-toolbar-title>
<q-chip dense color="white" text-color="primary" icon="admin_panel_settings">
{{ auth.member?.nickname ?? '관리자' }}
{{ auth.admin?.name ?? '관리자' }}
</q-chip>
<q-btn flat dense round icon="logout" aria-label="로그아웃" @click="onLogout" />
</q-toolbar>
+37 -86
View File
@@ -9,34 +9,43 @@
<div class="text-caption text-grey-6">관리자 계정으로 로그인하세요</div>
</div>
<div class="flex flex-center">
<div ref="googleBtn" />
</div>
<q-form @submit="onSubmit" class="q-gutter-md">
<q-input
v-model="username"
outlined
dense
label="아이디"
autocomplete="username"
:rules="[(v) => !!v || '아이디를 입력하세요.']"
>
<template #prepend><q-icon name="person" /></template>
</q-input>
<q-input
v-model="password"
outlined
dense
type="password"
label="비밀번호"
autocomplete="current-password"
:rules="[(v) => !!v || '비밀번호를 입력하세요.']"
>
<template #prepend><q-icon name="lock" /></template>
</q-input>
<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">
<q-banner v-if="error" dense class="bg-red-1 text-red-8 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-btn
type="submit"
unelevated
color="primary"
class="full-width q-py-sm"
label="로그인"
:loading="loading"
/>
</q-form>
</q-card>
</q-page>
</q-page-container>
@@ -44,82 +53,24 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { authApi } from '@/api/auth'
import { ApiError } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const auth = useAuthStore()
const googleBtn = ref<HTMLElement | null>(null)
const clientId = ref('')
const ready = ref(false)
const username = ref('')
const password = ref('')
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(() => auth.loginWithGoogle(response.credential))
}
function onDevLogin() {
finishLogin(() => auth.loginDev('admin@dog.com'))
}
async function finishLogin(login: () => Promise<{ role: string }>) {
async function onSubmit() {
loading.value = true
error.value = ''
try {
const member = await login()
if (member.role !== 'ADMIN') {
await auth.logout()
error.value = '관리자 권한이 없는 계정입니다.'
return
}
await auth.login(username.value, password.value)
await router.replace({ name: 'dashboard' })
} catch (e) {
error.value = e instanceof ApiError ? e.message : '로그인 중 오류가 발생했습니다.'
+5 -14
View File
@@ -24,10 +24,7 @@
<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-weight-medium">{{ props.row.nickname }}</div>
<div class="text-caption text-grey-6">{{ props.row.email }}</div>
</div>
</div>
@@ -45,7 +42,7 @@
<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"
:disable="busyId === props.row.id" style="min-width: 110px"
@update:model-value="(v: MemberStatus) => onStatus(props.row, v)" />
</q-td>
</template>
@@ -61,7 +58,7 @@
<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"
:disable="busyId === props.row.id" style="min-width: 110px"
@update:model-value="(v: MemberRole) => onRole(props.row, v)" />
</q-td>
</template>
@@ -71,7 +68,7 @@
<q-btn flat dense round icon="visibility" color="primary" @click="goDetail(props.row)">
<q-tooltip>상세</q-tooltip>
</q-btn>
<q-btn flat dense round icon="delete" color="negative" :disable="isSelf(props.row) || busyId === props.row.id" @click="onDelete(props.row)" />
<q-btn flat dense round icon="delete" color="negative" :disable="busyId === props.row.id" @click="onDelete(props.row)" />
</q-td>
</template>
</q-table>
@@ -82,14 +79,11 @@
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar, type QTableColumn } from 'quasar'
import { membersApi, type MemberRole, type MemberStatus, type MemberTier } from '@/api/members'
import type { Member } from '@/api/auth'
import { membersApi, type Member, type MemberRole, type MemberStatus, type MemberTier } from '@/api/members'
import { ApiError } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
const $q = useQuasar()
const router = useRouter()
const auth = useAuthStore()
const members = ref<Member[]>([])
const loading = ref(false)
@@ -124,9 +118,6 @@ const columns: QTableColumn<Member>[] = [
onMounted(load)
function isSelf(m: Member): boolean {
return m.id === auth.member?.id
}
function goDetail(m: Member) {
router.push({ name: 'member-detail', params: { id: m.id } })
}
+1 -1
View File
@@ -111,7 +111,7 @@ import { onMounted, ref } from 'vue'
import { useQuasar, type QTableColumn } from 'quasar'
import { subscriptionsApi, type Subscription, type SubscriptionFilter, type SubscriptionSummary } from '@/api/subscriptions'
import { membersApi } from '@/api/members'
import type { Member } from '@/api/auth'
import type { Member } from '@/api/members'
import { ApiError } from '@/api/http'
const $q = useQuasar()
+4 -4
View File
@@ -29,19 +29,19 @@ const router = createRouter({
routes,
})
// 인증 가드 — 토큰/회원 복원 후 ADMIN 여부 확인
// 인증 가드 — 토큰으로 관리자 복원 후 접근 제어
router.beforeEach(async (to) => {
const auth = useAuthStore()
const needsAdmin = to.matched.some((r) => r.meta.requiresAdmin)
if (auth.token && !auth.member) {
if (auth.token && !auth.admin) {
await auth.fetchMe()
}
if (needsAdmin && !auth.isAdmin) {
if (needsAdmin && !auth.isAuthenticated) {
return { name: 'login' }
}
if (to.name === 'login' && auth.isAdmin) {
if (to.name === 'login' && auth.isAuthenticated) {
return { name: 'dashboard' }
}
return true
+13 -20
View File
@@ -1,38 +1,31 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { authApi, type Member } from '@/api/auth'
import { authApi, type Admin } from '@/api/auth'
import { getToken, setToken } from '@/api/http'
/**
* 관리자 인증 스토어. 토큰은 localStorage(http.ts)에 저장, 회원은 /api/auth/me 로 복원.
* 관리자 인증 스토어. 아이디/비밀번호 로그인, 토큰은 localStorage(http.ts)에 저장.
*/
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(getToken())
const member = ref<Member | null>(null)
const admin = ref<Admin | null>(null)
const isAuthenticated = computed(() => !!token.value)
const isAdmin = computed(() => member.value?.role === 'ADMIN')
function apply(res: { token: string; member: Member }): Member {
async function login(username: string, password: string): Promise<Admin> {
const res = await authApi.login(username, password)
setToken(res.token)
token.value = res.token
member.value = res.member
return res.member
admin.value = res.admin
return res.admin
}
async function loginWithGoogle(idToken: string): Promise<Member> {
return apply(await authApi.googleLogin(idToken))
}
async function loginDev(email: string): Promise<Member> {
return apply(await authApi.devLogin(email))
}
async function fetchMe(): Promise<Member | null> {
/** 토큰으로 관리자 복원(새로고침/가드). 실패 시 세션 정리. */
async function fetchMe(): Promise<Admin | null> {
if (!token.value) return null
try {
member.value = await authApi.me()
return member.value
admin.value = await authApi.me()
return admin.value
} catch {
clear()
return null
@@ -51,8 +44,8 @@ export const useAuthStore = defineStore('auth', () => {
function clear(): void {
setToken(null)
token.value = null
member.value = null
admin.value = null
}
return { token, member, isAuthenticated, isAdmin, loginWithGoogle, loginDev, fetchMe, logout, clear }
return { token, admin, isAuthenticated, login, fetchMe, logout, clear }
})
-32
View File
@@ -1,32 +0,0 @@
// 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
}
}
}