From 317a2ce8afc4921ce1afe24b5116c61c29fb816a Mon Sep 17 00:00:00 2001 From: zenn Date: Fri, 24 Apr 2026 11:43:22 +0900 Subject: [PATCH] =?UTF-8?q?v0.1.47=20-=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=A0=95=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 5 +- TODO.md | 2 +- backend/src/db/init.js | 4 + backend/src/db/schema.js | 1 + backend/src/lib/authSession.js | 5 + backend/src/routes/admin.js | 161 +++++++++++++++++++++++++++++- backend/src/routes/auth.js | 7 ++ package-lock.json | 4 +- package.json | 2 +- src/App.vue | 79 ++++++++++++++- src/components/AdminDashboard.vue | 69 ++++++++++++- src/lib/adminApi.js | 25 ++++- 12 files changed, 350 insertions(+), 14 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 05e022a..b5968bd 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. diff --git a/TODO.md b/TODO.md index 8370697..d0c53b0 100644 --- a/TODO.md +++ b/TODO.md @@ -101,7 +101,7 @@ - [x] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다. - [x] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다. - [x] 메일 발송 인프라와 발신 도메인 정책을 Resend 기준으로 확정한다. -- [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다. +- [x] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다. - [ ] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다. - [ ] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다. diff --git a/backend/src/db/init.js b/backend/src/db/init.js index 7428c82..2e6ff87 100644 --- a/backend/src/db/init.js +++ b/backend/src/db/init.js @@ -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; diff --git a/backend/src/db/schema.js b/backend/src/db/schema.js index 3d20779..efda7da 100644 --- a/backend/src/db/schema.js +++ b/backend/src/db/schema.js @@ -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(), diff --git a/backend/src/lib/authSession.js b/backend/src/lib/authSession.js index a905a1e..7107c1b 100644 --- a/backend/src/lib/authSession.js +++ b/backend/src/lib/authSession.js @@ -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 diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index ba89bb1..7dc003b 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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: '사용자 계정과 관련 데이터를 삭제했습니다.', + } + }) } diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 4a25fb0..114bc7c 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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: '이메일 인증을 완료한 뒤 로그인해 주세요.', diff --git a/package-lock.json b/package-lock.json index 323daf0..91cb91a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index 7dce18a..46ac087 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.46", + "version": "0.1.47", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 34f6933..192d829 100644 --- a/src/App.vue +++ b/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" />
-
+

Total Users

{{ summary.totalUsers }}

@@ -69,6 +79,12 @@ function formatDate(value) {

{{ summary.verifiedUsers }} / {{ summary.totalAdmins }}

인증 완료 계정 수 / 관리자 수

+ +
+

Disabled Users

+

{{ summary.disabledUsers ?? 0 }}

+

관리자가 비활성화한 계정 수

+
@@ -128,7 +144,7 @@ function formatDate(value) {

-