v0.1.47 - 관리자 계정 정리 기능 추가
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
- 프로젝트명: 10 Minute Planner 웹 UI
|
||||
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
|
||||
- 현재 기준 버전: `v0.1.46` 준비 중
|
||||
- 현재 기준 버전: `v0.1.47` 준비 중
|
||||
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
|
||||
|
||||
## 기준 디자인
|
||||
@@ -233,6 +233,9 @@
|
||||
- `/api/auth/logout`이 추가되어 로그아웃 시 프론트 저장 토큰만 지우는 것이 아니라 서버 세션도 함께 폐기한다.
|
||||
- SETTINGS 화면 왼쪽 카드에 현재 기기 로그인 유지 방식(`로그인 유지` 또는 브라우저 세션만 유지), 최근 로그인 시각, 이메일 인증 상태를 보여준다.
|
||||
- 현재 로그인 유지 방식은 `authPersist` 상태로 함께 들고 가며, 프로필 저장 후에도 원래 저장 방식(localStorage/sessionStorage)을 유지하도록 정리했다.
|
||||
- 사용자 테이블에 `disabledAt` 컬럼이 추가되었다. 관리자가 비활성화한 계정은 즉시 현재 세션이 모두 종료되고, 이후 로그인도 차단된다.
|
||||
- 관리자 화면에서 일반 사용자 계정에 대해 `비활성화/다시 허용`, `강제 로그아웃`, `삭제`를 실행할 수 있다. 관리자 계정은 이 화면에서 비활성화하거나 삭제하지 못하게 막는다.
|
||||
- 관리자 사용자 목록에는 현재 활성 세션 수와 비활성화 상태가 함께 표시된다.
|
||||
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
|
||||
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
||||
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
||||
|
||||
2
TODO.md
2
TODO.md
@@ -101,7 +101,7 @@
|
||||
- [x] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다.
|
||||
- [x] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
|
||||
- [x] 메일 발송 인프라와 발신 도메인 정책을 Resend 기준으로 확정한다.
|
||||
- [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.
|
||||
- [x] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.
|
||||
- [ ] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다.
|
||||
- [ ] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다.
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export async function ensureDatabaseSchema() {
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
nickname VARCHAR(60) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
disabled_at TIMESTAMPTZ,
|
||||
email_verified_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
@@ -21,6 +22,9 @@ export async function ensureDatabaseSchema() {
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'user';
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS disabled_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export const users = pgTable('users', {
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
nickname: varchar('nickname', { length: 60 }).notNull(),
|
||||
role: varchar('role', { length: 20 }).notNull().default('user'),
|
||||
disabledAt: timestamp('disabled_at', { withTimezone: true }),
|
||||
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
||||
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
||||
|
||||
@@ -65,6 +65,11 @@ export async function findAuthenticatedUser(request) {
|
||||
.where(eq(users.id, session.userId))
|
||||
.limit(1)
|
||||
|
||||
if (user?.disabledAt) {
|
||||
await db.delete(authSessions).where(eq(authSessions.id, session.id))
|
||||
return null
|
||||
}
|
||||
|
||||
if (user && user.role !== 'admin' && !user.emailVerifiedAt) {
|
||||
await db.delete(authSessions).where(eq(authSessions.id, session.id))
|
||||
return null
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { db } from '../db/client.js'
|
||||
import { findAuthenticatedUser } from '../lib/authSession.js'
|
||||
import { users } from '../db/schema.js'
|
||||
import { authSessions, users } from '../db/schema.js'
|
||||
|
||||
const adminUserIdSchema = z.coerce.number().int().positive()
|
||||
const adminStatusSchema = z.object({
|
||||
disabled: z.boolean(),
|
||||
})
|
||||
|
||||
async function requireAdminUser(request, reply) {
|
||||
const user = await findAuthenticatedUser(request)
|
||||
@@ -35,6 +41,7 @@ export async function registerAdminRoutes(app) {
|
||||
.select({
|
||||
totalUsers: sql`count(*)::int`,
|
||||
totalAdmins: sql`count(*) filter (where ${users.role} = 'admin')::int`,
|
||||
disabledUsers: sql`count(*) filter (where ${users.disabledAt} is not null)::int`,
|
||||
verifiedUsers: sql`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`,
|
||||
activeUsers30d: sql`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::int`,
|
||||
newUsers7d: sql`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`,
|
||||
@@ -54,16 +61,19 @@ export async function registerAdminRoutes(app) {
|
||||
u.nickname,
|
||||
u.email,
|
||||
u.role,
|
||||
u.disabled_at as "disabledAt",
|
||||
u.created_at as "createdAt",
|
||||
u.email_verified_at as "emailVerifiedAt",
|
||||
u.last_login_at as "lastLoginAt",
|
||||
count(distinct pe.id)::int as "plannerEntryCount",
|
||||
count(distinct g.id)::int as "goalCount",
|
||||
count(distinct s.id)::int as "activeSessionCount",
|
||||
max(pe.entry_date) as "lastEntryDate",
|
||||
max(pe.updated_at) as "lastEntryUpdatedAt"
|
||||
from users u
|
||||
left join planner_entries pe on pe.user_id = u.id
|
||||
left join goals g on g.user_id = u.id
|
||||
left join auth_sessions s on s.user_id = u.id and s.expires_at > now()
|
||||
group by u.id
|
||||
order by coalesce(u.last_login_at, u.created_at) desc, u.id desc
|
||||
`)
|
||||
@@ -88,6 +98,7 @@ export async function registerAdminRoutes(app) {
|
||||
verifiedUsers: summary?.verifiedUsers ?? 0,
|
||||
activeUsers30d: summary?.activeUsers30d ?? 0,
|
||||
newUsers7d: summary?.newUsers7d ?? 0,
|
||||
disabledUsers: summary?.disabledUsers ?? 0,
|
||||
totalPlannerEntries: plannerCountResult.rows[0]?.count ?? 0,
|
||||
totalGoals: goalCountResult.rows[0]?.count ?? 0,
|
||||
},
|
||||
@@ -100,4 +111,150 @@ export async function registerAdminRoutes(app) {
|
||||
recentLogins: recentLoginsResult.rows,
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/admin/users/:userId/status', async (request, reply) => {
|
||||
const adminUser = await requireAdminUser(request, reply)
|
||||
|
||||
if (!adminUser) {
|
||||
return
|
||||
}
|
||||
|
||||
const userIdResult = adminUserIdSchema.safeParse(request.params.userId)
|
||||
const payload = adminStatusSchema.safeParse(request.body)
|
||||
|
||||
if (!userIdResult.success || !payload.success) {
|
||||
return reply.code(400).send({
|
||||
message: '관리자 요청 값이 올바르지 않습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const userId = userIdResult.data
|
||||
|
||||
const [targetUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!targetUser) {
|
||||
return reply.code(404).send({
|
||||
message: '대상 사용자를 찾을 수 없습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
if (targetUser.role === 'admin') {
|
||||
return reply.code(403).send({
|
||||
message: '관리자 계정 상태는 여기서 변경할 수 없습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const disabled = payload.data.disabled
|
||||
const now = new Date()
|
||||
|
||||
const [updatedUser] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
disabledAt: disabled ? now : null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
.returning()
|
||||
|
||||
if (disabled) {
|
||||
await db.delete(authSessions).where(eq(authSessions.userId, userId))
|
||||
}
|
||||
|
||||
return {
|
||||
message: disabled ? '계정을 비활성화했습니다.' : '계정을 다시 사용할 수 있게 했습니다.',
|
||||
user: updatedUser,
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/admin/users/:userId/revoke-sessions', async (request, reply) => {
|
||||
const adminUser = await requireAdminUser(request, reply)
|
||||
|
||||
if (!adminUser) {
|
||||
return
|
||||
}
|
||||
|
||||
const userIdResult = adminUserIdSchema.safeParse(request.params.userId)
|
||||
|
||||
if (!userIdResult.success) {
|
||||
return reply.code(400).send({
|
||||
message: '대상 사용자 값이 올바르지 않습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const userId = userIdResult.data
|
||||
|
||||
const [targetUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!targetUser) {
|
||||
return reply.code(404).send({
|
||||
message: '대상 사용자를 찾을 수 없습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
if (targetUser.role === 'admin') {
|
||||
return reply.code(403).send({
|
||||
message: '관리자 계정 세션은 여기서 정리하지 않습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const deletedSessions = await db
|
||||
.delete(authSessions)
|
||||
.where(eq(authSessions.userId, userId))
|
||||
.returning({ id: authSessions.id })
|
||||
|
||||
return {
|
||||
message: deletedSessions.length > 0 ? '해당 사용자의 로그인 세션을 모두 종료했습니다.' : '종료할 로그인 세션이 없습니다.',
|
||||
revokedCount: deletedSessions.length,
|
||||
}
|
||||
})
|
||||
|
||||
app.delete('/api/admin/users/:userId', async (request, reply) => {
|
||||
const adminUser = await requireAdminUser(request, reply)
|
||||
|
||||
if (!adminUser) {
|
||||
return
|
||||
}
|
||||
|
||||
const userIdResult = adminUserIdSchema.safeParse(request.params.userId)
|
||||
|
||||
if (!userIdResult.success) {
|
||||
return reply.code(400).send({
|
||||
message: '대상 사용자 값이 올바르지 않습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const userId = userIdResult.data
|
||||
|
||||
const [targetUser] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
|
||||
if (!targetUser) {
|
||||
return reply.code(404).send({
|
||||
message: '대상 사용자를 찾을 수 없습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
if (targetUser.role === 'admin') {
|
||||
return reply.code(403).send({
|
||||
message: '관리자 계정은 여기서 삭제할 수 없습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
await db.delete(users).where(and(eq(users.id, userId), eq(users.role, 'user')))
|
||||
|
||||
return {
|
||||
message: '사용자 계정과 관련 데이터를 삭제했습니다.',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ function sanitizeUser(user) {
|
||||
loginId: user.loginId,
|
||||
nickname: user.nickname,
|
||||
role: user.role,
|
||||
disabledAt: user.disabledAt,
|
||||
emailVerifiedAt: user.emailVerifiedAt,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
@@ -239,6 +240,12 @@ export async function registerAuthRoutes(app) {
|
||||
})
|
||||
}
|
||||
|
||||
if (user.disabledAt) {
|
||||
return reply.code(403).send({
|
||||
message: '비활성화된 계정입니다. 관리자에게 문의해 주세요.',
|
||||
})
|
||||
}
|
||||
|
||||
if (user.role !== 'admin' && !user.emailVerifiedAt) {
|
||||
return reply.code(403).send({
|
||||
message: '이메일 인증을 완료한 뒤 로그인해 주세요.',
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.1.46",
|
||||
"version": "0.1.47",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.1.46",
|
||||
"version": "0.1.47",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"private": true,
|
||||
"version": "0.1.46",
|
||||
"version": "0.1.47",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
79
src/App.vue
79
src/App.vue
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user