diff --git a/HANDOFF.md b/HANDOFF.md index 1e149c5..03531c9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.39` 준비 중 +- 현재 기준 버전: `v0.1.40` 준비 중 - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -82,6 +82,7 @@ - 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다. - 백엔드에는 `/api/auth/verification/request`, `/api/auth/verification/confirm` 이메일 인증 토큰 API가 추가되었다. - 백엔드에는 `/api/auth/password-reset/request`, `/api/auth/password-reset/confirm` 비밀번호 재설정 토큰 API가 추가되었다. +- 백엔드에는 `/api/admin/overview` 관리자 요약 API가 추가되었다. - 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다. - 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다. - 초기 실행 시 `backend/src/db/init.js`에서 테이블이 없으면 자동 생성하도록 맞춰두었다. @@ -199,6 +200,10 @@ - 미니 달력 날짜 버튼은 원형 비율이 흔들리지 않도록 고정 `width/height` 기준으로 다시 맞췄다. - 플래너 본문 D-DAY 텍스트는 3줄까지만 보이고, 넘치면 말줄임 처리되도록 정리했다. - 목표가 없는 빈 날짜에서는 `D-DAY 사용` 토글이 저장 상태와 무관하게 `OFF + 비활성`처럼 보이도록 보정했다. +- 관리자 전용 `ADMIN` 메뉴와 기본 대시보드가 추가되었다. 현재는 사용자 수, 최근 접속, 문서 수, 목표 수, 계정별 최종 접속일을 읽기 전용으로 확인할 수 있다. +- `users` 테이블에 `role`, `last_login_at` 컬럼이 추가되었다. +- 관리자 이메일은 현재 `ADMIN_EMAILS` 환경변수로 판별한다. 기본값은 `zenn.message@gmail.com`이며, 쉼표로 여러 이메일을 넣을 수 있다. +- 관리자 대시보드는 현재 읽기 전용이며, 계정 정지/삭제/강제 로그아웃 같은 실제 운영 액션은 아직 없다. - 비로그인 랜딩 카드는 상단 고정이 아니라 화면 중앙에 오도록 정렬을 수정했다. - 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. diff --git a/README.md b/README.md index 1c6525a..dd30926 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,119 @@ Vue 3 + TailwindCSS + Fastify + PostgreSQL 기반의 `10분 플래너 다이어리` 프로젝트다. +## 시작 전에 + +이 프로젝트는 `Docker`와 `Docker Compose`가 설치된 환경을 기준으로 실행한다. + +NAS나 서버에서 처음 올리는 경우 흐름은 아래처럼 생각하면 된다. + +1. 프로젝트를 받을 폴더로 이동한다. +2. `git clone`으로 저장소를 내려받는다. +3. 내려받은 프로젝트 폴더로 들어간다. +4. `docker compose`로 컨테이너를 빌드하고 실행한다. + +예시: + +```bash +cd /volume1/docker +git clone https://git.sori.studio/zenn/planner.sori.studio.git +cd planner.sori.studio +docker compose up -d --build +``` + +처음 한 번은 이미지 빌드 때문에 시간이 걸릴 수 있다. + +## 초보자용 빠른 실행 + +### 1. 프로젝트 받기 + +원하는 작업 폴더로 이동한 뒤 저장소를 내려받는다. + +```bash +cd /원하는/폴더 +git clone https://git.sori.studio/zenn/planner.sori.studio.git +cd planner.sori.studio +``` + +### 2. 배포용으로 바로 실행하기 + +실제 동작 확인이나 NAS 상시 실행은 아래 명령으로 시작한다. + +```bash +docker compose up -d --build +``` + +의미: + +- `up`: 컨테이너를 실행한다. +- `-d`: 백그라운드에서 실행한다. +- `--build`: 이미지가 없거나 코드가 바뀌었을 때 다시 빌드한다. + +브라우저 접속 주소: + +- 프론트엔드: `http://NAS주소:48081` +- PostgreSQL: `NAS주소:45432` + +현재 `docker-compose.yml` 기준 내부 구성: + +- 프론트엔드 nginx +- 백엔드 Fastify +- PostgreSQL + +### 3. 실행 상태 확인하기 + +```bash +docker compose ps +``` + +로그를 보고 싶으면: + +```bash +docker compose logs -f +``` + +특정 서비스만 보고 싶으면: + +```bash +docker compose logs -f frontend +docker compose logs -f backend +docker compose logs -f postgres +``` + +### 4. 종료하기 + +```bash +docker compose down +``` + +데이터베이스 볼륨까지 완전히 지우고 처음부터 다시 시작하고 싶을 때만 아래 명령을 사용한다. + +```bash +docker compose down -v +``` + +주의: + +- `-v`는 PostgreSQL 데이터까지 지울 수 있으니 정말 초기화가 필요할 때만 사용한다. + +### 5. 코드 수정 후 다시 반영하기 + +배포용 compose는 코드가 자동 반영되지 않는다. + +코드를 수정했다면 프로젝트 폴더 안에서 다시 실행한다. + +```bash +docker compose up -d --build +``` + +즉, NAS에서 배포용으로 돌릴 때는 보통 아래 순서를 반복한다. + +```bash +cd /volume1/docker/planner.sori.studio +git pull +docker compose up -d --build +``` + ## 실행 방법 ### 개발용 @@ -12,6 +125,15 @@ Vue 3 + TailwindCSS + Fastify + PostgreSQL 기반의 `10분 플래너 다이어 docker compose -f docker-compose.dev.yml up ``` +개발용도 처음 시작할 때는 아래처럼 프로젝트 폴더 안에서 실행하면 된다. + +```bash +cd /원하는/폴더 +git clone https://git.sori.studio/zenn/planner.sori.studio.git +cd planner.sori.studio +docker compose -f docker-compose.dev.yml up +``` + 개발용 포트: - 프론트엔드: `http://localhost:5173` @@ -34,8 +156,8 @@ docker compose up -d --build 배포용 포트: -- 프론트엔드: `http://localhost:8080` -- PostgreSQL: `localhost:5432` +- 프론트엔드: `http://localhost:48081` +- PostgreSQL: `localhost:45432` 배포용 특징: diff --git a/TODO.md b/TODO.md index 215818d..b6f70f2 100644 --- a/TODO.md +++ b/TODO.md @@ -94,6 +94,9 @@ - [ ] 로그인 및 인증 관련 rate limit / 잠금 정책을 추가한다. - [ ] 메일 발송 인프라와 발신 도메인 정책을 확정한다. - [ ] Resend 무료 플랜 대체 수단으로 SES 또는 일반 SMTP 연동 방식을 확정한다. +- [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다. +- [ ] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다. +- [ ] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다. ## 메모 @@ -125,3 +128,4 @@ - TASK LABELS는 버튼 묶음 대신 동일한 토글 패턴으로 단순화했다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. - Resend 무료 플랜은 도메인 수 제약이 있으므로, 실제 인증 메일은 AWS SES 또는 별도 SMTP 서비스 전환을 전제로 설계하는 편이 안전하다. +- 관리자 기능은 읽기 전용 대시보드부터 시작하고, 실제 정리 액션은 권한/감사 로그 정책을 정한 뒤 추가하는 편이 안전하다. diff --git a/backend/src/config.js b/backend/src/config.js index a8cccc3..791a850 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -14,6 +14,7 @@ const envSchema = z.object({ CORS_ORIGIN: z.string().default('http://localhost:5173'), SESSION_TTL_DAYS: z.coerce.number().default(30), APP_BASE_URL: z.string().default('http://localhost:5173'), + ADMIN_EMAILS: z.string().default('zenn.message@gmail.com'), }) export const env = envSchema.parse(process.env) diff --git a/backend/src/db/init.js b/backend/src/db/init.js index 75c79e2..79a1c2a 100644 --- a/backend/src/db/init.js +++ b/backend/src/db/init.js @@ -7,14 +7,22 @@ export async function ensureDatabaseSchema() { email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, nickname VARCHAR(60) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'user', email_verified_at TIMESTAMPTZ, + last_login_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL ); + ALTER TABLE users + ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'user'; + ALTER TABLE users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ; + ALTER TABLE users + ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; + CREATE TABLE IF NOT EXISTS auth_sessions ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/backend/src/db/schema.js b/backend/src/db/schema.js index 0ae7c61..14ba553 100644 --- a/backend/src/db/schema.js +++ b/backend/src/db/schema.js @@ -14,7 +14,9 @@ export const users = pgTable('users', { email: varchar('email', { length: 255 }).notNull().unique(), passwordHash: varchar('password_hash', { length: 255 }).notNull(), nickname: varchar('nickname', { length: 60 }).notNull(), + role: varchar('role', { length: 20 }).notNull().default('user'), emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }), + lastLoginAt: timestamp('last_login_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(), }) diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..aedfe89 --- /dev/null +++ b/backend/src/routes/admin.js @@ -0,0 +1,103 @@ +import { sql } from 'drizzle-orm' +import { db } from '../db/client.js' +import { findAuthenticatedUser } from '../lib/authSession.js' +import { users } from '../db/schema.js' + +async function requireAdminUser(request, reply) { + const user = await findAuthenticatedUser(request) + + if (!user) { + reply.code(401).send({ + message: '인증이 필요합니다.', + }) + return null + } + + if (user.role !== 'admin') { + reply.code(403).send({ + message: '관리자만 접근할 수 있습니다.', + }) + return null + } + + return user +} + +export async function registerAdminRoutes(app) { + app.get('/api/admin/overview', async (request, reply) => { + const adminUser = await requireAdminUser(request, reply) + + if (!adminUser) { + return + } + + const [summary] = await db + .select({ + totalUsers: sql`count(*)::int`, + totalAdmins: sql`count(*) filter (where ${users.role} = 'admin')::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`, + }) + .from(users) + + const plannerCountResult = await db.execute( + sql`select count(*)::int as count from planner_entries`, + ) + const goalCountResult = await db.execute( + sql`select count(*)::int as count from goals`, + ) + + const userRowsResult = await db.execute(sql` + select + u.id, + u.nickname, + u.email, + u.role, + 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", + 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 + group by u.id + order by coalesce(u.last_login_at, u.created_at) desc, u.id desc + `) + + const recentLoginsResult = await db.execute(sql` + select + id, + nickname, + email, + role, + last_login_at as "lastLoginAt" + from users + where last_login_at is not null + order by last_login_at desc + limit 5 + `) + + return { + summary: { + totalUsers: summary?.totalUsers ?? 0, + totalAdmins: summary?.totalAdmins ?? 0, + verifiedUsers: summary?.verifiedUsers ?? 0, + activeUsers30d: summary?.activeUsers30d ?? 0, + newUsers7d: summary?.newUsers7d ?? 0, + totalPlannerEntries: plannerCountResult.rows[0]?.count ?? 0, + totalGoals: goalCountResult.rows[0]?.count ?? 0, + }, + users: userRowsResult.rows.map((row) => ({ + ...row, + isActiveRecently: row.lastLoginAt + ? new Date(row.lastLoginAt).getTime() >= Date.now() - 30 * 24 * 60 * 60 * 1000 + : false, + })), + recentLogins: recentLoginsResult.rows, + } + }) +} diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 6b7e902..3db7c00 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -45,6 +45,15 @@ const passwordResetConfirmSchema = z.object({ }) const TOKEN_TTL_MS = 1000 * 60 * 30 +const adminEmails = new Set( + env.ADMIN_EMAILS.split(',') + .map((email) => email.trim().toLowerCase()) + .filter(Boolean), +) + +function resolveUserRole(email) { + return adminEmails.has(email.toLowerCase()) ? 'admin' : 'user' +} function buildPreviewUrl(pathname, token) { const url = new URL(pathname, env.APP_BASE_URL) @@ -99,7 +108,9 @@ function sanitizeUser(user) { id: user.id, email: user.email, nickname: user.nickname, + role: user.role, emailVerifiedAt: user.emailVerifiedAt, + lastLoginAt: user.lastLoginAt, createdAt: user.createdAt, updatedAt: user.updatedAt, } @@ -133,6 +144,7 @@ export async function registerAuthRoutes(app) { const now = new Date() const passwordHash = await hashPassword(password) + const role = resolveUserRole(normalizedEmail) const [user] = await db .insert(users) @@ -140,7 +152,9 @@ export async function registerAuthRoutes(app) { email: normalizedEmail, passwordHash, nickname, + role, emailVerifiedAt: null, + lastLoginAt: now, createdAt: now, updatedAt: now, }) @@ -189,12 +203,25 @@ export async function registerAuthRoutes(app) { }) } + const now = new Date() + const role = resolveUserRole(user.email) + + const [updatedUser] = await db + .update(users) + .set({ + role, + lastLoginAt: now, + updatedAt: now, + }) + .where(eq(users.id, user.id)) + .returning() + const { token } = await createSession(user.id) return { message: '로그인에 성공했습니다.', token, - user: sanitizeUser(user), + user: sanitizeUser(updatedUser), } }) @@ -207,6 +234,23 @@ export async function registerAuthRoutes(app) { }) } + const resolvedRole = resolveUserRole(user.email) + + if (user.role !== resolvedRole) { + const [updatedUser] = await db + .update(users) + .set({ + role: resolvedRole, + updatedAt: new Date(), + }) + .where(eq(users.id, user.id)) + .returning() + + return { + user: sanitizeUser(updatedUser), + } + } + return { user: sanitizeUser(user), } @@ -249,6 +293,7 @@ export async function registerAuthRoutes(app) { .set({ email: normalizedEmail, nickname: payload.data.nickname, + role: resolveUserRole(normalizedEmail), updatedAt: new Date(), }) .where(eq(users.id, user.id)) diff --git a/backend/src/server.js b/backend/src/server.js index e444b97..52df638 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -4,6 +4,7 @@ import { env } from './config.js' import { pool } from './db/client.js' import { ensureDatabaseSchema } from './db/init.js' import { registerAuthRoutes } from './routes/auth.js' +import { registerAdminRoutes } from './routes/admin.js' import { registerGoalRoutes } from './routes/goals.js' import { registerPlannerRoutes } from './routes/planner.js' @@ -19,6 +20,7 @@ await app.register(cors, { }) await registerAuthRoutes(app) +await registerAdminRoutes(app) await registerGoalRoutes(app) await registerPlannerRoutes(app) @@ -42,6 +44,7 @@ app.get('/api/meta', async () => ({ orm: 'drizzle', notes: [ '회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.', + '관리자용 사용자 현황 요약 API가 준비되어 있습니다.', '사용자별 목표 목록, 수정, 삭제 API가 준비되어 있습니다.', '사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.', ], diff --git a/package-lock.json b/package-lock.json index 14e8765..fa98004 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.39", + "version": "0.1.40", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.39", + "version": "0.1.40", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 33fae47..44c85b3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.39", + "version": "0.1.40", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 0a8b1a7..013f260 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,11 +1,13 @@ + + diff --git a/src/lib/adminApi.js b/src/lib/adminApi.js new file mode 100644 index 0000000..65d8715 --- /dev/null +++ b/src/lib/adminApi.js @@ -0,0 +1,21 @@ +import { buildApiUrl, toUserFacingApiError } from './apiBase' + +async function request(path, token) { + const response = await fetch(buildApiUrl(path), { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(toUserFacingApiError(data, '관리자 데이터를 불러오지 못했습니다.')) + } + + return data +} + +export async function fetchAdminOverview(token) { + return request('/api/admin/overview', token) +}