v1.7.1: 멤버 권한·추천 사이트·네비게이션 UI 개선
관리자는 VIP·멤버만 수정 가능하도록 서버·UI 권한을 정리하고 소유자 권한 이전 API와 확인 모달을 추가했다. 추천 사이트 파비콘 캐시·URL 접두사·카드 편집 UI를 보강하고 확인 얼럿을 AdminConfirmModal로 통일했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
18
docs/spec.md
18
docs/spec.md
@@ -555,7 +555,7 @@ components/content/
|
||||
> 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다.
|
||||
> `AVATAR_WEBP_QUALITY`는 1~100 범위로 보정하며, 최대 해상도 설정이 최소 해상도보다 작으면 서버에서 최소값 이상으로 자동 보정한다.
|
||||
> 회원 썸네일을 새로 업로드하거나 제거·탈퇴할 때, 이전 썸네일 URL에 대한 `media_metadata` 행은 `removeManagedAvatarAsset`으로 제거하되 **디스크 파일은 삭제하지 않는다.** 관리자 미디어 **썸네일** 탭에서 미사용 파일을 확인·삭제한다.
|
||||
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·마지막 접속·IP)가 포함될 수 있다.
|
||||
> 관리자 미디어 화면의 **썸네일** 탭에서만 회원 썸네일을 목록·검색하며, `GET /admin/api/media` 응답에 `avatarOwner`(닉네임·이메일·등급·마지막 접속·IP)가 포함될 수 있다. 프로필 이미지 목록 카드 배지는 등급(소유자·관리자·VIP·멤버)으로 표시한다.
|
||||
|
||||
### 업로드 파일 제공
|
||||
|
||||
@@ -595,7 +595,7 @@ components/content/
|
||||
- `POST /admin/api/media-folders` - 미디어 폴더 생성
|
||||
- `DELETE /admin/api/media-folders` - 미디어 폴더 삭제(해당 경로·하위 경로로 분류된 `media_metadata`는 `미분류`로 되돌림)
|
||||
- `POST /admin/api/uploads` - 관리자 게시물·페이지용 이미지 업로드(저장 파일명은 원본명 기반·동일 월 폴더 내 충돌 시 `이름-2` 등 넘버링, `/uploads/posts/YYYY/MM` 저장)
|
||||
- `POST /admin/api/member-avatar` - 관리자 새 회원 생성 전 썸네일 사전 업로드(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `POST /admin/api/member-avatar` - 관리자 멤버 썸네일 사전 업로드(신규·편집 공통, `/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭). 편집 화면에서는 미리보기만 반영하고 **저장**(`PUT /admin/api/members/:id`) 시 회원에 연결한다.
|
||||
- `GET /admin/api/tags` - 태그 목록(옵션: `tagType`, `q`, `limit`)
|
||||
- `POST /admin/api/tags` - 태그 생성
|
||||
- `GET /admin/api/tags/:id` - 태그 상세
|
||||
@@ -609,12 +609,14 @@ components/content/
|
||||
- `GET /api/site-settings` - 공개 사이트 설정 조회(`showPostUpdatedAt`, 라이트·다크 홈 커버·어나운스 바·사이트 코드 필드 포함)
|
||||
- `GET /admin/api/navigation` - 네비게이션 항목 목록
|
||||
- `PUT /admin/api/navigation` - 네비게이션 항목 일괄 저장
|
||||
- `POST /admin/api/navigation/:id/favicon-refresh` - 추천 사이트 파비콘 수동 갱신(`{ cachedFaviconUrl, faviconCachedAt }` 반환)
|
||||
- `GET /admin/api/members` - 회원 목록(권한 코드, 최근 접속, 접속 IP, 댓글 수 포함)
|
||||
- `POST /admin/api/members` - 관리자 회원 생성. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 생성된 회원은 `member` 권한이며 초기 비밀번호는 임의 해시로 저장한다.
|
||||
- `GET /admin/api/members/:id` - 관리자 회원 상세(썸네일, 이름, 이메일, 레이블, 관리자 노트, 활동 요약 포함)
|
||||
- `PUT /admin/api/members/:id` - 관리자 회원 기본 정보 수정. 본문: `username`, `email`, 선택 `avatarUrl`, `labels`, `note`. 이전 값이 회원 전용 썸네일 URL이고 새 값과 달라지면 `media_metadata` 연결을 분리한다.
|
||||
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`vip`/`member`)
|
||||
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 DB 반영(레거시 API; 관리자 UI는 `member-avatar` + `PUT` 흐름 사용)
|
||||
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`vip`/`member`). 소유자 권한 이전은 `POST /admin/api/members/:id/ownership-transfer` 사용.
|
||||
- `POST /admin/api/members/:id/ownership-transfer` - 소유자 권한 이전(대상→소유자, 요청자→관리자)
|
||||
|
||||
- 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
|
||||
- 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다.
|
||||
@@ -782,10 +784,10 @@ components/content/
|
||||
- 네비게이션은 `navigation_items` 테이블로 관리한다.
|
||||
- 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`|`recommended`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다.
|
||||
- 동일 `location`·`parent_id`·`label`·`url` 조합은 중복 저장하지 않는다. 반복 마이그레이션으로 생긴 중복은 `019_dedupe_navigation_items.sql`이 정리하고, 표현식 유니크 인덱스로 재발을 막는다.
|
||||
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`는 **평면** 배열(`parent_id` 없음)이다. `recommended`는 `id`, `label`, `url`, `descriptionText`, `thumbnailUrl`, `isVisible`을 내려준다.
|
||||
- `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**. **상단은 루트 직속 자식만** 트리에 붙이며(부모의 부모가 있는 행은 자식으로 무시), `footer`·`recommended`는 **평면** 배열(`parent_id` 없음)이다. `recommended`는 `id`, `label`, `url`, `descriptionText`, `thumbnailUrl`, `cachedFaviconUrl`, `isVisible`을 내려준다. 관리자 `GET /admin/api/navigation`에는 추가로 `faviconCachedAt`을 포함한다.
|
||||
- `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 `primary`·`footer`·`recommended` 각각 위치별 트리(또는 평면 루트) DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 **상단은 한 단계(루트→자식)만** 허용한다. `footer`·`recommended` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다.
|
||||
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. 추천 사이트는 공개 화면 **왼쪽 사이드바** Categories 아래 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시하고 썸네일 URL이 있으면 Google Favicon 프록시 대신 썸네일을 표시한다. 썸네일이 없고 `https://` URL이면 클라이언트에서 origin을 뽑아 Google Favicon V2(`https://t3.gstatic.com/faviconV2?...`) 이미지로 표시할 수 있다(내부 경로만 있으면 아이콘 생략). 썸네일 로드에 실패하면 파비콘, 파비콘도 실패하면 라벨 첫 글자로 대체한다. 오른쪽 사이드바는 추천 사이트를 렌더하지 않고 광고와 게시물 목차 영역에 집중한다.
|
||||
- URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다. 저장 시 `uniare.sori.studio`처럼 프로토콜 없는 외부 호스트는 `https://`를 자동으로 붙인다. 관리자 추천 사이트 편집 모달은 URL 접두사(`https://`·`http://`·내부 경로) 선택과 본문 입력으로 나눈다.
|
||||
- 관리자 메뉴 화면은 **상단 네비게이션**·**하단 네비게이션**·**추천 사이트** 탭으로 구분한다. 편집 UI는 **태그 관리 메인 태그**와 같은 테이블·`cursor-move` 행 드래그 패턴을 쓰며, 드래그는 입력·버튼 위가 아닌 행 여백·개요 열에서 시작한다. 상단은 `buildNavigationEditorTree` 결과를 `flattenNavigationEditorWrappers`로 한 테이블에 평면 표시하고, **같은 행의 위 1/3·가운데·아래 1/3**에 드롭하면 각각 대상 **앞**(동일 부모)·**하위**(대상의 자식, **루트 행에만**)·**뒤**(동일 부모)로 이동한다. **상단은 루트→자식 한 단계만** 허용하며(하위 행에는 가운데 드롭이 형제 앞·뒤로 대체됨, 이미 하위가 있는 항목은 다른 항목의 하위로 넣을 수 없음), 드래그 중 하이라이트는 **형제 앞·뒤**를 파란 가로 끝선·연한 파란 배경, **하위 편입**을 앰버 링·연한 앰버 배경으로 구분하고, 개요 열에 `앞에 끼움` / `뒤에 끼움` / `하위로 넣기` 짧은 문구를 덧붙인다. 개요 열 표기는 `1`, `2.1`, `2.2`, `3`처럼 깊이별 계층 번호로 보이며, 라벨 열은 깊이만큼 `padding-left`로 들여쓴다. 자기 자신·자기 하위로의 편입은 거부한다. 하단·추천 사이트는 평면 목록만 드래그 정렬한다. **추천 사이트** 탭 목록은 공개 Recommended와 같은 **카드**(아이콘·제목·설명 또는 URL) 형태이며, 캐시 갱신 시각은 카드 오른쪽 끝에 표시한다. 추천 사이트 드래그는 카드 **위·아래 절반**에 드롭하면 각각 대상 **앞·뒤**로 삽입되며, 파란 가로 삽입선·`앞에 삽입` / `뒤에 삽입` 안내를 표시한다. 추천 사이트는 공개 화면 **왼쪽 사이드바** Categories 아래 Recommended 영역에 카드로 노출되며, 대체 텍스트가 있으면 URL 대신 대체 텍스트를 표시한다. 아이콘 우선순위는 **수동 썸네일 URL > 서버 캐시 파비콘(`cached_favicon_url`) > 외부 faviconV2 프록시**이다. 서버는 외부 `https://` URL의 파비콘을 `/uploads/system/recommended-favicon-*.png`로 저장하고 `favicon_cached_at`을 기록한다. 자동 갱신은 **매월 1일 03:00(KST)** 에 전체 추천 사이트를 대상으로 수행하며, **관리자 추천 사이트 탭**은 공개와 같은 **카드 목록**으로 미리보기하고 **편집** 버튼으로 모달에서 제목·URL·대체 텍스트·썸네일 URL 전체를 확인·수정한다. 삭제·취소(배경 클릭 포함)는 브라우저 `confirm`으로 한 번 더 확인하며, 모달에서 **적용** 전 입력 변경이 있으면 닫기 전에 안내한다. 편집 모달에서 `POST /admin/api/navigation/:id/favicon-refresh`로 파비콘을 수동 갱신할 수 있다. 네비게이션 저장 시 캐시 없는 추천 사이트는 백그라운드로 최초 캐시를 시도한다. 운영 수동 일괄 갱신은 `npm run navigation:refresh-recommended-favicons`를 사용한다. 오른쪽 사이드바는 추천 사이트를 렌더하지 않고 광고와 게시물 목차 영역에 집중한다.
|
||||
- `parent_id` / `is_folder` 컬럼이 DB에 없을 때 저장은 실패한다. `npm run db:migrate:dev`로 `017_navigation_hierarchy.sql`을 적용해야 한다(저장 API는 해당 경우 한국어 안내 메시지를 반환할 수 있다).
|
||||
- 공개 왼쪽 사이드바 상단은 `SidebarPrimaryNavList`로 렌더링한다. **하위가 있는 노드**는 한 줄 `button`으로, **행 전체(이름·왼쪽 세로 데코·chevron) 클릭**으로 접기/펼친다. 부모·리프 모두 왼쪽 장식은 **기본 세로 막대(`--site-line`)**, **호버 시에만** 리프와 동일하게 **작은 원형**으로 전환한다. **내부 경로**(`/`로 시작, `//`·`http(s)` 제외)이고 현재 `route.path`와 정규화한 경로가 같으면 장식 색을 **`--site-accent`(브랜드 오렌지)**로 둔다. 외부 URL은 비교하지 않는다. 펼침 상태는 `localStorage` 키 `sori-primary-nav-expanded`에 저장된다. 상단·리프 링크·부모 버튼 행은 **`w-full`**로 `site-sidebar-nav-row` 호버 배경이 가로 전체를 쓴다(라이트 `#F7F4EF`, 다크는 `site-panel-hover`와 동일한 `color-mix` 패턴).
|
||||
- 관리자 메뉴 관리 화면에서 저장 API로 보내는 직렬화 결과가 서버에서 불러온 직후와 동일하면 `메뉴 저장` 버튼이 비활성화된다.
|
||||
@@ -807,7 +809,7 @@ components/content/
|
||||
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 기존 회원 상세는 처음에는 읽기 전용 보기 모드로 현재 값만 표시하고, 상단 `수정하기`를 누르면 입력 필드·셀렉트가 편집 컨트롤로 전환된다. 저장 버튼은 수정 모드에서 기존 값과 달라진 내용이 있을 때만 활성화한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 멤버 등급은 셀렉트 변경 즉시 저장하지 않고 저장 버튼을 눌렀을 때 기본 정보와 함께 반영한다. 소유자 본인이나 관리자가 다른 관리자·소유자를 보는 등 서버에서 변경 불가한 경우 등급 셀렉트는 비활성화하고, 등급 변경 권한이 없는 세션에서는 등급을 일반 텍스트로 표시한다. 저장·업로드·비밀번호 변경 피드백은 우측 상단 관리자 토스트로 표시한다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
|
||||
- 관리자 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 관리자 게시글 작성/수정 화면은 **서버에 이미 즉시 발행 또는 예약으로 저장된 글**에서 미저장 변경이 있을 때만 동일 방식으로 내부 이동을 막고, 초안(서버 기준)은 서버 자동 저장과 라우트 이탈 직전 플러시로 처리하여 해당 경우에는 모달을 쓰지 않는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 레거시 키 `SORI_ADMIN_POST_AUTOSAVE:*`가 있으면 삭제한다.
|
||||
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
|
||||
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다. 권한 변경은 소유자와 관리자만 가능하다. 관리자는 다른 관리자·소유자의 권한을 변경할 수 없고, 소유자·관리자 등급을 부여할 수 없다. 소유자는 모든 등급을 관리할 수 있으나 본인 권한을 직접 낮출 수 없고, 시스템에는 최소 1명의 소유자가 항상 남아야 한다.
|
||||
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다. 권한 변경은 소유자와 관리자만 가능하다. **관리자**는 VIP·멤버의 정보만 수정·삭제·비밀번호 변경할 수 있으며, 소유자·관리자·본인 계정은 조회만 한다. 관리자는 소유자·관리자 등급을 부여할 수 없다. **소유자**는 모든 등급 회원 정보를 수정할 수 있고 본인 정보 수정도 가능하나, 본인 등급은 직접 낮출 수 없다. 소유자는 다른 회원을 소유자로 지정할 수 있으며, 이때 확인 모달 후 `POST /admin/api/members/:id/ownership-transfer`로 권한을 이전하고 기존 소유자는 관리자가 된다. 소유자는 항상 1명만 유지된다.
|
||||
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
|
||||
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
||||
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
|
||||
|
||||
Reference in New Issue
Block a user