관리자와 회원 설정 계정 작업 정리
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import { getUserById, touchUserActivity } from '../../repositories/member-repository'
|
||||
import { getUserById } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { getRequestIP } from 'h3'
|
||||
|
||||
/**
|
||||
* 회원 세션 조회 API
|
||||
@@ -9,10 +8,6 @@ import { getRequestIP } from 'h3'
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
await touchUserActivity({
|
||||
userId: session.userId,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
const user = await getUserById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
@@ -31,4 +26,3 @@ export default defineEventHandler(async (event) => {
|
||||
avatarUrl: user.avatarUrl || ''
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { getUserById } from '../../repositories/member-repository'
|
||||
import { getUserProfileById } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { createError } from 'h3'
|
||||
|
||||
/**
|
||||
* 회원 프로필 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string }>} 회원 프로필
|
||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number }>} 회원 프로필
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const user = await getUserById(session.userId)
|
||||
const user = await getUserProfileById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
@@ -22,7 +22,12 @@ export default defineEventHandler(async (event) => {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl || ''
|
||||
avatarUrl: user.avatarUrl || '',
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt.toISOString(),
|
||||
lastSeenAt: user.lastSeenAt ? user.lastSeenAt.toISOString() : null,
|
||||
previousLastSeenAt: user.previousLastSeenAt ? user.previousLastSeenAt.toISOString() : null,
|
||||
previousLastSeenIp: user.previousLastSeenIp || '',
|
||||
commentCount: Number(user.commentCount || 0)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ const getMemberRoleLabel = (roleCode) => roleCode === MEMBER_ROLE.OWNER
|
||||
*/
|
||||
const mapAdminMemberRow = (row) => {
|
||||
const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null
|
||||
const previousLastSeenAt = row.previousLastSeenAt ? row.previousLastSeenAt.toISOString() : null
|
||||
const isActive = row.lastSeenAt
|
||||
? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30
|
||||
: false
|
||||
@@ -45,6 +46,8 @@ const mapAdminMemberRow = (row) => {
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
lastSeenAt,
|
||||
lastSeenIp: row.lastSeenIp || '',
|
||||
previousLastSeenAt,
|
||||
previousLastSeenIp: row.previousLastSeenIp || '',
|
||||
commentCount: Number(row.commentCount || 0),
|
||||
activityStatus: isActive ? '활성' : '비활성',
|
||||
role: getMemberRoleLabel(roleCode)
|
||||
@@ -64,6 +67,8 @@ const mapAdminMemberRow = (row) => {
|
||||
* @property {string} updatedAt - 수정 시각(ISO)
|
||||
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
|
||||
* @property {string} lastSeenIp - 최근 접속 IP
|
||||
* @property {string | null} previousLastSeenAt - 이전 로그인 시각(ISO)
|
||||
* @property {string} previousLastSeenIp - 이전 로그인 IP
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -100,7 +105,9 @@ export const getUserByEmail = async (email) => {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
FROM users
|
||||
WHERE lower(email) = lower(${email})
|
||||
LIMIT 1
|
||||
@@ -127,7 +134,9 @@ export const getUserById = async (id) => {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
FROM users
|
||||
WHERE id = ${id}
|
||||
LIMIT 1
|
||||
@@ -136,6 +145,38 @@ export const getUserById = async (id) => {
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 설정 화면용 회원 프로필을 조회한다.
|
||||
* @param {string} id - 사용자 ID
|
||||
* @returns {Promise<(Omit<MemberUser, 'passwordHash'> & { commentCount: number }) | null>} 회원 프로필
|
||||
*/
|
||||
export const getUserProfileById = async (id) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
users.id,
|
||||
users.username,
|
||||
users.email,
|
||||
users.avatar_url AS "avatarUrl",
|
||||
users.is_admin AS "isAdmin",
|
||||
users.user_role AS "role",
|
||||
users.created_at AS "createdAt",
|
||||
users.updated_at AS "updatedAt",
|
||||
users.last_seen_at AS "lastSeenAt",
|
||||
users.last_seen_ip AS "lastSeenIp",
|
||||
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||
FROM users
|
||||
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||
WHERE users.id = ${id}
|
||||
GROUP BY users.id
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 회원 조회(비밀번호 포함)
|
||||
* @param {string} id - 사용자 ID
|
||||
@@ -155,7 +196,9 @@ export const getUserByIdWithPassword = async (id) => {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
FROM users
|
||||
WHERE id = ${id}
|
||||
LIMIT 1
|
||||
@@ -198,7 +241,9 @@ export const createUser = async (input) => {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
`
|
||||
})
|
||||
|
||||
@@ -223,6 +268,8 @@ export const touchUserActivity = async (input) => {
|
||||
await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
previous_last_seen_at = last_seen_at,
|
||||
previous_last_seen_ip = last_seen_ip,
|
||||
last_seen_at = now(),
|
||||
last_seen_ip = ${input.ip},
|
||||
updated_at = now()
|
||||
@@ -254,7 +301,9 @@ export const updateMemberProfile = async (input) => {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
@@ -308,6 +357,38 @@ export const deleteMember = async (userId) => {
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 화면에서 회원을 삭제한다.
|
||||
* @param {{ actorUserId: string, targetUserId: string }} input - 삭제 정보
|
||||
* @returns {Promise<Object>} 삭제된 회원
|
||||
*/
|
||||
export const deleteMemberByAdmin = async (input) => {
|
||||
const target = await getMemberForAdmin(input.targetUserId)
|
||||
if (!target) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.id === input.actorUserId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '현재 로그인한 계정은 여기서 삭제할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.roleCode === MEMBER_ROLE.OWNER && (await countOwnerMembers()) <= 1) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
await deleteMember(input.targetUserId)
|
||||
return target
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자 권한 회원 수를 조회한다.
|
||||
* @returns {Promise<number>} 소유자 회원 수
|
||||
@@ -375,7 +456,7 @@ export const isEmailTaken = async (input) => {
|
||||
|
||||
/**
|
||||
* 관리자용 회원 목록 조회(댓글 활동 포함)
|
||||
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
|
||||
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, previousLastSeenAt: string | null, previousLastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
|
||||
*/
|
||||
export const listMembersForAdmin = async () => {
|
||||
const sql = requireSql()
|
||||
@@ -393,6 +474,8 @@ export const listMembersForAdmin = async () => {
|
||||
users.updated_at AS "updatedAt",
|
||||
users.last_seen_at AS "lastSeenAt",
|
||||
users.last_seen_ip AS "lastSeenIp",
|
||||
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||
FROM users
|
||||
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||
@@ -424,6 +507,8 @@ export const getMemberForAdmin = async (memberId) => {
|
||||
users.updated_at AS "updatedAt",
|
||||
users.last_seen_at AS "lastSeenAt",
|
||||
users.last_seen_ip AS "lastSeenIp",
|
||||
users.previous_last_seen_at AS "previousLastSeenAt",
|
||||
users.previous_last_seen_ip AS "previousLastSeenIp",
|
||||
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||
FROM users
|
||||
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||
@@ -519,7 +604,9 @@ export const getAdminUserByEmail = async (email) => {
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
last_seen_ip AS "lastSeenIp",
|
||||
previous_last_seen_at AS "previousLastSeenAt",
|
||||
previous_last_seen_ip AS "previousLastSeenIp"
|
||||
FROM users
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND user_role = ANY(${PRIVILEGED_ROLES})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { setAdminSession } from '../../../../utils/admin-auth'
|
||||
import { getAdminUserByEmail } from '../../../../repositories/member-repository'
|
||||
import { getAdminUserByEmail, touchUserActivity } from '../../../../repositories/member-repository'
|
||||
import { setMemberSession } from '../../../../utils/member-auth'
|
||||
|
||||
const loginSchema = z.object({
|
||||
@@ -47,6 +47,10 @@ export default defineEventHandler(async (event) => {
|
||||
userId: adminUser.id,
|
||||
email: adminUser.email
|
||||
})
|
||||
await touchUserActivity({
|
||||
userId: adminUser.id,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
|
||||
return {
|
||||
userId: adminUser.id,
|
||||
|
||||
32
server/routes/admin/api/members/[id].delete.js
Normal file
32
server/routes/admin/api/members/[id].delete.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createError, getRouterParam } from 'h3'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { deleteMemberByAdmin } from '../../../../repositories/member-repository'
|
||||
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
|
||||
|
||||
/**
|
||||
* 관리자 회원 삭제 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>} 삭제 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const deletedMember = await deleteMemberByAdmin({
|
||||
actorUserId: session.userId,
|
||||
targetUserId: memberId
|
||||
})
|
||||
|
||||
if (deletedMember.avatarUrl) {
|
||||
await removeManagedAvatarAsset(deletedMember.avatarUrl)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
50
server/routes/admin/api/members/[id]/password.put.js
Normal file
50
server/routes/admin/api/members/[id]/password.put.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { createError, getRouterParam, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||
import { getMemberForAdmin, updateMemberPassword } from '../../../../../repositories/member-repository'
|
||||
|
||||
const adminMemberPasswordSchema = z.object({
|
||||
password: z.string().min(8).max(32)
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 회원 비밀번호 직접 변경 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>} 변경 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
const parsedBody = adminMemberPasswordSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '비밀번호 변경 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const member = await getMemberForAdmin(memberId)
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsedBody.data.password, 12)
|
||||
await updateMemberPassword({
|
||||
userId: memberId,
|
||||
passwordHash
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
Reference in New Issue
Block a user