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:
+10
-20
@@ -1,33 +1,23 @@
|
|||||||
import { http } from './http'
|
import { http } from './http'
|
||||||
|
|
||||||
export interface Member {
|
/** 관리자 계정 (아이디/비밀번호 로그인). 회원(users)과 분리. */
|
||||||
|
export interface Admin {
|
||||||
id: number
|
id: number
|
||||||
email: string
|
username: string
|
||||||
nickname: string
|
name: string
|
||||||
role: 'USER' | 'ADMIN'
|
|
||||||
provider: 'GOOGLE' | 'APPLE'
|
|
||||||
subscriptionTier: 'FREE' | 'AD_FREE' | 'PREMIUM'
|
|
||||||
status: 'ACTIVE' | 'DORMANT' | 'BANNED'
|
|
||||||
profileImage: string | null
|
|
||||||
createdAt: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface AdminLoginResponse {
|
||||||
token: string
|
token: string
|
||||||
expiresInSeconds: number
|
expiresInSeconds: number
|
||||||
member: Member
|
admin: Admin
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authApi = {
|
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) =>
|
me: () => http.get<Admin>('/api/admin/auth/me'),
|
||||||
http.post<LoginResponse>('/api/auth/google', { idToken, rememberMe }),
|
|
||||||
|
|
||||||
me: () => http.get<Member>('/api/auth/me'),
|
logout: () => http.post<void>('/api/admin/auth/logout'),
|
||||||
|
|
||||||
logout: () => http.post<void>('/api/auth/logout'),
|
|
||||||
|
|
||||||
/** 개발자 로그인 — local 백엔드 전용(구글 없이 이메일로 세션 발급). */
|
|
||||||
devLogin: (email: string) => http.post<LoginResponse>('/api/auth/dev-login', { email }),
|
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -1,5 +1,17 @@
|
|||||||
import { http } from './http'
|
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 MemberStatus = 'ACTIVE' | 'DORMANT' | 'BANNED'
|
||||||
export type MemberTier = 'FREE' | 'AD_FREE' | 'PREMIUM'
|
export type MemberTier = 'FREE' | 'AD_FREE' | 'PREMIUM'
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
</q-toolbar-title>
|
</q-toolbar-title>
|
||||||
|
|
||||||
<q-chip dense color="white" text-color="primary" icon="admin_panel_settings">
|
<q-chip dense color="white" text-color="primary" icon="admin_panel_settings">
|
||||||
{{ auth.member?.nickname ?? '관리자' }}
|
{{ auth.admin?.name ?? '관리자' }}
|
||||||
</q-chip>
|
</q-chip>
|
||||||
<q-btn flat dense round icon="logout" aria-label="로그아웃" @click="onLogout" />
|
<q-btn flat dense round icon="logout" aria-label="로그아웃" @click="onLogout" />
|
||||||
</q-toolbar>
|
</q-toolbar>
|
||||||
|
|||||||
+37
-86
@@ -9,34 +9,43 @@
|
|||||||
<div class="text-caption text-grey-6">관리자 계정으로 로그인하세요</div>
|
<div class="text-caption text-grey-6">관리자 계정으로 로그인하세요</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-center">
|
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||||
<div ref="googleBtn" />
|
<q-input
|
||||||
</div>
|
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-banner v-if="error" dense class="bg-red-1 text-red-8 rounded-borders">
|
||||||
<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>
|
<template #avatar><q-icon name="error" /></template>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<div v-if="!clientId && ready" class="text-caption text-grey-6 text-center q-mt-md">
|
<q-btn
|
||||||
구글 로그인이 설정되지 않았습니다. (서버 GOOGLE_CLIENT_ID 확인)
|
type="submit"
|
||||||
</div>
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
class="full-width q-py-sm"
|
||||||
|
label="로그인"
|
||||||
|
:loading="loading"
|
||||||
|
/>
|
||||||
|
</q-form>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-page>
|
</q-page>
|
||||||
</q-page-container>
|
</q-page-container>
|
||||||
@@ -44,82 +53,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { authApi } from '@/api/auth'
|
|
||||||
import { ApiError } from '@/api/http'
|
import { ApiError } from '@/api/http'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const googleBtn = ref<HTMLElement | null>(null)
|
const username = ref('')
|
||||||
const clientId = ref('')
|
const password = ref('')
|
||||||
const ready = ref(false)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV
|
async function onSubmit() {
|
||||||
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 }>) {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
const member = await login()
|
await auth.login(username.value, password.value)
|
||||||
if (member.role !== 'ADMIN') {
|
|
||||||
await auth.logout()
|
|
||||||
error.value = '관리자 권한이 없는 계정입니다.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await router.replace({ name: 'dashboard' })
|
await router.replace({ name: 'dashboard' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof ApiError ? e.message : '로그인 중 오류가 발생했습니다.'
|
error.value = e instanceof ApiError ? e.message : '로그인 중 오류가 발생했습니다.'
|
||||||
|
|||||||
@@ -24,10 +24,7 @@
|
|||||||
<q-icon v-else name="person" />
|
<q-icon v-else name="person" />
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<div class="q-ml-sm">
|
<div class="q-ml-sm">
|
||||||
<div class="text-weight-medium">
|
<div class="text-weight-medium">{{ props.row.nickname }}</div>
|
||||||
{{ 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 class="text-caption text-grey-6">{{ props.row.email }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +42,7 @@
|
|||||||
<template #body-cell-status="props">
|
<template #body-cell-status="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<q-select dense options-dense outlined emit-value map-options :model-value="props.row.status" :options="statusOptions"
|
<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)" />
|
@update:model-value="(v: MemberStatus) => onStatus(props.row, v)" />
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,7 +58,7 @@
|
|||||||
<template #body-cell-role="props">
|
<template #body-cell-role="props">
|
||||||
<q-td :props="props">
|
<q-td :props="props">
|
||||||
<q-select dense options-dense outlined emit-value map-options :model-value="props.row.role" :options="roleOptions"
|
<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)" />
|
@update:model-value="(v: MemberRole) => onRole(props.row, v)" />
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
@@ -71,7 +68,7 @@
|
|||||||
<q-btn flat dense round icon="visibility" color="primary" @click="goDetail(props.row)">
|
<q-btn flat dense round icon="visibility" color="primary" @click="goDetail(props.row)">
|
||||||
<q-tooltip>상세</q-tooltip>
|
<q-tooltip>상세</q-tooltip>
|
||||||
</q-btn>
|
</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>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</q-table>
|
||||||
@@ -82,14 +79,11 @@
|
|||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useQuasar, type QTableColumn } from 'quasar'
|
import { useQuasar, type QTableColumn } from 'quasar'
|
||||||
import { membersApi, type MemberRole, type MemberStatus, type MemberTier } from '@/api/members'
|
import { membersApi, type Member, type MemberRole, type MemberStatus, type MemberTier } from '@/api/members'
|
||||||
import type { Member } from '@/api/auth'
|
|
||||||
import { ApiError } from '@/api/http'
|
import { ApiError } from '@/api/http'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
|
||||||
|
|
||||||
const members = ref<Member[]>([])
|
const members = ref<Member[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -124,9 +118,6 @@ const columns: QTableColumn<Member>[] = [
|
|||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
||||||
function isSelf(m: Member): boolean {
|
|
||||||
return m.id === auth.member?.id
|
|
||||||
}
|
|
||||||
function goDetail(m: Member) {
|
function goDetail(m: Member) {
|
||||||
router.push({ name: 'member-detail', params: { id: m.id } })
|
router.push({ name: 'member-detail', params: { id: m.id } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ import { onMounted, ref } from 'vue'
|
|||||||
import { useQuasar, type QTableColumn } from 'quasar'
|
import { useQuasar, type QTableColumn } from 'quasar'
|
||||||
import { subscriptionsApi, type Subscription, type SubscriptionFilter, type SubscriptionSummary } from '@/api/subscriptions'
|
import { subscriptionsApi, type Subscription, type SubscriptionFilter, type SubscriptionSummary } from '@/api/subscriptions'
|
||||||
import { membersApi } from '@/api/members'
|
import { membersApi } from '@/api/members'
|
||||||
import type { Member } from '@/api/auth'
|
import type { Member } from '@/api/members'
|
||||||
import { ApiError } from '@/api/http'
|
import { ApiError } from '@/api/http'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
|
|||||||
+4
-4
@@ -29,19 +29,19 @@ const router = createRouter({
|
|||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 인증 가드 — 토큰/회원 복원 후 ADMIN 여부 확인
|
// 인증 가드 — 토큰으로 관리자 복원 후 접근 제어
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const needsAdmin = to.matched.some((r) => r.meta.requiresAdmin)
|
const needsAdmin = to.matched.some((r) => r.meta.requiresAdmin)
|
||||||
|
|
||||||
if (auth.token && !auth.member) {
|
if (auth.token && !auth.admin) {
|
||||||
await auth.fetchMe()
|
await auth.fetchMe()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (needsAdmin && !auth.isAdmin) {
|
if (needsAdmin && !auth.isAuthenticated) {
|
||||||
return { name: 'login' }
|
return { name: 'login' }
|
||||||
}
|
}
|
||||||
if (to.name === 'login' && auth.isAdmin) {
|
if (to.name === 'login' && auth.isAuthenticated) {
|
||||||
return { name: 'dashboard' }
|
return { name: 'dashboard' }
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
+13
-20
@@ -1,38 +1,31 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
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'
|
import { getToken, setToken } from '@/api/http'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 관리자 인증 스토어. 토큰은 localStorage(http.ts)에 저장, 회원은 /api/auth/me 로 복원.
|
* 관리자 인증 스토어. 아이디/비밀번호 로그인, 토큰은 localStorage(http.ts)에 저장.
|
||||||
*/
|
*/
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string | null>(getToken())
|
const token = ref<string | null>(getToken())
|
||||||
const member = ref<Member | null>(null)
|
const admin = ref<Admin | null>(null)
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
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)
|
setToken(res.token)
|
||||||
token.value = res.token
|
token.value = res.token
|
||||||
member.value = res.member
|
admin.value = res.admin
|
||||||
return res.member
|
return res.admin
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loginWithGoogle(idToken: string): Promise<Member> {
|
/** 토큰으로 관리자 복원(새로고침/가드). 실패 시 세션 정리. */
|
||||||
return apply(await authApi.googleLogin(idToken))
|
async function fetchMe(): Promise<Admin | null> {
|
||||||
}
|
|
||||||
|
|
||||||
async function loginDev(email: string): Promise<Member> {
|
|
||||||
return apply(await authApi.devLogin(email))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchMe(): Promise<Member | null> {
|
|
||||||
if (!token.value) return null
|
if (!token.value) return null
|
||||||
try {
|
try {
|
||||||
member.value = await authApi.me()
|
admin.value = await authApi.me()
|
||||||
return member.value
|
return admin.value
|
||||||
} catch {
|
} catch {
|
||||||
clear()
|
clear()
|
||||||
return null
|
return null
|
||||||
@@ -51,8 +44,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
function clear(): void {
|
function clear(): void {
|
||||||
setToken(null)
|
setToken(null)
|
||||||
token.value = 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 }
|
||||||
})
|
})
|
||||||
|
|||||||
Vendored
-32
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user