Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 317a2ce8af |
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- 프로젝트명: 10 Minute Planner 웹 UI
|
- 프로젝트명: 10 Minute Planner 웹 UI
|
||||||
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
|
- 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript
|
||||||
- 현재 기준 버전: `v0.1.46` 준비 중
|
- 현재 기준 버전: `v0.1.47` 준비 중
|
||||||
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
|
- Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git`
|
||||||
|
|
||||||
## 기준 디자인
|
## 기준 디자인
|
||||||
@@ -233,6 +233,9 @@
|
|||||||
- `/api/auth/logout`이 추가되어 로그아웃 시 프론트 저장 토큰만 지우는 것이 아니라 서버 세션도 함께 폐기한다.
|
- `/api/auth/logout`이 추가되어 로그아웃 시 프론트 저장 토큰만 지우는 것이 아니라 서버 세션도 함께 폐기한다.
|
||||||
- SETTINGS 화면 왼쪽 카드에 현재 기기 로그인 유지 방식(`로그인 유지` 또는 브라우저 세션만 유지), 최근 로그인 시각, 이메일 인증 상태를 보여준다.
|
- SETTINGS 화면 왼쪽 카드에 현재 기기 로그인 유지 방식(`로그인 유지` 또는 브라우저 세션만 유지), 최근 로그인 시각, 이메일 인증 상태를 보여준다.
|
||||||
- 현재 로그인 유지 방식은 `authPersist` 상태로 함께 들고 가며, 프로필 저장 후에도 원래 저장 방식(localStorage/sessionStorage)을 유지하도록 정리했다.
|
- 현재 로그인 유지 방식은 `authPersist` 상태로 함께 들고 가며, 프로필 저장 후에도 원래 저장 방식(localStorage/sessionStorage)을 유지하도록 정리했다.
|
||||||
|
- 사용자 테이블에 `disabledAt` 컬럼이 추가되었다. 관리자가 비활성화한 계정은 즉시 현재 세션이 모두 종료되고, 이후 로그인도 차단된다.
|
||||||
|
- 관리자 화면에서 일반 사용자 계정에 대해 `비활성화/다시 허용`, `강제 로그아웃`, `삭제`를 실행할 수 있다. 관리자 계정은 이 화면에서 비활성화하거나 삭제하지 못하게 막는다.
|
||||||
|
- 관리자 사용자 목록에는 현재 활성 세션 수와 비활성화 상태가 함께 표시된다.
|
||||||
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
|
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
|
||||||
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
|
||||||
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.
|
||||||
|
|||||||
2
TODO.md
2
TODO.md
@@ -101,7 +101,7 @@
|
|||||||
- [x] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다.
|
- [x] 설정 화면에서 현재 기기 로그인 상태와 저장 방식을 안내한다.
|
||||||
- [x] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
|
- [x] 서버 세션을 명시적으로 폐기하는 로그아웃 API를 추가한다.
|
||||||
- [x] 메일 발송 인프라와 발신 도메인 정책을 Resend 기준으로 확정한다.
|
- [x] 메일 발송 인프라와 발신 도메인 정책을 Resend 기준으로 확정한다.
|
||||||
- [ ] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.
|
- [x] 관리자 페이지에서 계정 비활성화 / 강제 로그아웃 / 삭제 기능을 추가한다.
|
||||||
- [ ] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다.
|
- [ ] 관리자 페이지에서 사용자별 문서 상세 조회 기능을 추가한다.
|
||||||
- [ ] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다.
|
- [ ] 관리자 페이지에서 검색 / 정렬 / 필터 UX를 추가한다.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export async function ensureDatabaseSchema() {
|
|||||||
password_hash VARCHAR(255) NOT NULL,
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
nickname VARCHAR(60) NOT NULL,
|
nickname VARCHAR(60) NOT NULL,
|
||||||
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||||
|
disabled_at TIMESTAMPTZ,
|
||||||
email_verified_at TIMESTAMPTZ,
|
email_verified_at TIMESTAMPTZ,
|
||||||
last_login_at TIMESTAMPTZ,
|
last_login_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL,
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
@@ -21,6 +22,9 @@ export async function ensureDatabaseSchema() {
|
|||||||
ALTER TABLE users
|
ALTER TABLE users
|
||||||
ADD COLUMN IF NOT EXISTS role VARCHAR(20) NOT NULL DEFAULT 'user';
|
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
|
ALTER TABLE users
|
||||||
ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ;
|
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(),
|
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||||
nickname: varchar('nickname', { length: 60 }).notNull(),
|
nickname: varchar('nickname', { length: 60 }).notNull(),
|
||||||
role: varchar('role', { length: 20 }).notNull().default('user'),
|
role: varchar('role', { length: 20 }).notNull().default('user'),
|
||||||
|
disabledAt: timestamp('disabled_at', { withTimezone: true }),
|
||||||
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
||||||
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
|
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
||||||
|
|||||||
@@ -65,6 +65,11 @@ export async function findAuthenticatedUser(request) {
|
|||||||
.where(eq(users.id, session.userId))
|
.where(eq(users.id, session.userId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
|
if (user?.disabledAt) {
|
||||||
|
await db.delete(authSessions).where(eq(authSessions.id, session.id))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (user && user.role !== 'admin' && !user.emailVerifiedAt) {
|
if (user && user.role !== 'admin' && !user.emailVerifiedAt) {
|
||||||
await db.delete(authSessions).where(eq(authSessions.id, session.id))
|
await db.delete(authSessions).where(eq(authSessions.id, session.id))
|
||||||
return null
|
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 { db } from '../db/client.js'
|
||||||
import { findAuthenticatedUser } from '../lib/authSession.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) {
|
async function requireAdminUser(request, reply) {
|
||||||
const user = await findAuthenticatedUser(request)
|
const user = await findAuthenticatedUser(request)
|
||||||
@@ -35,6 +41,7 @@ export async function registerAdminRoutes(app) {
|
|||||||
.select({
|
.select({
|
||||||
totalUsers: sql`count(*)::int`,
|
totalUsers: sql`count(*)::int`,
|
||||||
totalAdmins: sql`count(*) filter (where ${users.role} = 'admin')::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`,
|
verifiedUsers: sql`count(*) filter (where ${users.emailVerifiedAt} is not null)::int`,
|
||||||
activeUsers30d: sql`count(*) filter (where ${users.lastLoginAt} >= now() - interval '30 days')::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`,
|
newUsers7d: sql`count(*) filter (where ${users.createdAt} >= now() - interval '7 days')::int`,
|
||||||
@@ -54,16 +61,19 @@ export async function registerAdminRoutes(app) {
|
|||||||
u.nickname,
|
u.nickname,
|
||||||
u.email,
|
u.email,
|
||||||
u.role,
|
u.role,
|
||||||
|
u.disabled_at as "disabledAt",
|
||||||
u.created_at as "createdAt",
|
u.created_at as "createdAt",
|
||||||
u.email_verified_at as "emailVerifiedAt",
|
u.email_verified_at as "emailVerifiedAt",
|
||||||
u.last_login_at as "lastLoginAt",
|
u.last_login_at as "lastLoginAt",
|
||||||
count(distinct pe.id)::int as "plannerEntryCount",
|
count(distinct pe.id)::int as "plannerEntryCount",
|
||||||
count(distinct g.id)::int as "goalCount",
|
count(distinct g.id)::int as "goalCount",
|
||||||
|
count(distinct s.id)::int as "activeSessionCount",
|
||||||
max(pe.entry_date) as "lastEntryDate",
|
max(pe.entry_date) as "lastEntryDate",
|
||||||
max(pe.updated_at) as "lastEntryUpdatedAt"
|
max(pe.updated_at) as "lastEntryUpdatedAt"
|
||||||
from users u
|
from users u
|
||||||
left join planner_entries pe on pe.user_id = u.id
|
left join planner_entries pe on pe.user_id = u.id
|
||||||
left join goals g on g.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
|
group by u.id
|
||||||
order by coalesce(u.last_login_at, u.created_at) desc, u.id desc
|
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,
|
verifiedUsers: summary?.verifiedUsers ?? 0,
|
||||||
activeUsers30d: summary?.activeUsers30d ?? 0,
|
activeUsers30d: summary?.activeUsers30d ?? 0,
|
||||||
newUsers7d: summary?.newUsers7d ?? 0,
|
newUsers7d: summary?.newUsers7d ?? 0,
|
||||||
|
disabledUsers: summary?.disabledUsers ?? 0,
|
||||||
totalPlannerEntries: plannerCountResult.rows[0]?.count ?? 0,
|
totalPlannerEntries: plannerCountResult.rows[0]?.count ?? 0,
|
||||||
totalGoals: goalCountResult.rows[0]?.count ?? 0,
|
totalGoals: goalCountResult.rows[0]?.count ?? 0,
|
||||||
},
|
},
|
||||||
@@ -100,4 +111,150 @@ export async function registerAdminRoutes(app) {
|
|||||||
recentLogins: recentLoginsResult.rows,
|
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,
|
loginId: user.loginId,
|
||||||
nickname: user.nickname,
|
nickname: user.nickname,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
disabledAt: user.disabledAt,
|
||||||
emailVerifiedAt: user.emailVerifiedAt,
|
emailVerifiedAt: user.emailVerifiedAt,
|
||||||
lastLoginAt: user.lastLoginAt,
|
lastLoginAt: user.lastLoginAt,
|
||||||
createdAt: user.createdAt,
|
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) {
|
if (user.role !== 'admin' && !user.emailVerifiedAt) {
|
||||||
return reply.code(403).send({
|
return reply.code(403).send({
|
||||||
message: '이메일 인증을 완료한 뒤 로그인해 주세요.',
|
message: '이메일 인증을 완료한 뒤 로그인해 주세요.',
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ten-minute-planner",
|
"name": "ten-minute-planner",
|
||||||
"version": "0.1.46",
|
"version": "0.1.47",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ten-minute-planner",
|
"name": "ten-minute-planner",
|
||||||
"version": "0.1.46",
|
"version": "0.1.47",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ten-minute-planner",
|
"name": "ten-minute-planner",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.46",
|
"version": "0.1.47",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"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 PlannerPage from './components/PlannerPage.vue'
|
||||||
import SettingsDashboard from './components/SettingsDashboard.vue'
|
import SettingsDashboard from './components/SettingsDashboard.vue'
|
||||||
import StatsDashboard from './components/StatsDashboard.vue'
|
import StatsDashboard from './components/StatsDashboard.vue'
|
||||||
import { fetchAdminOverview } from './lib/adminApi'
|
import {
|
||||||
|
deleteAdminUser,
|
||||||
|
fetchAdminOverview,
|
||||||
|
revokeAdminUserSessions,
|
||||||
|
updateAdminUserStatus,
|
||||||
|
} from './lib/adminApi'
|
||||||
import {
|
import {
|
||||||
clearAuthState,
|
clearAuthState,
|
||||||
confirmVerification,
|
confirmVerification,
|
||||||
@@ -107,10 +112,12 @@ const guideTooltipResetMessage = ref('')
|
|||||||
const hiddenGuideTooltips = ref(readHiddenGuideTooltips())
|
const hiddenGuideTooltips = ref(readHiddenGuideTooltips())
|
||||||
const ddayDisabledDateKeys = ref(readDdayDisabledDateKeys())
|
const ddayDisabledDateKeys = ref(readDdayDisabledDateKeys())
|
||||||
const adminBusy = ref(false)
|
const adminBusy = ref(false)
|
||||||
|
const adminActionUserId = ref(null)
|
||||||
const adminMessage = ref('')
|
const adminMessage = ref('')
|
||||||
const adminOverview = ref({
|
const adminOverview = ref({
|
||||||
totalUsers: 0,
|
totalUsers: 0,
|
||||||
totalAdmins: 0,
|
totalAdmins: 0,
|
||||||
|
disabledUsers: 0,
|
||||||
verifiedUsers: 0,
|
verifiedUsers: 0,
|
||||||
activeUsers30d: 0,
|
activeUsers30d: 0,
|
||||||
newUsers7d: 0,
|
newUsers7d: 0,
|
||||||
@@ -1678,6 +1685,7 @@ function clearAuthenticatedState() {
|
|||||||
adminOverview.value = {
|
adminOverview.value = {
|
||||||
totalUsers: 0,
|
totalUsers: 0,
|
||||||
totalAdmins: 0,
|
totalAdmins: 0,
|
||||||
|
disabledUsers: 0,
|
||||||
verifiedUsers: 0,
|
verifiedUsers: 0,
|
||||||
activeUsers30d: 0,
|
activeUsers30d: 0,
|
||||||
newUsers7d: 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() {
|
async function loadGoals() {
|
||||||
if (!authToken.value) {
|
if (!authToken.value) {
|
||||||
return
|
return
|
||||||
@@ -3118,7 +3191,11 @@ onBeforeUnmount(() => {
|
|||||||
:users="adminUsers"
|
:users="adminUsers"
|
||||||
:recent-logins="adminRecentLogins"
|
:recent-logins="adminRecentLogins"
|
||||||
:busy="adminBusy"
|
:busy="adminBusy"
|
||||||
|
:action-busy-user-id="adminActionUserId"
|
||||||
:message="adminMessage"
|
:message="adminMessage"
|
||||||
|
@toggle-user-status="toggleAdminUserStatus"
|
||||||
|
@revoke-user-sessions="revokeAdminSessions"
|
||||||
|
@delete-user="removeAdminUser"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatsDashboard
|
<StatsDashboard
|
||||||
|
|||||||
@@ -20,8 +20,18 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
|
actionBusyUserId: {
|
||||||
|
type: Number,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'toggle-user-status',
|
||||||
|
'revoke-user-sessions',
|
||||||
|
'delete-user',
|
||||||
|
])
|
||||||
|
|
||||||
function formatDate(value) {
|
function formatDate(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return '기록 없음'
|
return '기록 없음'
|
||||||
@@ -45,7 +55,7 @@ function formatDate(value) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="grid gap-6">
|
<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">
|
<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="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>
|
<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-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>
|
<p class="mt-2 text-sm font-semibold text-stone-500">인증 완료 계정 수 / 관리자 수</p>
|
||||||
</article>
|
</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>
|
||||||
|
|
||||||
<div class="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
|
<div class="grid gap-6 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
@@ -128,7 +144,7 @@ function formatDate(value) {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-5 overflow-hidden rounded-[24px] border border-stone-200 bg-white">
|
<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>ID</span>
|
||||||
<span>사용자</span>
|
<span>사용자</span>
|
||||||
<span>권한</span>
|
<span>권한</span>
|
||||||
@@ -136,6 +152,7 @@ function formatDate(value) {
|
|||||||
<span>문서 수</span>
|
<span>문서 수</span>
|
||||||
<span>목표 수</span>
|
<span>목표 수</span>
|
||||||
<span>상태</span>
|
<span>상태</span>
|
||||||
|
<span>관리</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divide-y divide-stone-200">
|
<div class="divide-y divide-stone-200">
|
||||||
@@ -144,7 +161,7 @@ function formatDate(value) {
|
|||||||
:key="user.id"
|
:key="user.id"
|
||||||
class="px-5 py-4"
|
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>
|
<p class="text-xs font-bold tracking-[0.14em] text-stone-500">#{{ user.id }}</p>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-stone-900">{{ user.nickname }}</p>
|
<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">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
|
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>
|
||||||
<span
|
<span
|
||||||
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
|
class="rounded-full px-3 py-1 text-[10px] font-bold tracking-[0.14em]"
|
||||||
@@ -167,6 +184,48 @@ function formatDate(value) {
|
|||||||
>
|
>
|
||||||
{{ user.emailVerifiedAt ? '이메일 인증' : '미인증' }}
|
{{ user.emailVerifiedAt ? '이메일 인증' : '미인증' }}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { buildApiUrl, toUserFacingApiError } from './apiBase'
|
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), {
|
const response = await fetch(buildApiUrl(path), {
|
||||||
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(hasBody ? { 'Content-Type': 'application/json' } : {}),
|
||||||
},
|
},
|
||||||
|
body: hasBody ? JSON.stringify(body) : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json().catch(() => ({}))
|
const data = await response.json().catch(() => ({}))
|
||||||
@@ -19,3 +23,22 @@ async function request(path, token) {
|
|||||||
export async function fetchAdminOverview(token) {
|
export async function fetchAdminOverview(token) {
|
||||||
return request('/api/admin/overview', 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