관리자 레이아웃과 네비게이션 정리
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { deleteMember, getUserByIdWithPassword } from '../../repositories/member-repository'
|
||||
import { countOwnerMembers, deleteMember, getUserByIdWithPassword, MEMBER_ROLE } from '../../repositories/member-repository'
|
||||
import { clearAdminSession } from '../../utils/admin-auth'
|
||||
import { clearMemberSession, requireMemberSession } from '../../utils/member-auth'
|
||||
import { removeManagedAvatarAsset } from '../../utils/member-avatar'
|
||||
|
||||
@@ -41,13 +42,20 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (user.role === MEMBER_ROLE.OWNER && (await countOwnerMembers()) <= 1) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (user.avatarUrl) {
|
||||
await removeManagedAvatarAsset(user.avatarUrl)
|
||||
}
|
||||
|
||||
await deleteMember(session.userId)
|
||||
clearMemberSession(event)
|
||||
clearAdminSession(event)
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
countOtpSendsLastHour,
|
||||
hasRecentOtpSend,
|
||||
insertOtpChallenge,
|
||||
invalidatePendingOtpChallenges
|
||||
deleteOtpChallengeById,
|
||||
invalidatePendingOtpChallenges,
|
||||
invalidatePendingOtpChallengesExcept
|
||||
} from '../../../repositories/email-otp-repository'
|
||||
import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp'
|
||||
import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail'
|
||||
@@ -129,8 +131,7 @@ export default defineEventHandler(async (event) => {
|
||||
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
|
||||
const createdIp = String(getRequestIP(event) || '')
|
||||
|
||||
await invalidatePendingOtpChallenges(sql, email, purpose)
|
||||
await insertOtpChallenge(sql, {
|
||||
const challengeId = await insertOtpChallenge(sql, {
|
||||
email,
|
||||
purpose,
|
||||
codeHash,
|
||||
@@ -147,13 +148,20 @@ export default defineEventHandler(async (event) => {
|
||||
? `<p>회원가입을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
|
||||
: `<p>비밀번호 재설정을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
|
||||
|
||||
await sendResendEmail({
|
||||
apiKey: String(config.resendApiKey).trim(),
|
||||
from: String(config.resendFromEmail).trim(),
|
||||
to: email,
|
||||
subject,
|
||||
html
|
||||
})
|
||||
try {
|
||||
await sendResendEmail({
|
||||
apiKey: String(config.resendApiKey).trim(),
|
||||
from: String(config.resendFromEmail).trim(),
|
||||
to: email,
|
||||
subject,
|
||||
html
|
||||
})
|
||||
} catch (error) {
|
||||
await deleteOtpChallengeById(sql, challengeId)
|
||||
throw error
|
||||
}
|
||||
|
||||
await invalidatePendingOtpChallengesExcept(sql, email, purpose, challengeId)
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
34
server/middleware/admin-api-session.js
Normal file
34
server/middleware/admin-api-session.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createError, getRequestURL } from 'h3'
|
||||
import { getAdminSession } from '../utils/admin-auth'
|
||||
import { isPrivilegedMember } from '../repositories/member-repository'
|
||||
|
||||
/**
|
||||
* 관리자 API 요청마다 현재 DB 권한을 다시 확인한다.
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const pathname = getRequestURL(event).pathname
|
||||
|
||||
if (!pathname.startsWith('/admin/api/') || pathname === '/admin/api/auth/login' || pathname === '/admin/api/auth/logout') {
|
||||
return
|
||||
}
|
||||
|
||||
const session = getAdminSession(event)
|
||||
|
||||
if (!session) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '관리자 로그인이 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const stillPrivileged = await isPrivilegedMember(session.userId)
|
||||
|
||||
if (!stillPrivileged) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '현재 관리자 권한이 없습니다. 다시 로그인해 주세요.'
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -37,6 +37,38 @@ export const invalidatePendingOtpChallenges = async (sql, email, purpose) => {
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 OTP를 제외한 동일 이메일·용도의 미소진 OTP를 무효화한다.
|
||||
* @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션
|
||||
* @param {string} email - 정규화된 이메일
|
||||
* @param {string} purpose - signup | password_reset
|
||||
* @param {string} keepId - 유지할 챌린지 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const invalidatePendingOtpChallengesExcept = async (sql, email, purpose, keepId) => {
|
||||
await sql`
|
||||
UPDATE email_otp_challenges
|
||||
SET consumed_at = now()
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND purpose = ${purpose}
|
||||
AND id <> ${keepId}
|
||||
AND consumed_at IS NULL
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 OTP 챌린지를 삭제한다.
|
||||
* @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션
|
||||
* @param {string} id - 챌린지 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteOtpChallengeById = async (sql, id) => {
|
||||
await sql`
|
||||
DELETE FROM email_otp_challenges
|
||||
WHERE id = ${id}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 짧은 시간 내 동일 이메일·용도 발송이 있는지 확인한다.
|
||||
* @param {import('postgres').Sql} sql - sql
|
||||
|
||||
@@ -130,31 +130,35 @@ export const getUserByIdWithPassword = async (id) => {
|
||||
export const createUser = async (input) => {
|
||||
const sql = requireSql()
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
|
||||
VALUES (
|
||||
${input.username},
|
||||
${input.email},
|
||||
${input.passwordHash},
|
||||
'',
|
||||
NOT EXISTS (SELECT 1 FROM users),
|
||||
CASE
|
||||
WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER}
|
||||
ELSE ${MEMBER_ROLE.MEMBER}
|
||||
END
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
`
|
||||
const rows = await sql.begin(async (tx) => {
|
||||
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
|
||||
|
||||
return tx`
|
||||
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
|
||||
VALUES (
|
||||
${input.username},
|
||||
${input.email},
|
||||
${input.passwordHash},
|
||||
'',
|
||||
NOT EXISTS (SELECT 1 FROM users),
|
||||
CASE
|
||||
WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER}
|
||||
ELSE ${MEMBER_ROLE.MEMBER}
|
||||
END
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
`
|
||||
})
|
||||
|
||||
const created = rows?.[0]
|
||||
if (!created) {
|
||||
@@ -262,6 +266,21 @@ export const deleteMember = async (userId) => {
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자 권한 회원 수를 조회한다.
|
||||
* @returns {Promise<number>} 소유자 회원 수
|
||||
*/
|
||||
export const countOwnerMembers = async () => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT COUNT(*)::int AS "ownerCount"
|
||||
FROM users
|
||||
WHERE user_role = ${MEMBER_ROLE.OWNER}
|
||||
`
|
||||
|
||||
return Number(rows?.[0]?.ownerCount || 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자명 중복 확인
|
||||
* @param {{ username: string, excludeUserId?: string }} input - 사용자명과 제외 사용자 ID
|
||||
@@ -500,4 +519,3 @@ export const updateMemberRoleByAdmin = async (input) => {
|
||||
isAdmin: Boolean(updated.isAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user