5 Commits

Author SHA1 Message Date
6e25cdfd60 블록 에디터 빈 단락·슬래시·방향키·검색 폭 등 (v0.0.102)
- 빈 문단 마커 직렬화·공개 렌더 파싱
- 슬래시 메뉴 스크롤·하이라이트·블록 간 이동
- 헤더 검색 버튼 min-width, 네비 관리 안내 문구 정리

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 15:45:48 +09:00
5031b9de22 공개 primary 네비 트리 중복 방지 (v0.0.101)
- 동일 id 중복 제거, 자식으로 붙은 항목은 루트에서 제외
- spec·history·update 반영

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 15:45:17 +09:00
003fb86fad EMAIL_OTP_PEPPER 역할·권장값 문서 보강 (v0.0.100)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 14:12:18 +09:00
6a059a9a59 헤더 검색 중앙 정렬·Resend 이메일 OTP·비밀번호 찾기 (v0.0.99)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 13:34:21 +09:00
996965740f 사이드바 푸터 링크 줄바꿈·상단 네비 호버 행 전체 너비 (v0.0.98)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 12:33:10 +09:00
28 changed files with 1325 additions and 68 deletions

View File

@@ -24,5 +24,10 @@ AVATAR_WEBP_QUALITY=82
NUXT_PUBLIC_SITE_URL=https://sori.studio
NUXT_PUBLIC_SITE_TITLE=sori.studio
# Transactional email (Resend, optional — 회원가입 OTP·비밀번호 찾기)
# RESEND_API_KEY=
# RESEND_FROM_EMAIL=noreply@yourdomain.com
# EMAIL_OTP_PEPPER= ← 선택. OTP를 DB에 해시해 저장할 때 섞는 서버 전용 비밀(긴 난문자열 권장, 예: openssl rand -hex 32). 비우면 MEMBER_SESSION_SECRET을 대신 사용.
# Server
APP_PORT=43118

View File

