v1.7.1: 멤버 권한·추천 사이트·네비게이션 UI 개선

관리자는 VIP·멤버만 수정 가능하도록 서버·UI 권한을 정리하고 소유자 권한 이전 API와 확인 모달을 추가했다. 추천 사이트 파비콘 캐시·URL 접두사·카드 편집 UI를 보강하고 확인 얼럿을 AdminConfirmModal로 통일했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 14:32:11 +09:00
parent f206499f99
commit 5b2ed97748
30 changed files with 1837 additions and 211 deletions

View File

@@ -10,6 +10,62 @@ export const MEMBER_ROLE = {
const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
const MEMBERSHIP_ACCESS_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN, MEMBER_ROLE.VIP]
const BELOW_ADMIN_ROLES = [MEMBER_ROLE.VIP, MEMBER_ROLE.MEMBER]
/**
* 관리자가 대상 회원 프로필을 수정할 수 있는지 확인한다.
* @param {{ actorRoleCode: string, targetRoleCode: string, actorUserId: string, targetUserId: string }} input - 검사 정보
* @returns {boolean} 수정 가능 여부
*/
export const canActorEditMemberProfile = (input) => {
if (input.actorRoleCode === MEMBER_ROLE.OWNER) {
return true
}
if (input.actorRoleCode === MEMBER_ROLE.ADMIN) {
return String(input.actorUserId) !== String(input.targetUserId)
&& BELOW_ADMIN_ROLES.includes(input.targetRoleCode)
}
return false
}
/**
* 관리자가 대상 회원 프로필을 수정할 수 있는지 검증한다.
* @param {{ actorUserId: string, targetUserId: string }} input - 검증 정보
* @returns {Promise<{ actor: Object, target: Object }>} 검증된 회원 정보
*/
export const assertActorCanEditMemberProfile = async (input) => {
const actor = await getMemberForAdmin(input.actorUserId)
if (!actor || !isPrivilegedRole(actor.roleCode)) {
throw createError({
statusCode: 403,
message: '회원 수정 권한이 없습니다.'
})
}
const target = await getMemberForAdmin(input.targetUserId)
if (!target) {
throw createError({
statusCode: 404,
message: '회원을 찾을 수 없습니다.'
})
}
if (!canActorEditMemberProfile({
actorRoleCode: actor.roleCode,
targetRoleCode: target.roleCode,
actorUserId: actor.id,
targetUserId: target.id
})) {
throw createError({
statusCode: 403,
message: '이 회원 정보를 수정할 권한이 없습니다.'
})
}
return { actor, target }
}
/**
* 회원 권한 표시 문자열을 반환한다.
@@ -467,15 +523,12 @@ export const deleteMember = async (userId) => {
* @returns {Promise<Object>} 삭제된 회원
*/
export const deleteMemberByAdmin = async (input) => {
const target = await getMemberForAdmin(input.targetUserId)
if (!target) {
throw createError({
statusCode: 404,
message: '회원을 찾을 수 없습니다.'
})
}
const { target } = await assertActorCanEditMemberProfile({
actorUserId: input.actorUserId,
targetUserId: input.targetUserId
})
if (target.id === input.actorUserId) {
if (String(target.id) === String(input.actorUserId)) {
throw createError({
statusCode: 400,
message: '현재 로그인한 계정은 여기서 삭제할 수 없습니다.'
@@ -668,10 +721,15 @@ export const createMemberByAdmin = async (input) => {
/**
* 관리자 화면에서 회원 기본 정보를 수정한다.
* @param {{ memberId: string, username: string, email: string, avatarUrl: string, labels: string[], note: string }} input - 수정 값
* @param {{ actorUserId: string, memberId: string, username: string, email: string, avatarUrl: string, labels: string[], note: string }} input - 수정 값
* @returns {Promise<Object | null>} 수정된 회원
*/
export const updateMemberByAdmin = async (input) => {
await assertActorCanEditMemberProfile({
actorUserId: input.actorUserId,
targetUserId: input.memberId
})
const sql = requireSql()
const rows = await sql`
UPDATE users
@@ -691,10 +749,15 @@ export const updateMemberByAdmin = async (input) => {
/**
* 관리자 화면에서 회원 썸네일만 수정한다.
* @param {{ memberId: string, avatarUrl: string }} input - 수정 값
* @param {{ actorUserId: string, memberId: string, avatarUrl: string }} input - 수정 값
* @returns {Promise<Object | null>} 수정된 회원
*/
export const updateMemberAvatarByAdmin = async (input) => {
await assertActorCanEditMemberProfile({
actorUserId: input.actorUserId,
targetUserId: input.memberId
})
const sql = requireSql()
const rows = await sql`
UPDATE users
@@ -871,7 +934,7 @@ export const updateMemberRoleByAdmin = async (input) => {
if (isPrivilegedRole(target.roleCode)) {
throw createError({
statusCode: 403,
message: '관리자는 다른 소유자 또는 관리자 권한을 변경할 수 없습니다.'
message: '관리자는 소유자 또는 관리자 회원의 등급을 변경할 수 없습니다.'
})
}
@@ -883,6 +946,17 @@ export const updateMemberRoleByAdmin = async (input) => {
}
}
if (
normalizedRole === MEMBER_ROLE.OWNER
&& actor.roleCode === MEMBER_ROLE.OWNER
&& target.id !== actor.id
) {
throw createError({
statusCode: 400,
message: '소유자 권한 이전은 전용 확인 절차를 사용하세요.'
})
}
if (target.id === input.actorUserId && !isPrivilegedRole(normalizedRole)) {
throw createError({
statusCode: 400,
@@ -934,3 +1008,93 @@ export const updateMemberRoleByAdmin = async (input) => {
isAdmin: Boolean(updated.isAdmin)
}
}
/**
* 소유자 권한을 다른 회원에게 이전하고 기존 소유자를 관리자로 변경한다.
* @param {{ actorUserId: string, targetUserId: string }} input - 이전 정보
* @returns {Promise<{ target: Object, previousOwner: Object }>} 이전 결과
*/
export const transferMemberOwnership = async (input) => {
const sql = requireSql()
await sql.begin(async (tx) => {
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
const actorRows = await tx`
SELECT id, user_role AS "roleCode"
FROM users
WHERE id = ${input.actorUserId}
LIMIT 1
`
const actor = actorRows?.[0]
if (!actor || actor.roleCode !== MEMBER_ROLE.OWNER) {
throw createError({
statusCode: 403,
message: '소유자만 권한을 이전할 수 있습니다.'
})
}
const targetRows = await tx`
SELECT id, user_role AS "roleCode"
FROM users
WHERE id = ${input.targetUserId}
LIMIT 1
`
const target = targetRows?.[0]
if (!target) {
throw createError({
statusCode: 404,
message: '대상 회원을 찾을 수 없습니다.'
})
}
if (String(target.id) === String(actor.id)) {
throw createError({
statusCode: 400,
message: '본인에게 소유자 권한을 이전할 수 없습니다.'
})
}
if (target.roleCode === MEMBER_ROLE.OWNER) {
throw createError({
statusCode: 400,
message: '이미 소유자입니다.'
})
}
await tx`
UPDATE users
SET
user_role = ${MEMBER_ROLE.OWNER},
is_admin = true,
updated_at = now()
WHERE id = ${input.targetUserId}
`
await tx`
UPDATE users
SET
user_role = ${MEMBER_ROLE.ADMIN},
is_admin = true,
updated_at = now()
WHERE id = ${input.actorUserId}
`
})
const target = await getMemberForAdmin(input.targetUserId)
const previousOwner = await getMemberForAdmin(input.actorUserId)
if (!target || !previousOwner) {
throw createError({
statusCode: 500,
message: '소유자 권한 이전에 실패했습니다.'
})
}
return {
target,
previousOwner
}
}