340 Commits
Author SHA1 Message Date
Lectom C Han 8df95c8a13 i18n 대대적 변경 2026-02-10 19:13:00 +09:00
kyy 798d37bed9 Merge pull request 'Http 환경 CopyButton 오류 수정' (#236) from feature/devfront-content into dev
Reviewed-on: ai-team/baron-sso#236
2026-02-10 12:43:10 +09:00
kyy 28477aafc6 Http 환경 CopyButton 오류 수정 2026-02-10 12:40:19 +09:00
chan eec583b0a1 Merge pull request 'dev-stagingcd-fix' (#235) from dev-stagingcd-fix into dev
Reviewed-on: ai-team/baron-sso#235
2026-02-10 12:31:48 +09:00
chan 90fcd99b2f Merge branch 'feature/stagingcd' into dev 2026-02-10 11:29:41 +09:00
kyy 3a019f8e23 Merge pull request '초기 coverage 값 수정' (#227) from feature/hydra-content into dev
Reviewed-on: ai-team/baron-sso#227
2026-02-10 10:53:55 +09:00
kyy 7003c0ada7 초기 coverage 값 수정 2026-02-10 10:53:21 +09:00
kyy 93699bb1b3 Merge pull request 'feature/hydra-content' (#226) from feature/hydra-content into dev
Reviewed-on: ai-team/baron-sso#226
2026-02-10 10:34:00 +09:00
chan 3a05ba94e0 1 del 2026-02-10 10:32:07 +09:00
chan b3371f48bb CLICKHOUSE_HOST add 2026-02-10 10:30:44 +09:00
chan a1ece93a5c REDIS_ADDR 문법 오류 수정 2026-02-10 10:17:46 +09:00
kyy 07aae642a7 golangci lint 적용 2026-02-10 10:15:44 +09:00
chan f0cd60bf56 문법 오류 수정 2026-02-10 10:03:39 +09:00
kyy 6569faee76 App 현황 표시 UI개선 2026-02-10 09:58:29 +09:00
chan 0d3aac0a58 $기호 정제 2026-02-10 09:56:48 +09:00
chan 93f6182cce $빠진거 추가 2026-02-10 09:56:28 +09:00
chan 27248cfa53 $ 제거 2026-02-10 09:34:41 +09:00
kyy 0741baf60b df 클라이언트 상세 조회 테스트 추가 2026-02-10 09:07:47 +09:00
kyy 68459151c3 백엔드 Hydra 테스트 가이드 문서 업데이트 2026-02-10 09:07:47 +09:00
kyy dd9e6394ad Hydra 연동 관련 핸들러 테스트 케이스 추가 2026-02-10 09:07:47 +09:00
kyy 15a27a6620 핸들러 및 서비스 테스트 리팩토링 2026-02-10 09:07:47 +09:00
kyy f16cb9a344 Actions 백엔드 테스트 커버리지 워크플로우 추가 2026-02-10 09:07:47 +09:00
Lectom C Han 9cdd89c1c1 환경변수 descope관련 값 제거, 비밀번호 변경기능 규격 변경 2026-02-09 18:01:59 +09:00
chan abba85af75 "" 제거 2026-02-09 17:40:51 +09:00
chan c5dc531f1b 안쓰는 변수 비활 2026-02-09 17:26:31 +09:00
chan 440785dfe8 환경변수 추가 2026-02-09 16:58:36 +09:00
chan 2ca612da7f oathkeeper url r고정 2026-02-09 14:32:52 +09:00
chan 0e88bc3986 중복 "github.com/coreos/go-oidc/v3/oidc"
제거
2026-02-09 14:32:21 +09:00
chan c5f9445d67 nginx 5000 fix 2026-02-09 14:27:28 +09:00
chan c7c4eb1b82 Oathkeeper 명시적 URL 2026-02-09 14:27:05 +09:00
chan 880287088f oathkeeper 이름 통일 2026-02-09 13:56:48 +09:00
chan 561ae56cbb depends_on 조정 2026-02-09 13:52:05 +09:00
chan 5d66e983cd 환경변수 추가 2026-02-09 13:08:18 +09:00
kyy 8e45422606 Merge pull request 'feature/hydra-content' (#221) from feature/hydra-content into dev
Reviewed-on: ai-team/baron-sso#221
2026-02-09 12:32:30 +09:00
chan eb34d387ad -e add 2026-02-09 11:26:15 +09:00
kyy 9b7b2da497 golangci 린트 적용 2026-02-09 11:23:13 +09:00
chan 7f0a44e4ee sql command 수정 2026-02-09 11:21:15 +09:00
kyy 05b6ff6d9e 앱 현황 섹션 통합 및 Linked API 기반 UI 개편 2026-02-09 11:19:10 +09:00
kyy bb78c8e572 Linked API에 Audit Log 스캔을 통한 과거 이력 보강 로직 추가 2026-02-09 11:18:32 +09:00
chan 3d3e6a0c9c 디버그용 문장 추가 2026-02-09 11:16:30 +09:00
chan b49a39f9b2 healthcheck 시간 추가 2026-02-09 11:04:48 +09:00
chan 658113d779 설정 파일 오류 수정 2026-02-09 10:59:05 +09:00
kyy 4b7264217d Apply stashed changes after rebase 2026-02-09 09:56:10 +09:00
chan 02e5fd2254 배포 환경 설정 2026-02-06 18:35:51 +09:00
chan 564ad29fc0 hydra-migrate add 2026-02-06 18:14:56 +09:00
chan 126462ce10 템플릿변경 2026-02-06 18:10:56 +09:00
chan cfcb8f023f Merge commit '226de652e370dc06c34bdcd14753c3dade28bb2c' 2026-02-06 17:32:44 +09:00
chan 29c5c3f0cf DSN 따옴표 제거 및 --dev 플래그 제거 2026-02-06 17:31:46 +09:00
Lectom C Han 226de652e3 Fix hydra consent session parsing and audit detail tests 2026-02-06 17:30:21 +09:00
Lectom C Han 3a6ae4948a 테스트 로직 강화, hydra 연동 데이터 용량 제한 완화. 2026-02-06 17:30:21 +09:00
chan d3072aceca nsupported dialect '"postgres' 오류를 해결 2026-02-06 17:26:44 +09:00
chan 804dc412fb export 수정 2026-02-06 17:18:19 +09:00
chan 22b132eb51 eof 제거 2026-02-06 17:14:13 +09:00
chan 8bb8017e9b 들여쓰기 2026-02-06 17:12:03 +09:00
chan d06edd03c0 이전의 오류 해결 2026-02-06 17:10:57 +09:00
chan f0541368b2 변수 치환 2026-02-06 17:06:54 +09:00
chan 89f3e09967 이미지 이름과 태그 변수 명시적으로 치환 2026-02-06 17:04:01 +09:00
chan 27a69db023 copy files 수정 2026-02-06 17:01:48 +09:00
chan 168fc5d991 스태이징 템플릿으로 설정 2026-02-06 16:59:00 +09:00
chan a4345b3fb4 배포 도커 수정 2026-02-06 16:55:08 +09:00
chan 8333210a7d 도커 네트워크 생성 2026-02-06 16:49:24 +09:00
kyy 253e15202f Merge pull request 'dev/ory-hydra2' (#218) from dev/ory-hydra2 into main
Reviewed-on: ai-team/baron-sso#218
2026-02-06 16:30:12 +09:00
kyy 5294066de6 golangci lint 적용 2026-02-06 16:28:00 +09:00
kyy 568af8f90e Go 바이너리 실행 파일 제외 2026-02-06 16:28:00 +09:00
kyy 6b85f001e2 클라이언트 관리 페이지 UX 최적화 및 정리 2026-02-06 16:28:00 +09:00
kyy b0899d8db3 클라이언트 상세 페이지 비밀키 재발급 기능 연동 2026-02-06 16:28:00 +09:00
kyy a443ab3e72 클라이언트 비밀키 재발급(Rotate) API 구현 2026-02-06 16:28:00 +09:00
chan 20590b67d9 배포용 도커파일 추가 2026-02-06 16:24:04 +09:00
chan e57bf270eb 들여쓰기 수정 2026-02-06 16:21:48 +09:00
chan e0ea0c7048 printf에서 cat <<'EOF' (HEREDOC) 방식으로 변경 2026-02-06 16:15:03 +09:00
chan cf521f848f printf 대신 cat t사용 2026-02-06 16:13:13 +09:00
chan a87f1d2ce9 스테이징용 도커 템플릿 2026-02-06 16:01:25 +09:00
chan ce5255346c 스태이징용 배포 yml 2026-02-06 16:01:25 +09:00
chan 56fecb7e89 Merge pull request 'feat/staging-deploy' (#214) from feat/staging-deploy into main
Reviewed-on: ai-team/baron-sso#214
2026-02-06 14:57:26 +09:00
chan 0b33ca0af6 스태이징용 배포 yml 2026-02-06 14:52:10 +09:00
chan 051f446adc 빌드 rc dev, admin add 2026-02-06 14:50:06 +09:00
chan b2fd09fc43 test code GetTenant 수정 2026-02-06 14:46:12 +09:00
kyy 9ddb4d6aff Merge pull request '누락된 go test 가이드 문서 추가' (#213) from dev/ory-hydra2 into main
Reviewed-on: ai-team/baron-sso#213
2026-02-06 14:31:45 +09:00
kyy 0725608f13 누락된 go test 가이드 문서 추가 2026-02-06 14:30:54 +09:00
chan 81824bd2de Merge pull request 'feat/issue-204-kratos-sot' (#209) from feat/issue-204-kratos-sot into main
Reviewed-on: ai-team/baron-sso#209
2026-02-06 13:04:59 +09:00
chan 93a11c80a5 add PROFILE_CACHE_TTL 2026-02-06 12:59:51 +09:00
chan 0e0d8c1ebc Kratos SoT 정합화 2026-02-06 12:59:51 +09:00
kyy 8168c47ad7 Merge pull request 'dev/ory-hydra2' (#208) from dev/ory-hydra2 into main
Reviewed-on: ai-team/baron-sso#208
2026-02-06 11:31:22 +09:00
kyy 033ba64997 동의 내역 조회 기능 명세 및 데이터 저장 흐름 문서화 2026-02-06 11:30:13 +09:00
kyy 7f52479c5c 클라이언트 동의 내역 페이지 전체 목록 조회 및 UX 개선 2026-02-06 11:30:13 +09:00
kyy 7a057fa5db DevHandler 동의 내역 조회 API를 로컬 DB 기반으로 변경 2026-02-06 11:30:13 +09:00
kyy 94c2ce9f31 Hydra 동의/철회 이벤트 시 로컬 DB 동기화 로직 추가 2026-02-06 11:30:13 +09:00
kyy 5af39085e7 동의 내역 관리를 위한 ClientConsent 모델 및 Repository 구현 2026-02-06 11:30:13 +09:00
kyy beebb3f7e6 안내 메세지 및 실제 이름 표시 2026-02-06 11:30:12 +09:00
kyy ea66671d44 Kratos를 통해 UUID로 변환 2026-02-06 11:30:12 +09:00
Lectom C Han 66e1ed1e72 Fix audit timeline app names and stabilize backend tests 2026-02-06 11:26:59 +09:00
Lectom C Han 62b5bdba76 Baron 통합로그인 -> Baron 로그인 명칭 변경 2026-02-05 18:36:28 +09:00
chan d3d9f7bea6 Merge commit 'a4e5ee78d1b7220ad55467376b8a675d202e4de3' 2026-02-05 17:09:35 +09:00
chan 8831208c08 admin api test 코드 2026-02-05 17:01:47 +09:00
Lectom C Han a4e5ee78d1 위키 업데이트 및 docs 최신화 2026-02-05 14:48:37 +09:00
kyy 044f5cf122 Merge pull request 'dev/ory-hydra2' (#198) from dev/ory-hydra2 into main
Reviewed-on: ai-team/baron-sso#198
2026-02-05 13:51:51 +09:00
kyy c8510fee4e .out 파일 제외 추가 2026-02-05 13:50:36 +09:00
kyy b50f7b7436 test corverage 관련 작업 문서화 2026-02-05 13:50:36 +09:00
kyy 5710bea4f9 백엔드 핸들러 OIDC/Hydra 흐름 검즘 2026-02-05 13:50:36 +09:00
kyy f362e21774 HydraAdminService와 RelyingParty 간의 통합 및 롤백 로직 검증 2026-02-05 13:50:36 +09:00
kyy 7c3295fdc7 Ory Hydra Admin API 통신 로직 검증 2026-02-05 13:50:36 +09:00
chan 86613fac46 adminfront RP 관리 기능 삭제 2026-02-05 11:21:56 +09:00
chan f719563ba9 Merge commit '5633d75bd78806ee3c87270d075c65e14f7b35cd' 2026-02-05 10:17:07 +09:00
chan d8f133b1e5 SSOT 전환 2026-02-05 10:15:54 +09:00
kyy 5633d75bd7 Merge pull request 'dev/ory-hydra2' (#197) from dev/ory-hydra2 into main
Reviewed-on: ai-team/baron-sso#197
2026-02-05 10:08:44 +09:00
kyy f4c38755ff Consent 반복 노출 문제 해결 문서화 2026-02-05 10:06:09 +09:00
kyy 3627d70ad9 재로그인 시 동의 화면 생략 로직 구현 2026-02-05 10:06:09 +09:00
kyy 974bfe7b9a Hydra 동의 기억 기간 연장 2026-02-05 10:06:09 +09:00
kyy c811b7e283 Merge pull request 'dev/ory-hydra2' (#192) from dev/ory-hydra2 into main
Reviewed-on: ai-team/baron-sso#192
2026-02-04 17:22:03 +09:00
kyy a4c5ab547d 병합 오류 수정 2026-02-04 17:11:47 +09:00
kyy b577568d07 클라이언트 비활성화 및 로그인 차단 흐름 문서화 2026-02-04 16:03:26 +09:00
kyy 7144c1176c 비활성화 클라이언트 OIDC 인증 차단 로직 구현 2026-02-04 16:03:26 +09:00
kyy 9f7c6bfd27 Hydra Client Patch 규격 수정 및 로그인 거부 기능 추가 2026-02-04 16:03:25 +09:00
kyy 41c1672afc 클라이언트 상태 토글 기능 활성화 및 전역 알림 추가 2026-02-04 16:00:00 +09:00
kyy f5d519feaf RP 활동상황 UX 확장 기능 동작 흐름 문서화 2026-02-04 16:00:00 +09:00
kyy 63c9172ebc Consent 승인 및 해지 이벤트 감사 로그 기록 2026-02-04 16:00:00 +09:00
kyy f88b1c37cb RP 연동 이력 조회 API 엔드포인트 구현 2026-02-04 16:00:00 +09:00
kyy b4241cb98b 감사 로그 조회 기능 확장 (사용자 및 이벤트 필터링) 2026-02-04 16:00:00 +09:00
kyy 5cf784a2c2 활동상황 UX 확장 기능 구현 (스코프, 이력, 과거 앱) 2026-02-04 16:00:00 +09:00
kyy 336eabf492 RP 이동 및 연동 해지 기능 로직 문서화 2026-02-04 16:00:00 +09:00
kyy 3de77ef681 활성 RP 홈페이지 이동 기능 추가 2026-02-04 16:00:00 +09:00
kyy 12b3d21da6 RedirectURI 기반 Fallback 로직 추가 2026-02-04 16:00:00 +09:00
kyy 3989e30bc2 연동 해지 기능 구현 문서화 2026-02-04 16:00:00 +09:00
kyy 1a02c15e78 RP 활성화/비활성화 UX 개선 2026-02-04 16:00:00 +09:00
kyy 1f1e7b6ce7 활동상황 카드 '연동 해지' 액션 추가 2026-02-04 16:00:00 +09:00
kyy 0eda7bde13 연동 ㅎ해지 UI 및 서비스 로직 구현 2026-02-04 16:00:00 +09:00
kyy c5cec11c03 연동 해지 api 앤드포인트 구현 2026-02-04 16:00:00 +09:00
kyy edfe47b356 Consent 거부 리다이랙트 흐름 문서화 2026-02-04 16:00:00 +09:00
kyy 69b1257ad7 동의 화면 '취소' 기능 및 확인 다이얼로그 구현 2026-02-04 16:00:00 +09:00
kyy 5bb48191e8 권한 동의 거부 api 호출 함수 추가 2026-02-04 16:00:00 +09:00
kyy 4e068f76a7 권한 동의 거부 api 구현 및 hydra 연동 2026-02-04 15:59:58 +09:00
kyy a6836cad09 clinet id 및 요청 scope 개수 표시 2026-02-04 15:57:32 +09:00
kyy ee9f91e9f8 스코프 동적 처리 작업 문서화 2026-02-04 15:57:32 +09:00
kyy a6e37b97c6 스코프 상태 및 UI 랜더링 로직 전면 재작성 2026-02-04 15:57:32 +09:00
kyy 40a4765dd6 grant_scope 파라미터 추가 2026-02-04 15:57:32 +09:00
kyy 0ac5b9ee0a scope 설명/필수 여부 및 grant_scope 검증 2026-02-04 15:57:32 +09:00
kyy 19862c6188 adminer 인프라 제거 2026-02-04 15:57:32 +09:00
kyy 50b9de5a64 hydra consent 페이지 구동 문서화 2026-02-04 15:57:32 +09:00
kyy 1a97483bea 클라이언트 시크릿 캐싱 로직 적용 및 의존성 주입 2026-02-04 15:57:30 +09:00
kyy 981643df38 클라이언트 시크릿 저장소 구현 2026-02-04 15:56:56 +09:00
kyy 663bc7c8a8 클라이언트 시크릿 DB 모델 정의 및 초기 인프라 설정 2026-02-04 15:56:55 +09:00
chan 764c833ad3 Merge commit 'd3facfbe776b0dd7a0a17d3fa3fed47b0d8641ab' 2026-02-04 15:02:25 +09:00
chan 7e09764ad9 ReBAC 고도화 및 애플리케이션 관리 시스템 통합 구현 2026-02-04 15:01:13 +09:00
chan 066ea86f46 애플리케이션(RP) 관리 기능 구현 및 Ory Keto 권한 연동 2026-02-04 14:56:16 +09:00
Lectom C Han d3facfbe77 인증수단 세션 확인 추가 2026-02-04 14:33:47 +09:00
Lectom C Han bf469b1eb4 Merge origin/main and remove Descope deps 2026-02-03 18:10:31 +09:00
Lectom C Han b908d71666 Merge remote-tracking branch 'origin/main' 2026-02-03 16:50:11 +09:00
chan 1c8a599d46 빌드오류 수정 2026-02-03 15:10:13 +09:00
Lectom C Han f14eb57ef8 RP 로그인 이력 통합 2026-02-03 15:05:46 +09:00
chan 3868f5967e Merge commit '1406c20959678870fe01564147613b24806697a2' 2026-02-03 14:27:21 +09:00
chan d09abab5a2 3단계 권한 모델 확장, keto 권한 정책 2026-02-03 14:21:37 +09:00
Lectom C Han 1406c20959 다른 탭에 로그인 되어 있는 경우 추가 로그인 요청하지 않고 넘어가도록 개선 2026-02-03 14:17:36 +09:00
Lectom C Han 4f3d0759c3 userfront 연동이력 맞춤 2026-02-03 13:37:24 +09:00
Lectom C Han e20b61189c Merge remote-tracking branch 'origin/main' 2026-02-03 08:34:46 +09:00
Lectom C Han c94a369d1d consent 변화 내부 머지 완료 2026-02-02 17:13:18 +09:00
chan 6dbdd5d483 Merge commit 'e345570210aa0fc8acdb9cf042561f35f00812f0' 2026-02-02 17:12:45 +09:00
chan 12ae5929e3 사용자 필드 관리 2026-02-02 17:11:43 +09:00
kyy e345570210 Merge pull request 'dev/ory-hydra2' (#165) from dev/ory-hydra2 into main
Reviewed-on: ai-team/baron-sso#165
2026-02-02 17:04:28 +09:00
kyy f756959bbe sso-test 고정 제거 2026-02-02 16:59:28 +09:00
kyy fb2541cb09 클라이언트 목록 및 상세 화면에 복사 피드백 적용 2026-02-02 16:39:35 +09:00
kyy a01ebfd143 Devfront 복사 버튼 및 토스트 알림 추가 2026-02-02 16:39:35 +09:00
kyy 3a3ea4879e 프론트엔드 동의 화면 추가 및 OIDC 로그인 흐름 완성 2026-02-02 16:39:34 +09:00
kyy 849424f030 OIDC 인증 로직 수정 및 백엔드 라우팅 오류 해결 2026-02-02 16:24:18 +09:00
kyy fa1f37dc90 Oathkeeper API 액세스 규칙 및 경로 업데이트 2026-02-02 16:24:18 +09:00
kyy 8a9bbeeb88 OIDC 헤더 처리 위한 게이트웨이 및 인프라 설정 2026-02-02 16:24:18 +09:00
Lectom C Han 39296ca522 구조 통합 2026-02-02 16:22:23 +09:00
chan a54c2ab138 빌드오류 수정 2026-02-02 14:18:59 +09:00
chan 9600845551 Merge commit '1c0a5ed2720348f0ef14eca36b214210a5390b44' 2026-02-02 14:09:04 +09:00
chan 5dd425050c 테넌트 등록 방식을 결정 2026-02-02 14:05:50 +09:00
Lectom C Han 1c0a5ed272 접근 이력 스크롤 조회 기능 추가 2026-02-02 14:03:54 +09:00
Lectom C Han 7e662c9878 fix: verify-only magic link approval flow 2026-02-02 12:13:59 +09:00
Lectom C Han 9d1fe0fb1f fix: gateway oidc rewrite and abstract rules 2026-02-02 11:27:16 +09:00
Lectom C Han a92a47ad35 Merge remote-tracking branch 'origin/main' 2026-02-02 11:26:14 +09:00
Lectom C Han a0ebf7a48d gateway 분리 적용 2026-02-02 11:05:25 +09:00
kyy 521e519c4b Merge pull request 'dev/ory-hydra2' (#150) from dev/ory-hydra2 into main
Reviewed-on: ai-team/baron-sso#150
2026-02-02 09:51:15 +09:00
kyy f6e05596f2 Hydra Public API (/oidc) 경로 Rewrite 설정 추가 2026-02-02 09:04:00 +09:00
kyy 10b3d93538 Gitea 연동을 위한 Hydra Issuer 및 Oathkeeper 규칙 설정 2026-02-02 09:03:59 +09:00
kyy c6c6bd2092 backend depends 추가 2026-02-02 09:03:07 +09:00
kyy 109f138c8d 클라이언트 시크릿키 GET 요청 우회 2026-02-02 09:03:07 +09:00
kyy c8f2127360 federation 탭 제거 2026-02-02 09:03:07 +09:00
Lectom C Han 9e9c622600 gateway 분리 아키텍처 2026-01-30 17:56:42 +09:00
Lectom C Han 03a78d75ae gateway 분리 아키텍처 2026-01-30 17:55:43 +09:00
Lectom C Han b39789dbe2 세션정보 누락 해결. 2026-01-30 16:49:39 +09:00
Lectom C Han 1db7ce8f10 userfront gateway 분리. 2026-01-30 16:14:20 +09:00
Lectom C Han 35552943d7 내정보 페이지 사용성개선, adminFront user 정보 연동. 2026-01-30 13:42:41 +09:00
kyy 1cb5115f2a Merge pull request 'dev/ory-hydra2' (#138) from dev/ory-hydra2 into main
Reviewed-on: ai-team/baron-sso#138
2026-01-30 12:27:20 +09:00
kyy 83b5359405 REDIS 환경 설정값 불러오도록 2026-01-30 11:22:56 +09:00
kyy f8b17f79cb 개발자 라우터 api -> dev 2026-01-30 11:22:56 +09:00
kyy 91e724a3a9 클라이언트 시크릿 키 2026-01-30 11:22:56 +09:00
kyy 765bf67cab 클라이언트 관리 페이지 ui/ux 수정 2026-01-30 11:22:56 +09:00
kyy 3e2ceff692 IdP 연동 기능 devfront 이전 및 클라이언트 종속으로 개편 2026-01-30 11:22:56 +09:00
kyy 59a5f99fb9 클라이언트 상세 페이지에 Federation 탭 추가 2026-01-30 11:22:56 +09:00
kyy c82934b7cd API 기본 경로 오류 수정 2026-01-30 11:22:56 +09:00
kyy 968e16422d IdP 설정 생성 구현 및 오류 수정 2026-01-30 11:22:56 +09:00
kyy a8ac66b318 외부 OIDC IdP 연동 계획 2026-01-30 11:22:56 +09:00
kyy ec90853fe3 IdP 연동 관리 UI 및 라우팅 추가 2026-01-30 11:22:56 +09:00
kyy a52ec3b9f8 외부 IdP 연동 관리 API 기능 구현 2026-01-30 11:22:56 +09:00
kyy f6c7021fda 작업 문서 추가 2026-01-30 11:22:56 +09:00
kyy a5e61df4ae 실행 오류 수정 2026-01-30 11:22:56 +09:00
kyy aa24471012 서비스 실행 오류 수정 2026-01-30 11:22:56 +09:00
Lectom C Han 60df7ba904 userfront 이력 session ID기반 작업 완료. 2026-01-30 11:16:09 +09:00
Lectom C Han c58572b7cd 로그인 이력확인 2026-01-30 10:05:38 +09:00
chan 3b0e471471 Merge pull request '빌드 오류 수정' (#127) from dev/adminpage into main
Reviewed-on: ai-team/baron-sso#127
2026-01-29 17:06:04 +09:00
chan ab9d5e123b 빌드 오류 수정 2026-01-29 17:04:22 +09:00
chan 502efc7cbb Merge pull request 'dev/adminpage' (#125) from dev/adminpage into main
Reviewed-on: ai-team/baron-sso#125
2026-01-29 16:46:37 +09:00
chan 84b645ba7b Resolve merge conflicts with main 2026-01-29 16:45:40 +09:00
chan 5bdcb67200 역할부여 페이지 제거 2026-01-29 16:38:59 +09:00
chan a27026fa2a api키 생성 기능 및 페이지 구현 2026-01-29 16:38:27 +09:00
Lectom C Han ff655dc7c7 QR 로그인 구현 완료 2026-01-29 16:35:08 +09:00
chan ee4c07f66d users 정보 페이지 구현 2026-01-29 14:47:20 +09:00
Lectom C Han 77d4e9fd77 한 endpoint URL로 전체 서빙 #120 2026-01-29 14:42:15 +09:00
Lectom C Han 209314fea7 링크로 로그인하기 구현 완료 2026-01-29 09:57:36 +09:00
Lectom C Han 8faa08e377 링크로그인 동작 2026-01-29 09:40:01 +09:00
Lectom C Han 742964cf71 기본 발송 중간 2026-01-29 09:28:48 +09:00
Lectom C Han b88de7ec91 audit 로그 개선. kratos 코드발급 링크로 전송까지 진행 완료 #104 2026-01-29 01:20:19 +09:00
Lectom C Han ff17259117 oathkeeper 동작 확인 2026-01-28 20:07:52 +09:00
Lectom C Han 39594f8e21 README update 2026-01-28 17:30:23 +09:00
chan df03771121 adminfront에 API Key 관리 및 RBAC 기능 추가 2026-01-28 17:13:53 +09:00
Lectom C Han 3e95650024 adminFront에 Audit Log 기능 추가 2026-01-28 16:15:44 +09:00
Lectom C Han f33f417045 keto MCP 제작. ory-keto-migrate 오류 재발생 해결 2026-01-28 14:36:34 +09:00
Lectom C Han e573f4ca50 샘플 adminfront, devfront 구성. ory-keto-migrate 오류 해결 2026-01-28 14:03:42 +09:00
Lectom C Han b7a0397ef9 backend swagger/redoc 추가 2026-01-28 13:06:09 +09:00
Lectom C Han 1883753414 fix(ory): remove 'dsn: memory' from keto.yml to use env DSN (#90) 2026-01-28 12:33:30 +09:00
Lectom C Han b55cb2c4bf ory selfservice 샘플 구성 2026-01-28 12:19:56 +09:00
Lectom C Han 93cab064fc ory용 MCP 제작, devfront/adminfront 백엔드 연결 2026-01-28 10:57:22 +09:00
Lectom C Han 1aaa772907 userfront로 리펙토링 완료 2026-01-28 08:28:25 +09:00
Lectom C Han 6d88c81217 ory stack 결합 및 IDP 추상화 로직 일부 수정 2026-01-27 23:00:29 +09:00
Lectom C Han c3f7b18afc ory-hosting 기본구동 2026-01-27 22:58:49 +09:00
Lectom C Han 492919e104 fix: resolve nil pointer panic in password reset handler (issue #79)
- Add nil check for DescopeClient before accessing password policy.
- Use fallback password policy (min 8 chars) if DescopeClient is nil or policy fetch fails.
- Ensure 400 Bad Request is returned for weak passwords even in test environments without DescopeClient.
- Fix syntax errors introduced during manual edit.
2026-01-27 19:56:59 +09:00
Lectom C Han 41f0549435 wiki로 정리한 문서는 삭제 2026-01-27 17:59:17 +09:00
Lectom C Han 5c19b88230 문서 업데이트 2026-01-27 17:59:17 +09:00
chan 5fa0af8d2e Merge commit '26e6430aa715d451f172fa45b0e29f09efeea4eb' 2026-01-27 17:17:22 +09:00
chan 1a0dc74cb5 로그인 전 계정조회 2026-01-27 17:16:36 +09:00
kyy 26e6430aa7 Merge pull request 'dev/passwd-tab-signup' (#71) from dev/passwd-tab-signup into main
Reviewed-on: ai-team/baron-sso#71
2026-01-27 15:52:09 +09:00
kyy 5c24e6eb4e 불필요한 파일 제거 2026-01-27 15:48:21 +09:00
kyy 1f7835f5a9 비밀번호 정책 유효성 검사 이슈 2026-01-27 15:48:21 +09:00
kyy 920c98a8f8 회원가입 Descope 비밀번호 정책 동기화 2026-01-27 15:48:21 +09:00
chan c914fad405 린트 적용 2026-01-27 15:37:00 +09:00
chan ce86aee5fb Merge commit '36cef142741879e59321ec7e0ca09a6ebd9eb898' 2026-01-27 14:27:04 +09:00
chan 22a608f07a 이용약관 추가 2026-01-27 14:25:43 +09:00
kyy 36cef14274 Merge pull request '/login -> /signin 변경' (#69) from dev/passwd-tab-signup into main
Reviewed-on: ai-team/baron-sso#69
2026-01-27 14:23:13 +09:00
kyy 1c97731c52 /login -> /signin 변경 2026-01-27 14:21:53 +09:00
chan 1c1616de09 탭 하단 양식 통일 2026-01-27 13:58:58 +09:00
chan 36e91771f8 Merge pull request 'dev/mypage' (#68) from dev/mypage into main
Reviewed-on: ai-team/baron-sso#68
2026-01-27 13:47:37 +09:00
chan 28c6d6447a Merge remote-tracking branch 'origin/main' into dev/mypage 2026-01-27 13:46:04 +09:00
chan 4a49c702b3 admin page del 2026-01-27 13:42:36 +09:00
chan 60035aad53 마이페이지 구현 2026-01-27 13:39:49 +09:00
kyy 4ab6476f0f Merge pull request 'fix/password-reset' (#67) from fix/password-reset into main
Reviewed-on: ai-team/baron-sso#67
2026-01-27 13:06:00 +09:00
kyy 164203ffa1 불필요한 문서 정리 2026-01-27 12:52:15 +09:00
kyy 8ba4af4945 비밀번호 로그인 +82 후처리 추가 2026-01-27 12:52:15 +09:00
Lectom C Hanandkyy 72a36701da Refactor password reset flow 2026-01-27 12:52:14 +09:00
kyy 739da39a61 비밀번호 변경 중간 저장2 2026-01-27 12:45:34 +09:00
kyy d922de5df6 비밀번호 변경 2026-01-27 12:28:39 +09:00
kyy b013184bf7 IDP 팩토리 패턴 구현 2026-01-27 12:23:35 +09:00
kyy b42a4698e6 IDP 초기화 로직 팩토리 패턴 적용 2026-01-27 12:23:35 +09:00
kyy 5d136d0542 IDP_PROVIDER 환경 변수 추가 2026-01-27 12:23:35 +09:00
kyy 939d8ee911 이메일/비밀번호 로그인 기능 구현 2026-01-27 12:23:35 +09:00
Lectom C Han 4c608c6c3c admin front 일부 페이지 스케폴딩 2026-01-26 19:51:13 +09:00
chan 99b9e6bd48 Merge pull request 'dev/signup' (#62) from dev/signup into main
Reviewed-on: ai-team/baron-sso#62
2026-01-26 17:23:58 +09:00
chan dec66c136c 가족사 이메일 자동 매핑핑 2026-01-26 17:22:55 +09:00
chan 4919cb2f8b 로그인 페이지 및 기능 구현 2026-01-26 14:21:44 +09:00
chan 3725eac1a8 fix(auth): normalize phone number to E.164 format in Signup 2026-01-26 10:37:18 +09:00
Lectom C Han 98ba4c5b30 multi IDP 모델 적용 scaffolding 2026-01-23 16:27:30 +09:00
kyy a2e9f87d33 Merge pull request '메인 주소 sso.hmac.kr로 통일함' (#50) from dev/ci-cd2 into main
Reviewed-on: ai-team/baron-sso#50
2026-01-22 10:04:10 +09:00
kyy f261618dc6 메인 주소 sso.hmac.kr로 통일함 2026-01-22 10:00:55 +09:00
kyy eb41d6cc2b Merge pull request 'dev/ci-cd2' (#49) from dev/ci-cd2 into main
Reviewed-on: ai-team/baron-sso#49
2026-01-21 17:51:35 +09:00
kyy 29ed12c3bb 테스트 후 원상복구 2026-01-21 17:49:24 +09:00
kyy 78f11e1e63 테스트 파일 제거 2026-01-21 17:48:03 +09:00
kyy bf5943bc93 Gitea 설정 변수 정의 2026-01-21 17:40:44 +09:00
kyy 6d4b0d360e port 통일 2026-01-21 17:23:55 +09:00
kyy 7a00d84043 gitea 설정 변수 정의 2026-01-21 17:23:20 +09:00
kyy baab657ef0 MyApp -> BaronSSOApp 로 정의 2026-01-21 17:00:39 +09:00
kyy 89cf9b58a7 golangci-lint gofmt/gofumpt 적용 2026-01-21 16:54:47 +09:00
kyy 8894341f5a assets: 라인 자체 제거 2026-01-21 16:53:25 +09:00
kyy 493a9e4ecc 미사용 파일 제거 2026-01-21 16:51:44 +09:00
kyy 25b1dcfdaa test/ 없으면 스킵 2026-01-21 16:51:28 +09:00
kyy be722068b5 envsubst 하기 전에 .env 로드 2026-01-21 16:34:12 +09:00
kyy e2aa3c5301 이미지 컨텍스트 변경 2026-01-21 16:22:44 +09:00
kyy 0e71f8b4e7 Docker images 변경 2026-01-21 16:15:17 +09:00
chan 8e89c6c1c4 Merge branch 'main' of https://gitea.hmac.kr/ai-team/baron-sso 2026-01-21 13:09:05 +09:00
chan 801c21a907 icon 깨짐 방지 2026-01-21 13:06:22 +09:00
chan c39857c66c slog 통합 2026-01-21 12:51:00 +09:00
kyy 96bfcf0847 Merge pull request 'dev/ci-cd' (#47) from dev/ci-cd into main
Reviewed-on: ai-team/baron-sso#47
2026-01-21 12:39:55 +09:00
kyy 167b27bf2e 배포용 compose 파일 2026-01-21 11:32:25 +09:00
kyy a2af07f10e 배포용 infra 파일 2026-01-21 11:31:58 +09:00
kyy 639072c0d5 CI/CD Workflows First commit 2026-01-21 11:31:29 +09:00
chan 03c8c14aa1 Merge branch 'dev/qr' 2026-01-20 15:38:16 +09:00
chan 20e848deb6 리다이렉트 납치 방지 2026-01-20 15:27:35 +09:00
chan 4856801756 자식 창 로그인 우선순위 조정 2026-01-20 15:25:56 +09:00
kyy 85d3044ace Merge pull request 'dev/auth-auto-login' (#45) from dev/auth-auto-login into main
Reviewed-on: ai-team/baron-sso#45
2026-01-20 15:07:04 +09:00
kyy 8ea9f06a9a 인증되지 않은 사용자의 어드민 경로 접근 제한 2026-01-20 15:04:56 +09:00
kyy c0083c1b69 rexp와 exp 토큰 파싱 및 유효기간 확인 2026-01-20 15:03:19 +09:00
chan 431e3a7e05 root page 변경 2026-01-20 11:22:07 +09:00
chan 8c5d87a5d2 200 log x 2026-01-20 10:56:15 +09:00
chan 16552dac54 내용 변경 2026-01-20 10:17:17 +09:00
chan ffe96c8c65 헬스체크 추가 2026-01-20 10:10:50 +09:00
chan 79e5a22a97 Merge pull request 'dev/qr' (#43) from dev/qr into main
Reviewed-on: ai-team/baron-sso#43
2026-01-19 17:05:47 +09:00
chan 3c41c0dc62 aws ses 구현 2026-01-19 17:04:48 +09:00
chan 27b8ff2ac1 대시보드 qr 페이지 2026-01-19 15:09:07 +09:00
chan ebfd60f81a input 레이아웃 , 한글 번역 수정 2026-01-19 11:16:01 +09:00
chan b65ecc1b24 qr 로그인 2026-01-16 17:42:59 +09:00
chan 50385d510b Merge pull request 'dev/sso-from-b5aed4f' (#39) from dev/sso-from-b5aed4f into main
Reviewed-on: ai-team/baron-sso#39
2026-01-16 13:49:04 +09:00
chan e558de56c1 admin management 기능 추가 2026-01-16 13:48:16 +09:00
chan ca97bc84c9 pw add 2026-01-16 11:08:35 +09:00
chan 290d5c6c86 admin page add 2026-01-16 11:07:55 +09:00
kyy 1277fa8e46 Merge pull request 'SMS API 전화번호 형식 오류 수정' (#38) from dev/sso-from-placeholder into main
Reviewed-on: ai-team/baron-sso#38
2026-01-16 10:15:35 +09:00
kyy c6ef9b4393 SMS API 전화번호 형식 오류 수정 2026-01-16 10:13:41 +09:00
kyy bea549c899 Merge pull request '전화번호 기본값 변경' (#37) from dev/sso-from-placeholder into main
Reviewed-on: ai-team/baron-sso#37
2026-01-16 10:01:18 +09:00
kyy ffd2a95903 전화번호 기본값 변경 2026-01-16 09:56:02 +09:00
chan 5c79626518 Merge pull request '최초 로그인 창이 뜰 때 기본값으로 SMS 탭이 나오게 하기' (#36) from dev/sso-from-b5aed4f into main
Reviewed-on: ai-team/baron-sso#36
2026-01-16 09:13:24 +09:00
chan c51abd12dc 최초 로그인 창이 뜰 때 기본값으로 SMS 탭이 나오게 하기 2026-01-16 09:12:14 +09:00
chan 4526f08e5f Merge pull request 'dev/sso-from-b5aed4f' (#34) from dev/sso-from-b5aed4f into main
Reviewed-on: ai-team/baron-sso#34
2026-01-15 17:37:44 +09:00
chan 5dd2c94555 로그 포맷 통일, slog 적용 2026-01-15 14:16:34 +09:00
chan 2aff11bc5d front env 종속성 제거거 2026-01-15 14:15:39 +09:00
chan 2317eb857c log 추가 2026-01-14 15:00:46 +09:00
chan da5a5bd25a 의존성 버전 다운 2026-01-14 10:59:04 +09:00
chan 8b894cd90b 의존성 import 2026-01-14 10:58:32 +09:00
chan dcf827b9ee Merge pull request '하드코딩 변수 관리' (#31) from dev/sso-from-b5aed4f into main
Reviewed-on: ai-team/baron-sso#31
2026-01-14 10:10:07 +09:00
chan e02a9b2153 하드코딩 변수 관리 2026-01-14 10:01:51 +09:00
kyy ce901f3d49 Merge pull request '배포용 Docker compose 및 가이드 작성' (#30) from dev/dockerize into main
Reviewed-on: ai-team/baron-sso#30
2026-01-13 16:42:28 +09:00
kyy caae0a6815 배포용 Docker compose 및 가이드 작성 2026-01-13 16:40:43 +09:00
chan 7a6ffc038a Merge pull request 'dev/sso-from-b5aed4f' (#28) from dev/sso-from-b5aed4f into main
Reviewed-on: ai-team/baron-sso#28
2026-01-13 10:58:11 +09:00
chan 530694d613 로그인 url path 방식 수정 2026-01-13 10:56:05 +09:00
chan 59b5f8f7e9 변수 이름 수정 2026-01-13 10:31:18 +09:00
chan ee9f835ceb 토큰 8자리 2026-01-13 10:24:13 +09:00
chan b5aed4fedc only JWT 발급 2026-01-09 14:24:35 +09:00
kyy 4813ec2f6d Merge pull request 'dev/namecard2' (#13) from dev/namecard2 into main
Reviewed-on: ai-team/baron-sso#13
2026-01-06 17:07:32 +09:00
kyy 33444c00cd chore(docs): 개발 문서 초안 제거 2026-01-06 17:03:46 +09:00
kyy b43635111c frontend .env 2026-01-06 16:56:29 +09:00
kyy 160527b6ec Redis 인프라 추가 2026-01-06 16:34:05 +09:00
kyy c4b86f77f1 SMS 인증 코드 발송 및 검증 UI/로직 연동 2026-01-06 16:33:21 +09:00
kyy 659ccfbe53 SMS 발송 및 Redis 기반 인증 코드 검증, JWT 발급 기능 구현 2026-01-06 16:32:43 +09:00
kyy 362b6b60d4 SMS 기능 연동 설정 및 테스트 추가 2026-01-06 15:02:44 +09:00
kyy 8a25f143e7 SMS 인증을 위한 Naver SENS 연동 및 API 구현 2026-01-06 15:02:09 +09:00
kyy cd0ca7a421 SMS 인증 코드 발송 UI 및 백엔드 API 연동 2026-01-06 15:01:02 +09:00
kyy fec4a1cd55 ClickHouse 인증 설정 추가 2026-01-06 10:31:11 +09:00
chan c512f0f4e6 namecard 연동 2026-01-06 09:49:11 +09:00
Lectom C Han c56368d1cb 문서화 및 로직 정리. 2025-12-23 18:05:10 +09:00
Lectom C Han 904b35e6e6 논리 검사 계속. 스케폴딩 일부 진행 2025-12-23 17:59:37 +09:00
Lectom C Han 48589dca5d flutter 상위 구조로 포함. infra 분리 리팩토링 2025-12-23 17:20:27 +09:00
436 changed files with 63851 additions and 556 deletions
+124
View File
@@ -0,0 +1,124 @@
# ==========================================
# Baron SSO - Unified Environment Configuration
# ==========================================
# --- General System ---
APP_ENV=stage # 애플리케이션 실행 환경 (dev, stage, production)
TZ=Asia/Seoul
# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업)
IDP_PROVIDER=ory
# --- Infrastructure Ports ---
DB_PORT=5432
CLICKHOUSE_PORT_HTTP=8123
CLICKHOUSE_PORT_NATIVE=9000
BACKEND_PORT=3000
ADMINFRONT_PORT=5173
DEVFRONT_PORT=5174
USERFRONT_PORT=5000
# --- Database Credentials (PostgreSQL) ---
DB_USER=baron
DB_PASSWORD=password
DB_NAME=baron_sso
# --- Backend Configuration ---
# Must be 32 bytes. Generate with `openssl rand -hex 32`
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
JWT_SECRET=super-secret-key-must-be-32-bytes!
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
# Audit System Configuration
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
# Redis Cache Configuration
PROFILE_CACHE_TTL=30m # User Profile Redis 캐시 만료 시간
# --- Naver Cloud Services ---
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
NAVER_CLOUD_SECRET_KEY=ncp_iam_...
NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:...
NAVER_SENDER_PHONE_NUMBER=...
# --- AWS SES (이메일 발송용) ---
AWS_REGION=ap-northeast-2
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_SES_SENDER=no-reply@baron.co.kr
# --- 관리자 page pw ---
ADMIN_EMAIL=admin@baron.co.kr
ADMIN_PASSWORD=adminPasswordIsNotSimple
# --- URLs for Proxy/Handoff ---
# Project Public Base URL (Served by UserFront Nginx)
USERFRONT_URL=https://sso.hmac.kr
# Services proxied via Nginx
BACKEND_URL=${USERFRONT_URL}/api
OATHKEEPER_PUBLIC_URL=${USERFRONT_URL}
# ory-stack 변수들
ORY_POSTGRES_TAG=17-trixie
ORY_POSTGRES_USER=ory
ORY_POSTGRES_PASSWORD=EuBV5ywvXFehkggHQrnYo5727MseEi6i9
ORY_POSTGRES_DB=ory
# ORY_POSTGRES_PORT=5433 # Internal only
KRATOS_DB=ory_kratos
HYDRA_DB=ory_hydra
KETO_DB=ory_keto
# Ory Kratos Configuration
KRATOS_VERSION=v25.4.0-distroless
# KRATOS_PUBLIC_PORT=4433 # Internal only
# KRATOS_ADMINFRONT_PORT=4434 # Internal only
KRATOS_UI_NODE_VERSION=v25.4.0
# KRATOS_UI_PORT=4455 # Internal only
# Ory Hydra Configuration
HYDRA_VERSION=v25.4.0-distroless
# HYDRA_PUBLIC_PORT=4441 # Internal only
# HYDRA_ADMINFRONT_PORT=4445 # Internal only
# Ory Keto Configuration
KETO_VERSION=v25.4.0-distroless
# KETO_READ_PORT=4466 # Internal only
# KETO_WRITE_PORT=4467 # Internal only
# Kratos Selfservice UI upstreams (override for deployments)
ORY_SDK_URL=http://kratos:4433
KRATOS_PUBLIC_URL=http://kratos:4433
KRATOS_ADMIN_URL=http://kratos:4434
# 브라우저가 접근할 Kratos Public/UI 외부 URL
# Oathkeeper가 /auth 경로를 Kratos Public API로 라우팅합니다.
KRATOS_BROWSER_URL=${OATHKEEPER_PUBLIC_URL}/auth
# Kratos UI는 UserFront가 렌더링합니다.
KRATOS_UI_URL=http://localhost:5000
HYDRA_ADMIN_URL=http://hydra:4445
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
# Oathkeeper JWKS (내부 통신용)
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
# Oathkeeper 실행 사용자/프로브 설정
OATHKEEPER_VERSION=v25.4.0
OATHKEEPER_UID=1001
OATHKEEPER_GID=1001
OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready
OATHKEEPER_HEALTH_INTERVAL_SECONDS=10
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=2
OATHKEEPER_HEALTH_ENABLED=true
# Kratos Selfservice UI required secrets (local only)
COOKIE_SECRET=localcookie123
CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
CSRF_COOKIE_SECRET=localcsrf123
+7
View File
@@ -0,0 +1,7 @@
{
"Path": "./backend/coverage.out",
"Thresholds": {
"baron-sso-backend/internal/handler": 10,
"baron-sso-backend/internal/service": 10
}
}
@@ -0,0 +1,29 @@
name: Backend Test Coverage Check
on:
push:
branches:
- dev
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Run tests with coverage
working-directory: ./backend
run: |
go test -v -coverprofile=coverage.out -covermode=atomic ./internal/handler/... ./internal/service/...
- name: Check coverage
uses: vladopajic/go-test-coverage@v2
with:
config: ./.gitea/coverage.json
+117
View File
@@ -0,0 +1,117 @@
name: Build Baron SSO RC
on:
workflow_dispatch:
inputs:
version_tag:
description: "The version tag to release to staging (e.g., v1.2601.1)"
required: true
type: string
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install dependencies
run: sudo apt-get update && sudo apt-get install -y jq curl
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.HARBOR_ENDPOINT }}
username: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
password: ${{ secrets.HARBOR_ROBOT_KEY }}
- name: Calculate next RC tag
id: rc_calculator
env:
INPUT_TAG: ${{ github.event.inputs.version_tag }}
REGISTRY_URL: ${{ vars.HARBOR_ENDPOINT }}
HARBOR_USER: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
HARBOR_PASSWORD: ${{ secrets.HARBOR_ROBOT_KEY }}
run: |
# Generate YYMM dynamically for the new tag
CURRENT_YYMM=$(date +'%y%m')
# Reconstruct the base tag with the current YYMM
MAJOR_VERSION=$(echo "${INPUT_TAG}" | cut -d'.' -f1)
MINOR_VERSION=$(echo "${INPUT_TAG}" | cut -d'.' -f3)
BASE_TAG="${MAJOR_VERSION}.${CURRENT_YYMM}.${MINOR_VERSION}"
echo "Input tag: ${INPUT_TAG}"
echo "Generated dynamic base tag: ${BASE_TAG}"
# Using the backend repository as the source for RC version calculation
API_URL="${REGISTRY_URL}/api/v2.0/projects/baron_sso/repositories/backend/artifacts?sort=-creation_time&page_size=100"
AUTH_HEADER=$(echo -n "${HARBOR_USER}:${HARBOR_PASSWORD}" | base64)
API_RESPONSE=$(curl -s -k -H "Authorization: Basic ${AUTH_HEADER}" "${API_URL}")
# Define a search pattern to find RCs across different months for the same major/minor version
# e.g., matches v1.2508.1-RC, v1.2509.1-RC, etc.
SEARCH_PATTERN="^${MAJOR_VERSION}\.[0-9]{4}\.${MINOR_VERSION}-RC"
echo "Using search pattern: ${SEARCH_PATTERN}"
# Disable pipefail for grep, as it will exit with 1 if no match is found
set +o pipefail
# Find the highest RC number regardless of the YYMM part
LATEST_RC_NUM=$(echo "${API_RESPONSE}" | jq -r '.[] | .tags[]? | .name' | grep -E "${SEARCH_PATTERN}" | sed 's/.*-RC//' | sort -rn | head -n 1)
set -o pipefail
if [ -z "$LATEST_RC_NUM" ]; then
NEXT_RC_NUM=1
else
NEXT_RC_NUM=$((LATEST_RC_NUM + 1))
fi
# Create the new tag using the dynamically generated BASE_TAG and the incremented RC number
NEW_RC_TAG="${BASE_TAG}-RC${NEXT_RC_NUM}"
echo "new_rc_tag=$NEW_RC_TAG" >> $GITHUB_OUTPUT
echo "Found latest RC number: ${LATEST_RC_NUM:-0}"
echo "Calculated new RC tag: $NEW_RC_TAG"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push backend RC image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend:${{ steps.rc_calculator.outputs.new_rc_tag }}
provenance: false
sbom: false
- name: Build and push adminfront RC image
uses: docker/build-push-action@v5
with:
context: ./adminfront
file: ./adminfront/Dockerfile
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
provenance: false
sbom: false
- name: Build and push devfront RC image
uses: docker/build-push-action@v5
with:
context: ./devfront
file: ./devfront/Dockerfile
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
provenance: false
sbom: false
- name: Build and push userfront RC image
uses: docker/build-push-action@v5
with:
context: ./userfront
file: ./userfront/Dockerfile
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
provenance: false
sbom: false
+109
View File
@@ -0,0 +1,109 @@
name: Code Check
on:
workflow_dispatch:
inputs:
run_lint:
description: "Run linters for Go and Flutter"
required: true
type: boolean
default: true
run_backend_tests:
description: "Run backend Go tests"
required: true
type: boolean
default: true
run_userfront_tests:
description: "Run userfront Flutter tests"
required: true
type: boolean
default: true
jobs:
lint:
if: ${{ inputs.run_lint == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Lint Go backend
uses: golangci/golangci-lint-action@v6
with:
version: v1.59
working-directory: backend
args: --enable-only=gofmt,gofumpt
- name: Analyze Flutter userfront
run: |
cd userfront
flutter analyze --no-fatal-warnings --no-fatal-infos
backend-tests:
needs: lint
if: ${{ inputs.run_backend_tests == true }}
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
options: >
--health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
clickhouse:
image: clickhouse/clickhouse-server:24.6
options: >
--health-cmd "wget -qO- 'http://localhost:8123/ping'" --health-interval 10s --health-timeout 5s --health-retries 5
env:
REDIS_ADDR: redis:6379
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PORT_NATIVE: 9000
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Run backend tests
run: |
cd backend
go test -v ./...
userfront-tests:
needs: lint
if: ${{ inputs.run_userfront_tests == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Run userfront tests
run: |
cd userfront
if [ -d test ]; then
flutter test
else
echo "No userfront tests: skipping (test/ directory not found)."
fi
+144
View File
@@ -0,0 +1,144 @@
name: Release Baron SSO to Production
on:
workflow_dispatch:
inputs:
rc_version_tag:
description: "The version tag to release to production (e.g., v1.2601.1-RC1)"
required: true
type: string
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Login to Harbor Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.HARBOR_ENDPOINT }}
username: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
password: ${{ secrets.HARBOR_ROBOT_KEY }}
- name: Parse RC and re-tag to final
id: retag
env:
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
HARBOR_USER: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
HARBOR_PASSWORD: ${{ secrets.HARBOR_ROBOT_KEY }}
run: |
BASE_TAG=$(echo "${{ github.event.inputs.rc_version_tag }}" | xargs)
if [[ ! "$BASE_TAG" =~ ^v[0-9]+\.[0-9]{4}\.[0-9]+-RC[0-9]+$ ]]; then
echo "::error::rc_version_tag must look like vX.YYMM.Z-RC## (got: $BASE_TAG)"
exit 1
fi
RE_TAG="${BASE_TAG%-RC*}"
echo "Final tag will be: ${RE_TAG}"
if ! command -v skopeo >/dev/null 2>&1; then
sudo apt-get update -y && sudo apt-get install -y skopeo
fi
# Re-tag backend image
echo "Re-tagging backend image..."
skopeo copy --preserve-digests \
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
--src-tls-verify=false --dest-tls-verify=false \
"docker://${HARBOR_HOSTNAME}/baron_sso/backend:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/backend:${RE_TAG}"
# Re-tag userfront image
echo "Re-tagging userfront image..."
skopeo copy --preserve-digests \
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
--src-tls-verify=false --dest-tls-verify=false \
"docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${RE_TAG}"
echo "final_image_tag=${RE_TAG}" >> "$GITHUB_OUTPUT"
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
- name: Deploy to Production
env:
IMAGE_TAG: ${{ steps.retag.outputs.final_image_tag }}
BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
run: |
set -euo pipefail
echo "DEBUG: PROD_USER='${PROD_USER}'"
echo "DEBUG: PROD_HOST='${PROD_HOST}'"
echo "DEBUG: DEPLOY_PATH='${DEPLOY_PATH}'"
# Sanity check (fail fast with a clear message)
if [ -z "${PROD_USER}" ] || [ -z "${PROD_HOST}" ] || [ -z "${DEPLOY_PATH}" ]; then
echo "::error::Missing required vars (PROD_USER/PROD_HOST/DEPLOY_PATH). Check Gitea repo variables."
exit 1
fi
ssh-keyscan -H "${PROD_HOST}" >> ~/.ssh/known_hosts
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}'"
# Create the main .env file for Baron SSO on the remote server
# Note: All values are pulled from Gitea secrets and variables
printf '%s\n' \
"APP_ENV=production" \
"TZ=Asia/Seoul" \
"DB_PORT=${{ vars.PROD_DB_PORT }}" \
"CLICKHOUSE_PORT_HTTP=${{ vars.PROD_CLICKHOUSE_PORT_HTTP }}" \
"CLICKHOUSE_PORT_NATIVE=${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }}" \
"CLICKHOUSE_USER=${{ vars.PROD_CLICKHOUSE_USER }}" \
"CLICKHOUSE_PASSWORD=${{ secrets.PROD_CLICKHOUSE_PASSWORD }}" \
"BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
"USERFRONT_PORT=${{ vars.PROD_USERFRONT_PORT }}" \
"DB_USER=${{ vars.PROD_DB_USER }}" \
"DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}" \
"DB_NAME=${{ vars.PROD_DB_NAME }}" \
"COOKIE_SECRET=${{ secrets.PROD_COOKIE_SECRET }}" \
"JWT_SECRET=${{ secrets.PROD_JWT_SECRET }}" \
"REDIS_ADDR=${{ vars.PROD_REDIS_ADDR }}" \
"NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}" \
"NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}" \
"NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}" \
"NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }}" \
"AWS_REGION=${{ vars.AWS_REGION }}" \
"AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}" \
"AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}" \
"AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}" \
"USERFRONT_URL=${{ vars.PROD_USERFRONT_URL }}" \
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
> .env
# Copy compose template and .env file to the remote server
scp docker/docker-compose.template.yaml .env "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/"
scp docker/compose.infra.prd.yaml "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/compose.infra.yml"
# Deploy Baron SSO
echo "${HARBOR_ROBOT_KEY}" | ssh "${PROD_USER}@${PROD_HOST}" \
"export DEPLOY_PATH='${DEPLOY_PATH}'; \
export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
export IMAGE_TAG='${IMAGE_TAG}'; \
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
set -e; \
cd \"\${DEPLOY_PATH}\"; \
docker login \"\${HARBOR_ENDPOINT}\" -u \"\${HARBOR_ROBOT_ACCOUNT}\" --password-stdin; \
set -a; \
. ./.env; \
set +a; \
envsubst < docker-compose.template.yaml > docker-compose.yml; \
docker compose -f compose.infra.yml -f docker-compose.yml pull; \
docker compose -f compose.infra.yml -f docker-compose.yml up -d --remove-orphans"
+190
View File
@@ -0,0 +1,190 @@
name: Release Baron SSO to Staging
on:
workflow_dispatch:
inputs:
rc_version_tag:
description: "The version tag to deploy to staging (e.g., v1.2601.1-RC1)"
required: true
type: string
jobs:
deploy-staging:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
- name: Deploy to Staging
env:
IMAGE_TAG: ${{ github.event.inputs.rc_version_tag }}
BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
# Staging-specific variables
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
STAGE_HOST: ${{ vars.STAGE_HOST }}
STAGE_USER: ${{ vars.STAGE_USER }}
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
run: |
set -euo pipefail
echo "DEBUG: STAGE_USER='${STAGE_USER}'"
echo "DEBUG: STAGE_HOST='${STAGE_HOST}'"
echo "DEBUG: DEPLOY_PATH='${DEPLOY_PATH}'"
# Sanity check
if [ -z "${STAGE_USER}" ] || [ -z "${STAGE_HOST}" ] || [ -z "${DEPLOY_PATH}" ]; then
echo "::error::Missing required vars (STAGE_USER/STAGE_HOST/DEPLOY_PATH)."
exit 1
fi
ssh-keyscan -H "${STAGE_HOST}" >> ~/.ssh/known_hosts
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}'"
# .env 파일 생성
cat <<'EOF' > .env
APP_ENV=${{ vars.APP_ENV }}
TZ=Asia/Seoul
IDP_PROVIDER=ory
# DB & Clickhouse
DB_PORT=${{ vars.DB_PORT }}
CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }}
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
CLICKHOUSE_PASSWORD=${{ vars.CLICKHOUSE_PASSWORD }}
BACKEND_PORT=${{ vars.BACKEND_PORT }}
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
DB_USER=${{ vars.DB_USER }}
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
DB_NAME=${{ vars.DB_NAME }}
COOKIE_SECRET=${{ secrets.STG_COOKIE_SECRET }}
JWT_SECRET=${{ secrets.STG_JWT_SECRET }}
REDIS_ADDR=${{ vars.REDIS_ADDR }}
CORS_ALLOWED_ORIGINS=${{ vars.CORS_ALLOWED_ORIGINS }}
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }}
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }}
AWS_REGION=${{ vars.AWS_REGION }}
AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
USERFRONT_URL=${{ vars.USERFRONT_URL }}
BACKEND_URL=${{ vars.BACKEND_URL }}
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
ORY_POSTGRES_USER=${{ vars.ORY_POSTGRES_USER }}
ORY_POSTGRES_PASSWORD=${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
ORY_POSTGRES_DB=${{ vars.ORY_POSTGRES_DB }}
KRATOS_DB=${{ vars.KRATOS_DB }}
HYDRA_DB=${{ vars.HYDRA_DB }}
KETO_DB=${{ vars.KETO_DB }}
KRATOS_VERSION=${{ vars.KRATOS_VERSION }}
KRATOS_UI_NODE_VERSION=${{ vars.KRATOS_UI_NODE_VERSION }}
HYDRA_VERSION=${{ vars.HYDRA_VERSION }}
KETO_VERSION=${{ vars.KETO_VERSION }}
ORY_SDK_URL=${{ vars.ORY_SDK_URL }}
KRATOS_PUBLIC_URL=${{ vars.KRATOS_PUBLIC_URL }}
KRATOS_ADMIN_URL=${{ vars.KRATOS_ADMIN_URL }}
KRATOS_BROWSER_URL=${{ vars.KRATOS_BROWSER_URL }}
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
JWKS_URL=${{ vars.JWKS_URL }}
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
OATHKEEPER_GID=${{ vars.OATHKEEPER_GID }}
OATHKEEPER_HEALTH_URL=${{ vars.OATHKEEPER_HEALTH_URL }}
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }}
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
EOF
# 파일 복사
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker"
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
if [ -d "docker/init-metadata" ]; then
scp -r docker/init-metadata "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
fi
if [ -d "gateway" ]; then
scp -r gateway "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
fi
scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml"
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
# 배포 실행
echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \
"export DEPLOY_PATH='${DEPLOY_PATH}'; \
export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
export IMAGE_TAG='${IMAGE_TAG}'; \
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
cd \"\${DEPLOY_PATH}\"; \
docker login \"\${HARBOR_ENDPOINT}\" -u \"\${HARBOR_ROBOT_ACCOUNT}\" --password-stdin; \
set -a; . ./.env; set +a; \
# 네트워크 생성
for net in baron_net public_net ory-net hydranet kratosnet; do
docker network inspect \"\$net\" >/dev/null 2>&1 || docker network create \"\$net\"
done
envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
chmod -R 777 docker/ory
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml pull; \
# [주의] DB 초기화 스크립트는 '새로운 볼륨'에서만 실행됨.
# DB 초기화 문제를 확실히 해결하기 위해 기존 볼륨을 날리고 다시 띄움 (데이터 삭제됨 주의)
# 스테이징이므로 초기화 진행. 데이터 보존이 필요하면 이 줄 제거하고 수동으로 DB 만들어야 함.
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml down -v || true
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml up -d --remove-orphans; \
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
sleep 10; \
if [ \$(docker inspect -f '{{.State.ExitCode}}' baron-sso-staging-kratos-migrate-1) -ne 0 ]; then \
echo 'Kratos Migrate Failed. Logs:'; \
docker logs baron-sso-staging-kratos-migrate-1; \
exit 1; \
fi"
+32
View File
@@ -0,0 +1,32 @@
# General
.env
.temp
.DS_Store
.idea/
.vscode/
.codex
.codex/
*.swp
*.log
*.out
*.exe
# Docker Services Data (Volumes)
postgres_data/
clickhouse_data/
# Backend (Go)
backend/main
backend/bin/
backend/vendor/
backend/tmp/
backend/.env
backend/server
# userfront (Flutter)
# Note: userfront might have its own .gitignore, but adding here just in case
userfront/build/
userfront/.dart_tool/
userfront/.packages
userfront/.pub/
userfront/.env
+86
View File
@@ -0,0 +1,86 @@
# Baron SSO용 Docker Compose 헬퍼
# 환경 변수 로드
ifneq (,$(wildcard ./.env))
include .env
export
endif
# Compose 파일 경로
COMPOSE_INFRA := compose.infra.yaml
COMPOSE_ORY := compose.ory.yaml
COMPOSE_APP := docker-compose.yaml
# --- 기본 실행 ---
# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음)
up-all:
@echo "Starting ALL stacks (infra + ory + app)..."
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d
# --- 개별 스택 실행 ---
up-infra:
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
docker compose -f $(COMPOSE_INFRA) up -d
up-ory:
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
docker compose -f $(COMPOSE_ORY) up -d
up-app:
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
docker compose -f $(COMPOSE_APP) up -d
up-backend:
@echo "Starting Backend only..."
docker compose -f $(COMPOSE_APP) up -d backend
up-dev: up-infra up-ory
@echo "Dev stack is up (infra + ory)."
up-front-dev: up-infra up-ory up-backend
@echo "Dev stack is up (infra + ory + backend)."
# --- 종료 (Down) ---
down-all:
@echo "Stopping ALL stacks (infra + ory + app)..."
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
down-app:
@echo "Stopping App stack..."
docker compose -f $(COMPOSE_APP) down
down-backend:
@echo "Stopping Backend only..."
docker compose -f $(COMPOSE_APP) stop backend
down-infra:
@echo "Stopping Infra stack..."
docker compose -f $(COMPOSE_INFRA) down
down-ory:
@echo "Stopping Ory stack..."
docker compose -f $(COMPOSE_ORY) down
# --- 유틸리티 ---
# 인프라 상태 확인
check-infra:
@echo "Checking infra status..."
@if [ "$$(docker inspect -f '{{.State.Health.Status}}' baron_postgres 2>/dev/null)" != "healthy" ]; then \
echo "Error: PostgreSQL is not running or not healthy."; \
echo "Please run 'make up-infra' first."; \
exit 1; \
else \
echo "PostgreSQL is healthy."; \
fi
ps:
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) ps
logs-infra:
docker compose -f $(COMPOSE_INFRA) logs -f
logs-ory:
docker compose -f $(COMPOSE_ORY) logs -f
logs-app:
docker compose -f $(COMPOSE_APP) logs -f
+295
View File
@@ -0,0 +1,295 @@
# Baron SSO
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
* UserFront: Flutter로 구현된 커스텀 UI를 통해 매끄러운 사용자 경험을 보장합니다.
* 로그인 : 비밀번호, SMS, QR 스캔 등의 수단으로 로그인 가능
* 향후 모바일 앱 지원으로 인증 Push 승인 등 MFA 확장 (예정)
* 정보변경, 앱 연동 이력 확인 등 개인화 기능
* 사용 가능한 앱 리스트 (예정)
* AdminFront: 사용자 관리 등 Admin 기능
* DevFront: RP 관리 등 개발자 기능
## 🏗 아키텍처 (Architecture)
### 0. Ory Stack
- Ory Kratos: 사용자 인증/계정 관리(Identity).
- Ory Hydra: OAuth2/OIDC 발급 및 토큰 관리.
- Ory Keto: 권한/정책 기반 접근 제어.
- Oathkeeper: 인증/인가 프록시 및 라우팅 게이트웨이.
```mermaid
flowchart
subgraph Edge
OK["Oathkeeper<br/>(Only Public Entry)"]
end
subgraph App
BE["Backend<br/>(Only Upstream)"]
end
subgraph OryStack
KR[Kratos]
HY[Hydra]
KE[Keto]
KR --- HY --- KE
end
BE -->|Command| OryStack
OK -->|Query| KR
OK -->|Query| HY
OK -->|Query| KE
```
### 1. Backend (Go Fiber)
- **Language**: Go 1.25+
- **Framework**: Fiber v2.25+
- **Database**:
- **ClickHouse**: 감사 로그 (고성능 데이터 수집)
- **PostgreSQL**: 메타데이터 저장소 (Primary)
- **Features**:
- 인증용 SMS 발송 등 Ory-Stack으로 구현 어려운 부분 직접 구현
- `POST /api/v1/audit`: 감사 로그 수집 API
- userfront가 바라보는 backend
### 2. UserFront(Flutter Web/App)
- **Framework**: Flutter 3.32+
- **Key Packages**: `flutter_riverpod`, `go_router`
- **Features**:
- 탭 기반 로그인 UI (비밀번호 기반 / 링크 기반 / QR 기반 등)
### 3. adminfront(Web)
- **Framework**: Vite, React 19+, Shadcn/ui 등
- **Features**:
- 사용자 관리, 권한 부여 등 관리자 기능
- 앱 별 사용량(호출량) 등 통계
- 핵심 Audit 대상
### 4. devfront(Web)
- **Framework**: Vite, React 19+, Shadcn/ui 등
- **Features**:
- RP 등록 및 관리
- RP별 Consent 관리
### 4. 주요 시나리오 (Core Scenarios)
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
1.1. 단 약관동의(Consent) 이력이 없으면 Consent 단계로 이동
2. **Cross-Device Auth**: 이메일 SMS 등의 수단으로 링크를 전달받고 해당 링크를 사용자가 클릭하면 최초 로그인 요청한 세션이 활성화
2.1 향후 App Push 등 2차 인증 강화수단 검토 필요
3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환
### 전체 연결 구조도
```mermaid
flowchart TD
subgraph Clients ["External Clients"]
AF[adminfront]
DF[devfront]
UF["userfront"]
DS["일반SW"]
end
subgraph AppService ["Control Plane"]
BE["Backend (Command/Audit Controller)"]
end
subgraph OryBundle ["Ory Deployment Stack"]
direction TB
OK["Oathkeeper (Public Proxy/OIDC)"]
subgraph OryEngines ["Ory Services"]
direction LR
HY["Hydra"]
KR["Kratos"]
KE["Keto"]
end
ICH[(Internal Clickhouse)]
%% Internal Flow within Bundle
OK -->|Routing/Queries| OryEngines
OK -.->|Access/Usage Log| ICH
end
subgraph AuditDB ["Audit Storage"]
ECH[(External Clickhouse)]
end
%% Key Command Path
AF & DF & UF & DS ==>|Actions / Commands| BE
%% Backend Responsibilities
BE -->|Admin/State Control| OryEngines
BE -.->|Mandatory Audit Log| ECH
%% Connection Note (Hidden flow mentioned in logic)
%% OK is technically the entry for OIDC, but removed as per request
%% Styles
style OryBundle fill:#f8f9fa,stroke:#333,stroke-width:2px
style BE fill:#bbf,stroke:#333,stroke-width:2px
style ECH fill:#fdd,stroke:#333
style ICH fill:#dfd,stroke:#333
style OK fill:#f9f,stroke:#333
style OryEngines fill:#fff,stroke:#999,stroke-dasharray: 5 5
```
Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비지니스로직은 Backend를 통해서, 기본 인증 로직은 Ory Stack을 통해 진행됩니다.
---
## 🚀 시작하기 (Getting Started)
### 사전 요구사항 (Prerequisites)
- Docker & Docker Compose
- Flutter SDK (로컬 개발용)
- Go (로컬 백엔드 개발용)
### 환경 설정 (Environment Setup)
1. 예제 환경 설정 파일을 복사합니다.
```bash
cp .env.sample .env
```
2. **IDP 우선순위와 Ory 엔드포인트를 지정**합니다. 기본값은 Ory 입니다
```
IDP_PROVIDER=ory
KRATOS_ADMIN_URL=http://kratos:4434
HYDRA_ADMIN_URL=http://hydra:4445
HYDRA_PUBLIC_URL=http://hydra:4444
```
### 전체 스택 실행 (Running the Stack)
#### 1. 네트워크 생성 (최초 1회)
Ory Stack과 애플리케이션 간 통신을 위한 도커 네트워크를 생성합니다.
```bash
# ory-net은 bridge 모드로 생성
docker network create -d bridge ory-net
docker network create hydranet
docker network create kratosnet
docker network create public_net #서비스용
```
#### 2. 인프라 및 Ory Stack 실행
데이터베이스와 Ory 서비스(Kratos, Hydra, Keto 등)를 실행합니다.
```bash
docker compose -f compose.infra.yaml -f compose.ory.yaml up -d
```
#### 3. 애플리케이션 실행
userfront와 backend 서비스를 실행합니다.
```bash
docker compose -f docker-compose.yaml up -d
```
(또는 한번에 실행: `docker compose -f compose.infra.yaml -f compose.ory.yaml -f docker-compose.yaml up -d`)
- **gateway (UserFront 프록시)**: http://localhost:5000 접속
- **backend**: http://localhost:3000 (API)
- **ClickHouse**: http://localhost:8123
- **Kratos Public**: http://localhost:4433
- **Hydra Public**: http://localhost:4444
- **Kratos UI (UserFront)**: http://localhost:5000
### MCP 서버 (Hydra/Kratos/Keto)
MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다.
프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요.
```bash
docker compose -f compose.ory.yaml --profile mcp up -d hydra-mcp-server kratos-mcp-server keto-mcp-server
```
- MCP 서버는 stdio 기반이라 외부 포트를 열지 않습니다.
- 최초 실행시거나 빌드된 이미지가 없으면 `docker compose -f mcp/compose.mcp.ory.yaml build' 후에 사용 가능합니다
```toml
[mcp_servers.kratos-mcp]
command = "docker"
args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "kratos-mcp-server"]
[mcp_servers.kratos-mcp.env]
KRATOS_ADMIN_URL = "http://kratos:4434"
[mcp_servers.hydra-mcp]
command = "docker"
args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "hydra-mcp-server"]
[mcp_servers.hydra-mcp.env]
HYDRA_PUBLIC_URL = "http://hydra:4444"
HYDRA_ADMIN_URL = "http://hydra:4445"
[mcp_servers.keto-mcp]
command = "docker"
args = ["compose", "-f", "mcp/compose.mcp.ory.yaml", "run", "--rm", "--no-deps", "keto-mcp-server"]
[mcp_servers.keto-mcp.env]
KETO_READ_URL = "http://keto:4466"
KETO_WRITE_URL = "http://keto:4467"
```
### 로컬 개발 (Manual)
Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능.
백그라운드로 infra 및 ory stack이 구동중이라는 가정
**Backend:**
```bash
cd backend
go mod tidy
go run cmd/server/main.go
```
**userfront:**
```bash
cd userfront
flutter pub get
flutter run -d chrome
```
**adminfront:**
```bash
cd adminfront
npm install
npm run dev
```
**devfront:**
```bash
cd devfront
npm install
npm run dev
```
---
## 📂 프로젝트 구조 (Project Structure)
```
baron_sso/
├── backend/ # Go Fiber 애플리케이션
│ ├── cmd/server/ # 진입점 (Entry point)
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
│ └── Dockerfile
├── userfront/ # Flutter 애플리케이션
│ ├── src/ # UI 및 로직
│ └── pubspec.yaml
├── adminfront/ # React 기반 관리
│ ├── src/ # UI 및 로직
│ └── pubspec.yaml
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
├── docker-compose.yaml # 앱 서비스 (Front, Back)
├── .env.sample # 환경 설정 템플릿
└── README.md # 본 파일
```
## 📝 상태 및 로드맵 (Status & Roadmap)
- [x] **Phase 1**: 초기 설정 및 아키텍처 설계 (완료)
- [x] **Phase 2**: Backend Audit API 구현 (일부 완료)
- [ ] **Phase 3**: userfront 로그인 UI 인증 로직 (예정)
- [ ] **Phase 4**: adminfront 기능 추가 (예정)
- [ ] **Phase 5**: 대시보드 및 통합 런처 구현 (예정)
+110
View File
@@ -0,0 +1,110 @@
# Baron SSO
**Baron SSO** is a white-labeled User Authentication Hub and Unified Launcher.
It leverages **Descope** for secure, passwordless authentication (Enchanted Link / Magic Link) and provides a custom Flutter UI for a seamless user experience. A Go (Fiber) backend manages Audit Logs via ClickHouse.
## 🏗 Architecture
### 1. Frontend (Flutter Web)
- **Framework**: Flutter 3.32+
- **Organization**: `kr.co.baroncs`
- **Key Packages**: `descope`, `flutter_riverpod`, `go_router`
- **Features**:
- Login UI with Tabs (Email / SMS)
- Descope SDK Integration (Enchanted Link, Magic Link)
### 2. Backend (Go Fiber)
- **Language**: Go 1.25+
- **Framework**: Fiber v2.25+
- **Database**:
- **ClickHouse**: Audit Logs (High performance ingestion)
- **PostgreSQL**: Metadata storage (Primary)
- **Features**:
- `POST /api/v1/audit`: Endpoint to ingest audit logs.
### 3. Infrastructure (Docker)
- **Services**: `postgres`, `clickhouse` (defined in `compose.infra.yaml`)
- **App**: `userfront`, `backend` (defined in `docker-compose.yaml`)
---
## 🚀 Getting Started
### Prerequisites
- Docker & Docker Compose
- Flutter SDK (for local development)
- Go (for local backend development)
### Environment Setup
1. Copy the sample environment file.
```bash
cp .env.sample .env
```
2. Set the **IDP priority and Ory admin endpoints**. The default is Ory first with Descope as fallback.
```env
IDP_PROVIDER=ory,descope
KRATOS_ADMIN_URL=http://kratos:4434
HYDRA_ADMIN_URL=http://hydra:4445
```
### Running the Stack
#### 1. Start Infrastructure (Databases)
Start the persistent data layer first.
```bash
docker compose -f compose.infra.yaml up -d
```
#### 2. Start Applications
Start the userfront and backend services.
```bash
docker compose up
```
- **userfront**: Accessible at http://localhost:5000
- **backend**: API active at http://localhost:3000
- **ClickHouse**: http://localhost:8123
### Local Development (Manual)
If you prefer running without Docker for code editing:
**Backend:**
```bash
cd backend
go mod tidy
go run cmd/server/main.go
```
**userfront:**
```bash
cd userfront
flutter pub get
flutter run -d chrome
```
---
## 📂 Project Structure
```
baron_sso/
├── backend/ # Go Fiber Application
│ ├── cmd/server/ # Entry point
│ ├── internal/ # Domain, Handlers, Repository
│ └── Dockerfile
├── userfront/ # Flutter Application
│ ├── lib/ # UI & Logic
│ └── pubspec.yaml
├── compose.infra.yaml # DB Services (Postgres, ClickHouse)
├── docker-compose.yaml # App Services
├── .env.sample # Env Config Template
└── README.md # This file
```
## 📝 Status & Roadmap
- [x] **Phase 1**: Initial Setup & Architecture (Done)
- [x] **Phase 2**: Backend Audit API (Done)
- [x] **Phase 3**: Frontend Login UI & Descope Auth Logic (Done)
- [ ] **Phase 4**: Connect Frontend to Audit API (Todo)
- [ ] **Phase 5**: Dashboard & Unified Launcher (Todo)
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+25
View File
@@ -0,0 +1,25 @@
FROM node:lts
WORKDIR /app
# 패키지 정보 복사 및 의존성 설치
COPY package*.json ./
RUN npm ci
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
RUN npm install -g serve
# 소스 코드 복사
COPY . .
# Vite 기본 포트
EXPOSE 5173
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
CMD sh -c "if [ \"$APP_ENV\" = 'production' ]; then \
echo 'Running in production mode...'; \
npm run build && serve -s dist -l 5173; \
else \
echo 'Running in development mode...'; \
npm run dev -- --host 0.0.0.0; \
fi"
+29
View File
@@ -0,0 +1,29 @@
# Admin Front (React 19 + Vite)
관리자 포털을 위한 React/Vite 기반 웹입니다. 이슈 #60 스펙을 바탕으로 라우팅, 서버 상태, 스타일 토큰을 세팅했고 특정 벤더에 종속되지 않는 IDP 연동 훅 포인트를 남겨두었습니다.
## 주요 스택
- React 19, Vite 7, TypeScript(strict)
- React Router v6 (data router)
- TanStack Query v5
- Tailwind CSS v3 + shadcn/ui 컴포넌트(seed: Button/Card/Badge/Input/Table/Avatar)
- Axios 클라이언트 스텁: Bearer + `X-Tenant-ID` 헤더 주입 준비
- React Hook Form + Zod (추가 예정)
- Biome (formatter/linter)
## 실행
```bash
npm install
npm run dev
```
## 구조
- `src/app`: 라우터, QueryClient 등 전역 설정
- `src/components/layout`: App 레이아웃/네비게이션
- `src/features`: dashboard, clients, audit, auth 등 화면 스캐폴딩
- `src/lib/apiClient.ts`: Axios 인스턴스(토큰/테넌트 헤더 주입 스텁)
## 다음 작업 가이드
- IDP 중립 Auth 레이어 추가 후 관리자 역할 가드/세션 TTL 적용
- 테넌트 선택 UI 추가 → `X-Tenant-ID` 헤더에 반영
- shadcn/ui 도입 및 폼/테이블 컴포넌트 연결
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"style": {
"useEnumInitializers": "off"
},
"a11y": {
"noLabelWithoutControl": "off"
}
}
},
"organizeImports": {
"enabled": true
},
"files": {
"ignore": ["dist", "node_modules", "tsconfig*.json"]
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>바론 어드민 서비스</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3622
View File
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
{
"name": "adminfront",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "biome check .",
"lint:fix": "biome check . --write",
"format": "biome format . --write",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-scroll-area": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
"@tanstack/react-query-devtools": "^5.66.8",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-router-dom": "^6.28.2",
"tailwind-merge": "^3.4.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@playwright/test": "^1.58.0",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.2.5"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
}
}
File diff suppressed because one or more lines are too long
+59
View File
@@ -0,0 +1,59 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:5173",
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
/* Run your local dev server before starting the tests */
webServer: {
command: "npm run dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
},
});
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
});
+52
View File
@@ -0,0 +1,52 @@
import { createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout";
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
export const router = createBrowserRouter(
[
{
path: "/",
element: <AppLayout />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "dashboard", element: <DashboardPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "schema", element: <TenantSchemaPage /> },
],
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
],
},
],
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
{
future: {
v7_startTransition: true,
},
} as unknown as Parameters<typeof createBrowserRouter>[1],
);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,148 @@
import {
BadgeCheck,
Building2,
Key,
KeyRound,
LayoutDashboard,
Moon,
NotebookTabs,
ShieldHalf,
Sun,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
import RoleSwitcher from "./RoleSwitcher";
const navItems = [
{ label: "Overview", to: "/", icon: LayoutDashboard },
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
{ label: "Tenants", to: "/tenants", icon: Building2 },
{ label: "Users", to: "/users", icon: Users },
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
];
function AppLayout() {
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<ShieldHalf size={20} />
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Baron
</p>
<h1 className="text-lg font-semibold">
Admin Control
</h1>
</div>
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<BadgeCheck size={14} />
Scoped to /admin
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
IDP env: prod
</span>
<span className="rounded-full border border-border px-3 py-1">
Tenant-aware headers
</span>
</div>
<div className="flex flex-col gap-1">
{navItems.map(({ label, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{label}</span>
</NavLink>
))}
</div>
</nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
<p> /admin .</p>
<p>
IDP API로만 , ·
.
</p>
</div>
</aside>
<div className="relative">
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
Admin Plane
</p>
<span className="text-lg font-semibold">
Tenant isolation & least privilege by default
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<button
type="button"
onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
aria-label="테마 전환"
>
{theme === "light" ? (
<Sun size={16} />
) : (
<Moon size={16} />
)}
{theme === "light" ? "Light" : "Dark"}
</button>
<span className="rounded-full border border-border px-3 py-2 text-muted-foreground">
Session TTL: 15m admin
</span>
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
<RoleSwitcher />
</div>
</div>
);
}
export default AppLayout;
@@ -0,0 +1,68 @@
import React, { useState, useEffect } from 'react';
const RoleSwitcher: React.FC = () => {
const [currentRole, setCurrentRole] = useState<string>('super_admin');
useEffect(() => {
// localStorage에서 역할 읽기
const savedRole = window.localStorage.getItem('X-Mock-Role');
if (savedRole) {
setCurrentRole(savedRole);
} else {
// 기본값 설정
window.localStorage.setItem('X-Mock-Role', 'super_admin');
}
}, []);
const switchRole = (role: string) => {
// localStorage 설정
window.localStorage.setItem('X-Mock-Role', role);
setCurrentRole(role);
// 페이지 새로고침하여 권한 적용
window.location.reload();
};
if (import.meta.env.MODE === 'production') return null;
return (
<div style={{
position: 'fixed',
bottom: '20px',
right: '20px',
zIndex: 9999,
background: '#1A1F2C',
color: 'white',
padding: '10px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
fontSize: '12px'
}}>
<div style={{ fontWeight: 'bold', borderBottom: '1px solid #444', paddingBottom: '4px', marginBottom: '4px' }}>
🛠 DEV Role Switcher
</div>
{(['super_admin', 'tenant_admin', 'rp_admin', 'tenant_member'] as const).map(role => (
<button
key={role}
onClick={() => switchRole(role)}
style={{
background: currentRole === role ? '#3b82f6' : '#333',
color: 'white',
border: 'none',
padding: '4px 8px',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.2s'
}}
>
{role.toUpperCase().replace('_', ' ')} {currentRole === role ? '✅' : ''}
</button>
))}
</div>
);
};
export default RoleSwitcher;
+47
View File
@@ -0,0 +1,47 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { cn } from "../../lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-semibold text-foreground",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
+38
View File
@@ -0,0 +1,38 @@
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
import { cn } from "../../lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "text-foreground",
muted: "border-border bg-secondary/60 text-muted-foreground",
success:
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };
+56
View File
@@ -0,0 +1,56 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-6 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button, buttonVariants };
+72
View File
@@ -0,0 +1,72 @@
import type * as React from "react";
import { cn } from "../../lib/utils";
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
className,
)}
{...props}
/>
);
}
function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
);
}
function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
);
}
function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
}
function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}
function CardFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
);
}
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };
@@ -0,0 +1,44 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
import { cn } from "../../lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" && "h-2.5 border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
@@ -0,0 +1,16 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Separator = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("shrink-0 bg-border", "h-px w-full", className)}
{...props}
/>
));
Separator.displayName = "Separator";
export { Separator };
+26
View File
@@ -0,0 +1,26 @@
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "../../lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
+113
View File
@@ -0,0 +1,113 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-muted/50 font-medium text-foreground", className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/30 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-6 align-middle text-sm", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
@@ -0,0 +1,239 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { AlertCircle, Check, ChevronLeft, Copy, Loader2, Save, ShieldCheck } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import { cn } from "../../lib/utils";
import { createApiKey, type ApiKeyCreateRequest, type ApiKeyCreateResponse } from "../../lib/adminApi";
const AVAILABLE_SCOPES = [
{ id: "audit:read", label: "감사 로그 조회", desc: "시스템 내의 모든 이력을 조회할 수 있습니다." },
{ id: "audit:write", label: "감사 로그 생성", desc: "외부 앱의 로그를 Baron SSO로 전송합니다." },
{ id: "user:read", label: "사용자 조회", desc: "사용자 목록 및 프로필을 읽을 수 있습니다." },
{ id: "user:write", label: "사용자 관리", desc: "사용자 생성, 수정, 삭제 작업을 수행합니다." },
{ id: "tenant:read", label: "테넌트 조회", desc: "등록된 모든 조직 정보를 조회합니다." },
{ id: "tenant:write", label: "테넌트 관리", desc: "테넌트 정보를 직접 제어합니다." },
];
function ApiKeyCreatePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [createdResult, setCreatedResult] = React.useState<ApiKeyCreateResponse | null>(null);
const [selectedScopes, setSelectedScopes] = React.useState<string[]>(["audit:read", "user:read"]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<{ name: string }>({
defaultValues: { name: "" },
});
const mutation = useMutation({
mutationFn: (payload: ApiKeyCreateRequest) => createApiKey(payload),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["api-keys"] });
setCreatedResult(data);
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "API 키 생성에 실패했습니다.");
},
});
const toggleScope = (scopeId: string) => {
setSelectedScopes((prev) =>
prev.includes(scopeId) ? prev.filter((s) => s !== scopeId) : [...prev, scopeId]
);
};
const onSubmit = (data: { name: string }) => {
if (selectedScopes.length === 0) {
setError("최소 하나 이상의 권한을 선택해야 합니다.");
return;
}
setError(null);
mutation.mutate({ name: data.name, scopes: selectedScopes });
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
};
if (createdResult) {
return (
<div className="max-w-xl mx-auto py-12 space-y-8">
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center">
<ShieldCheck size={32} />
</div>
<h2 className="text-3xl font-bold tracking-tight">API </h2>
<p className="text-muted-foreground">
(Secret) <span className="text-destructive font-bold"> </span> .
</p>
</div>
<Card className="border-2 border-primary/20 shadow-xl">
<CardHeader className="bg-primary/5 border-b">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<AlertCircle size={16} className="text-primary" />
릿
</CardTitle>
</CardHeader>
<CardContent className="pt-8 pb-8 space-y-6">
<div className="space-y-3">
<Label className="text-xs font-bold text-muted-foreground uppercase tracking-widest">
X-Baron-Key-Secret
</Label>
<div className="relative group">
<Input
readOnly
value={createdResult.clientSecret}
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-primary/10"
onClick={() => handleCopy(createdResult.clientSecret)}
>
<Copy size={20} className="text-primary" />
</Button>
</div>
<p className="text-[11px] text-center text-muted-foreground italic">
( ) .
</p>
</div>
<div className="pt-4 flex flex-col gap-2">
<Button size="lg" className="w-full font-bold" asChild>
<Link to="/api-keys">. </Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="max-w-3xl mx-auto space-y-10">
<header className="flex items-center justify-between">
<div className="space-y-1">
<Button variant="ghost" size="sm" className="-ml-3 text-muted-foreground" onClick={() => navigate("/api-keys")}>
<ChevronLeft size={16} className="mr-1" />
</Button>
<h2 className="text-3xl font-bold tracking-tight"> API </h2>
<p className="text-muted-foreground"> .</p>
</div>
</header>
<div className="space-y-8">
{/* 섹션 1: 이름 설정 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">1</span>
<h3 className="font-semibold text-lg"> </h3>
</div>
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium"> </Label>
<Input
id="name"
placeholder="예: Jenkins-CI, Grafana-Dashboard"
className="text-base py-5"
{...register("name", { required: "이름은 필수입니다." })}
/>
{errors.name && <p className="text-sm text-destructive mt-1">{errors.name.message}</p>}
</div>
</CardContent>
</Card>
</section>
{/* 섹션 2: 권한 선택 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">2</span>
<h3 className="font-semibold text-lg"> (Scopes) </h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{AVAILABLE_SCOPES.map((scope) => {
const isSelected = selectedScopes.includes(scope.id);
return (
<button
key={scope.id}
type="button"
onClick={() => toggleScope(scope.id)}
className={cn(
"flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all",
isSelected
? "border-primary bg-primary/5 shadow-md"
: "border-border bg-card hover:border-muted-foreground/30"
)}
>
<div className="flex items-center justify-between w-full">
<span className={cn("font-bold text-sm", isSelected ? "text-primary" : "")}>{scope.label}</span>
<div className={cn(
"h-5 w-5 rounded-md flex items-center justify-center border",
isSelected ? "bg-primary border-primary" : "border-muted-foreground/30"
)}>
{isSelected && <Check size={12} className="text-primary-foreground" />}
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{scope.desc}
</p>
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">ID: {scope.id}</code>
</button>
);
})}
</div>
</section>
{/* 하단 실행 버튼 */}
<div className="pt-6 flex flex-col gap-4">
{error && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive p-4 rounded-lg flex items-center gap-3">
<AlertCircle size={20} />
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="flex items-center justify-between p-6 bg-muted/30 rounded-2xl border">
<div>
<p className="text-sm font-bold"> {selectedScopes.length} .</p>
<p className="text-xs text-muted-foreground"> .</p>
</div>
<Button
onClick={handleSubmit(onSubmit)}
size="lg"
className="px-8 font-bold shadow-lg shadow-primary/20"
disabled={mutation.isPending}
>
{mutation.isPending ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Save className="mr-2 h-5 w-5" />
)}
API
</Button>
</div>
</div>
</div>
</div>
);
}
export default ApiKeyCreatePage;
@@ -0,0 +1,172 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Key, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
function ApiKeyListPage() {
const query = useQuery({
queryKey: ["api-keys", { limit: 50, offset: 0 }],
queryFn: () => fetchApiKeys(50, 0),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteApiKey(id),
onSuccess: () => {
query.refetch();
},
});
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "API 키 목록 조회에 실패했습니다." : null;
const items = query.data?.items ?? [];
const handleDelete = (id: string, name: string) => {
if (!window.confirm(`API 키 "${name}"를 삭제할까요?`)) {
return;
}
deleteMutation.mutate(id);
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>API Keys</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold">API (M2M)</h2>
<p className="text-sm text-[var(--color-muted)]">
(Machine-to-Machine) API
.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
</Button>
<Button asChild>
<Link to="/api-keys/new">
<Plus size={16} />
API
</Link>
</Button>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>API Key Registry</CardTitle>
<CardDescription>
{query.data?.total ?? 0} API
</CardDescription>
</div>
<Badge variant="muted">System</Badge>
</CardHeader>
<CardContent>
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg ?? fallbackError}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>CLIENT ID</TableHead>
<TableHead>SCOPES</TableHead>
<TableHead>LAST USED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}> ...</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}> API .</TableCell>
</TableRow>
)}
{items.map((key) => (
<TableRow key={key.id}>
<TableCell className="font-semibold">
<div className="flex items-center gap-2">
<Key size={14} className="text-[var(--color-muted)]" />
{key.name}
</div>
</TableCell>
<TableCell>
<code>{key.client_id}</code>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{key.scopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="text-[10px]"
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
: "Never"}
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default ApiKeyListPage;
@@ -0,0 +1,562 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronDown,
ChevronUp,
Copy,
ListChecks,
RefreshCw,
Search,
Terminal,
} from "lucide-react";
import * as React from "react";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import type { AuditLog } from "../../lib/adminApi";
import { fetchAuditLogs } from "../../lib/adminApi";
const defaultAuditFilters = [
"method:POST path:/api/v1/*",
"status:failure",
"latency_ms:>1000",
];
type AuditDetails = {
request_id?: string;
method?: string;
path?: string;
status?: number;
latency_ms?: number;
error?: string;
tenant_id?: string;
actor_id?: string;
action?: string;
target?: string;
before?: unknown;
after?: unknown;
};
function parseDetails(details?: string): AuditDetails {
if (!details) {
return {};
}
try {
const parsed = JSON.parse(details);
if (parsed && typeof parsed === "object") {
return parsed as AuditDetails;
}
} catch {}
return {};
}
function formatCellValue(value: unknown) {
if (value === null || value === undefined || value === "") {
return "-";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function formatIsoDateTime(value: string) {
if (!value) {
return { date: "-", time: "-" };
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return { date: value, time: "-" };
}
const date = parsed.toISOString().slice(0, 10);
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
return { date, time };
}
function AuditLogsPage() {
const [filters, setFilters] = React.useState(defaultAuditFilters);
const [filterDraft, setFilterDraft] = React.useState("");
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
const {
data,
isLoading,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isFetching,
refetch,
} = useInfiniteQuery({
queryKey: ["audit-logs"],
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const logs =
data?.pages?.flatMap(
(page) =>
page?.items?.filter((item): item is AuditLog =>
Boolean(item),
) ?? [],
) ?? [];
const handleAddFilter = () => {
const trimmed = filterDraft.trim();
if (!trimmed) {
return;
}
setFilters((prev) =>
prev.includes(trimmed) ? prev : [...prev, trimmed],
);
setFilterDraft("");
};
if (isLoading) {
return <div className="p-8 text-center">Loading audit logs...</div>;
}
if (error) {
const errMsg =
(error as AxiosError<{ error?: string }>).response?.data?.error ??
(error as Error).message;
return (
<div className="p-8 text-center text-red-500">
Error loading logs: {errMsg}
</div>
);
}
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Audit</span>
<span>/</span>
<span className="text-foreground">Logs</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
Command ClickHouse .
/ .
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw size={16} />
</Button>
<Button>
<ListChecks size={16} />
Export CSV
</Button>
</div>
</header>
<div className="space-y-4">
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Audit registry</CardTitle>
<CardDescription>
{logs.length}
</CardDescription>
</div>
<Badge variant="muted">Command only</Badge>
</CardHeader>
<CardContent>
<div className="mb-4 flex flex-wrap items-center gap-2">
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]">
<Search size={14} />
<input
value={filterDraft}
onChange={(event) =>
setFilterDraft(event.target.value)
}
onKeyDown={(event) => {
if (event.key === "Enter") {
handleAddFilter();
}
}}
placeholder="필터 추가 (예: status:failure)"
className="w-full bg-transparent text-sm text-foreground outline-none"
/>
<Button
size="sm"
variant="outline"
onClick={handleAddFilter}
>
</Button>
</div>
{filters.length === 0 ? (
<span className="text-xs text-[var(--color-muted)]">
</span>
) : (
filters.map((filter) => (
<span
key={filter}
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
>
<Terminal size={12} />
{filter}
<button
type="button"
onClick={() =>
setFilters((prev) =>
prev.filter(
(item) =>
item !== filter,
),
)
}
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
aria-label={`${filter} 필터 제거`}
>
×
</button>
</span>
))
)}
</div>
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">
TIME
</TableHead>
<TableHead className="w-[160px]">
ACTOR (ID)
</TableHead>
<TableHead>REQUEST</TableHead>
<TableHead>PATH</TableHead>
<TableHead className="w-[120px]">
STATUS
</TableHead>
<TableHead>Action / Target</TableHead>
<TableHead className="w-[80px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={7}>
...
</TableCell>
</TableRow>
)}
{!isLoading && logs.length === 0 && (
<TableRow>
<TableCell colSpan={7}>
.
</TableCell>
</TableRow>
)}
{logs.map((row, index) => {
const details = parseDetails(row.details);
const actionLabel =
details.action ||
(details.method && details.path
? `${details.method} ${details.path}`
: row.event_type);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const isExpanded = Boolean(
expandedRows[rowKey],
);
return (
<React.Fragment key={rowKey}>
<TableRow className="bg-card/40">
<TableCell className="text-xs text-[var(--color-muted)]">
{(() => {
const { date, time } =
formatIsoDateTime(
row.timestamp,
);
return (
<div className="space-y-1">
<div>
{date}
</div>
<div>
{time}
</div>
</div>
);
})()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{row.user_id ||
details.actor_id ||
"-"}
</code>
{(row.user_id ||
details.actor_id) && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label="Copy actor id"
onClick={() =>
handleCopy(
row.user_id ||
details.actor_id ||
"",
)
}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="flex items-start gap-2">
<span className="break-all">
{formatCellValue(
details.request_id,
)}
</span>
{details.request_id && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label="Copy request id"
onClick={() =>
handleCopy(
details.request_id ||
"",
)
}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="font-semibold text-foreground">
{formatCellValue(
details.method,
)}
</div>
<div className="break-all">
{formatCellValue(
details.path,
)}
</div>
</TableCell>
<TableCell>
<Badge
variant={
row.status ===
"success" ||
row.status === "ok"
? "success"
: "warning"
}
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="font-semibold text-foreground">
{actionLabel}
</div>
{details.target && (
<div className="flex items-center gap-2">
<span className="break-all">
Target ·{" "}
{details.target}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label="Copy target"
onClick={() =>
handleCopy(
details.target ||
"",
)
}
>
<Copy className="h-3 w-3" />
</Button>
</div>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
setExpandedRows(
(prev) => ({
...prev,
[rowKey]:
!isExpanded,
}),
)
}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="bg-card/20">
<TableCell
colSpan={7}
className="text-xs"
>
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
Request
</div>
<div className="break-all">
Request ID ·{" "}
{formatCellValue(
details.request_id,
)}
</div>
<div className="break-all">
Event ID ·{" "}
{formatCellValue(
row.event_id,
)}
</div>
<div>
IP ·{" "}
{formatCellValue(
row.ip_address,
)}
</div>
<div>
Latency ·{" "}
{details.latency_ms !==
undefined
? `${details.latency_ms}ms`
: "-"}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
Actor
</div>
<div>
Actor ID ·{" "}
{row.user_id ||
details.actor_id ||
"-"}
</div>
<div>
Tenant ·{" "}
{formatCellValue(
details.tenant_id,
)}
</div>
<div>
Device ·{" "}
{formatCellValue(
row.device_id,
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
Result
</div>
<div className="break-all">
Error ·{" "}
{formatCellValue(
details.error,
)}
</div>
<div className="break-all">
Before ·{" "}
{formatCellValue(
details.before,
)}
</div>
<div className="break-all">
After ·{" "}
{formatCellValue(
details.after,
)}
</div>
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
<div className="pt-4 text-center">
{hasNextPage ? (
<Button
variant="outline"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? "Loading..."
: "Load more"}
</Button>
) : (
<span className="text-xs text-[var(--color-muted)]">
End of audit feed
</span>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}
export default AuditLogsPage;
+111
View File
@@ -0,0 +1,111 @@
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
const flows = [
{
title: "Admin login",
description:
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
pill: "15m TTL",
},
{
title: "Tenant pick",
description:
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
pill: "Header-ready",
},
{
title: "Device approval",
description:
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
pill: "App session",
},
];
function AuthPage() {
return (
<div className="space-y-8">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Admin auth
</p>
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
<p className="text-sm text-[var(--color-muted)]">
Build the admin-only login flow first, keeping app login separate.
Respect the fallback only when user chooses rule for SMS/email
vs app approval.
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
IDP session placeholder
</span>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
>
<Sparkles size={14} />
Connect auth layer
</button>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{flows.map((flow) => (
<div
key={flow.title}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
>
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
<span>{flow.pill}</span>
<Fingerprint size={14} />
</div>
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
<p className="text-sm text-[var(--color-muted)]">
{flow.description}
</p>
</div>
))}
</section>
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Smartphone size={16} />
<span className="text-xs uppercase tracking-[0.18em]">
App-based approvals
</span>
</div>
<h3 className="mt-2 text-xl font-semibold">
App session as MFA replacement
</h3>
<p className="text-sm text-[var(--color-muted)]">
If the admin keeps the mobile app signed in and opts in, use
push/deeplink approval instead of OTP. Otherwise fall back to
SMS/email based on user choice.
</p>
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<ArrowRight size={16} />
<span className="text-xs uppercase tracking-[0.18em]">
TTL discipline
</span>
</div>
<h3 className="mt-2 text-xl font-semibold">
Keep admin sessions short
</h3>
<p className="text-sm text-[var(--color-muted)]">
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
with step-up MFA when critical actions (rotate secret, export logs)
happen.
</p>
</div>
</section>
</div>
);
}
export default AuthPage;
@@ -0,0 +1,243 @@
import {
Activity,
ArrowRight,
Building2,
CheckCircle2,
LineChart,
Radio,
ShieldCheck,
Sparkles,
} from "lucide-react";
const guardHighlights = [
{
title: "Tenant isolation",
body: "All admin calls expect X-Tenant-ID and are prepared for tenant-aware headers.",
metric: "Header guard",
accent: "active",
},
{
title: "Admin TTL",
body: "Session budget kept short for admins. App session vs admin session split is explicit.",
metric: "15m default",
accent: "ttl",
},
{
title: "Audit-first",
body: "Every management action should log to ClickHouse. Hooks in place for later wiring.",
metric: "per-action",
accent: "audit",
},
];
const stackReadiness = [
"React 19 + Vite 7, strict TS, Router v6 data router.",
"TanStack Query 5 provider ready with sane defaults.",
"Axios client stub with bearer + tenant header injector.",
"Tailwind v4 tokens tuned for admin dark plane.",
"React Hook Form + Zod planned for client forms.",
"IdP-neutral auth hook point reserved for role guard.",
];
const nextSteps = [
"Add IdP-neutral OIDC/OAuth auth layer and enforce admin role in RequireAuth.",
"Persist tenant picklist and feed X-Tenant-ID for every admin call.",
"Add shadcn/ui primitives for forms and tables; lock lint/format.",
];
function DashboardPage() {
return (
<div className="space-y-10">
<section className="relative overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-7 shadow-[var(--shadow-card)]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_24%_20%,rgba(54,211,153,0.14),transparent_32%)]" />
<div className="relative flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-3 max-w-2xl">
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
<Sparkles size={14} />
adminfront ready
</div>
<h2 className="text-3xl font-semibold leading-tight">
Build the admin plane with{" "}
<span className="text-[var(--color-accent)]">tenant-aware</span>{" "}
defaults and{" "}
<span className="text-[var(--color-accent-strong)]">
least privilege
</span>{" "}
UX.
</h2>
<p className="text-[var(--color-muted)]">
Route, query, and styling scaffolds are in place. Use this canvas
to ship RP registry, audit exploration, and guarded login aligned
with issue #60 while keeping providers swappable.
</p>
<div className="flex flex-wrap gap-3 text-sm">
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
Router + Query wired
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
Admin namespace only
</span>
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
Auth hook pending
</span>
</div>
</div>
<div className="grid gap-3 text-sm">
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<ShieldCheck size={16} />
Admin guard scoped to /admin
</div>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<Building2 size={16} />
Tenant selection placeholder ready
</div>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<Radio size={16} />
Audit stream hook for ClickHouse
</div>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{guardHighlights.map((item) => (
<div
key={item.title}
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
<div className="relative flex items-center justify-between gap-2">
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
{item.metric}
</div>
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
{item.accent}
</span>
</div>
<div className="relative mt-3 space-y-2">
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="text-sm text-[var(--color-muted)]">{item.body}</p>
</div>
</div>
))}
</section>
<section className="grid gap-6 md:grid-cols-[1.2fr,0.8fr]">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Stack readiness
</p>
<h3 className="text-xl font-semibold">Matches issue #60</h3>
</div>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
>
Setup notes
<ArrowRight size={14} />
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{stackReadiness.map((item) => (
<div
key={item}
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
>
<CheckCircle2
size={16}
className="text-[var(--color-accent)]"
/>
<p className="text-sm">{item}</p>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Next actions
</p>
<h3 className="mt-2 text-xl font-semibold">
Ship the first guarded flows
</h3>
<div className="mt-4 space-y-3">
{nextSteps.map((item, idx) => (
<div
key={item}
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
{idx + 1}
</div>
<p className="text-sm text-[var(--color-text)]">{item}</p>
</div>
))}
</div>
</div>
</section>
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Ops board
</p>
<h3 className="text-xl font-semibold">What to prototype next</h3>
</div>
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
Audit ClickHouse
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
RP registry
</span>
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-3">
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<LineChart size={16} />
<span className="text-xs uppercase tracking-[0.16em]">
Metrics
</span>
</div>
<h4 className="mt-2 text-lg font-semibold">
RP registration funnel
</h4>
<p className="text-sm text-[var(--color-muted)]">
Visualize create secret rotate redirect URI edits per tenant.
</p>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Activity size={16} />
<span className="text-xs uppercase tracking-[0.16em]">Audit</span>
</div>
<h4 className="mt-2 text-lg font-semibold">Admin action stream</h4>
<p className="text-sm text-[var(--color-muted)]">
Live feed of admin API calls with per-action tenant, actor, and
rate-limit outcome.
</p>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<ShieldCheck size={16} />
<span className="text-xs uppercase tracking-[0.16em]">
Access
</span>
</div>
<h4 className="mt-2 text-lg font-semibold">Admin login journey</h4>
<p className="text-sm text-[var(--color-muted)]">
Outline SMS + app-based MFA choice and emphasize admin session
TTL with logout.
</p>
</div>
</div>
</section>
</div>
);
}
export default DashboardPage;
@@ -0,0 +1,170 @@
import {
Activity,
ArrowUpRight,
Box,
Database,
ShieldCheck,
Users,
} from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
const summaryCards = [
{
label: "Total Tenants",
value: "-",
hint: "Tenant-aware core",
icon: Users,
},
{
label: "OIDC Clients",
value: "-",
hint: "Hydra registry",
icon: ShieldCheck,
},
{
label: "Audit Events (24h)",
value: "-",
hint: "ClickHouse stream",
icon: Activity,
},
{
label: "Policy Gate",
value: "Planned",
hint: "Keto + Admin checks",
icon: Database,
},
];
function GlobalOverviewPage() {
return (
<div className="space-y-10">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Global Overview
</p>
<h2 className="text-3xl font-semibold">
Tenant-independent control plane
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="muted">IDP: Ory primary</Badge>
<Badge variant="muted">Fallback: Descope</Badge>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map(({ label, value, hint, icon: Icon }) => (
<Card key={label} className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{label}</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Icon size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{value}</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">{hint}</p>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl">Admin playbook</CardTitle>
<CardDescription>
, , .
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-[var(--color-muted)]">
<div className="flex items-start gap-3">
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
<ShieldCheck size={14} />
</div>
<div>
<p className="font-semibold text-foreground">
Backend-only IDP access
</p>
<p>
IDP backend를 , Hydra/Kratos
admin .
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
<Box size={14} />
</div>
<div>
<p className="font-semibold text-foreground">
Tenant isolation
</p>
<p>
Tenant , Keto
.
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl"> </CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/tenants/new">
<ArrowUpRight size={16} />
</Link>
</Button>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/audit-logs">
<ArrowUpRight size={16} />
</Link>
</Button>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/dashboard">
<ArrowUpRight size={16} />
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
export default GlobalOverviewPage;
@@ -0,0 +1,172 @@
import { useMutation } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { createTenant } from "../../../lib/adminApi";
function TenantCreatePage() {
const navigate = useNavigate();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
const mutation = useMutation({
mutationFn: () =>
createTenant({
name,
slug: slug || undefined,
description: description || undefined,
status,
domains: domains
.split(",")
.map((d) => d.trim())
.filter((d) => d !== ""),
}),
onSuccess: () => {
navigate("/tenants");
},
});
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
return (
<div className="space-y-8">
<header className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>/</span>
<span className="text-foreground">Create</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
.
</p>
</div>
<Badge variant="muted">Admin only</Badge>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} />
Tenant Profile
</CardTitle>
<CardDescription>
. Slug는 .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="tenant-slug"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)
</Label>
<Input
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder="example.com, example.kr"
/>
<p className="text-xs text-muted-foreground">
Users with these email domains will be automatically assigned to
this tenant.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
Active
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
Inactive
</Button>
</div>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
</div>
)}
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles size={18} />
</CardTitle>
<CardDescription>
Tenant Keto .
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-[var(--color-muted)]">
, .
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3">
<Button variant="outline" onClick={() => navigate("/tenants")}>
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || name.trim() === ""}
>
</Button>
</div>
</div>
);
}
export default TenantCreatePage;
@@ -0,0 +1,81 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { fetchTenant } from "../../../lib/adminApi";
function TenantDetailPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const location = useLocation();
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId!),
enabled: !!tenantId,
});
const isFederationTab = location.pathname.includes("/federation");
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/tenants" className="inline-flex items-center gap-2">
<ArrowLeft size={14} />
Tenants
</Link>
<span>/</span>
<span className="text-foreground">Detail</span>
</div>
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ?? "Loading Tenant..."}
</h2>
<p className="text-sm text-[var(--color-muted)]">
Edit tenant information or manage federation settings.
</p>
</div>
<Badge variant="muted">Admin only</Badge>
</header>
{/* Tabs */}
<div className="flex border-b">
<Link
to={`/tenants/${tenantId}`}
className={`px-4 py-2 text-sm font-medium ${
!isFederationTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Profile
</Link>
<Link
to={`/tenants/${tenantId}/federation`}
className={`px-4 py-2 text-sm font-medium ${
isFederationTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Federation
</Link>
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-4 py-2 text-sm font-medium ${
location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
}`}
>
Schema
</Link>
</div>
{/* Outlet for nested routes */}
<Outlet />
</div>
);
}
export default TenantDetailPage;
@@ -0,0 +1,221 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, RefreshCw, Trash2, Users, UserPlus, UserMinus, Shield } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchGroups, createGroup, deleteGroup, fetchUsers, addGroupMember, removeGroupMember } from "../../../lib/adminApi";
import { Badge } from "../../../components/ui/badge";
function TenantGroupsPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
// 그룹 목록 조회
const groupsQuery = useQuery({
queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId!),
enabled: !!tenantId,
});
// 사용자 목록 조회 (멤버 추가용)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100 }],
queryFn: () => fetchUsers(100, 0),
});
const createMutation = useMutation({
mutationFn: () => createGroup(tenantId!, { name: newGroupName, description: newGroupDesc }),
onSuccess: () => {
groupsQuery.refetch();
setNewGroupName("");
setNewGroupNameDesc("");
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteGroup(id),
onSuccess: () => groupsQuery.refetch(),
});
const addMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => addGroupMember(groupId, userId),
onSuccess: () => groupsQuery.refetch(),
});
const removeMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => removeGroupMember(groupId, userId),
onSuccess: () => groupsQuery.refetch(),
});
const handleAddMember = (groupId: string) => {
const userId = window.prompt("추가할 사용자의 UUID를 입력하세요:");
if (userId) {
addMemberMutation.mutate({ groupId, userId });
}
};
const currentGroup = groupsQuery.data?.find(g => g.id === selectedGroupId);
return (
<div className="space-y-6 mt-6">
<div className="grid gap-6 md:grid-cols-3">
{/* 그룹 생성 폼 */}
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="name"> </Label>
<Input
id="name"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
placeholder="예: 개발팀, 인사팀"
/>
</div>
<div className="space-y-1">
<Label htmlFor="desc"></Label>
<Input
id="desc"
value={newGroupDesc}
onChange={e => setNewGroupNameDesc(e.target.value)}
placeholder="그룹 용도 설명"
/>
</div>
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
>
</Button>
</CardContent>
</Card>
{/* 그룹 목록 */}
<Card className="bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Groups</CardTitle>
<CardDescription> .</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => groupsQuery.refetch()}>
<RefreshCw size={14} />
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>MEMBERS</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.data?.map((group) => (
<TableRow
key={group.id}
className={`cursor-pointer ${selectedGroupId === group.id ? 'bg-primary/5' : ''}`}
onClick={() => setSelectedGroupId(group.id)}
>
<TableCell>
<div className="font-semibold flex items-center gap-2">
<Users size={14} className="text-muted-foreground" />
{group.name}
</div>
<p className="text-[10px] text-muted-foreground">{group.description}</p>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.members?.length || 0} </Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleAddMember(group.id); }}>
<UserPlus size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(group.id); }}>
<Trash2 size={14} className="text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
{currentGroup && (
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" />
[{currentGroup.name}]
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow><TableCell colSpan={3} className="text-center py-4 text-muted-foreground"> .</TableCell></TableRow>
)}
{currentGroup.members?.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => removeMemberMutation.mutate({ groupId: currentGroup.id, userId: user.id })}
>
<UserMinus size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}
export default TenantGroupsPage;
@@ -0,0 +1,182 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { deleteTenant, fetchTenants } from "../../../lib/adminApi";
function TenantListPage() {
const navigate = useNavigate();
const query = useQuery({
queryKey: ["tenants", { limit: 50, offset: 0 }],
queryFn: () => fetchTenants(50, 0),
});
const deleteMutation = useMutation({
mutationFn: (tenantId: string) => deleteTenant(tenantId),
onSuccess: () => {
query.refetch();
},
});
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "테넌트 목록 조회에 실패했습니다." : null;
const items = query.data?.items ?? [];
const handleDelete = (tenantId: string, tenantName: string) => {
if (!window.confirm(`테넌트 "${tenantName}"를 삭제할까요?`)) {
return;
}
deleteMutation.mutate(tenantId);
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
</Button>
<Button asChild>
<Link to="/tenants/new">
<Plus size={16} />
</Link>
</Button>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Tenant registry</CardTitle>
<CardDescription>
{query.data?.total ?? 0}
</CardDescription>
</div>
<Badge variant="muted">Admin only</Badge>
</CardHeader>
<CardContent>
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg ?? fallbackError}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>SLUG</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>UPDATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}> ...</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}>
.
</TableCell>
</TableRow>
)}
{items.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">{tenant.name}</TableCell>
<TableCell>{tenant.slug}</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
className={
tenant.status === "pending"
? "bg-yellow-100 text-yellow-800"
: ""
}
>
{tenant.status}
</Badge>
</TableCell>
<TableCell>
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<Pencil size={14} />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(tenant.id, tenant.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantListPage;
@@ -0,0 +1,218 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import {
approveTenant,
deleteTenant,
fetchTenant,
updateTenant,
} from "../../../lib/adminApi";
export function TenantProfilePage() {
const { tenantId } = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
if (!tenantId) {
return <div>Tenant ID is missing</div>;
}
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
});
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
useEffect(() => {
if (tenantQuery.data) {
setName(tenantQuery.data.name);
setSlug(tenantQuery.data.slug);
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
}
}, [tenantQuery.data]);
const updateMutation = useMutation({
mutationFn: () =>
updateTenant(tenantId, {
name,
slug,
description: description || undefined,
status,
domains: domains
.split(",")
.map((d) => d.trim())
.filter((d) => d !== ""),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Tenant updated successfully");
},
});
const approveMutation = useMutation({
mutationFn: () => approveTenant(tenantId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Tenant approved successfully");
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteTenant(tenantId),
onSuccess: () => {
navigate("/tenants");
},
});
const errorMsg = (updateMutation.error as AxiosError<{ error?: string }>)
?.response?.data?.error;
const loadError = (tenantQuery.error as AxiosError<{ error?: string }>)
?.response?.data?.error;
const handleDelete = () => {
if (window.confirm("Are you sure you want to delete this tenant?")) {
deleteMutation.mutate();
}
};
const handleApprove = () => {
if (window.confirm("Approve this tenant?")) {
approveMutation.mutate();
}
};
return (
<>
<Card className="bg-[var(--color-panel)] mt-6">
<CardHeader>
<CardTitle>Tenant profile</CardTitle>
<CardDescription>
Changes to slug and status are applied immediately.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loadError && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{loadError}
</div>
)}
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)
</Label>
<Input
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder="example.com, example.kr"
/>
<p className="text-xs text-muted-foreground">
Users with these email domains will be automatically assigned to
this tenant.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
Active
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
Inactive
</Button>
</div>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
</div>
)}
</CardContent>
</Card>
<div className="mt-8 flex flex-wrap items-center justify-between gap-3">
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
Delete
</Button>
<div className="flex items-center gap-2">
{status === "pending" && (
<Button
variant="default"
className="bg-green-600 hover:bg-green-700"
onClick={handleApprove}
disabled={approveMutation.isPending}
>
Approve Tenant
</Button>
)}
<Button variant="outline" onClick={() => navigate("/tenants")}>
Cancel
</Button>
<Button
onClick={() => updateMutation.mutate()}
disabled={
updateMutation.isPending ||
tenantQuery.isLoading ||
name.trim() === ""
}
>
<Save size={16} />
Save
</Button>
</div>
</div>
</>
);
}
@@ -0,0 +1,152 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
type SchemaField = {
key: string;
label: string;
type: "text" | "number" | "boolean";
required: boolean;
};
export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
if (!tenantId) return <div>Tenant ID missing</div>;
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
});
const [fields, setFields] = useState<SchemaField[]>([]);
useEffect(() => {
if (tenantQuery.data?.config?.userSchema) {
setFields(tenantQuery.data.config.userSchema as SchemaField[]);
}
}, [tenantQuery.data]);
const updateMutation = useMutation({
mutationFn: (newFields: SchemaField[]) =>
updateTenant(tenantId, {
config: {
...tenantQuery.data?.config,
userSchema: newFields,
},
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Schema updated successfully");
},
onError: (err: AxiosError<{ error?: string }>) => {
alert(err.response?.data?.error || "Failed to update schema");
},
});
const addField = () => {
setFields([...fields, { key: "", label: "", type: "text", required: false }]);
};
const removeField = (index: number) => {
setFields(fields.filter((_, i) => i !== index));
};
const updateField = (index: number, updates: Partial<SchemaField>) => {
const newFields = [...fields];
newFields[index] = { ...newFields[index], ...updates };
setFields(newFields);
};
return (
<div className="space-y-6 mt-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>User Schema Extension</CardTitle>
<CardDescription>
Define custom attributes for users in this tenant.
</CardDescription>
</div>
<Button onClick={addField} size="sm">
<Plus size={16} className="mr-2" />
Add Field
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{fields.length === 0 && (
<div className="py-8 text-center text-muted-foreground border border-dashed rounded-md">
No custom fields defined. Click "Add Field" to begin.
</div>
)}
{fields.map((field, index) => (
<div key={index} className="flex items-end gap-4 p-4 border rounded-md bg-muted/30">
<div className="flex-1 space-y-2">
<Label>Field Key (ID)</Label>
<Input
value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })}
placeholder="e.g. employee_id"
/>
</div>
<div className="flex-1 space-y-2">
<Label>Display Label</Label>
<Input
value={field.label}
onChange={(e) => updateField(index, { label: e.target.value })}
placeholder="e.g. 사번"
/>
</div>
<div className="w-32 space-y-2">
<Label>Type</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
value={field.type}
onChange={(e) => updateField(index, { type: e.target.value as any })}
>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => removeField(index)}
>
<Trash2 size={16} />
</Button>
</div>
))}
</CardContent>
</Card>
<div className="flex justify-end">
<Button
onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading}
>
<Save size={16} className="mr-2" />
Save Schema Changes
</Button>
</div>
</div>
);
}
@@ -0,0 +1,87 @@
import { useQuery } from "@tanstack/react-query";
import { Building2, Plus, ArrowRight } from "lucide-react";
import { Link, useParams, useNavigate } from "react-router-dom";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card";
import { Button } from "../../../components/ui/button";
import { Badge } from "../../../components/ui/badge";
import { fetchTenants } from "../../../lib/adminApi";
function TenantSubTenantsPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const { data, isLoading } = useQuery({
queryKey: ["sub-tenants", tenantId],
queryFn: () => fetchTenants(50, 0, tenantId),
enabled: !!tenantId,
});
const subTenants = data?.items ?? [];
return (
<Card className="mt-6 bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
Sub-tenants ({subTenants.length})
</CardTitle>
<CardDescription> .</CardDescription>
</div>
<Button size="sm" asChild>
<Link to={`/tenants/new?parentId=${tenantId}`}>
<Plus size={14} className="mr-1" />
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>SLUG</TableHead>
<TableHead>STATUS</TableHead>
<TableHead className="text-right">ACTION</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subTenants.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{subTenants.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-semibold">{t.name}</TableCell>
<TableCell className="text-xs font-mono">{t.slug}</TableCell>
<TableCell>
<Badge variant={t.status === "active" ? "default" : "secondary"}>
{t.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => navigate(`/tenants/${t.id}`)}>
<ArrowRight size={12} className="ml-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
export default TenantSubTenantsPage;
@@ -0,0 +1,91 @@
import { useQuery } from "@tanstack/react-query";
import { User, Mail, Phone, ShieldCheck } from "lucide-react";
import { useParams } from "react-router-dom";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card";
import { Badge } from "../../../components/ui/badge";
import { fetchUsers, fetchTenant } from "../../../lib/adminApi";
function TenantUsersPage() {
const { tenantId } = useParams<{ tenantId: string }>();
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId!),
enabled: !!tenantId,
});
const companyCode = tenantQuery.data?.slug;
// 해당 슬러그로 사용자 검색
const usersQuery = useQuery({
queryKey: ["users", { companyCode }],
queryFn: () => fetchUsers(100, 0, companyCode),
enabled: !!companyCode,
});
const users = usersQuery.data?.items ?? [];
return (
<Card className="mt-6 bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User size={18} className="text-primary" />
Tenant Members ({users.length})
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-semibold">{user.name}</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{user.role.replace("_", " ")}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "default" : "muted"}>
{user.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
);
}
export default TenantUsersPage;
@@ -0,0 +1,371 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, ClipboardCopy, Loader2, Save } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
createUser,
fetchTenants,
fetchTenant,
type UserCreateRequest,
type UserCreateResponse,
} from "../../lib/adminApi";
function UserCreatePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = React.useState<string | null>(null);
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
const [autoPassword, setAutoPassword] = React.useState(true);
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
});
const tenants = tenantsData?.items ?? [];
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
defaultValues: {
email: "",
password: "",
name: "",
phone: "",
role: "user",
companyCode: "",
department: "",
metadata: {},
},
});
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (data: UserCreateResponse) => {
queryClient.invalidateQueries({ queryKey: ["users"] });
if (data.initialPassword) {
setGeneratedPassword(data.initialPassword);
setCreatedEmail(data.email);
return;
}
navigate("/users");
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "사용자 생성에 실패했습니다.");
},
});
const onSubmit = (data: UserCreateRequest) => {
setError(null);
setGeneratedPassword(null);
setCreatedEmail(null);
if (autoPassword) {
mutation.mutate({ ...data, password: "" });
return;
}
if (!data.password) {
setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요.");
return;
}
mutation.mutate(data);
};
const onCopyPassword = async () => {
if (!generatedPassword) return;
try {
await navigator.clipboard.writeText(generatedPassword);
} catch (_) {
// ignore
}
};
return (
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
</Link>
<span>/</span>
<span className="text-foreground">New</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
</Link>
</Button>
</header>
{generatedPassword && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
{createdEmail ? `${createdEmail} 계정의 초기 비밀번호입니다.` : "초기 비밀번호가 생성되었습니다."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed px-4 py-3">
<span className="font-mono text-sm">{generatedPassword}</span>
<Button size="sm" variant="outline" onClick={onCopyPassword}>
<ClipboardCopy className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="flex justify-end">
<Button onClick={() => navigate("/users")}> </Button>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input
id="email"
placeholder="user@example.com"
{...register("email", { required: "이메일은 필수입니다." })}
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password"></Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={autoPassword}
onChange={(event) => setAutoPassword(event.target.checked)}
/>
</label>
</div>
<Input
id="password"
type="password"
placeholder="********"
disabled={autoPassword}
{...register("password")}
/>
<p className="text-xs text-muted-foreground">
{autoPassword
? "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
: "초기 비밀번호를 직접 설정합니다."}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
placeholder="010-1234-5678"
{...register("phone")}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value=""> ( )</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<div className="relative">
<select
id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<p className="text-xs text-muted-foreground">
.
</p>
</div>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => navigate("/users")}
>
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
export default UserCreatePage;
@@ -0,0 +1,359 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, Loader2, Save } from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
fetchUser,
fetchTenants,
fetchTenant,
updateUser,
type UserUpdateRequest,
} from "../../lib/adminApi";
function UserDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [successMsg, setSuccessMsg] = React.useState<string | null>(null);
const { data: user, isLoading, isError } = useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id!),
enabled: !!id,
});
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
});
const tenants = tenantsData?.items ?? [];
const {
register,
handleSubmit,
reset,
watch,
formState: { errors },
} = useForm<UserUpdateRequest & { metadata: Record<string, any> }>({
defaultValues: {
name: "",
phone: "",
role: "user",
status: "active",
companyCode: "",
department: "",
password: "",
metadata: {},
},
});
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
React.useEffect(() => {
if (user) {
reset({
name: user.name,
phone: user.phone || "",
role: user.role,
status: user.status,
companyCode: user.companyCode || "",
department: user.department || "",
password: "",
metadata: user.metadata || {},
});
}
}, [user, reset]);
const mutation = useMutation({
mutationFn: (data: UserUpdateRequest) => updateUser(id!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", id] });
setSuccessMsg("사용자 정보가 수정되었습니다.");
setError(null);
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "사용자 수정에 실패했습니다.");
setSuccessMsg(null);
},
});
const onSubmit = (data: UserUpdateRequest) => {
const payload = { ...data };
if (!payload.password) {
delete payload.password;
}
mutation.mutate(payload);
};
if (isLoading) {
return <div className="p-8 text-center">Loading...</div>;
}
if (isError || !user) {
return (
<div className="p-8 text-center text-destructive">
.
</div>
);
}
return (
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
</Link>
<span>/</span>
<span className="text-foreground">{user.name}</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
</Link>
</Button>
</header>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
{user.email} .
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{error && (
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
)}
{successMsg && (
<div className="rounded-md bg-green-500/15 p-3 text-sm text-green-500">
{successMsg}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Input
id="phone"
placeholder="010-1234-5678"
{...register("phone")}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="status"></Label>
<div className="relative">
<select
id="status"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("status")}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="blocked">Blocked</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<div className="relative">
<select
id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="companyCode"> (Tenant)</Label>
<div className="relative">
<select
id="companyCode"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("companyCode")}
>
<option value=""> ( )</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
)}
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground"> </h3>
<div className="space-y-2">
<Label htmlFor="password"> </Label>
<Input
id="password"
type="password"
placeholder="변경할 경우에만 입력"
{...register("password")}
/>
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
</div>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => navigate("/users")}
>
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
export default UserDetailPage;
@@ -0,0 +1,285 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronLeft,
ChevronRight,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
User,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { deleteUser, fetchUsers } from "../../lib/adminApi";
function UserListPage() {
const navigate = useNavigate();
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState("");
const [searchDraft, setSearchDraft] = React.useState("");
const limit = 50;
const offset = (page - 1) * limit;
const query = useQuery({
queryKey: ["users", { limit, offset, search }],
queryFn: () => fetchUsers(limit, offset, search),
placeholderData: (previousData) => previousData,
});
const deleteMutation = useMutation({
mutationFn: (userId: string) => deleteUser(userId),
onSuccess: () => {
query.refetch();
},
});
const handleSearch = () => {
setSearch(searchDraft);
setPage(1);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "사용자 목록 조회에 실패했습니다." : null;
const items = query.data?.items ?? [];
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
React.useEffect(() => {
if (items.length > 0) {
console.log("User items:", items);
}
}, [items]);
const handleDelete = (userId: string, userName: string) => {
if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) {
return;
}
deleteMutation.mutate(userId);
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Users</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
. (Local DB)
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
</Button>
<Button asChild>
<Link to="/users/new">
<Plus size={16} />
</Link>
</Button>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Registry</CardTitle>
<CardDescription>
{total} .
</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="이름 또는 이메일 검색..."
className="pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<Button variant="secondary" onClick={handleSearch}>
</Button>
</div>
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg ?? fallbackError}
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME / EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>TENANT / DEPT</TableHead>
<TableHead>CREATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
...
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
.
</TableCell>
</TableRow>
)}
{items.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{user.role}</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "secondary"
}
>
{user.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium text-blue-600">
{user.tenant?.name || user.companyCode || "-"}
</span>
{user.tenant && (
<span className="text-[10px] text-muted-foreground uppercase">
Slug: {user.tenant.slug}
</span>
)}
<span className="text-xs text-muted-foreground">
{user.department || "-"}
</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
aria-label={`사용자 수정: ${user.name}`}
>
<Pencil size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
aria-label={`사용자 삭제: ${user.name}`}
>
<Trash2 size={16} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || query.isFetching}
>
<ChevronLeft size={16} />
Previous
</Button>
<div className="text-sm text-muted-foreground">
Page {page} of {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || query.isFetching}
>
Next
<ChevronRight size={16} />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default UserListPage;
+83
View File
@@ -0,0 +1,83 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 210 25% 6%;
--foreground: 210 35% 96%;
--card: 215 32% 9%;
--card-foreground: 210 35% 96%;
--popover: 215 32% 9%;
--popover-foreground: 210 35% 96%;
--primary: 209 79% 52%;
--primary-foreground: 210 35% 96%;
--secondary: 215 25% 16%;
--secondary-foreground: 210 35% 96%;
--muted: 215 15% 65%;
--muted-foreground: 215 15% 65%;
--accent: 42 95% 57%;
--accent-foreground: 215 25% 10%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 35% 96%;
--border: 215 25% 24%;
--input: 215 25% 24%;
--ring: 209 79% 52%;
--radius: 0.75rem;
}
.light {
--background: 0 0% 98%;
--foreground: 223 25% 12%;
--card: 0 0% 100%;
--card-foreground: 223 25% 12%;
--popover: 0 0% 100%;
--popover-foreground: 223 25% 12%;
--primary: 209 79% 52%;
--primary-foreground: 0 0% 100%;
--secondary: 220 17% 94%;
--secondary-foreground: 223 25% 20%;
--muted: 223 15% 45%;
--muted-foreground: 223 15% 45%;
--accent: 40 96% 62%;
--accent-foreground: 223 25% 12%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 220 17% 90%;
--input: 220 17% 90%;
--ring: 209 79% 52%;
}
* {
@apply border-border;
}
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient(
circle at 10% 18%,
rgba(54, 211, 153, 0.16),
transparent 28%
),
radial-gradient(
circle at 78% 4%,
rgba(249, 168, 38, 0.14),
transparent 24%
),
radial-gradient(
circle at 50% 90%,
rgba(54, 211, 153, 0.08),
transparent 30%
);
}
a {
@apply text-inherit no-underline;
}
}
@layer components {
.glass-panel {
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
}
}
+321
View File
@@ -0,0 +1,321 @@
import apiClient from "./apiClient";
export type AuditLog = {
event_id: string;
timestamp: string;
user_id: string;
event_type: string;
status: string;
ip_address: string;
user_agent: string;
device_id?: string;
details?: string;
};
export type AuditLogListResponse = {
items: AuditLog[];
limit: number;
cursor?: string;
next_cursor?: string;
};
export type TenantSummary = {
id: string;
name: string;
slug: string;
description: string;
status: string;
domains?: string[];
config?: Record<string, any>;
createdAt: string;
updatedAt: string;
};
export type TenantCreateRequest = {
name: string;
slug?: string;
description?: string;
status?: string;
domains?: string[];
config?: Record<string, any>;
};
export type TenantListResponse = {
items: TenantSummary[];
limit: number;
offset: number;
total: number;
};
export type TenantUpdateRequest = {
name?: string;
slug?: string;
description?: string;
status?: string;
domains?: string[];
config?: Record<string, any>;
};
export type ApiKeySummary = {
id: string;
name: string;
client_id: string;
scopes: string[];
status: string;
lastUsedAt?: string;
createdAt: string;
};
export type ApiKeyListResponse = {
items: ApiKeySummary[];
total: number;
};
export type RoleSummary = {
id: string;
name: string;
description: string;
permissions: string[];
createdAt: string;
updatedAt: string;
};
export type RoleListResponse = {
items: RoleSummary[];
total: number;
};
export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor },
});
return data;
}
export async function fetchTenants(limit = 50, offset = 0) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
{
params: { limit, offset },
},
);
return data;
}
export async function fetchTenant(tenantId: string) {
const { data } = await apiClient.get<TenantSummary>(
`/v1/admin/tenants/${tenantId}`,
);
return data;
}
export async function createTenant(payload: TenantCreateRequest) {
const { data } = await apiClient.post<TenantSummary>(
"/v1/admin/tenants",
payload,
);
return data;
}
export async function updateTenant(
tenantId: string,
payload: TenantUpdateRequest,
) {
const { data } = await apiClient.put<TenantSummary>(
`/v1/admin/tenants/${tenantId}`,
payload,
);
return data;
}
export async function deleteTenant(tenantId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
}
export async function approveTenant(tenantId: string) {
const { data } = await apiClient.post<TenantSummary>(
`/v1/admin/tenants/${tenantId}/approve`,
);
return data;
}
// API Key Management (M2M)
export type ApiKeyCreateRequest = {
name: string;
scopes: string[];
};
export type ApiKeyCreateResponse = {
apiKey: ApiKeySummary;
clientSecret: string;
};
export async function fetchApiKeys(limit = 50, offset = 0) {
const { data } = await apiClient.get<ApiKeyListResponse>(
"/v1/admin/api-keys",
{
params: { limit, offset },
},
);
return data;
}
export async function createApiKey(payload: ApiKeyCreateRequest) {
const { data } = await apiClient.post<ApiKeyCreateResponse>(
"/v1/admin/api-keys",
payload,
);
return data;
}
export async function deleteApiKey(apiKeyId: string) {
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
}
// User Management
export type UserSummary = {
id: string;
email: string;
name: string;
phone?: string;
role: string;
status: string;
companyCode?: string;
tenant?: TenantSummary;
metadata?: Record<string, any>;
department?: string;
createdAt: string;
updatedAt: string;
};
export type UserListResponse = {
items: UserSummary[];
limit: number;
offset: number;
total: number;
};
export type UserCreateRequest = {
email: string;
password?: string;
name: string;
phone?: string;
role?: string;
companyCode?: string;
department?: string;
};
export type UserCreateResponse = UserSummary & {
initialPassword?: string;
};
export type UserUpdateRequest = {
password?: string;
name?: string;
phone?: string;
role?: string;
status?: string;
companyCode?: string;
department?: string;
};
export async function fetchUsers(limit = 50, offset = 0, search?: string) {
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search },
});
return data;
}
export async function fetchUser(userId: string) {
const { data } = await apiClient.get<UserSummary>(
`/v1/admin/users/${userId}`,
);
return data;
}
export async function createUser(payload: UserCreateRequest) {
const { data } = await apiClient.post<UserCreateResponse>(
"/v1/admin/users",
payload,
);
return data;
}
export async function updateUser(userId: string, payload: UserUpdateRequest) {
const { data } = await apiClient.put<UserSummary>(
`/v1/admin/users/${userId}`,
payload,
);
return data;
}
export async function deleteUser(userId: string) {
await apiClient.delete(`/v1/admin/users/${userId}`);
}
// Relying Party Management
export type RelyingParty = {
clientId: string;
tenantId: string;
name: string;
description: string;
createdAt: string;
updatedAt: string;
};
export type HydraClientReq = {
client_id?: string;
client_name: string;
client_secret?: string;
redirect_uris: string[];
scope?: string;
token_endpoint_auth_method?: string;
grant_types?: string[];
response_types?: string[];
metadata?: Record<string, any>;
};
export async function fetchRelyingParties(tenantId: string) {
const { data } = await apiClient.get<RelyingParty[]>(
`/v1/admin/tenants/${tenantId}/relying-parties`,
);
return data;
}
export async function fetchAllRelyingParties() {
const { data } = await apiClient.get<RelyingParty[]>(
"/v1/admin/relying-parties",
);
return data;
}
export async function createRelyingParty(
tenantId: string,
payload: HydraClientReq,
) {
const { data } = await apiClient.post<RelyingParty>(
`/v1/admin/tenants/${tenantId}/relying-parties`,
payload,
);
return data;
}
export async function fetchRelyingParty(id: string) {
const { data } = await apiClient.get<{
relyingParty: RelyingParty;
oauth2Config: HydraClientReq;
}>(`/v1/admin/relying-parties/${id}`);
return data;
}
export async function updateRelyingParty(id: string, payload: HydraClientReq) {
const { data } = await apiClient.put<RelyingParty>(
`/v1/admin/relying-parties/${id}`,
payload,
);
return data;
}
export async function deleteRelyingParty(id: string) {
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
}
+37
View File
@@ -0,0 +1,37 @@
import axios from "axios";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_ADMIN_API_BASE ?? "/api",
});
apiClient.interceptors.request.use((config) => {
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
const sessionToken = window.localStorage.getItem("admin_session");
if (sessionToken) {
config.headers.Authorization = `Bearer ${sessionToken}`;
}
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
const tenantId = window.localStorage.getItem("admin_tenant");
if (tenantId) {
config.headers["X-Tenant-ID"] = tenantId;
}
// [Development Only] Inject Mock Role from RoleSwitcher
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (mockRole) {
config.headers["X-Test-Role"] = mockRole;
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
return Promise.reject(error);
},
);
export default apiClient;
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+21
View File
@@ -0,0 +1,21 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/routes";
import "./index.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
+67
View File
@@ -0,0 +1,67 @@
import type { Config } from "tailwindcss";
import { fontFamily } from "tailwindcss/defaultTheme";
const config: Config = {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "1.5rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["Space Grotesk", "Pretendard Variable", ...fontFamily.sans],
},
boxShadow: {
card: "0 12px 40px rgba(7, 15, 26, 0.25)",
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;
+4
View File
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}
+8
View File
@@ -0,0 +1,8 @@
import { expect, test } from "@playwright/test";
test("has title", async ({ page }) => {
await page.goto("/");
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/바론 어드민 서비스/);
});
+139
View File
@@ -0,0 +1,139 @@
import { expect, test } from "@playwright/test";
type UserSummary = {
id: string;
email: string;
name: string;
phone?: string;
role: string;
status: string;
companyCode?: string;
department?: string;
createdAt: string;
updatedAt: string;
};
type UserCreatePayload = {
email: string;
password?: string;
name: string;
phone?: string;
role?: string;
companyCode?: string;
department?: string;
};
test("user create and delete flow", async ({ page }) => {
const users: UserSummary[] = [];
let idSeq = 1;
await page.route("**/api/v1/admin/users**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const isCollection = path.endsWith("/api/v1/admin/users");
const isItem = path.includes("/api/v1/admin/users/");
if (request.method() === "GET" && isCollection) {
const search = url.searchParams.get("search")?.toLowerCase() ?? "";
const limit = Number(url.searchParams.get("limit") ?? "50");
const offset = Number(url.searchParams.get("offset") ?? "0");
const filtered = search
? users.filter(
(user) =>
user.name.toLowerCase().includes(search) ||
user.email.toLowerCase().includes(search),
)
: users;
const items = filtered.slice(offset, offset + limit);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items,
limit,
offset,
total: filtered.length,
}),
});
return;
}
if (request.method() === "POST" && isCollection) {
const payload = request.postDataJSON() as UserCreatePayload;
const now = new Date().toISOString();
const user: UserSummary = {
id: `user-${idSeq++}`,
email: payload.email,
name: payload.name,
phone: payload.phone,
role: payload.role ?? "user",
status: "active",
companyCode: payload.companyCode,
department: payload.department,
createdAt: now,
updatedAt: now,
};
users.unshift(user);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(user),
});
return;
}
if (request.method() === "DELETE" && isItem) {
const userId = path.split("/").pop();
const index = users.findIndex((user) => user.id === userId);
if (index === -1) {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "User not found" }),
});
return;
}
users.splice(index, 1);
await route.fulfill({ status: 204, body: "" });
return;
}
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "Not found" }),
});
});
await page.goto("/users");
await page.getByRole("link", { name: "사용자 추가" }).click();
await expect(page).toHaveURL(/\/users\/new$/);
const uniqueEmail = `playwright-${Date.now()}@example.com`;
await page.getByRole("checkbox", { name: "자동 생성" }).setChecked(false);
await page.getByLabel("이메일").fill(uniqueEmail);
await page.getByLabel("비밀번호").fill("Test1234!");
await page.getByLabel("이름").fill("Playwright User");
await page.getByLabel("전화번호").fill("010-0000-0000");
await page.getByLabel("회사 코드").fill("E2E");
await page.getByLabel("부서").fill("QA");
await page.getByLabel("역할 (Role)").selectOption("admin");
await page.getByRole("button", { name: "사용자 생성" }).click();
await expect(page).toHaveURL(/\/users$/);
const createdRow = page.locator("tbody tr").filter({ hasText: uniqueEmail });
await expect(createdRow).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await createdRow.getByRole("button", { name: /사용자 삭제/ }).click();
await expect(
page.locator("tbody tr").filter({ hasText: uniqueEmail }),
).toHaveCount(0);
});
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+16
View File
@@ -0,0 +1,16 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
proxy: {
"/api": {
target: process.env.API_PROXY_TARGET || "http://localhost:3000",
changeOrigin: true,
},
},
},
});
+21
View File
@@ -0,0 +1,21 @@
FROM golang:1.25-alpine
WORKDIR /app
# Install git for go mod download if needed
RUN apk add --no-cache git
# Pre-copy go.mod/sum to cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source
COPY . .
# Build for production (optional, can just run go run for dev)
RUN go build -o main ./cmd/server
EXPOSE 3000
# Default command (can be overridden by compose)
CMD ["./main"]
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"baron-sso-backend/internal/service"
"context"
"fmt"
"os"
)
func main() {
// KetoService 초기화
// KETO_READ_URL과 KETO_WRITE_URL은 컨테이너 외부 포트 또는 내부 주소에 맞게 설정 필요
os.Setenv("KETO_READ_URL", "http://keto:4466")
os.Setenv("KETO_WRITE_URL", "http://keto:4467")
keto := service.NewKetoService()
ctx := context.Background()
userID := "test-user-id"
tenantID := "test-tenant-id"
fmt.Println("--- Keto ReBAC Test Start ---")
// 1. 초기 권한 체크 (당연히 거부되어야 함)
allowed, _ := keto.CheckPermission(ctx, userID, "Tenant", tenantID, "view")
fmt.Printf("1. Initial Check (view): %v (Expected: false)\n", allowed)
// 2. 관계(Relation) 추가
fmt.Println("2. Adding relation: User is member of Tenant...")
err := keto.CreateRelation(ctx, "Tenant", tenantID, "members", userID)
if err != nil {
fmt.Printf("Failed to create relation: %v\n", err)
return
}
// 3. 다시 권한 체크 (허용되어야 함)
// OPL 정의에 의해 members는 view 권한을 포함함
allowed, _ = keto.CheckPermission(ctx, userID, "Tenant", tenantID, "view")
fmt.Printf("3. Final Check (view): %v (Expected: true)\n", allowed)
fmt.Println("--- Test Completed ---")
}
+90
View File
@@ -0,0 +1,90 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"github.com/joho/godotenv"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
type ApiKey struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()"`
Name string
ClientID string `gorm:"uniqueIndex"`
ClientSecretHash string
Scopes string
Status string `gorm:"default:'active'"`
}
func generateToken(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
func main() {
godotenv.Load(".env")
godotenv.Load("backend/.env")
pgHost := os.Getenv("DB_HOST")
if pgHost == "" {
pgHost = "localhost"
}
pgPort := os.Getenv("DB_PORT")
if pgPort == "" {
pgPort = "5432"
}
pgUser := os.Getenv("DB_USER")
if pgUser == "" {
pgUser = "baron"
}
pgPass := os.Getenv("DB_PASSWORD")
if pgPass == "" {
pgPass = "password"
}
pgName := os.Getenv("DB_NAME")
if pgName == "" {
pgName = "baron_sso"
}
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable",
pgHost, pgUser, pgPass, pgName, pgPort)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatalf("Failed to connect to DB: %v", err)
}
clientID := generateToken(8)
plainSecret := generateToken(16)
hashedSecret, _ := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost)
key := ApiKey{
Name: "Test Admin Key",
ClientID: clientID,
ClientSecretHash: string(hashedSecret),
Scopes: "tenant:read tenant:write user:read user:write audit:read audit:write",
Status: "active",
}
if err := db.Table("api_keys").Create(&key).Error; err != nil {
log.Fatalf("Failed to create API key: %v", err)
}
fmt.Println("====================================================")
fmt.Println("✅ API Key Generated Successfully!")
fmt.Printf("Client ID: %s\n", clientID)
fmt.Printf("Client Secret: %s\n", plainSecret)
fmt.Println("====================================================")
fmt.Println("Usage Example:")
fmt.Printf("curl -H \"X-Baron-Key-ID: %s\" -H \"X-Baron-Key-Secret: %s\" http://localhost:3000/api/v1/admin/tenants\n", clientID, plainSecret)
fmt.Println("====================================================")
}
+132
View File
@@ -0,0 +1,132 @@
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"sync"
"time"
)
type HTTPProbe struct {
name string
url string
interval time.Duration
timeout time.Duration
client *http.Client
mu sync.RWMutex
status string
lastError string
lastChecked time.Time
lastSuccess time.Time
}
type ProbeSnapshot struct {
Status string
Error string
LastChecked time.Time
LastSuccess time.Time
}
func NewHTTPProbe(name, url string, interval, timeout time.Duration) *HTTPProbe {
if interval <= 0 {
interval = 10 * time.Second
}
if timeout <= 0 {
timeout = 2 * time.Second
}
return &HTTPProbe{
name: name,
url: url,
interval: interval,
timeout: timeout,
client: &http.Client{
Timeout: timeout,
},
}
}
// Start는 프로브를 백그라운드에서 주기적으로 실행합니다.
func (p *HTTPProbe) Start() {
go func() {
p.checkOnce()
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for range ticker.C {
p.checkOnce()
}
}()
}
func (p *HTTPProbe) Snapshot() ProbeSnapshot {
p.mu.RLock()
defer p.mu.RUnlock()
return ProbeSnapshot{
Status: p.status,
Error: p.lastError,
LastChecked: p.lastChecked,
LastSuccess: p.lastSuccess,
}
}
func (p *HTTPProbe) StatusText() string {
s := p.Snapshot()
if s.Status == "ok" {
return "ok"
}
if s.Status == "" {
return "unknown"
}
if s.Error == "" {
return "error"
}
return "error: " + s.Error
}
func (p *HTTPProbe) checkOnce() {
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
if err != nil {
p.update("error", fmt.Sprintf("request build failed: %v", err), false)
return
}
resp, err := p.client.Do(req)
if err != nil {
p.update("error", err.Error(), false)
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
p.update("error", fmt.Sprintf("status=%d", resp.StatusCode), false)
return
}
p.update("ok", "", true)
}
func (p *HTTPProbe) update(status, errMsg string, success bool) {
p.mu.Lock()
prevStatus := p.status
p.status = status
p.lastError = errMsg
p.lastChecked = time.Now()
if success {
p.lastSuccess = p.lastChecked
}
p.mu.Unlock()
if prevStatus == status {
return
}
if status == "ok" {
slog.Info("Service probe recovered", "name", p.name, "url", p.url)
return
}
slog.Error("Service probe failed", "name", p.name, "url", p.url, "error", errMsg)
}
+684
View File
@@ -0,0 +1,684 @@
package main
import (
"baron-sso-backend/internal/bootstrap"
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/handler"
"baron-sso-backend/internal/idp"
"baron-sso-backend/internal/logger"
"baron-sso-backend/internal/middleware"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/validator"
"errors"
"fmt"
"log"
"log/slog"
"os"
"strconv"
"strings"
"time"
"github.com/bwmarrin/snowflake"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/encryptcookie"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/gofiber/fiber/v2/middleware/requestid"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormLogger "gorm.io/gorm/logger"
)
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
func normalizeDocsPrefix(prefix string) string {
trimmed := strings.TrimSpace(prefix)
if trimmed == "" || trimmed == "/" {
return ""
}
if !strings.HasPrefix(trimmed, "/") {
trimmed = "/" + trimmed
}
return strings.TrimRight(trimmed, "/")
}
func registerDocsRoutes(app *fiber.App, prefix string) {
base := normalizeDocsPrefix(prefix)
docsPath := base + "/docs"
redocPath := base + "/redoc"
openapiPath := base + "/openapi.yaml"
app.Get(docsPath, func(c *fiber.Ctx) error {
return c.SendFile("./docs/swagger-ui/index.html")
})
app.Get(docsPath+"/", func(c *fiber.Ctx) error {
return c.SendFile("./docs/swagger-ui/index.html")
})
app.Static(docsPath, "./docs/swagger-ui")
app.Get(redocPath, func(c *fiber.Ctx) error {
return c.SendFile("./docs/redoc/index.html")
})
app.Get(redocPath+"/", func(c *fiber.Ctx) error {
return c.SendFile("./docs/redoc/index.html")
})
app.Static(redocPath, "./docs/redoc")
app.Get(openapiPath, func(c *fiber.Ctx) error {
c.Type("yaml")
return c.SendFile("./docs/openapi.yaml")
})
}
func main() {
// Load .env file from possible paths
// 1. .env (Current Directory)
// 2. ../.env (Project Root when running from backend/)
// 3. ../../.env (Project Root when running from backend/cmd/server/)
if err := godotenv.Load(".env"); err != nil {
if err := godotenv.Load("../.env"); err != nil {
godotenv.Load("../../.env")
}
}
// 0. Initialize Logger
logger.Init(logger.Config{
ServiceName: "baron-sso",
Environment: getEnv("GO_ENV", "dev"),
})
// Initialize Snowflake Node (Node 2 for Baron)
node, err := snowflake.NewNode(2)
if err != nil {
slog.Error("Failed to initialize snowflake node", "error", err)
os.Exit(1)
}
// 1. Log Config on Startup
fmt.Println("============================================================")
fmt.Println(`
|\__/,| (\
_.|o o |_ ) )
-(((---(((--------
`)
fmt.Println("🚀 Baron SSO Backend Starting...")
slog.Info("Service starting",
"service", "baron-sso",
"app_env", getEnv("APP_ENV", "dev"),
"db_port", getEnv("DB_PORT", "5532"),
"backend_port", getEnv("BACKEND_PORT", "3000"),
"userfront_port", getEnv("USERFRONT_PORT", "5000"),
"userfront_url", getEnv("USERFRONT_URL", "http://sso.hmac.kr"),
"redis_addr", getEnv("REDIS_ADDR", "redis:6379"),
)
// --- Fail-Fast Schema Validation ---
// 팩토리를 사용하여 IDP 공급자를 초기화합니다.
idpProvider, err := idp.InitializeProvider()
if err != nil {
slog.Error("❌ [CRITICAL] Failed to initialize IDP Provider", "error", err)
os.Exit(1)
}
if err := validator.ValidateIDPCompatibility(domain.BrokerUser{}, idpProvider); err != nil {
slog.Error("❌ [CRITICAL] Broker Schema Mismatch",
"idp", idpProvider.Name(),
"error", err,
)
fmt.Printf("\n!!! CRITICAL ERROR: IDP Schema Mismatch !!!\n%v\n\n", err)
os.Exit(1) // Break the build/deployment
}
slog.Info("✅ IDP Schema Validation Passed", "idp", idpProvider.Name())
// -----------------------------------
if err := bootstrap.SeedAdminIdentity(idpProvider); err != nil {
slog.Error("❌ Admin identity seed failed", "error", err)
}
// 2. Initialize DB Connections
// ClickHouse
chHost := getEnv("CLICKHOUSE_HOST", "localhost")
chPort, _ := strconv.Atoi(getEnv("CLICKHOUSE_PORT_NATIVE", "9000"))
chUser := getEnv("CLICKHOUSE_USER", "baron")
chPass := getEnv("CLICKHOUSE_PASSWORD", "password")
chDB := getEnv("CLICKHOUSE_DB", "baron_sso")
var auditRepo domain.AuditRepository
if repo, err := repository.NewClickHouseRepository(chHost, chPort, chUser, chPass, chDB); err != nil {
slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err)
auditRepo = nil // Explicitly set to nil interface
} else {
auditRepo = repo
slog.Info("✅ Connected to ClickHouse")
}
var oathkeeperRepo domain.OathkeeperLogRepository
oryCHHost := getEnv("ORY_CLICKHOUSE_HOST", "ory_clickhouse")
oryCHPort, _ := strconv.Atoi(getEnv("ORY_CLICKHOUSE_PORT_NATIVE", "9000"))
oryCHUser := getEnv("ORY_CLICKHOUSE_USER", "ory")
oryCHPass := getEnv("ORY_CLICKHOUSE_PASSWORD", "orypass")
oryCHDB := getEnv("ORY_CLICKHOUSE_DB", "ory")
if repo, err := repository.NewOathkeeperClickHouseRepository(oryCHHost, oryCHPort, oryCHUser, oryCHPass, oryCHDB); err != nil {
slog.Warn("Failed to connect to Ory ClickHouse. Oathkeeper logs will be skipped.", "error", err)
oathkeeperRepo = nil
} else {
oathkeeperRepo = repo
slog.Info("✅ Connected to Ory ClickHouse")
}
redisService, err := service.NewRedisService()
if err != nil {
slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err)
}
ketoService := service.NewKetoService()
// PostgreSQL (Meta Store)
pgHost := getEnv("DB_HOST", "localhost")
pgPort := getEnv("DB_PORT", "5432")
pgUser := getEnv("DB_USER", "baron")
pgPass := getEnv("DB_PASSWORD", "password")
pgName := getEnv("DB_NAME", "baron_sso")
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=Asia/Seoul",
pgHost, pgUser, pgPass, pgName, pgPort)
gormLog := gormLogger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
gormLogger.Config{
SlowThreshold: time.Second,
LogLevel: gormLogger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: gormLog,
})
if err != nil {
slog.Error("❌ Failed to connect to PostgreSQL", "error", err)
os.Exit(1)
} else {
slog.Info("✅ Connected to PostgreSQL")
// Run Bootstrap (Migrations & Seeding)
if err := bootstrap.Run(db); err != nil {
slog.Error("❌ Bootstrap failed", "error", err)
}
// [New] Sync existing data to Keto
if ketoService != nil {
if err := bootstrap.SyncKetoRelations(db, ketoService); err != nil {
slog.Warn("⚠️ Keto synchronization failed during startup", "error", err)
}
}
}
// Oathkeeper 상태를 주기적으로 확인해 다운을 감지합니다.
var oathkeeperProbe *HTTPProbe
if strings.ToLower(getEnv("OATHKEEPER_HEALTH_ENABLED", "true")) != "false" {
intervalSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_INTERVAL_SECONDS", "10"))
if err != nil || intervalSec <= 0 {
intervalSec = 10
}
timeoutSec, err := strconv.Atoi(getEnv("OATHKEEPER_HEALTH_TIMEOUT_SECONDS", "2"))
if err != nil || timeoutSec <= 0 {
timeoutSec = 2
}
oathkeeperProbe = NewHTTPProbe(
"oathkeeper",
getEnv("OATHKEEPER_HEALTH_URL", "http://oathkeeper:4456/health/ready"),
time.Duration(intervalSec)*time.Second,
time.Duration(timeoutSec)*time.Second,
)
oathkeeperProbe.Start()
} else {
slog.Info("Oathkeeper probe disabled")
}
// 2. Initialize Handlers
tenantRepo := repository.NewTenantRepository(db)
tenantService := service.NewTenantService(tenantRepo)
tenantService.SetKetoService(ketoService) // Keto 주입
userRepo := repository.NewUserRepository(db)
// relyingPartyRepo removed as SSOT is now Hydra+Keto
hydraService := service.NewHydraAdminService()
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService)
secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db)
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo)
tenantHandler := handler.NewTenantHandler(db, tenantService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService)
kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider()
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo)
apiKeyHandler := handler.NewApiKeyHandler(db)
// 3. Initialize Fiber
appEnv := getEnv("APP_ENV", "dev")
app := fiber.New(fiber.Config{
AppName: "Baron SSO Backend",
DisableStartupMessage: true, // Clean logs
ReadBufferSize: 32768, // 32KB로 증가 (긴 OIDC 챌린지 대응)
// Global Error Handler for Production Masking
ErrorHandler: func(c *fiber.Ctx, err error) error {
// Default status code
code := fiber.StatusInternalServerError
// Check if it's a known fiber.Error
var e *fiber.Error
if errors.As(err, &e) {
code = e.Code
}
// In production or stage, mask detailed 500+ errors
if appEnv == "production" || appEnv == "stage" {
if code >= 500 {
// Log the actual error for developers
slog.Error("Internal Server Error",
"error", err.Error(),
"path", c.Path(),
"method", c.Method(),
)
// Return masked message
return c.Status(code).JSON(fiber.Map{
"error": "Internal Server Error",
})
}
}
// For development or non-500 errors, return the actual error message
return c.Status(code).JSON(fiber.Map{
"error": err.Error(),
})
},
})
// Middleware
app.Use(requestid.New(requestid.Config{
Generator: func() string {
return node.Generate().String()
},
}))
// [Standardized] HTTP Request Logger Middleware using slog
app.Use(func(c *fiber.Ctx) error {
start := time.Now()
// Handle request
err := c.Next()
// Log after request
latency := time.Since(start)
status := c.Response().StatusCode()
path := c.Path()
// Skip logging for all successful requests (status < 400)
if status < 400 {
return err
}
msg := "http_request"
if err != nil {
msg = "http_request_error"
}
slog.Info(msg,
"status", status,
"method", c.Method(),
"path", path,
"latency", latency.String(),
"ip", c.IP(),
"req_id", c.GetRespHeader(fiber.HeaderXRequestID),
)
return err
})
app.Use(recover.New(recover.Config{
EnableStackTrace: true,
}))
allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000")
allowCredentials := allowedOrigins != "*"
app.Use(cors.New(cors.Config{
AllowOrigins: allowedOrigins,
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
AllowCredentials: allowCredentials,
}))
// Ensure COOKIE_SECRET is exactly 32 bytes for AES-256
cookieSecret := getEnv("COOKIE_SECRET", "secret-key-must-be-32-bytes-long!")
if len(cookieSecret) != 32 {
slog.Warn("COOKIE_SECRET length is not 32 bytes. Adjusting...", "original_length", len(cookieSecret))
if len(cookieSecret) > 32 {
cookieSecret = cookieSecret[:32]
} else {
// Pad with '0' if too short
cookieSecret = fmt.Sprintf("%-32s", cookieSecret)
}
}
app.Use(encryptcookie.New(encryptcookie.Config{
Key: cookieSecret,
}))
// [Security] Disable Swagger/ReDoc in Production
if appEnv != "production" {
docsPrefix := getEnv("DOCS_BASE_PATH", "/api")
registerDocsRoutes(app, "")
if normalized := normalizeDocsPrefix(docsPrefix); normalized != "" {
registerDocsRoutes(app, normalized)
}
slog.Info("📚 API Docs enabled", "swagger", "/docs", "redoc", "/redoc", "docs_prefix", docsPrefix)
} else {
slog.Info("🔒 API Docs disabled in production")
}
// Routes
app.Get("/", func(c *fiber.Ctx) error {
return c.SendString("Baron SSO Audit Backend Online")
})
app.Get("/health", func(c *fiber.Ctx) error {
status := "ok"
checks := make(map[string]string)
// Check ClickHouse
if auditRepo != nil {
if err := auditRepo.Ping(c.Context()); err != nil {
checks["clickhouse"] = "error: " + err.Error()
status = "error"
} else {
checks["clickhouse"] = "ok"
}
} else {
checks["clickhouse"] = "not_initialized"
status = "degraded"
}
// Check Redis
if redisService != nil {
if err := redisService.Ping(c.Context()); err != nil {
checks["redis"] = "error: " + err.Error()
status = "error"
} else {
checks["redis"] = "ok"
}
} else {
checks["redis"] = "not_initialized"
status = "degraded"
}
// Check Oathkeeper
if oathkeeperProbe != nil {
snapshot := oathkeeperProbe.Snapshot()
switch snapshot.Status {
case "ok":
checks["oathkeeper"] = "ok"
case "":
checks["oathkeeper"] = "unknown"
if status != "error" {
status = "degraded"
}
default:
if snapshot.Error == "" {
checks["oathkeeper"] = "error"
} else {
checks["oathkeeper"] = "error: " + snapshot.Error
}
status = "error"
}
} else {
checks["oathkeeper"] = "disabled"
if status != "error" {
status = "degraded"
}
}
if status == "error" {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"status": status,
"checks": checks,
})
}
return c.JSON(fiber.Map{
"status": status,
"checks": checks,
})
})
// API Group
api := app.Group("/api/v1")
workerCount, _ := strconv.Atoi(getEnv("AUDIT_WORKER_COUNT", "5"))
queueSize, _ := strconv.Atoi(getEnv("AUDIT_QUEUE_SIZE", "2000"))
api.Use(middleware.AuditMiddleware(middleware.AuditConfig{
Repo: auditRepo,
ExcludePaths: map[string]struct{}{
"/api/v1/audit": {},
"/api/v1/client-log": {},
},
BodyDump: true,
WorkerCount: workerCount,
QueueSize: queueSize,
}))
api.Post("/audit", auditHandler.CreateLog)
api.Get("/audit", auditHandler.ListLogs)
api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
// Public Tenant Registration
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
// Auth Proxy Routes
auth := api.Group("/auth")
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
auth.Post("/password/login", authHandler.PasswordLogin)
auth.Get("/consent", authHandler.GetConsentRequest)
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
auth.Post("/consent/reject", authHandler.RejectConsentRequest)
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
auth.Post("/password/login", authHandler.PasswordLogin)
auth.Get("/consent", authHandler.GetConsentRequest)
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
// [Added] Use POST for actual verification triggered by the user
auth.Post("/password/reset/verify", authHandler.ProcessPasswordResetToken)
auth.Post("/password/reset/complete", authHandler.CompletePasswordReset)
auth.Get("/password/policy", authHandler.GetPasswordPolicy)
auth.Post("/sms", authHandler.SendSms)
auth.Post("/verify-sms", authHandler.VerifySms)
auth.Post("/qr/init", authHandler.InitQRLogin)
auth.Post("/qr/poll", authHandler.PollQRLogin)
auth.Post("/qr/approve", authHandler.ScanQRLogin)
// Signup Routes
signup := auth.Group("/signup")
signup.Post("/check-email", authHandler.CheckEmail)
signup.Post("/send-email-code", authHandler.SendSignupEmailCode)
signup.Post("/send-sms-code", authHandler.SendSignupSmsCode)
signup.Post("/verify-code", authHandler.VerifySignupCode)
signup.Post("/", authHandler.Signup)
// User Routes (My Page)
user := api.Group("/user")
user.Get("/me", authHandler.GetMe)
user.Put("/me", authHandler.UpdateMe)
user.Post("/me/password", authHandler.ChangeMyPassword)
user.Post("/me/send-code", authHandler.SendUpdateCode)
user.Post("/me/verify-code", authHandler.VerifyUpdateCode)
user.Get("/rp/linked", authHandler.ListLinkedRps)
user.Get("/rp/history", authHandler.ListRpHistory)
user.Delete("/rp/linked/:id", authHandler.RevokeLinkedRp)
// Admin Routes
admin := api.Group("/admin")
admin.Use(middleware.ApiKeyAuth(middleware.ApiKeyAuthConfig{DB: db})) // API Key 인증 추가
// RBAC Middleware Instances
requireSuperAdmin := middleware.RequireRole(middleware.RBACConfig{
AllowedRoles: []string{domain.RoleSuperAdmin},
AuthHandler: authHandler,
KetoService: ketoService,
})
requireAdmin := middleware.RequireRole(middleware.RBACConfig{
AllowedRoles: []string{domain.RoleSuperAdmin, domain.RoleTenantAdmin},
AuthHandler: authHandler,
KetoService: ketoService,
})
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
// Tenant Management (Super Admin Only)
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants)
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant)
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
admin.Put("/tenants/:id", requireSuperAdmin, tenantHandler.UpdateTenant)
admin.Delete("/tenants/:id", requireSuperAdmin, tenantHandler.DeleteTenant)
// Relying Party Management (Global List)
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)
// Relying Party Management (Tenant Context)
admin.Post("/tenants/:tenantId/relying-parties",
requireAdmin,
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"),
relyingPartyHandler.Create)
admin.Get("/tenants/:tenantId/relying-parties",
requireAdmin,
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"),
relyingPartyHandler.List)
admin.Get("/relying-parties/:id",
requireAdmin,
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "view"),
relyingPartyHandler.Get)
admin.Put("/relying-parties/:id",
requireAdmin,
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
relyingPartyHandler.Update)
admin.Delete("/relying-parties/:id",
requireAdmin,
middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "RelyingParty", "manage"),
relyingPartyHandler.Delete)
// Admin User Management
admin.Get("/users", requireAdmin, userHandler.ListUsers) // TODO: TenantAdmin인 경우 해당 테넌트 사용자만 보이도록 Handler 수정 필요
admin.Post("/users", requireAdmin, userHandler.CreateUser)
admin.Get("/users/:id", requireAdmin, userHandler.GetUser)
admin.Put("/users/:id", requireAdmin, userHandler.UpdateUser)
admin.Delete("/users/:id", requireAdmin, userHandler.DeleteUser)
// API Key Management (M2M) - Super Admin Only
admin.Get("/api-keys", requireSuperAdmin, apiKeyHandler.ListApiKeys)
admin.Post("/api-keys", requireSuperAdmin, apiKeyHandler.CreateApiKey)
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
dev := api.Group("/dev")
dev.Get("/clients", devHandler.ListClients)
dev.Post("/clients", devHandler.CreateClient)
dev.Get("/clients/:id", devHandler.GetClient)
dev.Put("/clients/:id", devHandler.UpdateClient)
dev.Post("/clients/:id/secret/rotate", devHandler.RotateClientSecret)
dev.Patch("/clients/:id/status", devHandler.UpdateClientStatus)
dev.Delete("/clients/:id", devHandler.DeleteClient)
dev.Get("/consents", devHandler.ListConsents)
dev.Delete("/consents", devHandler.RevokeConsents)
// Webhook for Kratos courier (HTTP delivery)
auth.Post("/webhooks/kratos-courier", authHandler.HandleKratosCourierRelay)
// Client Logging Route (Standardized & Flattened)
api.Post("/client-log", func(c *fiber.Ctx) error {
type LogReq struct {
Level string `json:"level"`
Message string `json:"message"`
Data map[string]interface{} `json:"data,omitempty"`
}
var req LogReq
if err := c.BodyParser(&req); err != nil {
return c.SendStatus(fiber.StatusBadRequest)
}
// Prepare attributes for flattening
attrs := []any{
slog.String("source", "client"),
}
for k, v := range req.Data {
// Skip svc if it's already set by the global logger to avoid confusion,
// or keep it as client_svc
if k == "svc" {
attrs = append(attrs, slog.Any("client_svc", v))
} else {
attrs = append(attrs, slog.Any(k, v))
}
}
// Map and log with correct level
var level slog.Level
switch req.Level {
case "SEVERE", "ERROR":
level = slog.LevelError
case "WARNING", "WARN":
level = slog.LevelWarn
default:
level = slog.LevelInfo
}
// Filter out noisy client navigation logs
if level == slog.LevelInfo {
msg := strings.ToLower(req.Message)
if strings.Contains(msg, "navigating to") ||
strings.Contains(msg, "going to") ||
strings.Contains(msg, "redirecting to") ||
strings.Contains(msg, "full paths for routes") {
return c.SendStatus(fiber.StatusOK)
}
}
slog.Log(c.Context(), level, req.Message, attrs...)
return c.SendStatus(fiber.StatusOK)
})
// Start Server
port := getEnv("BACKEND_PORT", "3000")
slog.Info("Server listening", "port", port)
fmt.Println("============================================================")
if err := app.Listen(":" + port); err != nil {
slog.Error("Server failed to start", "error", err)
os.Exit(1)
}
}
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Baron SSO ReDoc</title>
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id="redoc-root"></div>
<script>
(function () {
const path = window.location.pathname;
const docsBase = path.replace(/\/redoc\/?$/, "/redoc/");
const assetBase = docsBase.endsWith("/") ? docsBase : docsBase + "/";
const openapiUrl = assetBase.replace(/redoc\/$/, "openapi.yaml");
const script = document.createElement("script");
script.src = assetBase + "redoc.standalone.js";
script.onload = () => {
if (window.Redoc) {
window.Redoc.init(openapiUrl, {}, document.getElementById("redoc-root"));
}
};
script.onerror = (err) => {
console.error("ReDoc load failed", err);
};
document.body.appendChild(script);
})();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Baron SSO Swagger UI</title>
<style>
body {
margin: 0;
background: #f7f7f8;
}
.topbar {
display: none;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script>
(function () {
const path = window.location.pathname;
const docsBase = path.replace(/\/docs\/?$/, "/docs/");
const assetBase = docsBase.endsWith("/") ? docsBase : docsBase + "/";
const css = document.createElement("link");
css.rel = "stylesheet";
css.href = assetBase + "swagger-ui.css";
document.head.appendChild(css);
const loadScript = (src) =>
new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
Promise.all([
loadScript(assetBase + "swagger-ui-bundle.js"),
loadScript(assetBase + "swagger-ui-standalone-preset.js"),
])
.then(() => {
const openapiUrl = assetBase.replace(/docs\/$/, "openapi.yaml");
SwaggerUIBundle({
url: openapiUrl,
dom_id: "#swagger-ui",
deepLinking: true,
persistAuthorization: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset,
],
layout: "StandaloneLayout",
});
})
.catch((err) => {
console.error("Swagger UI load failed", err);
});
})();
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+83
View File
@@ -0,0 +1,83 @@
module baron-sso-backend
go 1.25.4
require (
github.com/ClickHouse/clickhouse-go/v2 v2.42.0
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.7
github.com/aws/aws-sdk-go-v2/credentials v1.19.7
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18
github.com/bwmarrin/snowflake v0.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/descope/go-sdk v1.7.0
github.com/go-redis/redis/v8 v8.11.5
github.com/gofiber/fiber/v2 v2.52.10
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.11.1
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.46.0
golang.org/x/oauth2 v0.34.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/ClickHouse/ch-go v0.69.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.6 // indirect
github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+242
View File
@@ -0,0 +1,242 @@
github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/iM=
github.com/ClickHouse/ch-go v0.69.0/go.mod h1:9XeZpSAT4S0kVjOpaJ5186b7PY/NH/hhF8R6u0WIjwg=
github.com/ClickHouse/clickhouse-go/v2 v2.42.0 h1:MdujEfIrpXesQUH0k0AnuVtJQXk6RZmxEhsKUCcv5xk=
github.com/ClickHouse/clickhouse-go/v2 v2.42.0/go.mod h1:riWnuo4YMVdajYll0q6FzRBomdyCrXyFY3VXeXczA8s=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/config v1.32.7 h1:vxUyWGUwmkQ2g19n7JY/9YL8MfAIl7bTesIUykECXmY=
github.com/aws/aws-sdk-go-v2/config v1.32.7/go.mod h1:2/Qm5vKUU/r7Y+zUk/Ptt2MDAEKAfUtKc1+3U1Mo3oY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7 h1:tHK47VqqtJxOymRrNtUXN5SP/zUTvZKeLx4tH6PGQc8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.7/go.mod h1:qOZk8sPDrxhf+4Wf4oT2urYJrYt3RejHSzgAquYeppw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18 h1:2Lnd3ZNTyWpFJJM55y0mP0aESovm+vFuFEwLijucUL8=
github.com/aws/aws-sdk-go-v2/service/ses v1.34.18/go.mod h1:BLwHw6wdkA6NfnW/cFaVcvpwdIXHLAkpe6nsLF9BVww=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9 h1:v6EiMvhEYBoHABfbGB4alOYmCIrcgyPPiBE1wZAEbqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.9/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 h1:gd84Omyu9JLriJVCbGApcLzVR3XtmC4ZDPcAI6Ftvds=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/descope/go-sdk v1.7.0 h1:DIRmnA4Q8TDtWdGJ9z0I11+AWMrzyNiiozFH557LrgQ=
github.com/descope/go-sdk v1.7.0/go.mod h1:lCwCgYOfrgjANMsR2BVe1yfX0Siwd2NjNAig0myWZqY=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY=
github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k=
github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo=
github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI=
github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4=
github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA=
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e h1:Ctm9yurWsg7aWwIpH9Bnap/IdSVxixymIb3MhiMEQQA=
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+43
View File
@@ -0,0 +1,43 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"fmt"
"log/slog"
"gorm.io/gorm"
)
// Run executes the application bootstrap logic (migrations, seeding, etc.)
func Run(db *gorm.DB) error {
slog.Info("[Bootstrap] Starting application bootstrap...")
// 1. Auto Migration
if err := migrateSchemas(db); err != nil {
return fmt.Errorf("migration failed: %w", err)
}
// 2. Seed Tenants
if err := SeedTenants(db); err != nil {
return fmt.Errorf("tenant seeding failed: %w", err)
}
slog.Info("[Bootstrap] User seed skipped (Kratos is SoT)")
slog.Info("[Bootstrap] Bootstrap completed successfully.")
return nil
}
func migrateSchemas(db *gorm.DB) error {
slog.Info("[Bootstrap] Migrating database schemas...")
// Add all domain models here
return db.AutoMigrate(
&domain.Tenant{},
&domain.TenantDomain{},
&domain.User{},
&domain.ApiKey{},
&domain.IdentityProviderConfig{},
&domain.ClientSecret{},
&domain.ClientConsent{},
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
)
}
+52
View File
@@ -0,0 +1,52 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"context"
"log/slog"
"gorm.io/gorm"
)
// SyncKetoRelations synchronizes all existing DB users and tenants to Ory Keto.
// This ensures data consistency for existing data when ReBAC is introduced.
func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
slog.Info("🚀 Starting Keto ReBAC relation synchronization...")
ctx := context.Background()
// 1. Sync All Tenants (Ensure they exist in Keto if needed)
var tenants []domain.Tenant
if err := db.Find(&tenants).Error; err != nil {
return err
}
slog.Info("Syncing tenants to Keto", "count", len(tenants))
for _, t := range tenants {
if t.ParentID != nil {
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID)
}
}
// 2. Sync All Users
var users []domain.User
if err := db.Find(&users).Error; err != nil {
return err
}
slog.Info("Syncing users to Keto", "count", len(users))
for _, u := range users {
// Membership
if u.TenantID != nil {
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", u.ID)
}
// Roles
if u.Role == domain.RoleSuperAdmin {
_ = keto.CreateRelation(ctx, "System", "global", "super_admins", u.ID)
} else if u.Role == domain.RoleTenantAdmin && u.TenantID != nil {
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", u.ID)
}
}
slog.Info("✅ Keto ReBAC synchronization completed.")
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"log/slog"
"os"
"strings"
)
// SeedAdminIdentity creates the initial admin identity in the configured IDP.
func SeedAdminIdentity(idp domain.IdentityProvider) error {
if idp == nil {
return nil
}
adminEmail := strings.TrimSpace(os.Getenv("ADMIN_EMAIL"))
adminPassword := os.Getenv("ADMIN_PASSWORD")
if adminEmail == "" || adminPassword == "" {
slog.Warn("[Bootstrap] ADMIN_EMAIL or ADMIN_PASSWORD not set. Skipping admin identity seed.")
return nil
}
adminName := strings.TrimSpace(os.Getenv("ADMIN_NAME"))
if adminName == "" {
adminName = "System Admin"
}
user := &domain.BrokerUser{
Email: adminEmail,
Name: adminName,
PhoneNumber: "",
Attributes: map[string]interface{}{
"department": "Admin",
"affiliationType": "internal",
"companyCode": "",
"grade": "admin",
},
}
_, err := idp.CreateUser(user, adminPassword)
if err != nil {
if strings.Contains(err.Error(), "already exists") {
slog.Info("[Bootstrap] Admin identity already exists in IDP", "email", adminEmail)
return nil
}
return err
}
slog.Info("[Bootstrap] Admin identity created in IDP", "email", adminEmail, "idp", idp.Name())
return nil
}
+70
View File
@@ -0,0 +1,70 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"context"
"log/slog"
"gorm.io/gorm"
)
type InitialTenantConfig struct {
Name string
Slug string
Description string
Domains []string
}
// Hardcoded for now, can be moved to config file or env later
var defaultTenants = []InitialTenantConfig{
{
Name: "Hanmac Engineering",
Slug: "hanmac",
Description: "Primary Family Company",
Domains: []string{"hanmaceng.co.kr", "hmac.kr"},
},
}
func SeedTenants(db *gorm.DB) error {
slog.Info("[Bootstrap] Seeding initial tenants...")
repo := repository.NewTenantRepository(db)
svc := service.NewTenantService(repo)
ctx := context.Background()
for _, config := range defaultTenants {
existing, err := repo.FindBySlug(ctx, config.Slug)
if err == nil && existing != nil {
slog.Info("[Bootstrap] Tenant already exists, checking domains...", "slug", config.Slug)
// Optional: Check and add missing domains
for _, d := range config.Domains {
found := false
for _, ed := range existing.Domains {
if ed.Domain == d {
found = true
break
}
}
if !found {
slog.Info("[Bootstrap] Adding missing domain to tenant", "slug", config.Slug, "domain", d)
if err := repo.AddDomain(ctx, existing.ID, d); err != nil {
slog.Error("Failed to add domain", "error", err)
}
}
}
continue
}
slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug)
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, config.Description, config.Domains)
if err != nil {
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
return err
}
// Explicitly set to active during seed
tenant.Status = domain.TenantStatusActive
db.Save(tenant)
}
return nil
}
+30
View File
@@ -0,0 +1,30 @@
package domain
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ApiKey represents an internal API key for Machine-to-Machine communication.
type ApiKey struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
Name string `gorm:"not null" json:"name"`
ClientID string `gorm:"uniqueIndex;not null" json:"clientId"`
ClientSecretHash string `gorm:"not null" json:"-"`
Scopes string `json:"scopes"` // Space or comma separated
Status string `gorm:"default:'active'" json:"status"`
LastUsedAt *time.Time `json:"lastUsedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// BeforeCreate hook to generate UUID if not present.
func (k *ApiKey) BeforeCreate(tx *gorm.DB) (err error) {
if k.ID == "" {
k.ID = uuid.NewString()
}
return
}
+109
View File
@@ -0,0 +1,109 @@
package domain
type EnchantedLinkInitRequest struct {
LoginID string `json:"loginId"`
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
Method string `json:"method,omitempty"` // "email" or "sms"
CodeOnly bool `json:"codeOnly,omitempty"`
DryRun bool `json:"dryRun,omitempty"`
DrySend bool `json:"drySend,omitempty"`
}
type EnchantedLinkInitResponse struct {
LinkID string `json:"linkId"`
PendingRef string `json:"pendingRef"`
MaskedEmail string `json:"maskedEmail"`
}
type EnchantedLinkPollRequest struct {
PendingRef string `json:"pendingRef"`
}
type EnchantedLinkPollResponse struct {
SessionToken string `json:"sessionToken"` // JWT
RefreshToken string `json:"refreshToken"`
UserID string `json:"userId,omitempty"`
}
type MagicLinkVerifyRequest struct {
Token string `json:"token"`
VerifyOnly bool `json:"verifyOnly,omitempty"`
}
type QRInitResponse struct {
QRCode string `json:"qrCode"` // Base64 or URL
PendingRef string `json:"pendingRef"`
ExpiresIn int `json:"expiresIn"`
}
// Signup Flow Models
type CheckEmailRequest struct {
Email string `json:"email"`
}
type SendSignupCodeRequest struct {
Target string `json:"target"` // Email or Phone
Type string `json:"type"` // "email" or "phone"
}
type VerifySignupCodeRequest struct {
Target string `json:"target"` // Email or Phone
Type string `json:"type"` // "email" or "phone"
Code string `json:"code"`
}
type SignupRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Phone string `json:"phone"`
AffiliationType string `json:"affiliationType"` // "AFFILIATE" or "GENERAL"
CompanyCode string `json:"companyCode,omitempty"`
Department string `json:"department"`
Metadata JSONMap `json:"metadata,omitempty"`
TermsAccepted bool `json:"termsAccepted"`
}
// User Profile Models
type UserProfileResponse struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"` // 추가
Department string `json:"department"`
AffiliationType string `json:"affiliationType"`
CompanyCode string `json:"companyCode,omitempty"`
TenantID *string `json:"tenantId,omitempty"` // 추가
RelyingPartyID *string `json:"relyingPartyId,omitempty"` // 추가
Metadata map[string]any `json:"metadata,omitempty"`
Tenant *Tenant `json:"tenant,omitempty"`
}
type UpdateUserRequest struct {
Name string `json:"name"`
Phone string `json:"phone"`
Department string `json:"department"`
VerificationCode string `json:"verificationCode,omitempty"` // For phone change
}
// PasswordResetInitiateRequest is the request body for initiating a password reset.
type PasswordResetInitiateRequest struct {
LoginID string `json:"loginId"`
DryRun bool `json:"dryRun,omitempty"`
DrySend bool `json:"drySend,omitempty"`
}
// PasswordResetCompleteRequest is the request body for completing a password reset.
type PasswordResetCompleteRequest struct {
LoginID string `json:"loginId"`
NewPassword string `json:"newPassword"`
}
// PasswordChangeRequest는 로그인 상태에서 비밀번호 변경 요청을 표현합니다.
type PasswordChangeRequest struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
+33
View File
@@ -0,0 +1,33 @@
package domain
import (
"time"
"github.com/google/uuid"
"github.com/lib/pq"
"gorm.io/gorm"
)
type ClientConsent struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
ClientID string `gorm:"index;uniqueIndex:idx_client_subject;not null" json:"clientId"`
Subject string `gorm:"index;uniqueIndex:idx_client_subject;not null" json:"subject"` // User UUID
GrantedScopes pq.StringArray `gorm:"type:text[];not null" json:"grantedScopes"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// ClientConsentWithTenantInfo is a struct to hold joined data for API responses
type ClientConsentWithTenantInfo struct {
ClientConsent
TenantID string `gorm:"column:tenant_id" json:"tenantId"`
TenantName string `gorm:"column:tenant_name" json:"tenantName"`
}
func (c *ClientConsent) BeforeCreate(tx *gorm.DB) (err error) {
if c.ID == "" {
c.ID = uuid.New().String()
}
return
}
+21
View File
@@ -0,0 +1,21 @@
package domain
import (
"context"
"time"
)
// ClientSecret represents the stored client secret for OIDC clients.
// Since Hydra only returns the secret once during creation, we store it here.
type ClientSecret struct {
ClientID string `gorm:"primaryKey;column:client_id"`
ClientSecret string `gorm:"column:client_secret;not null"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
type ClientSecretRepository interface {
Upsert(ctx context.Context, clientID, secret string) error
GetByID(ctx context.Context, clientID string) (string, error)
Delete(ctx context.Context, clientID string) error
}
+6
View File
@@ -0,0 +1,6 @@
package domain
// EmailService defines the interface for sending emails.
type EmailService interface {
SendEmail(to, subject, body string) error
}
@@ -0,0 +1,50 @@
package domain
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ProviderType defines the type of the identity provider.
type ProviderType string
const (
ProviderTypeOIDC ProviderType = "oidc"
ProviderTypeSAML ProviderType = "saml"
)
// IdentityProviderConfig stores the configuration for an external Identity Provider.
type IdentityProviderConfig struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
ClientID string `gorm:"type:uuid;not null;index" json:"client_id"` // Replaces TenantID
ProviderType ProviderType `gorm:"type:varchar(10);not null" json:"provider_type"`
DisplayName string `gorm:"not null" json:"display_name"`
Status string `gorm:"default:'active'" json:"status"`
// OIDC Specific Fields
IssuerURL *string `gorm:"null" json:"issuer_url,omitempty"`
OIDCClientID *string `gorm:"null" json:"oidc_client_id,omitempty"` // Renamed from ClientID
OIDCClientSecret *string `gorm:"null" json:"oidc_client_secret,omitempty"` // Renamed from ClientSecret
// Scopes are space-separated
Scopes *string `gorm:"null" json:"scopes,omitempty"`
// SAML Specific Fields
MetadataURL *string `gorm:"null" json:"metadata_url,omitempty"`
MetadataXML *string `gorm:"type:text;null" json:"metadata_xml,omitempty"`
EntityID *string `gorm:"null" json:"entity_id,omitempty"`
AcsURL *string `gorm:"null" json:"acs_url,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// BeforeCreate hook to generate UUID if not present.
func (idc *IdentityProviderConfig) BeforeCreate(tx *gorm.DB) (err error) {
if idc.ID == "" {
idc.ID = uuid.NewString()
}
return
}
+46
View File
@@ -0,0 +1,46 @@
package domain
import "time"
type HydraClient struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name,omitempty"`
ClientSecret string `json:"client_secret,omitempty"` // Added
ClientURI string `json:"client_uri,omitempty"`
RedirectURIs []string `json:"redirect_uris,omitempty"`
GrantTypes []string `json:"grant_types,omitempty"`
ResponseTypes []string `json:"response_types,omitempty"`
Scope string `json:"scope,omitempty"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type HydraConsentRequest struct {
Challenge string `json:"challenge"`
RequestedScope []string `json:"requested_scope"`
RequestedAudience []string `json:"requested_access_token_audience"`
Skip bool `json:"skip"`
Subject string `json:"subject"`
Client HydraClient `json:"client"`
}
type HydraLoginRequest struct {
Challenge string `json:"challenge"`
Subject string `json:"subject"`
Skip bool `json:"skip"`
Client HydraClient `json:"client"`
}
type HydraConsentSession struct {
ConsentRequestID string `json:"consent_request_id,omitempty"`
Subject string `json:"subject,omitempty"`
GrantedScope []string `json:"grant_scope,omitempty"`
GrantedAudience []string `json:"grant_access_token_audience,omitempty"`
Remember bool `json:"remember"`
RememberFor int `json:"remember_for,omitempty"`
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
RequestedAt *time.Time `json:"requested_at,omitempty"`
HandledAt *time.Time `json:"handled_at,omitempty"`
Client HydraClient `json:"client,omitempty"`
ConsentRequest *HydraConsentRequest `json:"consent_request,omitempty"`
}
+89
View File
@@ -0,0 +1,89 @@
package domain
import (
"errors"
"net/http"
"time"
)
// ErrNotSupported는 IDP가 특정 인증 흐름을 지원하지 않을 때 반환합니다.
var ErrNotSupported = errors.New("idp: not supported")
// BrokerUser is the standard user model used within Baron SSO business logic.
// It defines the canonical set of fields that must be supported by any underlying IDP.
type BrokerUser struct {
ID string `json:"id" required:"true"`
Email string `json:"email" required:"true"`
Name string `json:"name"`
PhoneNumber string `json:"phone_number"`
// Attributes stores custom user attributes.
// The "required_keys" tag specifies which keys MUST be present in the IDP's schema support.
Attributes map[string]interface{} `json:"attributes" required_keys:"grade,department"`
}
// IDPMetadata represents the schema capabilities of an Identity Provider.
type IDPMetadata struct {
// SupportedFields lists the BrokerUser fields (json tag names) that the IDP supports.
// For custom attributes, use the key name directly (e.g., "grade").
SupportedFields []string
}
// PasswordPolicy는 비밀번호 정책 정보를 표현합니다.
type PasswordPolicy struct {
MinLength int
Lowercase bool
Uppercase bool
Number bool
NonAlphanumeric bool
MinCharacterTypes int
}
// Token represents a session or refresh token.
type Token struct {
JWT string
Expiration time.Time
SessionID string
}
// AuthInfo contains authentication information after a successful login.
type AuthInfo struct {
SessionToken *Token
RefreshToken *Token
// Subject는 IDP 세션이 대표하는 주체(예: Kratos identity.id)를 나타냅니다.
Subject string
}
// LinkLoginInit는 링크 로그인 초기화 결과입니다.
type LinkLoginInit struct {
FlowID string
ExpiresAt time.Time
// Mode는 링크 로그인 완료 후 세션 처리 방식입니다. (예: "cookie")
Mode string
// LoginID는 IDP에 실제 전달된 식별자입니다.
LoginID string
}
// IdentityProvider is the interface that all IDP adapters must implement.
type IdentityProvider interface {
Name() string
// GetMetadata returns the schema support information for this IDP.
// This is used for startup-time validation.
GetMetadata() (*IDPMetadata, error)
// CreateUser는 BrokerUser 스키마를 기반으로 신규 사용자를 생성하고 주체 ID(예: identity.id)를 반환합니다.
CreateUser(user *BrokerUser, password string) (string, error)
// SignIn은 로그인 ID/비밀번호로 인증해 세션 정보를 반환합니다.
SignIn(loginID, password string) (*AuthInfo, error)
// UserExists는 loginID 기준으로 사용자 존재 여부를 확인합니다.
UserExists(loginID string) (bool, error)
// IssueSession은 비밀번호 없이 세션을 발급해야 하는 흐름에서 사용합니다.
IssueSession(loginID string) (*AuthInfo, error)
// InitiateLinkLogin은 링크 기반 로그인 요청을 IDP에 전달합니다.
InitiateLinkLogin(loginID, returnTo string) (*LinkLoginInit, error)
// VerifyLoginCode는 링크/코드 기반 로그인에서 코드를 제출해 세션을 발급합니다.
VerifyLoginCode(loginID, flowID, code string) (*AuthInfo, error)
// GetPasswordPolicy는 IDP가 제공하는 비밀번호 정책을 반환합니다.
GetPasswordPolicy() (*PasswordPolicy, error)
InitiatePasswordReset(loginID, redirectUrl string) error
VerifyPasswordResetToken(token string) (*AuthInfo, error)
UpdateUserPassword(loginID, newPassword string, r *http.Request) error
}
+42
View File
@@ -0,0 +1,42 @@
package domain
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
)
// JSONMap is a custom type for handling map[string]any with PostgreSQL JSONB
type JSONMap map[string]any
// Value implements the driver.Valuer interface
func (m JSONMap) Value() (driver.Value, error) {
if m == nil {
return nil, nil
}
ba, err := json.Marshal(m)
return string(ba), err
}
// Scan implements the sql.Scanner interface
func (m *JSONMap) Scan(value any) error {
if value == nil {
*m = make(JSONMap)
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New(fmt.Sprintf("failed to scan JSONMap: %v", value))
}
result := make(JSONMap)
err := json.Unmarshal(bytes, &result)
*m = result
return err
}
+44
View File
@@ -0,0 +1,44 @@
package domain
import (
"context"
"time"
)
// AuditLog represents a single audit event
type AuditLog struct {
EventID string `json:"event_id"`
Timestamp time.Time `json:"timestamp"`
UserID string `json:"user_id"`
SessionID string `json:"session_id,omitempty"`
EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent"
Status string `json:"status"` // e.g., "success", "failure"
AuthMethod string `json:"auth_method,omitempty"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
DeviceID string `json:"device_id,omitempty"`
Details string `json:"details,omitempty"` // JSON string or simple text
}
// AuditRepository defines interface for storing logs
type AuditRepository interface {
Create(log *AuditLog) error
FindPage(ctx context.Context, limit int, cursor *AuditCursor) ([]AuditLog, error)
FindByUserAndEvents(ctx context.Context, userID string, eventTypes []string, limit int) ([]AuditLog, error)
Ping(ctx context.Context) error
}
type AuditCursor struct {
Timestamp time.Time
EventID string
}
// RedisRepository defines interface for KV storage (Redis)
type RedisRepository interface {
Set(key string, value string, expiration time.Duration) error
Get(key string) (string, error)
Delete(key string) error
StoreVerificationCode(phone, code string) error
GetVerificationCode(phone string) (string, error)
DeleteVerificationCode(phone string) error
}
@@ -0,0 +1,31 @@
package domain
import (
"context"
"time"
)
type OathkeeperAccessLog struct {
Timestamp time.Time
RequestID string
Method string
Path string
Status int
LatencyMs int
ClientID string
RP string
Action string
Target string
Subject string
ClientIP string
UserAgent string
Decision string
TraceID string
SpanID string
Raw string
}
type OathkeeperLogRepository interface {
FindPageBySubject(ctx context.Context, subject string, limit int, cursor *AuditCursor) ([]OathkeeperAccessLog, error)
Ping(ctx context.Context) error
}
+19
View File
@@ -0,0 +1,19 @@
package domain
import (
"time"
)
// RelyingParty represents an OAuth2 Client owner by a Tenant.
// It maps 1:1 to a Hydra Client.
type RelyingParty struct {
ClientID string `json:"clientId"` // Maps to Hydra Client ID
TenantID string `json:"tenantId"`
Name string `json:"name"` // Display name (can be same as Hydra Client Name)
Description string `json:"description"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// DeletedAt removed as it's not a DB model anymore
}
// TableName removed
+41
View File
@@ -0,0 +1,41 @@
package domain
// SmsService defines the interface for sending SMS messages.
type SmsService interface {
SendSms(to, content string) error
}
// NaverSmsRequest represents the request body for the Naver Cloud SMS API.
type NaverSmsRequest struct {
Type string `json:"type"`
ContentType string `json:"contentType"`
CountryCode string `json:"countryCode"`
From string `json:"from"`
Content string `json:"content"`
Messages []SmsMessage `json:"messages"`
}
// SmsMessage represents a single message to be sent.
type SmsMessage struct {
To string `json:"to"`
Content string `json:"content,omitempty"`
}
// NaverSmsResponse represents the response from the Naver Cloud SMS API.
type NaverSmsResponse struct {
RequestID string `json:"requestId"`
RequestTime string `json:"requestTime"`
StatusCode string `json:"statusCode"`
StatusName string `json:"statusName"`
}
// SmsRequest represents the request body for sending an SMS.
type SmsRequest struct {
PhoneNumber string `json:"phoneNumber"`
}
// SmsVerifyRequest represents the request body for verifying an SMS code.
type SmsVerifyRequest struct {
PhoneNumber string `json:"phoneNumber"`
Code string `json:"code"`
}
+43
View File
@@ -0,0 +1,43 @@
package domain
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Tenant statuses
const (
TenantStatusPending = "pending"
TenantStatusActive = "active"
TenantStatusSuspended = "suspended"
TenantStatusDeleted = "deleted"
)
// Tenant represents a tenant model stored in PostgreSQL.
type Tenant struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"`
Description string `json:"description"`
Status string `gorm:"default:'pending'" json:"status"`
Domains []TenantDomain `gorm:"foreignKey:TenantID" json:"domains,omitempty"`
Config JSONMap `gorm:"type:jsonb" json:"config,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
func (t *Tenant) IsActive() bool {
return t.Status == TenantStatusActive
}
// BeforeCreate hook to generate UUID if not present.
func (t *Tenant) BeforeCreate(tx *gorm.DB) (err error) {
if t.ID == "" {
t.ID = uuid.NewString()
}
return
}

Some files were not shown because too many files have changed in this diff Show More