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

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