feat(member): 회원 설정/헤더 상태 UI와 관리자 멤버 관리 추가
로그인 상태를 헤더에서 즉시 인지하고 계정 관리를 이어갈 수 있도록 사용자 설정과 관리자 멤버 관측 기능을 연결했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@ DB_PORT=43119
|
|||||||
# Auth
|
# Auth
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=replace-with-random-password
|
ADMIN_PASSWORD=replace-with-random-password
|
||||||
|
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||||
|
|
||||||
# Upload
|
# Upload
|
||||||
UPLOAD_DIR=/uploads
|
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 userMenuRef = ref(null)
|
||||||
const userMenuToggleRef = ref(null)
|
const userMenuToggleRef = ref(null)
|
||||||
const searchOpen = ref(false)
|
const searchOpen = ref(false)
|
||||||
|
const member = ref(null)
|
||||||
|
|
||||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||||
default: () => ({
|
default: () => ({
|
||||||
@@ -58,6 +59,31 @@ const toggleUserMenu = () => {
|
|||||||
menuUserOpen.value = !menuUserOpen.value
|
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 - 클릭 이벤트
|
* @param {MouseEvent} event - 클릭 이벤트
|
||||||
@@ -113,6 +139,7 @@ const onGlobalKeydown = (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
fetchMember()
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick)
|
||||||
document.addEventListener('keydown', onGlobalKeydown)
|
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>
|
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
|
||||||
</button>
|
</button>
|
||||||
<nav class="site-header__nav flex shrink-0 items-center gap-3 text-sm sm:gap-3.5">
|
<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">
|
<div class="site-header__user-menu relative">
|
||||||
<button
|
<button
|
||||||
ref="userMenuToggleRef"
|
ref="userMenuToggleRef"
|
||||||
@@ -182,11 +206,15 @@ onBeforeUnmount(() => {
|
|||||||
:aria-expanded="menuUserOpen.toString()"
|
:aria-expanded="menuUserOpen.toString()"
|
||||||
@click="toggleUserMenu"
|
@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]">
|
<img
|
||||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
v-if="member?.avatarUrl"
|
||||||
<path d="M12 10m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
:src="member.avatarUrl"
|
||||||
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855" />
|
:alt="member.username || '회원 아바타'"
|
||||||
</svg>
|
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>
|
</button>
|
||||||
|
|
||||||
<Transition
|
<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="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">
|
<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>
|
||||||
<div class="flex flex-col gap-0.5">
|
<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>
|
||||||
</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">
|
<template v-if="member">
|
||||||
<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">
|
<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">
|
||||||
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
|
<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 9l-6 6" />
|
<path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Z" />
|
||||||
<path d="M15 15v-6h-6" />
|
<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>
|
</svg>
|
||||||
<span>Sign up</span>
|
<span>설정</span>
|
||||||
</NuxtLink>
|
</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">
|
<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">
|
<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="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="M21 12h-13l3 -3" />
|
||||||
<path d="M11 15l-3 -3" />
|
<path d="M11 15l-3 -3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Sign in</span>
|
<span>Sign in</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</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
|
## 2026-05-11 v0.0.65
|
||||||
|
|
||||||
### 통합 검색 모달과 `GET /api/search`
|
### 통합 검색 모달과 `GET /api/search`
|
||||||
|
|||||||
26
docs/map.md
26
docs/map.md
@@ -28,13 +28,14 @@
|
|||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
|
| 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/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
|
||||||
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 하단 푸터 `px-4`/`sm:px-5` |
|
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+`는 `sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 하단 푸터 `px-4`/`sm:px-5` |
|
||||||
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
|
||||||
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
|
||||||
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
|
||||||
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
| components/site/TagHeader.vue | 태그 페이지 헤더 |
|
||||||
|
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시 |
|
||||||
|
|
||||||
## 관리자 컴포넌트
|
## 관리자 컴포넌트
|
||||||
|
|
||||||
@@ -86,6 +87,7 @@
|
|||||||
| pages/admin/tags/new.vue | 태그 생성 |
|
| pages/admin/tags/new.vue | 태그 생성 |
|
||||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||||
| pages/admin/settings/index.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/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
|
||||||
| pages/posts/index.vue | 게시물 전체 목록 |
|
| pages/posts/index.vue | 게시물 전체 목록 |
|
||||||
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
|
| 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/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
|
||||||
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
|
||||||
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
|
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
|
||||||
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
| pages/pages/[slug].vue | 고정 페이지 상세 |
|
||||||
| pages/signup.vue | 회원가입 3단계, 2단계 입력에 `auth-form-input`, 패널 `auth-signup__panel`(보더·배경) |
|
| 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
|
## 서버 API
|
||||||
|
|
||||||
@@ -114,6 +117,17 @@
|
|||||||
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
|
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
|
||||||
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
| server/api/site-settings.get.js | 공개 사이트 설정 API |
|
||||||
| server/api/navigation.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/login.post.js | 관리자 로그인 API |
|
||||||
| server/routes/admin/api/auth/logout.post.js | 관리자 로그아웃 API |
|
| server/routes/admin/api/auth/logout.post.js | 관리자 로그아웃 API |
|
||||||
| server/routes/admin/api/auth/me.get.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/settings.put.js | 관리자 사이트 설정 수정 API |
|
||||||
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
|
||||||
| server/routes/admin/api/navigation.put.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/content-schema.js | Zod 콘텐츠 스키마 |
|
||||||
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
|
||||||
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
|
||||||
|
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
|
||||||
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
|
||||||
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
|
||||||
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
|
||||||
@@ -155,6 +171,8 @@
|
|||||||
| server/utils/media-library.js | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티 |
|
| server/utils/media-library.js | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티 |
|
||||||
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
|
||||||
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
|
| 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/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
|
||||||
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
|
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
|
||||||
| db/migrations/009_add_post_og_image.sql | 게시물 OG 이미지 필드 추가 |
|
| 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`로 닫는다.
|
- `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다.
|
||||||
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
|
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
|
||||||
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
|
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
|
||||||
- 헤더 우측 사용자 아이콘 버튼은 비로그인 기준 사용자 메뉴(Anonymous, Sign up, Sign in)만 표시한다.
|
- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Anonymous·Sign up·Sign in 메뉴를 표시한다.
|
||||||
|
|
||||||
### 공개 화면 색상
|
### 공개 화면 색상
|
||||||
|
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
|
|
||||||
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
|
||||||
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
|
||||||
|
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
|
||||||
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
|
||||||
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
|
||||||
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
|
||||||
@@ -83,6 +84,7 @@
|
|||||||
- `/tag/:slug` - 태그별 게시물 목록
|
- `/tag/:slug` - 태그별 게시물 목록
|
||||||
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
|
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
|
||||||
- `/signin` - 로그인
|
- `/signin` - 로그인
|
||||||
|
- `/settings` - 회원 설정(썸네일, 닉네임, 비밀번호, 회원 탈퇴)
|
||||||
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
|
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
|
||||||
|
|
||||||
### 공개 인증 화면(초기)
|
### 공개 인증 화면(초기)
|
||||||
@@ -201,6 +203,33 @@ components/content/
|
|||||||
| created_at | DateTime | 생성일 |
|
| created_at | DateTime | 생성일 |
|
||||||
| updated_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 (고정 페이지)
|
### Pages (고정 페이지)
|
||||||
|
|
||||||
| 필드 | 타입 | 설명 |
|
| 필드 | 타입 | 설명 |
|
||||||
@@ -296,12 +325,23 @@ components/content/
|
|||||||
|
|
||||||
- `GET /api/posts` - 게시물 목록
|
- `GET /api/posts` - 게시물 목록
|
||||||
- `GET /api/posts/:slug` - 게시물 상세
|
- `GET /api/posts/:slug` - 게시물 상세
|
||||||
|
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
|
||||||
|
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
|
||||||
- `GET /api/pages` - 고정 페이지 목록
|
- `GET /api/pages` - 고정 페이지 목록
|
||||||
- `GET /api/pages/:slug` - 고정 페이지 상세
|
- `GET /api/pages/:slug` - 고정 페이지 상세
|
||||||
- `GET /api/tags` - 태그 목록
|
- `GET /api/tags` - 태그 목록
|
||||||
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
|
||||||
- `GET /api/site-settings` - 공개 사이트 설정
|
- `GET /api/site-settings` - 공개 사이트 설정
|
||||||
- `GET /api/navigation` - 공개 네비게이션
|
- `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/`)
|
### 관리자 API (`/admin/api/`)
|
||||||
|
|
||||||
@@ -333,6 +373,7 @@ components/content/
|
|||||||
- `PUT /admin/api/settings` - 사이트 설정 수정
|
- `PUT /admin/api/settings` - 사이트 설정 수정
|
||||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||||
|
- `GET /admin/api/members` - 회원 목록(최근 접속, 접속 IP, 댓글 수 포함)
|
||||||
|
|
||||||
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
|
||||||
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
|
||||||
@@ -443,6 +484,13 @@ components/content/
|
|||||||
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용
|
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용
|
||||||
- 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증
|
- 세션 토큰은 `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
|
# Auth
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
ADMIN_PASSWORD=replace-with-random-password
|
ADMIN_PASSWORD=replace-with-random-password
|
||||||
|
MEMBER_SESSION_SECRET=replace-with-random-password
|
||||||
|
|
||||||
# Upload
|
# Upload
|
||||||
UPLOAD_DIR=/uploads
|
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
|
## v0.0.66
|
||||||
|
|
||||||
- 태그 검색은 `description`을 제외하고 `name`·`slug`만 부분 일치하도록 조정해, `p` 같은 한 글자 입력으로 의미 없는 태그가 뜨는 혼선을 줄임.
|
- 태그 검색은 `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 class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/navigation">
|
||||||
메뉴
|
메뉴
|
||||||
</NuxtLink>
|
</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 class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/settings">
|
||||||
설정
|
설정
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export default defineNuxtConfig({
|
|||||||
databaseName: process.env.DATABASE_NAME || '',
|
databaseName: process.env.DATABASE_NAME || '',
|
||||||
adminEmail: process.env.ADMIN_EMAIL || '',
|
adminEmail: process.env.ADMIN_EMAIL || '',
|
||||||
adminPassword: process.env.ADMIN_PASSWORD || '',
|
adminPassword: process.env.ADMIN_PASSWORD || '',
|
||||||
|
memberSessionSecret: process.env.MEMBER_SESSION_SECRET || '',
|
||||||
uploadDir: process.env.UPLOAD_DIR || '/uploads',
|
uploadDir: process.env.UPLOAD_DIR || '/uploads',
|
||||||
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
|
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
|
||||||
public: {
|
public: {
|
||||||
|
|||||||
28
package-lock.json
generated
28
package-lock.json
generated
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.59",
|
"version": "0.0.69",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.59",
|
"version": "0.0.69",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"nuxt": "^3.21.2",
|
"nuxt": "^3.21.2",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
@@ -4471,6 +4472,29 @@
|
|||||||
"node": ">=6.0.0"
|
"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": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.69",
|
"version": "0.0.71",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"nuxt": "^3.21.2",
|
"nuxt": "^3.21.2",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"vue": "^3.5.13",
|
"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>
|
<template>
|
||||||
<MainColumn>
|
<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="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">
|
<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">
|
<h1 class="text-xl font-semibold leading-[1.125] md:text-2xl">
|
||||||
@@ -226,7 +226,7 @@ const scrollFeatured = (direction) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="py-4">
|
<section class="py-4 px-6">
|
||||||
<div class="mx-auto max-w-[720px]">
|
<div class="mx-auto max-w-[720px]">
|
||||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
<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>
|
<h2 class="text-sm font-medium uppercase site-muted">Featured</h2>
|
||||||
@@ -281,7 +281,7 @@ const scrollFeatured = (direction) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="py-4">
|
<section class="py-4 px-6">
|
||||||
<div class="mx-auto max-w-[720px]">
|
<div class="mx-auto max-w-[720px]">
|
||||||
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
|
<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>
|
<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">
|
<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">
|
<div class="mx-auto max-w-[720px] px-4 text-sm sm:px-5">
|
||||||
<p class="font-medium">Comments</p>
|
<PostComments :slug="post.slug" />
|
||||||
<p class="mt-2 site-muted">
|
|
||||||
댓글 UI는 추후 연결 예정입니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
const submitSignIn = async () => {
|
const submitSignIn = async () => {
|
||||||
@@ -44,9 +44,22 @@ const submitSignIn = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
||||||
isSubmitting.value = false
|
try {
|
||||||
statusMessage.value = '현재 로그인 API 연결 전입니다. 관리자 로그인은 /admin 을 사용해 주세요.'
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ definePageMeta({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const currentStep = ref(1)
|
const currentStep = ref(1)
|
||||||
const resendCooldown = ref(0)
|
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const signupCompleted = ref(false)
|
const signupCompleted = ref(false)
|
||||||
const statusMessage = ref('')
|
const statusMessage = ref('')
|
||||||
|
const submitErrorMessage = ref('')
|
||||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||||
default: () => ({
|
default: () => ({
|
||||||
title: 'AFFiNE',
|
title: 'AFFiNE',
|
||||||
@@ -32,7 +32,6 @@ const errors = reactive({
|
|||||||
const showSignupPassword = ref(false)
|
const showSignupPassword = ref(false)
|
||||||
const showSignupPasswordConfirm = ref(false)
|
const showSignupPasswordConfirm = ref(false)
|
||||||
|
|
||||||
const canResend = computed(() => resendCooldown.value <= 0)
|
|
||||||
const welcomeTitle = computed(() => `Welcome to ${siteSettings.value?.title || 'AFFiNE'}`)
|
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 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 = ''
|
statusMessage.value = ''
|
||||||
|
submitErrorMessage.value = ''
|
||||||
|
|
||||||
if (currentStep.value === 1) {
|
if (currentStep.value === 1) {
|
||||||
currentStep.value = 2
|
currentStep.value = 2
|
||||||
@@ -104,8 +104,27 @@ const goNextStep = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
currentStep.value = 3
|
isSubmitting.value = true
|
||||||
resendCooldown.value = 30
|
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -271,25 +247,17 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="text-2xl font-semibold leading-tight">
|
<p class="text-2xl font-semibold leading-tight">
|
||||||
이메일 확인
|
회원가입 완료
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-sm text-[#9ba3af]">
|
<p class="mt-2 text-sm text-[#9ba3af]">
|
||||||
{{ form.email }} 주소로 인증 메일을 보냈습니다.<br>
|
{{ form.email }} 계정으로 가입되었습니다.<br>
|
||||||
이메일 링크를 확인해야 회원가입이 확정됩니다.
|
이제 로그인 후 댓글을 작성할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-8 rounded-[10px] border border-[#1a212a] bg-[#0d1116] p-4">
|
<div class="mt-8 rounded-[10px] border border-[#1a212a] bg-[#0d1116] p-4">
|
||||||
<p class="text-sm text-[#d8dee6]">
|
<p class="text-sm text-[#d8dee6]">
|
||||||
메일이 오지 않았다면 인증 메일을 재전송해 주세요.
|
가입이 완료되면 자동으로 홈으로 이동합니다.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p v-if="statusMessage" class="mt-4 text-sm text-[#7ccf90]" aria-live="polite">
|
<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"
|
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"
|
type="button"
|
||||||
:disabled="isSubmitting"
|
:disabled="isSubmitting"
|
||||||
@click="completeSignup"
|
@click="goNextStep"
|
||||||
>
|
>
|
||||||
인증 완료
|
가입 처리 중
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -338,6 +306,10 @@ onBeforeUnmount(() => {
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</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">
|
<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 >= 1 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
|
||||||
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 2 ? '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