Google 계정 연동 관리 개선
This commit is contained in:
@@ -30,8 +30,10 @@ const isEditingMember = ref(props.mode === 'new')
|
||||
const actionMenuOpen = ref(false)
|
||||
const passwordModalOpen = ref(false)
|
||||
const deleteModalOpen = ref(false)
|
||||
const googleUnlinkModalOpen = ref(false)
|
||||
const isUpdatingPassword = ref(false)
|
||||
const isDeletingMember = ref(false)
|
||||
const isUnlinkingGoogle = ref(false)
|
||||
const actionError = ref('')
|
||||
|
||||
const roleOptions = [
|
||||
@@ -109,6 +111,7 @@ const isFormEditable = computed(() => {
|
||||
const currentRoleLabel = computed(() => roleOptions.find((option) => option.value === form.roleCode)?.label || '멤버')
|
||||
const currentAdminRoleCode = computed(() => adminSession.value?.roleCode || '')
|
||||
const isCurrentAdminPrivileged = computed(() => ['owner', 'admin'].includes(currentAdminRoleCode.value))
|
||||
const isCurrentAdminOwner = computed(() => currentAdminRoleCode.value === 'owner')
|
||||
const isEditingSelf = computed(() => Boolean(props.member?.id && 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 canManageMemberActions = computed(() => canEditMemberProfile.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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>}
|
||||
@@ -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>} 저장된 회원
|
||||
@@ -833,6 +892,38 @@ watch(() => props.member, () => {
|
||||
</label>
|
||||
</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">
|
||||
<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">
|
||||
@@ -936,6 +1027,35 @@ watch(() => props.member, () => {
|
||||
</div>
|
||||
</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">
|
||||
<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)]">
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.12.2
|
||||
|
||||
- Google 계정 연결 후에는 회원 화면에서 읽기 전용 연결 상태만 표시하도록 정리했다.
|
||||
- 소유자는 관리자 멤버 상세에서 회원의 Google 로그인 연동 상태를 확인하고 해제할 수 있게 했다.
|
||||
|
||||
## v1.12.1
|
||||
|
||||
- Google 계정 로그인과 기존 계정 연결 흐름을 추가했다.
|
||||
|
||||
@@ -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.1에서 `npm run lint`, `npm test`, `npm run build`, `npm audit` 검증을 통과했다.
|
||||
> 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.2에서 `npm run lint`, `npm test`, `npm run build`, `npm audit` 검증을 통과했다.
|
||||
|
||||
## 빌드 유형
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
> `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다.
|
||||
|
||||
## v1.12.1 배포 전 검증
|
||||
## v1.12.2 배포 전 검증
|
||||
|
||||
Playwright API 테스트도 브라우저 바이너리는 필요하지 않지만 `verify:ci`에는 Chromium E2E가 포함된다. 최초 1회 의존성과 Chromium을 설치하고 Docker daemon이 실행 중인지 확인한다.
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
## 2026-07-29 v1.12.2 — Google 계정 재연결은 소유자 관리로 제한한다
|
||||
|
||||
회원 본인이 Google 계정을 다시 연결할 수 있으면 실수로 다른 Google 계정에 바꾸거나 관리자 계정의 로그인 경로가 예상과 달라질 수 있다. 따라서 회원 설정 화면은 연결 전에는 연결 버튼만, 연결 후에는 읽기 전용 상태만 표시한다. 예외적으로 계정 이전이나 잘못된 연결을 정리해야 할 수 있으므로, 해제 권한은 관리자 멤버 상세 화면의 소유자 전용 작업으로 둔다.
|
||||
|
||||
## 2026-07-29 v1.12.1 — Google 로그인은 기존 계정 연결 방식으로 추가한다
|
||||
|
||||
Google 계정은 사이트 계정을 대체하지 않고 외부 식별자(`sub`)로 연결한다. 일반 회원은 Google 로그인 시 연결된 계정이 있으면 그 계정으로 로그인하고, 없으면 이메일 중복이 없을 때 사이트 전용 회원을 새로 만든다. 관리자는 보안상 자동 생성이나 이메일 매칭을 허용하지 않고, 기존 관리자 계정으로 먼저 로그인한 뒤 설정 화면에서 직접 Google 계정을 연결한 경우에만 관리자 Google 로그인을 허용한다.
|
||||
|
||||
10
docs/map.md
10
docs/map.md
@@ -214,6 +214,7 @@
|
||||
| db/migrations/067_users_session_version.sql | 비밀번호 변경 후 기존 로그인 세션 무효화용 `users.session_version` 컬럼 추가 |
|
||||
| db/migrations/068_csp_violation_aggregates.sql | CSP 정책 버전·문서 경로·지시어·차단 origin·브라우저 계열 위반 집계 테이블 추가 |
|
||||
| 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/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘 ON/OFF 토글·비활성 아이콘 영역·배경색·코드 언어 셀렉트·줄번호 ON/OFF 토글·토글), 갤러리 선택 이미지 강조 |
|
||||
| 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/AdminConfirmModal.vue | 관리자 공통 확인 모달(제목·본문 슬롯·확인/취소 버튼, primary/danger 톤) |
|
||||
| components/admin/AdminModalFrame.vue | 관리자 공통 일반 모달 프레임(오버레이, dialog 접근성 속성, 기본 패널 스타일, 바깥 클릭 닫기) |
|
||||
@@ -379,7 +380,7 @@
|
||||
| lib/signup-blocked-usernames.js | 가입 금지 닉네임 정리·매칭·안내 문구 |
|
||||
| pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 글 목록과 같은 테두리형 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 등급+비활성 상태, 가입일+최근 활동, IP, 댓글 수) |
|
||||
| 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/signin.vue | 로그인, Google 로그인, `/forgot-password` 링크 |
|
||||
| pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) |
|
||||
| pages/settings/index.vue | 회원 설정(상단 프로필 요약, 가입 정보, 댓글 참여도, 하단 프로필 입력·이전 로그인 활동, Google 계정 연결 상태·연결 버튼, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) |
|
||||
| pages/settings/index.vue | 회원 설정(상단 프로필 요약, 가입 정보, 댓글 참여도, 하단 프로필 입력·이전 로그인 활동, Google 계정 미연결 시 연결 버튼·연결 후 읽기 전용 상태 배지, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) |
|
||||
|
||||
## 서버 API
|
||||
|
||||
@@ -500,6 +501,7 @@
|
||||
| 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]/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/utils/content-schema.js | Zod 콘텐츠 스키마 |
|
||||
| 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-import-repository.js | 게시물 Export ZIP Import 저장소(frontmatter 제한 읽기·자산 엔트리 스트리밍·URL 재매핑·게시물 생성·Import job/item 상태 관리·작업 재개) |
|
||||
| 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/analytics-repository.js | 방문·게시물·페이지·유입 통계 집계·관리자 요약/날짜 상세 조회·방문자 해시 보관 정리 |
|
||||
| server/utils/analytics-pageview-input.js | `POST /api/analytics/pageview` 검증·관리자 세션 제외·IP 해시 분당 상한·기록 |
|
||||
|
||||
@@ -216,7 +216,7 @@
|
||||
- Google OAuth state는 `MEMBER_SESSION_SECRET` 기반 HMAC 서명과 10분 nonce 쿠키로 검증한다. redirect는 `/`로 시작하는 내부 경로만 허용한다.
|
||||
- 일반 회원 Google 로그인은 `google_sub`로 기존 회원을 찾고, 없으면 이메일 중복이 없을 때 새 회원을 생성한다. 이미 같은 이메일로 가입된 계정이 있으면 기존 계정 로그인 후 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/logout` - 로그아웃
|
||||
- `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/posts?days=30&limit=5` - 기간 내 인기 게시물(조회·최근 30일 월간 조회·작성일·읽음·평균 체류·스크롤 구간)
|
||||
- `GET /admin/api/analytics/pages?days=30&limit=5` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간)
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.12.2
|
||||
|
||||
- 회원 설정 화면의 Google 계정 연결 완료 상태를 읽기 전용 배지로 변경.
|
||||
- 회원 설정 화면에서 Google 계정 다시 연결 버튼과 불필요한 안내 문구 제거.
|
||||
- 관리자 멤버 상세 화면에 Google 로그인 연동 상태 표시 추가.
|
||||
- 소유자 권한으로 관리자 멤버 상세에서 Google 로그인 연동을 해제하는 API와 확인 모달 추가.
|
||||
|
||||
## v1.12.1
|
||||
|
||||
- Google 계정 로그인과 기존 계정 Google 연동 흐름 추가.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.1",
|
||||
"version": "1.12.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.1",
|
||||
"version": "1.12.2",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.12.1",
|
||||
"version": "1.12.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -147,6 +147,10 @@ const loadProfile = async () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const startGoogleLink = () => {
|
||||
if (googleLinked.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
window.location.href = '/api/auth/google/start?mode=link&redirect=/settings'
|
||||
}
|
||||
@@ -161,6 +165,8 @@ watch(
|
||||
loadProfile()
|
||||
} else if (value === 'already-linked') {
|
||||
googleMessage.value = '이미 다른 계정에 연결된 Google 계정입니다.'
|
||||
} else if (value === 'already-connected') {
|
||||
googleMessage.value = '이미 Google 계정이 연결되어 있습니다.'
|
||||
} else if (value === 'email-unverified') {
|
||||
googleMessage.value = 'Google 계정의 이메일 확인 상태를 확인할 수 없습니다.'
|
||||
} 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="min-w-0">
|
||||
<p class="text-sm font-semibold">{{ googleLinkedText }}</p>
|
||||
<p class="mt-1 text-sm site-muted">
|
||||
연결하면 다음 로그인부터 Google 계정으로 로그인할 수 있습니다.
|
||||
</p>
|
||||
</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">
|
||||
{{ googleLinked ? '다시 연결' : 'Google 계정 연결' }}
|
||||
<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">
|
||||
연결 됨
|
||||
</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>
|
||||
</div>
|
||||
<p v-if="googleMessage" class="mt-4 text-sm site-muted" aria-live="polite">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createError, getQuery, sendRedirect } from 'h3'
|
||||
import {
|
||||
createGoogleUser,
|
||||
getUserByEmail,
|
||||
getUserById,
|
||||
getUserByGoogleSub,
|
||||
isPrivilegedMemberSession,
|
||||
linkGoogleIdentityToUser,
|
||||
@@ -123,6 +124,17 @@ export default defineEventHandler(async (event) => {
|
||||
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)) {
|
||||
return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'already-linked'), 302)
|
||||
}
|
||||
|
||||
@@ -115,6 +115,10 @@ const mapAdminMemberRow = (row) => {
|
||||
lastSeenIp: row.lastSeenIp || '',
|
||||
previousLastSeenAt,
|
||||
previousLastSeenIp: row.previousLastSeenIp || '',
|
||||
googleEmail: row.googleEmail || '',
|
||||
googleName: row.googleName || '',
|
||||
googleAvatarUrl: row.googleAvatarUrl || '',
|
||||
googleLinkedAt: row.googleLinkedAt ? row.googleLinkedAt.toISOString() : null,
|
||||
commentCount: Number(row.commentCount || 0),
|
||||
activityStatus: isActive ? '활성' : '비활성',
|
||||
role: getMemberRoleLabel(roleCode)
|
||||
@@ -826,6 +830,10 @@ export const listMembersForAdmin = async () => {
|
||||
users.member_note AS "note",
|
||||
users.is_admin AS "isAdmin",
|
||||
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.updated_at AS "updatedAt",
|
||||
users.last_seen_at AS "lastSeenAt",
|
||||
@@ -859,6 +867,10 @@ export const getMemberForAdmin = async (memberId) => {
|
||||
users.member_note AS "note",
|
||||
users.is_admin AS "isAdmin",
|
||||
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.updated_at AS "updatedAt",
|
||||
users.last_seen_at AS "lastSeenAt",
|
||||
@@ -946,6 +958,45 @@ export const updateMemberByAdmin = async (input) => {
|
||||
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 - 수정 값
|
||||
|
||||
34
server/routes/admin/api/members/[id]/google.delete.js
Normal file
34
server/routes/admin/api/members/[id]/google.delete.js
Normal 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
|
||||
})
|
||||
Reference in New Issue
Block a user