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

@@ -0,0 +1,99 @@
<script setup>
defineProps({
open: {
type: Boolean,
default: false
},
title: {
type: String,
required: true
},
confirmLabel: {
type: String,
default: '확인'
},
cancelLabel: {
type: String,
default: '취소'
},
confirmTone: {
type: String,
default: 'primary'
},
loading: {
type: Boolean,
default: false
},
loadingLabel: {
type: String,
default: ''
}
})
defineEmits(['confirm', 'cancel'])
/**
* 확인 버튼 스타일 클래스를 반환한다.
* @param {string} tone - 버튼 톤
* @returns {string} 클래스 문자열
*/
const getConfirmButtonClass = (tone) => {
if (tone === 'danger') {
return 'bg-[#d21a26] text-white hover:bg-[#b81620]'
}
return 'bg-[#15171a] text-white hover:bg-[#2b2f35]'
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="admin-confirm-modal fixed inset-0 z-[100] flex items-start justify-center bg-black/35 px-4 pt-10"
role="dialog"
aria-modal="true"
:aria-labelledby="'admin-confirm-modal-title'"
>
<section class="admin-confirm-modal__panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
<header class="admin-confirm-modal__header flex items-center justify-between border-b border-line px-6 py-5">
<h2 id="admin-confirm-modal-title" class="admin-confirm-modal__title text-xl font-semibold">
{{ title }}
</h2>
<button
class="admin-confirm-modal__close grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]"
type="button"
aria-label="닫기"
:disabled="loading"
@click="$emit('cancel')"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
</header>
<div class="admin-confirm-modal__body grid gap-4 px-6 py-5 text-sm leading-6 text-[#657080]">
<slot />
</div>
<footer class="admin-confirm-modal__footer flex justify-end gap-2 border-t border-line px-6 py-4">
<button
class="admin-confirm-modal__cancel h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]"
type="button"
:disabled="loading"
@click="$emit('cancel')"
>
{{ cancelLabel }}
</button>
<button
class="admin-confirm-modal__confirm h-10 rounded-md px-4 text-sm font-semibold transition disabled:opacity-50"
:class="getConfirmButtonClass(confirmTone)"
type="button"
:disabled="loading"
@click="$emit('confirm')"
>
{{ loading && loadingLabel ? loadingLabel : confirmLabel }}
</button>
</footer>
</section>
</div>
</Teleport>
</template>

View File

@@ -59,6 +59,10 @@ const deleteForm = reactive({
confirmText: ''
})
/** 소유자 권한 이전 확인 모달 */
const ownershipTransferModalOpen = ref(false)
const isTransferringOwnership = ref(false)
/**
* 회원 폼 값을 현재 회원 정보로 동기화한다.
* @returns {void}
@@ -93,16 +97,51 @@ const normalizedLabels = computed(() => [...new Set(
.filter(Boolean)
)])
const isFormEditable = computed(() => isNewMember.value || isEditingMember.value)
const isFormEditable = computed(() => {
if (isNewMember.value) {
return true
}
if (!canEditMemberProfile.value) {
return false
}
return isEditingMember.value
})
const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버')
const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '')
const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value))
const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId)
&& String(props.member.id) === String(adminSession.value.userId))
const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(props.member?.roleCode || form.roleCode))
/** 저장된 대상 회원 등급(권한 판단용) */
const targetSavedRoleCode = computed(() => String(props.member?.roleCode || 'member'))
const isTargetPrivilegedRole = computed(() => ['owner', 'admin'].includes(targetSavedRoleCode.value))
/**
* 대상 회원 프로필을 수정할 수 있는지 여부
* @returns {boolean} 수정 가능 여부
*/
const canEditMemberProfile = computed(() => {
if (isNewMember.value) {
return isCurrentAdminPrivileged.value
}
if (currentAdminRoleCode.value === 'owner') {
return true
}
if (currentAdminRoleCode.value === 'admin') {
return !isEditingSelf.value && ['vip', 'member'].includes(targetSavedRoleCode.value)
}
return false
})
const canOpenEditMode = computed(() => !isNewMember.value && canEditMemberProfile.value && !isEditingMember.value)
const canManageMemberActions = computed(() => canEditMemberProfile.value)
const shouldRenderRoleAsText = computed(() => !isFormEditable.value || isNewMember.value || !isCurrentAdminPrivileged.value)
const canEditRoleSelect = computed(() => {
if (!isFormEditable.value || shouldRenderRoleAsText.value || isSaving.value) {
/**
* 회원 등급 변경 권한 여부(저장 중 상태는 제외)
* @returns {boolean} 변경 가능 여부
*/
const canChangeMemberRole = computed(() => {
if (!isFormEditable.value || shouldRenderRoleAsText.value) {
return false
}
@@ -116,6 +155,8 @@ const canEditRoleSelect = computed(() => {
return false
})
const canEditRoleSelect = computed(() => canChangeMemberRole.value && !isSaving.value)
const availableRoleOptions = computed(() => {
if (!canEditRoleSelect.value) {
return roleOptions
@@ -127,6 +168,18 @@ const availableRoleOptions = computed(() => {
return roleOptions
})
/**
* 저장 시 소유자 권한 이전 확인이 필요한지 여부
* @returns {boolean} 확인 필요 여부
*/
const isOwnershipTransferPending = computed(() => {
return !isNewMember.value
&& currentAdminRoleCode.value === 'owner'
&& !isEditingSelf.value
&& form.roleCode === 'owner'
&& targetSavedRoleCode.value !== 'owner'
})
const roleHelpText = computed(() => {
if (!isFormEditable.value) {
return '수정하기를 누르면 변경 가능한 항목을 편집할 수 있습니다.'
@@ -136,18 +189,26 @@ const roleHelpText = computed(() => {
return '멤버와 VIP는 관리자 권한이 없어 등급을 변경할 수 없습니다.'
}
if (!canEditMemberProfile.value && currentAdminRoleCode.value === 'admin') {
return '관리자는 VIP·멤버 정보만 수정할 수 있습니다.'
}
if (isEditingSelf.value && currentAdminRoleCode.value === 'owner') {
return '소유자는 본인 권한을 직접 낮출 수 없습니다.'
}
if (currentAdminRoleCode.value === 'admin' && isTargetPrivilegedRole.value) {
return '관리자는 소유자 또는 다른 관리자의 등급을 변경할 수 없습니다.'
return '관리자는 소유자·관리자 정보를 수정하거나 등급을 변경할 수 없습니다.'
}
if (currentAdminRoleCode.value === 'admin') {
return '관리자는 멤버와 VIP 등급만 변경할 수 있습니다.'
}
if (isOwnershipTransferPending.value) {
return '소유자 등급으로 저장하면 권한 이전 확인 모달이 표시됩니다. 승인 시 본인은 관리자로 변경됩니다.'
}
return 'VIP 이상 등급은 멤버십 게시물을 볼 수 있습니다.'
})
@@ -248,6 +309,9 @@ const {
* @returns {void}
*/
const enterEditMode = () => {
if (!canEditMemberProfile.value) {
return
}
isEditingMember.value = true
}
@@ -285,21 +349,14 @@ const uploadAvatar = async (event) => {
try {
const formData = new FormData()
formData.append('file', file)
const result = isNewMember.value
? await $fetch('/admin/api/member-avatar', {
method: 'POST',
body: formData
})
: await $fetch(`/admin/api/members/${props.member.id}/avatar`, {
method: 'POST',
body: formData
})
const result = await $fetch('/admin/api/member-avatar', {
method: 'POST',
body: formData
})
form.avatarUrl = result.avatarUrl || ''
if (!isNewMember.value) {
emit('saved', result)
savedMemberSnapshot.value = serializeMemberPayload()
showToast('success', '썸네일이 변경되었습니다.')
showToast('info', '썸네일 미리보기가 반영되었습니다. 저장을 눌러 적용하세요.')
}
} catch (error) {
showToast('error', error?.data?.message || '썸네일 업로드에 실패했습니다.')
@@ -451,6 +508,77 @@ const deleteMember = async () => {
}
}
/**
* 회원 기본 정보를 저장한다.
* @returns {Promise<Object>} 저장된 회원
*/
const performMemberSave = async () => {
const payload = getMemberPayload()
const roleChanged = !isNewMember.value && form.roleCode !== props.member?.roleCode
if (roleChanged && canChangeMemberRole.value && form.roleCode !== 'owner') {
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',
body: payload
})
: await $fetch(`/admin/api/members/${props.member.id}`, {
method: 'PUT',
body: payload
})
savedMemberSnapshot.value = serializeMemberPayload()
emit('saved', saved)
isEditingMember.value = isNewMember.value
return saved
}
/**
* 소유자 권한 이전 모달을 닫는다.
* @returns {void}
*/
const closeOwnershipTransferModal = () => {
if (isTransferringOwnership.value) {
return
}
ownershipTransferModalOpen.value = false
}
/**
* 소유자 권한 이전을 확정한다.
* @returns {Promise<void>}
*/
const confirmOwnershipTransfer = async () => {
if (isTransferringOwnership.value || !props.member?.id) {
return
}
isTransferringOwnership.value = true
try {
if (hasUnsavedMemberChanges.value) {
await performMemberSave()
}
await $fetch(`/admin/api/members/${props.member.id}/ownership-transfer`, {
method: 'POST'
})
ownershipTransferModalOpen.value = false
showToast('success', '소유자 권한을 이전했습니다. 본인 계정은 관리자로 변경되었습니다.')
await navigateTo('/admin/members')
} catch (error) {
showToast('error', error?.data?.message || '소유자 권한 이전에 실패했습니다.')
} finally {
isTransferringOwnership.value = false
}
}
/**
* 회원 기본 정보를 저장한다.
* @returns {Promise<void>}
@@ -461,6 +589,9 @@ const saveMember = async () => {
}
if (!isFormEditable.value) {
if (!canOpenEditMode.value) {
return
}
enterEditMode()
return
}
@@ -469,32 +600,15 @@ const saveMember = async () => {
return
}
if (isOwnershipTransferPending.value) {
ownershipTransferModalOpen.value = true
return
}
isSaving.value = true
try {
const payload = getMemberPayload()
if (!isNewMember.value && canEditRoleSelect.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',
body: payload
})
: await $fetch(`/admin/api/members/${props.member.id}`, {
method: 'PUT',
body: payload
})
savedMemberSnapshot.value = serializeMemberPayload()
emit('saved', saved)
isEditingMember.value = isNewMember.value
await performMemberSave()
showToast('success', '저장되었습니다.')
} catch (error) {
showToast('error', error?.data?.message || '저장에 실패했습니다.')
@@ -527,7 +641,7 @@ watch(() => props.member, () => {
</h1>
</div>
<div class="admin-member-form__actions flex items-center gap-3">
<div v-if="!isNewMember" class="admin-member-form__action-menu relative">
<div v-if="!isNewMember && canManageMemberActions" class="admin-member-form__action-menu relative">
<button class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650] transition hover:border-[#c5ccd5] hover:bg-[#f4f6f8]" type="button" aria-label="멤버 작업" :aria-expanded="actionMenuOpen" @click="toggleActionMenu">
<svg class="h-5 w-5" viewBox="0 0 24 24" aria-hidden="true">
<path d="M10.546 2.438a1.957 1.957 0 002.908 0L14.4 1.4a1.959 1.959 0 013.41 1.413l-.071 1.4a1.958 1.958 0 002.051 2.054l1.4-.071a1.959 1.959 0 011.41 3.41l-1.042.94a1.96 1.96 0 000 2.909l1.042.94a1.959 1.959 0 01-1.413 3.41l-1.4-.071a1.958 1.958 0 00-2.056 2.056l.071 1.4A1.959 1.959 0 0114.4 22.6l-.941-1.041a1.959 1.959 0 00-2.908 0L9.606 22.6A1.959 1.959 0 016.2 21.192l.072-1.4a1.958 1.958 0 00-2.056-2.056l-1.4.071A1.958 1.958 0 011.4 14.4l1.041-.94a1.96 1.96 0 000-2.909L1.4 9.606A1.958 1.958 0 012.809 6.2l1.4.071a1.958 1.958 0 002.058-2.06L6.2 2.81A1.959 1.959 0 019.606 1.4z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
@@ -552,13 +666,19 @@ watch(() => props.member, () => {
<button
class="admin-member-form__save h-11 rounded-md bg-[#15171a] px-5 text-sm font-semibold text-white transition hover:bg-[#2b2f35] disabled:cursor-not-allowed disabled:bg-[#c7cdd4] disabled:text-white"
type="button"
:disabled="isFormEditable && !canSubmitMemberForm"
:disabled="(isFormEditable && !canSubmitMemberForm) || (!isFormEditable && !canOpenEditMode)"
@click="saveMember"
>
{{ !isFormEditable ? '수정하기' : isSaving ? '저장 중' : '저장' }}
{{ !isFormEditable ? (canOpenEditMode ? '수정하기' : '수정 불가') : isSaving ? '저장 중' : '저장' }}
</button>
</div>
</div>
<p
v-if="!isNewMember && !canEditMemberProfile"
class="admin-member-form__readonly-note mt-4 rounded-md border border-line bg-[#f9f9f7] px-4 py-3 text-sm text-[#657080]"
>
관리자는 VIP·멤버 정보만 수정할 수 있습니다. 소유자·관리자 계정은 조회만 가능합니다.
</p>
</div>
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-3">
@@ -824,5 +944,33 @@ watch(() => props.member, () => {
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="ownershipTransferModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
<header class="flex items-center justify-between border-b border-line px-6 py-5">
<h2 class="text-xl font-semibold">소유자 권한 이전</h2>
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeOwnershipTransferModal">
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
</header>
<div class="grid gap-4 px-6 py-5">
<p class="text-sm leading-6 text-[#657080]">
<strong class="text-[#15171a]">{{ form.username || form.email }}</strong> 님을 새로운 소유자로 지정하시겠습니까?<br> 소유자는 한 명만 설정할 수 있으며, 변경 후 현재 계정은 <strong class="text-[#15171a]">관리자 권한</strong>으로 전환됩니다.
</p>
</div>
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeOwnershipTransferModal">
취소
</button>
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isTransferringOwnership" @click="confirmOwnershipTransfer">
{{ isTransferringOwnership ? '이전 중' : '권한 이전' }}
</button>
</footer>
</section>
</div>
</Teleport>
</section>
</template>