관리자 멤버 상세와 추가 화면 구현

This commit is contained in:
2026-05-13 11:18:06 +09:00
parent b4f3fdb77d
commit fb0dadb7b9
16 changed files with 778 additions and 36 deletions

View File

@@ -0,0 +1,319 @@
<script setup>
const props = defineProps({
member: {
type: Object,
default: () => null
},
mode: {
type: String,
default: 'edit'
}
})
const emit = defineEmits(['saved'])
const isNewMember = computed(() => props.mode === 'new')
const saveMessage = ref('')
const saveError = ref('')
const isSaving = ref(false)
const form = reactive({
username: '',
email: '',
avatarUrl: '',
labelsText: '',
note: ''
})
/**
* 회원 폼 값을 현재 회원 정보로 동기화한다.
* @returns {void}
*/
const syncMemberForm = () => {
const member = props.member || {}
form.username = member.username || ''
form.email = member.email || ''
form.avatarUrl = member.avatarUrl || ''
form.labelsText = Array.isArray(member.labels) ? member.labels.join(', ') : ''
form.note = member.note || ''
}
watch(() => props.member, syncMemberForm, { immediate: true })
const pageTitle = computed(() => {
if (isNewMember.value) {
return '새 멤버'
}
return form.username || props.member?.email || '멤버'
})
const memberInitial = computed(() => String(form.username || form.email || '?').slice(0, 1).toUpperCase())
const noteLength = computed(() => form.note.length)
const normalizedLabels = computed(() => [...new Set(
form.labelsText
.split(',')
.map((label) => label.trim())
.filter(Boolean)
)])
/**
* 날짜 표시 형식 변환
* @param {string | null} value - ISO 날짜 문자열
* @returns {string} 화면 표시 날짜
*/
const formatDate = (value) => {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}.${month}.${day}`
}
/**
* 최근 활동 시각을 상대 시간으로 표시한다.
* @param {string | null} value - ISO 시각
* @returns {string} 상대 시간
*/
const formatRelativeTime = (value) => {
if (!value) {
return '최근 활동 없음'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '최근 활동 없음'
}
const diffMs = Date.now() - date.getTime()
const minute = 1000 * 60
const hour = minute * 60
const day = hour * 24
if (diffMs < minute) {
return '방금 전'
}
if (diffMs < hour) {
return `${Math.floor(diffMs / minute)}분 전`
}
if (diffMs < day) {
return `${Math.floor(diffMs / hour)}시간 전`
}
if (diffMs < day * 30) {
return `${Math.floor(diffMs / day)}일 전`
}
return formatDate(value)
}
/**
* 회원 저장 요청 본문을 만든다.
* @returns {{ username: string, email: string, avatarUrl: string, labels: string[], note: string }} 저장 본문
*/
const getMemberPayload = () => ({
username: form.username.trim(),
email: form.email.trim(),
avatarUrl: form.avatarUrl.trim(),
labels: normalizedLabels.value,
note: form.note
})
/**
* 회원 기본 정보를 저장한다.
* @returns {Promise<void>}
*/
const saveMember = async () => {
if (isSaving.value) {
return
}
saveMessage.value = ''
saveError.value = ''
isSaving.value = true
try {
const payload = getMemberPayload()
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
})
emit('saved', saved)
saveMessage.value = '저장되었습니다.'
} catch (error) {
saveError.value = error?.data?.message || '저장에 실패했습니다.'
} finally {
isSaving.value = false
}
}
</script>
<template>
<section class="admin-member-form bg-paper p-6">
<div class="admin-member-form__header sticky top-0 z-10 -mx-6 -mt-6 border-b border-line bg-paper/95 px-6 py-5 backdrop-blur">
<div class="admin-member-form__header-inner flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div class="admin-member-form__title-block">
<div class="admin-member-form__breadcrumb flex items-center gap-2 text-sm text-[#8a95a5]">
<NuxtLink class="admin-member-form__breadcrumb-link text-[#3f4650] hover:text-[#15171a]" to="/admin/members">
멤버
</NuxtLink>
<svg class="h-3 w-3" viewBox="0 0 18 27" aria-hidden="true">
<path d="M2.397 25.426l13.143-11.5-13.143-11.5" stroke-width="3" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span>{{ isNewMember ? '새 멤버' : '멤버 편집' }}</span>
</div>
<h1 class="admin-member-form__title mt-4 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
{{ pageTitle }}
</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="멤버 작업">
<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>
<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:opacity-50" type="button" :disabled="isSaving" @click="saveMember">
{{ isSaving ? '저장 ' : '저장' }}
</button>
</div>
</div>
</div>
<div class="admin-member-form__body grid gap-8 py-8 xl:grid-cols-[280px_minmax(0,1fr)]">
<aside class="admin-member-form__summary">
<div class="admin-member-form__identity flex items-center gap-4">
<img
v-if="form.avatarUrl"
class="admin-member-form__avatar h-20 w-20 rounded-full object-cover"
:src="form.avatarUrl"
:alt="pageTitle"
>
<span v-else class="admin-member-form__avatar flex h-20 w-20 items-center justify-center rounded-full bg-[#15171a] text-2xl font-semibold text-white">
{{ memberInitial }}
</span>
<div class="min-w-0">
<h2 class="truncate text-lg font-semibold text-[#15171a]">{{ pageTitle }}</h2>
<p class="mt-1 truncate text-sm text-[#657080]">{{ form.email || '이메일 없음' }}</p>
</div>
</div>
<div v-if="!isNewMember" class="admin-member-form__meta mt-10 space-y-3 text-sm text-[#4d5663]">
<p class="flex items-center gap-2">
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 26" aria-hidden="true">
<path d="M12 14.75a4 4 0 100-8 4 4 0 000 8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M21 10.75c0 7.9-6.932 12.331-8.629 13.3a.751.751 0 01-.743 0C9.931 23.08 3 18.648 3 10.75a9 9 0 1118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{{ member?.lastSeenIp || '접속 IP 없음' }}
</p>
<p class="flex items-center gap-2">
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 26 24" aria-hidden="true">
<path d="M13 5.001c-4.03-.078-8.2 3.157-10.82 6.47-.276.35-.428.805-.428 1.277 0 .472.152.928.427 1.278C4.743 17.27 8.9 20.578 13 20.5c4.1.079 8.258-3.23 10.824-6.473.275-.35.428-.806.428-1.278s-.153-.927-.428-1.278C21.2 8.158 17.031 4.923 13 5.001z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M16.75 12.751a3.75 3.75 0 11-7.5-.002 3.75 3.75 0 017.5.002z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{{ formatRelativeTime(member?.lastSeenAt) }}
</p>
</div>
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">가입 정보</h3>
<p class="mt-5 flex items-center gap-2 text-sm text-[#4d5663]">
<svg class="h-4 w-4 text-[#8a95a5]" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<path d="M11.5 12c-2.824 0-2.83.024-4.5.53-3.5 1.058-5 3.176-5 6.386V21h10m7-5v6m-3-3h6m-10.5-7a5.5 5.5 0 100-11 5.5 5.5 0 000 11z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
생성됨 <strong>{{ formatDate(member?.createdAt) }}</strong>
</p>
</div>
<div v-if="!isNewMember" class="admin-member-form__side-section mt-12 border-t border-line pt-6">
<h3 class="text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">참여도</h3>
<p class="mt-5 text-sm leading-6 text-[#8a95a5]">
댓글 작성 {{ member?.commentCount || 0 }}
</p>
</div>
</aside>
<div class="admin-member-form__content space-y-8">
<form class="admin-member-form__card rounded-xl border border-line bg-white p-5 md:p-6" @submit.prevent="saveMember">
<div class="grid gap-5 md:grid-cols-2">
<label class="admin-member-form__field block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이름</span>
<input v-model="form.username" class="admin-member-form__input 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]" type="text" maxlength="60" required>
</label>
<label class="admin-member-form__field block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">이메일</span>
<input v-model="form.email" class="admin-member-form__input 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]" type="email" maxlength="254" required>
</label>
</div>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">썸네일 URL</span>
<input v-model="form.avatarUrl" class="admin-member-form__input 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]" type="text" maxlength="500" placeholder="/uploads/members/avatars/...">
</label>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">레이블</span>
<input v-model="form.labelsText" class="admin-member-form__input 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]" type="text" placeholder="쉼표로 구분">
</label>
<label class="admin-member-form__field mt-5 block">
<span class="admin-member-form__label mb-2 block text-sm font-semibold text-[#15171a]">
노트 <span class="font-normal text-[#657080]">(멤버에게 보이지 않음)</span>
</span>
<textarea v-model="form.note" class="admin-member-form__textarea min-h-32 w-full resize-y rounded-md border border-transparent bg-[#eef1f4] px-4 py-3 text-sm text-[#15171a] outline-none focus:border-[#c5ccd5] focus:bg-white focus:ring-2 focus:ring-[#dce2e8]" maxlength="500" />
<span class="admin-member-form__count mt-2 block text-sm text-[#8a95a5]">
최대 500. 현재 {{ noteLength }}
</span>
</label>
<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>
</form>
<section v-if="!isNewMember" class="admin-member-form__activity">
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 border-b border-line py-5 text-sm last:border-b-0">
<span class="flex items-center gap-3 text-[#3f4650]">
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 12h10.31m-3.076-3.076L14.31 12l-3.076 3.077" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4.998 16.308a7.69 7.69 0 003.733 3.182 7.238 7.238 0 004.8.189 7.608 7.608 0 003.949-2.88A8.283 8.283 0 0018.998 12c0-1.73-.533-3.414-1.518-4.798a7.607 7.607 0 00-3.949-2.88 7.237 7.237 0 00-4.8.188 7.69 7.69 0 00-3.733 3.182" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
</svg>
로그인
</span>
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.lastSeenAt) }}</span>
</div>
<div class="admin-member-form__activity-row flex items-center justify-between gap-4 py-5 text-sm">
<span class="flex items-center gap-3 text-[#3f4650]">
<svg class="h-5 w-5 text-[#6c747d]" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M11.246 12.144a4.242 4.242 0 100-8.484 4.242 4.242 0 000 8.484zM4 18.761a8.484 8.484 0 0110.5-3.42" stroke="currentColor" stroke-width="1.714" stroke-linecap="round" stroke-linejoin="round" />
<path d="M17.54 16.077V23m-3.463-3.46H21" stroke="#30CF43" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
가입
</span>
<span class="text-[#9aa4b2]">{{ formatRelativeTime(member?.createdAt) }}</span>
</div>
</div>
</section>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,5 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS member_labels TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
ALTER TABLE users
ADD COLUMN IF NOT EXISTS member_note TEXT NOT NULL DEFAULT '';

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-05-13 v0.0.111
### 관리자 멤버 상세와 추가 화면 분리
멤버 목록에서 모든 편집 기능을 처리하면 목록의 스캔성이 떨어지고 권한 변경 같은 민감 액션도 너무 쉽게 노출된다. 목록은 관측과 진입에 집중하고, 개별 회원의 이름·이메일·레이블·관리자 노트는 별도 상세 화면에서 저장한다. 레이블은 아직 공개 기능에 쓰지 않지만 이후 사용자별 칭호나 분류로 확장할 수 있도록 배열 컬럼으로 두고, 신규 회원은 활동 이력이 없으므로 활동 섹션을 렌더링하지 않는다.
## 2026-05-13 v0.0.110
### 관리자 멤버 목록 정보 밀도 정리

View File

@@ -60,6 +60,7 @@
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 2열, 기본 정보·레이블·관리자 노트·활동 요약) |
## 콘텐츠 컴포넌트
@@ -103,6 +104,8 @@
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태) |
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장) |
## 공개 페이지
@@ -183,6 +186,9 @@
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
| server/routes/admin/api/members.post.js | 관리자 멤버 생성 API |
| server/routes/admin/api/members/[id].get.js | 관리자 멤버 상세 API |
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API |
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API |
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |

View File

@@ -415,6 +415,9 @@ components/content/
- `GET /admin/api/navigation` - 네비게이션 항목 목록
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
@@ -551,8 +554,10 @@ components/content/
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다.
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
- 관리자 멤버 목록은 멤버 검색과 멤버 추가 버튼을 제공한다. 멤버 추가 버튼의 실제 생성 화면 연결은 후속 작업에서 처리한다.
- 관리자 멤버 목록은 멤버 검색과 멤버 추가 버튼을 제공한다.
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다.
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다.
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.

View File

@@ -7,6 +7,7 @@
## 2차 관리자 개발
- [ ] 미디어 라이브러리 프로필/사이트 설정 이미지 사용처 추적
- [ ] 관리자 멤버 추가 후 초대 메일 또는 비밀번호 설정 안내 플로우 연결
## 프론트엔드 개발

View File

@@ -1,5 +1,15 @@
# 업데이트 이력
## v0.0.111
- 관리자 멤버 상세 화면(`/admin/members/:id`) 추가.
- 관리자 멤버 추가 화면(`/admin/members/new`) 추가.
- 멤버 목록 행 클릭 시 상세 화면으로 이동하도록 수정.
- 멤버 기본 정보 저장 API(`GET/PUT /admin/api/members/:id`, `POST /admin/api/members`) 추가.
- 회원 레이블·관리자 노트 저장 컬럼 마이그레이션(`020_add_member_admin_fields.sql`) 추가.
- 멤버 폼에 썸네일 URL, 이름, 이메일, 레이블, 관리자 노트, 기존 회원 활동 요약 표시.
- 패키지 버전 `0.0.111`로 갱신.
## v0.0.110
- 관리자 멤버 목록을 Ghost형 테이블 구조로 재정리.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -0,0 +1,35 @@
<script setup>
definePageMeta({
layout: 'admin'
})
const route = useRoute()
const memberId = computed(() => String(route.params.id || ''))
const { data: member, error } = await useFetch(() => `/admin/api/members/${memberId.value}`, {
default: () => null
})
/**
* 저장된 회원 정보로 화면 상태를 갱신한다.
* @param {Object} savedMember - 저장된 회원
* @returns {void}
*/
const handleMemberSaved = (savedMember) => {
member.value = savedMember
}
</script>
<template>
<AdminMemberForm
v-if="member"
:member="member"
mode="edit"
@saved="handleMemberSaved"
/>
<section v-else class="admin-member-detail bg-paper p-6">
<div class="rounded-xl border border-line bg-white px-5 py-8 text-sm text-muted">
{{ error?.data?.message || '회원 정보를 불러올 수 없습니다.' }}
</div>
</section>
</template>

View File

@@ -29,6 +29,19 @@ const memberCountLabel = computed(() => {
return `${count}`
})
/**
* 회원 상세 화면으로 이동한다.
* @param {Object} member - 회원 정보
* @returns {Promise<void>} 이동 처리
*/
const navigateToMember = async (member) => {
if (!member?.id) {
return
}
await navigateTo(`/admin/members/${member.id}`)
}
/**
* 회원 이니셜을 반환한다.
* @param {Object} member - 회원 정보
@@ -100,9 +113,12 @@ const formatRelativeTime = (value) => {
<template>
<section class="admin-members bg-paper p-6">
<div class="admin-members__header flex flex-col gap-5 border-b border-line pb-6 xl:flex-row xl:items-center xl:justify-between">
<div class="admin-members__header flex flex-col gap-5 pb-6 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 class="admin-members__title text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
<p class="admin-members__eyebrow text-xs font-semibold uppercase text-muted">
Members
</p>
<h1 class="admin-members__title mt-2 text-3xl font-semibold tracking-[-0.01em] text-[#15171a]">
멤버
</h1>
</div>
@@ -120,12 +136,12 @@ const formatRelativeTime = (value) => {
aria-label="멤버 검색"
>
</label>
<button
class="admin-members__new h-11 rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-[#2b2f35] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-2"
type="button"
<NuxtLink
class="admin-members__new inline-flex h-11 items-center rounded-md bg-[#15171a] px-4 text-sm font-semibold text-white transition hover:bg-[#2b2f35] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-2"
to="/admin/members/new"
>
멤버 추가
</button>
</NuxtLink>
</div>
</div>
@@ -141,7 +157,14 @@ const formatRelativeTime = (value) => {
</tr>
</thead>
<tbody class="admin-members__table-body divide-y divide-line/70 bg-paper">
<tr v-for="member in filteredMembers" :key="member.id" class="admin-members__row align-middle transition-colors hover:bg-[#f7f8fa]">
<tr
v-for="member in filteredMembers"
:key="member.id"
class="admin-members__row cursor-pointer align-middle transition-colors hover:bg-[#f7f8fa] focus-within:bg-[#f7f8fa]"
tabindex="0"
@click="navigateToMember(member)"
@keydown.enter.prevent="navigateToMember(member)"
>
<td class="admin-members__cell py-5 pr-5">
<div class="admin-members__profile flex min-w-0 items-center gap-3">
<img

View File

@@ -0,0 +1,22 @@
<script setup>
definePageMeta({
layout: 'admin'
})
/**
* 새 회원 저장 후 상세 화면으로 이동한다.
* @param {Object} member - 저장된 회원
* @returns {Promise<void>} 이동 처리
*/
const handleMemberSaved = async (member) => {
if (!member?.id) {
return
}
await navigateTo(`/admin/members/${member.id}`)
}
</script>
<template>
<AdminMemberForm mode="new" @saved="handleMemberSaved" />
</template>

View File

@@ -9,6 +9,48 @@ export const MEMBER_ROLE = {
const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
/**
* 회원 권한 표시 문자열을 반환한다.
* @param {string} roleCode - 권한 코드
* @returns {string} 권한 표시 문자열
*/
const getMemberRoleLabel = (roleCode) => roleCode === MEMBER_ROLE.OWNER
? '소유자'
: roleCode === MEMBER_ROLE.ADMIN
? '관리자'
: '멤버'
/**
* 관리자 회원 행을 응답 객체로 변환한다.
* @param {Object} row - DB 회원 행
* @returns {Object} 관리자 회원 응답
*/
const mapAdminMemberRow = (row) => {
const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null
const isActive = row.lastSeenAt
? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30
: false
const roleCode = String(row.roleCode || MEMBER_ROLE.MEMBER)
return {
id: row.id,
username: row.username,
email: row.email,
avatarUrl: row.avatarUrl || '',
labels: Array.isArray(row.labels) ? row.labels : [],
note: row.note || '',
isAdmin: Boolean(row.isAdmin),
roleCode,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
lastSeenAt,
lastSeenIp: row.lastSeenIp || '',
commentCount: Number(row.commentCount || 0),
activityStatus: isActive ? '활성' : '비활성',
role: getMemberRoleLabel(roleCode)
}
}
/**
* @typedef {Object} MemberUser
* @property {string} id - 사용자 ID
@@ -306,6 +348,31 @@ export const isUsernameTaken = async (input) => {
return Boolean(rows?.[0])
}
/**
* 이메일 중복 확인
* @param {{ email: string, excludeUserId?: string }} input - 이메일과 제외 사용자 ID
* @returns {Promise<boolean>} 중복 여부
*/
export const isEmailTaken = async (input) => {
const sql = requireSql()
const rows = input.excludeUserId
? await sql`
SELECT id
FROM users
WHERE lower(email) = lower(${input.email})
AND id <> ${input.excludeUserId}
LIMIT 1
`
: await sql`
SELECT id
FROM users
WHERE lower(email) = lower(${input.email})
LIMIT 1
`
return Boolean(rows?.[0])
}
/**
* 관리자용 회원 목록 조회(댓글 활동 포함)
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean, roleCode: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, commentCount: number, activityStatus: string, role: string }>>} 회원 목록
@@ -318,6 +385,8 @@ export const listMembersForAdmin = async () => {
users.username,
users.email,
users.avatar_url AS "avatarUrl",
users.member_labels AS "labels",
users.member_note AS "note",
users.is_admin AS "isAdmin",
users.user_role AS "roleCode",
users.created_at AS "createdAt",
@@ -331,32 +400,104 @@ export const listMembersForAdmin = async () => {
ORDER BY users.created_at DESC
`
return rows.map((row) => {
const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null
const isActive = row.lastSeenAt
? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30
: false
return rows.map(mapAdminMemberRow)
}
return {
id: row.id,
username: row.username,
email: row.email,
avatarUrl: row.avatarUrl || '',
isAdmin: Boolean(row.isAdmin),
roleCode: String(row.roleCode || MEMBER_ROLE.MEMBER),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
lastSeenAt,
lastSeenIp: row.lastSeenIp || '',
commentCount: Number(row.commentCount || 0),
activityStatus: isActive ? '활성' : '비활성',
role: row.roleCode === MEMBER_ROLE.OWNER
? '소유자'
: row.roleCode === MEMBER_ROLE.ADMIN
? '관리자'
: '멤버'
}
})
/**
* 관리자용 회원 상세 조회
* @param {string} memberId - 회원 ID
* @returns {Promise<Object | null>} 회원 상세
*/
export const getMemberForAdmin = async (memberId) => {
const sql = requireSql()
const rows = await sql`
SELECT
users.id,
users.username,
users.email,
users.avatar_url AS "avatarUrl",
users.member_labels AS "labels",
users.member_note AS "note",
users.is_admin AS "isAdmin",
users.user_role AS "roleCode",
users.created_at AS "createdAt",
users.updated_at AS "updatedAt",
users.last_seen_at AS "lastSeenAt",
users.last_seen_ip AS "lastSeenIp",
COALESCE(count(comments.id), 0)::int AS "commentCount"
FROM users
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
WHERE users.id = ${memberId}
GROUP BY users.id
LIMIT 1
`
return rows?.[0] ? mapAdminMemberRow(rows[0]) : null
}
/**
* 관리자 화면에서 회원을 생성한다.
* @param {{ username: string, email: string, passwordHash: string, avatarUrl: string, labels: string[], note: string }} input - 생성 값
* @returns {Promise<Object>} 생성된 회원
*/
export const createMemberByAdmin = async (input) => {
const sql = requireSql()
const rows = await sql`
INSERT INTO users (
username,
email,
password_hash,
avatar_url,
member_labels,
member_note,
is_admin,
user_role
)
VALUES (
${input.username},
${input.email},
${input.passwordHash},
${input.avatarUrl},
${input.labels},
${input.note},
false,
${MEMBER_ROLE.MEMBER}
)
RETURNING id
`
const created = rows?.[0]
if (!created) {
throw createError({
statusCode: 500,
message: '회원 생성에 실패했습니다.'
})
}
return getMemberForAdmin(created.id)
}
/**
* 관리자 화면에서 회원 기본 정보를 수정한다.
* @param {{ memberId: string, username: string, email: string, avatarUrl: string, labels: string[], note: string }} input - 수정 값
* @returns {Promise<Object | null>} 수정된 회원
*/
export const updateMemberByAdmin = async (input) => {
const sql = requireSql()
const rows = await sql`
UPDATE users
SET
username = ${input.username},
email = ${input.email},
avatar_url = ${input.avatarUrl},
member_labels = ${input.labels},
member_note = ${input.note},
updated_at = now()
WHERE id = ${input.memberId}
RETURNING id
`
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
}
/**

View File

@@ -0,0 +1,59 @@
import { randomBytes } from 'node:crypto'
import bcrypt from 'bcrypt'
import { createError, readBody } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../utils/admin-auth'
import { createMemberByAdmin, isEmailTaken, isUsernameTaken } from '../../../repositories/member-repository'
const memberInputSchema = z.object({
username: z.string().trim().min(1).max(60),
email: z.string().trim().email().max(254),
avatarUrl: z.string().trim().max(500).optional().default(''),
labels: z.array(z.string().trim().min(1).max(40)).max(20).optional().default([]),
note: z.string().max(500).optional().default('')
})
/**
* 관리자 회원 생성 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 생성된 회원
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const parsedBody = memberInputSchema.safeParse(await readBody(event))
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '회원 생성 요청 형식이 올바르지 않습니다.'
})
}
const body = parsedBody.data
const email = body.email.toLowerCase()
if (await isUsernameTaken({ username: body.username })) {
throw createError({
statusCode: 409,
message: '이미 사용 중인 이름입니다.'
})
}
if (await isEmailTaken({ email })) {
throw createError({
statusCode: 409,
message: '이미 사용 중인 이메일입니다.'
})
}
const passwordHash = await bcrypt.hash(randomBytes(32).toString('hex'), 12)
return createMemberByAdmin({
username: body.username,
email,
passwordHash,
avatarUrl: body.avatarUrl,
labels: [...new Set(body.labels)],
note: body.note
})
})

View File

@@ -0,0 +1,30 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { getMemberForAdmin } from '../../../../repositories/member-repository'
/**
* 관리자 회원 상세 조회 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 회원 상세
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const memberId = String(getRouterParam(event, 'id') || '')
if (!memberId) {
throw createError({
statusCode: 400,
message: '회원 ID가 필요합니다.'
})
}
const member = await getMemberForAdmin(memberId)
if (!member) {
throw createError({
statusCode: 404,
message: '회원을 찾을 수 없습니다.'
})
}
return member
})

View File

@@ -0,0 +1,80 @@
import { createError, getRouterParam, readBody } from 'h3'
import { z } from 'zod'
import { requireAdminSession } from '../../../../utils/admin-auth'
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin } from '../../../../repositories/member-repository'
const memberInputSchema = z.object({
username: z.string().trim().min(1).max(60),
email: z.string().trim().email().max(254),
avatarUrl: z.string().trim().max(500).optional().default(''),
labels: z.array(z.string().trim().min(1).max(40)).max(20).optional().default([]),
note: z.string().max(500).optional().default('')
})
/**
* 관리자 회원 기본 정보 수정 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 회원
*/
export default defineEventHandler(async (event) => {
requireAdminSession(event)
const memberId = String(getRouterParam(event, 'id') || '')
const parsedBody = memberInputSchema.safeParse(await readBody(event))
if (!memberId) {
throw createError({
statusCode: 400,
message: '회원 ID가 필요합니다.'
})
}
if (!parsedBody.success) {
throw createError({
statusCode: 400,
message: '회원 수정 요청 형식이 올바르지 않습니다.'
})
}
const existing = await getMemberForAdmin(memberId)
if (!existing) {
throw createError({
statusCode: 404,
message: '회원을 찾을 수 없습니다.'
})
}
const body = parsedBody.data
const email = body.email.toLowerCase()
if (await isUsernameTaken({ username: body.username, excludeUserId: memberId })) {
throw createError({
statusCode: 409,
message: '이미 사용 중인 이름입니다.'
})
}
if (await isEmailTaken({ email, excludeUserId: memberId })) {
throw createError({
statusCode: 409,
message: '이미 사용 중인 이메일입니다.'
})
}
const updated = await updateMemberByAdmin({
memberId,
username: body.username,
email,
avatarUrl: body.avatarUrl,
labels: [...new Set(body.labels)],
note: body.note
})
if (!updated) {
throw createError({
statusCode: 500,
message: '회원 수정에 실패했습니다.'
})
}
return updated
})