v0.1.47 - 관리자 계정 정리 기능 추가

This commit is contained in:
2026-04-24 11:43:22 +09:00
parent c442e0d8bb
commit 317a2ce8af
12 changed files with 350 additions and 14 deletions

View File

@@ -8,7 +8,12 @@ import MiniCalendar from './components/MiniCalendar.vue'
import PlannerPage from './components/PlannerPage.vue'
import SettingsDashboard from './components/SettingsDashboard.vue'
import StatsDashboard from './components/StatsDashboard.vue'
import { fetchAdminOverview } from './lib/adminApi'
import {
deleteAdminUser,
fetchAdminOverview,
revokeAdminUserSessions,
updateAdminUserStatus,
} from './lib/adminApi'
import {
clearAuthState,
confirmVerification,
@@ -107,10 +112,12 @@ const guideTooltipResetMessage = ref('')
const hiddenGuideTooltips = ref(readHiddenGuideTooltips())
const ddayDisabledDateKeys = ref(readDdayDisabledDateKeys())
const adminBusy = ref(false)
const adminActionUserId = ref(null)
const adminMessage = ref('')
const adminOverview = ref({
totalUsers: 0,
totalAdmins: 0,
disabledUsers: 0,
verifiedUsers: 0,
activeUsers30d: 0,
newUsers7d: 0,
@@ -1678,6 +1685,7 @@ function clearAuthenticatedState() {
adminOverview.value = {
totalUsers: 0,
totalAdmins: 0,
disabledUsers: 0,
verifiedUsers: 0,
activeUsers30d: 0,
newUsers7d: 0,
@@ -1734,6 +1742,71 @@ async function loadAdminDashboard() {
}
}
async function toggleAdminUserStatus(user) {
const willDisable = !user.disabledAt
const confirmed = window.confirm(
willDisable
? `"${user.nickname}" 계정을 비활성화할까요? 즉시 로그인할 수 없고 현재 세션도 종료됩니다.`
: `"${user.nickname}" 계정을 다시 사용할 수 있게 할까요?`,
)
if (!confirmed) {
return
}
adminActionUserId.value = user.id
try {
const result = await updateAdminUserStatus(authToken.value, user.id, willDisable)
adminMessage.value = result.message || '계정 상태를 변경했습니다.'
await loadAdminDashboard()
} catch (error) {
adminMessage.value = error.message || '계정 상태를 변경하지 못했습니다.'
} finally {
adminActionUserId.value = null
}
}
async function revokeAdminSessions(user) {
const confirmed = window.confirm(`"${user.nickname}" 사용자를 현재 로그인된 모든 기기에서 로그아웃시킬까요?`)
if (!confirmed) {
return
}
adminActionUserId.value = user.id
try {
const result = await revokeAdminUserSessions(authToken.value, user.id)
adminMessage.value = result.message || '사용자 세션을 정리했습니다.'
await loadAdminDashboard()
} catch (error) {
adminMessage.value = error.message || '사용자 세션을 종료하지 못했습니다.'
} finally {
adminActionUserId.value = null
}
}
async function removeAdminUser(user) {
const confirmed = window.confirm(`"${user.nickname}" 계정을 삭제할까요? 플래너 기록과 목표 데이터도 함께 삭제됩니다.`)
if (!confirmed) {
return
}
adminActionUserId.value = user.id
try {
const result = await deleteAdminUser(authToken.value, user.id)
adminMessage.value = result.message || '사용자 계정을 삭제했습니다.'
await loadAdminDashboard()
} catch (error) {
adminMessage.value = error.message || '사용자 계정을 삭제하지 못했습니다.'
} finally {
adminActionUserId.value = null
}
}
async function loadGoals() {
if (!authToken.value) {
return
@@ -3118,7 +3191,11 @@ onBeforeUnmount(() => {
:users="adminUsers"
:recent-logins="adminRecentLogins"
:busy="adminBusy"
:action-busy-user-id="adminActionUserId"
:message="adminMessage"
@toggle-user-status="toggleAdminUserStatus"
@revoke-user-sessions="revokeAdminSessions"
@delete-user="removeAdminUser"
/>
<StatsDashboard

View File

@@ -20,8 +20,18 @@ const props = defineProps({
type: String,
default: '',
},
actionBusyUserId: {
type: Number,
default: null,
},
})
const emit = defineEmits([
'toggle-user-status',
'revoke-user-sessions',
'delete-user',
])
function formatDate(value) {
if (!value) {
return '기록 없음'
@@ -45,7 +55,7 @@ function formatDate(value) {
<template>
<section class="grid gap-6">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Total Users</p>
<p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.totalUsers }}</p>
@@ -69,6 +79,12 @@ function formatDate(value) {
<p class="mt-4 text-3xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.verifiedUsers }} / {{ summary.totalAdmins }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">인증 완료 계정 / 관리자 </p>
</article>
<article class="rounded-[28px] border border-white/60 bg-white/80 p-6">
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">Disabled Users</p>
<p class="mt-4 text-4xl font-semibold tracking-[-0.06em] text-stone-900">{{ summary.disabledUsers ?? 0 }}</p>
<p class="mt-2 text-sm font-semibold text-stone-500">관리자가 비활성화한 계정 </p>
</article>
</div>
<div class="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
@@ -128,7 +144,7 @@ function formatDate(value) {
</p>
<div class="mt-5 overflow-hidden rounded-[24px] border border-stone-200 bg-white">
<div class="hidden grid-cols-[84px_minmax(0,1.4fr)_120px_150px_110px_100px_150px] gap-3 border-b border-stone-200 bg-[#f8f4ed] px-5 py-4 text-[10px] font-bold uppercase tracking-[0.18em] text-stone-500 xl:grid">
<div class="hidden grid-cols-[84px_minmax(0,1.2fr)_110px_150px_90px_90px_150px_190px] gap-3 border-b border-stone-200 bg-[#f8f4ed] px-5 py-4 text-[10px] font-bold uppercase tracking-[0.18em] text-stone-500 xl:grid">
<span>ID</span>
<span>사용자</span>
<span>권한</span>
@@ -136,6 +152,7 @@ function formatDate(value) {
<span>문서 </span>
<span>목표 </span>
<span>상태</span>
<span>관리</span>
</div>
<div class="divide-y divide-stone-200">
@@ -144,7 +161,7 @@ function formatDate(value) {
:key="user.id"
class="px-5 py-4"
>
<div class="grid gap-3 xl:grid-cols-[84px_minmax(0,1.4fr)_120px_150px_110px_100px_150px] xl:items-center">
<div class="grid gap-3 xl:grid-cols-[84px_minmax(0,1.2fr)_110px_150px_90px_90px_150px_190px] xl:items-center">
<p class="text-xs font-bold tracking-[0.14em] text-stone-500">#{{ user.id }}</p>
<div>
<p class="text-sm font-semibold text-stone-900">{{ user.nickname }}</p>
@@ -157,9 +174,9 @@ function formatDate(value) {
<div class="flex flex-wrap items-center gap-2">
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
:class="user.isActiveRecently ? 'bg-emerald-100 text-emerald-700' : 'bg-stone-100 text-stone-500'"
:class="user.disabledAt ? 'bg-rose-100 text-rose-700' : user.isActiveRecently ? 'bg-emerald-100 text-emerald-700' : 'bg-stone-100 text-stone-500'"
>
{{ user.isActiveRecently ? '활동 중' : '휴면 가능성' }}
{{ user.disabledAt ? '비활성화' : user.isActiveRecently ? '활동 중' : '휴면 가능성' }}
</span>
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
@@ -167,6 +184,48 @@ function formatDate(value) {
>
{{ user.emailVerifiedAt ? '이메일 인증' : '미인증' }}
</span>
<span
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
:class="user.activeSessionCount > 0 ? 'bg-violet-100 text-violet-700' : 'bg-stone-100 text-stone-500'"
>
세션 {{ user.activeSessionCount }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<button
v-if="user.role !== 'admin'"
type="button"
class="rounded-full border px-3 py-2 text-[10px] font-bold tracking-[0.14em] transition"
:class="user.disabledAt ? 'border-emerald-300 text-emerald-700 hover:bg-emerald-50' : 'border-amber-300 text-amber-700 hover:bg-amber-50'"
:disabled="actionBusyUserId === user.id"
@click="emit('toggle-user-status', user)"
>
{{ user.disabledAt ? '다시 허용' : '비활성화' }}
</button>
<button
v-if="user.role !== 'admin'"
type="button"
class="rounded-full border border-stone-300 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-stone-700 transition hover:bg-stone-100"
:disabled="actionBusyUserId === user.id"
@click="emit('revoke-user-sessions', user)"
>
강제 로그아웃
</button>
<button
v-if="user.role !== 'admin'"
type="button"
class="rounded-full border border-rose-300 px-3 py-2 text-[10px] font-bold tracking-[0.14em] text-rose-700 transition hover:bg-rose-50"
:disabled="actionBusyUserId === user.id"
@click="emit('delete-user', user)"
>
삭제
</button>
<span
v-if="actionBusyUserId === user.id"
class="text-[10px] font-bold tracking-[0.12em] text-stone-400"
>
처리 ...
</span>
</div>
</div>
</article>

View File

@@ -1,10 +1,14 @@
import { buildApiUrl, toUserFacingApiError } from './apiBase'
async function request(path, token) {
async function request(path, token, { method = 'GET', body } = {}) {
const hasBody = body !== undefined
const response = await fetch(buildApiUrl(path), {
method,
headers: {
Authorization: `Bearer ${token}`,
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
},
body: hasBody ? JSON.stringify(body) : undefined,
})
const data = await response.json().catch(() => ({}))
@@ -19,3 +23,22 @@ async function request(path, token) {
export async function fetchAdminOverview(token) {
return request('/api/admin/overview', token)
}
export async function updateAdminUserStatus(token, userId, disabled) {
return request(`/api/admin/users/${userId}/status`, token, {
method: 'PUT',
body: { disabled },
})
}
export async function revokeAdminUserSessions(token, userId) {
return request(`/api/admin/users/${userId}/revoke-sessions`, token, {
method: 'POST',
})
}
export async function deleteAdminUser(token, userId) {
return request(`/api/admin/users/${userId}`, token, {
method: 'DELETE',
})
}