v0.1.51 - 관리자 사용자 상세 조회 추가
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
|
||||
- 프로젝트명: 10 Minute Planner 웹 UI
|
||||
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
|
||||
- 현재 기준 버전: `v0.1.50` 준비 중
|
||||
- 현재 기준 버전: `v0.1.51` 준비 중
|
||||
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
|
||||
|
||||
## 기준 디자인
|
||||
@@ -201,12 +201,13 @@
|
||||
- 미니 달력 날짜 버튼은 원형 비율이 흔들리지 않도록 고정 `width/height` 기준으로 다시 맞췄다.
|
||||
- 플래너 본문 D-DAY 텍스트는 3줄까지만 보이고, 넘치면 말줄임 처리되도록 정리했다.
|
||||
- 목표가 없는 빈 날짜에서는 `D-DAY 사용` 토글이 저장 상태와 무관하게 `OFF + 비활성`처럼 보이도록 보정했다.
|
||||
- 관리자 전용 `ADMIN` 메뉴와 기본 대시보드가 추가되었다. 현재는 사용자 수, 최근 접속, 문서 수, 목표 수, 계정별 최종 접속일을 읽기 전용으로 확인할 수 있다.
|
||||
- 관리자 전용 `ADMIN` 메뉴와 기본 대시보드가 추가되었다. 현재는 사용자 수, 최근 접속, 문서 수, 목표 수, 계정별 최종 접속일을 확인할 수 있다.
|
||||
- `users` 테이블에 `login_id`, `role`, `last_login_at` 컬럼이 추가되었다.
|
||||
- 관리자 계정은 이제 이메일이 아니라 별도 자동 생성 계정으로 관리한다.
|
||||
- 관리자 계정은 서버 시작 시 `ADMIN_ACCOUNT_ID`, `ADMIN_ACCOUNT_PASSWORD`, `ADMIN_ACCOUNT_EMAIL`, `ADMIN_ACCOUNT_NICKNAME` 환경변수 조합으로 자동 생성된다.
|
||||
- 관리자 아이디와 비밀번호는 저장소 문서에 실제 값을 남기지 않고, Docker 배포 시 루트 `.env` 같은 비공개 환경변수 파일에서만 관리한다.
|
||||
- 관리자 대시보드는 현재 읽기 전용이며, 계정 정지/삭제/강제 로그아웃 같은 실제 운영 액션은 아직 없다.
|
||||
- 관리자 대시보드에서는 계정 비활성화, 강제 로그아웃, 삭제를 실행할 수 있다.
|
||||
- 관리자 대시보드에서 특정 사용자의 최근 플래너 기록 12건과 목표 20건을 상세 조회할 수 있다.
|
||||
- 배포용 `docker-compose.yml`은 현재 PostgreSQL 외부 포트 `45432`, 프론트 외부 포트 `48081` 기준이며, DB 계정/비밀번호는 루트 `.env`에서 주입한다.
|
||||
- 설정 화면의 보조 메모 카드는 주석 처리되어 현재는 보이지 않는다.
|
||||
- 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다.
|
||||
|
||||
2
TODO.md
2
TODO.md
@@ -102,5 +102,5 @@
|
||||
- [x] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
|
||||
- [x] 메일 발송 인프라와 발신 도메인 정책을 Resend 기준으로 확정한다.
|
||||
- [x] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.
|
||||
- [ ] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다.
|
||||
- [x] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다.
|
||||
- [ ] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다.
|
||||
|
||||
@@ -112,6 +112,89 @@ export async function registerAdminRoutes(app) {
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/admin/users/:userId/detail', 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 detailResult = await db.execute(sql`
|
||||
select
|
||||
u.id,
|
||||
u.nickname,
|
||||
u.email,
|
||||
u.role,
|
||||
u.disabled_at as "disabledAt",
|
||||
u.created_at as "createdAt",
|
||||
u.updated_at as "updatedAt",
|
||||
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"
|
||||
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()
|
||||
where u.id = ${userId}
|
||||
group by u.id
|
||||
limit 1
|
||||
`)
|
||||
|
||||
const detailUser = detailResult.rows[0]
|
||||
|
||||
if (!detailUser) {
|
||||
return reply.code(404).send({
|
||||
message: '대상 사용자를 찾을 수 없습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const plannerEntriesResult = await db.execute(sql`
|
||||
select
|
||||
entry_date as "entryDate",
|
||||
payload,
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from planner_entries
|
||||
where user_id = ${userId}
|
||||
order by entry_date desc
|
||||
limit 12
|
||||
`)
|
||||
|
||||
const goalsResult = await db.execute(sql`
|
||||
select
|
||||
id,
|
||||
title,
|
||||
target_date as "targetDate",
|
||||
active_from as "activeFrom",
|
||||
active_until as "activeUntil",
|
||||
color,
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
from goals
|
||||
where user_id = ${userId}
|
||||
order by updated_at desc, id desc
|
||||
limit 20
|
||||
`)
|
||||
|
||||
return {
|
||||
user: detailUser,
|
||||
plannerEntries: plannerEntriesResult.rows,
|
||||
goals: goalsResult.rows,
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/admin/users/:userId/status', async (request, reply) => {
|
||||
const adminUser = await requireAdminUser(request, reply)
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.51",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ten-minute-planner",
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.51",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ten-minute-planner",
|
||||
"private": true,
|
||||
"version": "0.1.50",
|
||||
"version": "0.1.51",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
47
src/App.vue
47
src/App.vue
@@ -1719,6 +1719,8 @@ function clearAuthenticatedState() {
|
||||
adminMessage.value = ''
|
||||
adminUsers.value = []
|
||||
adminRecentLogins.value = []
|
||||
adminSelectedUserId.value = null
|
||||
adminUserDetail.value = null
|
||||
accountDeleteMessage.value = ''
|
||||
adminOverview.value = {
|
||||
totalUsers: 0,
|
||||
@@ -1761,6 +1763,8 @@ async function loadAdminDashboard() {
|
||||
if (!authToken.value || !isAdmin.value) {
|
||||
adminUsers.value = []
|
||||
adminRecentLogins.value = []
|
||||
adminSelectedUserId.value = null
|
||||
adminUserDetail.value = null
|
||||
adminMessage.value = ''
|
||||
return
|
||||
}
|
||||
@@ -1780,6 +1784,35 @@ async function loadAdminDashboard() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdminUserDetail(userId) {
|
||||
if (!authToken.value || !isAdmin.value) {
|
||||
return
|
||||
}
|
||||
|
||||
adminDetailBusy.value = true
|
||||
|
||||
try {
|
||||
const result = await fetchAdminUserDetail(authToken.value, userId)
|
||||
adminSelectedUserId.value = userId
|
||||
adminUserDetail.value = result
|
||||
adminMessage.value = ''
|
||||
} catch (error) {
|
||||
adminMessage.value = error.message || '사용자 상세 정보를 불러오지 못했습니다.'
|
||||
} finally {
|
||||
adminDetailBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectAdminUser(user) {
|
||||
if (adminSelectedUserId.value === user.id && adminUserDetail.value) {
|
||||
adminSelectedUserId.value = null
|
||||
adminUserDetail.value = null
|
||||
return
|
||||
}
|
||||
|
||||
void loadAdminUserDetail(user.id)
|
||||
}
|
||||
|
||||
async function toggleAdminUserStatus(user) {
|
||||
const willDisable = !user.disabledAt
|
||||
const confirmed = window.confirm(
|
||||
@@ -1798,6 +1831,9 @@ async function toggleAdminUserStatus(user) {
|
||||
const result = await updateAdminUserStatus(authToken.value, user.id, willDisable)
|
||||
adminMessage.value = result.message || '계정 상태를 변경했습니다.'
|
||||
await loadAdminDashboard()
|
||||
if (adminSelectedUserId.value === user.id) {
|
||||
await loadAdminUserDetail(user.id)
|
||||
}
|
||||
} catch (error) {
|
||||
adminMessage.value = error.message || '계정 상태를 변경하지 못했습니다.'
|
||||
} finally {
|
||||
@@ -1818,6 +1854,9 @@ async function revokeAdminSessions(user) {
|
||||
const result = await revokeAdminUserSessions(authToken.value, user.id)
|
||||
adminMessage.value = result.message || '사용자 세션을 정리했습니다.'
|
||||
await loadAdminDashboard()
|
||||
if (adminSelectedUserId.value === user.id) {
|
||||
await loadAdminUserDetail(user.id)
|
||||
}
|
||||
} catch (error) {
|
||||
adminMessage.value = error.message || '사용자 세션을 종료하지 못했습니다.'
|
||||
} finally {
|
||||
@@ -1838,6 +1877,10 @@ async function removeAdminUser(user) {
|
||||
const result = await deleteAdminUser(authToken.value, user.id)
|
||||
adminMessage.value = result.message || '사용자 계정을 삭제했습니다.'
|
||||
await loadAdminDashboard()
|
||||
if (adminSelectedUserId.value === user.id) {
|
||||
adminSelectedUserId.value = null
|
||||
adminUserDetail.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
adminMessage.value = error.message || '사용자 계정을 삭제하지 못했습니다.'
|
||||
} finally {
|
||||
@@ -3227,10 +3270,14 @@ onBeforeUnmount(() => {
|
||||
class="scrollbar-hide print-hidden xl:h-full xl:overflow-y-auto"
|
||||
:summary="adminOverview"
|
||||
:users="adminUsers"
|
||||
:selected-user-id="adminSelectedUserId"
|
||||
:user-detail="adminUserDetail"
|
||||
:recent-logins="adminRecentLogins"
|
||||
:busy="adminBusy"
|
||||
:action-busy-user-id="adminActionUserId"
|
||||
:detail-busy="adminDetailBusy"
|
||||
:message="adminMessage"
|
||||
@select-user="selectAdminUser"
|
||||
@toggle-user-status="toggleAdminUserStatus"
|
||||
@revoke-user-sessions="revokeAdminSessions"
|
||||
@delete-user="removeAdminUser"
|
||||
|
||||
@@ -8,6 +8,14 @@ const props = defineProps({
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
selectedUserId: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
userDetail: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
recentLogins: {
|
||||
type: Array,
|
||||
required: true,
|
||||
@@ -24,9 +32,14 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
detailBusy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'select-user',
|
||||
'toggle-user-status',
|
||||
'revoke-user-sessions',
|
||||
'delete-user',
|
||||
@@ -51,6 +64,28 @@ function formatDate(value) {
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function getPlannerSummary(payload) {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return {
|
||||
comment: '코멘트 없음',
|
||||
taskCount: 0,
|
||||
completedCount: 0,
|
||||
memoCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const tasks = Array.isArray(payload.tasks) ? payload.tasks : []
|
||||
const memo = Array.isArray(payload.memo) ? payload.memo : []
|
||||
const activeTasks = tasks.filter((task) => task?.title?.trim?.())
|
||||
|
||||
return {
|
||||
comment: payload.comment?.trim?.() || '코멘트 없음',
|
||||
taskCount: activeTasks.length,
|
||||
completedCount: activeTasks.filter((task) => task.checked).length,
|
||||
memoCount: memo.filter((item) => item?.text?.trim?.() || item?.label?.trim?.()).length,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -166,6 +201,13 @@ function formatDate(value) {
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-stone-900">{{ user.nickname }}</p>
|
||||
<p class="mt-1 text-xs font-semibold text-stone-500">{{ user.email }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-2 text-[11px] font-bold tracking-[0.12em] text-stone-500 underline underline-offset-4 transition hover:text-stone-900"
|
||||
@click="emit('select-user', user)"
|
||||
>
|
||||
{{ selectedUserId === user.id ? '상세 닫기' : '상세 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm font-semibold text-stone-700">{{ user.role }}</p>
|
||||
<p class="text-sm font-semibold text-stone-700">{{ formatDate(user.lastLoginAt) }}</p>
|
||||
@@ -238,6 +280,122 @@ function formatDate(value) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="border-t border-stone-200 bg-[#fcfaf6] px-5 py-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.24em] text-stone-500">사용자 상세</p>
|
||||
<p class="mt-2 text-sm font-semibold text-stone-500">선택한 사용자의 최근 플래너 기록과 목표를 확인합니다.</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="detailBusy"
|
||||
class="rounded-full bg-stone-900 px-4 py-2 text-[11px] font-bold tracking-[0.16em] text-white"
|
||||
>
|
||||
불러오는 중...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="userDetail?.user"
|
||||
class="mt-5 grid gap-5 xl:grid-cols-[minmax(0,1.15fr)_minmax(320px,0.85fr)]"
|
||||
>
|
||||
<div class="rounded-[24px] border border-stone-200 bg-white p-5">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-lg font-semibold text-stone-900">{{ userDetail.user.nickname }}</p>
|
||||
<p class="mt-1 text-sm font-semibold text-stone-500">{{ userDetail.user.email }}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<span class="rounded-full bg-stone-100 px-3 py-1 text-[10px] font-bold tracking-[0.14em] text-stone-600">{{ userDetail.user.role }}</span>
|
||||
<span
|
||||
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
|
||||
:class="userDetail.user.disabledAt ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'"
|
||||
>
|
||||
{{ userDetail.user.disabledAt ? '비활성화' : '사용 가능' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
|
||||
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">최근 로그인</p>
|
||||
<p class="mt-2 text-sm font-semibold text-stone-800">{{ formatDate(userDetail.user.lastLoginAt) }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
|
||||
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">문서 / 목표</p>
|
||||
<p class="mt-2 text-sm font-semibold text-stone-800">{{ userDetail.user.plannerEntryCount }} / {{ userDetail.user.goalCount }}</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-stone-200 bg-[#fbf7f0] px-4 py-4">
|
||||
<p class="text-[10px] font-bold tracking-[0.16em] text-stone-500">활성 세션</p>
|
||||
<p class="mt-2 text-sm font-semibold text-stone-800">{{ userDetail.user.activeSessionCount }}개</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.2em] text-stone-500">최근 플래너 기록</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<article
|
||||
v-for="entry in userDetail.plannerEntries"
|
||||
:key="entry.entryDate"
|
||||
class="rounded-2xl border border-stone-200 bg-[#fffdfa] px-4 py-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-stone-900">{{ entry.entryDate }}</p>
|
||||
<p class="text-[11px] font-semibold text-stone-500">{{ formatDate(entry.updatedAt) }}</p>
|
||||
</div>
|
||||
<p class="mt-3 text-sm font-semibold leading-6 text-stone-700">{{ getPlannerSummary(entry.payload).comment }}</p>
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-[11px] font-bold tracking-[0.12em] text-stone-500">
|
||||
<span>작업 {{ getPlannerSummary(entry.payload).taskCount }}개</span>
|
||||
<span>완료 {{ getPlannerSummary(entry.payload).completedCount }}개</span>
|
||||
<span>메모 {{ getPlannerSummary(entry.payload).memoCount }}개</span>
|
||||
</div>
|
||||
</article>
|
||||
<p
|
||||
v-if="userDetail.plannerEntries.length === 0"
|
||||
class="rounded-2xl border border-dashed border-stone-300 bg-white px-4 py-4 text-sm font-semibold text-stone-500"
|
||||
>
|
||||
아직 작성한 플래너 기록이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-[24px] border border-stone-200 bg-white p-5">
|
||||
<p class="text-[11px] font-bold uppercase tracking-[0.2em] text-stone-500">목표 목록</p>
|
||||
<div class="mt-3 space-y-3">
|
||||
<article
|
||||
v-for="goal in userDetail.goals"
|
||||
:key="goal.id"
|
||||
class="rounded-2xl border border-stone-200 bg-[#fffdfa] px-4 py-4"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-stone-900">{{ goal.title }}</p>
|
||||
<span class="rounded-full border border-stone-200 px-3 py-1 text-[10px] font-bold tracking-[0.12em] text-stone-500">
|
||||
{{ goal.targetDate }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2 text-[11px] font-bold tracking-[0.12em] text-stone-500">
|
||||
<span>표시 시작 {{ goal.activeFrom || '미설정' }}</span>
|
||||
<span>표시 종료 {{ goal.activeUntil || '미설정' }}</span>
|
||||
</div>
|
||||
</article>
|
||||
<p
|
||||
v-if="userDetail.goals.length === 0"
|
||||
class="rounded-2xl border border-dashed border-stone-300 bg-white px-4 py-4 text-sm font-semibold text-stone-500"
|
||||
>
|
||||
등록된 목표가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="mt-5 rounded-[24px] border border-dashed border-stone-300 bg-white px-5 py-10 text-center text-sm font-semibold text-stone-500"
|
||||
>
|
||||
사용자 목록에서 `상세 보기`를 눌러 기록과 목표를 확인해 주세요.
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -24,6 +24,10 @@ export async function fetchAdminOverview(token) {
|
||||
return request('/api/admin/overview', token)
|
||||
}
|
||||
|
||||
export async function fetchAdminUserDetail(token, userId) {
|
||||
return request(`/api/admin/users/${userId}/detail`, token)
|
||||
}
|
||||
|
||||
export async function updateAdminUserStatus(token, userId, disabled) {
|
||||
return request(`/api/admin/users/${userId}/status`, token, {
|
||||
method: 'PUT',
|
||||
|
||||
Reference in New Issue
Block a user