[feat] 백오피스(admin-web) 구축 및 회원관리 기능 - #18
Conversation
Vite + React + TypeScript + Refine 기반 백오피스 프로젝트를 초기화하고, Tailwind + shadcn/ui(흰색·neutral 테마)로 공용 UI 컴포넌트를 구성한다. 경로 별칭(@/*)과 빌드 설정 포함.
회원 목록(검색·페이지네이션)과 상세 화면을 구현하고, 사이드바 레이아웃과 로그인 화면을 구성한다. 데이터·인증은 우선 목(mock)으로 연결한다.
SPA를 빌드해 nginx로 서빙하는 멀티스테이지 Dockerfile과 정적 서버 설정을 추가한다. 리버스 프록시 블록·compose·CD 연동은 deploy 참조 문서로 정리한다.
구글 로그인(Firebase ID 토큰)으로 확인한 이메일이 허용목록(admin.emails)에 있을 때만 ROLE_ADMIN 토큰을 발급한다. JWT에 admin 종류를 더해 인증 필터가 회원·관리자 토큰을 구분하고, /api/admin/** 는 ROLE_ADMIN 을 요구한다.
관리자용 회원 목록(검색·페이지네이션)과 상세 조회 엔드포인트를 추가한다. 회원 도메인을 재사용하고 이메일·닉네임 부분 검색을 지원한다.
목 인증·데이터를 걷어내고 구글 로그인(Firebase)으로 전환한다. 로그인 시 Firebase ID 토큰을 백엔드 관리자 토큰으로 교환하며, 이때 허용목록 검증이 이뤄진다. 이후 /api/admin/** 를 관리자 토큰으로 호출하고, 만료(401) 시 세션으로 자동 재발급한다. Firebase 설정은 빌드 시점 환경변수(VITE_*)로 주입한다.
Firebase 없이 허용목록 이메일로 관리자 토큰을 발급하는 dev-login 을 추가한다. 컨트롤러는 @Profile("local") 이고 보안 공개 경로도 local 프로필에서만 열려, dev/prod 에는 이 엔드포인트가 존재하지 않는다.
Firebase 없이 로컬 개발이 가능하도록 mock 모드를 추가한다. VITE_AUTH_MODE=mock 이면 백엔드 dev-login 으로 로그인하고, Firebase는 지연 초기화해 설정이 없어도 앱이 뜬다. vite dev 서버가 /api 를 로컬 백엔드(:8080)로 프록시한다.
MySQL·백엔드·프론트를 순서대로 띄우고, 백엔드는 헬스체크까지 기다린 뒤 프론트를 포그라운드로 실행한다. Ctrl+C 시 백엔드도 함께 종료된다.
모노크롬 세련 방향으로 다듬는다. 사이드바(브랜드·섹션 라벨·활성 상태·유저 블록), 상단 sticky 헤더(브레드크럼·로컬 모드 표시)를 정비하고, 회원 목록에 스켈레톤 로딩· 상태 배지(닷)·빈 상태·정제된 페이지네이션을 적용한다. 상세는 아바타 헤더와 구분선 정의 리스트로, 로그인은 은은한 배경과 카드 그림자로 개선한다. 공통 컴포넌트(아바타· 스켈레톤·페이지 헤더·상태 배지) 추가.
회원 목록에 status 필터를 더하고, 관리자가 회원을 탈퇴 처리하는
POST /api/admin/members/{id}/withdraw 를 추가한다(도메인 withdraw 재사용).
목록에 전체/활성/탈퇴 세그먼트 필터를 추가하고, 상세에서 확인 다이얼로그로 회원을 탈퇴 처리한다. dataProvider.custom 으로 탈퇴 API를 호출한다.
전체·활성·탈퇴 회원 수와 최근 N일 일자별 가입 추이(KST, 빈 날 0)를 집계하는 GET /api/admin/members/stats 를 추가한다.
회원 관리 상단에 지표 카드와 상태 비율 도넛 게이지, 가입 추이 영역 그래프를 recharts 로 시각화한다. 차트는 지연 로드해 초기·로그인 번들에서 제외한다.
Test Coverage
|
|
Warning Review limit reached
Next review available in: 58 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (19)
Walkthrough관리자 허용목록과 JWT 인증을 백엔드에 추가하고, 회원 검색·통계·상세·탈퇴 API를 구현했습니다. React/Refine 기반 admin SPA에는 인증, 회원관리 화면, 공통 UI, Docker·Nginx 배포 구성을 추가했습니다. Changes관리자 인증 및 보안
회원관리 API
Admin SPA
개발 및 배포
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant 관리자
participant LoginPage
participant AuthProvider
participant Firebase
participant AdminAPI
관리자->>LoginPage: 로그인 선택
LoginPage->>AuthProvider: login()
AuthProvider->>Firebase: Google 인증 또는 mock 로그인
Firebase-->>AuthProvider: Firebase ID token
AuthProvider->>AdminAPI: 관리자 토큰 교환
AdminAPI-->>AuthProvider: 관리자 JWT
AuthProvider-->>관리자: 회원 목록으로 이동
관리자->>AdminAPI: 회원 검색·통계 요청
AdminAPI-->>관리자: 목록·통계 응답
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 20
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@admin-web/dev.sh`:
- Around line 16-17: Update the cleanup function to stop using lsof on port 8080
and killing every matching process. Track the process started by the script via
backend_pid and use child-process termination such as pkill -P to terminate only
that process’s descendants, while preserving safe behavior when no backend
process exists.
In `@admin-web/README.md`:
- Around line 1-32: Replace the default Vite template content in the README with
project-specific documentation for the GAMSS admin backoffice. Include the
application purpose, prerequisites and runtime setup, development startup using
npm run dev, required .env configuration, and the main technology stack
including React, Refine, and Firebase.
In `@admin-web/src/App.tsx`:
- Around line 8-14: Move the authProvider declaration below all import
statements in App.tsx, keeping its existing mock-mode selection logic unchanged
and ensuring the module imports remain at the file top level.
In `@admin-web/src/components/ui/avatar.tsx`:
- Around line 1-16: Resolve the mismatch between the Avatar component and the
installed `@radix-ui/react-avatar` dependency: either refactor Avatar to use Radix
Avatar primitives, including its image and fallback behavior, or remove the
unused dependency from package.json if the existing text-only implementation is
intentional. Keep the chosen approach consistent with the component’s supported
API and current usage.
In `@admin-web/src/components/ui/skeleton.tsx`:
- Around line 3-5: Update the Skeleton component’s props to extend
React.HTMLAttributes<HTMLDivElement>, destructure className separately, and
forward the remaining props to the rendered div while preserving the existing
default classes and className merging.
In `@admin-web/src/index.css`:
- Line 37: Update the text-rendering declaration to use the lowercase
optimizelegibility value, preserving the existing property and behavior while
satisfying the Stylelint value-keyword-case rule.
In `@admin-web/src/lib/api.ts`:
- Around line 23-24: Update the authentication flow around the accessToken
assignment and TOKEN_KEY storage to avoid persisting the JWT in localStorage;
migrate token issuance and session handling to an HttpOnly, secure cookie
through the backend, and adjust the client API usage to rely on that cookie
instead of JavaScript-accessible token storage.
- Around line 64-67: Update refreshToken to cache the in-flight token-refresh
Promise and return that shared Promise to concurrent callers, regardless of
MOCK_MODE. Clear the cached Promise after completion, including rejection, so
later expired-token requests can retry normally while preserving the existing
devLoginToken and exchangeAdminToken behavior.
- Line 84: Update the response handling condition in the API request flow to
invoke refreshToken() only for 401 Unauthorized responses; return or propagate
403 Forbidden responses immediately without retrying or refreshing credentials.
In `@admin-web/src/pages/members/list.tsx`:
- Around line 75-83: 검색 입력의 즉시 상태와 목록 조회용 검색어를 분리하고, 조회용 값에는 약 300ms 디바운스를
적용하세요. Input의 onChange는 입력 상태만 갱신하고, 디바운스가 완료된 값으로 filters/useList가 요청되도록 해당 검색
상태와 조회 로직을 수정하세요.
In `@admin-web/src/pages/members/member-stats.tsx`:
- Around line 92-107: Prevent the zero-member case in the member stats hints
from displaying a misleading 100% withdrawn ratio. Update the hint values
associated with activeRatio and the withdrawn Metric in the surrounding stats
component so both use a neutral value such as "—" when total is zero, while
preserving the existing percentage calculations when total is greater than zero.
In `@admin-web/src/pages/members/show.tsx`:
- Around line 75-81: AlertDialogAction의 기본 즉시 닫힘 동작과 withdrawing 로딩 상태 처리를
일치시키세요. 로딩 상태를 사용자에게 표시하려는 의도라면 onWithdraw에서 이벤트 기본 동작을 막고, API 성공 시 제어된 다이얼로그
상태를 통해 닫도록 open/onOpenChange 흐름을 구현하세요. 즉시 닫기가 의도라면 AlertDialogAction의
withdrawing 기반 disabled 속성을 제거하세요.
In `@admin-web/tailwind.config.js`:
- Line 67: Update the Tailwind configuration’s plugins declaration to use a
static ESM import for tailwindcss-animate instead of require. Add the import at
the top of the configuration file, bind it to a named variable, and reference
that variable in the plugins array.
In `@src/main/kotlin/com/nexters/gamss/admin/config/AdminProperties.kt`:
- Around line 17-19: AdminProperties의 private normalizeEmail 로직이
AdminAuthService와 AdminDevAuthController에 중복되지 않도록 공통 재사용 경로를 제공하세요.
normalizeEmail의 가시성을 외부 호출 가능하게 변경하거나 공통 확장 함수로 분리한 뒤, 두 호출 지점이 해당 구현을 사용하도록
업데이트하고 기존 isAllowed 동작은 유지하세요.
In `@src/main/kotlin/com/nexters/gamss/admin/service/AdminAuthService.kt`:
- Around line 21-22: Update the authentication flow around
socialTokenVerifier.verify in AdminAuthService to validate that user.provider is
Google before accepting user.email. Reject non-Google providers with
BusinessException(ErrorCode.NOT_ADMIN), while preserving the existing
missing-email rejection.
In `@src/main/kotlin/com/nexters/gamss/global/security/JjwtIssuer.kt`:
- Around line 34-36: Update parseAccessToken and parseRefreshToken to use
nullable Long conversion via toLongOrNull instead of direct toLong, and map a
failed conversion to the existing INVALID_TOKEN domain exception. Preserve
successful numeric Member ID parsing for both access and refresh tokens.
- Line 65: Update the subject-returning logic in JjwtIssuer so a missing
claims.subject is handled explicitly with the established authentication-failure
exception path, rather than allowing Kotlin’s non-null return contract to fail
at runtime. Preserve returning the subject unchanged when it is present.
In `@src/main/kotlin/com/nexters/gamss/member/repository/MemberRepository.kt`:
- Around line 25-33: Retain the current partial-match query in the
MemberRepository search condition; no immediate code change is required. Record
the potential scalability concern for future optimization through full-text
search or a dedicated search engine if member data volume makes this query a
bottleneck.
In `@src/main/kotlin/com/nexters/gamss/member/service/MemberService.kt`:
- Around line 47-51: Replace the in-memory grouping in MemberService’s
countsByDate flow with a repository-level aggregation that returns date/count
results for the requested period and zone. Add the corresponding
findCreatedAtsSince aggregation method in memberRepository using the project’s
database query conventions, and map only the aggregated results into
countsByDate.
In `@src/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt`:
- Line 86: Replace the time-dependent Instant.now() values in the
findCreatedAtsSince stub within MemberServiceTest with a fixed, explicit instant
so the test no longer depends on execution time or date boundaries. Preserve the
existing repository return shape and assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: f3415919-a8c5-41c2-84fb-c5f7773517ca
⛔ Files ignored due to path filters (2)
admin-web/package-lock.jsonis excluded by!**/package-lock.jsonadmin-web/public/favicon.svgis excluded by!**/*.svg
📒 Files selected for processing (74)
admin-web/.dockerignoreadmin-web/.env.exampleadmin-web/.gitignoreadmin-web/.oxlintrc.jsonadmin-web/Dockerfileadmin-web/README.mdadmin-web/deploy/README.mdadmin-web/deploy/gamss-admin.confadmin-web/dev.shadmin-web/index.htmladmin-web/nginx.confadmin-web/package.jsonadmin-web/postcss.config.jsadmin-web/src/App.tsxadmin-web/src/components/layout/AdminLayout.tsxadmin-web/src/components/page-header.tsxadmin-web/src/components/status-badge.tsxadmin-web/src/components/ui/alert-dialog.tsxadmin-web/src/components/ui/avatar.tsxadmin-web/src/components/ui/badge.tsxadmin-web/src/components/ui/button.tsxadmin-web/src/components/ui/card.tsxadmin-web/src/components/ui/input.tsxadmin-web/src/components/ui/skeleton.tsxadmin-web/src/components/ui/table.tsxadmin-web/src/index.cssadmin-web/src/lib/api.tsadmin-web/src/lib/firebase.tsadmin-web/src/lib/format.tsadmin-web/src/lib/utils.tsadmin-web/src/main.tsxadmin-web/src/pages/login.tsxadmin-web/src/pages/members/list.tsxadmin-web/src/pages/members/member-stats.tsxadmin-web/src/pages/members/show.tsxadmin-web/src/providers/authProvider.tsadmin-web/src/providers/dataProvider.tsadmin-web/src/providers/devAuthProvider.tsadmin-web/src/types/member.tsadmin-web/tailwind.config.jsadmin-web/tsconfig.app.jsonadmin-web/tsconfig.jsonadmin-web/tsconfig.node.jsonadmin-web/vite.config.tssrc/main/kotlin/com/nexters/gamss/admin/config/AdminProperties.ktsrc/main/kotlin/com/nexters/gamss/admin/controller/AdminAuthController.ktsrc/main/kotlin/com/nexters/gamss/admin/controller/AdminDevAuthController.ktsrc/main/kotlin/com/nexters/gamss/admin/controller/AdminMemberController.ktsrc/main/kotlin/com/nexters/gamss/admin/controller/dto/AdminDevLoginRequest.ktsrc/main/kotlin/com/nexters/gamss/admin/controller/dto/AdminLoginRequest.ktsrc/main/kotlin/com/nexters/gamss/admin/controller/dto/AdminMeResponse.ktsrc/main/kotlin/com/nexters/gamss/admin/controller/dto/AdminTokenResponse.ktsrc/main/kotlin/com/nexters/gamss/admin/controller/dto/DailySignupResponse.ktsrc/main/kotlin/com/nexters/gamss/admin/controller/dto/MemberStatsResponse.ktsrc/main/kotlin/com/nexters/gamss/admin/controller/dto/PageResponse.ktsrc/main/kotlin/com/nexters/gamss/admin/service/AdminAuthService.ktsrc/main/kotlin/com/nexters/gamss/global/exception/ErrorCode.ktsrc/main/kotlin/com/nexters/gamss/global/security/AdminPrincipal.ktsrc/main/kotlin/com/nexters/gamss/global/security/JjwtIssuer.ktsrc/main/kotlin/com/nexters/gamss/global/security/JwtAuthenticationFilter.ktsrc/main/kotlin/com/nexters/gamss/global/security/JwtIssuer.ktsrc/main/kotlin/com/nexters/gamss/global/security/SecurityConfig.ktsrc/main/kotlin/com/nexters/gamss/global/security/TokenType.ktsrc/main/kotlin/com/nexters/gamss/member/repository/MemberRepository.ktsrc/main/kotlin/com/nexters/gamss/member/service/DailySignup.ktsrc/main/kotlin/com/nexters/gamss/member/service/MemberService.ktsrc/main/kotlin/com/nexters/gamss/member/service/MemberStats.ktsrc/main/resources/application-local.ymlsrc/main/resources/application.ymlsrc/test/kotlin/com/nexters/gamss/admin/config/AdminPropertiesTest.ktsrc/test/kotlin/com/nexters/gamss/admin/service/AdminAuthServiceTest.ktsrc/test/kotlin/com/nexters/gamss/global/security/JjwtIssuerTest.ktsrc/test/kotlin/com/nexters/gamss/member/repository/MemberRepositoryTest.ktsrc/test/kotlin/com/nexters/gamss/member/service/MemberServiceTest.kt
이메일만 믿으면 이메일 소유를 검증하지 않는 제공자로 관리자와 같은 주소를 만들어 권한을 탈취할 수 있어, 관리자는 구글 로그인만 허용하도록 검증을 추가한다.
- dev.sh: 8080 맹목적 종료 대신 백엔드 프로세스 그룹만 종료(무관한 프로세스 보호) - 회원 통계: 회원 0명일 때 비율 힌트를 —로 표시(모순 문구 방지) - 회원 상세: 즉시 닫히는 다이얼로그에서 무효한 disabled 속성 제거
- api: 401에만 토큰 재발급(403 제외), 동시 갱신 Promise 캐싱으로 중복 호출 방지 - 회원 목록 검색 300ms 디바운스 - import 순서 정리, Skeleton 표준 속성 전달, tailwind require→import - 미사용 radix 의존성(avatar·dropdown-menu) 제거, README 프로젝트용으로 교체
- JjwtIssuer: subject null·비숫자 subject를 INVALID_TOKEN으로 매핑(500 방지) - 이메일 정규화(AdminProperties.normalize)를 토큰 발급부에서 재사용 - 통계 테스트: 시간 의존 단언을 기간 합계로 바꿔 자정 경계 플래키 제거
|
회원 목록/통계 API에
허용목록에 없으면 토큰 자체를 못 받으니 공격 표면은 좁지만, (참고로 |
리뷰 반영: - 회원 목록 size(<=100)·page(>=0), 통계 days(1~365)에 @Max/@min 상한 추가 (@validated + ConstraintViolationException 을 400 INVALID_INPUT 으로 매핑) - AccessDeniedHandler 추가 — ROLE_ADMIN 없는 인증 요청 403을 ApiResponse 포맷으로 반환
SecurityConfig 충돌 해소: PUBLIC_PATHS에 관리자 로그인(/api/admin/auth/login)과 dev의 /actuator/health 를 모두 유지. 나머지는 정상 자동 병합.
|
개요
같은 레포에 백오피스(admin-web)를 SPA로 구축하고, 첫 기능으로 회원 관리를 붙였습니다.
인증은 구글 로그인 + 이메일 허용목록 방식이며, 로컬은 Firebase 없이 개발할 수 있습니다.
Closes #12
주요 변경
프론트엔드 (
admin-web/)백엔드 (
/api/admin/**)admin.emails)에 있으면ROLE_ADMIN토큰 발급. JWT에 admin 종류를 더해 인증 필터가 회원·관리자 토큰을 구분하고,/api/admin/**는ROLE_ADMIN을 요구POST /members/{id}/withdraw)GET /members/stats)로컬 개발
@Profile("local")전용dev-login으로 Firebase 없이 허용목록 이메일로 로그인(dev/prod엔 이 엔드포인트 없음)admin-web/dev.sh한 줄로 MySQL·백엔드·프론트 기동로컬 실행
admin-web/.env.local에VITE_AUTH_MODE=mock,VITE_DEV_ADMIN_EMAIL=<허용목록 이메일>필요(.env.example참고).배포 관련 (별도 진행)
이 PR은 코드까지입니다. 실제 서빙은 배포 인프라(#8) 머지 후 아래를 와이어링합니다 — 절차는
admin-web/deploy/README.md:admin.gamss.kr/dev-admin.gamss.kr서브도메인(DNS는 이미 추가·전파됨) + nginx server 블록 + admin 컨테이너/CDVITE_FIREBASE_*, 공개값), 관리자 이메일(ADMIN_EMAILS)모습
검증
Summary by CodeRabbit