From 3c431576b73b5a86e64be7fda7a12279b3569ef7 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 29 Jul 2026 14:18:40 +0900 Subject: [PATCH] =?UTF-8?q?Google=20=EA=B3=84=EC=A0=95=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EA=B4=80=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminMemberForm.vue | 120 ++++++++++++++++++ docs/changelog.md | 5 + docs/deploy.md | 6 +- docs/history.md | 4 + docs/map.md | 10 +- docs/spec.md | 3 +- docs/update.md | 7 + package-lock.json | 4 +- package.json | 2 +- pages/settings/index.vue | 16 ++- server/api/auth/google/callback.get.js | 12 ++ server/repositories/member-repository.js | 51 ++++++++ .../admin/api/members/[id]/google.delete.js | 34 +++++ 13 files changed, 258 insertions(+), 16 deletions(-) create mode 100644 server/routes/admin/api/members/[id]/google.delete.js diff --git a/components/admin/AdminMemberForm.vue b/components/admin/AdminMemberForm.vue index d72c94e..67aeb92 100644 --- a/components/admin/AdminMemberForm.vue +++ b/components/admin/AdminMemberForm.vue @@ -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} @@ -508,6 +539,34 @@ const deleteMember = async () => { } } +/** + * 소유자 권한으로 회원 Google 연동을 해제한다. + * @returns {Promise} + */ +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} 저장된 회원 @@ -833,6 +892,38 @@ watch(() => props.member, () => { +
+

Google 로그인

+
+
+
+

+ {{ googleLinked ? `${googleAccountText} 연결됨` : '연결된 Google 계정 없음' }} +

+

+ 연결일 {{ formatDate(member?.googleLinkedAt) }} +

+
+
+ + {{ googleLinked ? '연결 됨' : '미연결' }} + + +
+
+
+
+

활동

@@ -936,6 +1027,35 @@ watch(() => props.member, () => {
+ +
+
+
+

Google 연동 해제

+ +
+
+

+ {{ form.username || form.email }} 님의 Google 로그인 연동을 해제합니다. 해제 후에는 이 Google 계정으로 로그인할 수 없습니다. +

+

{{ actionError }}

+
+
+ + +
+
+
+
+
diff --git a/docs/changelog.md b/docs/changelog.md index 691527e..6cb97df 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.12.2 + +- Google 계정 연결 후에는 회원 화면에서 읽기 전용 연결 상태만 표시하도록 정리했다. +- 소유자는 관리자 멤버 상세에서 회원의 Google 로그인 연동 상태를 확인하고 해제할 수 있게 했다. + ## v1.12.1 - Google 계정 로그인과 기존 계정 연결 흐름을 추가했다. diff --git a/docs/deploy.md b/docs/deploy.md index 6cec8e8..c100d90 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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이 실행 중인지 확인한다. diff --git a/docs/history.md b/docs/history.md index 6e98646..144be11 100644 --- a/docs/history.md +++ b/docs/history.md @@ -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 로그인을 허용한다. diff --git a/docs/map.md b/docs/map.md index 5f067c1..2ecdb8c 100644 --- a/docs/map.md +++ b/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 해시 분당 상한·기록 | diff --git a/docs/spec.md b/docs/spec.md index 66f67ed..eb1de12 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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` - 기간 내 인기 페이지(조회·방문자·평균 체류·스크롤 구간) diff --git a/docs/update.md b/docs/update.md index c0bbd56..f623dd9 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,12 @@ # 업데이트 이력 +## v1.12.2 + +- 회원 설정 화면의 Google 계정 연결 완료 상태를 읽기 전용 배지로 변경. +- 회원 설정 화면에서 Google 계정 다시 연결 버튼과 불필요한 안내 문구 제거. +- 관리자 멤버 상세 화면에 Google 로그인 연동 상태 표시 추가. +- 소유자 권한으로 관리자 멤버 상세에서 Google 로그인 연동을 해제하는 API와 확인 모달 추가. + ## v1.12.1 - Google 계정 로그인과 기존 계정 Google 연동 흐름 추가. diff --git a/package-lock.json b/package-lock.json index e4564c6..7537b15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e6b59a1..794533f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.12.1", + "version": "1.12.2", "private": true, "type": "module", "imports": { diff --git a/pages/settings/index.vue b/pages/settings/index.vue index 6e531f9..5ccafbc 100644 --- a/pages/settings/index.vue +++ b/pages/settings/index.vue @@ -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)

{{ googleLinkedText }}

-

- 연결하면 다음 로그인부터 Google 계정으로 로그인할 수 있습니다. -

-

diff --git a/server/api/auth/google/callback.get.js b/server/api/auth/google/callback.get.js index c6b2ae7..285979d 100644 --- a/server/api/auth/google/callback.get.js +++ b/server/api/auth/google/callback.get.js @@ -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) } diff --git a/server/repositories/member-repository.js b/server/repositories/member-repository.js index fdf2815..743eb30 100644 --- a/server/repositories/member-repository.js +++ b/server/repositories/member-repository.js @@ -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} 수정된 회원 + */ +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 - 수정 값 diff --git a/server/routes/admin/api/members/[id]/google.delete.js b/server/routes/admin/api/members/[id]/google.delete.js new file mode 100644 index 0000000..b185737 --- /dev/null +++ b/server/routes/admin/api/members/[id]/google.delete.js @@ -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} 수정된 회원 + */ +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 +})