관리자 부트스트랩 복구 보강

This commit is contained in:
2026-05-14 13:36:51 +09:00
parent 6367e62ef0
commit 4862b52b3a
10 changed files with 199 additions and 19 deletions

View File

@@ -217,6 +217,14 @@ export const createUser = async (input) => {
const rows = await sql.begin(async (tx) => {
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
const privilegedRows = await tx`
SELECT EXISTS (
SELECT 1
FROM users
WHERE user_role = ANY(${PRIVILEGED_ROLES})
) AS "hasPrivilegedUsers"
`
const shouldCreateOwner = !Boolean(privilegedRows?.[0]?.hasPrivilegedUsers)
return tx`
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
@@ -225,9 +233,9 @@ export const createUser = async (input) => {
${input.email},
${input.passwordHash},
'',
NOT EXISTS (SELECT 1 FROM users),
${shouldCreateOwner},
CASE
WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER}
WHEN ${shouldCreateOwner} THEN ${MEMBER_ROLE.OWNER}
ELSE ${MEMBER_ROLE.MEMBER}
END
)
@@ -258,6 +266,91 @@ export const createUser = async (input) => {
return created
}
/**
* 관리자 권한이 없을 때 환경 변수 기반 owner 계정을 생성하거나 기존 회원을 승격한다.
* @param {{ username: string, email: string, passwordHash: string }} input - 입력
* @returns {Promise<MemberUser | null>} 생성 또는 승격된 owner 회원
*/
export const upsertBootstrapOwner = async (input) => {
const sql = requireSql()
const rows = await sql.begin(async (tx) => {
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
const privilegedRows = await tx`
SELECT id
FROM users
WHERE user_role = ANY(${PRIVILEGED_ROLES})
LIMIT 1
`
if (privilegedRows?.[0]) {
return []
}
const existingRows = await tx`
SELECT id
FROM users
WHERE lower(email) = lower(${input.email})
LIMIT 1
`
if (existingRows?.[0]) {
return tx`
UPDATE users
SET
password_hash = ${input.passwordHash},
is_admin = true,
user_role = ${MEMBER_ROLE.OWNER},
updated_at = now()
WHERE id = ${existingRows[0].id}
RETURNING
id,
username,
email,
password_hash AS "passwordHash",
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",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
`
}
return tx`
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
VALUES (
${input.username},
${input.email},
${input.passwordHash},
'',
true,
${MEMBER_ROLE.OWNER}
)
RETURNING
id,
username,
email,
password_hash AS "passwordHash",
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",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
`
})
return rows?.[0] || null
}
/**
* 회원 최근 활동 정보를 기록한다.
* @param {{ userId: string, ip: string }} input - 사용자 ID와 접속 IP
@@ -618,20 +711,24 @@ export const getAdminUserByEmail = async (email) => {
/**
* 최초 관리자 등록 필요 여부를 확인한다.
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
* @returns {Promise<{ hasUsers: boolean, hasPrivilegedUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
*/
export const getMemberBootstrapState = async () => {
const sql = requireSql()
const rows = await sql`
SELECT COUNT(*)::int AS "userCount"
SELECT
COUNT(*)::int AS "userCount",
COUNT(*) FILTER (WHERE user_role = ANY(${PRIVILEGED_ROLES}))::int AS "privilegedUserCount"
FROM users
`
const userCount = Number(rows?.[0]?.userCount || 0)
const privilegedUserCount = Number(rows?.[0]?.privilegedUserCount || 0)
return {
hasUsers: userCount > 0,
needsAdminSetup: userCount === 0
hasPrivilegedUsers: privilegedUserCount > 0,
needsAdminSetup: privilegedUserCount === 0
}
}

View File

@@ -2,7 +2,7 @@ import { z } from 'zod'
import { createError, getRequestIP, readBody } from 'h3'
import bcrypt from 'bcrypt'
import { safeCompare, setAdminSession } from '../../../../utils/admin-auth'
import { createUser, getAdminUserByEmail, getMemberBootstrapState, touchUserActivity } from '../../../../repositories/member-repository'
import { getAdminUserByEmail, getMemberBootstrapState, touchUserActivity, upsertBootstrapOwner } from '../../../../repositories/member-repository'
import { setMemberSession } from '../../../../utils/member-auth'
const loginSchema = z.object({
@@ -40,16 +40,11 @@ const createBootstrapAdminUser = async (credentials) => {
}
const passwordHash = await bcrypt.hash(credentials.password, 12)
const created = await createUser({
return upsertBootstrapOwner({
username: createBootstrapUsername(adminEmail),
email: adminEmail,
passwordHash
})
return {
...created,
passwordHash
}
}
/**