관리자 부트스트랩 복구 보강
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user