소유자 권한 보호와 멤버 목록 등급 표시 v1.5.8
This commit is contained in:
18
db/migrations/038_restore_owner_when_missing.sql
Normal file
18
db/migrations/038_restore_owner_when_missing.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
WITH fallback_owner AS (
|
||||||
|
SELECT id
|
||||||
|
FROM users
|
||||||
|
WHERE user_role = 'admin'
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
user_role = 'owner',
|
||||||
|
is_admin = true,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id IN (SELECT id FROM fallback_owner)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM users
|
||||||
|
WHERE user_role = 'owner'
|
||||||
|
);
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.5.8
|
||||||
|
|
||||||
|
- 소유자가 본인 권한을 직접 낮춰 소유자가 사라지는 상황을 막았다.
|
||||||
|
- 멤버 목록의 상태 열에 등급을 함께 표시하고, 비활성 회원만 보조 상태로 보이도록 정리했다.
|
||||||
|
- 소유자가 없는 DB 상태를 복구하는 마이그레이션을 추가했다.
|
||||||
|
|
||||||
## v1.5.7
|
## v1.5.7
|
||||||
|
|
||||||
- 일반 텍스트 페이지에서도 페이지 형식 선택을 다시 HTML로 되돌릴 수 있게 수정했다.
|
- 일반 텍스트 페이지에서도 페이지 형식 선택을 다시 HTML로 되돌릴 수 있게 수정했다.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-27 v1.5.8 — 소유자 권한 보호와 멤버 목록 등급 표시
|
||||||
|
|
||||||
|
소유자는 시스템을 복구할 수 있는 최상위 권한이므로 본인 계정을 관리자 이하로 직접 낮출 수 없게 한다. 기존의 마지막 소유자 보호는 동시에 여러 사용자가 권한을 바꾸는 상황을 막기 위한 장치로 유지하고, 이미 소유자가 0명이 된 개발 DB는 마이그레이션으로 가장 오래된 관리자 계정을 소유자로 되돌릴 수 있게 했다. 멤버 목록은 운영자가 등급을 빠르게 스캔해야 하므로 별도 열을 추가하지 않고 기존 상태 열에 등급을 먼저 보여주며, 활성 상태는 기본값이라 숨기고 비활성만 보조 상태로 표시한다.
|
||||||
|
|
||||||
## 2026-05-26 v1.5.7 — 페이지 형식 선택과 IP 기록 보정
|
## 2026-05-26 v1.5.7 — 페이지 형식 선택과 IP 기록 보정
|
||||||
|
|
||||||
페이지 형식 선택은 현재 모드와 관계없이 되돌릴 수 있어야 하므로 설정 패널에서 항상 표시한다. 일반 텍스트 모드는 HTML 문서가 아니어서 HTML 자산 업로드가 의미 없으므로 해당 업로드 UI는 HTML 문서 모드에서만 표시한다. 멤버 정보의 접속 IP는 프록시 뒤에서 기본 `getRequestIP`가 비어 저장될 수 있으므로, 로그인·회원가입·댓글 활동 등 회원 활동 기록에는 `x-forwarded-for`를 포함한 요청 IP 조회를 공통으로 사용한다.
|
페이지 형식 선택은 현재 모드와 관계없이 되돌릴 수 있어야 하므로 설정 패널에서 항상 표시한다. 일반 텍스트 모드는 HTML 문서가 아니어서 HTML 자산 업로드가 의미 없으므로 해당 업로드 UI는 HTML 문서 모드에서만 표시한다. 멤버 정보의 접속 IP는 프록시 뒤에서 기본 `getRequestIP`가 비어 저장될 수 있으므로, 로그인·회원가입·댓글 활동 등 회원 활동 기록에는 `x-forwarded-for`를 포함한 요청 IP 조회를 공통으로 사용한다.
|
||||||
|
|||||||
@@ -141,7 +141,7 @@
|
|||||||
| pages/admin/tags/[id].vue | 태그 수정 |
|
| pages/admin/tags/[id].vue | 태그 수정 |
|
||||||
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 토글), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(라이트·다크 커버 이미지·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색), **스팸 필터**(가입 금지 닉네임), 타임존·Import/Export 플레이스홀더 |
|
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 토글·읽기 모드 비활성 토글), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(라이트·다크 커버 이미지·오버레이 텍스트), **어나운스 바**(사용 토글·맞춤 설정·배경색), **스팸 필터**(가입 금지 닉네임), 타임존·Import/Export 플레이스홀더 |
|
||||||
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
||||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) |
|
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
|
||||||
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
| pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) |
|
||||||
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 저장 버튼 기반 멤버 등급 변경, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 저장 버튼 기반 멤버 등급 변경, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
|
||||||
|
|
||||||
@@ -293,6 +293,7 @@
|
|||||||
| db/migrations/035_default_pages_to_html_document.sql | 고정 페이지 렌더링 모드 기본값을 HTML 문서로 변경 |
|
| db/migrations/035_default_pages_to_html_document.sql | 고정 페이지 렌더링 모드 기본값을 HTML 문서로 변경 |
|
||||||
| db/migrations/036_content_visibility_statuses.sql | 게시물 상태에 `members`/`private`, 페이지 상태에 `published`/`draft`/`private` 제약 추가 |
|
| db/migrations/036_content_visibility_statuses.sql | 게시물 상태에 `members`/`private`, 페이지 상태에 `published`/`draft`/`private` 제약 추가 |
|
||||||
| db/migrations/037_add_vip_member_role.sql | 회원 권한 단계에 `vip` 허용 |
|
| db/migrations/037_add_vip_member_role.sql | 회원 권한 단계에 `vip` 허용 |
|
||||||
|
| db/migrations/038_restore_owner_when_missing.sql | 소유자가 없는 경우 기존 관리자 중 가장 오래된 계정을 소유자로 복구 |
|
||||||
|
|
||||||
## 설정/배포
|
## 설정/배포
|
||||||
|
|
||||||
|
|||||||
@@ -689,13 +689,13 @@ components/content/
|
|||||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. 요청 IP는 프록시 헤더(`x-forwarded-for`)를 포함해 기록한다.
|
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. 요청 IP는 프록시 헤더(`x-forwarded-for`)를 포함해 기록한다.
|
||||||
- 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
|
- 관리자 설정 화면은 사이트 자체 설정만 관리한다. 관리자 프로필과 비밀번호는 멤버 편집 화면의 계정 작업에서 처리한다.
|
||||||
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/admin/members/:id` 멤버 편집 화면으로 이동한다.
|
- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/admin/members/:id` 멤버 편집 화면으로 이동한다.
|
||||||
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다.
|
- 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다. 상태 열은 멤버 등급을 먼저 표시하고, 비활성 회원만 작은 보조 상태로 표시한다.
|
||||||
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
|
- 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다.
|
||||||
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
|
- 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다.
|
||||||
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 멤버 등급은 셀렉트 변경 즉시 저장하지 않고 저장 버튼을 눌렀을 때 기본 정보와 함께 반영한다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
|
- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 멤버 등급 선택, 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 멤버 등급은 셀렉트 변경 즉시 저장하지 않고 저장 버튼을 눌렀을 때 기본 정보와 함께 반영한다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다.
|
||||||
- 관리자 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 관리자 게시글 작성/수정 화면은 **서버에 이미 즉시 발행 또는 예약으로 저장된 글**에서 미저장 변경이 있을 때만 동일 방식으로 내부 이동을 막고, 초안(서버 기준)은 서버 자동 저장과 라우트 이탈 직전 플러시로 처리하여 해당 경우에는 모달을 쓰지 않는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 레거시 키 `SORI_ADMIN_POST_AUTOSAVE:*`가 있으면 삭제한다.
|
- 관리자 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 관리자 게시글 작성/수정 화면은 **서버에 이미 즉시 발행 또는 예약으로 저장된 글**에서 미저장 변경이 있을 때만 동일 방식으로 내부 이동을 막고, 초안(서버 기준)은 서버 자동 저장과 라우트 이탈 직전 플러시로 처리하여 해당 경우에는 모달을 쓰지 않는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 레거시 키 `SORI_ADMIN_POST_AUTOSAVE:*`가 있으면 삭제한다.
|
||||||
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
|
- `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다.
|
||||||
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다. 권한 변경은 소유자와 관리자만 가능하다. 관리자는 다른 관리자·소유자의 권한을 변경할 수 없고, 소유자·관리자 등급을 부여할 수 없다. 소유자는 모든 등급을 관리할 수 있으나 시스템에는 최소 1명의 소유자가 항상 남아야 한다.
|
- 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `VIP(vip)`, `멤버(member)` 단계를 사용한다. VIP는 관리자 권한이 없지만 멤버십 게시물을 볼 수 있는 등급이며, 상세 화면에서 변경한다. 권한 변경은 소유자와 관리자만 가능하다. 관리자는 다른 관리자·소유자의 권한을 변경할 수 없고, 소유자·관리자 등급을 부여할 수 없다. 소유자는 모든 등급을 관리할 수 있으나 본인 권한을 직접 낮출 수 없고, 시스템에는 최소 1명의 소유자가 항상 남아야 한다.
|
||||||
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
|
- 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다.
|
||||||
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
- 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다.
|
||||||
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
|
- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.5.8
|
||||||
|
|
||||||
|
- 관리자 멤버 권한 변경: 소유자가 본인 권한을 직접 낮출 수 없도록 수정.
|
||||||
|
- 관리자 멤버 목록: 상태 열에 멤버 등급을 표시하고, 비활성 회원만 보조 상태로 표시하도록 수정.
|
||||||
|
- DB: 소유자가 없는 상태일 때 기존 관리자 중 가장 오래된 계정을 소유자로 복구하는 마이그레이션 추가.
|
||||||
|
|
||||||
## v1.5.7
|
## v1.5.7
|
||||||
|
|
||||||
- 관리자 페이지 작성/수정: 일반 텍스트 모드에서도 페이지 형식 선택창이 계속 보이도록 수정.
|
- 관리자 페이지 작성/수정: 일반 텍스트 모드에서도 페이지 형식 선택창이 계속 보이도록 수정.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.7",
|
"version": "1.5.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.7",
|
"version": "1.5.8",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.5.7",
|
"version": "1.5.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ const formatRelativeTime = (value) => {
|
|||||||
<thead class="admin-members__table-head border-b border-line text-xs uppercase tracking-[0.02em] text-[#15171a]">
|
<thead class="admin-members__table-head border-b border-line text-xs uppercase tracking-[0.02em] text-[#15171a]">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="admin-members__cell w-[32%] py-4 pr-5 font-semibold">{{ memberCountLabel }}</th>
|
<th class="admin-members__cell w-[32%] py-4 pr-5 font-semibold">{{ memberCountLabel }}</th>
|
||||||
<th class="admin-members__cell w-[15%] px-5 py-4 font-semibold">상태</th>
|
<th class="admin-members__cell w-[15%] px-5 py-4 font-semibold">등급/상태</th>
|
||||||
<th class="admin-members__cell w-[16%] px-5 py-4 font-semibold">댓글 작성</th>
|
<th class="admin-members__cell w-[16%] px-5 py-4 font-semibold">댓글 작성</th>
|
||||||
<th class="admin-members__cell w-[17%] px-5 py-4 font-semibold">접속 IP</th>
|
<th class="admin-members__cell w-[17%] px-5 py-4 font-semibold">접속 IP</th>
|
||||||
<th class="admin-members__cell w-[20%] py-4 pl-5 font-semibold">가입일</th>
|
<th class="admin-members__cell w-[20%] py-4 pl-5 font-semibold">가입일</th>
|
||||||
@@ -549,7 +549,13 @@ const formatRelativeTime = (value) => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
|
<td class="admin-members__cell px-5 py-5 text-[#2f343b]">
|
||||||
<span class="admin-members__status text-sm text-[#394047]">
|
<span class="admin-members__role block text-sm font-semibold text-[#15171a]">
|
||||||
|
{{ member.role || '멤버' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="member.activityStatus !== '활성'"
|
||||||
|
class="admin-members__status mt-1 block text-xs font-medium text-red-300"
|
||||||
|
>
|
||||||
{{ member.activityStatus }}
|
{{ member.activityStatus }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -856,6 +856,17 @@ export const updateMemberRoleByAdmin = async (input) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
target.id === input.actorUserId &&
|
||||||
|
target.roleCode === MEMBER_ROLE.OWNER &&
|
||||||
|
normalizedRole !== MEMBER_ROLE.OWNER
|
||||||
|
) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
message: '소유자는 본인 권한을 직접 낮출 수 없습니다.'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (actor.roleCode === MEMBER_ROLE.ADMIN) {
|
if (actor.roleCode === MEMBER_ROLE.ADMIN) {
|
||||||
if (isPrivilegedRole(target.roleCode)) {
|
if (isPrivilegedRole(target.roleCode)) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|||||||
Reference in New Issue
Block a user