태그를 관리용/일반용으로 분리하고 관리자 드래그 정렬을 추가.
댓글/회원/관리자 인증·프로필 흐름 보완과 관련 마이그레이션 및 문서를 함께 반영해 운영 동선을 안정화. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,7 +23,8 @@ const form = reactive({
|
||||
slug: props.initialTag.slug || '',
|
||||
description: props.initialTag.description || '',
|
||||
sortOrder: props.initialTag.sortOrder ?? 0,
|
||||
color: props.initialTag.color || '#15171a'
|
||||
color: props.initialTag.color || '#15171a',
|
||||
tagType: props.initialTag.tagType || 'managed'
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -63,8 +64,9 @@ const submitTag = () => {
|
||||
name: form.name.trim(),
|
||||
slug: toSlug(form.slug || form.name),
|
||||
description: form.description.trim(),
|
||||
sortOrder: Number(form.sortOrder) || 0,
|
||||
color: form.color
|
||||
sortOrder: form.tagType === 'managed' ? Number(form.sortOrder) || 0 : 0,
|
||||
color: form.color,
|
||||
tagType: form.tagType
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -102,6 +104,17 @@ const submitTag = () => {
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">태그 유형</span>
|
||||
<select
|
||||
v-model="form.tagType"
|
||||
class="admin-tag-form__input rounded border border-line bg-white px-3 py-2"
|
||||
>
|
||||
<option value="managed">관리용 태그 (카테고리)</option>
|
||||
<option value="general">일반 태그 (배지)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="admin-tag-form__display grid gap-4 md:grid-cols-2">
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
<span class="admin-tag-form__label font-medium">정렬 순서</span>
|
||||
@@ -111,7 +124,11 @@ const submitTag = () => {
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
:disabled="form.tagType !== 'managed'"
|
||||
>
|
||||
<span v-if="form.tagType !== 'managed'" class="text-xs text-muted">
|
||||
일반 태그는 정렬 순서를 사용하지 않습니다.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="admin-tag-form__field grid gap-2 text-sm">
|
||||
|
||||
@@ -11,14 +11,17 @@ const member = ref(null)
|
||||
const loadingComments = ref(false)
|
||||
const submitting = ref(false)
|
||||
const submittingReplyId = ref('')
|
||||
const likingCommentIds = ref([])
|
||||
const errorMessage = ref('')
|
||||
const replyErrorMessage = ref('')
|
||||
const newCommentBody = ref('')
|
||||
const replyBody = ref('')
|
||||
const activeReplyTargetId = ref('')
|
||||
const sortOption = ref('best')
|
||||
const brokenAvatarCommentIds = ref([])
|
||||
|
||||
/**
|
||||
* ISO 문자열을 표시용 날짜 문자열로 변환한다.
|
||||
* 댓글 시간을 상대 시간 형식으로 변환한다.
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {string} 표시용 문자열
|
||||
*/
|
||||
@@ -32,13 +35,74 @@ const formatCommentDate = (value) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
const now = Date.now()
|
||||
const diffMs = now - date.getTime()
|
||||
|
||||
if (diffMs < 60 * 1000) {
|
||||
return '방금 전'
|
||||
}
|
||||
|
||||
if (diffMs < 60 * 60 * 1000) {
|
||||
return `${Math.max(1, Math.floor(diffMs / (60 * 1000)))}분 전`
|
||||
}
|
||||
|
||||
if (diffMs < 24 * 60 * 60 * 1000) {
|
||||
return `${Math.max(1, Math.floor(diffMs / (60 * 60 * 1000)))}시간 전`
|
||||
}
|
||||
|
||||
const sameYear = date.getFullYear() === new Date(now).getFullYear()
|
||||
return date.toLocaleDateString('ko-KR', sameYear
|
||||
? { month: 'short', day: 'numeric' }
|
||||
: { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 작성자 아바타 이니셜을 생성한다.
|
||||
* @param {{ username?: string, email?: string }} user - 작성자 정보
|
||||
* @returns {string} 아바타 이니셜
|
||||
*/
|
||||
const getAvatarInitials = (user) => {
|
||||
const baseText = String(user?.username || user?.email || '').trim()
|
||||
if (!baseText) {
|
||||
return '@'
|
||||
}
|
||||
const tokens = baseText.split(/\s+/g).filter(Boolean)
|
||||
if (tokens.length >= 2) {
|
||||
return `${tokens[0].slice(0, 1)}${tokens[1].slice(0, 1)}`.toUpperCase()
|
||||
}
|
||||
return baseText.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 작성 시각 숫자값 반환
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {number} 시간 숫자값
|
||||
*/
|
||||
const toTimeValue = (value) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 0
|
||||
}
|
||||
return date.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 이미지 깨짐 여부 확인
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {boolean} 깨짐 여부
|
||||
*/
|
||||
const isAvatarBroken = (commentId) => brokenAvatarCommentIds.value.includes(commentId)
|
||||
|
||||
/**
|
||||
* 아바타 이미지 로드 실패를 기록한다.
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const markAvatarBroken = (commentId) => {
|
||||
if (isAvatarBroken(commentId)) {
|
||||
return
|
||||
}
|
||||
brokenAvatarCommentIds.value = [...brokenAvatarCommentIds.value, commentId]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,7 +217,68 @@ const submitReply = async (parentId) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 댓글의 좋아요 요청 진행 여부
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {boolean} 진행 여부
|
||||
*/
|
||||
const isLikingComment = (commentId) => likingCommentIds.value.includes(commentId)
|
||||
|
||||
/**
|
||||
* 댓글 좋아요를 토글한다.
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const toggleLike = async (commentId) => {
|
||||
if (!member.value || isLikingComment(commentId)) {
|
||||
return
|
||||
}
|
||||
|
||||
likingCommentIds.value = [...likingCommentIds.value, commentId]
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const result = await $fetch(`/api/posts/${props.slug}/comments/${commentId}/like`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
comments.value = comments.value.map((item) => {
|
||||
if (item.id !== commentId) {
|
||||
return item
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
likeCount: Number(result.likeCount || 0),
|
||||
likedByMe: Boolean(result.liked)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '좋아요 처리에 실패했습니다.'
|
||||
} finally {
|
||||
likingCommentIds.value = likingCommentIds.value.filter((id) => id !== commentId)
|
||||
}
|
||||
}
|
||||
|
||||
const rootComments = computed(() => comments.value.filter((item) => !item.parentId))
|
||||
const sortedRootComments = computed(() => {
|
||||
const copied = [...rootComments.value]
|
||||
|
||||
if (sortOption.value === 'latest') {
|
||||
return copied.sort((left, right) => toTimeValue(right.createdAt) - toTimeValue(left.createdAt))
|
||||
}
|
||||
|
||||
if (sortOption.value === 'oldest') {
|
||||
return copied.sort((left, right) => toTimeValue(left.createdAt) - toTimeValue(right.createdAt))
|
||||
}
|
||||
|
||||
return copied.sort((left, right) => {
|
||||
const likeDiff = Number(right.likeCount || 0) - Number(left.likeCount || 0)
|
||||
if (likeDiff !== 0) {
|
||||
return likeDiff
|
||||
}
|
||||
return toTimeValue(left.createdAt) - toTimeValue(right.createdAt)
|
||||
})
|
||||
})
|
||||
const repliesByParent = computed(() => {
|
||||
/** @type {Record<string, Array<any>>} */
|
||||
const grouped = {}
|
||||
@@ -168,6 +293,10 @@ const repliesByParent = computed(() => {
|
||||
grouped[item.parentId].push(item)
|
||||
}
|
||||
|
||||
for (const parentId of Object.keys(grouped)) {
|
||||
grouped[parentId] = grouped[parentId].sort((left, right) => toTimeValue(left.createdAt) - toTimeValue(right.createdAt))
|
||||
}
|
||||
|
||||
return grouped
|
||||
})
|
||||
|
||||
@@ -179,12 +308,25 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="post-comments text-sm">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="font-medium">Comments</p>
|
||||
<span class="site-muted">{{ comments.length }}</span>
|
||||
<p class="font-medium"><span class="site-muted">{{ comments.length }}</span> Comments</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2 text-xs site-muted">
|
||||
<label for="comment-sort">Sort by:</label>
|
||||
<select
|
||||
id="comment-sort"
|
||||
v-model="sortOption"
|
||||
class="rounded-md border border-[var(--site-line)] bg-transparent px-2 py-1 text-xs font-semibold text-[var(--site-ink)] outline-none"
|
||||
>
|
||||
<option value="best">Best</option>
|
||||
<option value="latest">Latest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div v-if="member" class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3">
|
||||
<div v-if="member" class="rounded-[10px] p-3">
|
||||
<p class="mb-2 text-xs site-muted">
|
||||
{{ member.username || member.email }} 님으로 댓글 작성
|
||||
</p>
|
||||
@@ -225,29 +367,85 @@ onMounted(async () => {
|
||||
댓글을 불러오는 중입니다.
|
||||
</p>
|
||||
|
||||
<ul v-else-if="rootComments.length > 0" class="flex flex-col gap-3">
|
||||
<ul v-else-if="sortedRootComments.length > 0" class="flex flex-col divide-y divide-[var(--site-line)]">
|
||||
<li
|
||||
v-for="comment in rootComments"
|
||||
v-for="comment in sortedRootComments"
|
||||
:key="comment.id"
|
||||
class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3"
|
||||
class="py-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<strong class="text-sm">{{ comment.user.username }}</strong>
|
||||
<span class="text-xs site-muted">{{ formatCommentDate(comment.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-line leading-relaxed">
|
||||
{{ comment.body }}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex w-8 flex-none flex-col items-center">
|
||||
<div class="h-8 w-8 min-h-8 min-w-8 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<img
|
||||
v-if="comment.user.avatarUrl && !isAvatarBroken(comment.id)"
|
||||
:src="comment.user.avatarUrl"
|
||||
:alt="`${comment.user.username} 아바타`"
|
||||
class="block h-full w-full object-cover"
|
||||
@error="markAvatarBroken(comment.id)"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="grid h-full w-full place-items-center text-[11px] font-semibold site-muted"
|
||||
>
|
||||
{{ getAvatarInitials(comment.user) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<strong class="text-sm">{{ comment.user.username }}</strong>
|
||||
<span class="text-xs site-muted">{{ formatCommentDate(comment.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-2 whitespace-pre-line leading-relaxed">
|
||||
{{ comment.body }}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
v-if="member"
|
||||
type="button"
|
||||
class="text-xs site-muted hover:opacity-75"
|
||||
@click="openReplyForm(comment.id)"
|
||||
>
|
||||
답글
|
||||
</button>
|
||||
<div class="mt-2 flex items-center gap-3 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-1 site-muted hover:opacity-75 disabled:opacity-50"
|
||||
:disabled="!member || isLikingComment(comment.id)"
|
||||
@click="toggleLike(comment.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 13.2 2.9 8.1a3.4 3.4 0 0 1 0-4.8 3.4 3.4 0 0 1 4.8 0L8 3.6l.3-.3a3.4 3.4 0 0 1 4.8 4.8L8 13.2Z"
|
||||
:fill="comment.likedByMe ? 'currentColor' : 'none'"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ comment.likeCount || 0 }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="member"
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-1 site-muted hover:opacity-75"
|
||||
@click="openReplyForm(comment.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m1.5 7 4.7-4.2v2.6c4.7 0 8 2 8.3 6.5-1.5-2.3-3.3-3.1-8.3-3.1v2.4L1.5 7Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
답글
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] border border-[var(--site-line)] p-2">
|
||||
@@ -277,20 +475,63 @@ onMounted(async () => {
|
||||
|
||||
<ul
|
||||
v-if="repliesByParent[comment.id]?.length"
|
||||
class="mt-3 flex flex-col gap-2 border-l border-[var(--site-line)] pl-3"
|
||||
class="mt-3 ml-4 flex flex-col gap-3 border-l border-[var(--site-line)]/90 pl-4"
|
||||
>
|
||||
<li
|
||||
v-for="reply in repliesByParent[comment.id]"
|
||||
:key="reply.id"
|
||||
class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-2.5"
|
||||
class="relative rounded-[10px] bg-[var(--site-panel)] p-2.5"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<strong class="text-sm">{{ reply.user.username }}</strong>
|
||||
<span class="text-xs site-muted">{{ formatCommentDate(reply.createdAt) }}</span>
|
||||
<span class="absolute -left-4 top-5 h-px w-3 bg-[var(--site-line)]/90" />
|
||||
<div class="flex gap-2.5">
|
||||
<div class="h-8 w-8 min-h-8 min-w-8 flex-none overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-bg)]">
|
||||
<img
|
||||
v-if="reply.user.avatarUrl && !isAvatarBroken(reply.id)"
|
||||
:src="reply.user.avatarUrl"
|
||||
:alt="`${reply.user.username} 아바타`"
|
||||
class="block h-full w-full object-cover"
|
||||
@error="markAvatarBroken(reply.id)"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="grid h-full w-full place-items-center text-[11px] font-semibold site-muted"
|
||||
>
|
||||
{{ getAvatarInitials(reply.user) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<strong class="text-sm">{{ reply.user.username }}</strong>
|
||||
<span class="text-xs site-muted">{{ formatCommentDate(reply.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 whitespace-pre-line leading-relaxed">
|
||||
{{ reply.body }}
|
||||
</p>
|
||||
<div class="mt-1.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 site-muted hover:opacity-75 disabled:opacity-50"
|
||||
:disabled="!member || isLikingComment(reply.id)"
|
||||
@click="toggleLike(reply.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 13.2 2.9 8.1a3.4 3.4 0 0 1 0-4.8 3.4 3.4 0 0 1 4.8 0L8 3.6l.3-.3a3.4 3.4 0 0 1 4.8 4.8L8 13.2Z"
|
||||
:fill="reply.likedByMe ? 'currentColor' : 'none'"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ reply.likeCount || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 whitespace-pre-line leading-relaxed">
|
||||
{{ reply.body }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
@@ -38,7 +38,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
class="group relative flex w-full items-center"
|
||||
>
|
||||
<NuxtLink
|
||||
class="left-sidebar__nav-link flex flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200 before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:bg-[var(--site-line)] before:transition-[width,height,border-radius,background-color] before:duration-200 hover:bg-[#f2f2f2] hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full hover:before:bg-[rgba(17,17,17,0.25)]"
|
||||
class="left-sidebar__nav-link site-panel-hover flex flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200 before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:bg-[var(--site-line)] before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]"
|
||||
:to="item.url"
|
||||
>
|
||||
<span class="left-sidebar__nav-link-label flex-1 transition-transform duration-200 group-hover:translate-x-[3px]">{{ item.label }}</span>
|
||||
@@ -57,7 +57,7 @@ const { data: navigation } = await useFetch('/api/navigation', {
|
||||
<NuxtLink
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="left-sidebar__category group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:bg-[#f2f2f2] hover:px-3"
|
||||
class="left-sidebar__category site-panel-hover group flex items-center gap-2 rounded-[10px] py-2 pr-3 pl-0 leading-tight transition-[padding,background-color,color] duration-200 hover:px-3"
|
||||
:to="`/tag/${tag.slug}`"
|
||||
>
|
||||
<span class="left-sidebar__category-color h-4 w-1 rounded-sm rounded-l-none transition-all duration-200 group-hover:h-2 group-hover:w-2 group-hover:rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
|
||||
9
db/migrations/012_add_comment_likes.sql
Normal file
9
db/migrations/012_add_comment_likes.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS comment_likes (
|
||||
comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (comment_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS comment_likes_user_id_idx
|
||||
ON comment_likes (user_id);
|
||||
12
db/migrations/013_add_user_admin_role.sql
Normal file
12
db/migrations/013_add_user_admin_role.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
WITH first_user AS (
|
||||
SELECT id
|
||||
FROM users
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE users
|
||||
SET is_admin = true
|
||||
WHERE id IN (SELECT id FROM first_user);
|
||||
27
db/migrations/014_add_user_role_levels.sql
Normal file
27
db/migrations/014_add_user_role_levels.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS user_role TEXT NOT NULL DEFAULT 'member';
|
||||
|
||||
UPDATE users
|
||||
SET user_role = CASE
|
||||
WHEN is_admin THEN 'admin'
|
||||
ELSE 'member'
|
||||
END
|
||||
WHERE user_role NOT IN ('owner', 'admin', 'member');
|
||||
|
||||
WITH first_user AS (
|
||||
SELECT id
|
||||
FROM users
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE users
|
||||
SET user_role = 'owner'
|
||||
WHERE id IN (SELECT id FROM first_user)
|
||||
AND is_admin = true;
|
||||
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_user_role_check;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_user_role_check
|
||||
CHECK (user_role IN ('owner', 'admin', 'member'));
|
||||
13
db/migrations/015_add_tag_type_and_reorder_support.sql
Normal file
13
db/migrations/015_add_tag_type_and_reorder_support.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE tags
|
||||
ADD COLUMN IF NOT EXISTS tag_type TEXT NOT NULL DEFAULT 'managed';
|
||||
|
||||
UPDATE tags
|
||||
SET tag_type = 'managed'
|
||||
WHERE tag_type NOT IN ('managed', 'general');
|
||||
|
||||
ALTER TABLE tags
|
||||
DROP CONSTRAINT IF EXISTS tags_tag_type_check;
|
||||
|
||||
ALTER TABLE tags
|
||||
ADD CONSTRAINT tags_tag_type_check
|
||||
CHECK (tag_type IN ('managed', 'general'));
|
||||
@@ -1,5 +1,26 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-11 v0.0.80
|
||||
|
||||
### 태그를 관리용/일반용으로 분리하고 관리용만 정렬
|
||||
|
||||
태그 수가 많아질수록 모든 태그에 순번을 강제하면 운영 비용이 커지므로, 홈페이지 카테고리로 쓰는 태그만 `managed`로 분리해 순서를 관리하고 나머지는 `general`로 분리했다. 관리용 태그는 드래그 앤 드롭으로 순서를 바꾸고 일괄 저장 API로 반영해 중복 숫자 입력 문제를 제거했다.
|
||||
공개 `GET /api/tags`는 관리용 태그만 반환하도록 바꿔, 카테고리 노출 목적과 일반 배지 태그 목적이 섞이지 않게 했다.
|
||||
|
||||
## 2026-05-11 v0.0.79
|
||||
|
||||
### 최초 사용자 관리자 부트스트랩 전환
|
||||
|
||||
관리자 계정을 환경변수로 고정하면 실제 운영에서 초기 세팅 흐름이 불명확하고, 관리자 프로필/권한 관리가 회원 데이터와 분리되어 확장성이 떨어진다. 따라서 최초 사용자가 회원가입을 시도하는 시점에 `관리자 등록` 모드로 안내하고, 첫 계정에 `is_admin=true`를 부여하는 부트스트랩 방식으로 전환했다. 관리자 로그인도 동일한 `users` 인증 체계를 사용하도록 맞춰, 관리자/회원 계정 모델을 일원화했다.
|
||||
관리자 화면에서 썸네일/이름을 바로 수정할 수 있도록, 관리자 로그인 시 회원 세션도 함께 발급해 기존 회원 프로필 API를 재사용하는 방향을 선택했다.
|
||||
권한은 향후 기능 확장을 위해 `owner`/`admin`/`member` 3단계로 먼저 분리하고, 현재 단계에서는 관리자 멤버 화면에서 권한 값을 변경할 수 있게 준비했다.
|
||||
|
||||
## 2026-05-11 v0.0.79
|
||||
|
||||
### 댓글 아바타/좋아요/상대시간 표시 정렬
|
||||
|
||||
댓글 영역은 텍스트 중심 구조만으로는 SNS형 피드백 흐름이 약해 참여 지표를 확인하기 어려웠다. 작성자 썸네일과 좋아요 토글을 기본 액션으로 배치하고, 시간 표기는 최근 24시간 동안 상대 시간으로 보여 즉시성을 높였다. 24시간 이후에는 날짜로 전환해 장기 글에서도 타임라인 문맥을 유지한다.
|
||||
|
||||
## 2026-05-11 v0.0.78
|
||||
|
||||
### 관리자 미디어에서 회원 썸네일 가시성 복구
|
||||
|
||||
14
docs/map.md
14
docs/map.md
@@ -35,7 +35,7 @@
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시, 작성자 썸네일/좋아요/상대시간 표시 |
|
||||
|
||||
## 관리자 컴포넌트
|
||||
|
||||
@@ -83,10 +83,10 @@
|
||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, 폴더 트리, 복수 선택, 드래그 이동 |
|
||||
| pages/admin/navigation/index.vue | 메뉴/네비게이션 관리 |
|
||||
| pages/admin/tags/index.vue | 태그 관리 |
|
||||
| pages/admin/tags/index.vue | 태그 관리(관리용/일반용 분리, 관리용 드래그 정렬 저장) |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(닉네임, 이메일, 최근 접속, IP, 댓글 수, 활동 상태) |
|
||||
|
||||
## 공개 페이지
|
||||
@@ -118,6 +118,7 @@
|
||||
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
||||
| server/api/navigation.get.js | 공개 네비게이션 API |
|
||||
| server/api/auth/signup.post.js | 회원 가입 API |
|
||||
| server/api/auth/bootstrap-status.get.js | 최초 관리자 등록 필요 여부 조회 API |
|
||||
| server/api/auth/login.post.js | 회원 로그인 API |
|
||||
| server/api/auth/me.get.js | 회원 세션 조회 API |
|
||||
| server/api/auth/logout.post.js | 회원 로그아웃 API |
|
||||
@@ -130,6 +131,7 @@
|
||||
| server/api/auth/account.delete.js | 회원 탈퇴 API |
|
||||
| server/api/posts/[slug]/comments.get.js | 게시물 댓글 목록 조회 API |
|
||||
| server/api/posts/[slug]/comments.post.js | 게시물 댓글 작성 API |
|
||||
| server/api/posts/[slug]/comments/[commentId]/like.post.js | 댓글 좋아요 토글 API |
|
||||
| server/routes/admin/api/auth/login.post.js | 관리자 로그인 API |
|
||||
| server/routes/admin/api/auth/logout.post.js | 관리자 로그아웃 API |
|
||||
| server/routes/admin/api/auth/me.get.js | 관리자 세션 조회 API |
|
||||
@@ -154,11 +156,13 @@
|
||||
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
||||
| server/routes/admin/api/tags/[id].put.js | 관리자 태그 수정 API |
|
||||
| server/routes/admin/api/tags/[id].delete.js | 관리자 태그 삭제 API |
|
||||
| server/routes/admin/api/tags/reorder.put.js | 관리자 관리용 태그 순서 일괄 저장 API |
|
||||
| server/routes/admin/api/settings.get.js | 관리자 사이트 설정 조회 API |
|
||||
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
|
||||
| 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/[id]/role.put.js | 관리자 멤버 권한 변경 API |
|
||||
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||
@@ -192,6 +196,10 @@
|
||||
| db/migrations/009_add_post_og_image.sql | 게시물 OG 이미지 필드 추가 |
|
||||
| db/migrations/010_add_members_and_comments.sql | 회원/댓글 테이블 추가 |
|
||||
| db/migrations/011_add_member_profile_and_activity.sql | 회원 아바타/최근 활동 컬럼 추가 및 닉네임 유니크 인덱스 추가 |
|
||||
| db/migrations/012_add_comment_likes.sql | 댓글 좋아요 테이블 추가 |
|
||||
| db/migrations/014_add_user_role_levels.sql | 회원 권한 3단계(owner/admin/member) 컬럼 추가 |
|
||||
| db/migrations/013_add_user_admin_role.sql | 회원 관리자 권한 컬럼 추가 및 첫 사용자 관리자 승격 |
|
||||
| db/migrations/015_add_tag_type_and_reorder_support.sql | 태그 유형(`managed`/`general`) 컬럼 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
|
||||
45
docs/spec.md
45
docs/spec.md
@@ -20,7 +20,7 @@
|
||||
|------|-----------|
|
||||
| Header | 높이 57px, `sticky top-0`, `shrink-0`. `lg`~`xl` 구간은 내부 `px-5`~`px-6`로 좌우 여백을 두고, 검색창은 뷰포트에 맞춰 `max-w`로 단계 축소한다(`2xl`에서 고정 470px). |
|
||||
| Shell | `min-height: 100vh`, `flex` 세로 컬럼 |
|
||||
| 그리드(데스크톱 `lg+`) | `items-start`, `column-gap`(`lg:gap-x-4`, `xl:gap-x-5`)으로 열 사이 간격. 중앙 열은 `minmax(0,1fr)`로 패딩이 있어도 가로 합이 넘치지 않게 함 — **문서(`html`/`body`) 스크롤**로 긴 본문 처리(스크롤바는 브라우저 오른쪽) |
|
||||
| 그리드(데스크톱 `lg+`) | `items-start`, 3열 그리드(`287px / minmax(0,1fr) / 287px`)를 사용하고 열 간 `column-gap`은 두지 않는다(`gap-x-0`). 경계선은 사이드바 보더로만 구분해 이중 패딩처럼 보이는 여백을 방지한다. 긴 본문은 **문서(`html`/`body`) 스크롤**로 처리한다. |
|
||||
| 그리드(모바일 `lg` 미만) | 단일 세로 흐름: **본문 → 오른쪽 사이드** 순. 왼쪽 사이드는 레이아웃 흐름에서 분리된 고정 슬라이드 패널로 표시 |
|
||||
| Left Aside | 너비 287px, `sticky top-[57px]`, `h-[calc(100vh-57px)]`와 `max-h` 동일(뷰포트 기준 고정 높이), 내부 상단은 `.site-sidebar-scroll`(스크롤바 숨김), 하단 푸터 `shrink-0`·상단 보더로 스크롤 영역과 구분, 푸터 좌우는 `px-4`~`sm:px-5`로 본문 블록과 유사한 여백 |
|
||||
| Left Aside(모바일) | `fixed` 좌측 패널, 열림 시 `translate-x-0`, 닫힘 시 화면 밖으로 이동. 열린 동안 백드롭과 `html.site-mobile-nav-open`으로 문서 스크롤 잠금 |
|
||||
@@ -59,6 +59,10 @@
|
||||
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
||||
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
||||
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
||||
- 댓글은 작성자 썸네일(없으면 이니셜)과 좋아요 수를 표시한다.
|
||||
- 댓글 시간은 24시간 이내일 때 상대 시간(분/시간 전), 이후에는 날짜로 표시한다.
|
||||
- 댓글 정렬은 `Best`(좋아요 우선), `Latest`, `Oldest`를 제공한다.
|
||||
- 댓글 아바타 이미지 로드 실패 시 이니셜 아바타로 즉시 대체한다.
|
||||
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
||||
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
||||
@@ -212,6 +216,8 @@ components/content/
|
||||
| email | String | 로그인 이메일(유니크) |
|
||||
| password_hash | String | bcrypt 해시 비밀번호 |
|
||||
| avatar_url | String | 프로필 썸네일 URL |
|
||||
| is_admin | Boolean | 관리자 권한 여부 |
|
||||
| user_role | Enum | 권한 단계(`owner`/`admin`/`member`) |
|
||||
| last_seen_at | DateTime nullable | 마지막 접속 시각 |
|
||||
| last_seen_ip | String | 마지막 접속 IP |
|
||||
| created_at | DateTime | 생성일 |
|
||||
@@ -230,6 +236,14 @@ components/content/
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### CommentLikes
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| comment_id | UUID | FK → Comments |
|
||||
| user_id | UUID | FK → Users |
|
||||
| created_at | DateTime | 생성일 |
|
||||
|
||||
### Pages (고정 페이지)
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
@@ -250,8 +264,9 @@ components/content/
|
||||
| name | String | 태그명 |
|
||||
| slug | String | URL 슬러그 |
|
||||
| description | String | 설명 |
|
||||
| sort_order | Integer | 사용자 화면 표시 순서 |
|
||||
| sort_order | Integer | 관리용 태그 정렬 순서 |
|
||||
| color | String | 태그 색상 코드 |
|
||||
| tag_type | Enum | 태그 유형(`managed`/`general`) |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
@@ -327,6 +342,7 @@ components/content/
|
||||
- `GET /api/posts/:slug` - 게시물 상세
|
||||
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
|
||||
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
|
||||
- `POST /api/posts/:slug/comments/:commentId/like` - 댓글 좋아요 토글(회원 세션 필요)
|
||||
- `GET /api/pages` - 고정 페이지 목록
|
||||
- `GET /api/pages/:slug` - 고정 페이지 상세
|
||||
- `GET /api/tags` - 태그 목록
|
||||
@@ -334,6 +350,7 @@ components/content/
|
||||
- `GET /api/site-settings` - 공개 사이트 설정
|
||||
- `GET /api/navigation` - 공개 네비게이션
|
||||
- `POST /api/auth/signup` - 회원 가입
|
||||
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부 조회
|
||||
- `POST /api/auth/login` - 회원 로그인
|
||||
- `GET /api/auth/me` - 현재 회원 세션 조회
|
||||
- `POST /api/auth/logout` - 회원 로그아웃
|
||||
@@ -377,15 +394,19 @@ components/content/
|
||||
- `GET /admin/api/tags/:id` - 태그 상세
|
||||
- `PUT /admin/api/tags/:id` - 태그 수정
|
||||
- `DELETE /admin/api/tags/:id` - 태그 삭제
|
||||
- `PUT /admin/api/tags/reorder` - 관리용 태그 순서 일괄 저장
|
||||
- `GET /admin/api/settings` - 사이트 설정 조회
|
||||
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||
- `GET /admin/api/members` - 회원 목록(최근 접속, 접속 IP, 댓글 수 포함)
|
||||
- `GET /admin/api/members` - 회원 목록(권한, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
|
||||
|
||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||
> 태그 목록은 `sort_order ASC, name ASC` 기준으로 정렬한다.
|
||||
> 공개 `GET /api/tags`는 `managed` 태그만 반환한다.
|
||||
> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, name ASC` 기준으로 정렬한다.
|
||||
> 관리용 태그 순서 저장은 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
|
||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||
|
||||
### 관리자 글 편집
|
||||
@@ -487,16 +508,22 @@ components/content/
|
||||
|
||||
### 관리자 인증
|
||||
|
||||
- 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용
|
||||
- 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정
|
||||
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용
|
||||
- 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증
|
||||
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다.
|
||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다.
|
||||
- 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다.
|
||||
- 관리자 멤버 화면에서 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계로 표시하고 변경할 수 있다.
|
||||
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
|
||||
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
||||
|
||||
### 회원 인증
|
||||
|
||||
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
|
||||
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)로 세션을 관리한다.
|
||||
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
|
||||
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.
|
||||
|
||||
---
|
||||
@@ -590,6 +617,6 @@ APP_PORT=43118
|
||||
|
||||
## 버전 관리
|
||||
|
||||
- 현재 버전: v0.0.78
|
||||
- 현재 버전: v0.0.80
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.80
|
||||
|
||||
- 태그에 유형(`managed`/`general`) 컬럼을 추가하는 마이그레이션(`015_add_tag_type_and_reorder_support.sql`)을 추가.
|
||||
- 관리자 태그 입력 폼에 태그 유형 선택을 추가하고, 일반 태그는 정렬 순서를 사용하지 않도록 정리.
|
||||
- 관리자 태그 목록을 `관리용 태그`/`일반 태그`로 분리.
|
||||
- 관리용 태그 목록에 드래그 앤 드롭 정렬 UI를 추가하고, 저장 버튼으로 순서를 일괄 반영하도록 개선.
|
||||
- `PUT /admin/api/tags/reorder` API를 추가해 관리용 태그의 `sort_order`를 순차 재정렬하도록 확장.
|
||||
- 공개 태그 API(`GET /api/tags`)는 관리용 태그만 반환하도록 변경해 좌측 카테고리 영역과 태그 관리 목적을 분리.
|
||||
|
||||
## v0.0.79
|
||||
|
||||
- 댓글 목록에 작성자 썸네일(`avatar_url`)을 노출하고, 썸네일이 없으면 이니셜 아바타를 표시하도록 UI를 개선.
|
||||
- 댓글/대댓글에 좋아요 토글을 추가하고, 좋아요 수/내 좋아요 상태를 함께 반환하도록 API와 저장소를 확장.
|
||||
- 댓글 시간 표기를 `n분 전`, `n시간 전`, 24시간 이후 날짜 형식으로 변경.
|
||||
- `comment_likes` 테이블 마이그레이션(`012_add_comment_likes.sql`)을 추가.
|
||||
- 댓글 정렬 옵션(`Best`, `Latest`, `Oldest`)을 실제 동작하도록 연결.
|
||||
- 댓글/대댓글 카드 보더를 최소화하고 간격·아이콘 스타일을 정리해 더 단정한 레이아웃으로 조정.
|
||||
- 대댓글 아바타가 깨질 때 이니셜로 안전하게 대체되도록 이미지 로드 실패 처리 보강.
|
||||
- 공개 레이아웃(`default`, `post`)의 데스크톱 3열 `gap-x`를 제거해 본문-사이드 사이가 이중 패딩처럼 보이던 간격을 정리.
|
||||
- 좌측 사이드바 네비게이션/카테고리 hover 배경을 `site-panel-hover` 기반으로 통일해 다크 모드에서 텍스트 가독성 저하를 수정.
|
||||
- 관리자 멤버 목록에 권한 컬럼을 추가하고, 환경변수 관리자 계정을 `관리자` 권한으로 함께 표시하도록 보강.
|
||||
- 사용자 테이블에 관리자 권한 컬럼(`is_admin`)을 추가하는 마이그레이션(`013_add_user_admin_role.sql`)을 추가.
|
||||
- 최초 사용자 생성 시 관리자 권한을 자동 부여하고, 가입 응답에 관리자 여부를 함께 반환하도록 수정.
|
||||
- 관리자 로그인은 환경변수 고정 계정 대신 `is_admin` 회원 계정 인증으로 전환.
|
||||
- `GET /api/auth/bootstrap-status`를 추가해 최초 관리자 등록 필요 여부를 조회하도록 확장.
|
||||
- 회원가입 화면에서 최초 사용자일 때 `관리자 등록` 타이틀/설명/필드 라벨을 노출하도록 수정.
|
||||
- 관리자 로그인 시 회원 세션도 함께 설정하고, 관리자 로그아웃 시 회원 세션도 함께 정리하도록 수정.
|
||||
- 관리자 설정 화면에 관리자 프로필(썸네일 업로드/제거, 이름 저장) 섹션을 추가.
|
||||
- 관리자 설정 화면에서 현재 비밀번호 확인 기반 관리자 비밀번호 변경을 지원하도록 추가.
|
||||
- 회원 권한 3단계(`owner`/`admin`/`member`)를 위한 마이그레이션(`014_add_user_role_levels.sql`)을 추가.
|
||||
- 관리자 멤버 화면에서 권한 선택/저장 UI와 `PUT /admin/api/members/:id/role` 권한 변경 API를 추가.
|
||||
|
||||
## v0.0.78
|
||||
|
||||
- 관리자 미디어 목록에서 회원 썸네일 경로(`/uploads/members/avatars/`)를 다시 포함해 `회원/썸네일` 폴더에서 확인 가능하도록 수정.
|
||||
|
||||
@@ -45,7 +45,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
class="public-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-4 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-5 xl:px-6 2xl:px-0"
|
||||
class="public-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0"
|
||||
:class="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'"
|
||||
>
|
||||
<main
|
||||
|
||||
@@ -45,7 +45,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
class="post-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-4 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-5 xl:px-6 2xl:px-0"
|
||||
class="post-layout__grid mx-auto flex w-full max-w-[1294px] flex-1 flex-col bg-[var(--site-bg)] px-4 lg:grid lg:grid-cols-[287px_minmax(0,1fr)_287px] lg:items-start lg:gap-x-0 lg:px-5 lg:transition-[grid-template-columns,max-width,gap] lg:duration-300 lg:ease-out xl:gap-x-0 xl:px-6 2xl:px-0"
|
||||
:class="menuOpen ? '' : 'lg:max-w-[1007px] lg:!gap-x-0 lg:[grid-template-columns:0_minmax(0,1fr)_287px]'"
|
||||
>
|
||||
<main
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.78",
|
||||
"version": "0.0.79",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -10,6 +10,12 @@ const form = reactive({
|
||||
|
||||
const pending = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
default: () => ({
|
||||
hasUsers: true,
|
||||
needsAdminSetup: false
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 로그인 제출
|
||||
@@ -42,6 +48,16 @@ const submitLogin = async () => {
|
||||
<h1 class="admin-login__title mt-2 text-3xl font-semibold">
|
||||
로그인
|
||||
</h1>
|
||||
<p
|
||||
v-if="bootstrapStatus?.needsAdminSetup"
|
||||
class="mt-3 rounded border border-[#ff4f2e]/30 bg-[#ff4f2e]/10 px-3 py-2 text-xs text-[#b63a23]"
|
||||
>
|
||||
등록된 관리자가 없습니다.
|
||||
<NuxtLink class="font-semibold underline-offset-2 hover:underline" to="/signup">
|
||||
관리자 등록으로 이동
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
||||
<form class="admin-login__form mt-8 grid gap-4" @submit.prevent="submitLogin">
|
||||
<label class="admin-login__field grid gap-2 text-sm">
|
||||
<span class="admin-login__label font-medium">이메일</span>
|
||||
|
||||
@@ -6,6 +6,14 @@ definePageMeta({
|
||||
const { data: members } = await useFetch('/admin/api/members', {
|
||||
default: () => []
|
||||
})
|
||||
const roleSavingIds = ref([])
|
||||
const roleMessage = ref('')
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'owner', label: '소유자' },
|
||||
{ value: 'admin', label: '관리자' },
|
||||
{ value: 'member', label: '멤버' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 최근 접속 시각 표시 문자열을 반환한다.
|
||||
@@ -30,6 +38,55 @@ const formatLastSeen = (value) => {
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 저장 진행 여부를 확인한다.
|
||||
* @param {string} memberId - 회원 ID
|
||||
* @returns {boolean} 진행 여부
|
||||
*/
|
||||
const isSavingRole = (memberId) => roleSavingIds.value.includes(memberId)
|
||||
|
||||
/**
|
||||
* 회원 권한을 변경한다.
|
||||
* @param {Object} member - 회원 정보
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const updateRole = async (member) => {
|
||||
if (!member?.id || isSavingRole(member.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
roleSavingIds.value = [...roleSavingIds.value, member.id]
|
||||
roleMessage.value = ''
|
||||
|
||||
try {
|
||||
const result = await $fetch(`/admin/api/members/${member.id}/role`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
role: member.roleCode
|
||||
}
|
||||
})
|
||||
|
||||
members.value = members.value.map((item) => {
|
||||
if (item.id !== member.id) {
|
||||
return item
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
role: result.role,
|
||||
roleCode: result.roleCode,
|
||||
isAdmin: Boolean(result.isAdmin)
|
||||
}
|
||||
})
|
||||
|
||||
roleMessage.value = '권한이 변경되었습니다.'
|
||||
} catch (error) {
|
||||
roleMessage.value = error?.data?.message || '권한 변경에 실패했습니다.'
|
||||
} finally {
|
||||
roleSavingIds.value = roleSavingIds.value.filter((id) => id !== member.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,11 +102,13 @@ const formatLastSeen = (value) => {
|
||||
<thead class="bg-[#f7f7f5] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="px-3 py-2.5">닉네임</th>
|
||||
<th class="px-3 py-2.5">권한</th>
|
||||
<th class="px-3 py-2.5">이메일</th>
|
||||
<th class="px-3 py-2.5">최근 활동</th>
|
||||
<th class="px-3 py-2.5">접속 IP</th>
|
||||
<th class="px-3 py-2.5">활동 현황</th>
|
||||
<th class="px-3 py-2.5 text-right">댓글 개수</th>
|
||||
<th class="px-3 py-2.5 text-right">권한 변경</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -68,6 +127,18 @@ const formatLastSeen = (value) => {
|
||||
<span>{{ member.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<span
|
||||
class="rounded-full border px-2 py-0.5 text-xs"
|
||||
:class="member.roleCode === 'owner'
|
||||
? 'border-[#8a56ff]/35 text-[#8a56ff]'
|
||||
: member.roleCode === 'admin'
|
||||
? 'border-[#ff4f2e]/30 text-[#ff4f2e]'
|
||||
: 'border-line text-muted'"
|
||||
>
|
||||
{{ member.role || '멤버' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-muted">{{ member.email }}</td>
|
||||
<td class="px-3 py-3 text-muted">{{ formatLastSeen(member.lastSeenAt) }}</td>
|
||||
<td class="px-3 py-3 text-muted">{{ member.lastSeenIp || '-' }}</td>
|
||||
@@ -75,13 +146,37 @@ const formatLastSeen = (value) => {
|
||||
<span class="rounded-full border border-line px-2 py-0.5 text-xs">{{ member.activityStatus }}</span>
|
||||
</td>
|
||||
<td class="px-3 py-3 text-right font-semibold text-ink">{{ member.commentCount }}</td>
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex justify-end gap-2">
|
||||
<select
|
||||
v-model="member.roleCode"
|
||||
class="rounded border border-line bg-white px-2 py-1 text-xs"
|
||||
:disabled="isSavingRole(member.id)"
|
||||
>
|
||||
<option v-for="option in roleOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-line px-2 py-1 text-xs font-semibold hover:bg-paper disabled:opacity-50"
|
||||
:disabled="isSavingRole(member.id)"
|
||||
@click="updateRole(member)"
|
||||
>
|
||||
{{ isSavingRole(member.id) ? '저장 중' : '저장' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="members.length === 0">
|
||||
<td colspan="6" class="px-3 py-6 text-center text-sm text-muted">등록된 회원이 없습니다.</td>
|
||||
<td colspan="8" class="px-3 py-6 text-center text-sm text-muted">등록된 회원이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-if="roleMessage" class="mt-3 text-xs text-muted">
|
||||
{{ roleMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -4,9 +4,17 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const loadingProfile = ref(true)
|
||||
const savingProfile = ref(false)
|
||||
const savingPassword = ref(false)
|
||||
const uploadingAvatar = ref(false)
|
||||
const removingAvatar = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const profileMessage = ref('')
|
||||
const passwordMessage = ref('')
|
||||
const toast = ref(null)
|
||||
let toastTimer = null
|
||||
const avatarInputRef = ref(null)
|
||||
|
||||
const { data: settings } = await useFetch('/admin/api/settings')
|
||||
|
||||
@@ -18,6 +26,204 @@ const form = reactive({
|
||||
copyrightText: settings.value?.copyrightText || '©2026 sori.studio'
|
||||
})
|
||||
|
||||
const profileForm = reactive({
|
||||
email: '',
|
||||
username: '',
|
||||
avatarUrl: ''
|
||||
})
|
||||
|
||||
const passwordForm = reactive({
|
||||
currentPassword: '',
|
||||
nextPassword: '',
|
||||
nextPasswordConfirm: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 프로필을 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const loadAdminProfile = async () => {
|
||||
loadingProfile.value = true
|
||||
profileMessage.value = ''
|
||||
|
||||
try {
|
||||
const profile = await $fetch('/api/auth/profile')
|
||||
profileForm.email = profile.email || ''
|
||||
profileForm.username = profile.username || ''
|
||||
profileForm.avatarUrl = profile.avatarUrl || ''
|
||||
} catch {
|
||||
profileMessage.value = '관리자 프로필을 불러오지 못했습니다. 다시 로그인해 주세요.'
|
||||
} finally {
|
||||
loadingProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 닉네임 중복 여부를 확인한다.
|
||||
* @returns {Promise<boolean>} 사용 가능 여부
|
||||
*/
|
||||
const checkUsernameAvailable = async () => {
|
||||
const username = profileForm.username.trim()
|
||||
if (!username) {
|
||||
profileMessage.value = '관리자 이름을 입력해 주세요.'
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $fetch('/api/auth/check-username', {
|
||||
query: {
|
||||
username
|
||||
}
|
||||
})
|
||||
|
||||
if (!result.available) {
|
||||
profileMessage.value = '이미 사용 중인 이름입니다.'
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
profileMessage.value = error?.data?.message || '이름 중복 확인에 실패했습니다.'
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 프로필을 저장한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const saveAdminProfile = async () => {
|
||||
const available = await checkUsernameAvailable()
|
||||
if (!available) {
|
||||
return
|
||||
}
|
||||
|
||||
savingProfile.value = true
|
||||
profileMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/profile', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
username: profileForm.username.trim(),
|
||||
avatarUrl: profileForm.avatarUrl.trim()
|
||||
}
|
||||
})
|
||||
profileMessage.value = '관리자 프로필이 저장되었습니다.'
|
||||
} catch (error) {
|
||||
profileMessage.value = error?.data?.message || '관리자 프로필 저장에 실패했습니다.'
|
||||
} finally {
|
||||
savingProfile.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 썸네일 파일 선택창을 연다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const openAvatarFilePicker = () => {
|
||||
avatarInputRef.value?.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 썸네일을 업로드한다.
|
||||
* @param {Event} event - 파일 선택 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadAvatar = async (event) => {
|
||||
const target = /** @type {HTMLInputElement | null} */ (event.target instanceof HTMLInputElement ? event.target : null)
|
||||
const file = target?.files?.[0]
|
||||
|
||||
if (!file || uploadingAvatar.value) {
|
||||
return
|
||||
}
|
||||
|
||||
uploadingAvatar.value = true
|
||||
profileMessage.value = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const result = await $fetch('/api/auth/avatar', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
profileForm.avatarUrl = result.avatarUrl || ''
|
||||
profileMessage.value = '관리자 썸네일이 업로드되었습니다.'
|
||||
} catch (error) {
|
||||
profileMessage.value = error?.data?.message || '관리자 썸네일 업로드에 실패했습니다.'
|
||||
} finally {
|
||||
uploadingAvatar.value = false
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 썸네일을 제거한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const removeAvatar = async () => {
|
||||
if (removingAvatar.value) {
|
||||
return
|
||||
}
|
||||
|
||||
removingAvatar.value = true
|
||||
profileMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/avatar', {
|
||||
method: 'DELETE'
|
||||
})
|
||||
profileForm.avatarUrl = ''
|
||||
profileMessage.value = '관리자 썸네일이 제거되었습니다.'
|
||||
} catch (error) {
|
||||
profileMessage.value = error?.data?.message || '관리자 썸네일 제거에 실패했습니다.'
|
||||
} finally {
|
||||
removingAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 비밀번호를 변경한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const saveAdminPassword = async () => {
|
||||
passwordMessage.value = ''
|
||||
|
||||
if (!passwordForm.currentPassword || !passwordForm.nextPassword || !passwordForm.nextPasswordConfirm) {
|
||||
passwordMessage.value = '모든 비밀번호 입력값을 작성해 주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordForm.nextPassword !== passwordForm.nextPasswordConfirm) {
|
||||
passwordMessage.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
|
||||
return
|
||||
}
|
||||
|
||||
savingPassword.value = true
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/password', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
currentPassword: passwordForm.currentPassword,
|
||||
nextPassword: passwordForm.nextPassword
|
||||
}
|
||||
})
|
||||
|
||||
passwordForm.currentPassword = ''
|
||||
passwordForm.nextPassword = ''
|
||||
passwordForm.nextPasswordConfirm = ''
|
||||
passwordMessage.value = '관리자 비밀번호가 변경되었습니다.'
|
||||
} catch (error) {
|
||||
passwordMessage.value = error?.data?.message || '관리자 비밀번호 변경에 실패했습니다.'
|
||||
} finally {
|
||||
savingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장 상태 토스트 표시
|
||||
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||
@@ -66,6 +272,8 @@ const saveSettings = async () => {
|
||||
onBeforeUnmount(() => {
|
||||
window.clearTimeout(toastTimer)
|
||||
})
|
||||
|
||||
onMounted(loadAdminProfile)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -83,6 +291,139 @@ onBeforeUnmount(() => {
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<section class="admin-settings__profile mb-6 max-w-3xl rounded border border-line bg-white p-5">
|
||||
<h2 class="text-base font-semibold text-ink">관리자 프로필</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
썸네일과 이름을 수정할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div v-if="loadingProfile" class="mt-4 text-sm text-muted">
|
||||
관리자 프로필을 불러오는 중입니다.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 rounded border border-line bg-paper p-3 md:flex-row md:items-center">
|
||||
<div class="relative w-fit shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="group relative h-24 w-24 overflow-hidden rounded-full border border-line bg-white"
|
||||
:disabled="uploadingAvatar || removingAvatar"
|
||||
@click="openAvatarFilePicker"
|
||||
>
|
||||
<img
|
||||
v-if="profileForm.avatarUrl"
|
||||
:src="profileForm.avatarUrl"
|
||||
alt="관리자 썸네일"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="grid h-full w-full place-items-center text-2xl font-semibold text-muted"
|
||||
>
|
||||
{{ (profileForm.username || profileForm.email || '@').slice(0, 1).toUpperCase() }}
|
||||
</span>
|
||||
<span class="pointer-events-none absolute inset-0 grid place-items-center bg-black/45 text-[11px] font-medium text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
{{ profileForm.avatarUrl ? '이미지 변경' : '썸네일 등록' }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="profileForm.avatarUrl"
|
||||
type="button"
|
||||
class="absolute right-0 top-0 grid h-6 w-6 -translate-y-1/3 translate-x-1/3 place-items-center rounded-full border border-line bg-paper text-xs text-muted transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||
:disabled="removingAvatar"
|
||||
@click.stop="removeAvatar"
|
||||
>
|
||||
{{ removingAvatar ? '...' : 'X' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid min-w-0 flex-1 gap-3">
|
||||
<label class="grid gap-1 text-sm">
|
||||
<span class="text-xs text-muted">관리자 이름</span>
|
||||
<input
|
||||
v-model="profileForm.username"
|
||||
type="text"
|
||||
class="rounded border border-line bg-white px-3 py-2"
|
||||
>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm">
|
||||
<span class="text-xs text-muted">관리자 이메일</span>
|
||||
<input
|
||||
:value="profileForm.email"
|
||||
type="text"
|
||||
class="rounded border border-line bg-[#f7f7f5] px-3 py-2 text-muted"
|
||||
readonly
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="avatarInputRef"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
:disabled="uploadingAvatar"
|
||||
@change="uploadAvatar"
|
||||
>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="savingProfile"
|
||||
@click="saveAdminProfile"
|
||||
>
|
||||
{{ savingProfile ? '저장 중' : '관리자 프로필 저장' }}
|
||||
</button>
|
||||
<p v-if="profileMessage" class="text-xs text-muted">
|
||||
{{ profileMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-settings__password mb-6 max-w-3xl rounded border border-line bg-white p-5">
|
||||
<h2 class="text-base font-semibold text-ink">관리자 비밀번호 변경</h2>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
현재 비밀번호 확인 후 새 비밀번호로 변경할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div class="mt-4 grid gap-3">
|
||||
<input
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
class="rounded border border-line bg-white px-3 py-2 text-sm"
|
||||
placeholder="현재 비밀번호"
|
||||
>
|
||||
<input
|
||||
v-model="passwordForm.nextPassword"
|
||||
type="password"
|
||||
class="rounded border border-line bg-white px-3 py-2 text-sm"
|
||||
placeholder="새 비밀번호"
|
||||
>
|
||||
<input
|
||||
v-model="passwordForm.nextPasswordConfirm"
|
||||
type="password"
|
||||
class="rounded border border-line bg-white px-3 py-2 text-sm"
|
||||
placeholder="새 비밀번호 확인"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
class="rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="savingPassword"
|
||||
@click="saveAdminPassword"
|
||||
>
|
||||
{{ savingPassword ? '변경 중' : '관리자 비밀번호 변경' }}
|
||||
</button>
|
||||
<p v-if="passwordMessage" class="text-xs text-muted">
|
||||
{{ passwordMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="admin-settings__form grid max-w-3xl gap-6" @submit.prevent="saveSettings">
|
||||
<label class="admin-settings__field grid gap-2 text-sm">
|
||||
<span class="admin-settings__label font-medium">사이트 이름</span>
|
||||
|
||||
@@ -4,12 +4,128 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const deletingId = ref('')
|
||||
const draggingTagId = ref('')
|
||||
const dragOverTagId = ref('')
|
||||
const savingOrder = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const infoMessage = ref('')
|
||||
|
||||
const { data: tags, refresh } = await useFetch('/admin/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
|
||||
const generalTags = computed(() => tags.value.filter((tag) => tag.tagType === 'general'))
|
||||
|
||||
/**
|
||||
* 관리용 태그 드래그 시작
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {string} tagId - 태그 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleDragStart = (event, tagId) => {
|
||||
if (!event.dataTransfer) {
|
||||
return
|
||||
}
|
||||
draggingTagId.value = tagId
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 드래그 오버
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {string} tagId - 태그 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleDragOver = (event, tagId) => {
|
||||
event.preventDefault()
|
||||
dragOverTagId.value = tagId
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 드래그 종료
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleDragEnd = () => {
|
||||
draggingTagId.value = ''
|
||||
dragOverTagId.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 순서를 교환한다.
|
||||
* @param {string} sourceId - 원본 태그 ID
|
||||
* @param {string} targetId - 대상 태그 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const moveManagedTag = (sourceId, targetId) => {
|
||||
const sourceIndex = tags.value.findIndex((tag) => tag.id === sourceId)
|
||||
const targetIndex = tags.value.findIndex((tag) => tag.id === targetId)
|
||||
if (sourceIndex < 0 || targetIndex < 0 || sourceIndex === targetIndex) {
|
||||
return
|
||||
}
|
||||
|
||||
const sourceTag = tags.value[sourceIndex]
|
||||
const targetTag = tags.value[targetIndex]
|
||||
if (sourceTag.tagType !== 'managed' || targetTag.tagType !== 'managed') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextTags = [...tags.value]
|
||||
const [moved] = nextTags.splice(sourceIndex, 1)
|
||||
nextTags.splice(targetIndex, 0, moved)
|
||||
tags.value = nextTags
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 드롭 처리
|
||||
* @param {DragEvent} event - 드래그 이벤트
|
||||
* @param {string} targetId - 대상 태그 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleDrop = (event, targetId) => {
|
||||
event.preventDefault()
|
||||
if (!draggingTagId.value) {
|
||||
return
|
||||
}
|
||||
moveManagedTag(draggingTagId.value, targetId)
|
||||
handleDragEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리용 태그 순서를 저장한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const saveManagedOrder = async () => {
|
||||
if (savingOrder.value || managedTags.value.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
savingOrder.value = true
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
const reordered = await $fetch('/admin/api/tags/reorder', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
tagIds: managedTags.value.map((tag) => tag.id)
|
||||
}
|
||||
})
|
||||
|
||||
const managedTagMap = new Map(reordered.map((tag) => [tag.id, tag]))
|
||||
tags.value = [
|
||||
...reordered,
|
||||
...generalTags.value.map((tag) => managedTagMap.get(tag.id) || tag)
|
||||
]
|
||||
await refresh()
|
||||
infoMessage.value = '관리용 태그 순서가 저장되었습니다.'
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '정렬 순서를 저장하지 못했습니다.'
|
||||
} finally {
|
||||
savingOrder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 태그 삭제
|
||||
* @param {Object} tag - 삭제할 태그
|
||||
@@ -22,6 +138,7 @@ const deleteTag = async (tag) => {
|
||||
|
||||
deletingId.value = tag.id
|
||||
errorMessage.value = ''
|
||||
infoMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/admin/api/tags/${tag.id}`, {
|
||||
@@ -51,16 +168,33 @@ const deleteTag = async (tag) => {
|
||||
태그 추가
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
관리용 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 추후 배지형 노출 용도로 사용됩니다.
|
||||
</p>
|
||||
|
||||
<p v-if="errorMessage" class="admin-tags__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<p v-if="infoMessage" class="mt-3 rounded border border-line bg-white px-4 py-3 text-sm text-muted">
|
||||
{{ infoMessage }}
|
||||
</p>
|
||||
|
||||
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
|
||||
<div class="admin-tags__table mt-6 overflow-hidden border border-line">
|
||||
<div class="flex items-center justify-between border-b border-line bg-[#f7f7f5] px-4 py-2.5">
|
||||
<p class="text-xs font-semibold uppercase text-muted">관리용 태그</p>
|
||||
<button
|
||||
class="rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="savingOrder || managedTags.length === 0"
|
||||
@click="saveManagedOrder"
|
||||
>
|
||||
{{ savingOrder ? '저장 중' : '정렬 저장' }}
|
||||
</button>
|
||||
</div>
|
||||
<table class="admin-tags__table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-tags__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-tags__cell px-4 py-3">순서</th>
|
||||
<th class="admin-tags__cell px-4 py-3">번호</th>
|
||||
<th class="admin-tags__cell px-4 py-3">색상</th>
|
||||
<th class="admin-tags__cell px-4 py-3">이름</th>
|
||||
<th class="admin-tags__cell px-4 py-3">슬러그</th>
|
||||
@@ -69,9 +203,19 @@ const deleteTag = async (tag) => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-tags__table-body divide-y divide-line bg-white">
|
||||
<tr v-for="tag in tags" :key="tag.id" class="admin-tags__row">
|
||||
<tr
|
||||
v-for="(tag, index) in managedTags"
|
||||
:key="tag.id"
|
||||
class="admin-tags__row cursor-move"
|
||||
:class="dragOverTagId === tag.id ? 'bg-[#f9f9f7]' : ''"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, tag.id)"
|
||||
@dragover="handleDragOver($event, tag.id)"
|
||||
@drop="handleDrop($event, tag.id)"
|
||||
@dragend="handleDragEnd"
|
||||
>
|
||||
<td class="admin-tags__cell px-4 py-4 text-muted">
|
||||
{{ tag.sortOrder }}
|
||||
{{ index + 1 }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4">
|
||||
<span class="admin-tags__color flex items-center gap-2">
|
||||
@@ -108,6 +252,57 @@ const deleteTag = async (tag) => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
|
||||
<div class="border-b border-line bg-[#f7f7f5] px-4 py-2.5">
|
||||
<p class="text-xs font-semibold uppercase text-muted">일반 태그</p>
|
||||
</div>
|
||||
<table class="admin-tags__table-inner w-full border-collapse text-left text-sm">
|
||||
<thead class="admin-tags__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
|
||||
<tr>
|
||||
<th class="admin-tags__cell px-4 py-3">색상</th>
|
||||
<th class="admin-tags__cell px-4 py-3">이름</th>
|
||||
<th class="admin-tags__cell px-4 py-3">슬러그</th>
|
||||
<th class="admin-tags__cell px-4 py-3">설명</th>
|
||||
<th class="admin-tags__cell px-4 py-3">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="admin-tags__table-body divide-y divide-line bg-white">
|
||||
<tr v-for="tag in generalTags" :key="tag.id" class="admin-tags__row">
|
||||
<td class="admin-tags__cell px-4 py-4">
|
||||
<span class="admin-tags__color flex items-center gap-2">
|
||||
<span class="admin-tags__color-swatch h-5 w-2 rounded-full" :style="{ backgroundColor: tag.color }" />
|
||||
<span class="admin-tags__color-code text-xs text-muted">{{ tag.color }}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4 font-semibold">
|
||||
{{ tag.name }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4 text-muted">
|
||||
{{ tag.slug }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4 text-muted">
|
||||
{{ tag.description || '-' }}
|
||||
</td>
|
||||
<td class="admin-tags__cell px-4 py-4">
|
||||
<div class="admin-tags__actions flex gap-2">
|
||||
<NuxtLink class="admin-tags__edit rounded border border-line px-3 py-1.5 text-xs font-semibold" :to="`/admin/tags/${tag.id}`">
|
||||
수정
|
||||
</NuxtLink>
|
||||
<button
|
||||
class="admin-tags__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
|
||||
type="button"
|
||||
:disabled="deletingId === tag.id"
|
||||
@click="deleteTag(tag)"
|
||||
>
|
||||
{{ deletingId === tag.id ? '삭제 중' : '삭제' }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p v-if="tags.length === 0" class="admin-tags__empty mt-6 text-sm text-muted">
|
||||
아직 등록된 태그가 없습니다.
|
||||
</p>
|
||||
|
||||
@@ -6,6 +6,7 @@ definePageMeta({
|
||||
const currentStep = ref(1)
|
||||
const isSubmitting = ref(false)
|
||||
const signupCompleted = ref(false)
|
||||
const createdAdmin = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const submitErrorMessage = ref('')
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
@@ -14,6 +15,12 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
description: 'Configure your Self Host AFFiNE with a few simple settings.'
|
||||
})
|
||||
})
|
||||
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
|
||||
default: () => ({
|
||||
hasUsers: true,
|
||||
needsAdminSetup: false
|
||||
})
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
@@ -32,8 +39,13 @@ const errors = reactive({
|
||||
const showSignupPassword = ref(false)
|
||||
const showSignupPasswordConfirm = ref(false)
|
||||
|
||||
const isAdminBootstrapMode = computed(() => Boolean(bootstrapStatus.value?.needsAdminSetup))
|
||||
const welcomeTitle = computed(() => `Welcome to ${siteSettings.value?.title || 'AFFiNE'}`)
|
||||
const welcomeDescription = computed(() => siteSettings.value?.description || 'Configure your Self Host AFFiNE with a few simple settings.')
|
||||
const stepTwoTitle = computed(() => (isAdminBootstrapMode.value ? '관리자 등록' : '회원 가입'))
|
||||
const stepTwoDescription = computed(() => (isAdminBootstrapMode.value
|
||||
? '첫 번째 사용자이므로 소유자 권한이 부여됩니다. 관리 작업 및 사용자 생성이 가능합니다.'
|
||||
: '서비스 이용을 위한 회원 정보를 입력해 주세요.'))
|
||||
|
||||
/**
|
||||
* 필드 에러 메시지를 초기화한다.
|
||||
@@ -107,7 +119,7 @@ const goNextStep = async () => {
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/signup', {
|
||||
const signupResult = await $fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: form.username.trim(),
|
||||
@@ -115,11 +127,14 @@ const goNextStep = async () => {
|
||||
password: form.password
|
||||
}
|
||||
})
|
||||
createdAdmin.value = Boolean(signupResult?.isAdmin)
|
||||
|
||||
signupCompleted.value = true
|
||||
currentStep.value = 3
|
||||
statusMessage.value = '회원가입이 완료되었습니다. 잠시 후 홈으로 이동합니다.'
|
||||
await navigateTo('/')
|
||||
statusMessage.value = createdAdmin.value
|
||||
? '관리자 등록이 완료되었습니다. 관리자 화면으로 이동합니다.'
|
||||
: '회원가입이 완료되었습니다. 잠시 후 홈으로 이동합니다.'
|
||||
await navigateTo(createdAdmin.value ? '/admin' : '/')
|
||||
} catch (error) {
|
||||
submitErrorMessage.value = error?.data?.message || '회원가입에 실패했습니다.'
|
||||
} finally {
|
||||
@@ -157,15 +172,15 @@ const goPreviousStep = () => {
|
||||
|
||||
<template v-else-if="currentStep === 2">
|
||||
<p class="text-2xl font-semibold leading-tight">
|
||||
회원 가입
|
||||
{{ stepTwoTitle }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[#9ba3af]">
|
||||
처음 생성하는 계정은 관리자 계정으로 자동 생성됩니다.
|
||||
{{ stepTwoDescription }}
|
||||
</p>
|
||||
|
||||
<form class="mt-8 space-y-5" @submit.prevent="goNextStep">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">사용자명</label>
|
||||
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 이름' : '사용자명' }}</label>
|
||||
<input
|
||||
v-model="form.username"
|
||||
class="auth-form-input h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
@@ -179,7 +194,7 @@ const goPreviousStep = () => {
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">이메일</label>
|
||||
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 이메일' : '이메일' }}</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
class="auth-form-input h-10 w-full rounded-[8px] border bg-transparent px-3 text-sm outline-none transition-colors"
|
||||
@@ -193,7 +208,7 @@ const goPreviousStep = () => {
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">비밀번호</label>
|
||||
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 비밀번호' : '비밀번호' }}</label>
|
||||
<div
|
||||
class="flex items-center rounded-[8px] border transition-colors focus-within:border-[#2f6feb]"
|
||||
:class="errors.password ? 'border-[#b03b43]' : 'border-[#1a212a]'"
|
||||
@@ -212,7 +227,7 @@ const goPreviousStep = () => {
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs text-[#d8dee6]">비밀번호 확인</label>
|
||||
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 비밀번호 확인' : '비밀번호 확인' }}</label>
|
||||
<div
|
||||
class="flex items-center rounded-[8px] border transition-colors focus-within:border-[#2f6feb]"
|
||||
:class="errors.passwordConfirm ? 'border-[#b03b43]' : 'border-[#1a212a]'"
|
||||
@@ -247,11 +262,11 @@ const goPreviousStep = () => {
|
||||
|
||||
<template v-else>
|
||||
<p class="text-2xl font-semibold leading-tight">
|
||||
회원가입 완료
|
||||
{{ createdAdmin ? '관리자 등록 완료' : '회원가입 완료' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[#9ba3af]">
|
||||
{{ form.email }} 계정으로 가입되었습니다.<br>
|
||||
이제 로그인 후 댓글을 작성할 수 있습니다.
|
||||
{{ form.email }} 계정으로 {{ createdAdmin ? '관리자 등록' : '가입' }}되었습니다.<br>
|
||||
{{ createdAdmin ? '이제 관리자 화면에서 사이트 운영을 시작할 수 있습니다.' : '이제 로그인 후 댓글을 작성할 수 있습니다.' }}
|
||||
</p>
|
||||
|
||||
<div class="mt-8 rounded-[10px] border border-[#1a212a] bg-[#0d1116] p-4">
|
||||
|
||||
7
server/api/auth/bootstrap-status.get.js
vendored
Normal file
7
server/api/auth/bootstrap-status.get.js
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import { getMemberBootstrapState } from '../../repositories/member-repository'
|
||||
|
||||
/**
|
||||
* 최초 관리자 등록 필요 여부를 조회한다.
|
||||
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
|
||||
*/
|
||||
export default defineEventHandler(async () => getMemberBootstrapState())
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
import { createUser, getUserByEmail, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
|
||||
import { setMemberSession } from '../../utils/member-auth'
|
||||
import { setAdminSession } from '../../utils/admin-auth'
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string().trim().min(1),
|
||||
@@ -13,7 +14,7 @@ const signupSchema = z.object({
|
||||
/**
|
||||
* 회원 가입 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, username: string, email: string }>} 회원 정보
|
||||
* @returns {Promise<{ id: string, username: string, email: string, avatarUrl: string, isAdmin: boolean }>} 회원 정보
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parsedBody = signupSchema.safeParse(await readBody(event))
|
||||
@@ -54,6 +55,12 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
|
||||
setMemberSession(event, { userId: created.id, email: created.email })
|
||||
if (created.isAdmin) {
|
||||
setAdminSession(event, {
|
||||
userId: created.id,
|
||||
email: created.email
|
||||
})
|
||||
}
|
||||
await touchUserActivity({
|
||||
userId: created.id,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
@@ -63,7 +70,8 @@ export default defineEventHandler(async (event) => {
|
||||
id: created.id,
|
||||
username: created.username,
|
||||
email: created.email,
|
||||
avatarUrl: created.avatarUrl || ''
|
||||
avatarUrl: created.avatarUrl || '',
|
||||
isAdmin: Boolean(created.isAdmin)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { listPostCommentsBySlug } from '../../../repositories/comment-repository'
|
||||
import { getMemberSession } from '../../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 게시물 댓글 목록 조회 API
|
||||
@@ -7,7 +8,8 @@ import { listPostCommentsBySlug } from '../../../repositories/comment-repository
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = String(getRouterParam(event, 'slug') || '')
|
||||
const comments = await listPostCommentsBySlug(slug)
|
||||
const session = getMemberSession(event)
|
||||
const comments = await listPostCommentsBySlug(slug, session?.userId || null)
|
||||
|
||||
return {
|
||||
comments
|
||||
|
||||
28
server/api/posts/[slug]/comments/[commentId]/like.post.js
Normal file
28
server/api/posts/[slug]/comments/[commentId]/like.post.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getRequestIP } from 'h3'
|
||||
import { toggleCommentLike } from '../../../../../repositories/comment-repository'
|
||||
import { touchUserActivity } from '../../../../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../../../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 댓글 좋아요 토글 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ liked: boolean, likeCount: number }>} 좋아요 상태
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const slug = String(getRouterParam(event, 'slug') || '')
|
||||
const commentId = String(getRouterParam(event, 'commentId') || '')
|
||||
|
||||
const result = await toggleCommentLike({
|
||||
slug,
|
||||
commentId,
|
||||
userId: session.userId
|
||||
})
|
||||
|
||||
await touchUserActivity({
|
||||
userId: session.userId,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
@@ -4,4 +4,4 @@ import { listTags } from '../repositories/content-repository'
|
||||
* 공개 태그 목록 API
|
||||
* @returns {Array} 태그 목록
|
||||
*/
|
||||
export default defineEventHandler(() => listTags())
|
||||
export default defineEventHandler(() => listTags({ tagType: 'managed' }))
|
||||
|
||||
@@ -10,7 +10,9 @@ import { getPostgresClient } from './postgres-client'
|
||||
* @property {string} status - 댓글 상태
|
||||
* @property {string} createdAt - 생성 시각
|
||||
* @property {string} updatedAt - 수정 시각
|
||||
* @property {{ id: string, username: string }} user - 작성자 정보
|
||||
* @property {number} likeCount - 좋아요 수
|
||||
* @property {boolean} likedByMe - 현재 회원 좋아요 여부
|
||||
* @property {{ id: string, username: string, avatarUrl: string }} user - 작성자 정보
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -19,6 +21,13 @@ import { getPostgresClient } from './postgres-client'
|
||||
*/
|
||||
const getSql = () => getPostgresClient()
|
||||
|
||||
/**
|
||||
* Postgres undefined table 에러 여부 확인
|
||||
* @param {unknown} error - 에러 객체
|
||||
* @returns {boolean} undefined table 여부
|
||||
*/
|
||||
const isUndefinedTableError = (error) => String(error?.code || '') === '42P01'
|
||||
|
||||
/**
|
||||
* 게시물 ID 조회
|
||||
* @param {ReturnType<typeof import('postgres').default>} sql - postgres 클라이언트
|
||||
@@ -46,7 +55,7 @@ const findPublishedPostIdBySlug = async (sql, slug) => {
|
||||
* @param {string} slug - 게시물 슬러그
|
||||
* @returns {Promise<Array<PostComment>>} 댓글 목록
|
||||
*/
|
||||
export const listPostCommentsBySlug = async (slug) => {
|
||||
export const listPostCommentsBySlug = async (slug, viewerUserId = null) => {
|
||||
const sql = getSql()
|
||||
|
||||
if (!sql) {
|
||||
@@ -61,23 +70,64 @@ export const listPostCommentsBySlug = async (slug) => {
|
||||
})
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
comments.id,
|
||||
comments.post_id AS "postId",
|
||||
comments.parent_id AS "parentId",
|
||||
comments.body,
|
||||
comments.status,
|
||||
comments.created_at AS "createdAt",
|
||||
comments.updated_at AS "updatedAt",
|
||||
users.id AS "userId",
|
||||
users.username AS "username"
|
||||
FROM comments
|
||||
INNER JOIN users ON users.id = comments.user_id
|
||||
WHERE comments.post_id = ${postId}
|
||||
AND comments.status = 'published'
|
||||
ORDER BY comments.created_at ASC
|
||||
`
|
||||
let rows = []
|
||||
|
||||
try {
|
||||
rows = await sql`
|
||||
SELECT
|
||||
comments.id,
|
||||
comments.post_id AS "postId",
|
||||
comments.parent_id AS "parentId",
|
||||
comments.body,
|
||||
comments.status,
|
||||
comments.created_at AS "createdAt",
|
||||
comments.updated_at AS "updatedAt",
|
||||
users.id AS "userId",
|
||||
users.username AS "username",
|
||||
users.avatar_url AS "avatarUrl",
|
||||
COALESCE(comment_like_counts.like_count, 0) AS "likeCount",
|
||||
CASE
|
||||
WHEN viewer_comment_likes.user_id IS NULL THEN false
|
||||
ELSE true
|
||||
END AS "likedByMe"
|
||||
FROM comments
|
||||
INNER JOIN users ON users.id = comments.user_id
|
||||
LEFT JOIN (
|
||||
SELECT comment_id, COUNT(*)::INT AS like_count
|
||||
FROM comment_likes
|
||||
GROUP BY comment_id
|
||||
) AS comment_like_counts ON comment_like_counts.comment_id = comments.id
|
||||
LEFT JOIN comment_likes AS viewer_comment_likes
|
||||
ON viewer_comment_likes.comment_id = comments.id
|
||||
AND viewer_comment_likes.user_id = ${viewerUserId}
|
||||
WHERE comments.post_id = ${postId}
|
||||
AND comments.status = 'published'
|
||||
ORDER BY comments.created_at ASC
|
||||
`
|
||||
} catch (error) {
|
||||
if (!isUndefinedTableError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
rows = await sql`
|
||||
SELECT
|
||||
comments.id,
|
||||
comments.post_id AS "postId",
|
||||
comments.parent_id AS "parentId",
|
||||
comments.body,
|
||||
comments.status,
|
||||
comments.created_at AS "createdAt",
|
||||
comments.updated_at AS "updatedAt",
|
||||
users.id AS "userId",
|
||||
users.username AS "username",
|
||||
users.avatar_url AS "avatarUrl"
|
||||
FROM comments
|
||||
INNER JOIN users ON users.id = comments.user_id
|
||||
WHERE comments.post_id = ${postId}
|
||||
AND comments.status = 'published'
|
||||
ORDER BY comments.created_at ASC
|
||||
`
|
||||
}
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
@@ -87,9 +137,12 @@ export const listPostCommentsBySlug = async (slug) => {
|
||||
status: row.status,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
likeCount: Number(row.likeCount || 0),
|
||||
likedByMe: Boolean(row.likedByMe),
|
||||
user: {
|
||||
id: row.userId,
|
||||
username: row.username
|
||||
username: row.username,
|
||||
avatarUrl: row.avatarUrl || ''
|
||||
}
|
||||
}))
|
||||
}
|
||||
@@ -165,7 +218,7 @@ export const createComment = async (input) => {
|
||||
}
|
||||
|
||||
const userRows = await sql`
|
||||
SELECT id, username
|
||||
SELECT id, username, avatar_url
|
||||
FROM users
|
||||
WHERE id = ${input.userId}
|
||||
LIMIT 1
|
||||
@@ -189,8 +242,84 @@ export const createComment = async (input) => {
|
||||
updatedAt: created.updatedAt.toISOString(),
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username
|
||||
username: user.username,
|
||||
avatarUrl: user.avatar_url || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 좋아요를 토글한다.
|
||||
* @param {{ slug: string, commentId: string, userId: string }} input - 좋아요 입력값
|
||||
* @returns {Promise<{ liked: boolean, likeCount: number }>} 좋아요 결과
|
||||
*/
|
||||
export const toggleCommentLike = async (input) => {
|
||||
const sql = getSql()
|
||||
|
||||
if (!sql) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '데이터베이스 설정이 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const postId = await findPublishedPostIdBySlug(sql, input.slug)
|
||||
if (!postId) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '게시물을 찾을 수 없습니다'
|
||||
})
|
||||
}
|
||||
|
||||
const commentRows = await sql`
|
||||
SELECT id
|
||||
FROM comments
|
||||
WHERE id = ${input.commentId}
|
||||
AND post_id = ${postId}
|
||||
AND status = 'published'
|
||||
LIMIT 1
|
||||
`
|
||||
const comment = commentRows?.[0]
|
||||
|
||||
if (!comment) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '댓글을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const likedRows = await sql`
|
||||
SELECT 1
|
||||
FROM comment_likes
|
||||
WHERE comment_id = ${input.commentId}
|
||||
AND user_id = ${input.userId}
|
||||
LIMIT 1
|
||||
`
|
||||
const alreadyLiked = Boolean(likedRows?.[0])
|
||||
|
||||
if (alreadyLiked) {
|
||||
await sql`
|
||||
DELETE FROM comment_likes
|
||||
WHERE comment_id = ${input.commentId}
|
||||
AND user_id = ${input.userId}
|
||||
`
|
||||
} else {
|
||||
await sql`
|
||||
INSERT INTO comment_likes (comment_id, user_id)
|
||||
VALUES (${input.commentId}, ${input.userId})
|
||||
ON CONFLICT (comment_id, user_id) DO NOTHING
|
||||
`
|
||||
}
|
||||
|
||||
const countRows = await sql`
|
||||
SELECT COUNT(*)::INT AS like_count
|
||||
FROM comment_likes
|
||||
WHERE comment_id = ${input.commentId}
|
||||
`
|
||||
|
||||
return {
|
||||
liked: !alreadyLiked,
|
||||
likeCount: Number(countRows?.[0]?.like_count || 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,8 @@ const mapTagRow = (row) => ({
|
||||
slug: row.slug,
|
||||
description: row.description,
|
||||
sortOrder: row.sort_order,
|
||||
color: row.color
|
||||
color: row.color,
|
||||
tagType: row.tag_type || 'managed'
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -532,22 +533,45 @@ export const getPageBySlug = async (slug) => {
|
||||
* 공개 태그 목록 조회
|
||||
* @returns {Promise<Array>} 태그 목록
|
||||
*/
|
||||
export const listTags = async () => {
|
||||
export const listTags = async ({ tagType } = {}) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return getSampleTags()
|
||||
const sampleTags = getSampleTags().map((tag) => ({
|
||||
...tag,
|
||||
tagType: 'managed'
|
||||
}))
|
||||
if (!tagType) {
|
||||
return sampleTags
|
||||
}
|
||||
return sampleTags.filter((tag) => tag.tagType === tagType)
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT *
|
||||
FROM tags
|
||||
ORDER BY sort_order ASC, name ASC
|
||||
`
|
||||
const rows = tagType
|
||||
? await sql`
|
||||
SELECT *
|
||||
FROM tags
|
||||
WHERE tag_type = ${tagType}
|
||||
ORDER BY sort_order ASC, name ASC
|
||||
`
|
||||
: await sql`
|
||||
SELECT *
|
||||
FROM tags
|
||||
ORDER BY
|
||||
CASE tag_type WHEN 'managed' THEN 0 ELSE 1 END ASC,
|
||||
sort_order ASC,
|
||||
name ASC
|
||||
`
|
||||
|
||||
return rows.map(mapTagRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 태그 목록 조회
|
||||
* @returns {Promise<Array>} 관리자 태그 목록
|
||||
*/
|
||||
export const listAdminTags = async () => listTags()
|
||||
|
||||
const SEARCH_TAG_LIMIT = 12
|
||||
const SEARCH_POST_LIMIT = 12
|
||||
const SEARCH_POST_CANDIDATE_LIMIT = 48
|
||||
@@ -823,8 +847,8 @@ export const createAdminTag = async (input) => {
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO tags (name, slug, description, sort_order, color)
|
||||
VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.sortOrder}, ${input.color})
|
||||
INSERT INTO tags (name, slug, description, sort_order, color, tag_type)
|
||||
VALUES (${input.name}, ${input.slug}, ${input.description}, ${input.sortOrder}, ${input.color}, ${input.tagType})
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
@@ -852,6 +876,7 @@ export const updateAdminTag = async (id, input) => {
|
||||
description = ${input.description},
|
||||
sort_order = ${input.sortOrder},
|
||||
color = ${input.color},
|
||||
tag_type = ${input.tagType},
|
||||
updated_at = now()
|
||||
WHERE id = ${id}
|
||||
RETURNING *
|
||||
@@ -860,6 +885,35 @@ export const updateAdminTag = async (id, input) => {
|
||||
return rows[0] ? mapTagRow(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 관리용 태그 순서를 일괄 갱신
|
||||
* @param {Array<string>} tagIds - 정렬된 태그 ID 목록
|
||||
* @returns {Promise<Array>} 갱신된 태그 목록
|
||||
*/
|
||||
export const reorderManagedTags = async (tagIds) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
await sql.begin(async (transaction) => {
|
||||
for (let index = 0; index < tagIds.length; index += 1) {
|
||||
const tagId = tagIds[index]
|
||||
await transaction`
|
||||
UPDATE tags
|
||||
SET
|
||||
sort_order = ${(index + 1) * 10},
|
||||
updated_at = now()
|
||||
WHERE id = ${tagId}
|
||||
AND tag_type = 'managed'
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
return listTags({ tagType: 'managed' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 태그 삭제
|
||||
* @param {string} id - 태그 ID
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createError } from 'h3'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
|
||||
export const MEMBER_ROLE = {
|
||||
OWNER: 'owner',
|
||||
ADMIN: 'admin',
|
||||
MEMBER: 'member'
|
||||
}
|
||||
|
||||
const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
|
||||
|
||||
/**
|
||||
* @typedef {Object} MemberUser
|
||||
* @property {string} id - 사용자 ID
|
||||
@@ -8,6 +16,8 @@ import { getPostgresClient } from './postgres-client'
|
||||
* @property {string} email - 이메일
|
||||
* @property {string} passwordHash - 비밀번호 해시
|
||||
* @property {string} avatarUrl - 아바타 URL
|
||||
* @property {boolean} isAdmin - 관리자 여부
|
||||
* @property {'owner' | 'admin' | 'member'} role - 권한 코드
|
||||
* @property {string} createdAt - 생성 시각(ISO)
|
||||
* @property {string} updatedAt - 수정 시각(ISO)
|
||||
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
|
||||
@@ -43,6 +53,8 @@ export const getUserByEmail = async (email) => {
|
||||
email,
|
||||
password_hash AS "passwordHash",
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -68,6 +80,8 @@ export const getUserById = async (id) => {
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -94,6 +108,8 @@ export const getUserByIdWithPassword = async (id) => {
|
||||
email,
|
||||
password_hash AS "passwordHash",
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -115,13 +131,25 @@ export const createUser = async (input) => {
|
||||
const sql = requireSql()
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO users (username, email, password_hash, avatar_url)
|
||||
VALUES (${input.username}, ${input.email}, ${input.passwordHash}, '')
|
||||
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
|
||||
VALUES (
|
||||
${input.username},
|
||||
${input.email},
|
||||
${input.passwordHash},
|
||||
'',
|
||||
NOT EXISTS (SELECT 1 FROM users),
|
||||
CASE
|
||||
WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER}
|
||||
ELSE ${MEMBER_ROLE.MEMBER}
|
||||
END
|
||||
)
|
||||
RETURNING
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -175,6 +203,8 @@ export const updateMemberProfile = async (input) => {
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
@@ -240,7 +270,7 @@ export const isUsernameTaken = async (input) => {
|
||||
|
||||
/**
|
||||
* 관리자용 회원 목록 조회(댓글 활동 포함)
|
||||
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, commentCount: number, activityStatus: string }>>} 회원 목록
|
||||
* @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 }>>} 회원 목록
|
||||
*/
|
||||
export const listMembersForAdmin = async () => {
|
||||
const sql = requireSql()
|
||||
@@ -250,6 +280,8 @@ export const listMembersForAdmin = async () => {
|
||||
users.username,
|
||||
users.email,
|
||||
users.avatar_url AS "avatarUrl",
|
||||
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",
|
||||
@@ -272,13 +304,181 @@ export const listMembersForAdmin = async () => {
|
||||
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 ? '활성' : '비활성'
|
||||
activityStatus: isActive ? '활성' : '비활성',
|
||||
role: row.roleCode === MEMBER_ROLE.OWNER
|
||||
? '소유자'
|
||||
: row.roleCode === MEMBER_ROLE.ADMIN
|
||||
? '관리자'
|
||||
: '멤버'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 기준 관리자 회원 조회
|
||||
* @param {string} email - 이메일
|
||||
* @returns {Promise<MemberUser | null>} 관리자 회원
|
||||
*/
|
||||
export const getAdminUserByEmail = async (email) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash AS "passwordHash",
|
||||
avatar_url AS "avatarUrl",
|
||||
is_admin AS "isAdmin",
|
||||
user_role AS "role",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
FROM users
|
||||
WHERE lower(email) = lower(${email})
|
||||
AND user_role = ANY(${PRIVILEGED_ROLES})
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 최초 관리자 등록 필요 여부를 확인한다.
|
||||
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
|
||||
*/
|
||||
export const getMemberBootstrapState = async () => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT COUNT(*)::int AS "userCount"
|
||||
FROM users
|
||||
`
|
||||
|
||||
const userCount = Number(rows?.[0]?.userCount || 0)
|
||||
|
||||
return {
|
||||
hasUsers: userCount > 0,
|
||||
needsAdminSetup: userCount === 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 권한을 가진 회원 여부를 확인한다.
|
||||
* @param {string} userId - 사용자 ID
|
||||
* @returns {Promise<boolean>} 관리자 권한 여부
|
||||
*/
|
||||
export const isPrivilegedMember = async (userId) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE id = ${userId}
|
||||
AND user_role = ANY(${PRIVILEGED_ROLES})
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return Boolean(rows?.[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 화면에서 회원 권한을 변경한다.
|
||||
* @param {{ actorUserId: string, targetUserId: string, role: 'owner' | 'admin' | 'member' }} input - 변경 정보
|
||||
* @returns {Promise<{ id: string, roleCode: string, role: string, isAdmin: boolean }>} 변경 결과
|
||||
*/
|
||||
export const updateMemberRoleByAdmin = async (input) => {
|
||||
const sql = requireSql()
|
||||
const normalizedRole = String(input.role || '').trim()
|
||||
const allowedRoles = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN, MEMBER_ROLE.MEMBER]
|
||||
|
||||
if (!allowedRoles.includes(normalizedRole)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '유효하지 않은 권한 값입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const actorCanManage = await isPrivilegedMember(input.actorUserId)
|
||||
if (!actorCanManage) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '권한 변경 권한이 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const targetRows = await sql`
|
||||
SELECT id, user_role AS "roleCode"
|
||||
FROM users
|
||||
WHERE id = ${input.targetUserId}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
const target = targetRows?.[0]
|
||||
if (!target) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '대상 회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.id === input.actorUserId && normalizedRole === MEMBER_ROLE.MEMBER) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '본인 계정을 멤버로 변경할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.roleCode === MEMBER_ROLE.OWNER && normalizedRole !== MEMBER_ROLE.OWNER) {
|
||||
const ownerRows = await sql`
|
||||
SELECT COUNT(*)::int AS "ownerCount"
|
||||
FROM users
|
||||
WHERE user_role = ${MEMBER_ROLE.OWNER}
|
||||
`
|
||||
|
||||
if (Number(ownerRows?.[0]?.ownerCount || 0) <= 1) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '최소 1명의 소유자 권한은 유지되어야 합니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRows = await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
user_role = ${normalizedRole},
|
||||
is_admin = ${normalizedRole === MEMBER_ROLE.OWNER || normalizedRole === MEMBER_ROLE.ADMIN},
|
||||
updated_at = now()
|
||||
WHERE id = ${input.targetUserId}
|
||||
RETURNING
|
||||
id,
|
||||
user_role AS "roleCode",
|
||||
is_admin AS "isAdmin"
|
||||
`
|
||||
|
||||
const updated = updatedRows?.[0]
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '권한 변경에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
roleCode: updated.roleCode,
|
||||
role: updated.roleCode === MEMBER_ROLE.OWNER
|
||||
? '소유자'
|
||||
: updated.roleCode === MEMBER_ROLE.ADMIN
|
||||
? '관리자'
|
||||
: '멤버',
|
||||
isAdmin: Boolean(updated.isAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { safeCompare, setAdminSession } from '../../../../utils/admin-auth'
|
||||
import bcrypt from 'bcrypt'
|
||||
import { setAdminSession } from '../../../../utils/admin-auth'
|
||||
import { getAdminUserByEmail } from '../../../../repositories/member-repository'
|
||||
import { setMemberSession } from '../../../../utils/member-auth'
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -14,7 +17,6 @@ const loginSchema = z.object({
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parsedBody = loginSchema.safeParse(await readBody(event))
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
@@ -25,19 +27,30 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const body = parsedBody.data
|
||||
|
||||
if (
|
||||
!safeCompare(body.email, config.adminEmail) ||
|
||||
!safeCompare(body.password, config.adminPassword)
|
||||
) {
|
||||
const adminUser = await getAdminUserByEmail(body.email)
|
||||
const passwordMatched = adminUser
|
||||
? await bcrypt.compare(body.password, adminUser.passwordHash)
|
||||
: false
|
||||
|
||||
if (!adminUser || !passwordMatched) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
setAdminSession(event, body.email)
|
||||
setAdminSession(event, {
|
||||
userId: adminUser.id,
|
||||
email: adminUser.email
|
||||
})
|
||||
setMemberSession(event, {
|
||||
userId: adminUser.id,
|
||||
email: adminUser.email
|
||||
})
|
||||
|
||||
return {
|
||||
email: body.email
|
||||
userId: adminUser.id,
|
||||
email: adminUser.email,
|
||||
username: adminUser.username
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { clearAdminSession } from '../../../../utils/admin-auth'
|
||||
import { clearMemberSession } from '../../../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 관리자 로그아웃 API
|
||||
@@ -7,6 +8,7 @@ import { clearAdminSession } from '../../../../utils/admin-auth'
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
clearAdminSession(event)
|
||||
clearMemberSession(event)
|
||||
|
||||
return {
|
||||
ok: true
|
||||
|
||||
@@ -3,6 +3,6 @@ import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
/**
|
||||
* 관리자 세션 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ email: string }} 관리자 세션 정보
|
||||
* @returns {{ userId: string, email: string, role: 'admin' }} 관리자 세션 정보
|
||||
*/
|
||||
export default defineEventHandler((event) => requireAdminSession(event))
|
||||
|
||||
39
server/routes/admin/api/members/[id]/role.put.js
Normal file
39
server/routes/admin/api/members/[id]/role.put.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createError, getRouterParam, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||
import { updateMemberRoleByAdmin } from '../../../../../repositories/member-repository'
|
||||
|
||||
const roleSchema = z.object({
|
||||
role: z.enum(['owner', 'admin', 'member'])
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 회원 권한 변경 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, roleCode: string, role: string, isAdmin: boolean }>} 변경 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
const parsedBody = roleSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '대상 회원 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '권한 변경 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return updateMemberRoleByAdmin({
|
||||
actorUserId: session.userId,
|
||||
targetUserId: memberId,
|
||||
role: parsedBody.data.role
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { listTags } from '../../../repositories/content-repository'
|
||||
import { listAdminTags } from '../../../repositories/content-repository'
|
||||
|
||||
/**
|
||||
* 관리자 태그 목록 API
|
||||
@@ -9,5 +9,5 @@ import { listTags } from '../../../repositories/content-repository'
|
||||
export default defineEventHandler((event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
return listTags()
|
||||
return listAdminTags()
|
||||
})
|
||||
|
||||
27
server/routes/admin/api/tags/reorder.put.js
Normal file
27
server/routes/admin/api/tags/reorder.put.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { reorderManagedTags } from '../../../../repositories/content-repository'
|
||||
|
||||
const reorderSchema = z.object({
|
||||
tagIds: z.array(z.string().uuid()).min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 관리용 태그 순서 일괄 저장 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Array>} 정렬 저장 후 태그 목록
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const parsedBody = reorderSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '정렬 저장 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return reorderManagedTags(parsedBody.data.tagIds)
|
||||
})
|
||||
@@ -49,12 +49,14 @@ const signPayload = (payload) => createHmac('sha256', getSessionSecret())
|
||||
|
||||
/**
|
||||
* 관리자 세션 토큰 생성
|
||||
* @param {string} email - 관리자 이메일
|
||||
* @param {{ userId: string, email: string }} adminUser - 관리자 사용자 정보
|
||||
* @returns {string} 세션 토큰
|
||||
*/
|
||||
export const createAdminSessionToken = (email) => {
|
||||
export const createAdminSessionToken = (adminUser) => {
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
email,
|
||||
userId: adminUser.userId,
|
||||
email: adminUser.email,
|
||||
role: 'admin',
|
||||
expiresAt: Date.now() + sessionMaxAge * 1000
|
||||
})).toString('base64url')
|
||||
|
||||
@@ -64,7 +66,7 @@ export const createAdminSessionToken = (email) => {
|
||||
/**
|
||||
* 관리자 세션 토큰 검증
|
||||
* @param {string | undefined} token - 세션 토큰
|
||||
* @returns {{ email: string } | null} 세션 정보
|
||||
* @returns {{ userId: string, email: string, role: 'admin' } | null} 세션 정보
|
||||
*/
|
||||
export const verifyAdminSessionToken = (token) => {
|
||||
if (!token) {
|
||||
@@ -85,23 +87,25 @@ export const verifyAdminSessionToken = (token) => {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!session.email || !session.expiresAt || session.expiresAt < Date.now()) {
|
||||
if (!session.userId || !session.email || session.role !== 'admin' || !session.expiresAt || session.expiresAt < Date.now()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
email: session.email
|
||||
userId: session.userId,
|
||||
email: session.email,
|
||||
role: 'admin'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 세션 쿠키 설정
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {string} email - 관리자 이메일
|
||||
* @param {{ userId: string, email: string }} adminUser - 관리자 사용자 정보
|
||||
* @returns {void}
|
||||
*/
|
||||
export const setAdminSession = (event, email) => {
|
||||
setCookie(event, adminSessionCookieName, createAdminSessionToken(email), {
|
||||
export const setAdminSession = (event, adminUser) => {
|
||||
setCookie(event, adminSessionCookieName, createAdminSessionToken(adminUser), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
@@ -124,14 +128,14 @@ export const clearAdminSession = (event) => {
|
||||
/**
|
||||
* 관리자 세션 조회
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ email: string } | null} 세션 정보
|
||||
* @returns {{ userId: string, email: string, role: 'admin' } | null} 세션 정보
|
||||
*/
|
||||
export const getAdminSession = (event) => verifyAdminSessionToken(getCookie(event, adminSessionCookieName))
|
||||
|
||||
/**
|
||||
* 관리자 세션 필수 확인
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ email: string }} 세션 정보
|
||||
* @returns {{ userId: string, email: string, role: 'admin' }} 세션 정보
|
||||
*/
|
||||
export const requireAdminSession = (event) => {
|
||||
const session = getAdminSession(event)
|
||||
|
||||
@@ -5,7 +5,8 @@ export const adminTagInputSchema = z.object({
|
||||
slug: z.string().trim().min(1).regex(/^[a-z0-9가-힣]+(?:-[a-z0-9가-힣]+)*$/),
|
||||
description: z.string().default(''),
|
||||
sortOrder: z.number().int().min(0).default(0),
|
||||
color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).default('#15171a')
|
||||
color: z.string().trim().regex(/^#[0-9a-fA-F]{6}$/).default('#15171a'),
|
||||
tagType: z.enum(['managed', 'general']).default('managed')
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user