diff --git a/components/admin/AdminMemberForm.vue b/components/admin/AdminMemberForm.vue index bd5889e..9db3600 100644 --- a/components/admin/AdminMemberForm.vue +++ b/components/admin/AdminMemberForm.vue @@ -10,7 +10,7 @@ const props = defineProps({ } }) -const emit = defineEmits(['saved']) +const emit = defineEmits(['saved', 'deleted']) const isNewMember = computed(() => props.mode === 'new') const saveMessage = ref('') @@ -19,6 +19,13 @@ const isSaving = ref(false) const savedMemberSnapshot = ref('') const avatarInputRef = ref(null) const isUploadingAvatar = ref(false) +const actionMenuOpen = ref(false) +const passwordModalOpen = ref(false) +const deleteModalOpen = ref(false) +const isUpdatingPassword = ref(false) +const isDeletingMember = ref(false) +const actionMessage = ref('') +const actionError = ref('') const form = reactive({ username: '', @@ -28,6 +35,15 @@ const form = reactive({ note: '' }) +const passwordForm = reactive({ + password: '', + passwordConfirm: '' +}) + +const deleteForm = reactive({ + confirmText: '' +}) + /** * 회원 폼 값을 현재 회원 정보로 동기화한다. * @returns {void} @@ -199,6 +215,134 @@ const removeAvatar = () => { form.avatarUrl = '' } +/** + * 회원 작업 메뉴를 토글한다. + * @returns {void} + */ +const toggleActionMenu = () => { + actionMenuOpen.value = !actionMenuOpen.value +} + +/** + * 회원 작업 메뉴를 닫는다. + * @returns {void} + */ +const closeActionMenu = () => { + actionMenuOpen.value = false +} + +/** + * 비밀번호 변경 모달을 연다. + * @returns {void} + */ +const openPasswordModal = () => { + passwordForm.password = '' + passwordForm.passwordConfirm = '' + actionMessage.value = '' + actionError.value = '' + passwordModalOpen.value = true + closeActionMenu() +} + +/** + * 회원 삭제 모달을 연다. + * @returns {void} + */ +const openDeleteModal = () => { + deleteForm.confirmText = '' + actionMessage.value = '' + actionError.value = '' + deleteModalOpen.value = true + closeActionMenu() +} + +/** + * 비밀번호 변경 모달을 닫는다. + * @returns {void} + */ +const closePasswordModal = () => { + if (isUpdatingPassword.value) { + return + } + + passwordModalOpen.value = false +} + +/** + * 회원 삭제 모달을 닫는다. + * @returns {void} + */ +const closeDeleteModal = () => { + if (isDeletingMember.value) { + return + } + + deleteModalOpen.value = false +} + +/** + * 관리자 권한으로 회원 비밀번호를 변경한다. + * @returns {Promise} + */ +const updateMemberPassword = async () => { + actionMessage.value = '' + actionError.value = '' + + if (!passwordForm.password || passwordForm.password.length < 8 || passwordForm.password.length > 32) { + actionError.value = '새 비밀번호는 8~32자로 입력해 주세요.' + return + } + + if (passwordForm.password !== passwordForm.passwordConfirm) { + actionError.value = '새 비밀번호와 확인 값이 일치하지 않습니다.' + return + } + + isUpdatingPassword.value = true + try { + await $fetch(`/admin/api/members/${props.member.id}/password`, { + method: 'PUT', + body: { + password: passwordForm.password + } + }) + passwordModalOpen.value = false + passwordForm.password = '' + passwordForm.passwordConfirm = '' + actionMessage.value = '비밀번호가 변경되었습니다.' + } catch (error) { + actionError.value = error?.data?.message || '비밀번호 변경에 실패했습니다.' + } finally { + isUpdatingPassword.value = false + } +} + +/** + * 관리자 권한으로 회원을 삭제한다. + * @returns {Promise} + */ +const deleteMember = async () => { + actionMessage.value = '' + actionError.value = '' + + if (deleteForm.confirmText !== form.email) { + actionError.value = '삭제하려면 회원 이메일을 정확히 입력해 주세요.' + return + } + + isDeletingMember.value = true + try { + await $fetch(`/admin/api/members/${props.member.id}`, { + method: 'DELETE' + }) + emit('deleted') + } catch (error) { + actionError.value = error?.data?.message || '회원 삭제에 실패했습니다.' + } finally { + isDeletingMember.value = false + } +} + /** * 회원 기본 정보를 저장한다. * @returns {Promise} @@ -258,7 +402,23 @@ watch(() => props.member, () => {
- +
+ + +
+
+ + +
+

+ 이메일 전송이 불가능한 상황을 대비해 관리자가 직접 새 비밀번호를 설정합니다. +

+ + +

{{ actionError }}

+
+
+ + +
+ + + + + +
+
+
+

멤버 삭제

+ +
+
+

+ 삭제하면 멤버 계정과 작성 댓글이 함께 삭제됩니다. 계속하려면 아래에 {{ form.email }} 을 입력해 주세요. +

+ +

{{ actionError }}

+
+
+ + +
+
+
+
diff --git a/db/migrations/021_add_member_previous_login.sql b/db/migrations/021_add_member_previous_login.sql new file mode 100644 index 0000000..ea3aa43 --- /dev/null +++ b/db/migrations/021_add_member_previous_login.sql @@ -0,0 +1,12 @@ +ALTER TABLE users + ADD COLUMN IF NOT EXISTS previous_last_seen_at TIMESTAMPTZ; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS previous_last_seen_ip TEXT NOT NULL DEFAULT ''; + +UPDATE users +SET + previous_last_seen_at = last_seen_at, + previous_last_seen_ip = last_seen_ip +WHERE previous_last_seen_at IS NULL + AND last_seen_at IS NOT NULL; diff --git a/docs/deploy.md b/docs/deploy.md index 2ace094..f9aa520 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -183,6 +183,7 @@ docker run -d -p 3000:3000 sori.studio:latest - 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용 - 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행 - 네비게이션 계층(`parent_id`, `is_folder`)은 `017_navigation_hierarchy.sql` 적용 후 저장 API가 정상 동작한다(미적용 시 `INSERT` 컬럼 불일치). +- 회원 마지막 로그인 표시(`previous_last_seen_at`, `previous_last_seen_ip`)는 `021_add_member_previous_login.sql` 적용 후 정상 동작한다. ### 개발/운영 DB 분리 검증 절차 diff --git a/docs/history.md b/docs/history.md index 3881fe2..7b73fe1 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-13 v0.0.114 + +### 멤버 계정 작업과 사용자 설정 화면 정리 + +관리자 하단의 `내 프로필`은 공개 사용자 설정으로 이동하면 관리자 컨텍스트가 끊기므로, 같은 계정이라도 관리자 멤버 편집 화면으로 보내 계정 관리 흐름을 유지한다. 비밀번호 직접 변경은 이메일 전송 장애 같은 비상 상황을 위한 관리자 전용 보조 수단으로 두고, 일반 사용자 설정에서는 비밀번호 변경과 회원 탈퇴를 상시 노출하지 않고 설정 메뉴의 모달 액션으로 낮췄다. 마지막 로그인은 현재 세션 조회 때마다 갱신하면 의미가 흐려지므로, 로그인 성공 시 기존 `last_seen_*` 값을 `previous_last_seen_*`로 옮긴 뒤 현재 로그인만 갱신한다. + ## 2026-05-13 v0.0.113 ### 멤버 필터와 썸네일 편집 방식 정리 diff --git a/docs/map.md b/docs/map.md index 39a944f..f9bbd23 100644 --- a/docs/map.md +++ b/docs/map.md @@ -8,7 +8,7 @@ |------|------| | layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 | | layouts/post.vue | 개별 게시물 — `default`와 동일 | -| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 | +| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 | | layouts/page.vue | 고정 페이지 전체 화면 | ## Composables @@ -60,7 +60,7 @@ | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 | | components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) | -| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 미저장 변경사항 이탈 확인) | +| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 미저장 변경사항 이탈 확인) | | components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) | ## 관리자 컴포저블 @@ -112,7 +112,7 @@ | pages/admin/settings/index.vue | 사이트 설정 + 관리자 프로필(썸네일/이름) + 관리자 비밀번호 변경 | | pages/admin/members/index.vue | 관리자 멤버 목록(Ghost형 테이블, 검색, 조건 필터, 멤버 추가 버튼, 닉네임+이메일, 가입일+최근 활동, IP, 댓글 수, 활동 상태 텍스트) | | pages/admin/members/new.vue | 관리자 멤버 추가(썸네일 URL, 이름, 이메일, 레이블, 관리자 노트) | -| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장) | +| pages/admin/members/[id].vue | 관리자 멤버 상세/수정(회원 요약, 가입 정보, 활동 요약, 기본 정보 저장, 삭제 후 목록 이동) | ## 공개 페이지 @@ -129,7 +129,7 @@ | pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` | | pages/signin.vue | 로그인, `/forgot-password` 링크 | | pages/forgot-password.vue | 비밀번호 찾기(Resend 설정 시 OTP 발송·`POST /api/auth/password-reset/confirm`) | -| pages/settings/index.vue | 회원 설정(썸네일 미리보기/이미지 변경·제거, 닉네임 변경/중복확인, 비밀번호 변경, 회원 탈퇴) | +| pages/settings/index.vue | 회원 설정(Ghost형 프로필 요약, 가입 정보, 댓글 참여도, 이전 로그인 활동, 썸네일 변경·제거, 닉네임 저장, 설정 메뉴 모달 비밀번호 변경·회원 탈퇴) | ## 서버 API diff --git a/docs/spec.md b/docs/spec.md index fd1225f..dc7a64b 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -551,12 +551,13 @@ components/content/ - 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다. - 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다. - `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다. -- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다. +- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. - 관리자 설정 화면에서 관리자 프로필(이름, 썸네일)과 관리자 비밀번호를 수정할 수 있다. +- 관리자 사이드바 하단 사용자 메뉴의 `내 프로필`은 `/admin/members/:id` 멤버 편집 화면으로 이동한다. - 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다. - 관리자 멤버 목록은 멤버 검색, 멤버 추가 버튼, 조건 필터를 제공한다. 필터는 이름·이메일·레이블 텍스트 조건, 활성/비활성 상태 조건, 최근 접속·가입일 날짜 조건을 지원하며 여러 조건은 AND로 적용한다. - 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다. -- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. +- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 썸네일은 URL 입력 대신 요약 영역의 원형 썸네일 hover 액션으로 등록·변경·제거한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. 기존 회원 상세의 설정 메뉴는 관리자 직접 비밀번호 변경(`PUT /admin/api/members/:id/password`)과 멤버 삭제(`DELETE /admin/api/members/:id`)를 제공한다. - 관리자 게시글 작성/수정과 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 해당 로컬 자동 저장본은 삭제한다. - `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다. - 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다. @@ -569,7 +570,9 @@ components/content/ - 회원 인증은 `users` 테이블의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다. - `/signin` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다. - 로그인 성공 시 httpOnly 세션 쿠키(`sori_member_session`)를 `/` 경로에 설정한다. -- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다. +- 회원 API(`POST /api/auth/signup`, `POST /api/auth/login`, `GET /api/auth/me`, `GET/PUT /api/auth/profile`, `POST /api/auth/logout`, `GET /api/auth/bootstrap-status`, `POST /api/auth/email-otp/request`, `POST /api/auth/password-reset/confirm`)로 세션·이메일 OTP를 관리한다. +- 회원 로그인 성공 시 `previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. `/api/auth/me`는 세션 확인만 수행하고 로그인 이력을 갱신하지 않는다. +- 사용자 설정 화면은 프로필, 가입 정보, 댓글 참여도, 활동 정보를 기본으로 표시한다. 비밀번호 변경과 회원 탈퇴는 설정 버튼의 모달 액션으로만 노출한다. 활동 정보의 `마지막 로그인`은 현재 로그인 이전에 저장된 `previous_last_seen_at`을 표시한다. - 첫 회원가입 시 관리자 세션도 함께 설정되어 관리자 화면(`/admin`)으로 바로 진입할 수 있다. - 회원 세션 서명은 `MEMBER_SESSION_SECRET`을 우선 사용하고, 값이 없으면 개발 편의를 위해 `ADMIN_PASSWORD`를 fallback으로 사용한다. diff --git a/docs/update.md b/docs/update.md index 87d1a82..6df4e7e 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,15 @@ # 업데이트 이력 +## v0.0.114 + +- 관리자 하단 사용자 메뉴의 `내 프로필` 경로를 사용자 설정에서 관리자 멤버 편집 화면으로 변경. +- 관리자 멤버 편집 설정 메뉴에 비밀번호 직접 변경과 멤버 삭제 모달 추가. +- 사용자 설정 화면을 관리자 멤버 편집과 같은 요약/본문 구조로 재정리. +- 사용자 설정의 비밀번호 변경과 회원 탈퇴를 설정 메뉴 모달로 분리. +- 로그인 시 이전 로그인 시각/IP를 보존하는 `021_add_member_previous_login.sql` 마이그레이션 추가. +- 회원 프로필 API에 가입일, 이전 로그인, 댓글 수 정보를 추가. +- 패키지 버전 `0.0.114`로 갱신. + ## v0.0.113 - 관리자 미저장 변경사항 모달을 화면 상단 40px 여백 위치로 조정. diff --git a/layouts/admin.vue b/layouts/admin.vue index 3174a8b..24a7189 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -24,6 +24,7 @@ const adminDisplayName = computed(() => adminMember.value?.username || adminMemb const adminDisplayEmail = computed(() => adminMember.value?.email || '') const adminAvatarUrl = computed(() => adminMember.value?.avatarUrl || '') const adminAvatarInitial = computed(() => adminDisplayName.value.slice(0, 1).toUpperCase() || 'A') +const adminProfilePath = computed(() => adminMember.value?.id ? `/admin/members/${adminMember.value.id}` : '/admin/members') /** * 관리자 내비게이션 활성 경로 확인 @@ -226,7 +227,7 @@ const logoutAdmin = async () => {