feat(member): 회원 설정/헤더 상태 UI와 관리자 멤버 관리 추가

로그인 상태를 헤더에서 즉시 인지하고 계정 관리를 이어갈 수 있도록 사용자 설정과 관리자 멤버 관측 기능을 연결했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 17:10:48 +09:00
parent 91573a31d6
commit f5cd73b223
34 changed files with 2093 additions and 107 deletions

View File

@@ -9,6 +9,7 @@ DB_PORT=43119
# Auth
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-random-password
MEMBER_SESSION_SECRET=replace-with-random-password
# Upload
UPLOAD_DIR=/uploads

View 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>

View File

@@ -4,6 +4,7 @@ const menuUserOpen = ref(false)
const userMenuRef = ref(null)
const userMenuToggleRef = ref(null)
const searchOpen = ref(false)
const member = ref(null)
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
@@ -58,6 +59,31 @@ const toggleUserMenu = () => {
menuUserOpen.value = !menuUserOpen.value
}
/**
* 회원 세션 정보를 조회한다.
* @returns {Promise<void>}
*/
const fetchMember = async () => {
try {
member.value = await $fetch('/api/auth/me')
} catch {
member.value = null
}
}
/**
* 회원 로그아웃을 처리한다.
* @returns {Promise<void>}
*/
const logoutMember = async () => {
await $fetch('/api/auth/logout', {
method: 'POST'
})
member.value = null
closeUserMenu()
await navigateTo('/')
}
/**
* 문서 클릭 시 사용자 메뉴 외부 영역이면 메뉴를 닫는다.
* @param {MouseEvent} event - 클릭 이벤트
@@ -113,6 +139,7 @@ const onGlobalKeydown = (event) => {
}
onMounted(() => {
fetchMember()
document.addEventListener('click', onDocumentClick)
document.addEventListener('keydown', onGlobalKeydown)
})
@@ -170,9 +197,6 @@ onBeforeUnmount(() => {
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
</button>
<nav class="site-header__nav flex shrink-0 items-center gap-3 text-sm sm:gap-3.5">
<NuxtLink class="site-header__buy site-accent-button shrink-0 rounded-lg px-3 py-1.5 text-xs font-semibold sm:px-4 sm:py-2 sm:text-sm" to="/pages/about">
Subscribe
</NuxtLink>
<div class="site-header__user-menu relative">
<button
ref="userMenuToggleRef"
@@ -182,11 +206,15 @@ onBeforeUnmount(() => {
:aria-expanded="menuUserOpen.toString()"
@click="toggleUserMenu"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" class="h-6 w-6 stroke-current stroke-[1.75] md:h-7 md:w-7 md:stroke-[1.5]">
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
<path d="M12 10m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855" />
</svg>
<img
v-if="member?.avatarUrl"
:src="member.avatarUrl"
:alt="member.username || '회원 아바타'"
class="h-full w-full rounded-full object-cover"
>
<span v-else class="grid h-full w-full place-items-center rounded-full bg-[var(--site-panel)] text-[11px] font-semibold">
{{ (member?.username || member?.email || '@').slice(0, 1).toUpperCase() }}
</span>
</button>
<Transition
@@ -204,30 +232,62 @@ onBeforeUnmount(() => {
>
<div class="mb-2 flex items-center gap-2 border-b border-[var(--site-line)] pb-3">
<div class="site-header__avatar-wrap flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-[var(--site-panel)] md:h-10 md:w-10">
<span class="text-base font-normal uppercase md:text-lg">@</span>
<img
v-if="member?.avatarUrl"
:src="member.avatarUrl"
:alt="member.username || '회원 아바타'"
class="h-full w-full object-cover"
>
<span v-else class="text-base font-normal uppercase md:text-lg">
{{ (member?.username || member?.email || '@').slice(0, 1) }}
</span>
</div>
<div class="flex flex-col gap-0.5">
<div class="max-w-xs truncate leading-[1.15]">Anonymous</div>
<div class="max-w-xs truncate leading-[1.15]">
{{ member?.username || 'Anonymous' }}
</div>
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
{{ member.email }}
</div>
</div>
</div>
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M15 9l-6 6" />
<path d="M15 15v-6h-6" />
</svg>
<span>Sign up</span>
</NuxtLink>
<template v-if="member">
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/settings" @click="closeUserMenu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Z" />
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 1-2 0 1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 1 0-2 1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 1 2 0 1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.24.36.48.69.6 1 .18.45.18 1.55 0 2-.12.31-.36.64-.6 1Z" />
</svg>
<span>설정</span>
</NuxtLink>
<button class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 text-left transition-colors duration-150 hover:bg-[var(--site-panel)]" type="button" @click="logoutMember">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
<path d="M21 12h-13l3 -3" />
<path d="M11 15l-3 -3" />
</svg>
<span>로그아웃</span>
</button>
</template>
<template v-else>
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M15 9l-6 6" />
<path d="M15 15v-6h-6" />
</svg>
<span>Sign up</span>
</NuxtLink>
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
<path d="M21 12h-13l3 -3" />
<path d="M11 15l-3 -3" />
</svg>
<span>Sign in</span>
</NuxtLink>
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
<path d="M21 12h-13l3 -3" />
<path d="M11 15l-3 -3" />
</svg>
<span>Sign in</span>
</NuxtLink>
</template>
</div>
</Transition>
</div>

View 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);

View 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));

View File

@@ -1,5 +1,19 @@
# 의사결정 이력
## 2026-05-11 v0.0.71
### 회원 UX를 헤더 중심으로 전환
기존 헤더는 로그인 여부와 무관하게 Anonymous 메뉴를 고정으로 보여 실제 로그인 상태가 사용자에게 전달되지 않았다. 구독 버튼 대신 우측 아바타만 남기고, 로그인 상태에서는 설정/로그아웃 메뉴를 제공해 계정 액션을 한 위치로 정리했다. 비로그인 상태에서는 기존 Sign up/Sign in을 유지한다.
### 회원 설정/활동 추적과 관리자 멤버 관측
회원 기능이 들어오면서 운영 관점에서 사용자 정보와 활동 추적이 필요해졌다. `users``avatar_url`, `last_seen_at`, `last_seen_ip`를 추가하고 로그인/세션조회/댓글작성 시 최근 활동을 갱신한다. 관리자는 `/admin/members`에서 닉네임, 이메일, 최근 접속, IP, 댓글 수를 확인해 운영 판단을 할 수 있다.
### 닉네임 유니크 정책
사용자 설정에서 닉네임 변경 시 중복 체크가 필요하므로 DB 레벨에서 `lower(username)` 유니크 인덱스를 도입했다. 기존 중복 데이터로 마이그레이션이 막히지 않도록, 인덱스 생성 전 중복 닉네임은 `-2`, `-3` 접미사를 붙여 자동 정리한 뒤 인덱스를 생성한다.
## 2026-05-11 v0.0.65
### 통합 검색 모달과 `GET /api/search`

View File

@@ -28,13 +28,14 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(Anonymous/Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(로그인: 설정/로그아웃, 비로그인: Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 |
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 하단 푸터 `px-4`/`sm:px-5` |
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
| components/site/TagHeader.vue | 태그 페이지 헤더 |
| components/comments/PostComments.vue | 게시물 상세 `#comments` 영역, 회원 댓글/답글(1단) 작성 및 목록 표시 |
## 관리자 컴포넌트
@@ -86,6 +87,7 @@
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 |
| pages/admin/members/index.vue | 관리자 멤버 목록(닉네임, 이메일, 최근 접속, IP, 댓글 수, 활동 상태) |
## 공개 페이지
@@ -94,13 +96,14 @@
| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 |
| pages/posts/index.vue | 게시물 전체 목록 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사) |
| pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 |
| pages/pages/[slug].vue | 고정 페이지 상세 |
| pages/signup.vue | 회원가입 3단계, 2단계 입력에 `auth-form-input`, 패널 `auth-signup__panel`(보더·배경) |
| pages/signin.vue | 로그인(다크 폼, `[color-scheme:dark]`, 입력 `auth-form-input`), 비밀번호 SVG 토글 |
| pages/signin.vue | 로그인(다크 폼, `[color-scheme:dark]`, 입력 `auth-form-input`), 비밀번호 SVG 토글, 회원 로그인 API 연동 |
| pages/settings/index.vue | 회원 설정(썸네일 URL, 닉네임 변경/중복확인, 비밀번호 변경, 회원 탈퇴) |
## 서버 API
@@ -114,6 +117,17 @@
| server/api/search.get.js | 통합 검색 API(`q` 쿼리) |
| server/api/site-settings.get.js | 공개 사이트 설정 API |
| server/api/navigation.get.js | 공개 네비게이션 API |
| server/api/auth/signup.post.js | 회원 가입 API |
| server/api/auth/login.post.js | 회원 로그인 API |
| server/api/auth/me.get.js | 회원 세션 조회 API |
| server/api/auth/logout.post.js | 회원 로그아웃 API |
| server/api/auth/profile.get.js | 회원 프로필 조회 API |
| server/api/auth/profile.put.js | 회원 프로필 수정 API |
| server/api/auth/check-username.get.js | 닉네임 중복 확인 API |
| server/api/auth/password.put.js | 회원 비밀번호 변경 API |
| server/api/auth/account.delete.js | 회원 탈퇴 API |
| server/api/posts/[slug]/comments.get.js | 게시물 댓글 목록 조회 API |
| server/api/posts/[slug]/comments.post.js | 게시물 댓글 작성 API |
| server/routes/admin/api/auth/login.post.js | 관리자 로그인 API |
| server/routes/admin/api/auth/logout.post.js | 관리자 로그아웃 API |
| server/routes/admin/api/auth/me.get.js | 관리자 세션 조회 API |
@@ -142,9 +156,11 @@
| server/routes/admin/api/settings.put.js | 관리자 사이트 설정 수정 API |
| server/routes/admin/api/navigation.get.js | 관리자 네비게이션 목록 API |
| server/routes/admin/api/navigation.put.js | 관리자 네비게이션 일괄 저장 API |
| server/routes/admin/api/members.get.js | 관리자 멤버 목록 API |
| server/utils/content-schema.js | Zod 콘텐츠 스키마 |
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
| server/utils/admin-auth.js | 관리자 세션 쿠키 인증 유틸리티 |
| server/utils/member-auth.js | 회원 세션 쿠키 인증 유틸리티 |
| server/utils/admin-post-input.js | 관리자 게시물 입력값 검증 스키마 |
| server/utils/admin-page-input.js | 관리자 고정 페이지 입력값 검증 스키마 |
| server/utils/admin-site-settings-input.js | 관리자 사이트 설정 입력값 검증 스키마 |
@@ -155,6 +171,8 @@
| server/utils/media-library.js | 업로드 미디어 파일과 폴더 메타데이터 관리 유틸리티 |
| server/repositories/postgres-client.js | PostgreSQL 클라이언트 |
| server/repositories/content-repository.js | 콘텐츠 조회 저장소 |
| server/repositories/member-repository.js | 회원 조회/생성 저장소 |
| server/repositories/comment-repository.js | 댓글 조회/생성 저장소 |
## 데이터베이스
@@ -169,6 +187,8 @@
| db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 |
| db/migrations/008_add_post_seo_fields.sql | 게시물 SEO 필드 추가 |
| db/migrations/009_add_post_og_image.sql | 게시물 OG 이미지 필드 추가 |
| db/migrations/010_add_members_and_comments.sql | 회원/댓글 테이블 추가 |
| db/migrations/011_add_member_profile_and_activity.sql | 회원 아바타/최근 활동 컬럼 추가 및 닉네임 유니크 인덱스 추가 |
## 설정/배포

View File

@@ -37,7 +37,7 @@
- `lg` 미만에서는 왼쪽 사이드바를 화면 좌측 고정 슬라이드 패널로 표시하고, 열린 동안 백드롭을 탭하면 `closeMenu`로 닫는다.
- `Escape` 키는 통합 검색 모달이 열려 있으면 최우선으로 닫고, 그다음 사용자 드롭다운, 이어서 모바일에서만 좌측 슬라이드 메뉴를 닫는다.
- `/` 키는 `INPUT`·`TEXTAREA`·`SELECT`·`contenteditable`에 포커스가 없고 `Ctrl`/`Meta`/`Alt`와 함께 눌리지 않을 때 통합 검색 모달을 연다. 헤더 검색 영역(`md+`) 클릭으로도 동일하게 연다.
- 헤더 우측 사용자 아이콘 버튼은 로그인 기준 사용자 메뉴(Anonymous, Sign up, Sign in)만 표시한다.
- 헤더 우측 사용자 아이콘 버튼은 로그인 상태면 회원 아바타/닉네임과 설정·로그아웃 메뉴를, 비로그인 상태면 Anonymous·Sign up·Sign in 메뉴를 표시한다.
### 공개 화면 색상
@@ -58,6 +58,7 @@
- 게시물 화면은 레이아웃 그리드의 좌우 패딩을 기본으로 사용하고, 섹션 단위 `px-*`는 내부 래퍼로만 두어 패딩이 2중 적용되지 않게 한다.
- 댓글 시작 섹션의 구분선(`border-y`)은 패딩 없이 전체 폭으로 표시하고, 내용만 내부 래퍼에 패딩을 둔다.
- 댓글은 로그인 회원만 작성 가능하며, 대댓글은 1단까지만 허용한다.
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 공유 모달은 게시물 썸네일/제목/요약 미리보기, X/Bluesky/Facebook/LinkedIn/Email 링크, 링크 복사 액션을 제공한다.
@@ -83,6 +84,7 @@
- `/tag/:slug` - 태그별 게시물 목록
- `/signup` - 회원가입(3단계: 환영/입력/이메일 확인)
- `/signin` - 로그인
- `/settings` - 회원 설정(썸네일, 닉네임, 비밀번호, 회원 탈퇴)
- 기존 `/posts/:slug`, `/tags/:slug` 상세 경로는 새 단수형 상세 경로로 리다이렉트한다.
### 공개 인증 화면(초기)
@@ -201,6 +203,33 @@ components/content/
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
### Users
| 필드 | 타입 | 설명 |
|------|------|------|
| id | UUID | Primary Key |
| username | String | 사용자명 |
| email | String | 로그인 이메일(유니크) |
| password_hash | String | bcrypt 해시 비밀번호 |
| avatar_url | String | 프로필 썸네일 URL |
| last_seen_at | DateTime nullable | 마지막 접속 시각 |
| last_seen_ip | String | 마지막 접속 IP |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
### Comments
| 필드 | 타입 | 설명 |
|------|------|------|
| id | UUID | Primary Key |
| post_id | UUID | FK → Posts |
| user_id | UUID | FK → Users |
| parent_id | UUID nullable | FK → Comments, 대댓글 1단 |
| body | Text | 댓글 본문 |
| status | Enum | published/pending/blocked |
| created_at | DateTime | 생성일 |
| updated_at | DateTime | 수정일 |
### Pages (고정 페이지)
| 필드 | 타입 | 설명 |
@@ -296,12 +325,23 @@ components/content/
- `GET /api/posts` - 게시물 목록
- `GET /api/posts/:slug` - 게시물 상세
- `GET /api/posts/:slug/comments` - 게시물 댓글 목록
- `POST /api/posts/:slug/comments` - 게시물 댓글 작성(회원 세션 필요, 대댓글 1단)
- `GET /api/pages` - 고정 페이지 목록
- `GET /api/pages/:slug` - 고정 페이지 상세
- `GET /api/tags` - 태그 목록
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
- `GET /api/site-settings` - 공개 사이트 설정
- `GET /api/navigation` - 공개 네비게이션
- `POST /api/auth/signup` - 회원 가입
- `POST /api/auth/login` - 회원 로그인
- `GET /api/auth/me` - 현재 회원 세션 조회
- `POST /api/auth/logout` - 회원 로그아웃
- `GET /api/auth/profile` - 회원 설정 조회
- `PUT /api/auth/profile` - 회원 프로필 수정(닉네임, 썸네일)
- `GET /api/auth/check-username?username=` - 닉네임 중복 확인
- `PUT /api/auth/password` - 회원 비밀번호 변경
- `DELETE /api/auth/account` - 회원 탈퇴
### 관리자 API (`/admin/api/`)
@@ -333,6 +373,7 @@ components/content/
- `PUT /admin/api/settings` - 사이트 설정 수정
- `GET /admin/api/navigation` - 네비게이션 항목 목록
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
- `GET /admin/api/members` - 회원 목록(최근 접속, 접속 IP, 댓글 수 포함)
> 글 발행/초안/비공개 전환은 현재 `PUT /admin/api/posts/:id`의 `status` 값으로 처리한다.
> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
@@ -443,6 +484,13 @@ components/content/
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용
- 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증
### 회원 인증
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)로 세션을 관리한다.
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.
---
## 미디어 관리
@@ -488,6 +536,7 @@ DB_PORT=43119
# Auth
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=replace-with-random-password
MEMBER_SESSION_SECRET=replace-with-random-password
# Upload
UPLOAD_DIR=/uploads

View File

@@ -1,5 +1,19 @@
# 업데이트 이력
## v0.0.71
- 헤더 사용자 영역에서 구독 버튼을 제거하고, 로그인 상태 기반 아바타/드롭다운(설정, 로그아웃 / 비로그인 시 Sign up, Sign in)으로 정리.
- 회원 설정 페이지(`/settings`)를 추가하고 닉네임 변경(중복 확인), 썸네일 URL 변경, 비밀번호 변경, 회원 탈퇴 기능을 연결.
- 관리자 멤버 화면(`/admin/members`)과 회원 목록 API를 추가해 닉네임, 이메일, 최근 접속 시각/IP, 댓글 개수, 활동 현황을 확인할 수 있게 구성.
- 회원 활동 컬럼(`last_seen_at`, `last_seen_ip`)과 아바타 컬럼(`avatar_url`)을 DB에 추가하고, 로그인/세션 조회/댓글 작성 시 최근 활동을 갱신.
## v0.0.70
- 회원 인증 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)와 회원 세션 쿠키(`sori_member_session`)를 추가.
- 댓글 DB 스키마(`users`, `comments`)와 게시물 댓글 API(`GET/POST /api/posts/:slug/comments`)를 추가하고, 대댓글은 1단까지만 허용하도록 검증을 적용.
- 게시물 상세 `#comments` 영역에 `PostComments` UI를 연결해 로그인 회원 댓글/답글 작성과 댓글 목록 표시를 구현.
- `signin`/`signup` 화면을 시뮬레이션에서 실제 API 연동으로 전환.
## v0.0.66
- 태그 검색은 `description`을 제외하고 `name`·`slug`만 부분 일치하도록 조정해, `p` 같은 한 글자 입력으로 의미 없는 태그가 뜨는 혼선을 줄임.

View File

@@ -70,6 +70,9 @@ const logoutAdmin = async () => {
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/navigation">
메뉴
</NuxtLink>
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/members">
멤버
</NuxtLink>
<NuxtLink class="admin-layout__nav-link rounded px-3 py-2 transition-colors hover:bg-white/10 hover:text-white" to="/admin/settings">
설정
</NuxtLink>

View File

@@ -50,6 +50,7 @@ export default defineNuxtConfig({
databaseName: process.env.DATABASE_NAME || '',
adminEmail: process.env.ADMIN_EMAIL || '',
adminPassword: process.env.ADMIN_PASSWORD || '',
memberSessionSecret: process.env.MEMBER_SESSION_SECRET || '',
uploadDir: process.env.UPLOAD_DIR || '/uploads',
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
public: {

28
package-lock.json generated
View File

@@ -1,15 +1,16 @@
{
"name": "sori.studio",
"version": "0.0.59",
"version": "0.0.69",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "0.0.59",
"version": "0.0.69",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
"bcrypt": "^6.0.0",
"nuxt": "^3.21.2",
"postgres": "^3.4.9",
"vue": "^3.5.13",
@@ -4471,6 +4472,29 @@
"node": ">=6.0.0"
}
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/bcrypt/node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.69",
"version": "0.0.71",
"private": true,
"type": "module",
"imports": {
@@ -18,6 +18,7 @@
},
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
"bcrypt": "^6.0.0",
"nuxt": "^3.21.2",
"postgres": "^3.4.9",
"vue": "^3.5.13",

View 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>

View File

@@ -204,7 +204,7 @@ const scrollFeatured = (direction) => {
<template>
<MainColumn>
<section class="py-6 md:py-8">
<section class="py-6 px-6 md:py-8">
<div class="mx-auto flex max-w-[720px] flex-col-reverse gap-6">
<div class="z-[2] flex flex-col items-center justify-center gap-2 text-center">
<h1 class="text-xl font-semibold leading-[1.125] md:text-2xl">
@@ -226,7 +226,7 @@ const scrollFeatured = (direction) => {
</div>
</section>
<section class="py-4">
<section class="py-4 px-6">
<div class="mx-auto max-w-[720px]">
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
<h2 class="text-sm font-medium uppercase site-muted">Featured</h2>
@@ -281,7 +281,7 @@ const scrollFeatured = (direction) => {
</div>
</section>
<section class="py-4">
<section class="py-4 px-6">
<div class="mx-auto max-w-[720px]">
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
<h2 class="text-sm font-medium uppercase site-muted">Latest</h2>

View File

@@ -276,10 +276,7 @@ useHead(() => ({
<section id="comments" class="mb-6 border-y border-[var(--site-line)] bg-[var(--site-panel-strong)] py-5 scroll-mt-14">
<div class="mx-auto max-w-[720px] px-4 text-sm sm:px-5">
<p class="font-medium">Comments</p>
<p class="mt-2 site-muted">
댓글 UI는 추후 연결 예정입니다.
</p>
<PostComments :slug="post.slug" />
</div>
</section>

273
pages/settings/index.vue Normal file
View 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>

View File

@@ -35,7 +35,7 @@ const validateSignIn = () => {
}
/**
* 로그인 요청을 시뮬레이션한다.
* 로그인 요청을 처리한다.
* @returns {Promise<void>}
*/
const submitSignIn = async () => {
@@ -44,9 +44,22 @@ const submitSignIn = async () => {
}
isSubmitting.value = true
await new Promise((resolve) => setTimeout(resolve, 500))
isSubmitting.value = false
statusMessage.value = '현재 로그인 API 연결 전입니다. 관리자 로그인은 /admin 을 사용해 주세요.'
try {
await $fetch('/api/auth/login', {
method: 'POST',
body: {
email: form.email.trim(),
password: form.password
}
})
statusMessage.value = '로그인되었습니다. 잠시 후 이동합니다.'
await navigateTo('/')
} catch (error) {
errorMessage.value = error?.data?.message || '로그인에 실패했습니다.'
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -4,10 +4,10 @@ definePageMeta({
})
const currentStep = ref(1)
const resendCooldown = ref(0)
const isSubmitting = ref(false)
const signupCompleted = ref(false)
const statusMessage = ref('')
const submitErrorMessage = ref('')
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({
title: 'AFFiNE',
@@ -32,7 +32,6 @@ const errors = reactive({
const showSignupPassword = ref(false)
const showSignupPasswordConfirm = ref(false)
const canResend = computed(() => resendCooldown.value <= 0)
const welcomeTitle = computed(() => `Welcome to ${siteSettings.value?.title || 'AFFiNE'}`)
const welcomeDescription = computed(() => siteSettings.value?.description || 'Configure your Self Host AFFiNE with a few simple settings.')
@@ -89,10 +88,11 @@ const validateStepTwo = () => {
/**
* 다음 단계로 이동한다.
* @returns {void}
* @returns {Promise<void>}
*/
const goNextStep = () => {
const goNextStep = async () => {
statusMessage.value = ''
submitErrorMessage.value = ''
if (currentStep.value === 1) {
currentStep.value = 2
@@ -104,8 +104,27 @@ const goNextStep = () => {
return
}
currentStep.value = 3
resendCooldown.value = 30
isSubmitting.value = true
try {
await $fetch('/api/auth/signup', {
method: 'POST',
body: {
username: form.username.trim(),
email: form.email.trim(),
password: form.password
}
})
signupCompleted.value = true
currentStep.value = 3
statusMessage.value = '회원가입이 완료되었습니다. 잠시 후 홈으로 이동합니다.'
await navigateTo('/')
} catch (error) {
submitErrorMessage.value = error?.data?.message || '회원가입에 실패했습니다.'
} finally {
isSubmitting.value = false
}
}
}
@@ -120,49 +139,6 @@ const goPreviousStep = () => {
}
}
/**
* 인증 메일 재전송을 시뮬레이션한다.
* @returns {Promise<void>}
*/
const resendVerificationEmail = async () => {
if (!canResend.value || isSubmitting.value) {
return
}
isSubmitting.value = true
await new Promise((resolve) => setTimeout(resolve, 500))
isSubmitting.value = false
resendCooldown.value = 30
statusMessage.value = '인증 메일을 다시 보냈습니다. 메일함을 확인해 주세요.'
}
/**
* 이메일 인증 완료를 시뮬레이션한다.
* @returns {Promise<void>}
*/
const completeSignup = async () => {
isSubmitting.value = true
await new Promise((resolve) => setTimeout(resolve, 600))
isSubmitting.value = false
signupCompleted.value = true
statusMessage.value = '이메일 인증이 완료되었습니다. 로그인 페이지로 이동할 수 있습니다.'
}
const countdownTimer = ref(/** @type {ReturnType<typeof setInterval> | null} */ (null))
onMounted(() => {
countdownTimer.value = setInterval(() => {
if (resendCooldown.value > 0) {
resendCooldown.value -= 1
}
}, 1000)
})
onBeforeUnmount(() => {
if (countdownTimer.value) {
clearInterval(countdownTimer.value)
}
})
</script>
<template>
@@ -271,25 +247,17 @@ onBeforeUnmount(() => {
<template v-else>
<p class="text-2xl font-semibold leading-tight">
이메일 확인
회원가입 완료
</p>
<p class="mt-2 text-sm text-[#9ba3af]">
{{ form.email }} 주소 인증 메일을 보냈습니다.<br>
메일 링크를 확인해야 회원가입이 확정됩니다.
{{ form.email }} 계정으 가입되었습니다.<br>
로그인 댓글을 작성할 있습니다.
</p>
<div class="mt-8 rounded-[10px] border border-[#1a212a] bg-[#0d1116] p-4">
<p class="text-sm text-[#d8dee6]">
메일 오지 않았다면 인증 메일을 재전송해 주세요.
가입 완료되면 자동으로 홈으로 이동합니다.
</p>
<button
class="mt-3 h-9 rounded-[8px] border border-[#2f6feb] px-4 text-xs font-medium text-[#7eb8ff] transition-opacity disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="!canResend || isSubmitting"
@click="resendVerificationEmail"
>
{{ canResend ? '인증 메일 재전송' : `${resendCooldown}초 후 재전송` }}
</button>
</div>
<p v-if="statusMessage" class="mt-4 text-sm text-[#7ccf90]" aria-live="polite">
@@ -324,9 +292,9 @@ onBeforeUnmount(() => {
class="h-9 rounded-[8px] bg-[#2f6feb] px-8 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
type="button"
:disabled="isSubmitting"
@click="completeSignup"
@click="goNextStep"
>
인증 완료
가입 처리
</button>
<NuxtLink
@@ -338,6 +306,10 @@ onBeforeUnmount(() => {
</NuxtLink>
</div>
<p v-if="submitErrorMessage" class="mt-3 text-xs text-[#e5acb1]" aria-live="polite">
{{ submitErrorMessage }}
</p>
<div class="mt-8 flex items-center gap-1.5">
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 1 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />
<span class="h-[2px] w-9 rounded-full" :class="currentStep >= 2 ? 'bg-[#2f6feb]' : 'bg-[#222a34]'" />

View 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 }
})

View 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
}
})

View 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 || ''
}
})

View 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
View 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 || ''
}
})

View 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 }
})

View 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 || ''
}
})

View 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 || ''
}
})

View 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 || ''
}
})

View 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
}
})

View 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
}
})

View 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
}
}
}

View 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 ? '활성' : '비활성'
}
})
}

View 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
View 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
}