Google 계정 연동 관리 개선

This commit is contained in:
2026-07-29 14:18:40 +09:00
parent 52ed053876
commit 3c431576b7
13 changed files with 258 additions and 16 deletions

View File

@@ -30,8 +30,10 @@ const isEditingMember = ref(props.mode === 'new')
const actionMenuOpen = ref(false) const actionMenuOpen = ref(false)
const passwordModalOpen = ref(false) const passwordModalOpen = ref(false)
const deleteModalOpen = ref(false) const deleteModalOpen = ref(false)
const googleUnlinkModalOpen = ref(false)
const isUpdatingPassword = ref(false) const isUpdatingPassword = ref(false)
const isDeletingMember = ref(false) const isDeletingMember = ref(false)
const isUnlinkingGoogle = ref(false)
const actionError = ref('') const actionError = ref('')
const roleOptions = [ const roleOptions = [
@@ -109,6 +111,7 @@ const isFormEditable = computed(() => {
const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버') const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버')
const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '') const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '')
const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value)) const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value))
const isCurrentAdminOwner = computed(() => currentAdminRoleCode.value === 'owner')
const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId) const isEditingSelf = computed(() => Boolean(props.member?.id && adminSession.value?.userId)
&& String(props.member.id) === String(adminSession.value.userId)) && String(props.member.id) === String(adminSession.value.userId))
/** 저장된 대상 회원 등급(권한 판단용) */ /** 저장된 대상 회원 등급(권한 판단용) */
@@ -135,6 +138,9 @@ const canEditMemberProfile = computed(() => {
const canOpenEditMode = computed(() => !isNewMember.value && canEditMemberProfile.value && !isEditingMember.value) const canOpenEditMode = computed(() => !isNewMember.value && canEditMemberProfile.value && !isEditingMember.value)
const canManageMemberActions = computed(() => canEditMemberProfile.value) const canManageMemberActions = computed(() => canEditMemberProfile.value)
const shouldRenderRoleAsText = computed(() => !isFormEditable.value || isNewMember.value || !isCurrentAdminPrivileged.value) const shouldRenderRoleAsText = computed(() => !isFormEditable.value || isNewMember.value || !isCurrentAdminPrivileged.value)
const googleLinked = computed(() => Boolean(props.member?.googleLinkedAt))
const googleAccountText = computed(() => props.member?.googleEmail || props.member?.googleName || 'Google 계정')
const canUnlinkGoogle = computed(() => !isNewMember.value && googleLinked.value && isCurrentAdminOwner.value)
/** /**
* 회원 등급 변경 권한 여부(저장 중 상태는 제외) * 회원 등급 변경 권한 여부(저장 중 상태는 제외)
@@ -443,6 +449,31 @@ const closeDeleteModal = () => {
deleteModalOpen.value = false deleteModalOpen.value = false
} }
/**
* Google 연동 해제 확인 모달을 연다.
* @returns {void}
*/
const openGoogleUnlinkModal = () => {
if (!canUnlinkGoogle.value) {
return
}
actionError.value = ''
googleUnlinkModalOpen.value = true
}
/**
* Google 연동 해제 확인 모달을 닫는다.
* @returns {void}
*/
const closeGoogleUnlinkModal = () => {
if (isUnlinkingGoogle.value) {
return
}
googleUnlinkModalOpen.value = false
}
/** /**
* 관리자 권한으로 회원 비밀번호를 변경한다. * 관리자 권한으로 회원 비밀번호를 변경한다.
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -508,6 +539,34 @@ const deleteMember = async () => {
} }
} }
/**
* 소유자 권한으로 회원 Google 연동을 해제한다.
* @returns {Promise<void>}
*/
const unlinkGoogleIdentity = async () => {
if (!canUnlinkGoogle.value || isUnlinkingGoogle.value) {
return
}
actionError.value = ''
isUnlinkingGoogle.value = true
try {
const updated = await $fetch(`/admin/api/members/${props.member.id}/google`, {
method: 'DELETE'
})
googleUnlinkModalOpen.value = false
emit('saved', updated)
showToast('success', 'Google 연동을 해제했습니다.')
} catch (error) {
const message = error?.data?.message || 'Google 연동 해제에 실패했습니다.'
actionError.value = message
showToast('error', message)
} finally {
isUnlinkingGoogle.value = false
}
}
/** /**
* 회원 기본 정보를 저장한다. * 회원 기본 정보를 저장한다.
* @returns {Promise<Object>} 저장된 회원 * @returns {Promise<Object>} 저장된 회원
@@ -833,6 +892,38 @@ watch(() => props.member, () => {
</label> </label>
</form> </form>
<section v-if="!isNewMember" class="admin-member-form__google">
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">Google 로그인</h2>
<div class="admin-member-form__google-card rounded-xl border border-line bg-white px-5 py-5 md:px-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<p class="text-sm font-semibold text-[#15171a]">
{{ googleLinked ? `${googleAccountText} 연결됨` : '연결된 Google 계정 없음' }}
</p>
<p v-if="googleLinked" class="mt-1 text-sm text-[#8a95a5]">
연결일 {{ formatDate(member?.googleLinkedAt) }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<span
class="admin-member-form__google-badge inline-flex h-9 items-center rounded-full px-4 text-sm font-semibold"
:class="googleLinked ? 'bg-[#eef1f4] text-[#8a95a5]' : 'bg-[#f7f8fa] text-[#b3bbc5]'"
>
{{ googleLinked ? '연결 됨' : '미연결' }}
</span>
<button
v-if="canUnlinkGoogle"
class="admin-member-form__google-unlink h-9 rounded-md border border-[#ffd2d6] px-4 text-sm font-semibold text-[#d21a26] transition hover:bg-[#fff1f2]"
type="button"
@click="openGoogleUnlinkModal"
>
연동 해제
</button>
</div>
</div>
</div>
</section>
<section v-if="!isNewMember" class="admin-member-form__activity"> <section v-if="!isNewMember" class="admin-member-form__activity">
<h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2> <h2 class="admin-member-form__section-title mb-4 text-xs font-semibold uppercase tracking-[0.04em] text-[#15171a]">활동</h2>
<div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6"> <div class="admin-member-form__activity-card rounded-xl border border-line bg-white px-5 md:px-6">
@@ -936,6 +1027,35 @@ watch(() => props.member, () => {
</div> </div>
</Teleport> </Teleport>
<Teleport to="body">
<div v-if="googleUnlinkModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">
<header class="flex items-center justify-between border-b border-line px-6 py-5">
<h2 class="text-xl font-semibold">Google 연동 해제</h2>
<button class="grid h-8 w-8 place-items-center rounded-md text-[#657080] hover:bg-[#f3f5f7]" type="button" aria-label="닫기" @click="closeGoogleUnlinkModal">
<svg class="h-4 w-4" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" fill="currentColor" />
</svg>
</button>
</header>
<div class="grid gap-4 px-6 py-5">
<p class="text-sm leading-6 text-[#657080]">
<strong class="text-[#15171a]">{{ form.username || form.email }}</strong> 님의 Google 로그인 연동을 해제합니다. 해제 후에는 이 Google 계정으로 로그인할 수 없습니다.
</p>
<p v-if="actionError" class="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ actionError }}</p>
</div>
<footer class="flex justify-end gap-2 border-t border-line px-6 py-4">
<button class="h-10 rounded-md border border-line bg-white px-4 text-sm font-semibold text-[#3f4650]" type="button" @click="closeGoogleUnlinkModal">
취소
</button>
<button class="h-10 rounded-md bg-[#d21a26] px-4 text-sm font-semibold text-white disabled:opacity-50" type="button" :disabled="isUnlinkingGoogle" @click="unlinkGoogleIdentity">
{{ isUnlinkingGoogle ? '해제 중' : '연동 해제' }}
</button>
</footer>
</section>
</div>
</Teleport>
<Teleport to="body"> <Teleport to="body">
<div v-if="ownershipTransferModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10"> <div v-if="ownershipTransferModalOpen" class="admin-member-form__modal fixed inset-0 z-50 flex items-start justify-center bg-black/35 px-4 pt-10">
<section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]"> <section class="admin-member-form__modal-panel w-full max-w-[520px] rounded-xl bg-white text-[#15171a] shadow-[0_22px_70px_rgba(15,23,42,0.25)]">

View File

@@ -1,5 +1,10 @@
# 업데이트 요약 # 업데이트 요약
## v1.12.2
- Google 계정 연결 후에는 회원 화면에서 읽기 전용 연결 상태만 표시하도록 정리했다.
- 소유자는 관리자 멤버 상세에서 회원의 Google 로그인 연동 상태를 확인하고 해제할 수 있게 했다.
## v1.12.1 ## v1.12.1
- Google 계정 로그인과 기존 계정 연결 흐름을 추가했다. - Google 계정 로그인과 기존 계정 연결 흐름을 추가했다.

View File

@@ -1,7 +1,7 @@
# 배포 가이드 # 배포 가이드
> v1.12.1은 Google 로그인/계정 연동을 추가한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다. > v1.12.2는 Google 로그인/계정 연동과 소유자 전용 연동 해제 관리를 추가한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다.
> 로컬 기준 v1.12.1에서 `npm run lint`, `npm test`, `npm run build`, `npm audit` 검증을 통과했다. > 로컬 기준 v1.12.2에서 `npm run lint`, `npm test`, `npm run build`, `npm audit` 검증을 통과했다.
## 빌드 유형 ## 빌드 유형
@@ -14,7 +14,7 @@
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다. > `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
## v1.12.1 배포 전 검증 ## v1.12.2 배포 전 검증
Playwright API 테스트도 브라우저 바이너리는 필요하지 않지만 `verify:ci`에는 Chromium E2E가 포함된다. 최초 1회 의존성과 Chromium을 설치하고 Docker daemon이 실행 중인지 확인한다. Playwright API 테스트도 브라우저 바이너리는 필요하지 않지만 `verify:ci`에는 Chromium E2E가 포함된다. 최초 1회 의존성과 Chromium을 설치하고 Docker daemon이 실행 중인지 확인한다.

View File

@@ -1,3 +1,7 @@
## 2026-07-29 v1.12.2 — Google 계정 재연결은 소유자 관리로 제한한다
회원 본인이 Google 계정을 다시 연결할 수 있으면 실수로 다른 Google 계정에 바꾸거나 관리자 계정의 로그인 경로가 예상과 달라질 수 있다. 따라서 회원 설정 화면은 연결 전에는 연결 버튼만, 연결 후에는 읽기 전용 상태만 표시한다. 예외적으로 계정 이전이나 잘못된 연결을 정리해야 할 수 있으므로, 해제 권한은 관리자 멤버 상세 화면의 소유자 전용 작업으로 둔다.
## 2026-07-29 v1.12.1 — Google 로그인은 기존 계정 연결 방식으로 추가한다 ## 2026-07-29 v1.12.1 — Google 로그인은 기존 계정 연결 방식으로 추가한다
Google 계정은 사이트 계정을 대체하지 않고 외부 식별자(`sub`)로 연결한다. 일반 회원은 Google 로그인 시 연결된 계정이 있으면 그 계정으로 로그인하고, 없으면 이메일 중복이 없을 때 사이트 전용 회원을 새로 만든다. 관리자는 보안상 자동 생성이나 이메일 매칭을 허용하지 않고, 기존 관리자 계정으로 먼저 로그인한 뒤 설정 화면에서 직접 Google 계정을 연결한 경우에만 관리자 Google 로그인을 허용한다. Google 계정은 사이트 계정을 대체하지 않고 외부 식별자(`sub`)로 연결한다. 일반 회원은 Google 로그인 시 연결된 계정이 있으면 그 계정으로 로그인하고, 없으면 이메일 중복이 없을 때 사이트 전용 회원을 새로 만든다. 관리자는 보안상 자동 생성이나 이메일 매칭을 허용하지 않고, 기존 관리자 계정으로 먼저 로그인한 뒤 설정 화면에서 직접 Google 계정을 연결한 경우에만 관리자 Google 로그인을 허용한다.

View File

@@ -214,6 +214,7 @@
| db/migrations/067_users_session_version.sql | 비밀번호 변경 후 기존 로그인 세션 무효화용 `users.session_version` 컬럼 추가 | | db/migrations/067_users_session_version.sql | 비밀번호 변경 후 기존 로그인 세션 무효화용 `users.session_version` 컬럼 추가 |
| db/migrations/068_csp_violation_aggregates.sql | CSP 정책 버전·문서 경로·지시어·차단 origin·브라우저 계열 위반 집계 테이블 추가 | | db/migrations/068_csp_violation_aggregates.sql | CSP 정책 버전·문서 경로·지시어·차단 origin·브라우저 계열 위반 집계 테이블 추가 |
| db/migrations/069_media_catalog.sql | `media_metadata` 파일 카탈로그 필드·상태·관계, `media_references`, `media_catalog_state` 추가 | | db/migrations/069_media_catalog.sql | `media_metadata` 파일 카탈로그 필드·상태·관계, `media_references`, `media_catalog_state` 추가 |
| db/migrations/071_users_google_oauth.sql | 회원 Google 로그인 연동 식별자·이메일·프로필·연결 시각 컬럼 추가 |
## 서버 미들웨어 ## 서버 미들웨어
@@ -290,7 +291,7 @@
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함, 후보 없는 슬래시 입력은 일반 줄바꿈·커서 이동 허용)·미디어 모달 선택 결과 본문 삽입(이미지·갤러리·비디오·오디오·파일), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 슬래시 메뉴 위치·슬래시 명령 실행·공통 포커스·블록 패널 옵션 적용·라이브 미리보기 이벤트 적용 composable 연결, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 | | components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함, 후보 없는 슬래시 입력은 일반 줄바꿈·커서 이동 허용)·미디어 모달 선택 결과 본문 삽입(이미지·갤러리·비디오·오디오·파일), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 소스 모드 입력 직후 textarea 최신 본문 기준 커서 논리 줄·블록 컨텍스트 갱신, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 슬래시 메뉴 위치·슬래시 명령 실행·공통 포커스·블록 패널 옵션 적용·라이브 미리보기 이벤트 적용 composable 연결, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 현재 블록 범위 안에서만 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연·미디어 명령 원문 줄 재반영 방지 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘 ON/OFF 토글·비활성 아이콘 영역·배경색·코드 언어 셀렉트·줄번호 ON/OFF 토글·토글), 갤러리 선택 이미지 강조 | | components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘 ON/OFF 토글·비활성 아이콘 영역·배경색·코드 언어 셀렉트·줄번호 ON/OFF 토글·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) | | components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·Google 로그인 연동 상태·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 소유자 전용 Google 연동 해제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) | | components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
| components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) | | components/admin/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
| components/admin/AdminModalFrame.vue | 관리자 공통 일반 모달 프레임(오버레이, dialog 접근성 속성, 기본 패널 스타일, 바깥 클릭 닫기) | | components/admin/AdminModalFrame.vue | 관리자 공통 일반 모달 프레임(오버레이, dialog 접근성 속성, 기본 패널 스타일, 바깥 클릭 닫기) |
@@ -379,7 +380,7 @@
| 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 | 관리자 멤버 상세/수정(회원 요약, 읽기 전용 진입 후 수정하기 전환, 변경 시 저장 버튼 활성, 저장 버튼 기반 멤버 등급 변경, 권한 변경 불가 상황 셀렉트 잠금/텍스트 표시, Google 로그인 연동 상태와 소유자 전용 해제, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) |
## 공개 페이지 ## 공개 페이지
@@ -396,7 +397,7 @@
| pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, 모바일 OTP 입력창 높이 보정, `POST /api/auth/email-otp/request` | | pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, 모바일 OTP 입력창 높이 보정, `POST /api/auth/email-otp/request` |
| pages/signin.vue | 로그인, Google 로그인, `/forgot-password` 링크 | | pages/signin.vue | 로그인, Google 로그인, `/forgot-password` 링크 |
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) | | pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
| pages/settings/index.vue | 회원 설정(상단 프로필 요약, 가입 정보, 댓글 참여도, 하단 프로필 입력·이전 로그인 활동, Google 계정 연결 상태·연결 버튼, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) | | pages/settings/index.vue | 회원 설정(상단 프로필 요약, 가입 정보, 댓글 참여도, 하단 프로필 입력·이전 로그인 활동, Google 계정 미연결 시 연결 버튼·연결 후 읽기 전용 상태 배지, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) |
## 서버 API ## 서버 API
@@ -500,6 +501,7 @@
| server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API(회원 전용 썸네일 교체·제거 시 메타 연결 분리) | | server/routes/admin/api/members/[id].put.js | 관리자 멤버 기본 정보 수정 API(회원 전용 썸네일 교체·제거 시 메타 연결 분리) |
| server/routes/admin/api/members/[id]/avatar.post.js | 관리자 멤버 썸네일 업로드 및 즉시 반영 API | | server/routes/admin/api/members/[id]/avatar.post.js | 관리자 멤버 썸네일 업로드 및 즉시 반영 API |
| server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API(`owner`/`admin`/`vip`/`member`, 관리자 상호 조작 차단, 마지막 소유자 보호) | | server/routes/admin/api/members/[id]/role.put.js | 관리자 멤버 권한 변경 API(`owner`/`admin`/`vip`/`member`, 관리자 상호 조작 차단, 마지막 소유자 보호) |
| server/routes/admin/api/members/[id]/google.delete.js | 소유자 전용 관리자 멤버 Google 로그인 연동 해제 API |
| server/routes/admin/api/members/[id]/ownership-transfer.post.js | 소유자 권한 이전 API(대상→소유자, 요청자→관리자) | | server/routes/admin/api/members/[id]/ownership-transfer.post.js | 소유자 권한 이전 API(대상→소유자, 요청자→관리자) |
| server/utils/content-schema.js | Zod 콘텐츠 스키마 | | server/utils/content-schema.js | Zod 콘텐츠 스키마 |
| server/utils/sample-content.js | 샘플 콘텐츠 저장소 | | server/utils/sample-content.js | 샘플 콘텐츠 저장소 |
@@ -524,7 +526,7 @@
| server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획·원본 자산 store 방식 스트리밍 ZIP 생성, PostgreSQL advisory lock 기반 다중 인스턴스 단일 worker 저장소 | | server/repositories/post-export-repository.js | 게시물 Export 작업·분할 파일 계획·원본 자산 store 방식 스트리밍 ZIP 생성, PostgreSQL advisory lock 기반 다중 인스턴스 단일 worker 저장소 |
| server/repositories/post-import-repository.js | 게시물 Export ZIP Import 저장소(frontmatter 제한 읽기·자산 엔트리 스트리밍·URL 재매핑·게시물 생성·Import job/item 상태 관리·작업 재개) | | server/repositories/post-import-repository.js | 게시물 Export ZIP Import 저장소(frontmatter 제한 읽기·자산 엔트리 스트리밍·URL 재매핑·게시물 생성·Import job/item 상태 관리·작업 재개) |
| server/plugins/post-import-worker.js | 대기·중단된 게시물 Import 작업을 주기적으로 백그라운드 실행 | | server/plugins/post-import-worker.js | 대기·중단된 게시물 Import 작업을 주기적으로 백그라운드 실행 |
| server/repositories/member-repository.js | 회원 조회/생성 저장소 | | server/repositories/member-repository.js | 회원 조회/생성 저장소, Google 로그인 연결·소유자 해제 처리 |
| server/repositories/comment-repository.js | 댓글 조회/생성/좋아요 저장소, 공개·멤버십 게시물 접근 조건 통합 | | server/repositories/comment-repository.js | 댓글 조회/생성/좋아요 저장소, 공개·멤버십 게시물 접근 조건 통합 |
| server/repositories/analytics-repository.js | 방문·게시물·페이지·유입 통계 집계·관리자 요약/날짜 상세 조회·방문자 해시 보관 정리 | | server/repositories/analytics-repository.js | 방문·게시물·페이지·유입 통계 집계·관리자 요약/날짜 상세 조회·방문자 해시 보관 정리 |
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·관리자 세션 제외·IP 해시 분당 상한·기록 | | server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·관리자 세션 제외·IP 해시 분당 상한·기록 |

View File

@@ -216,7 +216,7 @@
- Google OAuth state는 `MEMBER_SESSION_SECRET` 기반 HMAC 서명과 10분 nonce 쿠키로 검증한다. redirect는 `/`로 시작하는 내부 경로만 허용한다. - Google OAuth state는 `MEMBER_SESSION_SECRET` 기반 HMAC 서명과 10분 nonce 쿠키로 검증한다. redirect는 `/`로 시작하는 내부 경로만 허용한다.
- 일반 회원 Google 로그인은 `google_sub`로 기존 회원을 찾고, 없으면 이메일 중복이 없을 때 새 회원을 생성한다. 이미 같은 이메일로 가입된 계정이 있으면 기존 계정 로그인 후 Google 계정 연결을 요청한다. - 일반 회원 Google 로그인은 `google_sub`로 기존 회원을 찾고, 없으면 이메일 중복이 없을 때 새 회원을 생성한다. 이미 같은 이메일로 가입된 계정이 있으면 기존 계정 로그인 후 Google 계정 연결을 요청한다.
- 관리자 Google 로그인은 이미 `google_sub`가 연결된 `owner` 또는 `admin` 계정만 허용한다. 관리자 계정은 먼저 기존 비밀번호 로그인으로 들어간 뒤 회원 설정 화면에서 Google 계정을 연결해야 한다. - 관리자 Google 로그인은 이미 `google_sub`가 연결된 `owner` 또는 `admin` 계정만 허용한다. 관리자 계정은 먼저 기존 비밀번호 로그인으로 들어간 뒤 회원 설정 화면에서 Google 계정을 연결해야 한다.
- 회원 설정 화면은 Google 계정 연결 상태를 표시하고 `/api/auth/google/start?mode=link`로 현재 로그인 계정에 Google 식별자를 연결한다. 같은 Google 계정이 다른 회원에 이미 연결되어 있으면 연결하지 않는다. - 회원 설정 화면은 Google 계정 연결 상태를 표시하고, 미연결 상태에서만 `/api/auth/google/start?mode=link`로 현재 로그인 계정에 Google 식별자를 연결한다. 연결 후 회원 본인은 다시 연결하거나 다른 Google 계정으로 교체할 수 없으며 읽기 전용 `연결 됨` 상태만 본다. 같은 Google 계정이 다른 회원에 이미 연결되어 있으면 연결하지 않는다.
### 레이아웃 파일 ### 레이아웃 파일
@@ -671,6 +671,7 @@ components/content/
- `POST /admin/api/auth/login` - 로그인. 회원 로그인과 분리된 계정·IP 실패 횟수 제한을 적용하며 한도를 넘으면 429를 반환한다. - `POST /admin/api/auth/login` - 로그인. 회원 로그인과 분리된 계정·IP 실패 횟수 제한을 적용하며 한도를 넘으면 429를 반환한다.
- `POST /admin/api/auth/logout` - 로그아웃 - `POST /admin/api/auth/logout` - 로그아웃
- `GET /admin/api/auth/me` - 현재 관리자 세션 조회. `optional=1` 쿼리를 붙이면 비로그인 상태에서 401 대신 `null`을 반환하며, 관리자 라우트 보호용 기본 호출은 401을 유지한다. - `GET /admin/api/auth/me` - 현재 관리자 세션 조회. `optional=1` 쿼리를 붙이면 비로그인 상태에서 401 대신 `null`을 반환하며, 관리자 라우트 보호용 기본 호출은 401을 유지한다.
- `DELETE /admin/api/members/:id/google` - 소유자 권한으로 해당 회원의 Google 로그인 연동을 해제한다. 해제 후 회원 설정 화면에서는 미연결 상태가 되며, 회원이 필요하면 다시 Google 계정을 연결할 수 있다.
- `GET /admin/api/analytics/summary?period=weekly&days=56` - 통계 요약(오늘 및 최근 7/30/180/365일 방문, 누적 방문자·누적 조회수·공개 게시물 수, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, `collectedSince`, 선택 집계 단위 `trends`). `period`는 `daily`, `weekly`, `monthly`, `yearly`를 사용하며 대시보드는 일일·주간·월간·년간으로 전환한다. - `GET /admin/api/analytics/summary?period=weekly&days=56` - 통계 요약(오늘 및 최근 7/30/180/365일 방문, 누적 방문자·누적 조회수·공개 게시물 수, 30일 조회, 현재 접속자, 평균 체류, 50% 스크롤 도달, `collectedSince`, 선택 집계 단위 `trends`). `period`는 `daily`, `weekly`, `monthly`, `yearly`를 사용하며 대시보드는 일일·주간·월간·년간으로 전환한다.
- `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·최근 30일 월간 조회·작성일·읽음·평균 체류·스크롤 구간) - `GET /admin/api/analytics/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·최근 30일 월간 조회·작성일·읽음·평균 체류·스크롤 구간)
- `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간) - `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간)

View File

@@ -1,5 +1,12 @@
# 업데이트 이력 # 업데이트 이력
## v1.12.2
- 회원 설정 화면의 Google 계정 연결 완료 상태를 읽기 전용 배지로 변경.
- 회원 설정 화면에서 Google 계정 다시 연결 버튼과 불필요한 안내 문구 제거.
- 관리자 멤버 상세 화면에 Google 로그인 연동 상태 표시 추가.
- 소유자 권한으로 관리자 멤버 상세에서 Google 로그인 연동을 해제하는 API와 확인 모달 추가.
## v1.12.1 ## v1.12.1
- Google 계정 로그인과 기존 계정 Google 연동 흐름 추가. - Google 계정 로그인과 기존 계정 Google 연동 흐름 추가.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "sori.studio", "name": "sori.studio",
"version": "1.12.1", "version": "1.12.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sori.studio", "name": "sori.studio",
"version": "1.12.1", "version": "1.12.2",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",

View File

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

View File

@@ -147,6 +147,10 @@ const loadProfile = async () => {
* @returns {void} * @returns {void}
*/ */
const startGoogleLink = () => { const startGoogleLink = () => {
if (googleLinked.value) {
return
}
if (import.meta.client) { if (import.meta.client) {
window.location.href = '/api/auth/google/start?mode=link&redirect=/settings' window.location.href = '/api/auth/google/start?mode=link&redirect=/settings'
} }
@@ -161,6 +165,8 @@ watch(
loadProfile() loadProfile()
} else if (value === 'already-linked') { } else if (value === 'already-linked') {
googleMessage.value = '이미 다른 계정에 연결된 Google 계정입니다.' googleMessage.value = '이미 다른 계정에 연결된 Google 계정입니다.'
} else if (value === 'already-connected') {
googleMessage.value = '이미 Google 계정이 연결되어 있습니다.'
} else if (value === 'email-unverified') { } else if (value === 'email-unverified') {
googleMessage.value = 'Google 계정의 이메일 확인 상태를 확인할 수 없습니다.' googleMessage.value = 'Google 계정의 이메일 확인 상태를 확인할 수 없습니다.'
} else if (value === 'session-mismatch') { } else if (value === 'session-mismatch') {
@@ -557,12 +563,12 @@ onMounted(loadProfile)
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm font-semibold">{{ googleLinkedText }}</p> <p class="text-sm font-semibold">{{ googleLinkedText }}</p>
<p class="mt-1 text-sm site-muted">
연결하면 다음 로그인부터 Google 계정으로 로그인할 수 있습니다.
</p>
</div> </div>
<button class="settings-page__google-link-button h-10 rounded-[8px] border border-[var(--site-line)] px-4 text-sm font-semibold transition hover:bg-[var(--site-panel)]" type="button" @click="startGoogleLink"> <span v-if="googleLinked" class="settings-page__google-linked-badge inline-flex h-9 items-center rounded-full bg-[var(--site-panel)] px-4 text-sm font-semibold site-muted">
{{ googleLinked ? '다시 연결' : 'Google 계정 연결' }} 연결 됨
</span>
<button v-else class="settings-page__google-link-button h-10 rounded-[8px] border border-[var(--site-line)] px-4 text-sm font-semibold transition hover:bg-[var(--site-panel)]" type="button" @click="startGoogleLink">
Google 계정 연결
</button> </button>
</div> </div>
<p v-if="googleMessage" class="mt-4 text-sm site-muted" aria-live="polite"> <p v-if="googleMessage" class="mt-4 text-sm site-muted" aria-live="polite">

View File

@@ -4,6 +4,7 @@ import { createError, getQuery, sendRedirect } from 'h3'
import { import {
createGoogleUser, createGoogleUser,
getUserByEmail, getUserByEmail,
getUserById,
getUserByGoogleSub, getUserByGoogleSub,
isPrivilegedMemberSession, isPrivilegedMemberSession,
linkGoogleIdentityToUser, linkGoogleIdentityToUser,
@@ -123,6 +124,17 @@ export default defineEventHandler(async (event) => {
return sendRedirect(event, appendGoogleStatus('/signin', 'session-mismatch'), 302) return sendRedirect(event, appendGoogleStatus('/signin', 'session-mismatch'), 302)
} }
const currentUser = await getUserById(session.userId)
if (!currentUser) {
clearMemberSession(event)
clearAdminSession(event)
return sendRedirect(event, appendGoogleStatus('/signin', 'session-mismatch'), 302)
}
if (currentUser.googleLinkedAt) {
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'already-connected'), 302)
}
if (linkedUser && String(linkedUser.id) !== String(session.userId)) { if (linkedUser && String(linkedUser.id) !== String(session.userId)) {
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'already-linked'), 302) return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'already-linked'), 302)
} }

View File

@@ -115,6 +115,10 @@ const mapAdminMemberRow = (row) => {
lastSeenIp: row.lastSeenIp || '', lastSeenIp: row.lastSeenIp || '',
previousLastSeenAt, previousLastSeenAt,
previousLastSeenIp: row.previousLastSeenIp || '', previousLastSeenIp: row.previousLastSeenIp || '',
googleEmail: row.googleEmail || '',
googleName: row.googleName || '',
googleAvatarUrl: row.googleAvatarUrl || '',
googleLinkedAt: row.googleLinkedAt ? row.googleLinkedAt.toISOString() : null,
commentCount: Number(row.commentCount || 0), commentCount: Number(row.commentCount || 0),
activityStatus: isActive ? '활성' : '비활성', activityStatus: isActive ? '활성' : '비활성',
role: getMemberRoleLabel(roleCode) role: getMemberRoleLabel(roleCode)
@@ -826,6 +830,10 @@ export const listMembersForAdmin = async () => {
users.member_note AS "note", users.member_note AS "note",
users.is_admin AS "isAdmin", users.is_admin AS "isAdmin",
users.user_role AS "roleCode", users.user_role AS "roleCode",
users.google_email AS "googleEmail",
users.google_name AS "googleName",
users.google_avatar_url AS "googleAvatarUrl",
users.google_linked_at AS "googleLinkedAt",
users.created_at AS "createdAt", users.created_at AS "createdAt",
users.updated_at AS "updatedAt", users.updated_at AS "updatedAt",
users.last_seen_at AS "lastSeenAt", users.last_seen_at AS "lastSeenAt",
@@ -859,6 +867,10 @@ export const getMemberForAdmin = async (memberId) => {
users.member_note AS "note", users.member_note AS "note",
users.is_admin AS "isAdmin", users.is_admin AS "isAdmin",
users.user_role AS "roleCode", users.user_role AS "roleCode",
users.google_email AS "googleEmail",
users.google_name AS "googleName",
users.google_avatar_url AS "googleAvatarUrl",
users.google_linked_at AS "googleLinkedAt",
users.created_at AS "createdAt", users.created_at AS "createdAt",
users.updated_at AS "updatedAt", users.updated_at AS "updatedAt",
users.last_seen_at AS "lastSeenAt", users.last_seen_at AS "lastSeenAt",
@@ -946,6 +958,45 @@ export const updateMemberByAdmin = async (input) => {
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
} }
/**
* 소유자 권한으로 회원 Google 계정 연결을 해제한다.
* @param {{ actorUserId: string, memberId: string }} input - 해제 정보
* @returns {Promise<Object | null>} 수정된 회원
*/
export const unlinkGoogleIdentityByOwner = async (input) => {
const actor = await getMemberForAdmin(input.actorUserId)
if (!actor || actor.roleCode !== MEMBER_ROLE.OWNER) {
throw createError({
statusCode: 403,
message: 'Google 연동 해제는 소유자만 할 수 있습니다.'
})
}
const target = await getMemberForAdmin(input.memberId)
if (!target) {
throw createError({
statusCode: 404,
message: '회원을 찾을 수 없습니다.'
})
}
const sql = requireSql()
const rows = await sql`
UPDATE users
SET
google_sub = NULL,
google_email = NULL,
google_name = NULL,
google_avatar_url = NULL,
google_linked_at = NULL,
updated_at = now()
WHERE id = ${input.memberId}
RETURNING id
`
return rows?.[0] ? getMemberForAdmin(rows[0].id) : null
}
/** /**
* 관리자 화면에서 회원 썸네일만 수정한다. * 관리자 화면에서 회원 썸네일만 수정한다.
* @param {{ actorUserId: string, memberId: string, avatarUrl: string }} input - 수정 값 * @param {{ actorUserId: string, memberId: string, avatarUrl: string }} input - 수정 값

View File

@@ -0,0 +1,34 @@
import { createError, getRouterParam } from 'h3'
import { requireAdminSession } from '../../../../../utils/admin-auth'
import { unlinkGoogleIdentityByOwner } from '../../../../../repositories/member-repository'
/**
* 관리자 회원 Google 계정 연결 해제 API
* @param {import('h3').H3Event} event - 요청 이벤트
* @returns {Promise<Object>} 수정된 회원
*/
export default defineEventHandler(async (event) => {
const session = requireAdminSession(event)
const memberId = String(getRouterParam(event, 'id') || '')
if (!memberId) {
throw createError({
statusCode: 400,
message: '회원 ID가 필요합니다.'
})
}
const updated = await unlinkGoogleIdentityByOwner({
actorUserId: session.userId,
memberId
})
if (!updated) {
throw createError({
statusCode: 500,
message: 'Google 연동 해제에 실패했습니다.'
})
}
return updated
})