v1.7.1: 멤버 권한·추천 사이트·네비게이션 UI 개선
관리자는 VIP·멤버만 수정 가능하도록 서버·UI 권한을 정리하고 소유자 권한 이전 API와 확인 모달을 추가했다. 추천 사이트 파비콘 캐시·URL 접두사·카드 편집 UI를 보강하고 확인 얼럿을 AdminConfirmModal로 통일했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user