멤버 등급 변경 권한 규칙 수정 v1.5.6

This commit is contained in:
2026-05-26 16:31:56 +09:00
parent 3843e16d9f
commit a5ae2c3fce
9 changed files with 120 additions and 94 deletions

View File

@@ -24,7 +24,6 @@ const passwordModalOpen = ref(false)
const deleteModalOpen = ref(false)
const isUpdatingPassword = ref(false)
const isDeletingMember = ref(false)
const isUpdatingRole = ref(false)
const actionMessage = ref('')
const actionError = ref('')
@@ -93,7 +92,10 @@ const currentRoleLabel = computed(() => roleOptions.find((option) => option.valu
* 회원 저장 요청 본문을 문자열로 직렬화한다.
* @returns {string} 직렬화된 회원 입력값
*/
const serializeMemberPayload = () => JSON.stringify(getMemberPayload())
const serializeMemberPayload = () => JSON.stringify({
...getMemberPayload(),
roleCode: form.roleCode
})
/**
* 날짜 표시 형식 변환
@@ -340,40 +342,6 @@ const updateMemberPassword = async () => {
}
}
/**
* 관리자 권한으로 회원 등급을 변경한다.
* @returns {Promise<void>}
*/
const updateMemberRole = async () => {
if (isNewMember.value || isUpdatingRole.value) {
return
}
actionMessage.value = ''
actionError.value = ''
isUpdatingRole.value = true
try {
const updated = await $fetch(`/admin/api/members/${props.member.id}/role`, {
method: 'PUT',
body: {
role: form.roleCode
}
})
emit('saved', {
...props.member,
...updated
})
actionMessage.value = '멤버 등급이 변경되었습니다.'
} catch (error) {
form.roleCode = props.member?.roleCode || 'member'
actionError.value = error?.data?.message || '멤버 등급 변경에 실패했습니다.'
} finally {
isUpdatingRole.value = false
}
}
/**
* 관리자 권한으로 회원을 삭제한다.
* @returns {Promise<void>}
@@ -415,6 +383,15 @@ const saveMember = async () => {
try {
const payload = getMemberPayload()
if (!isNewMember.value && form.roleCode !== props.member?.roleCode) {
await $fetch(`/admin/api/members/${props.member.id}/role`, {
method: 'PUT',
body: {
role: form.roleCode
}
})
}
const saved = isNewMember.value
? await $fetch('/admin/api/members', {
method: 'POST',
@@ -584,8 +561,7 @@ watch(() => props.member, () => {
<select
v-model="form.roleCode"
class="admin-member-form__select h-12 w-full rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8] disabled:opacity-60"
:disabled="isNewMember || isUpdatingRole"
@change="updateMemberRole"
:disabled="isNewMember || isSaving"
>
<option v-for="option in roleOptions" :key="option.value" :value="option.value">
{{ option.label }}

View File

@@ -1,5 +1,10 @@
# 업데이트 요약
## v1.5.6
- 멤버 등급 변경이 저장 버튼을 눌렀을 때만 반영되도록 수정했다.
- 관리자 권한 변경 규칙을 강화해 관리자끼리 조작하거나 마지막 소유자를 없앨 수 없도록 막았다.
## v1.5.5
- 멤버 등급에 VIP를 추가하고, 멤버십 게시물은 VIP 이상 등급에게만 공개되도록 정리했다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-05-26 v1.5.6 — 멤버 권한 변경을 저장 액션과 서버 규칙으로 고정
멤버 등급은 접근 권한을 바꾸는 민감한 값이므로 셀렉트 변경 즉시 저장하면 사용자가 의도하지 않은 권한 변경이 발생할 수 있다. 따라서 멤버 상세 화면의 등급 변경은 다른 기본 정보처럼 저장 버튼을 눌렀을 때만 반영한다. 서버는 화면 상태와 무관하게 소유자·관리자만 권한을 변경할 수 있게 하고, 관리자는 다른 관리자나 소유자를 조작하지 못하며 소유자·관리자 등급도 부여하지 못하게 막는다. 마지막 소유자 보호는 트랜잭션 잠금 안에서 검사해 동시에 권한을 바꿔도 소유자가 사라지지 않도록 한다.
## 2026-05-26 v1.5.5 — 멤버십 공개 기준을 VIP 등급으로 고정
로그인 회원은 댓글 작성을 위해 필요한 기본 사용자 범위라서 멤버십 콘텐츠의 공개 기준으로 쓰기에는 너무 넓다. 따라서 `members` 상태 게시물은 단순 세션 존재가 아니라 `vip`, `admin`, `owner` 등급에만 노출되도록 바꾼다. `vip`는 관리자 권한은 없지만 멤버십 콘텐츠 접근 권한을 가진 등급이며, 관리자 멤버 상세 화면에서 직접 지정할 수 있게 했다.

View File

@@ -143,7 +143,7 @@
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 멤버 등급 변경, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 저장 버튼 기반 멤버 등급 변경, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
## 공개 페이지
@@ -231,7 +231,7 @@
| server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API |
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API(회원 전용 썸네일 교체·제거 시 메타 연결 분리) |
| server/routes/admin/api/members/[id]/avatar.post.js | 관리자 멤버 썸네일 업로드 및 즉시 반영 API |
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API(`owner`/`admin`/`vip`/`member`) |
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API(`owner`/`admin`/`vip`/`member`, 관리자 상호 조작 차단, 마지막 소유자 보호) |
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |

View File

@@ -691,10 +691,10 @@ components/content/
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 멤버 등급은 셀렉트 변경 즉시 저장하지 않고 저장 버튼을 눌렀을 때 기본 정보와 함께 반영한다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
- 관리자 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 관리자 게시글 작성/수정 화면은 **서버에 이미 즉시 발행 또는 예약으로 저장된 글**에서 미저장 변경이 있을 때만 동일 방식으로 내부 이동을 막고, 초안(서버 기준)은 서버 자동 저장과 라우트 이탈 직전 플러시로 처리하여 해당 경우에는 모달을 쓰지 않는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 레거시 키 `SORI_ADMIN_POST_AUTOSAVE:*`가 있으면 삭제한다.
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다.
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다. 권한 변경은 소유자와 관리자만 가능하다. 관리자는 다른 관리자·소유자의 권한을 변경할 수 없고, 소유자·관리자 등급을 부여할 수 없다. 소유자는 모든 등급을 관리할 수 있으나 시스템에는 최소 1명의 소유자가 항상 남아야 한다.
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.5.6
- 관리자 멤버 상세: 멤버 등급 선택이 즉시 저장되지 않고 저장 버튼으로만 반영되도록 수정.
- 회원 권한 변경 API: 소유자·관리자만 권한 변경 가능하도록 규칙 강화.
- 회원 권한 변경 API: 관리자는 다른 관리자·소유자를 조작할 수 없고, 소유자·관리자 등급을 부여할 수 없도록 수정.
- 회원 권한 변경 API: 마지막 소유자 권한이 사라지지 않도록 트랜잭션 잠금 안에서 검증하도록 수정.
## v1.5.5
- 회원 권한에 `VIP` 등급 추가.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.5.5",
"version": "1.5.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.5",
"version": "1.5.6",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.5",
"version": "1.5.6",
"private": true,
"type": "module",
"imports": {

View File

@@ -24,6 +24,13 @@ const getMemberRoleLabel = (roleCode) => roleCode === MEMBER_ROLE.OWNER
? 'VIP'
: '멤버'
/**
* 관리자 권한 코드 여부를 확인한다.
* @param {string} roleCode - 권한 코드
* @returns {boolean} 관리자 권한 여부
*/
const isPrivilegedRole = (roleCode) => PRIVILEGED_ROLES.includes(roleCode)
/**
* 관리자 회원 행을 응답 객체로 변환한다.
* @param {Object} row - DB 회원 행
@@ -816,63 +823,90 @@ export const updateMemberRoleByAdmin = async (input) => {
})
}
const actorCanManage = await isPrivilegedMember(input.actorUserId)
if (!actorCanManage) {
throw createError({
statusCode: 403,
message: '권한 변경 권한이 없습니다.'
})
}
const updatedRows = await sql.begin(async (tx) => {
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
const targetRows = await sql`
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 (target.id === input.actorUserId && !PRIVILEGED_ROLES.includes(normalizedRole)) {
throw createError({
statusCode: 400,
message: '본인 계정을 관리자 권한 없는 등급으로 변경할 수 없습니다.'
})
}
if (target.roleCode === MEMBER_ROLE.OWNER && normalizedRole !== MEMBER_ROLE.OWNER) {
const ownerRows = await sql`
SELECT COUNT(*)::int AS "ownerCount"
const actorRows = await tx`
SELECT id, user_role AS "roleCode"
FROM users
WHERE user_role = ${MEMBER_ROLE.OWNER}
WHERE id = ${input.actorUserId}
LIMIT 1
`
const actor = actorRows?.[0]
if (Number(ownerRows?.[0]?.ownerCount || 0) <= 1) {
if (!actor || !isPrivilegedRole(actor.roleCode)) {
throw createError({
statusCode: 400,
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
statusCode: 403,
message: '권한 변경 권한이 없습니다.'
})
}
}
const updatedRows = await sql`
UPDATE users
SET
user_role = ${normalizedRole},
is_admin = ${normalizedRole === MEMBER_ROLE.OWNER || normalizedRole === MEMBER_ROLE.ADMIN},
updated_at = now()
WHERE id = ${input.targetUserId}
RETURNING
id,
user_role AS "roleCode",
is_admin AS "isAdmin"
`
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 (actor.roleCode === MEMBER_ROLE.ADMIN) {
if (isPrivilegedRole(target.roleCode)) {
throw createError({
statusCode: 403,
message: '관리자는 다른 소유자 또는 관리자 권한을 변경할 수 없습니다.'
})
}
if (isPrivilegedRole(normalizedRole)) {
throw createError({
statusCode: 403,
message: '관리자는 소유자 또는 관리자 등급을 부여할 수 없습니다.'
})
}
}
if (target.id === input.actorUserId && !isPrivilegedRole(normalizedRole)) {
throw createError({
statusCode: 400,
message: '본인 계정을 관리자 권한 없는 등급으로 변경할 수 없습니다.'
})
}
if (target.roleCode === MEMBER_ROLE.OWNER && normalizedRole !== MEMBER_ROLE.OWNER) {
const ownerRows = await tx`
SELECT COUNT(*)::int AS "ownerCount"
FROM users
WHERE user_role = ${MEMBER_ROLE.OWNER}
`
if (Number(ownerRows?.[0]?.ownerCount || 0) <= 1) {
throw createError({
statusCode: 400,
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
})
}
}
return tx`
UPDATE users
SET
user_role = ${normalizedRole},
is_admin = ${normalizedRole === MEMBER_ROLE.OWNER || normalizedRole === MEMBER_ROLE.ADMIN},
updated_at = now()
WHERE id = ${input.targetUserId}
RETURNING
id,
user_role AS "roleCode",
is_admin AS "isAdmin"
`
})
const updated = updatedRows?.[0]
if (!updated) {