헤더 검색 중앙 정렬·Resend 이메일 OTP·비밀번호 찾기 (v0.0.99)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 13:34:21 +09:00
parent 996965740f
commit 6a059a9a59
22 changed files with 984 additions and 34 deletions

View File

@@ -18,7 +18,7 @@
| 요소 | 크기/속성 |
|------|-----------|
| 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` 미만) | 단일 세로 흐름: **본문 → 오른쪽 사이드** 순. 왼쪽 사이드는 레이아웃 흐름에서 분리된 고정 슬라이드 패널로 표시 |
@@ -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`·`MEMBER_SESSION_SECRET` 또는 `EMAIL_OTP_PEPPER` 존재 여부)를 반환한다.
- `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` - 회원 로그아웃
@@ -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으로 사용한다.