관리자와 회원 설정 계정 작업 정리

This commit is contained in:
2026-05-13 15:26:26 +09:00
parent 6481f958f5
commit bebf7ee1c9
18 changed files with 811 additions and 175 deletions

View File

@@ -10,7 +10,7 @@ const props = defineProps({
}
})
const emit = defineEmits(['saved'])
const emit = defineEmits(['saved', 'deleted'])
const isNewMember = computed(() => props.mode === 'new')
const saveMessage = ref('')
@@ -19,6 +19,13 @@ const isSaving = ref(false)
const savedMemberSnapshot = ref('')
const avatarInputRef = ref(null)
const isUploadingAvatar = ref(false)
const actionMenuOpen = ref(false)
const passwordModalOpen = ref(false)
const deleteModalOpen = ref(false)
const isUpdatingPassword = ref(false)
const isDeletingMember = ref(false)
const actionMessage = ref('')
const actionError = ref('')
const form = reactive({
username: '',
@@ -28,6 +35,15 @@ const form = reactive({
note: ''
})
const passwordForm = reactive({
password: '',
passwordConfirm: ''
})
const deleteForm = reactive({
confirmText: ''
})
/**
* 회원 폼 값을 현재 회원 정보로 동기화한다.
* @returns {void}
@@ -199,6 +215,134 @@ const removeAvatar = () => {
form.avatarUrl = ''
}
/**
* 회원 작업 메뉴를 토글한다.
* @returns {void}
*/
const toggleActionMenu = () => {
actionMenuOpen.value = !actionMenuOpen.value
}
/**
* 회원 작업 메뉴를 닫는다.
* @returns {void}
*/
const closeActionMenu = () => {
actionMenuOpen.value = false
}
/**
* 비밀번호 변경 모달을 연다.
* @returns {void}
*/
const openPasswordModal = () => {
passwordForm.password = ''
passwordForm.passwordConfirm = ''
actionMessage.value = ''
actionError.value = ''
passwordModalOpen.value = true
closeActionMenu()
}
/**
* 회원 삭제 모달을 연다.
* @returns {void}
*/
const openDeleteModal = () => {
deleteForm.confirmText = ''
actionMessage.value = ''
actionError.value = ''
deleteModalOpen.value = true
closeActionMenu()
}
/**
* 비밀번호 변경 모달을 닫는다.
* @returns {void}
*/
const closePasswordModal = () => {
if (isUpdatingPassword.value) {
return
}
passwordModalOpen.value = false
}
/**
* 회원 삭제 모달을 닫는다.
* @returns {void}
*/
const closeDeleteModal = () => {
if (isDeletingMember.value) {
return
}
deleteModalOpen.value = false
}
/**
* 관리자 권한으로 회원 비밀번호를 변경한다.
* @returns {Promise<void>}
*/
const updateMemberPassword = async () => {
actionMessage.value = ''
actionError.value = ''
if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) {
actionError.value = '새 비밀번호는 8~32자로 입력해 주세요.'
return
}
if (passwordForm.password !== passwordForm.passwordConfirm) {
actionError.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
return
}
isUpdatingPassword.value = true
try {
await $fetch(`/admin/api/members/${props.member.id}/password`, {
method: 'PUT',
body: {
password: passwordForm.password
}
})
passwordModalOpen.value = false
passwordForm.password = ''
passwordForm.passwordConfirm = ''
actionMessage.value = '비밀번호가 변경되었습니다.'
} catch (error) {
actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.'
} finally {
isUpdatingPassword.value = false
}
}
/**
* 관리자 권한으로 회원을 삭제한다.
* @returns {Promise<void>}
*/
const deleteMember = async () => {
actionMessage.value = ''
actionError.value = ''
if (deleteForm.confirmText !== form.email) {
actionError.value = '삭제하려면 회원 이메일을 정확히 입력해 주세요.'
return
}
isDeletingMember.value = true
try {
await $fetch(`/admin/api/members/${props.member.id}`, {
method: 'DELETE'
})
emit('deleted')
} catch (error) {
actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.'
} finally {
isDeletingMember.value = false
}
}
/**
* 회원 기본 정보를 저장한다.
* @returns {Promise<void>}
@@ -258,7 +402,23 @@ watch(() => props.member, () => {
</h1>
</div>
<div class="admin-member-form__actions flex items-center gap-3">
<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]" type="button" aria-label="멤버 작업">
<div v-if="!isNewMember" 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" />
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</button>
<div v-if="actionMenuOpen" class="admin-member-form__action-popover absolute right-0 top-12 z-20 grid w-52 overflow-hidden rounded-xl border border-line bg-white py-2 text-sm text-[#3f4650] shadow-[0_16px_44px_rgba(15,23,42,0.16)]">
<button class="admin-member-form__action-item px-4 py-2.5 text-left hover:bg-[#f3f5f7]" type="button" @click="openPasswordModal">
비밀번호 변경
</button>
<button class="admin-member-form__action-item px-4 py-2.5 text-left text-[#d21a26] hover:bg-[#fff1f2]" type="button" @click="openDeleteModal">
멤버 삭제
</button>
</div>
</div>
<button v-else class="admin-member-form__action-icon flex h-11 w-11 items-center justify-center rounded-md border border-line bg-white text-[#3f4650]" type="button" aria-label="멤버 작업" disabled>
<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" />
<circle cx="12" cy="12.001" r="4.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
@@ -378,6 +538,8 @@ watch(() => props.member, () => {
<p v-if="saveMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ saveMessage }}</p>
<p v-if="saveError" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ saveError }}</p>
<p v-if="actionMessage" class="admin-member-form__message mt-5 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">{{ actionMessage }}</p>
<p v-if="actionError && !passwordModalOpen && !deleteModalOpen" class="admin-member-form__error mt-5 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
</form>
<section v-if="!isNewMember" class="admin-member-form__activity">
@@ -413,5 +575,72 @@ watch(() => props.member, () => {
@stay="stayOnUnsavedPage"
@leave="leaveUnsavedPage"
/>
<Teleport to="body">
<div v-if="passwordModalOpen" 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="closePasswordModal">
<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]">
이메일 전송이 불가능한 상황을 대비해 관리자가 직접 비밀번호를 설정합니다.
</p>
<label class="grid gap-2 text-sm font-semibold">
비밀번호
<input v-model="passwordForm.password" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
</label>
<label class="grid gap-2 text-sm font-semibold">
비밀번호 확인
<input v-model="passwordForm.passwordConfirm" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="password" autocomplete="new-password">
</label>
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</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="closePasswordModal">
취소
</button>
<button class="h-10 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isUpdatingPassword" @click="updateMemberPassword">
{{ isUpdatingPassword ? '변경 ' : '변경' }}
</button>
</footer>
</section>
</div>
</Teleport>
<Teleport to="body">
<div v-if="deleteModalOpen" 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="closeDeleteModal">
<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.email }}</strong> 입력해 주세요.
</p>
<input v-model="deleteForm.confirmText" class="h-11 rounded-md border border-transparent bg-[#eef1f4] px-4 text-sm outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" type="text" autocomplete="off">
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</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="closeDeleteModal">
취소
</button>
<button class="h-10 rounded-md bg-[#d21a26] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isDeletingMember" @click="deleteMember">
{{ isDeletingMember ? '삭제 ' : '삭제' }}
</button>
</footer>
</section>
</div>
</Teleport>
</section>
</template>