diff --git a/HANDOFF.md b/HANDOFF.md index 56db07d..ca001d6 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.41` 준비 중 +- 현재 기준 버전: `v0.1.42` 준비 중 - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -201,8 +201,10 @@ - 플래너 본문 D-DAY 텍스트는 3줄까지만 보이고, 넘치면 말줄임 처리되도록 정리했다. - 목표가 없는 빈 날짜에서는 `D-DAY 사용` 토글이 저장 상태와 무관하게 `OFF + 비활성`처럼 보이도록 보정했다. - 관리자 전용 `ADMIN` 메뉴와 기본 대시보드가 추가되었다. 현재는 사용자 수, 최근 접속, 문서 수, 목표 수, 계정별 최종 접속일을 읽기 전용으로 확인할 수 있다. -- `users` 테이블에 `role`, `last_login_at` 컬럼이 추가되었다. -- 관리자 이메일은 현재 `ADMIN_EMAILS` 환경변수로 판별한다. 기본값은 `zenn.message@gmail.com`이며, 쉼표로 여러 이메일을 넣을 수 있다. +- `users` 테이블에 `login_id`, `role`, `last_login_at` 컬럼이 추가되었다. +- 관리자 계정은 이제 이메일이 아니라 별도 자동 생성 계정으로 관리한다. +- 기본 관리자 계정은 `planner-admin / wps!vmffosj180204` 이고, 서버 시작 시 자동 생성된다. +- 관리자 판별용 환경변수는 `ADMIN_EMAILS`가 아니라 `ADMIN_ACCOUNT_ID`, `ADMIN_ACCOUNT_PASSWORD`, `ADMIN_ACCOUNT_EMAIL`, `ADMIN_ACCOUNT_NICKNAME` 조합으로 바뀌었다. - 관리자 대시보드는 현재 읽기 전용이며, 계정 정지/삭제/강제 로그아웃 같은 실제 운영 액션은 아직 없다. - 배포용 `docker-compose.yml`은 현재 PostgreSQL 외부 포트 `45432`, 프론트 외부 포트 `48081`, DB 계정 `zenn` 기준으로 맞춰져 있다. - 설정 화면의 보조 메모 카드는 주석 처리되어 현재는 보이지 않는다. diff --git a/README.md b/README.md index dd30926..bbc1660 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,15 @@ docker compose up -d --build - 프론트엔드: `http://NAS주소:48081` - PostgreSQL: `NAS주소:45432` +기본 관리자 계정: + +- 아이디: `planner-admin` +- 비밀번호: `wps!vmffosj180204` + +관리자 계정은 백엔드 시작 시 자동 생성된다. +일반 사용자는 기존처럼 회원가입 후 이메일로 로그인하고, +관리자는 `planner-admin` 아이디로 로그인하면 된다. + 현재 `docker-compose.yml` 기준 내부 구성: - 프론트엔드 nginx diff --git a/backend/src/config.js b/backend/src/config.js index 791a850..51e71a2 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -14,7 +14,10 @@ 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'), + ADMIN_ACCOUNT_ID: z.string().default('planner-admin'), + ADMIN_ACCOUNT_PASSWORD: z.string().default('wps!vmffosj180204'), + ADMIN_ACCOUNT_EMAIL: z.string().default('planner-admin@planner.local'), + ADMIN_ACCOUNT_NICKNAME: z.string().default('Planner Admin'), }) export const env = envSchema.parse(process.env) diff --git a/backend/src/db/init.js b/backend/src/db/init.js index 79a1c2a..7428c82 100644 --- a/backend/src/db/init.js +++ b/backend/src/db/init.js @@ -5,6 +5,7 @@ export async function ensureDatabaseSchema() { CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, + login_id VARCHAR(60) UNIQUE, password_hash VARCHAR(255) NOT NULL, nickname VARCHAR(60) NOT NULL, role VARCHAR(20) NOT NULL DEFAULT 'user', @@ -14,6 +15,9 @@ export async function ensureDatabaseSchema() { updated_at TIMESTAMPTZ NOT NULL ); + ALTER TABLE users + ADD COLUMN IF NOT EXISTS login_id VARCHAR(60); + ALTER TABLE users ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'user'; diff --git a/backend/src/db/schema.js b/backend/src/db/schema.js index 14ba553..3d20779 100644 --- a/backend/src/db/schema.js +++ b/backend/src/db/schema.js @@ -12,6 +12,7 @@ import { export const users = pgTable('users', { id: serial('id').primaryKey(), email: varchar('email', { length: 255 }).notNull().unique(), + loginId: varchar('login_id', { length: 60 }).unique(), passwordHash: varchar('password_hash', { length: 255 }).notNull(), nickname: varchar('nickname', { length: 60 }).notNull(), role: varchar('role', { length: 20 }).notNull().default('user'), diff --git a/backend/src/lib/adminAccount.js b/backend/src/lib/adminAccount.js new file mode 100644 index 0000000..6992b1b --- /dev/null +++ b/backend/src/lib/adminAccount.js @@ -0,0 +1,69 @@ +import { and, eq, isNull, ne, or } from 'drizzle-orm' +import { env } from '../config.js' +import { db } from '../db/client.js' +import { users } from '../db/schema.js' +import { hashPassword } from './password.js' + +export async function ensureAdminAccount() { + const loginId = env.ADMIN_ACCOUNT_ID.trim() + const email = env.ADMIN_ACCOUNT_EMAIL.trim().toLowerCase() + const nickname = env.ADMIN_ACCOUNT_NICKNAME.trim() + const password = env.ADMIN_ACCOUNT_PASSWORD + + const now = new Date() + + await db + .update(users) + .set({ + role: 'user', + updatedAt: now, + }) + .where( + and( + eq(users.role, 'admin'), + or( + ne(users.loginId, loginId), + isNull(users.loginId), + ), + ), + ) + + const [existingAdmin] = await db + .select() + .from(users) + .where( + or( + eq(users.loginId, loginId), + eq(users.email, email), + ), + ) + .limit(1) + + if (existingAdmin) { + await db + .update(users) + .set({ + email, + nickname, + role: 'admin', + updatedAt: now, + }) + .where(eq(users.id, existingAdmin.id)) + + return + } + + const passwordHash = await hashPassword(password) + + await db.insert(users).values({ + email, + loginId, + passwordHash, + nickname, + role: 'admin', + emailVerifiedAt: now, + lastLoginAt: null, + createdAt: now, + updatedAt: now, + }) +} diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 3db7c00..6f1835f 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,10 +1,10 @@ -import { and, eq, gt, isNull } from 'drizzle-orm' -import { env } from '../config.js' +import { and, eq, gt, isNull, or } from 'drizzle-orm' import { z } from 'zod' import { db } from '../db/client.js' import { authSessions, emailVerificationTokens, passwordResetTokens, users } from '../db/schema.js' import { createSessionToken, hashSessionToken, hashPassword, verifyPassword } from '../lib/password.js' import { createSession, findAuthenticatedUser } from '../lib/authSession.js' +import { env } from '../config.js' const signupSchema = z.object({ email: z.string().trim().email(), @@ -13,7 +13,7 @@ const signupSchema = z.object({ }) const loginSchema = z.object({ - email: z.string().trim().email(), + email: z.string().trim().min(1).max(255), password: z.string().min(1).max(72), }) @@ -45,15 +45,6 @@ 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) @@ -107,6 +98,7 @@ function sanitizeUser(user) { return { id: user.id, email: user.email, + loginId: user.loginId, nickname: user.nickname, role: user.role, emailVerifiedAt: user.emailVerifiedAt, @@ -144,15 +136,14 @@ export async function registerAuthRoutes(app) { const now = new Date() const passwordHash = await hashPassword(password) - const role = resolveUserRole(normalizedEmail) - const [user] = await db .insert(users) .values({ email: normalizedEmail, + loginId: null, passwordHash, nickname, - role, + role: 'user', emailVerifiedAt: null, lastLoginAt: now, createdAt: now, @@ -181,17 +172,23 @@ export async function registerAuthRoutes(app) { }) } - const normalizedEmail = payload.data.email.toLowerCase() + const identifier = payload.data.email.trim() + const normalizedEmail = identifier.toLowerCase() const [user] = await db .select() .from(users) - .where(eq(users.email, normalizedEmail)) + .where( + or( + eq(users.email, normalizedEmail), + eq(users.loginId, identifier), + ), + ) .limit(1) if (!user) { return reply.code(401).send({ - message: '이메일 또는 비밀번호가 올바르지 않습니다.', + message: '이메일, 아이디 또는 비밀번호가 올바르지 않습니다.', }) } @@ -199,17 +196,15 @@ export async function registerAuthRoutes(app) { if (!passwordMatches) { return reply.code(401).send({ - message: '이메일 또는 비밀번호가 올바르지 않습니다.', + message: '이메일, 아이디 또는 비밀번호가 올바르지 않습니다.', }) } const now = new Date() - const role = resolveUserRole(user.email) const [updatedUser] = await db .update(users) .set({ - role, lastLoginAt: now, updatedAt: now, }) @@ -234,23 +229,6 @@ 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), } @@ -293,7 +271,6 @@ 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 52df638..ca6d4d0 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -3,6 +3,7 @@ import cors from '@fastify/cors' import { env } from './config.js' import { pool } from './db/client.js' import { ensureDatabaseSchema } from './db/init.js' +import { ensureAdminAccount } from './lib/adminAccount.js' import { registerAuthRoutes } from './routes/auth.js' import { registerAdminRoutes } from './routes/admin.js' import { registerGoalRoutes } from './routes/goals.js' @@ -13,6 +14,7 @@ const app = Fastify({ }) await ensureDatabaseSchema() +await ensureAdminAccount() await app.register(cors, { origin: env.CORS_ORIGIN, diff --git a/package-lock.json b/package-lock.json index b6baefd..0d8ff49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.41", + "version": "0.1.42", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.41", + "version": "0.1.42", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 34405ec..5ef15d1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.41", + "version": "0.1.42", "type": "module", "scripts": { "dev": "vite", diff --git a/src/components/AuthDialog.vue b/src/components/AuthDialog.vue index f11382d..d9b79ef 100644 --- a/src/components/AuthDialog.vue +++ b/src/components/AuthDialog.vue @@ -75,12 +75,14 @@ function updateField(field, event) {