릴리스: v1.3.11 회원 관리 모달과 최고 관리자 보호

This commit is contained in:
2026-04-01 10:53:14 +09:00
parent 7b1ba19572
commit 695c0bd4dd
7 changed files with 327 additions and 111 deletions

View File

@@ -62,7 +62,8 @@ export const api = {
approveAdminTemplateRequest: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),
listAdminUsers: () => request('/api/admin/users'),
listAdminUsers: ({ q = '', sort = 'recent' } = {}) =>
request(`/api/admin/users?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),
updateAdminUser: (userId, payload) =>
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
updateAdminUserPassword: (userId, payload) =>

View File

@@ -47,6 +47,7 @@ const importModalNewGameId = ref('')
const importModalNewGameName = ref('')
const previewModalOpen = ref(false)
const previewTierList = ref(null)
const userEditModalOpen = ref(false)
const userPasswordModalOpen = ref(false)
const userDeleteModalOpen = ref(false)
const userRoleModalOpen = ref(false)
@@ -55,9 +56,14 @@ const customItemDeleteModalOpen = ref(false)
const modalTargetUser = ref(null)
const modalPasswordDraft = ref('')
const modalRoleNextAdmin = ref(false)
const modalUserDraftEmail = ref('')
const modalUserDraftNickname = ref('')
const modalUserDraftIsAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const users = ref([])
const userQuery = ref('')
const userSort = ref('recent')
const imageStats = ref(null)
const imageQueue = ref({ concurrency: 1, activeCount: 0, pendingCount: 0 })
const imageRecentJobs = ref([])
@@ -128,7 +134,7 @@ const adminOverviewStats = computed(() => {
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
const pendingRequests = templateRequests.value.length
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
const adminCount = users.value.filter((user) => user.isAdmin || user.draftIsAdmin).length
const adminCount = users.value.filter((user) => user.isAdmin).length
if (activeTab.value === 'featured') {
return [
@@ -351,9 +357,25 @@ function setUserAvatarInput(userId, el) {
userAvatarInputs.value[userId] = el
}
function isUserDirty(user) {
if (!user) return false
return user.draftEmail !== user.email || (user.draftNickname || '') !== (user.nickname || '') || !!user.draftIsAdmin !== !!user.isAdmin
const canManageModalRole = computed(() => {
if (!auth.user?.isPrimaryAdmin) return false
if (!modalTargetUser.value) return false
return !modalTargetUser.value.isPrimaryAdmin
})
const isUserEditDirty = computed(() => {
if (!modalTargetUser.value) return false
return (
modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') ||
modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') ||
!!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin
)
})
function roleLabelOf(user) {
if (user?.isPrimaryAdmin) return '최고 관리자'
if (user?.isAdmin) return '운영자'
return '일반 회원'
}
function openUserAvatarPicker(user) {
@@ -372,17 +394,14 @@ async function uploadUserAvatar(user, file, { remove = false } = {}) {
entry.id === updated.id
? {
...entry,
avatarSrc: updated.avatarSrc || '',
email: updated.email,
nickname: updated.nickname || '',
isAdmin: !!updated.isAdmin,
draftEmail: updated.email,
draftNickname: updated.nickname || '',
draftIsAdmin: !!updated.isAdmin,
...updated,
isAvatarBusy: false,
}
: entry
)
if (modalTargetUser.value?.id === updated.id) {
modalTargetUser.value = { ...modalTargetUser.value, ...updated }
}
if (updated.id === auth.user?.id) await auth.refresh()
await refreshUsers()
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
@@ -496,12 +515,9 @@ async function refreshTemplateRequests() {
async function refreshUsers() {
if (!auth.user?.isAdmin) return
try {
const data = await api.listAdminUsers()
const data = await api.listAdminUsers({ q: userQuery.value, sort: userSort.value })
users.value = (data.users || []).map((user) => ({
...user,
draftEmail: user.email,
draftNickname: user.nickname || '',
draftIsAdmin: !!user.isAdmin,
isAvatarBusy: false,
}))
} catch (e) {
@@ -778,29 +794,45 @@ async function removeGame() {
}
}
async function saveUser(user) {
function openUserEditModal(user) {
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalUserDraftEmail.value = user?.email || ''
modalUserDraftNickname.value = user?.nickname || ''
modalUserDraftIsAdmin.value = !!user?.isAdmin
userEditModalOpen.value = true
}
function closeUserEditModal() {
userEditModalOpen.value = false
modalTargetUser.value = null
modalUserDraftEmail.value = ''
modalUserDraftNickname.value = ''
modalUserDraftIsAdmin.value = false
}
async function saveUserEdit() {
resetMessages()
if (!modalTargetUser.value?.id) return
try {
const data = await api.updateAdminUser(user.id, {
email: user.draftEmail,
nickname: user.draftNickname,
isAdmin: !!user.draftIsAdmin,
const data = await api.updateAdminUser(modalTargetUser.value.id, {
email: modalUserDraftEmail.value.trim(),
nickname: modalUserDraftNickname.value.trim(),
isAdmin: !!modalUserDraftIsAdmin.value,
})
const updated = data.user
users.value = users.value.map((entry) =>
entry.id === updated.id
? {
...entry,
email: updated.email,
nickname: updated.nickname || '',
isAdmin: !!updated.isAdmin,
draftEmail: updated.email,
draftNickname: updated.nickname || '',
draftIsAdmin: !!updated.isAdmin,
...updated,
isAvatarBusy: entry.isAvatarBusy || false,
}
: entry
)
if (updated.id === auth.user?.id) await auth.refresh()
closeUserEditModal()
await refreshUsers()
success.value = '회원 정보를 저장했어요.'
} catch (e) {
@@ -810,7 +842,7 @@ async function saveUser(user) {
function openUserPasswordModal(user) {
resetMessages()
modalTargetUser.value = user || null
modalTargetUser.value = user ? { ...user } : null
modalPasswordDraft.value = ''
userPasswordModalOpen.value = true
}
@@ -842,7 +874,7 @@ async function confirmUserPasswordReset() {
function openUserDeleteModal(user) {
resetMessages()
modalTargetUser.value = user || null
modalTargetUser.value = user ? { ...user } : null
userDeleteModalOpen.value = true
}
@@ -869,34 +901,31 @@ async function confirmUserDelete() {
}
function openUserRoleModal(user) {
function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) {
resetMessages()
modalTargetUser.value = user || null
modalRoleNextAdmin.value = !user?.draftIsAdmin
modalTargetUser.value = user ? { ...user } : null
modalRoleNextAdmin.value = !!nextIsAdmin
userRoleModalOpen.value = true
}
function closeUserRoleModal() {
userRoleModalOpen.value = false
modalTargetUser.value = null
if (!userEditModalOpen.value) modalTargetUser.value = null
modalRoleNextAdmin.value = false
}
function confirmUserRoleDraft() {
if (!modalTargetUser.value?.id) return
users.value = users.value.map((entry) =>
entry.id === modalTargetUser.value.id
? {
...entry,
draftIsAdmin: modalRoleNextAdmin.value,
}
: entry
)
const targetLabel = modalRoleNextAdmin.value ? '관리자로 지정했어요. 저장하면 반영됩니다.' : '관리자 권한 해제로 표시했어요. 저장하면 반영됩니다.'
modalUserDraftIsAdmin.value = modalRoleNextAdmin.value
const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.'
closeUserRoleModal()
success.value = targetLabel
}
function submitUserFilters() {
refreshUsers()
}
function submitCustomItemSearch() {
customItemPage.value = 1
refreshCustomItems()
@@ -1548,6 +1577,21 @@ async function saveFeaturedOrder() {
</div>
</div>
<div class="toolbar toolbar--secondary">
<input
v-model="userQuery"
class="input toolbar__search"
placeholder="이메일, 닉네임 검색"
@keydown.enter.prevent="submitUserFilters"
/>
<select v-model="userSort" class="select toolbar__select" @change="submitUserFilters">
<option value="recent">최근 활동순</option>
<option value="created">가입순</option>
<option value="tierlists">작성 티어표 많은 </option>
</select>
<button class="btn btn--ghost toolbar__button" type="button" @click="submitUserFilters">조회</button>
</div>
<div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
<div v-else class="userList">
<article v-for="user in users" :key="user.id" class="userCard">
@@ -1584,25 +1628,17 @@ async function saveFeaturedOrder() {
</div>
</div>
<div v-if="user.draftIsAdmin" class="roleBadge userCard__roleBadge">Administrator</div>
<div v-if="user.isAdmin" class="roleBadge userCard__roleBadge">{{ roleLabelOf(user) }}</div>
<div class="userInfoList">
<div class="userInfoLine"><span>가입일</span><strong>{{ fmt(user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}</strong></div>
<div class="userInfoLine"><span>최근 활동</span><strong>{{ fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
<div class="userInfoLine"><span>권한</span><strong>{{ roleLabelOf(user) }}</strong></div>
</div>
<input v-model="user.draftEmail" class="input" placeholder="이메일" />
<input v-model="user.draftNickname" class="input" placeholder="닉네임" />
<button
class="userRoleAction"
type="button"
:disabled="user.id === auth.user?.id"
@click="openUserRoleModal(user)"
>
{{ user.draftIsAdmin ? '관리자 권한 해제' : '관리자 권한 임명' }}
</button>
<div class="userCard__actions userCard__actions--compact">
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)">
<SvgIcon class="iconActionButton__icon" :src="lockResetIcon" :size="18" />
@@ -1610,7 +1646,7 @@ async function saveFeaturedOrder() {
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="openUserDeleteModal(user)">
<SvgIcon class="iconActionButton__icon" :src="deleteIcon" :size="18" />
</button>
<button class="btn btn--ghost userSaveButton" :disabled="!isUserDirty(user)" @click="saveUser(user)">회원정보 저장</button>
<button class="btn btn--ghost userSaveButton" type="button" @click="openUserEditModal(user)">회원 정보 수정</button>
</div>
</article>
</div>
@@ -1632,6 +1668,39 @@ async function saveFeaturedOrder() {
</div>
</div>
<div v-if="userEditModalOpen" class="modalOverlay" @click.self="closeUserEditModal">
<div class="modalCard modalCard--userEdit" role="dialog" aria-modal="true">
<div class="modalCard__title">회원 정보 수정</div>
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정의 정보와 권한을 조정할 수 있어요.` : '' }}</div>
<div class="userEditForm">
<label class="field">
<span class="field__label">이메일</span>
<input v-model="modalUserDraftEmail" class="field__input" placeholder="계정 이메일" />
<span class="field__hint">로그인 계정으로 사용하는 이메일입니다.</span>
</label>
<label class="field">
<span class="field__label">닉네임</span>
<input v-model="modalUserDraftNickname" class="field__input" placeholder="표시용 닉네임" />
<span class="field__hint">티어표 작성자명과 프로필에 표시됩니다.</span>
</label>
<button
v-if="canManageModalRole"
class="userRoleAction"
type="button"
@click="openUserRoleModal(modalTargetUser, !modalUserDraftIsAdmin)"
>
{{ modalUserDraftIsAdmin ? '운영자 권한 해제' : '운영자 권한 부여' }}
</button>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeUserEditModal">취소</button>
<button class="btn btn--primary" :disabled="!isUserEditDirty" @click="saveUserEdit">회원 정보 저장</button>
</div>
</div>
</div>
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">비밀번호 초기화</div>
@@ -1659,13 +1728,13 @@ async function saveFeaturedOrder() {
<div v-if="userRoleModalOpen" class="modalOverlay" @click.self="closeUserRoleModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">관리 권한 변경</div>
<div class="modalCard__title">운영 권한 변경</div>
<div class="modalCard__desc">
{{
modalTargetUser
? modalRoleNextAdmin
? `${userDisplayName(modalTargetUser)} 사용자를 관리자로 임명할까요?`
: `${userDisplayName(modalTargetUser)} 사용자의 관리자 권한을 해제할까요?`
? `${userDisplayName(modalTargetUser)} 사용자를 운영자로 지정할까요?`
: `${userDisplayName(modalTargetUser)} 사용자의 운영자 권한을 해제할까요?`
: ''
}}
</div>
@@ -2995,6 +3064,44 @@ async function saveFeaturedOrder() {
font-size: 14px;
font-weight: 900;
}
.field {
display: grid;
gap: 8px;
}
.field__label {
font-size: 13px;
color: rgba(255, 255, 255, 0.62);
}
.field__input {
width: 100%;
padding: 14px 0;
border: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: transparent;
color: rgba(255, 255, 255, 0.94);
outline: none;
font-size: 18px;
letter-spacing: -0.02em;
}
.field__input:focus {
border-bottom-color: rgba(96, 165, 250, 0.9);
}
.field__hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
}
.userEditForm {
display: grid;
gap: 18px;
}
.userEditForm .field {
gap: 10px;
}
.modalCard--userEdit {
max-width: 520px;
}
.userCard__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));