@@ -112,6 +112,8 @@ const blockCommands = [
}
]
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
/**
* 에디터 블록 생성
* @param {string} type - 블록 타입
@@ -187,6 +189,12 @@ const parseMarkdownToBlocks = (markdown) => {
const line = lines[index]
const trimmedLine = line.trim()
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
blocks.push(createEditorBlock('paragraph', '', null, `editor-block-${blocks.length}`))
index += 1
continue
}
if (!trimmedLine) {
index += 1
continue
@@ -304,8 +312,9 @@ const serializeImage = (image) => {
*/
const serializeBlocks = () => {
const lines = editorBlocks.value
.map((block) => {
const text = block.text.trim()
.map((block, index) => {
const rawText = block.text || ''
const text = rawText.trim()
if (block.type === 'divider') {
return { type: block.type, value: '---' }
@@ -349,6 +358,14 @@ const serializeBlocks = () => {
: null
}
if (!text && block.type === 'paragraph') {
if (index === editorBlocks.value.length - 1) {
return null
}
return { type: block.type, value: BLANK_PARAGRAPH_MARKER }
}
if (!text) {
return null
}
@@ -454,7 +471,7 @@ const setBlockRef = (element, index) => {
* @param {number} index - 블록 인덱스
* @returns {void}
*/
const focusBlock = (index) => {
const focusBlock = (index, position = 'end') => {
nextTick(() => {
const element = blockRefs.value[index]
@@ -466,12 +483,41 @@ const focusBlock = (index) => {
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(element)
range.collapse(false)
range.collapse(position === 'start')
selection.removeAllRanges()
selection.addRange(range)
})
}
/**
* 현재 커서가 블록 시작/끝 경계에 있는지 확인
* @param {Element} element - 블록 요소
* @param {'start'|'end'} boundary - 경계 방향
* @returns {boolean} 경계 위치 여부
*/
const isCaretOnBoundary = (element, boundary) => {
const selection = window.getSelection()
if (!selection?.rangeCount) {
return false
}
const range = selection.getRangeAt(0)
if (!range.collapsed || !element.contains(range.commonAncestorContainer)) {
return false
}
const probeRange = range.cloneRange()
probeRange.selectNodeContents(element)
if (boundary === 'start') {
probeRange.setEnd(range.startContainer, range.startOffset)
return probeRange.toString().length === 0
}
probeRange.setStart(range.startContainer, range.startOffset)
return probeRange.toString().length === 0
}
/**
* 구조형 블록의 첫 입력 필드로 커서 이동
* @param {number} index - 블록 인덱스
@@ -751,11 +797,25 @@ const applyMarkdownShortcut = (block, index) => {
* @returns {void}
*/
const updateSlashQuery = (block) => {
slashQuery.value = block.text.startsWith('/')
const nextSlashQuery = block.text.startsWith('/')
? block.text.slice(1).trim().toLowerCase()
: ''
const hasQueryChanged = slashQuery.value !== nextSlashQuery
slashQuery.value = nextSlashQuery
highlightedCommandIndex.value = 0
if (hasQueryChanged) {
highlightedCommandIndex.value = 0
return
}
if (!visibleCommands.value.length) {
highlightedCommandIndex.value = 0
return
}
if (highlightedCommandIndex.value >= visibleCommands.value.length) {
highlightedCommandIndex.value = visibleCommands.value.length - 1
}
}
const activeBlockIndex = computed(() => editorBlocks.value.findIndex((block) => block.id === activeBlockId.value))
@@ -1030,6 +1090,12 @@ const removeGalleryImage = (block, imageIndex) => {
* @returns {void}
*/
const highlightNextCommand = (event) => {
const block = editorBlocks.value[activeBlockIndex.value]
if (!block?.text?.startsWith('/')) {
return
}
syncTextBlockFromDom(activeBlockIndex.value)
if (!visibleCommands.value.length) {
@@ -1046,6 +1112,12 @@ const highlightNextCommand = (event) => {
* @returns {void}
*/
const highlightPreviousCommand = (event) => {
const block = editorBlocks.value[activeBlockIndex.value]
if (!block?.text?.startsWith('/')) {
return
}
syncTextBlockFromDom(activeBlockIndex.value)
if (!visibleCommands.value.length) {
@@ -1058,6 +1130,72 @@ const highlightPreviousCommand = (event) => {
: highlightedCommandIndex.value - 1
}
/**
* 현재 하이라이트된 슬래시 메뉴 항목을 스크롤 영역에 맞춘다.
* @returns {void}
*/
const scrollHighlightedCommandIntoView = () => {
nextTick(() => {
if (!activeBlockId.value || !visibleCommands.value.length) {
return
}
const row = document.querySelector(`[data-editor-block-id="${activeBlockId.value}"]`)
const menu = row?.querySelector('.admin-block-editor__slash-menu')
const highlightedItem = row?.querySelector('.admin-block-editor__slash-item--active')
if (!menu || !highlightedItem) {
return
}
highlightedItem.scrollIntoView({
block: 'nearest'
})
})
}
/**
* 일반 본문 블록 방향키 이동 처리
* @param {KeyboardEvent} event - 키보드 이벤트
* @param {number} index - 현재 블록 인덱스
* @param {'up'|'down'} direction - 이동 방향
* @returns {void}
*/
const navigateAcrossBlocks = (event, index, direction) => {
const currentBlock = editorBlocks.value[index]
if (!currentBlock || currentBlock.text.startsWith('/')) {
return
}
const currentElement = blockRefs.value[index]
if (!currentElement) {
return
}
const isBoundary = direction === 'up'
? isCaretOnBoundary(currentElement, 'start')
: isCaretOnBoundary(currentElement, 'end')
if (!isBoundary) {
return
}
const nextIndex = direction === 'up' ? index - 1 : index + 1
if (nextIndex < 0 || nextIndex >= editorBlocks.value.length) {
return
}
event.preventDefault()
const targetBlock = editorBlocks.value[nextIndex]
if (isTextBlock(targetBlock)) {
focusBlock(nextIndex, direction === 'up' ? 'end' : 'start')
return
}
focusStructuredBlock(nextIndex)
}
/**
* 엔터 키로 다음 블록 생성
* @param {KeyboardEvent} event - 키보드 이벤트
@@ -1359,6 +1497,13 @@ watch(editorBlocks, () => {
})
}, { deep: true })
watch(
[highlightedCommandIndex, () => visibleCommands.value.length, activeBlockId],
() => {
scrollHighlightedCommandIntoView()
}
)
defineExpose({
focusFirstBlock: () => focusBlock(0)
})
@@ -1547,8 +1692,8 @@ defineExpose({
@compositionstart="startTextComposition"
@compositionend="finishTextComposition($event, index)"
@keydown.enter="handleEnter($event, index)"
@keydown.down="highlightNextCommand"
@keydown.up="highlightPreviousCommand"
@keydown.down="block.text.startsWith('/') ? highlightNextCommand($event) : navigateAcrossBlocks($event, index, 'down')"
@keydown.up="block.text.startsWith('/') ? highlightPreviousCommand($event) : navigateAcrossBlocks($event, index, 'up')"
@keydown.backspace="handleBackspace($event, index)"
/>
@@ -1565,14 +1710,14 @@ defineExpose({
<div
v-if="visibleCommands.length && activeBlockId === block.id"
class="admin-block-editor__slash-menu absolute left-0 z-20 w-72 overflow-hidden rounded border border-line bg-white shadow-lg"
class="admin-block-editor__slash-menu absolute left-0 z-20 w-72 max-h-[min(52vh,360px)] overflow-y-auto rounded border border-line bg-white shadow-lg"
:class="slashMenuDirection === 'up' ? 'bottom-full mb-2' : 'top-full mt-2'"
>
<button
v-for="(command, commandIndex) in visibleCommands"
:key="`${command.type}-${command.level || 'default'}`"
class="admin-block-editor__slash-item grid w-full gap-0.5 px-4 py-3 text-left hover:bg-surface"
:class="commandIndex === highlightedCommandIndex ? 'bg-surface' : ''"
:class="commandIndex === highlightedCommandIndex ? 'admin-block-editor__slash-item--active bg-surface' : ''"
type="button"
@mousedown.prevent="applyCommand(command)"
>

View File

@@ -6,6 +6,8 @@ const props = defineProps({
}
})
const BLANK_PARAGRAPH_MARKER = '<!--sori:blank-paragraph-->'
const activeLightboxImages = ref([])
const activeLightboxIndex = ref(0)
@@ -177,6 +179,12 @@ const parseMarkdownBlocks = (markdown) => {
const line = lines[index]
const trimmedLine = line.trim()
if (trimmedLine === BLANK_PARAGRAPH_MARKER) {
blocks.push(createBlock('paragraph', '', null, `block-${blocks.length}`))
index += 1
continue
}
if (!trimmedLine) {
index += 1
continue

View File

@@ -191,19 +191,22 @@ onMounted(() => {
</div>
</div>
<footer class="left-sidebar__footer flex shrink-0 items-center justify-between px-4 py-4 text-xs sm:px-5">
<nav class="left-sidebar__footer-nav flex gap-4">
<footer class="left-sidebar__footer flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 px-4 py-4 text-xs sm:px-5">
<nav
class="left-sidebar__footer-nav flex min-w-0 flex-1 flex-wrap items-center gap-x-4 gap-y-1"
aria-label="하단 링크"
>
<NuxtLink
v-for="item in navigation.footer"
:key="item.id"
class="site-interactive"
class="left-sidebar__footer-link site-interactive shrink-0"
:to="item.url"
>
{{ item.label }}
</NuxtLink>
</nav>
<button
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 place-items-center rounded-full"
class="left-sidebar__theme-dot site-panel-hover site-interactive grid h-7 w-7 shrink-0 place-items-center rounded-full"
type="button"
:aria-label="isDarkMode ? '라이트 모드로 전환' : '다크 모드로 전환'"
:title="isDarkMode ? '라이트 모드' : '다크 모드'"

View File

@@ -7,9 +7,26 @@ defineProps({
}
})
const route = useRoute()
const expandedSet = inject('sidebarPrimaryNavExpandedSet')
const toggleBranch = inject('sidebarPrimaryNavToggle')
/** 세로바·호버 시 원형으로 바뀌는 공통 before 스타일(리프 링크와 동일) */
const navBarBeforeBase =
"before:pointer-events-none before:content-[''] before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full"
/** 비활성 경로: 기본 회색 막대, 호버 시 원형·믹스 색 */
const navBarBeforeInactive =
`${navBarBeforeBase} before:bg-[var(--site-line)] hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]`
/** 현재 페이지와 일치하는 내부 링크: 브랜드(`--site-accent`) 막대·호버 원형 */
const navBarBeforeActive =
`${navBarBeforeBase} before:bg-[var(--site-accent)] hover:before:bg-[var(--site-accent)]`
/** 행 공통: site-panel-hover, flex, 패딩 전환(가로 전체 호버 배경) */
const navRowShell =
'site-panel-hover flex w-full min-w-0 max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200'
/**
* 노드가 펼쳐져 있는지
* @param {string} id - 노드 id
@@ -25,6 +42,53 @@ const isExpanded = (id) => expandedSet?.value?.has(String(id)) ?? true
const onBranchClick = (id) => {
toggleBranch(id)
}
/**
* 외부 URL 여부
* @param {string} raw - 네비 URL
* @returns {boolean}
*/
const isExternalUrl = (raw) => /^https?:\/\//i.test(String(raw || '').trim())
/**
* 내부 링크이고 현재 경로와 일치하는지(쿼리 무시, 끝 슬래시 정규화)
* @param {string} raw - 네비 URL
* @returns {boolean}
*/
const isInternalNavActive = (raw) => {
const u = String(raw || '').trim()
if (!u || u === '#' || !u.startsWith('/') || u.startsWith('//')) {
return false
}
if (isExternalUrl(u)) {
return false
}
const path = (route.path || '/').split('?')[0] || '/'
/**
* 경로 정규화
* @param {string} s - 경로
* @returns {string}
*/
const norm = (s) => {
let x = s || '/'
if (x.length > 1 && x.endsWith('/')) {
x = x.slice(0, -1)
}
return x || '/'
}
return norm(path) === norm(u)
}
/**
* 리프 `NuxtLink`용 클래스
* @param {string} url - 노드 URL
* @returns {string}
*/
const navLinkClass = (url) => {
const active = isInternalNavActive(url)
const bar = active ? navBarBeforeActive : navBarBeforeInactive
return `sidebar-primary-nav-list__nav-link ${navRowShell} ${bar}`
}
</script>
<template>
@@ -36,12 +100,12 @@ const onBranchClick = (id) => {
>
<button
type="button"
class="sidebar-primary-nav-list__branch-toggle site-panel-hover group flex w-full max-w-full items-center gap-2 rounded-[10px] py-1.5 pr-2 pl-0 text-left leading-tight text-[var(--site-text)] transition-[padding,background-color] duration-200 hover:px-3"
class="sidebar-primary-nav-list__branch-toggle group flex w-full max-w-full text-left text-[var(--site-text)]"
:class="`${navRowShell} ${navBarBeforeInactive}`"
:aria-expanded="isExpanded(node.id)"
:aria-label="isExpanded(node.id) ? `${node.label} 하위 메뉴 접기` : `${node.label} 하위 메뉴 펼치기`"
@click="onBranchClick(node.id)"
>
<span class="sidebar-primary-nav-list__dot h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--site-muted)]" />
<span class="sidebar-primary-nav-list__branch-label min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
<span
class="sidebar-primary-nav-list__chevron-wrap grid h-5 w-5 shrink-0 place-items-center text-[var(--site-muted)] transition-transform duration-200 ease-out group-hover:text-[var(--site-text)]"
@@ -70,17 +134,18 @@ const onBranchClick = (id) => {
>
<NuxtLink
v-if="node.url && String(node.url).trim() !== '' && String(node.url).trim() !== '#'"
class="sidebar-primary-nav-list__nav-link site-panel-hover flex min-w-0 max-w-full flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-0 leading-tight transition-[padding,background-color] duration-200 before:h-4 before:w-1 before:flex-none before:rounded-sm before:rounded-l-none before:bg-[var(--site-line)] before:transition-[width,height,border-radius,background-color] before:duration-200 hover:px-3 hover:before:h-2 hover:before:w-2 hover:before:rounded-full hover:before:bg-[color:color-mix(in_srgb,var(--site-text)_28%,var(--site-line))]"
:class="navLinkClass(node.url)"
:to="node.url"
:aria-current="isInternalNavActive(node.url) ? 'page' : undefined"
>
<span class="sidebar-primary-nav-list__label min-w-0 flex-1 truncate transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
</NuxtLink>
<span
v-else
class="sidebar-primary-nav-list__leaf-static flex min-w-0 max-w-full flex-1 items-center gap-2 rounded-[10px] py-1.5 pr-3 pl-2 text-[var(--site-text)]"
class="sidebar-primary-nav-list__leaf-static group text-[var(--site-text)]"
:class="`${navRowShell} ${navBarBeforeInactive}`"
>
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-[var(--site-muted)]" />
<span class="min-w-0 flex-1 truncate font-medium">{{ node.label }}</span>
<span class="min-w-0 flex-1 truncate font-medium transition-transform duration-200 group-hover:translate-x-[3px]">{{ node.label }}</span>
</span>
</li>
</template>

View File

@@ -152,8 +152,9 @@ onBeforeUnmount(() => {
<template>
<header class="site-header sticky top-0 z-20 backdrop-blur">
<div class="site-header__inner mx-auto flex h-full max-w-[1294px] items-center justify-between gap-3 px-4 sm:gap-4 lg:gap-5 lg:px-5 xl:gap-6 xl:px-6 2xl:px-0">
<NuxtLink class="site-header__brand flex min-w-0 shrink-1 items-center gap-2.5 text-[15px] font-semibold tracking-normal sm:gap-3 sm:text-[18px] lg:max-w-[min(240px,32vw)] xl:max-w-[min(320px,28vw)] 2xl:max-w-none 2xl:flex-1" to="/">
<div class="site-header__inner mx-auto grid h-full max-w-[1294px] grid-cols-3 items-center gap-2 px-4 sm:gap-3 lg:gap-4 lg:px-5 xl:gap-5 xl:px-6 2xl:px-0">
<div class="site-header__brand-slot flex min-w-0 justify-self-start">
<NuxtLink class="site-header__brand flex min-w-0 max-w-full items-center gap-2.5 text-[15px] font-semibold tracking-normal sm:gap-3 sm:text-[18px] lg:max-w-[min(240px,28vw)] xl:max-w-[min(300px,26vw)]" to="/">
<button
class="site-header__menu-toggle group flex h-7 w-7 items-center justify-center rounded-full transition-transform"
type="button"
@@ -185,18 +186,21 @@ onBeforeUnmount(() => {
</span>
</button>
<span class="min-w-0 truncate">{{ siteSettings.title }}</span>
</NuxtLink>
<button
</NuxtLink>
</div>
<div class="site-header__search-slot flex min-w-0 justify-center justify-self-center px-1 sm:px-2">
<button
type="button"
class="site-header__search site-header__search--responsive hidden h-9 min-w-0 flex-1 basis-0 cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex md:max-w-[min(470px,42vw)] lg:max-w-[min(470px,30vw)] xl:max-w-[min(470px,36vw)] 2xl:w-[470px] 2xl:max-w-[470px] 2xl:basis-auto 2xl:flex-none site-input"
class="site-header__search site-header__search--responsive hidden h-9 w-full min-w-[470px] max-w-[min(470px,100%)] cursor-pointer items-center rounded-lg px-3 text-left text-sm transition-colors hover:opacity-90 md:flex site-input"
aria-label="검색 열기"
@click="openSearchModal"
>
<span class="site-header__search-icon mr-2 text-lg leading-none"></span>
<span class="site-header__search-text site-soft">Search</span>
<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">
</button>
</div>
<nav class="site-header__nav site-header__actions flex min-w-0 shrink-0 items-center justify-end justify-self-end gap-3 text-sm sm:gap-3.5">
<div class="site-header__user-menu relative">
<button
ref="userMenuToggleRef"

View File

@@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS email_otp_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
purpose TEXT NOT NULL,
code_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
consumed_at TIMESTAMPTZ,
verify_attempt_count INTEGER NOT NULL DEFAULT 0,
created_ip TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT email_otp_challenges_purpose_check CHECK (purpose IN ('signup', 'password_reset'))
);
CREATE INDEX IF NOT EXISTS email_otp_challenges_email_purpose_created_idx
ON email_otp_challenges (lower(email), purpose, created_at DESC);

View File

@@ -165,6 +165,19 @@ docker run -d -p 3000:3000 sori.studio:latest
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
### 이메일 인증(Resend, 선택)
회원가입(일반)·비밀번호 찾기에 이메일 OTP를 쓰려면 `npm run db:migrate:dev``018_email_otp_challenges.sql`을 적용하고, `.env`에 다음을 설정한다.
| 변수 | 설명 |
|------|------|
| `RESEND_API_KEY` | [Resend](https://resend.com) API 키 |
| `RESEND_FROM_EMAIL` | 발신 주소(Resend에서 허용된 도메인 또는 테스트 발신자) |
| `MEMBER_SESSION_SECRET` | 세션 쿠키 서명용 비밀값. **OTP 해시에 쓰는 pepper로도 사용**되므로, `EMAIL_OTP_PEPPER`를 비워 두면 이 값이 OTP용 비밀 재료가 된다. |
| `EMAIL_OTP_PEPPER` | **선택.** 이메일로 받은 6자리 숫자를 DB에 넣기 전 SHA256 해시할 때 섞는 **서버 전용 비밀 문자열**이다. DB가 유출돼도 pepper를 모르면 인증번호 역산·무차별 대입이 어렵다. **짧은 숫자 한두 개가 아니라**, `openssl rand -hex 32`처럼 **긴 난수 문자열(32바이트 이상 권장)**을 쓰는 것이 안전하다. 비우면 `MEMBER_SESSION_SECRET`을 pepper로 쓴다. |
`RESEND_API_KEY``RESEND_FROM_EMAIL`이 비어 있으면 기존처럼 이메일 OTP 없이 최초 관리자 가입·일반 가입(OTP 생략)이 동작한다.
- 관리 도구: CloudBeaver 등으로 추후 연결 가능하게 설계
- NAS Docker 배포 시 PostgreSQL 초기 스키마는 `db/migrations/`의 SQL로 생성
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development``--env-file .env.development`를 함께 사용

View File

@@ -1,5 +1,41 @@
# 의사결정 이력
## 2026-05-12 v0.0.102
### 관리자 블록 에디터 빈 단락·키보드·슬래시 메뉴
Markdown 직렬화에서 연속 빈 줄은 사라져 중간 빈 단락을 유지하기 어렵다. 편집용으로만 `<!--sori:blank-paragraph-->` 한 줄을 쓰고 공개 렌더러에서 동일하게 빈 단락으로 복원한다. 슬래시 팔레트는 필터 변경 시 하이라이트를 초기화하고, 긴 목록은 스크롤·`scrollIntoView`로 따라가며, `/`가 아닐 때는 위아래 키로 블록 간 커서를 옮긴다.
## 2026-05-12 v0.0.101
### 공개 primary 네비 트리 중복 방지
`parent_id`가 있는 행과 루트로 들어온 동일 id가 공존하거나, 평면 목록에 id가 중복되면 한 루프로는 같은 노드가 `roots`와 부모 `children`에 동시에 들어갈 수 있다. 서버에서 id 단위로 한 번만 쓰고, 자식으로 연결된 id는 루트 후보에서 빼서 UI 중복을 제거한다.
## 2026-05-12 v0.0.100
### EMAIL_OTP_PEPPER 문서화
운영자가 짧은 숫자만 넣는 오해를 줄이기 위해, pepper는 세션 비밀과 별도로 OTP 해시에만 쓰이는 **긴 난수 문자열**임을 배포 문서·예시 env에 명시했다.
## 2026-05-12 v0.0.99
### 헤더 검색 중앙·Resend 이메일 OTP
헤더는 좌측 브랜드·우측 계정 사이에서 검색을 시각적 중심에 두는 편이 Ghost/Thred류 기대와 맞다. 트랜잭션 메일은 외부 SMTP 대신 Resend 단일 API로 운영 부담을 줄이고, 키가 없을 때는 기존 가입 흐름을 유지한다.
## 2026-05-12 v0.0.98
### 사이드바 푸터 링크 줄바꿈·상단 네비 호버 너비
`footer` 항목이 한 줄 `flex`로만 두면 좁은 사이드바에서 가로로 삐져나간다. `nav``flex-wrap``min-w-0 flex-1`을 주고 링크는 줄 단위로 감싸지게 했다. 상단 네비 행은 링크가 콘텐츠 너비만 차지하면 `site-panel-hover` 배경이 짧게 보이므로 `w-full`로 행 전체와 맞췄다.
## 2026-05-12 v0.0.97
### 상단 네비 장식·현재 페이지 표시
부모 행만 점으로 두면 리프와 시각 언어가 갈린다. `before` 세로바를 통일하고, 내부 활성 경로는 브랜드 악센트로 구분했다.
## 2026-05-12 v0.0.96
### 사이드바 상단 네비 폴더 토글 UX

View File

@@ -35,10 +35,10 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/auth/AuthPasswordVisibilityToggle.vue | 로그인·회원가입 비밀번호 표시/숨김 토글(SVG, scoped 스타일·`field-name`으로 접근성 레이블 구분) |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, 우측 사용자 아바타 드롭다운(로그인: 설정/로그아웃, 비로그인: Sign up/Sign in), `lg`~`xl` 헤더 여백·반응형 검색창 폭, `/`·검색 영역으로 `SiteSearchModal` 연동 |
| components/site/SiteHeader.vue | 모든 공개 페이지 상단, `grid-cols-3`로 검색 패널 중앙 정렬(`md+`), 우측 사용자 아바타 드롭다운, `/`·`SiteSearchModal` |
| components/site/SiteSearchModal.vue | `Teleport`·전체 화면 딤·Tags/Posts 결과·일치 구간 강조, 열림 시 `html.site-search-open` 스크롤 잠금 |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`) |
| components/site/SidebarPrimaryNavList.vue | 공개 상단 네비 트리: 하위 있으면 행 전체 `button` 토글·SVG chevron 회전·`grid` 높이 애니메이션, `inject`로 펼침·`localStorage` `sori-primary-nav-expanded` |
| components/site/LeftSidebar.vue | 왼쪽 사이드바, `lg+``sticky`+고정 높이+내부 무스크롤바 스크롤, `lg` 미만은 고정 슬라이드 패널, 상단 메뉴는 `SidebarPrimaryNavList`+`provide`로 트리·펼침 상태(`sori-primary-nav-expanded`), 푸터 `footer` 링크는 `flex-wrap`·테마 버튼 `shrink-0` |
| components/site/SidebarPrimaryNavList.vue | 상단 네비: 부모·리프 동일 `before` 막대/호버 원형, 내부 현재 경로 `--site-accent`, 행 `w-full`로 패널 호버 배경 폭, `inject`·`localStorage` 펼침 |
| components/site/RightSidebar.vue | 오른쪽 사이드바, `lg+`는 고정 열 높이·스티키, 모바일은 본문 아래 전체 너비, 하단 푸터 `pr-3` |
| components/site/MainColumn.vue | 메인 화면 중앙, `lg:max-w-[720px]`로 본문 상한 |
| components/site/PostCard.vue | 목록의 게시물 카드, 대표 이미지 썸네일, 카드 hover 인터랙션 |
@@ -110,8 +110,9 @@
| 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 토글, 회원 로그인 API 연동, 이메일·비밀번호 입력 시에만 제출 버튼 활성 |
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` |
| pages/signin.vue | 로그인, `/forgot-password` 링크 |
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
| pages/settings/index.vue | 회원 설정(썸네일 미리보기/이미지 변경·제거, 닉네임 변경/중복확인, 비밀번호 변경, 회원 탈퇴) |
## 서버 API
@@ -126,8 +127,13 @@
| 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/bootstrap-status.get.js | 최초 관리자 등록 필요 여부 조회 API |
| server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) |
| server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` |
| server/api/auth/email-otp/request.post.js | Resend로 OTP 발송(`signup` / `password_reset`) |
| server/api/auth/password-reset/confirm.post.js | OTP 검증 후 비밀번호 재설정 |
| server/repositories/email-otp-repository.js | `email_otp_challenges` CRUD·검증 |
| server/utils/email-otp.js | OTP 생성·해시 |
| server/utils/resend-mail.js | Resend REST 발송 |
| server/api/auth/login.post.js | 회원 로그인 API |
| server/api/auth/me.get.js | 회원 세션 조회 API |
| server/api/auth/logout.post.js | 회원 로그아웃 API |

View File

@@ -18,11 +18,11 @@
| 요소 | 크기/속성 |
|------|-----------|
| Header | 높이 57px, `sticky top-0`, `shrink-0`. `lg`~`xl` 구간은 내부 `px-5`~`px-6`로 좌우 여백을 두고, 검색창은 뷰포트에 맞춰 `max-w`로 단계 축소한다(`2xl`에서 고정 470px). |
| Header | 높이 57px, `sticky top-0`, `shrink-0`. 내부는 `grid-cols-3`**좌(브랜드·메뉴) / 중앙(검색, `md+`에서만 표시) / 우(사용자 메뉴)** 배치해 검색 패널을 가운데 열에 정렬한다. 검색 버튼은 중앙 열 안에서 `max-w-[min(470px,100%)]`로 폭을 제한한다. |
| Shell | `min-height: 100vh`, `flex` 세로 컬럼 |
| 그리드(데스크톱 `lg+`) | `items-start`, 3열 그리드(`287px / minmax(0,1fr) / 287px`)를 사용하고 열 간 `column-gap`은 두지 않는다(`gap-x-0`). 경계선은 사이드바 보더로만 구분해 이중 패딩처럼 보이는 여백을 방지한다. 긴 본문은 **문서(`html`/`body`) 스크롤**로 처리한다. |
| 그리드(모바일 `lg` 미만) | 단일 세로 흐름: **본문 → 오른쪽 사이드** 순. 왼쪽 사이드는 레이아웃 흐름에서 분리된 고정 슬라이드 패널로 표시 |
| Left Aside | 너비 287px, `sticky top-[57px]`, `h-[calc(100vh-57px)]``max-h` 동일(뷰포트 기준 고정 높이), 내부 상단은 `.site-sidebar-scroll`(스크롤바 숨김), 하단 푸터 `shrink-0`·상단 보더로 스크롤 영역과 구분, 푸터 좌우는 `px-4`~`sm:px-5`로 본문 블록과 유사한 여백 |
| Left Aside | 너비 287px, `sticky top-[57px]`, `h-[calc(100vh-57px)]``max-h` 동일(뷰포트 기준 고정 높이), 내부 상단은 `.site-sidebar-scroll`(스크롤바 숨김), 하단 푸터 `shrink-0`·상단 보더로 스크롤 영역과 구분, 푸터 좌우는 `px-4`~`sm:px-5`로 본문 블록과 유사한 여백. 푸터의 API `footer` 링크 영역은 `flex-wrap`·`min-w-0 flex-1`로 항목이 많을 때 줄바꿈되어 패널 밖으로 넘치지 않는다 |
| Left Aside(모바일) | `fixed` 좌측 패널, 열림 시 `translate-x-0`, 닫힘 시 화면 밖으로 이동. 열린 동안 백드롭과 `html.site-mobile-nav-open`으로 문서 스크롤 잠금 |
| Main | 중앙 열 안에서 `max-width: 720px`·`justify-self: start`, 별도 `overflow-y` 없음. 공개 페이지의 가로 패딩은 레이아웃 그리드(`public-layout__grid`)의 `px-*` 한 번만 사용하고, 본문 섹션의 `px-*`는 두지 않는다. |
| Right Aside | Left와 동일 패턴(스티키·고정 높이·내부 무스크롤바 스크롤·하단 카피라이트) |
@@ -349,8 +349,10 @@ components/content/
- `GET /api/search?q=` - 통합 검색(태그 `name`·`slug`, 게시물 `title`·`excerpt`·`content` 부분 일치, 각 최대 12건, 발행 게시물만)
- `GET /api/site-settings` - 공개 사이트 설정
- `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절)
- `POST /api/auth/signup` - 회원 가입
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부 조회
- `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status``emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다.
- `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다.
- `POST /api/auth/email-otp/request` - 본문: `email`, `purpose`(`"signup"` | `"password_reset"`). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503. `signup`은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400. `password_reset`은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요.
- `POST /api/auth/password-reset/confirm` - 본문: `email`, `code`(6자리), `newPassword`(8~32자). `password_reset` OTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다.
- `POST /api/auth/login` - 회원 로그인
- `GET /api/auth/me` - 현재 회원 세션 조회
- `POST /api/auth/logout` - 회원 로그아웃
@@ -518,12 +520,12 @@ components/content/
- 네비게이션은 `navigation_items` 테이블로 관리한다.
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
- `GET /api/navigation` 응답: `primary`**트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. `footer`**평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다.
- `GET /api/navigation` 응답: `primary`**트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**(레거시·중복 행으로 인한 사이드바 이중 표시 방지). `footer`**평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다.
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id``primary`에서만 허용되며 `footer` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·번호 열에서 시작한다. 상단은 `AdminNavPrimaryBranch`로 트리·동일 부모 내 순서 변경·하위 추가를 제공한다. 하단은 한 단계 목록만 드래그 정렬한다.
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev``017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 ·chevron) 클릭**으로 접기/펼친다(별도 chevron 전용 버튼 없음). chevron은 SVG에 `transition-transform`으로 회전하고, 하위 목록은 `grid-template-rows` `0fr``1fr` 전환으로 높이가 자연스럽게 바뀐다. 펼침 행에만 깔리던 배경 하이라이트는 두지 않는다. **하위가 있는 부모**는 토글 전용이므로 공개 화면에서 부모 `url`로 이동하지 않는다(링크는 리프·하위 항목에 둔다). 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다.
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage``sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-panel-hover` 배경이 가로 전체를 쓴다.
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
### 관리자 인증
@@ -544,7 +546,7 @@ components/content/
- 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
- `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
- 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다.
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`)로 세션을 관리한다.
- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다.
- 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다.
- 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다.

View File

@@ -1,5 +1,36 @@
# 업데이트 이력
## v0.0.102
- `AdminBlockEditor`: 빈 단락은 HTML 주석 마커로 직렬화·복원, 슬래시 메뉴 하이라이트·스크롤·긴 목록 `max-h`·블록 경계에서 위/아래 방향키로 인접 블록 이동.
- `ContentMarkdownRenderer`: 동일 마커 줄을 빈 단락으로 표시.
- `SiteHeader`: 검색 열기 버튼에 `min-w-[470px]`(md 이상)로 최소 폭 고정.
- `pages/admin/navigation`: 상단 마이그레이션 안내 문단 제거.
## v0.0.101
- `server/utils/navigation-tree.js` `buildPublicPrimaryTree`: 평면 `primary`에서 **동일 id 중복 제거**(정렬 후 첫 행 유지), **유효 부모 아래에 붙은 항목은 루트에서 제외**해 사이드바에 항목이 두 번 보이던 현상 방지.
## v0.0.100
- `.env.example`·`docs/deploy.md`·`docs/spec.md`: **`EMAIL_OTP_PEPPER` 의미**(OTP 해시용 서버 비밀, 긴 난문자열 권장·미설정 시 `MEMBER_SESSION_SECRET` 사용) 문구 보강.
## v0.0.99
- `SiteHeader`: 헤더 내부 `grid-cols-3`로 검색 패널 **중앙 열 정렬**(Ghost류 레이아웃).
- Resend 기반 **이메일 OTP**: 마이그레이션 `018_email_otp_challenges.sql`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`, `signup`·`bootstrap-status` 연동.
- `pages/forgot-password.vue`, `signin`에서 비밀번호 찾기 링크. `getUserByEmail`·로그인 이메일 **대소문자 무시** 조회.
- `nuxt.config` `runtimeConfig`: `resendApiKey`, `resendFromEmail`, `emailOtpPepper`.
## v0.0.98
- `SidebarPrimaryNavList`: `navRowShell`**`w-full`** 추가해 `site-panel-hover` 배경이 행 가로 전체를 쓰도록 함.
- `LeftSidebar`: 푸터 API `footer` 링크 **`flex-wrap`**·`nav` **`min-w-0 flex-1`**, 링크·테마 버튼 **`shrink-0`**로 항목이 많을 때 가로 오버플로 방지.
## v0.0.97
- `SidebarPrimaryNavList`: 하위 폴더 부모도 리프와 동일 **세로 막대→호버 원형** `before` 장식. 내부 링크가 현재 경로와 일치하면 장식을 **`--site-accent`**로 표시(`aria-current="page"`).
## v0.0.96
- `SidebarPrimaryNavList`: 하위가 있는 부모는 **행 전체 버튼**으로 토글, chevron **회전 애니메이션**, 하위 영역 **높이 전환 애니메이션**, 펼침 전용 배경 제거·너비·간격 리프와 통일.

View File

@@ -51,6 +51,9 @@ export default defineNuxtConfig({
adminEmail: process.env.ADMIN_EMAIL || '',
adminPassword: process.env.ADMIN_PASSWORD || '',
memberSessionSecret: process.env.MEMBER_SESSION_SECRET || '',
resendApiKey: process.env.RESEND_API_KEY || '',
resendFromEmail: process.env.RESEND_FROM_EMAIL || '',
emailOtpPepper: process.env.EMAIL_OTP_PEPPER || '',
uploadDir: process.env.UPLOAD_DIR || '/uploads',
maxFileSize: Number(process.env.MAX_FILE_SIZE || 10485760),
avatarMinWidth: Number(process.env.AVATAR_MIN_WIDTH || 96),

View File

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

View File

@@ -381,9 +381,6 @@ const saveNavigation = async () => {
<p class="admin-navigation__lead mt-2 max-w-xl text-sm text-muted">
상단·하단을 탭으로 나눕니다. 순서는 <span class="font-semibold text-ink">태그 관리의 메인 태그</span> 같이 행을 드래그합니다(번호·여백에서 잡고 이동). 입력란 위에서는 드래그가 시작되지 않습니다. 하위 메뉴가 있으면 공개 사이드바에서 접는 그룹으로 보입니다.
</p>
<p class="admin-navigation__migrate-hint mt-2 max-w-xl rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
저장 <code class="rounded bg-white/80 px-1">parent_id</code> 오류가 나면 DB에 마이그레이션이 아직 없는 것입니다. 로컬에서 <code class="rounded bg-white/80 px-1">npm run db:migrate:dev</code> <code class="rounded bg-white/80 px-1">017_navigation_hierarchy.sql</code> 적용하세요.
</p>
</div>
<div class="admin-navigation__header-actions flex flex-wrap gap-2">
<button

233
pages/forgot-password.vue Normal file
View File

@@ -0,0 +1,233 @@
<script setup>
definePageMeta({
layout: 'page'
})
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
default: () => ({
emailOtpConfigured: false
})
})
const step = ref(1)
const isSubmitting = ref(false)
const errorMessage = ref('')
const statusMessage = ref('')
const otpRequestLoading = ref(false)
const otpCooldownSeconds = ref(0)
let otpCooldownTimerId = null
const form = reactive({
email: '',
code: '',
newPassword: '',
newPasswordConfirm: ''
})
const showPassword = ref(false)
const showPasswordConfirm = ref(false)
const emailOtpAvailable = computed(() => Boolean(bootstrapStatus.value?.emailOtpConfigured))
/**
* 비밀번호 재설정용 인증번호를 요청한다.
* @returns {Promise<void>}
*/
const requestResetOtp = async () => {
errorMessage.value = ''
statusMessage.value = ''
if (!emailOtpAvailable.value) {
errorMessage.value = '비밀번호 재설정 이메일을 사용하려면 서버에 Resend(RESEND_API_KEY, RESEND_FROM_EMAIL)가 설정되어 있어야 합니다.'
return
}
if (!form.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
errorMessage.value = '유효한 이메일을 입력해 주세요.'
return
}
if (otpCooldownSeconds.value > 0 || otpRequestLoading.value) {
return
}
otpRequestLoading.value = true
try {
const res = await $fetch('/api/auth/email-otp/request', {
method: 'POST',
body: {
email: form.email.trim(),
purpose: 'password_reset'
}
})
statusMessage.value = res?.message || '요청을 처리했습니다.'
step.value = 2
otpCooldownSeconds.value = 60
if (otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
}
otpCooldownTimerId = setInterval(() => {
otpCooldownSeconds.value -= 1
if (otpCooldownSeconds.value <= 0 && otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
otpCooldownTimerId = null
}
}, 1000)
} catch (error) {
errorMessage.value = error?.data?.message || '인증번호 요청에 실패했습니다.'
} finally {
otpRequestLoading.value = false
}
}
/**
* 인증번호와 새 비밀번호로 재설정을 완료한다.
* @returns {Promise<void>}
*/
const submitReset = async () => {
errorMessage.value = ''
statusMessage.value = ''
const digits = String(form.code || '').replace(/\D/g, '')
if (digits.length !== 6) {
errorMessage.value = '6자리 인증번호를 입력해 주세요.'
return
}
if (!form.newPassword || form.newPassword.length < 8 || form.newPassword.length > 32) {
errorMessage.value = '새 비밀번호는 8~32자로 입력해 주세요.'
return
}
if (form.newPassword !== form.newPasswordConfirm) {
errorMessage.value = '새 비밀번호와 확인 값이 일치하지 않습니다.'
return
}
isSubmitting.value = true
try {
await $fetch('/api/auth/password-reset/confirm', {
method: 'POST',
body: {
email: form.email.trim(),
code: digits,
newPassword: form.newPassword
}
})
statusMessage.value = '비밀번호가 변경되었습니다. 로그인 페이지로 이동합니다.'
await navigateTo('/signin')
} catch (error) {
errorMessage.value = error?.data?.message || '비밀번호 재설정에 실패했습니다.'
} finally {
isSubmitting.value = false
}
}
onBeforeUnmount(() => {
if (otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
}
})
</script>
<template>
<section class="auth-forgot-password min-h-screen bg-[#0a0b0d] text-[#f5f7fa] [color-scheme:dark]">
<div class="mx-auto flex min-h-screen w-full max-w-[1280px] items-center px-5 py-12 sm:px-10 lg:px-16">
<div class="auth-forgot-password__panel w-full max-w-[430px] p-5 sm:p-8">
<p class="text-2xl font-semibold leading-tight">
비밀번호 찾기
</p>
<p class="mt-2 text-sm text-[#9ba3af]">
가입에 사용한 이메일로 인증번호를 보낸 , 비밀번호를 설정합니다.
</p>
<div v-if="!emailOtpAvailable" class="auth-forgot-password__warn mt-6 rounded-[10px] border border-[#3d2a1a] bg-[#1a1410] p-4 text-sm text-[#d8dee6]">
서버에 Resend가 설정되지 않았습니다. 운영 환경에서 <span class="font-mono text-xs">RESEND_API_KEY</span>, <span class="font-mono text-xs">RESEND_FROM_EMAIL</span>, <span class="font-mono text-xs">MEMBER_SESSION_SECRET</span>(또는 <span class="font-mono text-xs">EMAIL_OTP_PEPPER</span>) 설정한 다시 시도해 주세요.
</div>
<template v-else>
<div v-if="step === 1" class="mt-8 space-y-5">
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">이메일</label>
<input
v-model="form.email"
class="auth-form-input h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm outline-none transition-colors focus:border-[#2f6feb]"
type="email"
autocomplete="email"
>
</div>
<button
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-40"
type="button"
:disabled="otpRequestLoading"
@click="requestResetOtp"
>
{{ otpRequestLoading ? '처리 중…' : '인증번호 받기' }}
</button>
</div>
<form v-else class="auth-forgot-password__step2 mt-8 space-y-5" @submit.prevent="submitReset">
<p class="text-xs text-[#9ba3af]">
{{ form.email }} 발송된 인증번호를 입력해 주세요.
</p>
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">인증번호</label>
<input
v-model="form.code"
class="auth-form-input h-10 w-full rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm tracking-widest outline-none transition-colors focus:border-[#2f6feb]"
type="text"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
>
</div>
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]"> 비밀번호</label>
<div class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
<input
v-model="form.newPassword"
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
>
<AuthPasswordVisibilityToggle v-model="showPassword" />
</div>
</div>
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]"> 비밀번호 확인</label>
<div class="flex items-center rounded-[8px] border border-[#1a212a] transition-colors focus-within:border-[#2f6feb]">
<input
v-model="form.newPasswordConfirm"
class="auth-form-input h-10 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none"
:type="showPasswordConfirm ? 'text' : 'password'"
autocomplete="new-password"
>
<AuthPasswordVisibilityToggle v-model="showPasswordConfirm" field-name="새 비밀번호 확인" />
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
class="h-9 rounded-[8px] border border-[#1a212a] px-4 text-xs text-[#d8dee6] transition-opacity hover:opacity-75"
type="button"
:disabled="isSubmitting"
@click="step = 1"
>
이메일 다시 입력
</button>
<button
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-40"
type="submit"
:disabled="isSubmitting"
>
비밀번호 변경
</button>
</div>
</form>
</template>
<p v-if="errorMessage" class="mt-4 text-xs text-[#e5acb1]" aria-live="polite">
{{ errorMessage }}
</p>
<p v-if="statusMessage" class="mt-4 text-xs text-[#9fc4ff]" aria-live="polite">
{{ statusMessage }}
</p>
<p class="mt-8 text-sm text-[#9ba3af]">
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/signin">로그인으로 돌아가기</NuxtLink>
</p>
</div>
</div>
</section>
</template>

View File

@@ -126,6 +126,11 @@ const submitSignIn = async () => {
회원가입
</NuxtLink>
</p>
<p class="mt-3 text-sm text-[#9ba3af]">
<NuxtLink class="text-[#7eb8ff] hover:opacity-80" to="/forgot-password">
비밀번호를 잊으셨나요?
</NuxtLink>
</p>
<NuxtLink class="mt-2 inline-flex text-xs text-[#9ba3af] hover:opacity-80" to="/">
홈으로 돌아가기
</NuxtLink>

View File

@@ -18,13 +18,15 @@ const { data: siteSettings } = await useFetch('/api/site-settings', {
const { data: bootstrapStatus } = await useFetch('/api/auth/bootstrap-status', {
default: () => ({
hasUsers: true,
needsAdminSetup: false
needsAdminSetup: false,
emailOtpConfigured: false
})
})
const form = reactive({
username: '',
email: '',
emailOtp: '',
password: '',
passwordConfirm: ''
})
@@ -32,14 +34,20 @@ const form = reactive({
const errors = reactive({
username: '',
email: '',
emailOtp: '',
password: '',
passwordConfirm: ''
})
const showSignupPassword = ref(false)
const showSignupPasswordConfirm = ref(false)
const otpRequestLoading = ref(false)
const otpCooldownSeconds = ref(0)
let otpCooldownTimerId = null
const isAdminBootstrapMode = computed(() => Boolean(bootstrapStatus.value?.needsAdminSetup))
/** Resend 등 설정이 되어 있고 최초 관리자 모드가 아닐 때만 이메일 OTP 필요 */
const emailOtpRequired = computed(() => Boolean(bootstrapStatus.value?.emailOtpConfigured) && !isAdminBootstrapMode.value)
const welcomeTitle = computed(() => `Welcome to ${siteSettings.value?.title || 'AFFiNE'}`)
const welcomeDescription = computed(() => siteSettings.value?.description || 'Configure your Self Host AFFiNE with a few simple settings.')
const stepTwoTitle = computed(() => (isAdminBootstrapMode.value ? '관리자 등록' : '회원 가입'))
@@ -54,6 +62,7 @@ const stepTwoDescription = computed(() => (isAdminBootstrapMode.value
const resetErrors = () => {
errors.username = ''
errors.email = ''
errors.emailOtp = ''
errors.password = ''
errors.passwordConfirm = ''
}
@@ -95,9 +104,65 @@ const validateStepTwo = () => {
valid = false
}
if (emailOtpRequired.value) {
const digits = String(form.emailOtp || '').replace(/\D/g, '')
if (digits.length !== 6) {
errors.emailOtp = '이메일로 받은 6자리 인증번호를 입력해 주세요.'
valid = false
}
}
return valid
}
/**
* 회원가입용 이메일 인증번호를 요청한다.
* @returns {Promise<void>}
*/
const requestSignupEmailOtp = async () => {
submitErrorMessage.value = ''
errors.email = ''
errors.emailOtp = ''
if (!form.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
errors.email = '인증번호를 받으려면 유효한 이메일을 먼저 입력해 주세요.'
return
}
if (otpCooldownSeconds.value > 0 || otpRequestLoading.value) {
return
}
otpRequestLoading.value = true
try {
await $fetch('/api/auth/email-otp/request', {
method: 'POST',
body: {
email: form.email.trim(),
purpose: 'signup'
}
})
otpCooldownSeconds.value = 60
if (otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
}
otpCooldownTimerId = setInterval(() => {
otpCooldownSeconds.value -= 1
if (otpCooldownSeconds.value <= 0 && otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
otpCooldownTimerId = null
}
}, 1000)
} catch (error) {
submitErrorMessage.value = error?.data?.message || '인증번호 요청에 실패했습니다.'
} finally {
otpRequestLoading.value = false
}
}
onBeforeUnmount(() => {
if (otpCooldownTimerId) {
clearInterval(otpCooldownTimerId)
}
})
/**
* 다음 단계로 이동한다.
* @returns {Promise<void>}
@@ -119,13 +184,17 @@ const goNextStep = async () => {
isSubmitting.value = true
try {
const signupBody = {
username: form.username.trim(),
email: form.email.trim(),
password: form.password
}
if (emailOtpRequired.value) {
signupBody.emailOtp = String(form.emailOtp || '').replace(/\D/g, '')
}
const signupResult = await $fetch('/api/auth/signup', {
method: 'POST',
body: {
username: form.username.trim(),
email: form.email.trim(),
password: form.password
}
body: signupBody
})
createdAdmin.value = Boolean(signupResult?.isAdmin)
@@ -207,6 +276,36 @@ const goPreviousStep = () => {
</p>
</div>
<div v-if="emailOtpRequired" class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">이메일 인증번호</label>
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
<input
v-model="form.emailOtp"
class="auth-form-input h-10 min-w-0 flex-1 rounded-[8px] border border-[#1a212a] bg-transparent px-3 text-sm tracking-widest outline-none transition-colors focus:border-[#2f6feb]"
type="text"
inputmode="numeric"
maxlength="6"
pattern="[0-9]*"
placeholder="6자리"
autocomplete="one-time-code"
>
<button
class="auth-signup__otp-send h-10 shrink-0 rounded-[8px] border border-[#1a212a] px-4 text-xs text-[#d8dee6] transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="otpRequestLoading || otpCooldownSeconds > 0"
@click="requestSignupEmailOtp"
>
{{ otpCooldownSeconds > 0 ? `${otpCooldownSeconds}초 후 재요청` : (otpRequestLoading ? '발송 중…' : '인증번호 받기') }}
</button>
</div>
<p v-if="errors.emailOtp" class="text-xs text-[#e05d67]">
{{ errors.emailOtp }}
</p>
<p class="text-xs text-[#8c95a3]">
Resend로 발송됩니다. 메일이 보이지 않으면 스팸함을 확인해 주세요.
</p>
</div>
<div class="space-y-1.5">
<label class="text-xs text-[#d8dee6]">{{ isAdminBootstrapMode ? '관리자 비밀번호' : '비밀번호' }}</label>
<div

View File

@@ -1,7 +1,17 @@
import { getMemberBootstrapState } from '../../repositories/member-repository'
import { isResendConfigured } from '../../utils/resend-mail'
/**
* 최초 관리자 등록 필요 여부를 조회한다.
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태
* 최초 관리자 등록 필요 여부·이메일 OTP(Resend) 사용 가능 여부를 조회한다.
* @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean, emailOtpConfigured: boolean }>} 부트스트랩 상태
*/
export default defineEventHandler(async () => getMemberBootstrapState())
export default defineEventHandler(async () => {
const base = await getMemberBootstrapState()
const config = useRuntimeConfig()
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
const emailOtpConfigured = isResendConfigured(config) && hasPepper
return {
...base,
emailOtpConfigured
}
})

View File

@@ -0,0 +1,164 @@
import { randomBytes } from 'node:crypto'
import { z } from 'zod'
import { createError, getRequestIP, readBody } from 'h3'
import { getPostgresClient } from '../../../repositories/postgres-client'
import { getMemberBootstrapState, getUserByEmail } from '../../../repositories/member-repository'
import {
countOtpSendsLastHour,
hasRecentOtpSend,
insertOtpChallenge,
invalidatePendingOtpChallenges
} from '../../../repositories/email-otp-repository'
import { generateSixDigitOtp, hashOtpCode, normalizeOtpEmail } from '../../../utils/email-otp'
import { isResendConfigured, sendResendEmail } from '../../../utils/resend-mail'
const bodySchema = z.object({
email: z.string().trim().email(),
purpose: z.enum(['signup', 'password_reset'])
})
const OTP_TTL_MS = 15 * 60 * 1000
const MAX_SENDS_PER_HOUR = 5
/**
* OTP 이메일 발송용 pepper
* @param {import('nuxt/schema').RuntimeConfig} config - 런타임 설정
* @returns {string}
*/
const resolveOtpPepper = (config) => {
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
if (!pepper) {
throw createError({
statusCode: 500,
message: '이메일 인증을 사용하려면 MEMBER_SESSION_SECRET 또는 EMAIL_OTP_PEPPER를 설정해 주세요.'
})
}
return pepper
}
/**
* 인증번호 이메일 발송 요청
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ ok: boolean, message: string }>}
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
if (!isResendConfigured(config)) {
throw createError({
statusCode: 503,
message: '이메일 발송(Resend)이 서버에 설정되지 않았습니다. RESEND_API_KEY와 RESEND_FROM_EMAIL을 확인해 주세요.'
})
}
const parsed = bodySchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({
statusCode: 400,
message: '요청 형식이 올바르지 않습니다.'
})
}
const email = normalizeOtpEmail(parsed.data.email)
const purpose = parsed.data.purpose
const sql = getPostgresClient()
if (!sql) {
throw createError({
statusCode: 500,
message: '데이터베이스 설정이 필요합니다.'
})
}
const genericOk = {
ok: true,
message: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
}
if (purpose === 'signup') {
const bootstrap = await getMemberBootstrapState()
if (bootstrap.needsAdminSetup) {
throw createError({
statusCode: 400,
message: '최초 관리자 등록 단계에서는 이메일 인증이 필요하지 않습니다.'
})
}
const existing = await getUserByEmail(email)
if (existing) {
throw createError({
statusCode: 409,
message: '이미 사용 중인 이메일입니다.'
})
}
}
if (await hasRecentOtpSend(sql, email, purpose)) {
throw createError({
statusCode: 429,
message: '잠시 후 다시 인증번호를 요청해 주세요.'
})
}
if ((await countOtpSendsLastHour(sql, email, purpose)) >= MAX_SENDS_PER_HOUR) {
throw createError({
statusCode: 429,
message: '인증번호 요청 횟수가 너무 많습니다. 1시간 후 다시 시도해 주세요.'
})
}
if (purpose === 'password_reset') {
const user = await getUserByEmail(email)
if (!user) {
const dummyHash = randomBytes(32).toString('hex')
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
const createdIp = String(getRequestIP(event) || '')
await invalidatePendingOtpChallenges(sql, email, purpose)
await insertOtpChallenge(sql, {
email,
purpose,
codeHash: dummyHash,
expiresAt,
createdIp
})
return genericOk
}
}
const pepper = resolveOtpPepper(config)
const code = generateSixDigitOtp()
const codeHash = hashOtpCode({ pepper, email, purpose, code })
const expiresAt = new Date(Date.now() + OTP_TTL_MS)
const createdIp = String(getRequestIP(event) || '')
await invalidatePendingOtpChallenges(sql, email, purpose)
await insertOtpChallenge(sql, {
email,
purpose,
codeHash,
expiresAt,
createdIp
})
const siteTitle = String(config.public?.siteTitle || 'sori.studio')
const subject = purpose === 'signup'
? `[${siteTitle}] 회원가입 인증번호`
: `[${siteTitle}] 비밀번호 재설정 인증번호`
const html = purpose === 'signup'
? `<p>회원가입을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
: `<p>비밀번호 재설정을 위한 인증번호입니다.</p><p style="font-size:24px;font-weight:700;letter-spacing:0.2em">${code}</p><p>15분 이내에 입력해 주세요.</p>`
await sendResendEmail({
apiKey: String(config.resendApiKey).trim(),
from: String(config.resendFromEmail).trim(),
to: email,
subject,
html
})
return {
ok: true,
message: purpose === 'signup'
? '인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
: '등록된 주소라면 인증 메일을 발송했습니다. 메일함을 확인해 주세요.'
}
})

View File

@@ -25,7 +25,8 @@ export default defineEventHandler(async (event) => {
}
const body = parsedBody.data
const user = await getUserByEmail(body.email)
const emailNorm = body.email.trim().toLowerCase()
const user = await getUserByEmail(emailNorm)
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
throw createError({

View File

@@ -0,0 +1,65 @@
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { createError, readBody } from 'h3'
import { updateMemberPasswordByEmail } from '../../../repositories/member-repository'
import { verifyAndConsumeEmailOtp } from '../../../repositories/email-otp-repository'
const bodySchema = z.object({
email: z.string().trim().email(),
code: z.string().regex(/^\d{6}$/),
newPassword: z.string().min(8).max(32)
})
/**
* 이메일 OTP로 비밀번호를 재설정한다.
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<{ ok: boolean }>}
*/
export default defineEventHandler(async (event) => {
const parsed = bodySchema.safeParse(await readBody(event))
if (!parsed.success) {
throw createError({
statusCode: 400,
message: '요청 형식이 올바르지 않습니다.'
})
}
const config = useRuntimeConfig()
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
if (!pepper) {
throw createError({
statusCode: 500,
message: '서버 인증 설정이 올바르지 않습니다.'
})
}
const email = parsed.data.email.trim().toLowerCase()
const verify = await verifyAndConsumeEmailOtp({
email,
purpose: 'password_reset',
code: parsed.data.code,
pepper
})
if (!verify.ok) {
throw createError({
statusCode: 400,
message: '인증번호가 올바르지 않거나 만료되었습니다.'
})
}
const nextHash = await bcrypt.hash(parsed.data.newPassword, 12)
const updated = await updateMemberPasswordByEmail({
email,
passwordHash: nextHash
})
if (!updated) {
throw createError({
statusCode: 400,
message: '계정을 찾을 수 없습니다. 다시 인증번호를 요청해 주세요.'
})
}
return { ok: true }
})

View File

@@ -1,16 +1,33 @@
import bcrypt from 'bcrypt'
import { z } from 'zod'
import { createError, getRequestIP, readBody } from 'h3'
import { createUser, getUserByEmail, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
import { createUser, getUserByEmail, getMemberBootstrapState, isUsernameTaken, touchUserActivity } from '../../repositories/member-repository'
import { verifyAndConsumeEmailOtp } from '../../repositories/email-otp-repository'
import { setMemberSession } from '../../utils/member-auth'
import { setAdminSession } from '../../utils/admin-auth'
import { isResendConfigured } from '../../utils/resend-mail'
const signupSchema = z.object({
username: z.string().trim().min(1),
email: z.string().trim().email(),
password: z.string().min(8).max(32)
password: z.string().min(8).max(32),
emailOtp: z.string().regex(/^\d{6}$/).optional()
})
/**
* 이메일 OTP가 회원가입에 필요한지
* @param {import('nuxt/schema').RuntimeConfig} config - 런타임 설정
* @param {{ needsAdminSetup: boolean }} bootstrap - 부트스트랩 상태
* @returns {boolean}
*/
const isSignupOtpRequired = (config, bootstrap) => {
if (bootstrap.needsAdminSetup) {
return false
}
const hasPepper = Boolean(String(config.emailOtpPepper || config.memberSessionSecret || '').trim())
return isResendConfigured(config) && hasPepper
}
/**
* 회원 가입 API
* @param {import('h3').H3Event} event - 요청 이벤트
@@ -27,6 +44,11 @@ export default defineEventHandler(async (event) => {
}
const body = parsedBody.data
const config = useRuntimeConfig()
const bootstrap = await getMemberBootstrapState()
const otpRequired = isSignupOtpRequired(config, bootstrap)
const emailNorm = body.email.trim().toLowerCase()
const usernameTaken = await isUsernameTaken({
username: body.username
})
@@ -38,7 +60,7 @@ export default defineEventHandler(async (event) => {
})
}
const existingUser = await getUserByEmail(body.email)
const existingUser = await getUserByEmail(emailNorm)
if (existingUser) {
throw createError({
@@ -47,10 +69,33 @@ export default defineEventHandler(async (event) => {
})
}
if (otpRequired) {
const otp = body.emailOtp
if (!otp) {
throw createError({
statusCode: 400,
message: '이메일 인증번호를 입력해 주세요.'
})
}
const pepper = String(config.emailOtpPepper || config.memberSessionSecret || '').trim()
const verify = await verifyAndConsumeEmailOtp({
email: emailNorm,
purpose: 'signup',
code: otp,
pepper
})
if (!verify.ok) {
throw createError({
statusCode: 400,
message: '이메일 인증번호가 올바르지 않거나 만료되었습니다.'
})
}
}
const passwordHash = await bcrypt.hash(body.password, 12)
const created = await createUser({
username: body.username,
email: body.email,
username: body.username.trim(),
email: emailNorm,
passwordHash
})
@@ -74,4 +119,3 @@ export default defineEventHandler(async (event) => {
isAdmin: Boolean(created.isAdmin)
}
})

View File

@@ -0,0 +1,164 @@
import { createError } from 'h3'
import { getPostgresClient } from './postgres-client'
import { hashOtpCode, normalizeOtpEmail, timingSafeEqualHex } from '../utils/email-otp'
/** 최대 검증 시도 횟수(초과 시 해당 챌린지는 더 이상 사용 불가) */
const MAX_OTP_VERIFY_ATTEMPTS = 8
/**
* DB 클라이언트 조회 (필수)
* @returns {ReturnType<typeof import('postgres').default>} postgres sql
*/
const requireSql = () => {
const sql = getPostgresClient()
if (!sql) {
throw createError({
statusCode: 500,
message: '데이터베이스 설정이 필요합니다.'
})
}
return sql
}
/**
* 동일 이메일·용도의 미소진 OTP를 무효화한다.
* @param {import('postgres').TransactionSql} sql - sql 또는 트랜잭션
* @param {string} email - 정규화된 이메일
* @param {string} purpose - signup | password_reset
* @returns {Promise<void>}
*/
export const invalidatePendingOtpChallenges = async (sql, email, purpose) => {
await sql`
UPDATE email_otp_challenges
SET consumed_at = now()
WHERE lower(email) = lower(${email})
AND purpose = ${purpose}
AND consumed_at IS NULL
`
}
/**
* 최근 짧은 시간 내 동일 이메일·용도 발송이 있는지 확인한다.
* @param {import('postgres').Sql} sql - sql
* @param {string} email - 이메일
* @param {string} purpose - 용도
* @returns {Promise<boolean>} true면 재요청 쿨다운 중
*/
export const hasRecentOtpSend = async (sql, email, purpose) => {
const rows = await sql`
SELECT 1 AS "x"
FROM email_otp_challenges
WHERE lower(email) = lower(${email})
AND purpose = ${purpose}
AND created_at > now() - interval '55 seconds'
LIMIT 1
`
return Boolean(rows?.[0])
}
/**
* 1시간 내 발송 횟수
* @param {import('postgres').Sql} sql - sql
* @param {string} email - 이메일
* @param {string} purpose - 용도
* @returns {Promise<number>}
*/
export const countOtpSendsLastHour = async (sql, email, purpose) => {
const rows = await sql`
SELECT COUNT(*)::int AS "c"
FROM email_otp_challenges
WHERE lower(email) = lower(${email})
AND purpose = ${purpose}
AND created_at > now() - interval '1 hour'
`
return Number(rows?.[0]?.c || 0)
}
/**
* OTP 챌린지 행을 삽입한다.
* @param {import('postgres').Sql} sql - sql
* @param {{ email: string, purpose: string, codeHash: string, expiresAt: Date, createdIp: string }} input - 입력
* @returns {Promise<string>} 삽입된 id
*/
export const insertOtpChallenge = async (sql, input) => {
const rows = await sql`
INSERT INTO email_otp_challenges (email, purpose, code_hash, expires_at, created_ip)
VALUES (${input.email}, ${input.purpose}, ${input.codeHash}, ${input.expiresAt}, ${input.createdIp})
RETURNING id
`
const id = rows?.[0]?.id
if (!id) {
throw createError({
statusCode: 500,
message: '인증 정보 저장에 실패했습니다.'
})
}
return String(id)
}
/**
* 이메일 OTP를 검증하고 소진 처리한다.
* @param {{ email: string, purpose: string, code: string, pepper: string }} input - 입력
* @returns {Promise<{ ok: boolean, reason?: 'none' | 'expired' | 'locked' | 'mismatch' }>}
*/
export const verifyAndConsumeEmailOtp = async (input) => {
const sql = requireSql()
const email = normalizeOtpEmail(input.email)
const purpose = String(input.purpose || '').trim()
const code = String(input.code || '').trim()
const pepper = String(input.pepper || '')
if (!email || !purpose || !/^\d{6}$/.test(code) || !pepper) {
return { ok: false, reason: 'mismatch' }
}
return await sql.begin(async (tx) => {
const rows = await tx`
SELECT id, code_hash AS "codeHash", verify_attempt_count AS "verifyAttemptCount", expires_at AS "expiresAt"
FROM email_otp_challenges
WHERE lower(email) = lower(${email})
AND purpose = ${purpose}
AND consumed_at IS NULL
ORDER BY created_at DESC
LIMIT 1
FOR UPDATE
`
const row = rows?.[0]
if (!row) {
return { ok: false, reason: 'none' }
}
const expiresAt = new Date(row.expiresAt)
if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() < Date.now()) {
await tx`
UPDATE email_otp_challenges
SET consumed_at = now()
WHERE id = ${row.id}
`
return { ok: false, reason: 'expired' }
}
if (Number(row.verifyAttemptCount || 0) >= MAX_OTP_VERIFY_ATTEMPTS) {
return { ok: false, reason: 'locked' }
}
const expected = hashOtpCode({ pepper, email, purpose, code })
if (!timingSafeEqualHex(expected, row.codeHash)) {
await tx`
UPDATE email_otp_challenges
SET verify_attempt_count = verify_attempt_count + 1
WHERE id = ${row.id}
`
return { ok: false, reason: 'mismatch' }
}
await tx`
UPDATE email_otp_challenges
SET consumed_at = now()
WHERE id = ${row.id}
`
return { ok: true }
})
}

View File

@@ -60,7 +60,7 @@ export const getUserByEmail = async (email) => {
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp"
FROM users
WHERE email = ${email}
WHERE lower(email) = lower(${email})
LIMIT 1
`
@@ -214,6 +214,25 @@ export const updateMemberProfile = async (input) => {
return rows?.[0] || null
}
/**
* 이메일(대소문자 무시)로 비밀번호 해시를 갱신한다.
* @param {{ email: string, passwordHash: string }} input - 입력
* @returns {Promise<boolean>} 갱신된 행이 있으면 true
*/
export const updateMemberPasswordByEmail = async (input) => {
const sql = requireSql()
const rows = await sql`
UPDATE users
SET
password_hash = ${input.passwordHash},
updated_at = now()
WHERE lower(email) = lower(${input.email})
RETURNING id
`
return Boolean(rows?.[0])
}
/**
* 회원 비밀번호 변경
* @param {{ userId: string, passwordHash: string }} input - 수정 값

43
server/utils/email-otp.js Normal file
View File

@@ -0,0 +1,43 @@
import { createHash, randomInt, timingSafeEqual } from 'node:crypto'
/**
* OTP용 이메일 정규화(소문자·trim)
* @param {string} email - 원본
* @returns {string}
*/
export const normalizeOtpEmail = (email) => String(email || '').trim().toLowerCase()
/**
* 6자리 숫자 인증 코드 생성
* @returns {string}
*/
export const generateSixDigitOtp = () => String(randomInt(0, 1_000_000)).padStart(6, '0')
/**
* OTP 코드 해시(hex)
* @param {{ pepper: string, email: string, purpose: string, code: string }} input - 입력
* @returns {string} sha256 hex
*/
export const hashOtpCode = (input) => {
const payload = `${String(input.pepper || '')}|${normalizeOtpEmail(input.email)}|${String(input.purpose || '')}|${String(input.code || '')}`
return createHash('sha256').update(payload, 'utf8').digest('hex')
}
/**
* 두 sha256 hex 문자열을 상수 시간으로 비교한다.
* @param {string} a - hex
* @param {string} b - hex
* @returns {boolean}
*/
export const timingSafeEqualHex = (a, b) => {
try {
const ba = Buffer.from(String(a || ''), 'hex')
const bb = Buffer.from(String(b || ''), 'hex')
if (ba.length !== bb.length || ba.length === 0) {
return false
}
return timingSafeEqual(ba, bb)
} catch {
return false
}
}

View File

@@ -162,11 +162,38 @@ export const renumberSortOrderByTree = (items, location) => {
/**
* 공개 API용 primary 트리(순환 참조 없음).
* 동일 id가 평면 목록에 중복되거나(시드·이전 마이그레이션 등), 한 행은 루트·다른 행은 자식으로만 잡히면
* 기존 한 루프 방식으로는 같은 노드가 roots와 children에 동시에 들어갈 수 있어, 자식으로 연결된 id는 루트에서 제외한다.
* @param {Array<Object>} flatPrimary - location primary인 항목만
* @returns {Array<Object>}
*/
export const buildPublicPrimaryTree = (flatPrimary) => {
const list = flatPrimary.map((row) => ({
const sorted = [...(flatPrimary || [])].sort((a, b) => {
const sa = a.sortOrder || 0
const sb = b.sortOrder || 0
if (sa !== sb) {
return sa - sb
}
const la = String(a.label || '')
const lb = String(b.label || '')
if (la !== lb) {
return la.localeCompare(lb)
}
return String(a.id).localeCompare(String(b.id))
})
const idSeen = new Set()
const deduped = []
for (const row of sorted) {
const id = String(row.id)
if (idSeen.has(id)) {
continue
}
idSeen.add(id)
deduped.push(row)
}
const list = deduped.map((row) => ({
id: row.id,
label: row.label,
url: row.url,
@@ -177,16 +204,24 @@ export const buildPublicPrimaryTree = (flatPrimary) => {
}))
const byId = new Map(list.map((i) => [String(i.id), { ...i, children: [] }]))
const roots = []
const attachedAsChild = new Set()
for (const row of list) {
const id = String(row.id)
const node = byId.get(id)
const p = row.parentId
if (p && byId.has(String(p))) {
byId.get(String(p)).children.push(node)
} else {
roots.push(node)
const pid = p != null && String(p).trim() !== '' ? String(p).trim() : ''
if (pid && pid !== id && byId.has(pid)) {
byId.get(pid).children.push(node)
attachedAsChild.add(id)
}
}
const roots = []
for (const row of list) {
const id = String(row.id)
if (!attachedAsChild.has(id)) {
roots.push(byId.get(id))
}
}

View File

@@ -0,0 +1,42 @@
import { createError } from 'h3'
/**
* Resend가 서버 설정으로 사용 가능한지
* @param {{ resendApiKey?: string, resendFromEmail?: string }} config - 런타임 설정
* @returns {boolean}
*/
export const isResendConfigured = (config) => {
const key = String(config?.resendApiKey || '').trim()
const from = String(config?.resendFromEmail || '').trim()
return Boolean(key && from)
}
/**
* Resend REST API로 이메일을 발송한다.
* @param {{ apiKey: string, from: string, to: string, subject: string, html: string }} input - 발송 입력
* @returns {Promise<void>}
*/
export const sendResendEmail = async (input) => {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${input.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from: input.from,
to: [input.to],
subject: input.subject,
html: input.html
})
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
throw createError({
statusCode: 502,
message: '이메일 발송에 실패했습니다. Resend 발신 주소·도메인 설정을 확인해 주세요.',
data: process.env.NODE_ENV === 'development' && detail ? { detail } : undefined
})
}
}