feat(member): 회원 설정/헤더 상태 UI와 관리자 멤버 관리 추가
로그인 상태를 헤더에서 즉시 인지하고 계정 관리를 이어갈 수 있도록 사용자 설정과 관리자 멤버 관측 기능을 연결했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@ DB_PORT=43119
|
||||
# Auth
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
|
||||
305
components/comments/PostComments.vue
Normal file
305
components/comments/PostComments.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
slug: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const comments = ref([])
|
||||
const member = ref(null)
|
||||
const loadingComments = ref(false)
|
||||
const submitting = ref(false)
|
||||
const submittingReplyId = ref('')
|
||||
const errorMessage = ref('')
|
||||
const replyErrorMessage = ref('')
|
||||
const newCommentBody = ref('')
|
||||
const replyBody = ref('')
|
||||
const activeReplyTargetId = ref('')
|
||||
|
||||
/**
|
||||
* ISO 문자열을 표시용 날짜 문자열로 변환한다.
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {string} 표시용 문자열
|
||||
*/
|
||||
const formatCommentDate = (value) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션을 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
member.value = await $fetch('/api/auth/me')
|
||||
} catch {
|
||||
member.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 목록을 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchComments = async () => {
|
||||
loadingComments.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const response = await $fetch(`/api/posts/${props.slug}/comments`)
|
||||
comments.value = response.comments || []
|
||||
} catch (error) {
|
||||
comments.value = []
|
||||
errorMessage.value = error?.data?.message || '댓글을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
loadingComments.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 루트 댓글을 작성한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const submitComment = async () => {
|
||||
const body = newCommentBody.value.trim()
|
||||
if (!body || submitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/api/posts/${props.slug}/comments`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
body
|
||||
}
|
||||
})
|
||||
newCommentBody.value = ''
|
||||
await fetchComments()
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '댓글 작성에 실패했습니다.'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 답글 작성 UI를 연다.
|
||||
* @param {string} commentId - 대상 댓글 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const openReplyForm = (commentId) => {
|
||||
activeReplyTargetId.value = commentId
|
||||
replyBody.value = ''
|
||||
replyErrorMessage.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 답글 작성 UI를 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeReplyForm = () => {
|
||||
activeReplyTargetId.value = ''
|
||||
replyBody.value = ''
|
||||
replyErrorMessage.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 대댓글을 작성한다.
|
||||
* @param {string} parentId - 부모 댓글 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const submitReply = async (parentId) => {
|
||||
const body = replyBody.value.trim()
|
||||
if (!body || submittingReplyId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
submittingReplyId.value = parentId
|
||||
replyErrorMessage.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/api/posts/${props.slug}/comments`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
body,
|
||||
parentId
|
||||
}
|
||||
})
|
||||
closeReplyForm()
|
||||
await fetchComments()
|
||||
} catch (error) {
|
||||
replyErrorMessage.value = error?.data?.message || '답글 작성에 실패했습니다.'
|
||||
} finally {
|
||||
submittingReplyId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const rootComments = computed(() => comments.value.filter((item) => !item.parentId))
|
||||
const repliesByParent = computed(() => {
|
||||
/** @type {Record<string, Array<any>>} */
|
||||
const grouped = {}
|
||||
|
||||
for (const item of comments.value) {
|
||||
if (!item.parentId) {
|
||||
continue
|
||||
}
|
||||
if (!grouped[item.parentId]) {
|
||||
grouped[item.parentId] = []
|
||||
}
|
||||
grouped[item.parentId].push(item)
|
||||
}
|
||||
|
||||
return grouped
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchMember(), fetchComments()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div v-if="member" class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3">
|
||||
<p class="mb-2 text-xs site-muted">
|
||||
{{ member.username || member.email }} 님으로 댓글 작성
|
||||
</p>
|
||||
<textarea
|
||||
v-model="newCommentBody"
|
||||
rows="4"
|
||||
class="w-full rounded-[10px] border border-[var(--site-line)] bg-transparent px-3 py-2 outline-none focus-visible:border-[var(--site-accent)]"
|
||||
placeholder="댓글을 입력해 주세요."
|
||||
/>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="submitting"
|
||||
@click="submitComment"
|
||||
>
|
||||
{{ submitting ? '등록 중...' : '댓글 등록' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3">
|
||||
<p class="site-muted">
|
||||
댓글은 로그인한 회원만 작성할 수 있습니다.
|
||||
</p>
|
||||
<NuxtLink to="/signin" class="mt-2 inline-flex rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs font-semibold hover:opacity-80">
|
||||
로그인하러 가기
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="mt-3 text-xs text-red-500">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="mt-5">
|
||||
<p v-if="loadingComments" class="text-xs site-muted">
|
||||
댓글을 불러오는 중입니다.
|
||||
</p>
|
||||
|
||||
<ul v-else-if="rootComments.length > 0" class="flex flex-col gap-3">
|
||||
<li
|
||||
v-for="comment in rootComments"
|
||||
:key="comment.id"
|
||||
class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3"
|
||||
>
|
||||
<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>
|
||||
|
||||
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] border border-[var(--site-line)] p-2">
|
||||
<textarea
|
||||
v-model="replyBody"
|
||||
rows="3"
|
||||
class="w-full rounded-[10px] border border-[var(--site-line)] bg-transparent px-3 py-2 outline-none focus-visible:border-[var(--site-accent)]"
|
||||
placeholder="답글을 입력해 주세요."
|
||||
/>
|
||||
<p v-if="replyErrorMessage" class="mt-2 text-xs text-red-500">
|
||||
{{ replyErrorMessage }}
|
||||
</p>
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<button type="button" class="rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs" @click="closeReplyForm">
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="submittingReplyId === comment.id"
|
||||
@click="submitReply(comment.id)"
|
||||
>
|
||||
{{ submittingReplyId === comment.id ? '등록 중...' : '답글 등록' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="repliesByParent[comment.id]?.length"
|
||||
class="mt-3 flex flex-col gap-2 border-l border-[var(--site-line)] pl-3"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="text-xs site-muted">
|
||||
첫 댓글을 남겨보세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,7 @@ const menuUserOpen = ref(false)
|
||||
const userMenuRef = ref(null)
|
||||
const userMenuToggleRef = ref(null)
|
||||
const searchOpen = ref(false)
|
||||
const member = ref(null)
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
@@ -58,6 +59,31 @@ const toggleUserMenu = () => {
|
||||
menuUserOpen.value = !menuUserOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션 정보를 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
member.value = await $fetch('/api/auth/me')
|
||||
} catch {
|
||||
member.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 로그아웃을 처리한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const logoutMember = async () => {
|
||||
await $fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
member.value = null
|
||||
closeUserMenu()
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 클릭 시 사용자 메뉴 외부 영역이면 메뉴를 닫는다.
|
||||
* @param {MouseEvent} event - 클릭 이벤트
|
||||
@@ -113,6 +139,7 @@ const onGlobalKeydown = (event) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMember()
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
document.addEventListener('keydown', onGlobalKeydown)
|
||||
})
|
||||
@@ -170,9 +197,6 @@ onBeforeUnmount(() => {
|
||||
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
|
||||
</button>
|
||||
<nav class="site-header__nav flex shrink-0 items-center gap-3 text-sm sm:gap-3.5">
|
||||
<NuxtLink class="site-header__buy site-accent-button shrink-0 rounded-lg px-3 py-1.5 text-xs font-semibold sm:px-4 sm:py-2 sm:text-sm" to="/pages/about">
|
||||
Subscribe
|
||||
</NuxtLink>
|
||||
<div class="site-header__user-menu relative">
|
||||
<button
|
||||
ref="userMenuToggleRef"
|
||||
@@ -182,11 +206,15 @@ onBeforeUnmount(() => {
|
||||
:aria-expanded="menuUserOpen.toString()"
|
||||
@click="toggleUserMenu"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" class="h-6 w-6 stroke-current stroke-[1.75] md:h-7 md:w-7 md:stroke-[1.5]">
|
||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||
<path d="M12 10m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
||||
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855" />
|
||||
</svg>
|
||||
<img
|
||||
v-if="member?.avatarUrl"
|
||||
:src="member.avatarUrl"
|
||||
:alt="member.username || '회원 아바타'"
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
>
|
||||
<span v-else class="grid h-full w-full place-items-center rounded-full bg-[var(--site-panel)] text-[11px] font-semibold">
|
||||
{{ (member?.username || member?.email || '@').slice(0, 1).toUpperCase() }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
@@ -204,30 +232,62 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-2 border-b border-[var(--site-line)] pb-3">
|
||||
<div class="site-header__avatar-wrap flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-[var(--site-panel)] md:h-10 md:w-10">
|
||||
<span class="text-base font-normal uppercase md:text-lg">@</span>
|
||||
<img
|
||||
v-if="member?.avatarUrl"
|
||||
:src="member.avatarUrl"
|
||||
:alt="member.username || '회원 아바타'"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<span v-else class="text-base font-normal uppercase md:text-lg">
|
||||
{{ (member?.username || member?.email || '@').slice(0, 1) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="max-w-xs truncate leading-[1.15]">Anonymous</div>
|
||||
<div class="max-w-xs truncate leading-[1.15]">
|
||||
{{ member?.username || 'Anonymous' }}
|
||||
</div>
|
||||
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
|
||||
{{ member.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
|
||||
<path d="M15 9l-6 6" />
|
||||
<path d="M15 15v-6h-6" />
|
||||
</svg>
|
||||
<span>Sign up</span>
|
||||
</NuxtLink>
|
||||
<template v-if="member">
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/settings" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Z" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 1-2 0 1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 1 0-2 1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 1 2 0 1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.24.36.48.69.6 1 .18.45.18 1.55 0 2-.12.31-.36.64-.6 1Z" />
|
||||
</svg>
|
||||
<span>설정</span>
|
||||
</NuxtLink>
|
||||
<button class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 text-left transition-colors duration-150 hover:bg-[var(--site-panel)]" type="button" @click="logoutMember">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M21 12h-13l3 -3" />
|
||||
<path d="M11 15l-3 -3" />
|
||||
</svg>
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
|
||||
<path d="M15 9l-6 6" />
|
||||
<path d="M15 15v-6h-6" />
|
||||
</svg>
|
||||
<span>Sign up</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M21 12h-13l3 -3" />
|
||||
<path d="M11 15l-3 -3" />
|
||||
</svg>
|
||||
<span>Sign in</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M21 12h-13l3 -3" />
|
||||
<path d="M11 15l-3 -3" />
|
||||
</svg>
|
||||
<span>Sign in</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
30
db/migrations/010_add_members_and_comments.sql
Normal file
30
db/migrations/010_add_members_and_comments.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS users_email_idx
|
||||
ON users (email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
parent_id UUID REFERENCES comments(id) ON DELETE CASCADE,
|
||||
body TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'published',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT comments_status_check CHECK (status IN ('published', 'pending', 'blocked'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS comments_post_id_created_at_idx
|
||||
ON comments (post_id, created_at ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS comments_parent_id_idx
|
||||
ON comments (parent_id);
|
||||
|
||||
25
db/migrations/011_add_member_profile_and_activity.sql
Normal file
25
db/migrations/011_add_member_profile_and_activity.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS avatar_url TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS last_seen_ip TEXT NOT NULL DEFAULT '';
|
||||
|
||||
WITH deduplicated_users AS (
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
ROW_NUMBER() OVER (PARTITION BY lower(username) ORDER BY created_at ASC, id ASC) AS row_number
|
||||
FROM users
|
||||
)
|
||||
UPDATE users
|
||||
SET username = deduplicated_users.username || '-' || deduplicated_users.row_number
|
||||
FROM deduplicated_users
|
||||
WHERE users.id = deduplicated_users.id
|
||||
AND deduplicated_users.row_number > 1;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique_idx
|
||||
ON users (lower(username));
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-11 v0.0.71
|
||||
|
||||
### 회원 UX를 헤더 중심으로 전환
|
||||
|
||||
기존 헤더는 로그인 여부와 무관하게 Anonymous 메뉴를 고정으로 보여 실제 로그인 상태가 사용자에게 전달되지 않았다. 구독 버튼 대신 우측 아바타만 남기고, 로그인 상태에서는 설정/로그아웃 메뉴를 제공해 계정 액션을 한 위치로 정리했다. 비로그인 상태에서는 기존 Sign up/Sign in을 유지한다.
|
||||
|
||||
### 회원 설정/활동 추적과 관리자 멤버 관측
|
||||
|
||||
회원 기능이 들어오면서 운영 관점에서 사용자 정보와 활동 추적이 필요해졌다. `users`에 `avatar_url`, `last_seen_at`, `last_seen_ip`를 추가하고 로그인/세션조회/댓글작성 시 최근 활동을 갱신한다. 관리자는 `/admin/members`에서 닉네임, 이메일, 최근 접속, IP, 댓글 수를 확인해 운영 판단을 할 수 있다.
|
||||
|
||||
### 닉네임 유니크 정책
|
||||
|
||||
사용자 설정에서 닉네임 변경 시 중복 체크가 필요하므로 DB 레벨에서 `lower(username)` 유니크 인덱스를 도입했다. 기존 중복 데이터로 마이그레이션이 막히지 않도록, 인덱스 생성 전 중복 닉네임은 `-2`, `-3` 접미사를 붙여 자동 정리한 뒤 인덱스를 생성한다.
|
||||
|
||||
## 2026-05-11 v0.0.65
|
||||
|
||||
### 통합 검색 모달과 `GET /api/search`
|
||||
|
||||
26
docs/map.md
26
docs/map.md
@@ -28,13 +28,14 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(Anonymous/Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 |
|
||||
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(로그인: 설정/로그아웃, 비로그인: Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 |
|
||||
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 하단 푸터 `px-4`/`sm:px-5` |
|
||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시 |
|
||||
|
||||
## 관리자 컴포넌트
|
||||
|
||||
@@ -86,6 +87,7 @@
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||
| pages/admin/settings/index.vue | 사이트 설정 |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(닉네임, 이메일, 최근 접속, IP, 댓글 수, 활동 상태) |
|
||||
|
||||
## 공개 페이지
|
||||
|
||||
@@ -94,13 +96,14 @@
|
||||
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
|
||||
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사) |
|
||||
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
|
||||
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
|
||||
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
||||
| pages/signup.vue | 회원가입 3단계, 2단계 입력에 `auth-form-input`, 패널 `auth-signup__panel`(보더·배경) |
|
||||
| pages/signin.vue | 로그인(다크 폼, `[color-scheme:dark]`, 입력 `auth-form-input`), 비밀번호 SVG 토글 |
|
||||
| pages/signin.vue | 로그인(다크 폼, `[color-scheme:dark]`, 입력 `auth-form-input`), 비밀번호 SVG 토글, 회원 로그인 API 연동 |
|
||||
| pages/settings/index.vue | 회원 설정(썸네일 URL, 닉네임 변경/중복확인, 비밀번호 변경, 회원 탈퇴) |
|
||||
|
||||
## 서버 API
|
||||
|
||||
@@ -114,6 +117,17 @@
|
||||
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
|
||||
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
||||
| server/api/navigation.get.js | 공개 네비게이션 API |
|
||||
| server/api/auth/signup.post.js | 회원 가입 API |
|
||||
| server/api/auth/login.post.js | 회원 로그인 API |
|
||||
| server/api/auth/me.get.js | 회원 세션 조회 API |
|
||||
| server/api/auth/logout.post.js | 회원 로그아웃 API |
|
||||
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
|
||||
| server/api/auth/profile.put.js | 회원 프로필 수정 API |
|
||||
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
|
||||
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
|
||||
| server/api/auth/account.delete.js | 회원 탈퇴 API |
|
||||
| server/api/posts/[slug]/comments.get.js | 게시물 댓글 목록 조회 API |
|
||||
| server/api/posts/[slug]/comments.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 |
|
||||
@@ -142,9 +156,11 @@
|
||||
| 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/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
||||
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||
@@ -155,6 +171,8 @@
|
||||
| server/utils/media-library.js | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티 |
|
||||
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
||||
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
|
||||
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
@@ -169,6 +187,8 @@
|
||||
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
|
||||
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
|
||||
| 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 | 회원 아바타/최근 활동 컬럼 추가 및 닉네임 유니크 인덱스 추가 |
|
||||
|
||||
## 설정/배포
|
||||
|
||||
|
||||
51
docs/spec.md
51
docs/spec.md
@@ -37,7 +37,7 @@
|
||||
- `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다.
|
||||
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
|
||||
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
|
||||
- 헤더 우측 사용자 아이콘 버튼은 비로그인 기준 사용자 메뉴(Anonymous, Sign up, Sign in)만 표시한다.
|
||||
- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Anonymous·Sign up·Sign in 메뉴를 표시한다.
|
||||
|
||||
### 공개 화면 색상
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
|
||||
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
||||
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
||||
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
||||
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
||||
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
||||
@@ -83,6 +84,7 @@
|
||||
- `/tag/:slug` - 태그별 게시물 목록
|
||||
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
|
||||
- `/signin` - 로그인
|
||||
- `/settings` - 회원 설정(썸네일, 닉네임, 비밀번호, 회원 탈퇴)
|
||||
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
|
||||
|
||||
### 공개 인증 화면(초기)
|
||||
@@ -201,6 +203,33 @@ components/content/
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### Users
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | UUID | Primary Key |
|
||||
| username | String | 사용자명 |
|
||||
| email | String | 로그인 이메일(유니크) |
|
||||
| password_hash | String | bcrypt 해시 비밀번호 |
|
||||
| avatar_url | String | 프로필 썸네일 URL |
|
||||
| last_seen_at | DateTime nullable | 마지막 접속 시각 |
|
||||
| last_seen_ip | String | 마지막 접속 IP |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### Comments
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| id | UUID | Primary Key |
|
||||
| post_id | UUID | FK → Posts |
|
||||
| user_id | UUID | FK → Users |
|
||||
| parent_id | UUID nullable | FK → Comments, 대댓글 1단 |
|
||||
| body | Text | 댓글 본문 |
|
||||
| status | Enum | published/pending/blocked |
|
||||
| created_at | DateTime | 생성일 |
|
||||
| updated_at | DateTime | 수정일 |
|
||||
|
||||
### Pages (고정 페이지)
|
||||
|
||||
| 필드 | 타입 | 설명 |
|
||||
@@ -296,12 +325,23 @@ components/content/
|
||||
|
||||
- `GET /api/posts` - 게시물 목록
|
||||
- `GET /api/posts/:slug` - 게시물 상세
|
||||
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
|
||||
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
|
||||
- `GET /api/pages` - 고정 페이지 목록
|
||||
- `GET /api/pages/:slug` - 고정 페이지 상세
|
||||
- `GET /api/tags` - 태그 목록
|
||||
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
||||
- `GET /api/site-settings` - 공개 사이트 설정
|
||||
- `GET /api/navigation` - 공개 네비게이션
|
||||
- `POST /api/auth/signup` - 회원 가입
|
||||
- `POST /api/auth/login` - 회원 로그인
|
||||
- `GET /api/auth/me` - 현재 회원 세션 조회
|
||||
- `POST /api/auth/logout` - 회원 로그아웃
|
||||
- `GET /api/auth/profile` - 회원 설정 조회
|
||||
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, 썸네일)
|
||||
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
|
||||
- `PUT /api/auth/password` - 회원 비밀번호 변경
|
||||
- `DELETE /api/auth/account` - 회원 탈퇴
|
||||
|
||||
### 관리자 API (`/admin/api/`)
|
||||
|
||||
@@ -333,6 +373,7 @@ components/content/
|
||||
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||
- `GET /admin/api/members` - 회원 목록(최근 접속, 접속 IP, 댓글 수 포함)
|
||||
|
||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||
@@ -443,6 +484,13 @@ components/content/
|
||||
- 관리자 페이지 접근은 `/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`)로 세션을 관리한다.
|
||||
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.
|
||||
|
||||
---
|
||||
|
||||
## 미디어 관리
|
||||
@@ -488,6 +536,7 @@ DB_PORT=43119
|
||||
# Auth
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=replace-with-random-password
|
||||
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/uploads
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.71
|
||||
|
||||
- 헤더 사용자 영역에서 구독 버튼을 제거하고, 로그인 상태 기반 아바타/드롭다운(설정, 로그아웃 / 비로그인 시 Sign up, Sign in)으로 정리.
|
||||
- 회원 설정 페이지(`/settings`)를 추가하고 닉네임 변경(중복 확인), 썸네일 URL 변경, 비밀번호 변경, 회원 탈퇴 기능을 연결.
|
||||
- 관리자 멤버 화면(`/admin/members`)과 회원 목록 API를 추가해 닉네임, 이메일, 최근 접속 시각/IP, 댓글 개수, 활동 현황을 확인할 수 있게 구성.
|
||||
- 회원 활동 컬럼(`last_seen_at`, `last_seen_ip`)과 아바타 컬럼(`avatar_url`)을 DB에 추가하고, 로그인/세션 조회/댓글 작성 시 최근 활동을 갱신.
|
||||
|
||||
## v0.0.70
|
||||
|
||||
- 회원 인증 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)와 회원 세션 쿠키(`sori_member_session`)를 추가.
|
||||
- 댓글 DB 스키마(`users`, `comments`)와 게시물 댓글 API(`GET/POST /api/posts/:slug/comments`)를 추가하고, 대댓글은 1단까지만 허용하도록 검증을 적용.
|
||||
- 게시물 상세 `#comments` 영역에 `PostComments` UI를 연결해 로그인 회원 댓글/답글 작성과 댓글 목록 표시를 구현.
|
||||
- `signin`/`signup` 화면을 시뮬레이션에서 실제 API 연동으로 전환.
|
||||
|
||||
## v0.0.66
|
||||
|
||||
- 태그 검색은 `description`을 제외하고 `name`·`slug`만 부분 일치하도록 조정해, `p` 같은 한 글자 입력으로 의미 없는 태그가 뜨는 혼선을 줄임.
|
||||
|
||||
@@ -70,6 +70,9 @@ const logoutAdmin = async () => {
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/navigation">
|
||||
메뉴
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/members">
|
||||
멤버
|
||||
</NuxtLink>
|
||||
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/settings">
|
||||
설정
|
||||
</NuxtLink>
|
||||
|
||||
@@ -50,6 +50,7 @@ export default defineNuxtConfig({
|
||||
databaseName: process.env.DATABASE_NAME || '',
|
||||
adminEmail: process.env.ADMIN_EMAIL || '',
|
||||
adminPassword: process.env.ADMIN_PASSWORD || '',
|
||||
memberSessionSecret: process.env.MEMBER_SESSION_SECRET || '',
|
||||
uploadDir: process.env.UPLOAD_DIR || '/uploads',
|
||||
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
|
||||
public: {
|
||||
|
||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.59",
|
||||
"version": "0.0.69",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.59",
|
||||
"version": "0.0.69",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"nuxt": "^3.21.2",
|
||||
"postgres": "^3.4.9",
|
||||
"vue": "^3.5.13",
|
||||
@@ -4471,6 +4472,29 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt/node_modules/node-addon-api": {
|
||||
"version": "8.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.69",
|
||||
"version": "0.0.71",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"nuxt": "^3.21.2",
|
||||
"postgres": "^3.4.9",
|
||||
"vue": "^3.5.13",
|
||||
|
||||
88
pages/admin/members/index.vue
Normal file
88
pages/admin/members/index.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'admin'
|
||||
})
|
||||
|
||||
const { data: members } = await useFetch('/admin/api/members', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
/**
|
||||
* 최근 접속 시각 표시 문자열을 반환한다.
|
||||
* @param {string | null} value - ISO 시각
|
||||
* @returns {string} 표시 문자열
|
||||
*/
|
||||
const formatLastSeen = (value) => {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-'
|
||||
}
|
||||
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-members min-h-screen bg-paper">
|
||||
<div class="border-b border-line bg-paper px-6 py-5">
|
||||
<p class="text-xs font-semibold uppercase text-muted">Admin</p>
|
||||
<h1 class="mt-2 text-2xl font-semibold text-ink">멤버</h1>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<div class="overflow-x-auto rounded-[10px] border border-line bg-white">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<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">접속 IP</th>
|
||||
<th class="px-3 py-2.5">활동 현황</th>
|
||||
<th class="px-3 py-2.5 text-right">댓글 개수</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="member in members" :key="member.id" class="border-t border-line/70">
|
||||
<td class="px-3 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
v-if="member.avatarUrl"
|
||||
:src="member.avatarUrl"
|
||||
:alt="member.username"
|
||||
class="h-7 w-7 rounded-full object-cover"
|
||||
>
|
||||
<span v-else class="grid h-7 w-7 place-items-center rounded-full bg-[#efefec] text-xs font-semibold text-ink">
|
||||
{{ (member.username || '?').slice(0, 1).toUpperCase() }}
|
||||
</span>
|
||||
<span>{{ member.username }}</span>
|
||||
</div>
|
||||
</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>
|
||||
<td class="px-3 py-3">
|
||||
<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>
|
||||
</tr>
|
||||
<tr v-if="members.length === 0">
|
||||
<td colspan="6" class="px-3 py-6 text-center text-sm text-muted">등록된 회원이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -204,7 +204,7 @@ const scrollFeatured = (direction) => {
|
||||
|
||||
<template>
|
||||
<MainColumn>
|
||||
<section class="py-6 md:py-8">
|
||||
<section class="py-6 px-6 md:py-8">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col-reverse gap-6">
|
||||
<div class="z-[2] flex flex-col items-center justify-center gap-2 text-center">
|
||||
<h1 class="text-xl font-semibold leading-[1.125] md:text-2xl">
|
||||
@@ -226,7 +226,7 @@ const scrollFeatured = (direction) => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="py-4">
|
||||
<section class="py-4 px-6">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
||||
<h2 class="text-sm font-medium uppercase site-muted">Featured</h2>
|
||||
@@ -281,7 +281,7 @@ const scrollFeatured = (direction) => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="py-4">
|
||||
<section class="py-4 px-6">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
||||
<h2 class="text-sm font-medium uppercase site-muted">Latest</h2>
|
||||
|
||||
@@ -276,10 +276,7 @@ useHead(() => ({
|
||||
|
||||
<section id="comments" class="mb-6 border-y border-[var(--site-line)] bg-[var(--site-panel-strong)] py-5 scroll-mt-14">
|
||||
<div class="mx-auto max-w-[720px] px-4 text-sm sm:px-5">
|
||||
<p class="font-medium">Comments</p>
|
||||
<p class="mt-2 site-muted">
|
||||
댓글 UI는 추후 연결 예정입니다.
|
||||
</p>
|
||||
<PostComments :slug="post.slug" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
273
pages/settings/index.vue
Normal file
273
pages/settings/index.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<script setup>
|
||||
const loading = ref(true)
|
||||
const savingProfile = ref(false)
|
||||
const savingPassword = ref(false)
|
||||
const deletingAccount = ref(false)
|
||||
const profileMessage = ref('')
|
||||
const passwordMessage = ref('')
|
||||
const deleteMessage = ref('')
|
||||
|
||||
const profileForm = reactive({
|
||||
email: '',
|
||||
username: '',
|
||||
avatarUrl: ''
|
||||
})
|
||||
|
||||
const passwordForm = reactive({
|
||||
currentPassword: '',
|
||||
nextPassword: '',
|
||||
nextPasswordConfirm: ''
|
||||
})
|
||||
|
||||
const deleteForm = reactive({
|
||||
password: ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 설정 화면 초기 데이터를 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const profile = await $fetch('/api/auth/profile')
|
||||
profileForm.email = profile.email || ''
|
||||
profileForm.username = profile.username || ''
|
||||
profileForm.avatarUrl = profile.avatarUrl || ''
|
||||
} catch {
|
||||
await navigateTo('/signin')
|
||||
} finally {
|
||||
loading.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 saveProfile = async () => {
|
||||
profileMessage.value = ''
|
||||
const available = await checkUsernameAvailable()
|
||||
if (!available) {
|
||||
return
|
||||
}
|
||||
|
||||
savingProfile.value = true
|
||||
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 {Promise<void>}
|
||||
*/
|
||||
const savePassword = 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴를 처리한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const removeAccount = async () => {
|
||||
deleteMessage.value = ''
|
||||
if (!deleteForm.password) {
|
||||
deleteMessage.value = '탈퇴 확인용 비밀번호를 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
deletingAccount.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/account', {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
password: deleteForm.password
|
||||
}
|
||||
})
|
||||
await navigateTo('/')
|
||||
} catch (error) {
|
||||
deleteMessage.value = error?.data?.message || '회원 탈퇴에 실패했습니다.'
|
||||
} finally {
|
||||
deletingAccount.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadProfile)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="settings-page mx-auto w-full max-w-[720px] px-4 py-8 sm:px-5">
|
||||
<h1 class="text-xl font-semibold">사용자 설정</h1>
|
||||
|
||||
<div v-if="loading" class="mt-4 text-sm site-muted">
|
||||
설정 정보를 불러오는 중입니다.
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-5 flex flex-col gap-5">
|
||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
||||
<h2 class="text-sm font-semibold">프로필</h2>
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
<label class="text-xs site-muted">이메일</label>
|
||||
<input
|
||||
v-model="profileForm.email"
|
||||
type="text"
|
||||
disabled
|
||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-[var(--site-panel)] px-3 text-sm"
|
||||
>
|
||||
<label class="text-xs site-muted">닉네임</label>
|
||||
<input
|
||||
v-model="profileForm.username"
|
||||
type="text"
|
||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
||||
>
|
||||
<label class="text-xs site-muted">썸네일 URL</label>
|
||||
<input
|
||||
v-model="profileForm.avatarUrl"
|
||||
type="text"
|
||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
||||
placeholder="https://..."
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button mt-1 w-fit rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="savingProfile"
|
||||
@click="saveProfile"
|
||||
>
|
||||
{{ savingProfile ? '저장 중...' : '프로필 저장' }}
|
||||
</button>
|
||||
<p v-if="profileMessage" class="text-xs site-muted">
|
||||
{{ profileMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
||||
<h2 class="text-sm font-semibold">비밀번호 변경</h2>
|
||||
<div class="mt-3 flex flex-col gap-3">
|
||||
<input
|
||||
v-model="passwordForm.currentPassword"
|
||||
type="password"
|
||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
||||
placeholder="현재 비밀번호"
|
||||
>
|
||||
<input
|
||||
v-model="passwordForm.nextPassword"
|
||||
type="password"
|
||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
||||
placeholder="새 비밀번호"
|
||||
>
|
||||
<input
|
||||
v-model="passwordForm.nextPasswordConfirm"
|
||||
type="password"
|
||||
class="h-10 rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-[var(--site-accent)]"
|
||||
placeholder="새 비밀번호 확인"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="site-accent-button mt-1 w-fit rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
|
||||
:disabled="savingPassword"
|
||||
@click="savePassword"
|
||||
>
|
||||
{{ savingPassword ? '변경 중...' : '비밀번호 변경' }}
|
||||
</button>
|
||||
<p v-if="passwordMessage" class="text-xs site-muted">
|
||||
{{ passwordMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-4">
|
||||
<h2 class="text-sm font-semibold text-red-500/70">회원 탈퇴</h2>
|
||||
<p class="mt-2 text-xs site-muted">
|
||||
탈퇴 시 작성한 댓글과 계정 정보가 삭제됩니다.
|
||||
</p>
|
||||
<input
|
||||
v-model="deleteForm.password"
|
||||
type="password"
|
||||
class="mt-3 h-10 w-full rounded-[8px] border border-[var(--site-line)] bg-transparent px-3 text-sm outline-none focus-visible:border-red-400"
|
||||
placeholder="비밀번호 확인"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="mt-3 rounded-[10px] border border-red-400/40 px-3 py-1.5 text-xs text-red-500/70 transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||
:disabled="deletingAccount"
|
||||
@click="removeAccount"
|
||||
>
|
||||
{{ deletingAccount ? '처리 중...' : '회원 탈퇴' }}
|
||||
</button>
|
||||
<p v-if="deleteMessage" class="mt-2 text-xs site-muted">
|
||||
{{ deleteMessage }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -35,7 +35,7 @@ const validateSignIn = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 요청을 시뮬레이션한다.
|
||||
* 로그인 요청을 처리한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const submitSignIn = async () => {
|
||||
@@ -44,9 +44,22 @@ const submitSignIn = async () => {
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
isSubmitting.value = false
|
||||
statusMessage.value = '현재 로그인 API 연결 전입니다. 관리자 로그인은 /admin 을 사용해 주세요.'
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
email: form.email.trim(),
|
||||
password: form.password
|
||||
}
|
||||
})
|
||||
statusMessage.value = '로그인되었습니다. 잠시 후 이동합니다.'
|
||||
await navigateTo('/')
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '로그인에 실패했습니다.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@ definePageMeta({
|
||||
})
|
||||
|
||||
const currentStep = ref(1)
|
||||
const resendCooldown = ref(0)
|
||||
const isSubmitting = ref(false)
|
||||
const signupCompleted = ref(false)
|
||||
const statusMessage = ref('')
|
||||
const submitErrorMessage = ref('')
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
title: 'AFFiNE',
|
||||
@@ -32,7 +32,6 @@ const errors = reactive({
|
||||
const showSignupPassword = ref(false)
|
||||
const showSignupPasswordConfirm = ref(false)
|
||||
|
||||
const canResend = computed(() => resendCooldown.value <= 0)
|
||||
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.')
|
||||
|
||||
@@ -89,10 +88,11 @@ const validateStepTwo = () => {
|
||||
|
||||
/**
|
||||
* 다음 단계로 이동한다.
|
||||
* @returns {void}
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const goNextStep = () => {
|
||||
const goNextStep = async () => {
|
||||
statusMessage.value = ''
|
||||
submitErrorMessage.value = ''
|
||||
|
||||
if (currentStep.value === 1) {
|
||||
currentStep.value = 2
|
||||
@@ -104,8 +104,27 @@ const goNextStep = () => {
|
||||
return
|
||||
}
|
||||
|
||||
currentStep.value = 3
|
||||
resendCooldown.value = 30
|
||||
isSubmitting.value = true
|
||||
|
||||
try {
|
||||
await $fetch('/api/auth/signup', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: form.username.trim(),
|
||||
email: form.email.trim(),
|
||||
password: form.password
|
||||
}
|
||||
})
|
||||
|
||||
signupCompleted.value = true
|
||||
currentStep.value = 3
|
||||
statusMessage.value = '회원가입이 완료되었습니다. 잠시 후 홈으로 이동합니다.'
|
||||
await navigateTo('/')
|
||||
} catch (error) {
|
||||
submitErrorMessage.value = error?.data?.message || '회원가입에 실패했습니다.'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,49 +139,6 @@ const goPreviousStep = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인증 메일 재전송을 시뮬레이션한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const resendVerificationEmail = async () => {
|
||||
if (!canResend.value || isSubmitting.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
isSubmitting.value = false
|
||||
resendCooldown.value = 30
|
||||
statusMessage.value = '인증 메일을 다시 보냈습니다. 메일함을 확인해 주세요.'
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 인증 완료를 시뮬레이션한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const completeSignup = async () => {
|
||||
isSubmitting.value = true
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
isSubmitting.value = false
|
||||
signupCompleted.value = true
|
||||
statusMessage.value = '이메일 인증이 완료되었습니다. 로그인 페이지로 이동할 수 있습니다.'
|
||||
}
|
||||
|
||||
const countdownTimer = ref(/** @type {ReturnType<typeof setInterval> | null} */ (null))
|
||||
|
||||
onMounted(() => {
|
||||
countdownTimer.value = setInterval(() => {
|
||||
if (resendCooldown.value > 0) {
|
||||
resendCooldown.value -= 1
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (countdownTimer.value) {
|
||||
clearInterval(countdownTimer.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -271,25 +247,17 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template v-else>
|
||||
<p class="text-2xl font-semibold leading-tight">
|
||||
이메일 확인
|
||||
회원가입 완료
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-[#9ba3af]">
|
||||
{{ form.email }} 주소로 인증 메일을 보냈습니다.<br>
|
||||
이메일 링크를 확인해야 회원가입이 확정됩니다.
|
||||
{{ form.email }} 계정으로 가입되었습니다.<br>
|
||||
이제 로그인 후 댓글을 작성할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div class="mt-8 rounded-[10px] border border-[#1a212a] bg-[#0d1116] p-4">
|
||||
<p class="text-sm text-[#d8dee6]">
|
||||
메일이 오지 않았다면 인증 메일을 재전송해 주세요.
|
||||
가입이 완료되면 자동으로 홈으로 이동합니다.
|
||||
</p>
|
||||
<button
|
||||
class="mt-3 h-9 rounded-[8px] border border-[#2f6feb] px-4 text-xs font-medium text-[#7eb8ff] transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="!canResend || isSubmitting"
|
||||
@click="resendVerificationEmail"
|
||||
>
|
||||
{{ canResend ? '인증 메일 재전송' : `${resendCooldown}초 후 재전송` }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="statusMessage" class="mt-4 text-sm text-[#7ccf90]" aria-live="polite">
|
||||
@@ -324,9 +292,9 @@ onBeforeUnmount(() => {
|
||||
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
type="button"
|
||||
:disabled="isSubmitting"
|
||||
@click="completeSignup"
|
||||
@click="goNextStep"
|
||||
>
|
||||
인증 완료
|
||||
가입 처리 중
|
||||
</button>
|
||||
|
||||
<NuxtLink
|
||||
@@ -338,6 +306,10 @@ onBeforeUnmount(() => {
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<p v-if="submitErrorMessage" class="mt-3 text-xs text-[#e5acb1]" aria-live="polite">
|
||||
{{ submitErrorMessage }}
|
||||
</p>
|
||||
|
||||
<div class="mt-8 flex items-center gap-1.5">
|
||||
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 1 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
|
||||
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 2 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
|
||||
|
||||
48
server/api/auth/account.delete.js
Normal file
48
server/api/auth/account.delete.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { deleteMember, getUserByIdWithPassword } from '../../repositories/member-repository'
|
||||
import { clearMemberSession, requireMemberSession } from '../../utils/member-auth'
|
||||
|
||||
const deleteAccountSchema = z.object({
|
||||
password: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 탈퇴 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>} 처리 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const parsedBody = deleteAccountSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '탈퇴 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const user = await getUserByIdWithPassword(session.userId)
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const isPasswordValid = await bcrypt.compare(parsedBody.data.password, user.passwordHash)
|
||||
if (!isPasswordValid) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '비밀번호가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
await deleteMember(session.userId)
|
||||
clearMemberSession(event)
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
31
server/api/auth/check-username.get.js
Normal file
31
server/api/auth/check-username.get.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createError } from 'h3'
|
||||
import { isUsernameTaken } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 사용자명 중복 확인 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ available: boolean }>} 사용 가능 여부
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const rawUsername = getQuery(event).username
|
||||
const username = typeof rawUsername === 'string' ? rawUsername.trim() : ''
|
||||
|
||||
if (!username) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '닉네임을 입력해 주세요.'
|
||||
})
|
||||
}
|
||||
|
||||
const taken = await isUsernameTaken({
|
||||
username,
|
||||
excludeUserId: session.userId
|
||||
})
|
||||
|
||||
return {
|
||||
available: !taken
|
||||
}
|
||||
})
|
||||
|
||||
50
server/api/auth/login.post.js
Normal file
50
server/api/auth/login.post.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { z } from 'zod'
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
import { getUserByEmail, touchUserActivity } from '../../repositories/member-repository'
|
||||
import { setMemberSession } from '../../utils/member-auth'
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
password: z.string().min(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 로그인 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, username: string, email: string }>} 회원 정보
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parsedBody = loginSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '로그인 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const user = await getUserByEmail(body.email)
|
||||
|
||||
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '이메일 또는 비밀번호가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
setMemberSession(event, { userId: user.id, email: user.email })
|
||||
await touchUserActivity({
|
||||
userId: user.id,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl || ''
|
||||
}
|
||||
})
|
||||
|
||||
12
server/api/auth/logout.post.js
Normal file
12
server/api/auth/logout.post.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { clearMemberSession } from '../../utils/member-auth'
|
||||
|
||||
/**
|
||||
* 회원 로그아웃 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ ok: true }} 처리 결과
|
||||
*/
|
||||
export default defineEventHandler((event) => {
|
||||
clearMemberSession(event)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
34
server/api/auth/me.get.js
Normal file
34
server/api/auth/me.get.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getUserById, touchUserActivity } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { getRequestIP } from 'h3'
|
||||
|
||||
/**
|
||||
* 회원 세션 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, username: string, email: string, avatarUrl: string }>} 회원 정보
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
await touchUserActivity({
|
||||
userId: session.userId,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
const user = await getUserById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
id: session.userId,
|
||||
username: '',
|
||||
email: session.email,
|
||||
avatarUrl: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl || ''
|
||||
}
|
||||
})
|
||||
|
||||
52
server/api/auth/password.put.js
Normal file
52
server/api/auth/password.put.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { getUserByIdWithPassword, updateMemberPassword } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
|
||||
const updatePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
nextPassword: z.string().min(8).max(32)
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 비밀번호 변경 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ ok: true }>} 변경 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const parsedBody = updatePasswordSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '비밀번호 변경 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const user = await getUserByIdWithPassword(session.userId)
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const isCurrentValid = await bcrypt.compare(parsedBody.data.currentPassword, user.passwordHash)
|
||||
if (!isCurrentValid) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '현재 비밀번호가 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const nextHash = await bcrypt.hash(parsedBody.data.nextPassword, 12)
|
||||
await updateMemberPassword({
|
||||
userId: session.userId,
|
||||
passwordHash: nextHash
|
||||
})
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
28
server/api/auth/profile.get.js
Normal file
28
server/api/auth/profile.get.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { getUserById } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
import { createError } from 'h3'
|
||||
|
||||
/**
|
||||
* 회원 프로필 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string }>} 회원 프로필
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const user = await getUserById(session.userId)
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl || ''
|
||||
}
|
||||
})
|
||||
|
||||
67
server/api/auth/profile.put.js
Normal file
67
server/api/auth/profile.put.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createError, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { getUserById, isUsernameTaken, updateMemberProfile } from '../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../utils/member-auth'
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
username: z.string().trim().min(1).max(30),
|
||||
avatarUrl: z.string().trim().max(500).optional().default('')
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 프로필 수정 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, email: string, username: string, avatarUrl: string }>} 수정된 회원 프로필
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const parsedBody = updateProfileSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '프로필 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const taken = await isUsernameTaken({
|
||||
username: parsedBody.data.username,
|
||||
excludeUserId: session.userId
|
||||
})
|
||||
|
||||
if (taken) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 닉네임입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const updated = await updateMemberProfile({
|
||||
userId: session.userId,
|
||||
username: parsedBody.data.username,
|
||||
avatarUrl: parsedBody.data.avatarUrl
|
||||
})
|
||||
|
||||
if (!updated) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const user = await getUserById(session.userId)
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
avatarUrl: user.avatarUrl || ''
|
||||
}
|
||||
})
|
||||
|
||||
69
server/api/auth/signup.post.js
Normal file
69
server/api/auth/signup.post.js
Normal file
@@ -0,0 +1,69 @@
|
||||
import bcrypt from 'bcrypt'
|
||||
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'
|
||||
|
||||
const signupSchema = z.object({
|
||||
username: z.string().trim().min(1),
|
||||
email: z.string().trim().email(),
|
||||
password: z.string().min(8).max(32)
|
||||
})
|
||||
|
||||
/**
|
||||
* 회원 가입 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ id: string, username: string, email: string }>} 회원 정보
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const parsedBody = signupSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '회원가입 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const body = parsedBody.data
|
||||
const usernameTaken = await isUsernameTaken({
|
||||
username: body.username
|
||||
})
|
||||
|
||||
if (usernameTaken) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 닉네임입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const existingUser = await getUserByEmail(body.email)
|
||||
|
||||
if (existingUser) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
message: '이미 사용 중인 이메일입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.password, 12)
|
||||
const created = await createUser({
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
passwordHash
|
||||
})
|
||||
|
||||
setMemberSession(event, { userId: created.id, email: created.email })
|
||||
await touchUserActivity({
|
||||
userId: created.id,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
username: created.username,
|
||||
email: created.email,
|
||||
avatarUrl: created.avatarUrl || ''
|
||||
}
|
||||
})
|
||||
|
||||
16
server/api/posts/[slug]/comments.get.js
Normal file
16
server/api/posts/[slug]/comments.get.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { listPostCommentsBySlug } from '../../../repositories/comment-repository'
|
||||
|
||||
/**
|
||||
* 게시물 댓글 목록 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ comments: Array<Object> }>} 댓글 목록
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = String(getRouterParam(event, 'slug') || '')
|
||||
const comments = await listPostCommentsBySlug(slug)
|
||||
|
||||
return {
|
||||
comments
|
||||
}
|
||||
})
|
||||
|
||||
44
server/api/posts/[slug]/comments.post.js
Normal file
44
server/api/posts/[slug]/comments.post.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { createError, getRequestIP, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { createComment } from '../../../repositories/comment-repository'
|
||||
import { touchUserActivity } from '../../../repositories/member-repository'
|
||||
import { requireMemberSession } from '../../../utils/member-auth'
|
||||
|
||||
const createCommentSchema = z.object({
|
||||
body: z.string().trim().min(1).max(5000),
|
||||
parentId: z.string().uuid().optional().nullable()
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물 댓글 생성 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ comment: Object }>} 생성 댓글
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireMemberSession(event)
|
||||
const slug = String(getRouterParam(event, 'slug') || '')
|
||||
const parsedBody = createCommentSchema.safeParse(await readBody(event))
|
||||
|
||||
if (!parsedBody.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '댓글 요청 형식이 올바르지 않습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const comment = await createComment({
|
||||
slug,
|
||||
userId: session.userId,
|
||||
body: parsedBody.data.body,
|
||||
parentId: parsedBody.data.parentId || null
|
||||
})
|
||||
await touchUserActivity({
|
||||
userId: session.userId,
|
||||
ip: String(getRequestIP(event) || '')
|
||||
})
|
||||
|
||||
return {
|
||||
comment
|
||||
}
|
||||
})
|
||||
|
||||
196
server/repositories/comment-repository.js
Normal file
196
server/repositories/comment-repository.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import { createError } from 'h3'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
|
||||
/**
|
||||
* @typedef {Object} PostComment
|
||||
* @property {string} id - 댓글 ID
|
||||
* @property {string} postId - 게시물 ID
|
||||
* @property {string | null} parentId - 부모 댓글 ID
|
||||
* @property {string} body - 댓글 내용
|
||||
* @property {string} status - 댓글 상태
|
||||
* @property {string} createdAt - 생성 시각
|
||||
* @property {string} updatedAt - 수정 시각
|
||||
* @property {{ id: string, username: string }} user - 작성자 정보
|
||||
*/
|
||||
|
||||
/**
|
||||
* DB 클라이언트 조회 (선택)
|
||||
* @returns {ReturnType<typeof import('postgres').default> | null} postgres sql 클라이언트
|
||||
*/
|
||||
const getSql = () => getPostgresClient()
|
||||
|
||||
/**
|
||||
* 게시물 ID 조회
|
||||
* @param {ReturnType<typeof import('postgres').default>} sql - postgres 클라이언트
|
||||
* @param {string} slug - 게시물 슬러그
|
||||
* @returns {Promise<string | null>} 게시물 ID
|
||||
*/
|
||||
const findPublishedPostIdBySlug = async (sql, slug) => {
|
||||
const rows = await sql`
|
||||
SELECT id
|
||||
FROM posts
|
||||
WHERE slug = ${slug}
|
||||
AND status = 'published'
|
||||
AND (
|
||||
published_at IS NULL
|
||||
OR published_at <= now()
|
||||
)
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows?.[0]?.id || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 댓글 조회
|
||||
* @param {string} slug - 게시물 슬러그
|
||||
* @returns {Promise<Array<PostComment>>} 댓글 목록
|
||||
*/
|
||||
export const listPostCommentsBySlug = async (slug) => {
|
||||
const sql = getSql()
|
||||
|
||||
if (!sql) {
|
||||
return []
|
||||
}
|
||||
|
||||
const postId = await findPublishedPostIdBySlug(sql, slug)
|
||||
if (!postId) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '게시물을 찾을 수 없습니다'
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
`
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
postId: row.postId,
|
||||
parentId: row.parentId || null,
|
||||
body: row.body,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
user: {
|
||||
id: row.userId,
|
||||
username: row.username
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 생성
|
||||
* @param {{ slug: string, userId: string, body: string, parentId?: string | null }} input - 댓글 입력값
|
||||
* @returns {Promise<PostComment>} 생성된 댓글
|
||||
*/
|
||||
export const createComment = 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: '게시물을 찾을 수 없습니다'
|
||||
})
|
||||
}
|
||||
|
||||
let parentId = input.parentId || null
|
||||
|
||||
if (parentId) {
|
||||
const parentRows = await sql`
|
||||
SELECT id, post_id AS "postId", parent_id AS "parentId", status
|
||||
FROM comments
|
||||
WHERE id = ${parentId}
|
||||
LIMIT 1
|
||||
`
|
||||
const parent = parentRows?.[0]
|
||||
|
||||
if (!parent || parent.postId !== postId || parent.status !== 'published') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '유효하지 않은 부모 댓글입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (parent.parentId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '대댓글에는 추가 답글을 달 수 없습니다.'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
INSERT INTO comments (post_id, user_id, parent_id, body, status)
|
||||
VALUES (${postId}, ${input.userId}, ${parentId}, ${input.body}, 'published')
|
||||
RETURNING
|
||||
id,
|
||||
post_id AS "postId",
|
||||
parent_id AS "parentId",
|
||||
body,
|
||||
status,
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt"
|
||||
`
|
||||
|
||||
const created = rows?.[0]
|
||||
if (!created) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '댓글 생성에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const userRows = await sql`
|
||||
SELECT id, username
|
||||
FROM users
|
||||
WHERE id = ${input.userId}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
const user = userRows?.[0]
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '회원 정보를 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
postId: created.postId,
|
||||
parentId: created.parentId || null,
|
||||
body: created.body,
|
||||
status: created.status,
|
||||
createdAt: created.createdAt.toISOString(),
|
||||
updatedAt: created.updatedAt.toISOString(),
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
284
server/repositories/member-repository.js
Normal file
284
server/repositories/member-repository.js
Normal file
@@ -0,0 +1,284 @@
|
||||
import { createError } from 'h3'
|
||||
import { getPostgresClient } from './postgres-client'
|
||||
|
||||
/**
|
||||
* @typedef {Object} MemberUser
|
||||
* @property {string} id - 사용자 ID
|
||||
* @property {string} username - 사용자명
|
||||
* @property {string} email - 이메일
|
||||
* @property {string} passwordHash - 비밀번호 해시
|
||||
* @property {string} avatarUrl - 아바타 URL
|
||||
* @property {string} createdAt - 생성 시각(ISO)
|
||||
* @property {string} updatedAt - 수정 시각(ISO)
|
||||
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
|
||||
* @property {string} lastSeenIp - 최근 접속 IP
|
||||
*/
|
||||
|
||||
/**
|
||||
* DB 클라이언트 조회 (필수)
|
||||
* @returns {ReturnType<typeof import('postgres').default>} postgres sql 클라이언트
|
||||
*/
|
||||
const requireSql = () => {
|
||||
const sql = getPostgresClient()
|
||||
if (!sql) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '데이터베이스 설정이 필요합니다.'
|
||||
})
|
||||
}
|
||||
return sql
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일로 회원 조회
|
||||
* @param {string} email - 이메일
|
||||
* @returns {Promise<MemberUser | null>} 회원
|
||||
*/
|
||||
export const getUserByEmail = async (email) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash AS "passwordHash",
|
||||
avatar_url AS "avatarUrl",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
FROM users
|
||||
WHERE email = ${email}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 회원 조회
|
||||
* @param {string} id - 사용자 ID
|
||||
* @returns {Promise<Omit<MemberUser, 'passwordHash'> | null>} 회원
|
||||
*/
|
||||
export const getUserById = async (id) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
FROM users
|
||||
WHERE id = ${id}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* ID로 회원 조회(비밀번호 포함)
|
||||
* @param {string} id - 사용자 ID
|
||||
* @returns {Promise<MemberUser | null>} 회원
|
||||
*/
|
||||
export const getUserByIdWithPassword = async (id) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
password_hash AS "passwordHash",
|
||||
avatar_url AS "avatarUrl",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
FROM users
|
||||
WHERE id = ${id}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 생성
|
||||
* @param {{ username: string, email: string, passwordHash: string }} input - 입력
|
||||
* @returns {Promise<Omit<MemberUser, 'passwordHash'>>} 생성된 회원
|
||||
*/
|
||||
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}, '')
|
||||
RETURNING
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
`
|
||||
|
||||
const created = rows?.[0]
|
||||
if (!created) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '회원 생성에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 최근 활동 정보를 기록한다.
|
||||
* @param {{ userId: string, ip: string }} input - 사용자 ID와 접속 IP
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const touchUserActivity = async (input) => {
|
||||
const sql = requireSql()
|
||||
await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
last_seen_at = now(),
|
||||
last_seen_ip = ${input.ip},
|
||||
updated_at = now()
|
||||
WHERE id = ${input.userId}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 프로필 수정
|
||||
* @param {{ userId: string, username: string, avatarUrl: string }} input - 수정 값
|
||||
* @returns {Promise<Omit<MemberUser, 'passwordHash'> | null>} 수정된 회원
|
||||
*/
|
||||
export const updateMemberProfile = async (input) => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
username = ${input.username},
|
||||
avatar_url = ${input.avatarUrl},
|
||||
updated_at = now()
|
||||
WHERE id = ${input.userId}
|
||||
RETURNING
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
avatar_url AS "avatarUrl",
|
||||
created_at AS "createdAt",
|
||||
updated_at AS "updatedAt",
|
||||
last_seen_at AS "lastSeenAt",
|
||||
last_seen_ip AS "lastSeenIp"
|
||||
`
|
||||
|
||||
return rows?.[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 비밀번호 변경
|
||||
* @param {{ userId: string, passwordHash: string }} input - 수정 값
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const updateMemberPassword = async (input) => {
|
||||
const sql = requireSql()
|
||||
await sql`
|
||||
UPDATE users
|
||||
SET
|
||||
password_hash = ${input.passwordHash},
|
||||
updated_at = now()
|
||||
WHERE id = ${input.userId}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴
|
||||
* @param {string} userId - 사용자 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export const deleteMember = async (userId) => {
|
||||
const sql = requireSql()
|
||||
await sql`
|
||||
DELETE FROM users
|
||||
WHERE id = ${userId}
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자명 중복 확인
|
||||
* @param {{ username: string, excludeUserId?: string }} input - 사용자명과 제외 사용자 ID
|
||||
* @returns {Promise<boolean>} 중복 여부
|
||||
*/
|
||||
export const isUsernameTaken = async (input) => {
|
||||
const sql = requireSql()
|
||||
const rows = input.excludeUserId
|
||||
? await sql`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE lower(username) = lower(${input.username})
|
||||
AND id <> ${input.excludeUserId}
|
||||
LIMIT 1
|
||||
`
|
||||
: await sql`
|
||||
SELECT id
|
||||
FROM users
|
||||
WHERE lower(username) = lower(${input.username})
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return Boolean(rows?.[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자용 회원 목록 조회(댓글 활동 포함)
|
||||
* @returns {Promise<Array<{ id: string, username: string, email: string, avatarUrl: string, createdAt: string, updatedAt: string, lastSeenAt: string | null, lastSeenIp: string, commentCount: number, activityStatus: string }>>} 회원 목록
|
||||
*/
|
||||
export const listMembersForAdmin = async () => {
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
SELECT
|
||||
users.id,
|
||||
users.username,
|
||||
users.email,
|
||||
users.avatar_url AS "avatarUrl",
|
||||
users.created_at AS "createdAt",
|
||||
users.updated_at AS "updatedAt",
|
||||
users.last_seen_at AS "lastSeenAt",
|
||||
users.last_seen_ip AS "lastSeenIp",
|
||||
COALESCE(count(comments.id), 0)::int AS "commentCount"
|
||||
FROM users
|
||||
LEFT JOIN comments ON comments.user_id = users.id AND comments.status = 'published'
|
||||
GROUP BY users.id
|
||||
ORDER BY users.created_at DESC
|
||||
`
|
||||
|
||||
return rows.map((row) => {
|
||||
const lastSeenAt = row.lastSeenAt ? row.lastSeenAt.toISOString() : null
|
||||
const isActive = row.lastSeenAt
|
||||
? Date.now() - new Date(row.lastSeenAt).getTime() <= 1000 * 60 * 60 * 24 * 30
|
||||
: false
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
avatarUrl: row.avatarUrl || '',
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
lastSeenAt,
|
||||
lastSeenIp: row.lastSeenIp || '',
|
||||
commentCount: Number(row.commentCount || 0),
|
||||
activityStatus: isActive ? '활성' : '비활성'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
13
server/routes/admin/api/members.get.js
Normal file
13
server/routes/admin/api/members.get.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { listMembersForAdmin } from '../../../repositories/member-repository'
|
||||
|
||||
/**
|
||||
* 관리자 회원 목록 조회 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<Array<Object>>} 회원 목록
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
return listMembersForAdmin()
|
||||
})
|
||||
|
||||
152
server/utils/member-auth.js
Normal file
152
server/utils/member-auth.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
import { createError, deleteCookie, getCookie, setCookie } from 'h3'
|
||||
|
||||
const memberSessionCookieName = 'sori_member_session'
|
||||
const sessionMaxAge = 60 * 60 * 24 * 14
|
||||
|
||||
/**
|
||||
* 회원 세션 서명 비밀값 조회
|
||||
* @returns {string} 세션 서명 비밀값
|
||||
*/
|
||||
const getSessionSecret = () => {
|
||||
const config = useRuntimeConfig()
|
||||
const fallbackSecret = String(config.adminPassword || '')
|
||||
const sessionSecret = String(config.memberSessionSecret || '').trim() || fallbackSecret
|
||||
|
||||
if (!sessionSecret) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '회원 세션 비밀값 환경 변수가 없습니다. (MEMBER_SESSION_SECRET 또는 ADMIN_PASSWORD)'
|
||||
})
|
||||
}
|
||||
|
||||
return sessionSecret
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열 안전 비교
|
||||
* @param {string} left - 비교 문자열
|
||||
* @param {string} right - 비교 대상 문자열
|
||||
* @returns {boolean} 일치 여부
|
||||
*/
|
||||
const safeCompare = (left, right) => {
|
||||
const leftBuffer = Buffer.from(left)
|
||||
const rightBuffer = Buffer.from(right)
|
||||
|
||||
if (leftBuffer.length !== rightBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(leftBuffer, rightBuffer)
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 페이로드 서명
|
||||
* @param {string} payload - 인코딩된 세션 페이로드
|
||||
* @returns {string} 세션 서명
|
||||
*/
|
||||
const signPayload = (payload) => createHmac('sha256', getSessionSecret())
|
||||
.update(payload)
|
||||
.digest('base64url')
|
||||
|
||||
/**
|
||||
* 회원 세션 토큰 생성
|
||||
* @param {{ userId: string, email: string }} user - 회원 정보
|
||||
* @returns {string} 세션 토큰
|
||||
*/
|
||||
export const createMemberSessionToken = (user) => {
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
expiresAt: Date.now() + sessionMaxAge * 1000
|
||||
})).toString('base64url')
|
||||
|
||||
return `${payload}.${signPayload(payload)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션 토큰 검증
|
||||
* @param {string | undefined} token - 세션 토큰
|
||||
* @returns {{ userId: string, email: string } | null} 세션 정보
|
||||
*/
|
||||
export const verifyMemberSessionToken = (token) => {
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [payload, signature] = token.split('.')
|
||||
|
||||
if (!payload || !signature || !safeCompare(signature, signPayload(payload))) {
|
||||
return null
|
||||
}
|
||||
|
||||
let session = null
|
||||
|
||||
try {
|
||||
session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!session.userId || !session.email || !session.expiresAt || session.expiresAt < Date.now()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
userId: session.userId,
|
||||
email: session.email
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션 쿠키 설정
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @param {{ userId: string, email: string }} user - 회원 정보
|
||||
* @returns {void}
|
||||
*/
|
||||
export const setMemberSession = (event, user) => {
|
||||
setCookie(event, memberSessionCookieName, createMemberSessionToken(user), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: sessionMaxAge
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션 쿠키 삭제
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
export const clearMemberSession = (event) => {
|
||||
deleteCookie(event, memberSessionCookieName, {
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션 조회
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ userId: string, email: string } | null} 세션 정보
|
||||
*/
|
||||
export const getMemberSession = (event) => verifyMemberSessionToken(getCookie(event, memberSessionCookieName))
|
||||
|
||||
/**
|
||||
* 회원 세션 필수 확인
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {{ userId: string, email: string }} 세션 정보
|
||||
*/
|
||||
export const requireMemberSession = (event) => {
|
||||
const session = getMemberSession(event)
|
||||
|
||||
if (!session) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: '로그인이 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user