1
0
forked from baron/baron-sso

536 Commits

Author SHA1 Message Date
kyy
52046e4a66 adminfront/devfront code-check 수정 2026-06-10 15:42:01 +09:00
kyy
e9af231fb0 adminfront/devfront code-check 오류 수정 2026-06-10 15:19:34 +09:00
kyy
85c2eb1690 code-check 및 사용자 상세 claim 관련 오류 수정 2026-06-10 10:37:51 +09:00
kyy
4c9d219fd4 lint 포맷 불일치 수정 2026-06-10 10:20:40 +09:00
kyy
2234986abd devfront biome 오류 수정 2026-06-10 10:11:52 +09:00
kyy
b919f600e1 누락 locale 키 추가 및 린트 정리 2026-06-10 10:11:52 +09:00
kyy
437a3ad98d 개발자 권한을 페이지별로 선택/부여 가능하도록 개선 2026-06-10 10:11:52 +09:00
kyy
3ed9e912e6 테넌트 비소속 개발자 권한 신청/부여 가능 2026-06-10 10:11:52 +09:00
kyy
0f11173739 개발자 권한 부여 페이지 추가 2026-06-10 10:11:52 +09:00
kyy
41e755b1c7 devfront 테넌트 미소속 개발자 신청 안내 추가 2026-06-10 10:11:52 +09:00
kyy
894feb20f1 devfront rp_admin tenant_admin 제거 2026-06-10 10:11:52 +09:00
c880b3c333 orgfront 버그 픽스 2026-06-10 09:36:57 +09:00
28478309fa orgfront 권한 정리 2026-06-10 08:37:27 +09:00
cad1162597 Merge remote-tracking branch 'origin/dev' into dev 2026-06-09 21:08:43 +09:00
1341f07ef9 chore: consolidate local integration changes 2026-06-09 21:03:05 +09:00
107406d113 Merge pull request 'feat(monitor): add blackbox-exporter service to staging compose templates' (#1051) from feature/staging-healthcheck-monitoring into dev
Reviewed-on: baron/baron-sso#1051
2026-06-09 14:39:29 +09:00
67af52d8e2 feat(monitor): add blackbox-exporter service to staging compose templates 2026-06-09 14:38:18 +09:00
48048a24fe Merge pull request 'feature/staging-healthcheck-monitoring' (#1048) from feature/staging-healthcheck-monitoring into dev
Reviewed-on: baron/baron-sso#1048
2026-06-09 14:36:41 +09:00
4eb4c5af34 fix(monitor): update promtail config mount paths in staging compose templates 2026-06-09 14:31:00 +09:00
f61c56cfde Merge branch 'dev' into feature/staging-healthcheck-monitoring 2026-06-09 13:57:37 +09:00
2671ebda27 feat(monitor): commit preserved blackbox exporter config and observability dashboard 2026-06-09 13:53:01 +09:00
2405961375 chore(monitor): remove unused monitoring environment variables from env sample 2026-06-09 13:33:26 +09:00
ae97950108 feat(monitor): precisely exclude Loki, Grafana, and Prometheus while keeping promtail and blackbox-exporter 2026-06-09 10:23:54 +09:00
f726463a6c Merge pull request 'build 검증 워크플로우' (#1030) from feature/df-permission into dev
Reviewed-on: baron/baron-sso#1030
2026-06-08 10:56:58 +09:00
kyy
badcabb644 build 검증 워크플로우 2026-06-08 10:53:25 +09:00
aa2848c3b6 restart policy 정리 2026-06-08 08:30:51 +09:00
9be833d2e0 Merge pull request 'feature/af-tenant-ui' (#1024) from feature/af-tenant-ui into dev
Reviewed-on: baron/baron-sso#1024
2026-06-05 21:26:52 +09:00
4e81e214a3 fix(deploy): remove grafana-sms-webhook from compose templates again 2026-06-05 21:26:01 +09:00
561659f333 프롬테일 오류 수정 2026-06-05 21:22:04 +09:00
kyy
0b48fe22c7 adminfront-tests E2E 기대값을 현재 UI 동작에 맞게 정리 2026-06-05 21:16:11 +09:00
kyy
b8c1b116b1 adminfront-tests 테넌트 트리 선택 selector를 고유 testid 기준으로 수정 2026-06-05 21:07:27 +09:00
kyy
57c05c9241 adminfront 포맷 정리 2026-06-05 21:03:00 +09:00
kyy
9478944197 adminfront-tests 검색 placeholder 기대값을 현재 UI 문구에 맞게 수정 2026-06-05 21:00:40 +09:00
kyy
c9cf7d6c67 adminfront-tests 대량 테넌트 목록 집계 검증을 테스트 환경 동작에 맞게 수정 2026-06-05 20:53:28 +09:00
kyy
06d2b71e25 adminfront-tests 테넌트 타입 라벨 검증을 현재 UI에 맞게 수정 2026-06-05 20:48:39 +09:00
kyy
9803108de2 adminfront-tests Playwright CLI 경로 하드코딩 제거 2026-06-05 20:42:59 +09:00
fe176c6912 fix(deploy): remove unavailable grafana-sms-webhook and fix promtail env expansion 2026-06-05 20:42:22 +09:00
kyy
01cd7a0ad3 code-check 오류 수정 2026-06-05 20:37:23 +09:00
kyy
87a45f0e76 누락 키 추가 2026-06-05 19:30:35 +09:00
5670288616 fix(deploy): add docker login for Gitea registry in staging_code_pull workflow 2026-06-05 19:20:20 +09:00
3ab9d28c9d fix(deploy): use Gitea container registry domain for grafana-sms-webhook image 2026-06-05 19:15:37 +09:00
2dedeb66b6 feat(monitoring): add promtail and grafana-sms-webhook to staging_pull_compose template 2026-06-05 18:59:01 +09:00
1f47abb860 feat(monitoring): integrate prometheus and promtail log aggregation with sms alerts 2026-06-05 18:34:22 +09:00
kyy
a6f9d89477 사이드바 펼침/접기 형식 변환 추가 2026-06-05 17:47:45 +09:00
kyy
729a9890a6 테넌트 레지스트리 테이블 UI 복원 2026-06-05 15:32:00 +09:00
kyy
b4883bc9eb 빌드 오류 및 리다이렉트 수정 2026-06-05 14:32:49 +09:00
kyy
d54d258117 검색 placeholder와 폭 정리 2026-06-05 13:36:36 +09:00
kyy
f3e9ca52be 사용자 목록 로케일 및 링크 스타일 통일 2026-06-05 13:36:36 +09:00
kyy
1596342d03 사이드바 접기 기능 추가 2026-06-05 13:33:40 +09:00
kyy
f6c7cb3b22 tenants 레지스트리 가독성/로케일 적용 2026-06-05 13:32:27 +09:00
kyy
47d2f15283 tenants 목록 툴바 레이아웃 정리 2026-06-05 13:29:55 +09:00
29038254dd 백업/복구로직 변경, 깜빡임 버그 해결 2026-06-05 12:26:51 +09:00
4bae1dd00d fix(deploy): align staging frontend runtime with production images 2026-06-05 09:24:44 +09:00
ded9dfc56b Merge pull request 'fix(deploy): resolve frontend deployment failure by fixing workspace detection and dependency installation' (#1005) from feature/rbac-simplification-and-remove-dev-switcher into dev
Reviewed-on: baron/baron-sso#1005
2026-06-05 08:58:47 +09:00
d707cdf850 fix(deploy): resolve frontend deployment failure by fixing workspace detection and dependency installation 2026-06-05 08:58:18 +09:00
3f4138e3a0 Merge pull request 'feature/rbac-simplification-and-remove-dev-switcher' (#1003) from feature/rbac-simplification-and-remove-dev-switcher into dev
Reviewed-on: baron/baron-sso#1003
2026-06-04 18:11:48 +09:00
5c46727fb5 adminfront test 오류 해결 2026-06-04 18:07:08 +09:00
e5ac333efa fix(ci): pin dependencies to resolve supply-chain policy violations
- Added pnpm overrides in root package.json to pin '@types/node', 'undici', 'electron-to-chromium', and '@csstools/css-syntax-patches-for-csstree' to versions satisfying the minimum release age policy.
- Regenerated pnpm-lock.yaml with policy-compliant versions.
2026-06-04 17:50:17 +09:00
5377401574 fix(ci): restructure monorepo workspace and resolve vitest failures
- Restructured pnpm workspace by moving pnpm-workspace.yaml to the project root and removing redundant subdirectory configs.
- Fixed 'devfront-vitest-coverage' CI failure caused by missing root-level workspace configuration.
- Resolved Vitest failures in TenantListPage by bypassing virtualization in test environments (isTest/window._IS_TEST_MODE).
- Fixed syntax errors and type mismatches in AuditLogTable to unblock coverage reporting.
- Improved type safety by replacing 'any' casts with specific types in virtualized table components.
- Updated .gitignore to exclude root node_modules and synchronized pnpm-lock.yaml.
2026-06-04 17:43:25 +09:00
f76321c8ac test 파일 삭제 2026-06-04 16:38:47 +09:00
b2f155e35b perf(admin): full-stack performance optimization for all list tables
- Implemented server-side search, infinite scrolling, and list virtualization for Tenants, Users, and Audit Logs.
- Backend: Enhanced Repository, Service, and Handler layers to support 'search' and 'cursor' parameters.
- Frontend: Integrated @tanstack/react-virtual and useInfiniteQuery for high-performance rendering.
- Quality: Updated all unit tests and E2E tests to match the new asynchronous server-side search architecture.
- i18n: Synced all translation keys and cleaned up unused resources.
2026-06-04 16:06:30 +09:00
6d3f128282 perf(admin): implement server-side search and virtualization for tenant list
- Backend: Added 'search' parameter to TenantRepository and TenantService.
- Backend: Updated all Tenant list calls to support searching.
- Backend: Enhanced UserRepository.List to support cursor-based pagination and search.
- Frontend: Switched TenantListPage to use useInfiniteQuery for lazy loading.
- Frontend: Implemented list virtualization in TenantHierarchyView using @tanstack/react-virtual.
- Frontend: Added server-side search with debouncing (useDeferredValue).
- Fixed various Go compilation errors caused by method signature changes.
2026-06-04 14:08:55 +09:00
ba3e9103f2 Merge pull request 'feature/rbac-simplification-and-remove-dev-switcher' (#997) from feature/rbac-simplification-and-remove-dev-switcher into dev
Reviewed-on: baron/baron-sso#997
2026-06-04 13:06:11 +09:00
8f2e351875 fix(admin): stabilize tenant import report UI and satisfy E2E tests
- Added missing i18n keys for import results in both root and common locales.
- Fixed TypeScript type errors and implicit 'any' types in TenantListPage.
- Added 'destructive' variant to common Badge component.
- Updated Playwright tests with refined locators and enhanced API mocks to match the new reporting structure.
- Restored quick summary message in Tenant Registry for backward compatibility.
2026-06-04 12:59:32 +09:00
91e983b315 Merge branch 'dev' into feature/rbac-simplification-and-remove-dev-switcher 2026-06-04 11:27:39 +09:00
499b5d65da style(admin): enhance UI/UX of tenant import result modal
- Added visual summary cards with color-coded counts (Total, Created, Updated, Failed).
- Implemented Tabs for status-based filtering (ALL, CREATED, UPDATED, FAILED, SKIPPED).
- Improved tab visibility with bold text and status-specific active colors.
- Refined table layout with consolidated status badges and small tags for modified fields.
2026-06-04 11:26:24 +09:00
5ba0d0fb86 fix(backend): add missing reflect import 2026-06-04 11:20:35 +09:00
c6c79f7306 fix(admin): add missing Tabs import and refine import result UI type safety 2026-06-04 11:20:21 +09:00
fbdfb97c3e feat(admin): improve tenant bulk import reporting with detailed results
- Backend: Enhanced 'ImportTenantsCSV' to return row-by-row details including action, status, and modified fields.
- Backend: Refactored 'upsertTenantCSVRecord' to detect and return specific modified fields (Name, Type, ParentID, Slug, Description, Config, Domains).
- Frontend: Added 'TenantImportDetail' and updated 'TenantImportResult' types.
- Frontend: Implemented a detailed results modal in 'TenantListPage' showing processing summary and row-level feedback for better transparency.
2026-06-04 11:12:47 +09:00
8cdd73d31a Merge pull request 'feature/backend-rp-tenant' (#995) from feature/backend-rp-tenant into dev
Reviewed-on: baron/baron-sso#995
2026-06-04 10:57:10 +09:00
kyy
243b852591 tenant 제한 에러 처리 보안 2026-06-04 10:27:12 +09:00
kyy
80aa60fdf1 tenant 삭제 시 RP 허용 테넌트 정리 및 재유입 방지 2026-06-04 10:27:12 +09:00
af1f45cc25 Merge pull request 'feature/rbac-simplification-and-remove-dev-switcher' (#993) from feature/rbac-simplification-and-remove-dev-switcher into dev
Reviewed-on: baron/baron-sso#993
2026-06-04 10:21:59 +09:00
a125b1d7ae test(devfront): update unit tests to match refined RBAC model for privileged roles 2026-06-04 10:14:07 +09:00
322fd13d67 fix: resolve unit and integration test failures in adminfront
- Updated roles test to align with simplified RBAC model.
- Fixed AppLayout test navigation label order.
- Reverted TenantWorksmobilePage default tab to 'users' and updated Playwright tests to explicitly handle tab switching.
- Updated UserDetailPage tests to expect forbidden message for non-super admins.
2026-06-04 10:04:50 +09:00
fcb246ea9e fix: stabilize tests and refine RBAC model for privileged roles
- Updated devfront to recognize 'rp_admin' and 'tenant_admin' as privileged developer roles.
- Added specific forbidden messages for privileged roles in devfront.
- Improved adminfront Worksmobile test reliability across browsers.
- Updated Makefile to skip userfront tests in environments without Flutter SDK.
- Applied lint and format fixes across adminfront and devfront.
2026-06-04 09:56:02 +09:00
719f408e7e fix: resolve adminfront test failures and enforce role-based access control
- Fixed ReferenceErrors in UserCreatePage and UserListPage by adding missing imports and definitions.
- Implemented explicit role-based access control (forbidden messages) in UserCreatePage and UserDetailPage.
- Corrected Playwright security tests by aligning OIDC mocks and resolving route overlaps.
- Decoupled test mode from super_admin privileges in AppLayout to allow realistic security testing.
- Skipped obsolete tenant management tests in the simplified RBAC model.
2026-06-02 20:34:39 +09:00
ab6cb1331e test: add security role access control smoke test
- Added Playwright test to verify super_admin vs user access control in adminfront.
- Validates menu visibility and direct route access restrictions.
2026-06-02 19:32:28 +09:00
b7c963b672 fix: update devfront tests to match simplified RBAC model
- Updated role normalization tests to expect legacy roles mapped to 'user'.
- Updated forbidden message tests to expect standard user guidance for legacy roles.
2026-06-02 19:27:59 +09:00
e5f1c85e29 fix: additional UI cleanup for RBAC simplification
- Simplified access control in TenantListPage and UserDetailPage.
- Final formatting and default tab fixes in TenantWorksmobilePage.
2026-06-02 19:24:05 +09:00
74068503bb fix: resolve adminfront test failures and ReferenceErrors
- Fixed 'profileRole is not defined' ReferenceError by adding missing definition and import in UserCreatePage and UserListPage.
- Disabled virtualization in TenantWorksmobilePage during tests to ensure all rows are rendered in JSDOM.
- Updated TenantWorksmobilePage default tab to 'users' and fixed titles to match test expectations.
- Updated adminLargePages.test.tsx to explicitly switch to the history tab where required.
2026-06-02 19:23:11 +09:00
1f3d56933f fix: resolve remaining Hanmac email policy test failure
- Corrected mock tenant hierarchy in Hanmac email policy test.
- Ensured 100% pass rate for backend handlers under the new RBAC model.
2026-06-02 19:03:16 +09:00
f76dd4e60d chore: formatting and linting cleanup via code-check 2026-06-02 18:53:56 +09:00
bf64f82507 fix: resolve i18n synchronization and fix backend tests
- Added missing i18n keys for integrity and tenant profile pages to root and adminfront locales.
- Corrected i18n section structure in template.toml.
- Fixed Hanmac email policy test by improving tenant hierarchy mocking and ensuring correct CompanyCode propagation.
- Resolved various backend test failures by updating expectations for normalized roles and fixing undefined variables.
2026-06-02 18:50:26 +09:00
ae8c2ee06f Merge branch 'dev' into feature/rbac-simplification-and-remove-dev-switcher 2026-06-02 18:36:44 +09:00
802bf3e91d feat: simplify RBAC roles and remove dev role switcher
- Simplified RBAC system to two roles: super_admin and user.
- Removed tenant_admin and rp_admin roles across backend and frontend.
- Removed Dev Role Switcher feature from adminfront.
- Updated all handlers, middlewares, and navigation to reflect the new role model.
- Fixed backend build errors and updated tests.
2026-06-02 18:29:18 +09:00
d32ca69eee feat: improve Worksmobile tenant sync handling 2026-06-02 18:05:36 +09:00
d6d39ca300 Merge pull request 'feature/df-enchancement' (#979) from feature/df-enchancement into dev
Reviewed-on: baron/baron-sso#979
2026-06-02 13:34:06 +09:00
kyy
2c5eed1774 75f192fb24 기준 병합 code-check 수정 2026-06-02 11:52:16 +09:00
kyy
38605ac8a3 devfront 테스트 커버리지 추가 보강 2026-06-02 11:52:16 +09:00
kyy
a4d457073a 테스트 커버리지 보강 및 공통 유틸 테스트 추가 2026-06-02 11:52:16 +09:00
kyy
d0f44de2d1 최근 변경 앱 상세 다국어 정리 2026-06-02 11:52:16 +09:00
kyy
d2a7ebd82f 연동 앱 페이지 UI 정리 2026-06-02 11:52:16 +09:00
kyy
d40e443d48 최근 변경된 앱 대시보드 추가 2026-06-02 11:52:16 +09:00
kyy
c4487b9334 연동 앱 페이지 레이아웃 개선 2026-06-02 11:52:16 +09:00
57f05e2694 chore: remove Playwright MCP artifacts 2026-06-02 10:46:18 +09:00
565ef6b685 웍스 동기화 이력확인 기능추가 2026-06-02 10:41:33 +09:00
75f192fb24 merge: integrate origin dev into dev
Includes Worksmobile SSOT sync comparison updates, UUID import conflict resolution, and Playwright route mock stabilization.
2026-06-01 17:48:39 +09:00
5c8a338085 feat: update worksmobile sync and restore planning 2026-06-01 17:01:53 +09:00
af55e3dbb8 Merge pull request 'feat(user): support fixed UUID registration and enhance bulk import results' (#961) from feature/issue-918-uuid-integrity into dev
Reviewed-on: baron/baron-sso#961
2026-06-01 15:42:42 +09:00
31d107ff2e feat(user): support fixed UUID registration and enhance bulk import results
- Added support for fixed UUIDs during bulk registration (Search-first + ExternalID mapping)
- Implemented idempotency and visibility restoration for soft-deleted users
- Enhanced bulk upload UI to show 'New/Updated/Unchanged' status and modified fields
- Added logic to reclaim identifiers (login_id) from colliding records
- Added frontend E2E and backend unit tests for UUID integrity and conflict handling
- Fixed i18n, formatting, and mock tests to satisfy code-check
- Applied 'go fix' for 'omitzero' tags and general Go standards
2026-06-01 15:34:08 +09:00
6574fb54b9 fix: preserve published badge state 2026-06-01 11:35:00 +09:00
4a1e89e421 Merge pull request 'test: 감사 로그(Audit Logs) 페이지 E2E 테스트 추가 (#919)' (#937) from feature/issue-919-audit-logs-e2e into dev
Reviewed-on: baron/baron-sso#937
2026-05-29 19:53:09 +09:00
c59ec5ce83 adminfront-vitest-coverage 오류 수정 2026-05-29 19:48:01 +09:00
90457394b0 fix(ci): stabilize pnpm installation and ensure fail-fast testing 2026-05-29 19:39:56 +09:00
6259fb074b Merge branch 'dev' into feature/issue-919-audit-logs-e2e 2026-05-29 18:52:33 +09:00
90740ffb22 style: resolve biome configuration warnings and fix linting in orgfront 2026-05-29 18:47:04 +09:00
4aa0ada012 Merge pull request 'feature/uf-sign-page' (#942) from feature/uf-sign-page into dev
Reviewed-on: baron/baron-sso#942
2026-05-29 18:39:41 +09:00
22e2cc1f0f style(adminfront): fix biome formatting in audit spec 2026-05-29 18:38:11 +09:00
kyy
3c741ad0e3 devfront vitest 커버리지 오류 수정 2026-05-29 18:34:55 +09:00
e8d76e5e95 test(adminfront): fix csv parser vitest failure and optimize audit spec for CI 2026-05-29 18:34:10 +09:00
520d7404cf fix(adminfront): resolve biome lint errors and refine test stability 2026-05-29 18:26:22 +09:00
kyy
86940cce9e 23cd316c23 기준 병합 code-check 오류 수정 2026-05-29 18:26:17 +09:00
kyy
cadb0631fd 5b345fcf 기준 병합 code-check 오류 수정 2026-05-29 18:26:17 +09:00
kyy
420f2429c3 adminfront 정적 서빙 경로 수정 & devfront 빌드 오류 수정 2026-05-29 18:26:17 +09:00
kyy
bb87034898 c489c7c3 기준 병합 code-check 오류 수정 2026-05-29 18:26:17 +09:00
kyy
07b0c055cc 승인 완료 i18n/e2e 및 adminfront 정적 서빙 경로 수정 2026-05-29 18:26:17 +09:00
kyy
59514f4cf3 최근 변경된 앱 E2E 테스트 추가 2026-05-29 18:26:17 +09:00
kyy
0f06fbc901 개요/감사로그 개발자 권한 신청 E2E 추가 2026-05-29 18:26:17 +09:00
kyy
2c93bd8dfb 개발자 권한 접근 로직 공통화 2026-05-29 18:26:17 +09:00
kyy
b4dfbe0480 개요/감사로그 CTA 공통화 2026-05-29 18:26:17 +09:00
kyy
23e3738b80 i18n 누락 키 추가 및 Go 포맷 오류 정리 2026-05-29 18:26:17 +09:00
kyy
5648b7ec45 사용자 삭제 RP 관계 정리 로그 미표시 수정 2026-05-29 18:26:17 +09:00
kyy
a156713db7 최근 변경 앱 페이지네이션 적용 2026-05-29 18:26:17 +09:00
kyy
041b0724be 삭제된 사용자 RP 관계 정리 2026-05-29 18:26:17 +09:00
kyy
f8d0cf411a 개발자 권한 신청 역할 확인 및 사이드바 순서 변경 2026-05-29 18:26:17 +09:00
kyy
addded8942 변경 RP 카드 변경자 표시 추가 2026-05-29 18:26:17 +09:00
kyy
262c5959cf super admin 일반설정 제한 문제 수정 2026-05-29 18:26:17 +09:00
kyy
939bf68f85 변경된 앱 안내 패널 추가 2026-05-29 18:26:17 +09:00
kyy
73ba79b015 변경 앱 이력 조회 박스 추가 2026-05-29 18:26:17 +09:00
kyy
955d0fb6da e54802140a 병합 userfront-e2e 오류 수정 2026-05-29 18:26:17 +09:00
kyy
36cd693b4f 회원가입 테마 가시성 회귀 테스트 추가 2026-05-29 18:26:17 +09:00
kyy
509029f8f3 super admin RP 관계 관리 버튼 활성화 2026-05-29 18:26:17 +09:00
kyy
6512fea8fe 개요 차단 화면에 개발자 권한 신청 버튼 추가 2026-05-29 18:26:17 +09:00
kyy
7fe86e8aa4 일반 사용자 연동 앱 추가 버튼 노출 방지 2026-05-29 18:26:17 +09:00
kyy
a010bd44c0 code-check i18n 누락 및 userfront 포맷 수정 2026-05-29 18:26:17 +09:00
kyy
4ca492b31c 회원가입 UI 일관성 및 가시성 수정 2026-05-29 18:26:17 +09:00
kyy
cb8c7d78c3 로그인 승인 페이지 UI/UX 수정 2026-05-29 18:26:17 +09:00
bf94c7a3d6 Merge branch 'dev' into feature/issue-919-audit-logs-e2e 2026-05-29 18:21:38 +09:00
bdca346baa test(adminfront): further stabilize audit logs test and fix bulk secondary email test 2026-05-29 18:20:56 +09:00
4c56c28481 userfront&backend test coverage 추가 2026-05-29 18:04:04 +09:00
b74bab4161 Merge branch 'dev' into feature/issue-919-audit-logs-e2e 2026-05-29 17:32:14 +09:00
16b2c97ddc test(adminfront): eliminate CI flakiness in audit and tenant e2e tests 2026-05-29 17:21:20 +09:00
23cd316c23 코드체크 결과 README에 뱃지로 추가 2026-05-29 17:15:13 +09:00
f85def288d Merge branch 'dev' into feature/issue-919-audit-logs-e2e 2026-05-29 17:01:45 +09:00
d43787a96d test(adminfront): fix audit mock conflict and stabilize tenant dropdown interaction 2026-05-29 16:53:26 +09:00
5ddfc6c81b 코드체크 실패 케이스 해결. 배치잡 야간 배정 2026-05-29 16:44:46 +09:00
0448b86443 style(adminfront): fix biome formatting in e2e tests 2026-05-29 16:36:21 +09:00
b65d916a83 test(adminfront): stabilize e2e tests for CI environment 2026-05-29 16:34:24 +09:00
58a3be9a34 fix(adminfront): resolve biome lint and formatting failures 2026-05-29 16:21:27 +09:00
8d2e2c58fe test(adminfront): fix failing E2E tests for audit and users
- Relaxed audit log mock route matching to prevent empty state failures
- Fixed strict mode violation on appointment '추가' button by scoping to tabpanel
- Fixed UserDetailPage typescript compilation error during test build
2026-05-29 16:10:01 +09:00
2da470922b chore: fix formatting and lint errors across Makefile, backend, and adminfront
This commit addresses several linting and formatting issues that caused CI checks to fail:
- Makefile: Removed obsolete '--organize-imports-enabled' from Biome and switched to '@biomejs/biome'.
- backend: Fixed spacing and alignment issues according to gofmt.
- adminfront: Fixed multiple unused variables and imports, and configured unsafe fixes in the Biome config to remove dead code.
2026-05-29 15:42:55 +09:00
b33aabbb68 test: 감사 로그(Audit Logs) 페이지 E2E 테스트 추가 (#919)
- AuditLogsPage 내부 검색(search, action, status)에 `data-testid` 추가

- Playwright 테스트(`audit.spec.ts`) 작성하여 목록 로드, 필터 동작 확인
2026-05-29 15:01:34 +09:00
5b345fcf6a test: align csv secondary email expectation 2026-05-29 14:34:34 +09:00
5a98b8490c Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	adminfront/src/features/tenants/routes/TenantListPage.tsx
#	adminfront/src/features/users/UserDetailPage.tsx
#	adminfront/tests/users_bulk_secondary.spec.ts
2026-05-29 14:33:01 +09:00
3e31fdfa0c test: raise frontend coverage baselines 2026-05-29 14:31:10 +09:00
9040d22ad2 Merge pull request 'chore: fix biome lint warnings & update Makefile' (#934) from feature/issue-917-sub-email-support into dev
Reviewed-on: baron/baron-sso#934
2026-05-29 13:51:09 +09:00
29675d9cea chore: fix biome format issue in UserCreatePage (#917) 2026-05-29 13:44:40 +09:00
fcbd936053 test: align csvParser test with sub_email array structure (#917) 2026-05-29 13:40:55 +09:00
963b0835ea fix: resolve ambiguous '추가' button in E2E tests (#917) 2026-05-29 13:39:43 +09:00
00b89c04d6 chore: fix biome lint warnings & update Makefile 2026-05-29 13:32:35 +09:00
2808c68871 Merge pull request 'feature/issue-917-sub-email-support' (#933) from feature/issue-917-sub-email-support into dev
Reviewed-on: baron/baron-sso#933
2026-05-29 13:23:44 +09:00
deed33aad2 Merge branch 'dev' into feature/issue-917-sub-email-support 2026-05-29 13:23:06 +09:00
b245dd3111 fix: resolve TypeScript errors on sub_email ui (#917) 2026-05-29 13:21:55 +09:00
592c1d1741 Merge pull request 'feature/issue-917-sub-email-support' (#931) from feature/issue-917-sub-email-support into dev
Reviewed-on: baron/baron-sso#931
2026-05-29 13:14:11 +09:00
0e83561994 chore: update code check badges 2026-05-29 12:35:58 +09:00
faf6db204d test: align orgfront picker labels with namecards 2026-05-29 12:22:51 +09:00
b81edb8a64 ci: stabilize code check badge workflow 2026-05-29 12:16:55 +09:00
d2270765f2 docs: use repository relative badge assets 2026-05-29 12:07:16 +09:00
a830242947 ci: add code check badges and coverage reports 2026-05-29 12:05:43 +09:00
8d9ba3cfea feat: 보조 이메일(sub_email) 태그/칩 입력 UI 개선 (#917)
- `UserCreatePage` 및 `UserDetailPage`에서 보조 이메일을 입력할 때 일반 텍스트가 아닌 태그(Chip) 형태로 입력/삭제할 수 있도록 UX 개선
- 여러 개의 이메일을 엔터나 클릭으로 하나씩 추가하고, `X` 버튼을 눌러 개별 삭제가 가능하도록 인터랙션 보강
- Form의 `sub_email` 데이터 타입을 `string[]`으로 일원화하여 파싱 오류 및 데이터 정합성 강화
2026-05-29 11:19:04 +09:00
62b1938c42 refactor: 보조 이메일 키값을 sub_email로 통일 및 수동 폼 추가 (#917)
- `secondary_emails` 대신 `sub_email`을 키값으로 사용하도록 전면 수정
- 관리자 화면의 수동 사용자 생성(Create) 및 수정(Detail) 폼에 `sub_email` 입력 필드 추가
- CSV 템플릿의 컬럼명을 `sub_email`로 변경
- 백엔드의 Kratos Traits 조회 및 배열 추출 로직을 `sub_email` 기준으로 업데이트
- E2E 테스트(`users_bulk.spec.ts`, `users_bulk_secondary.spec.ts`)에서 `sub_email` 검증하도록 수정 및 통과 확인
2026-05-29 11:07:59 +09:00
00310448e9 fix: 사용자 템플릿 외부 함수 동기화 및 상세 페이지에 보조 이메일 표시 (#917) 2026-05-29 10:45:57 +09:00
6e610c553f feat: 사용자 벌크 CSV 등록 시 보조 이메일 지원 (#917)
- `adminfront` CSV 템플릿 헤더에 `secondary_emails` 추가 및 예시 반영
- `adminfront` CSV 파서(`csvParser.ts`)에서 `secondary_emails` 추출 로직 보강
- `backend` 에서 `BulkCreateUsers`, `UpdateUser` 실행 시 보조 이메일을 포함한 모든 이메일에 대해 식별자 유효성(ValidateLoginID) 검사 수행
- `domain.ValidateLoginID`의 파라미터를 복수 이메일 처리를 위해 `[]string`으로 변경
- Playwright E2E 테스트 `users_bulk_secondary.spec.ts` 신규 작성 및 테스트 패스 확인
2026-05-29 10:39:24 +09:00
c489c7c38f 조직도 표현 개선 2026-05-29 10:33:15 +09:00
6a6730b544 test(orgfront): align dense member column fixture 2026-05-29 08:45:39 +09:00
731ae9251e fix(orgfront): place GPDTDC users on leaf appointments 2026-05-29 08:38:05 +09:00
da01f63c54 userfront e2e 전체 테스트 2026-05-29 08:19:34 +09:00
dc16958804 ci(userfront-e2e): add chromium fast lane 2026-05-28 17:04:41 +09:00
27caf27416 Merge remote-tracking branch 'origin/dev' into dev 2026-05-28 16:53:41 +09:00
615d204678 fix(userfront): reduce service worker install cache 2026-05-28 16:53:37 +09:00
2595d9ab74 Merge pull request 'fix(ci): use npx pnpm for dependency installation and handle missing flutter gracefully' (#899) from add/e2e_test into dev
Reviewed-on: baron/baron-sso#899
2026-05-28 13:29:53 +09:00
6143569f7a fix(ci): use npx pnpm for dependency installation and handle missing flutter gracefully 2026-05-28 13:14:39 +09:00
e0e60295f3 Merge pull request 'add/e2e_test' (#894) from add/e2e_test into dev
Reviewed-on: baron/baron-sso#894
2026-05-28 12:01:57 +09:00
92c3905558 Merge branch 'dev' into add/e2e_test 2026-05-28 11:57:10 +09:00
177a319407 fix(adminfront): fix playwright webserver timeout by refining preview command and vite config 2026-05-28 10:52:14 +09:00
7401454bc0 fix(adminfront): guard employee ID metadata in GPO priority swap 2026-05-28 08:53:28 +09:00
bb5438bf8d fix(ci): fix build path and preview execution in CI 2026-05-27 17:54:20 +09:00
d88524b0f7 fix(ci): simplify webServer preview command to avoid host/port override 2026-05-27 17:47:20 +09:00
62d3923dee fix(adminfront): resolve workspace dependency and build configuration issues
- Resolve 'vite' package entry point error by consolidating workspace dependencies
- Fix PostCSS/Tailwind module resolution by utilizing pnpm hoisting
- Update vite.config.ts to stable build configuration
2026-05-27 17:41:12 +09:00
14b916fec8 fix(ci): improve webServer startup debugging and host binding 2026-05-27 17:34:03 +09:00
3b073a4e11 merge(ci): consolidate adminfront CI fixes with workspace-aware installation 2026-05-27 14:48:02 +09:00
200411a701 chore(adminfront): update vite to 8.0.14 for stable build 2026-05-27 14:46:46 +09:00
e09e83351e fix(adminfront): fix TS error and CI dependency resolution
- Fix 'unknown' to 'string' type mismatch in UserBulkUploadModal.tsx
- Update CI script to install from workspace root (common) for proper dependency resolution
- Use 'env CI=true' for better shell compatibility
2026-05-27 14:44:25 +09:00
45e49cf595 fix(adminfront): use pnpm for webServer commands in Playwright config 2026-05-27 13:54:15 +09:00
d7a56e7352 fix(ci): use pnpm exec and shamefully-hoist to fix Playwright module resolution 2026-05-27 13:48:56 +09:00
c7053c2c51 fix(userfront-e2e): fix widespread test failures in non-Chromium browsers
- Add COOP/COEP headers to serve script for Flutter WASM compatibility (SharedArrayBuffer)
- Update CI workflow to install all Playwright browsers for userfront-e2e
- Fix command reporting consistency in adminfront test script
2026-05-27 13:42:50 +09:00
d25b5bc61d style(userfront): format dart files 2026-05-27 13:18:15 +09:00
1808cf9f33 fix(ci): fix Playwright module resolution by running pnpm install in the app directory 2026-05-27 13:18:13 +09:00
899365de9d Merge pull request 'bugfix/org' (#893) from bugfix/org into dev
Reviewed-on: baron/baron-sso#893
2026-05-27 13:12:07 +09:00
dda1df9c48 Merge branch 'dev' into bugfix/org 2026-05-27 13:11:29 +09:00
35e51910c6 fix(frontend): pnpm TTY error and lockfile mismatch in non-interactive environments
- Set CI=true in Dockerfiles and pnpm commands to avoid TTY issues
- Use --no-frozen-lockfile in runtime scripts to allow lockfile updates during development/startup
- Resolves #890
2026-05-27 13:10:35 +09:00
0e7ab2a22f test: assert userfront boot warmup policy 2026-05-27 12:32:28 +09:00
e240470d04 [WIP]모바일 로그인창 테스트 강화중 2026-05-27 12:32:28 +09:00
368f4bbad8 모바일 로그인창 테스트 강화 2026-05-27 12:32:28 +09:00
53830b20d8 Merge pull request '이슈 #868: 총괄기획실 우선순위 적용 및 슬러그 유추 로직 강화' (#889) from feature/issue-868-gpd-priority into dev
Reviewed-on: baron/baron-sso#889
2026-05-26 17:29:38 +09:00
57d92fa748 이슈 #868: 총괄기획실 우선순위 적용 및 슬러그 유추 로직 강화 2026-05-26 17:07:26 +09:00
e54802140a 모바일 로그인 창 랜더링 조건 변경 2026-05-26 13:46:43 +09:00
e481ae2821 모바일 fallback 변경. .env유출 가능성 차단 2026-05-26 11:30:00 +09:00
0eb6dabdc1 테넌트 import 규칙 개선 2026-05-22 18:00:58 +09:00
dc68b7da41 fix userfront verify link routing 2026-05-21 19:35:45 +09:00
9fc6459636 fix userfront e2e stability 2026-05-21 18:36:44 +09:00
7c809fb478 fix userfront e2e build optimization 2026-05-21 18:23:47 +09:00
dbb5ad93b8 fix userfront approval i18n resources 2026-05-21 18:16:35 +09:00
e54cc121c7 fix userfront mobile approval close flow 2026-05-21 18:14:31 +09:00
66687a4c73 문자 인증 잔여 익셉션/창 안꺼짐 fix 2026-05-21 17:54:36 +09:00
c4f8d939d2 test verify-only approval client errors 2026-05-21 14:58:26 +09:00
710f1a865c test verify-only approval close routing 2026-05-21 14:48:32 +09:00
eb46918397 fix userfront verify-only approval routing 2026-05-21 14:33:40 +09:00
d56c041b67 Merge feature/874-auth-link-session-conflict-policy into dev 2026-05-21 13:58:40 +09:00
f19b694c0b fix auth link session conflict policy 2026-05-21 13:50:18 +09:00
8dfe6fed82 adminfront 태넌트 화면 기능 누락 복구 2026-05-21 10:29:15 +09:00
5bb1c5871c fix staging frontend docker build context 2026-05-21 08:51:35 +09:00
2d6ca2f66b 코드 체크 개선 2026-05-21 08:46:08 +09:00
79f99757ee Fix frontend test dependencies after merge 2026-05-20 18:20:39 +09:00
49b78b3786 Merge remote-tracking branch 'origin/dev' into dev 2026-05-20 18:16:03 +09:00
2c3cab78b1 Update dev workflow and org chart settings 2026-05-20 18:15:54 +09:00
8b61c054e7 Merge pull request 'feature/admin-user-data-mgmt-fix' (#870) from feature/admin-user-data-mgmt-fix into dev
Reviewed-on: baron/baron-sso#870
2026-05-20 18:00:52 +09:00
c46c700c60 Merge branch 'dev' of https://gitea.hmac.kr/baron/baron-sso into dev 2026-05-20 17:59:37 +09:00
8c991ec48d Merge pull request 'feature/front-i18n' (#869) from feature/front-i18n into dev
Reviewed-on: baron/baron-sso#869
2026-05-20 17:19:36 +09:00
kyy
0af268021e code-check 오류 수정 2026-05-20 17:12:48 +09:00
kyy
b55ab7bc67 앱 생성 개발자 권한 신청 안내 추가 2026-05-20 13:41:16 +09:00
kyy
dcb442b68d 인증 가드 타이틀 보조 문구 위치 조정 2026-05-20 13:41:16 +09:00
kyy
dd1238a4e4 headless JWKS 워커 backoff 회귀 테스트 추가 2026-05-20 13:41:16 +09:00
kyy
16d43c5973 headless JWKS 워커 실패 backoff 및 timeout 단축 2026-05-20 13:41:16 +09:00
kyy
c21ea29111 인증 가드 박스 타이틀 아이콘 배지 제거 2026-05-20 13:41:15 +09:00
kyy
fc4a2f3536 공용 i18n namespace 정리 2026-05-20 13:41:15 +09:00
kyy
c2dbc8fc88 페이지 헤더 스타일 통일 2026-05-20 13:41:15 +09:00
kyy
528ceea754 공유 의존성 설치 락 수정 2026-05-20 13:41:15 +09:00
kyy
63622dcf28 박스 타이틀 description 추가 및 로고 추가 2026-05-20 13:41:15 +09:00
kyy
598f6ff9d1 공유 node_modules 설치 충돌 방지 2026-05-20 13:41:15 +09:00
kyy
611730f22a Kratos 허용 return URL 생성 로직 보강 2026-05-20 13:41:15 +09:00
kyy
c9664b5844 테넌트 페이지 타이틀/박스 타이틀 보조 문구 로케일 수정 2026-05-20 13:41:15 +09:00
kyy
a1f3604b24 adminfront 카드 타이틀 크기 통일 (text-lg) 및 한국어 적용 2026-05-20 13:41:15 +09:00
kyy
097caf395c 타이틀 크기 및 상단 패딩 밀림 제거 2026-05-20 13:41:15 +09:00
kyy
54fb7b4db6 타이틀 이모티콘 로고 제거 2026-05-20 13:41:15 +09:00
kyy
d1184613d8 서브 타이틀 텍스트 렌더링 오류 수정 2026-05-20 13:41:15 +09:00
kyy
222dc6f4a4 adminfront 상단 화면 i18n 정리 2026-05-20 13:41:15 +09:00
kyy
279bfae9ec adminfront code-check 설치 단계 pnpm으로 전환 2026-05-20 13:41:15 +09:00
kyy
7d99dba890 API 키 페이지 영문 적용 2026-05-20 13:41:15 +09:00
kyy
e7dab0f8fd adminfront /api-keys 새로고침 404 해결 2026-05-20 13:41:15 +09:00
kyy
c7d25f3611 API 키 페이지 locale 전환 시 /api-keys 404 방지 2026-05-20 13:41:15 +09:00
5496735e2f make dev/dev-debug 구분. 2026-05-20 13:34:19 +09:00
f4bfa7c129 fix(adminfront): fix double-click required for file input in bulk upload modal
Refactored the file input button to use a native HTML label with Radix UI's asChild prop. This ensures the file dialog opens reliably on the first click without relying on JS synthetic click events.
2026-05-20 13:30:29 +09:00
11d535f4e3 fix(adminfront): prevent bulk import modal from unmounting when dropdown closes
Lifted the modal state out of the DropdownMenuContent to ensure the dialog does not unmount immediately when the dropdown item is clicked.
2026-05-20 13:28:03 +09:00
53dacda5d5 feat(adminfront): add Data Management menu to User tab
This commit introduces a 'Data Management' dropdown menu to the User list page, consolidating user CSV import, template download, and export actions. It aligns the UI with the existing Tenant list page.
2026-05-20 13:25:21 +09:00
0155ee4ee7 front류 개발모드에서는 세션 갱신 끄기 2026-05-20 11:48:31 +09:00
0031784c07 userfront dev모드 구동 모드 run으로 변경 2026-05-20 11:37:02 +09:00
0f61425bbf Merge remote-tracking branch 'origin/dev' into dev 2026-05-20 11:19:30 +09:00
fd82dd9bdd 조직 연동 오류 해결 2026-05-20 11:17:31 +09:00
58f968b0fe Merge pull request 'feature/userfront-magic-link-ux-fix' (#865) from feature/userfront-magic-link-ux-fix into dev
Reviewed-on: baron/baron-sso#865
2026-05-20 11:03:23 +09:00
8f593cf6c0 Merge branch 'dev' into feature/userfront-magic-link-ux-fix 2026-05-20 10:52:43 +09:00
42b49674cc 사용자 상태 세분화 2026-05-20 10:17:15 +09:00
bb918932f4 feat(userfront): improve magic link approval UX on mobile
- Fixes issue #852 where 'verify_failed' error was shown on remote approval
- Added specialized success view for remote-originated approval requests
- Added 'Close Window' action for mobile browsers
- Improved error handling for already verified/used tokens
- Added necessary i18n strings in Korean and English
2026-05-19 17:59:10 +09:00
9112c4fb36 조직도 줌 레벨 상향 2026-05-18 18:06:13 +09:00
0b54992309 테넌트 설정 샘플 추가 2026-05-18 15:38:21 +09:00
e29d056b9e 네이버 웍스 연동기능 개선 2026-05-18 15:36:30 +09:00
c71ece84b8 Merge pull request 'feature/common-ui' (#838) from feature/common-ui into dev
Reviewed-on: baron/baron-sso#838
2026-05-18 10:03:29 +09:00
kyy
36fc945eaf 런트 CI 안정화를 위한 React 중복 해석 방지 및 설치 재시도 추가 2026-05-18 09:58:56 +09:00
kyy
f22a914586 devfront ci 포트 변경 2026-05-15 20:31:04 +09:00
kyy
b84c52366e devfront 코드 체크 오류 2026-05-15 20:20:57 +09:00
kyy
a4ffb49314 CI 실행 경로와 Playwright 서버 명령 정리 2026-05-15 20:14:33 +09:00
kyy
97c02fdba1 webServer.command를 npm 기준에서 pnpm 기준으로 변경 2026-05-15 20:08:52 +09:00
kyy
f028aeb716 devfront 코드 체크 오류 수정 2026-05-15 19:58:22 +09:00
kyy
e01b3475ec code check 오류 수정 2026-05-15 19:49:16 +09:00
kyy
cd16cb3a4a userfront 런타임 BACKEND_URL fallback 수정 2026-05-15 18:42:05 +09:00
kyy
eddab895e9 CSV 내보내기 버튼 공통 2026-05-15 18:42:05 +09:00
kyy
0f80ee4f4d 페이지 타이틀 하단 설명 문구 수정 2026-05-15 18:42:05 +09:00
kyy
9df69f22e8 감사 로그 테이블 헤더 및 검색창 문구 수정 2026-05-15 18:42:05 +09:00
kyy
974af01d34 타이틀 상단 보조문구 제거 2026-05-15 18:42:05 +09:00
kyy
18eede3a10 감사로그 공통 로케일 적용 2026-05-15 18:42:05 +09:00
kyy
055a804f7f 감사 로그 테이블 공통 컬럼 통일 2026-05-15 18:42:05 +09:00
kyy
94f33a0a64 감사로그 수행자 표시 2026-05-15 18:42:05 +09:00
kyy
0bf8089120 admin/dev 사이드바 프레임 공통화 2026-05-15 18:42:05 +09:00
kyy
0327409631 devfront: 개요 페이지 경로와 레이아웃 정리 2026-05-15 18:42:05 +09:00
kyy
c0894eeb8a 개요 페이지 공통 컴포넌트 및 문구 적용 2026-05-15 18:42:05 +09:00
kyy
c9bf16cf8e 개요 화면 공통 컴포넌트와 로케일 추가 2026-05-15 18:42:05 +09:00
kyy
cb602de049 감사로그 검색창 통일화 2026-05-15 18:42:05 +09:00
kyy
faffb6dc05 검색/필터 바 shell 공통화 2026-05-15 18:42:05 +09:00
kyy
b3c360c54f 검색/필터 바 공토 shell 컴포넌트 추가 2026-05-15 18:42:05 +09:00
kyy
12e37b24b0 사이드바 메뉴 순서 변경 2026-05-15 18:42:05 +09:00
kyy
153ea3bad5 페이지 헤더 레이아웃 공통화 2026-05-15 18:42:05 +09:00
kyy
3a0cd1cfed 페이지 헤더 공통 컴포넌트 통일 2026-05-15 18:42:05 +09:00
kyy
0a5ae51a68 페이지 헤더 공통 컴포넌트 추가 2026-05-15 18:42:05 +09:00
kyy
bdd42be57e 공통 테이블 헤더 배경 토큰 적용 2026-05-15 18:42:05 +09:00
kyy
b387673a8a 하위 테넌트 테이블 쉘을 common/ui/table 기준으로 정리 2026-05-15 18:42:05 +09:00
kyy
5ef8f933cc 공통 테이블 헤더 배경 토큰 적용 2026-05-15 18:42:05 +09:00
kyy
0d84dbcde1 테이블 헤더 배경 및 sticky surface 공통 토큰 추가 2026-05-15 18:42:05 +09:00
kyy
4d0d4f6a63 개발자 권한 신청 테이블 공통 스타일 적용 2026-05-15 18:42:05 +09:00
55c44b1a6c Merge remote-tracking branch 'origin/dev' into dev 2026-05-15 18:21:01 +09:00
d4090b7d8d fix: align local Ory cookie domain rendering 2026-05-15 18:20:49 +09:00
220e87494b Merge pull request 'feature/tenant-user-list-ui-improvement' (#834) from feature/tenant-user-list-ui-improvement into dev
Reviewed-on: baron/baron-sso#834
2026-05-15 17:46:15 +09:00
b7fbbf568d Merge branch 'dev' into feature/tenant-user-list-ui-improvement 2026-05-15 17:45:35 +09:00
41fe1b09c6 fix(ci): fix multiline cache key error by extracting only the first line of playwright version 2026-05-15 17:39:46 +09:00
b1e617ff37 fix(ci): ensure bash trap cleanup does not return a non-zero exit code which fails the overall github action job 2026-05-15 17:17:09 +09:00
cca8aea7a2 fix(ci): use --no-frozen-lockfile for pnpm install to bypass strict lockfile validation errors in github actions 2026-05-15 16:17:39 +09:00
14fb155cd9 fix(userfront): prevent public env asset request 2026-05-15 15:00:23 +09:00
d28a121d6c fix(ci): use pnpm exec playwright install for pnpm workspaces to fix command not found 2026-05-15 14:23:46 +09:00
4346f48bbe perf(userfront): optimize login web loading 2026-05-15 14:16:34 +09:00
16422f4e2e fix(ci): use npx playwright and fix adminfront scripts for reliable binary resolution in CI 2026-05-15 13:59:51 +09:00
eff21aaa82 chore(frontend): sync pnpm-lock.yaml to include @playwright/test in workspace apps for CI 2026-05-15 13:51:43 +09:00
1b483d4cbf fix(ci): add @playwright/test to workspace projects so pnpm exec can locate the binary locally 2026-05-15 13:46:02 +09:00
8f57c6b15f fix(ci): use pnpm exec playwright install to ensure playwright is found in pnpm workspaces 2026-05-15 13:37:14 +09:00
565d03da43 fix(ci): use pnpm instead of npm to run playwright tests 2026-05-15 13:36:55 +09:00
eb697e560a fix(ci): use pnpm instead of npm to extract playwright version for caching 2026-05-15 13:36:37 +09:00
8010d3644d chore(frontend): sync pnpm-lock.yaml after dependency updates to fix CI outdated lockfile errors 2026-05-15 13:28:27 +09:00
9f7b925e73 fix(ci): update github actions to properly support pnpm workspace 2026-05-15 13:20:16 +09:00
55d5e58783 fix(frontend): revert to safe npm install in docker runtime to resolve persistent pnpm ENOENT cross-device store errors 2026-05-15 11:59:34 +09:00
4f952df003 fix(frontend): resolve docker build pnpm workspace errors by using no-frozen-lockfile and ignoring pnpm-store 2026-05-15 11:56:49 +09:00
412695841b fix(frontend): use pnpm with no-frozen-lockfile to bypass docker mount issues 2026-05-15 11:37:39 +09:00
62d765a77b fix(frontend): fallback to standard npm install in docker runtime scripts to bypass pnpm cross-device hardlink limitations 2026-05-15 11:31:10 +09:00
4de7124a3c fix(frontend): force hoisted node-linker in docker runtime script to prevent cross-volume symlink/hardlink ENOENT errors 2026-05-15 11:30:15 +09:00
e71e090eec fix(frontend): use isolated pnpm store inside docker to prevent ENOENT cache corruption errors 2026-05-15 11:24:22 +09:00
1a0dddbd98 fix(frontend): force clean pnpm install in runtime script to prevent ENOENT errors with corrupted node_modules mounts 2026-05-15 11:21:18 +09:00
dcab9205d2 fix(frontend): run pnpm install with CI=true to prevent TTY prompt errors during runtime setup 2026-05-15 11:14:22 +09:00
8951de510e refactor(frontend): centralize configurations and deduplicate dependencies in common workspace
- Centralized biome.json, tailwind.config.ts, and vite.config.ts into common/config.
- Updated sub-apps to inherit from shared base configurations.
- Deduplicated dependencies across apps using common workspace.
- Fixed TypeScript resolution issues by restoring necessary build dependencies.
- Removed obsolete package-lock.json files.
- Applied minor import fixes via Biome.
- Fixed react-router-dom v7 type errors.
2026-05-15 10:28:07 +09:00
4ca562ce0e fix: Dockerfile에서 더 이상 존재하지 않는 루트 package.json 복사 시도 오류 수정 2026-05-14 17:07:42 +09:00
3f957d7a9f chore: 모노레포 구축에 따른 Dockerfile 빌드 컨텍스트 변경 및 pnpm 의존성 설치 지원 2026-05-14 17:01:05 +09:00
c2a9e1044c common/node_modules 2026-05-14 16:36:14 +09:00
254e34dbca chore: 모노레포 구축에 따른 Docker 컨테이너 및 런타임 스크립트 대응 2026-05-14 16:29:27 +09:00
c06a5bf181 feat: common 폴더를 루트로 하는 pnpm workspace 모노레포 구축 2026-05-14 15:56:16 +09:00
57456bd4cd Merge pull request '테넌트 및 사용자 목록 UI 개선: 계층 구조 테이블 도입, 벌크 액션 바 통합, 컬럼 순서 조정 및 데이터 관리 드롭다운 적용' (#818) from feature/tenant-user-list-ui-improvement into dev
Reviewed-on: baron/baron-sso#818
2026-05-14 12:57:24 +09:00
d1b550f6f7 test: 프론트엔드 변경된 UI(트리 및 데이터 관리)에 따른 전체 테스트 케이스 동기화 및 픽스 완료 2026-05-14 12:50:35 +09:00
0b92ad49da test: 프론트엔드 테스트 환경에서 벌크 액션 버튼명 변경에 따른 테스트 케이스 동기화 2026-05-14 11:55:17 +09:00
5cd3f04f69 fix: 프론트엔드 테스트 환경 런타임 오류(getSortIcon 누락) 및 타입스크립트 에러 수정 2026-05-14 11:35:27 +09:00
574238c744 Merge branch 'dev' into feature/tenant-user-list-ui-improvement 2026-05-14 11:23:57 +09:00
1b9687e9e8 fix: 백엔드 테스트 추가 검증사항 수정 및 다국어 템플릿 정리 2026-05-14 11:20:38 +09:00
b4a3cc4318 Merge pull request 'feature/df-dashboard' (#819) from feature/df-dashboard into dev
Reviewed-on: baron/baron-sso#819
2026-05-14 11:14:07 +09:00
kyy
258c91a740 adminfront profile 접근 에러 2026-05-14 11:07:28 +09:00
ece8df50f6 Merge branch 'dev' into feature/tenant-user-list-ui-improvement 2026-05-14 11:06:39 +09:00
024e1cc5bd fix: 기타 문법 오류 수정 및 i18n 언어팩(누락된 키) 업데이트 2026-05-14 11:01:49 +09:00
841e1f8ab2 fix: 백엔드 테스트 오류 수정 (외래 키 제약조건 및 데이터 무결성 검증 로직 반영) 2026-05-14 11:00:15 +09:00
kyy
79f5ace7ef 누락 키 및 린트 적용 2026-05-14 10:56:23 +09:00
kyy
da10b4be15 92e607aee8 기준 code check 오류 수정 2026-05-14 10:24:21 +09:00
kyy
e803a0b150 프로필 메뉴 문구 한국어 적용 2026-05-14 10:23:24 +09:00
kyy
c7ed9186c9 consents 탭 박스 정렬 수정 2026-05-14 10:23:24 +09:00
kyy
76a63264fe devfront consents 및 audit 테이블 공통화 2026-05-14 10:23:24 +09:00
kyy
481ec5fc15 감사 로그 테이블에 공통 table 스타일 적용 2026-05-14 10:23:24 +09:00
kyy
ee8cfb4ba8 common/ui/table 기준 테이블 스타일 공통화 2026-05-14 10:23:24 +09:00
kyy
c8ac953b14 adminfront 사용자/테넌트 테이블 쉘 공통화 2026-05-14 10:23:24 +09:00
kyy
40d64acf15 devfront 연동 앱 목록 테이블 간격 및 문구 정리 2026-05-14 10:23:24 +09:00
kyy
4a0e5641cb dev/admin 테이블 정렬 헤더 UI 공통화 2026-05-14 10:23:24 +09:00
kyy
8a8b5baaf6 테이블 기본 정렬 생성일 통일 2026-05-14 10:23:24 +09:00
kyy
187f0da29b 정렬 헤더 UI 공통화 및 devfront secret 표시 수정 2026-05-14 10:23:24 +09:00
kyy
498fdd802c Server side app 클라이언트 키 표시 2026-05-14 10:23:24 +09:00
kyy
b9a351ca59 비권한 사용자 안내문구 2026-05-14 10:23:24 +09:00
kyy
a26093836f 불필요한 파일 제거 2026-05-14 10:23:24 +09:00
d77199bdbc Fix code-check locale and headless test failures 2026-05-14 10:15:50 +09:00
d3e83332fb 테넌트 및 사용자 목록 UI 개선: 계층 구조 테이블 도입, 벌크 액션 바 통합, 컬럼 순서 조정 및 데이터 관리 드롭다운 적용 2026-05-14 10:01:51 +09:00
8bca127723 orgfront 코드 체크 추가, 백엔드 기준 강화 2026-05-14 09:49:37 +09:00
92e607aee8 정합성 검사 중복실행 방지 2026-05-14 09:23:54 +09:00
df543d6203 정합성 위반사항 확인 및 조치기능 추가 2026-05-14 09:04:33 +09:00
9ca73e8774 권한부여 및 정합성 검사 추가 2026-05-14 08:45:48 +09:00
f6f8e88342 refactoring 2026-05-14 08:11:02 +09:00
e36a973053 사용자 테넌트 소속 데이터 정리 2026-05-13 18:27:55 +09:00
8a6e41d74c 테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거 2026-05-13 18:10:37 +09:00
5e7b7b878c 테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거 2026-05-13 18:05:51 +09:00
f4ed1057a2 Merge pull request 'fix/adminfront-bulk-upload-type-error' (#793) from fix/adminfront-bulk-upload-type-error into dev
Reviewed-on: baron/baron-sso#793
2026-05-13 15:15:36 +09:00
f047c24a38 fix(adminfront): fix ReferenceErrors and null checks in UI components
- Import and initialize 'navigate' in TenantUsersPage and TenantAdminsAndOwnersTab.
- Use optional chaining for 'user.id' in UserDetailPage to prevent runtime errors during initial load.
2026-05-13 15:08:36 +09:00
9681945f5a test(adminfront): update tests to match recent UI changes
- Remove checks for deleted 'Action' column buttons in tenant list.
- Update user list test to expect 'ROLE' column.
- Update user creation test to expect role field and payload.
2026-05-13 15:00:17 +09:00
3a3bfd3c00 Merge branch 'dev' into fix/adminfront-bulk-upload-type-error 2026-05-13 14:52:32 +09:00
a31eceaf16 feat(adminfront): implement user role management and cleanup tenant list UI
- Add user role management (view, edit, bulk) in UserListPage, UserDetailPage, and UserCreatePage.
- Restrict role modification to super_admin only.
- Remove redundant action columns from tenant-related lists (TenantListPage, TenantSubTenantsPage, TenantUsersPage, TenantAdminsAndOwnersTab).
- Improve navigation by making table rows clickable where actions were removed.
2026-05-13 14:50:11 +09:00
a4d707d4d8 기능 재배포 2026-05-13 14:27:00 +09:00
629716f226 액션 러너 캐시 정리 2026-05-13 14:04:59 +09:00
6ed9b2b734 Merge remote-tracking branch 'origin/dev' into dev 2026-05-13 13:46:10 +09:00
8c2b2f71ef 조직도 M2M조회 추가, 자동로그인 보완 2026-05-13 13:44:30 +09:00
ee24842225 Merge pull request 'fix(adminfront): add missing fields to TenantCSVRow in bulk upload modal' (#782) from fix/adminfront-bulk-upload-type-error into dev
Reviewed-on: baron/baron-sso#782
2026-05-13 11:32:45 +09:00
5f48a1c172 fix(adminfront): add missing fields to TenantCSVRow in bulk upload modal 2026-05-13 11:29:29 +09:00
72288f1d39 기능 동기화 2026-05-13 08:50:01 +09:00
def2f924c9 Merge remote-tracking branch 'origin/dev' into dev 2026-05-13 08:42:45 +09:00
ae0a516ee4 Merge pull request 'feature/common-core' (#774) from feature/common-core into dev
Reviewed-on: baron/baron-sso#774
2026-05-12 18:08:59 +09:00
0c706a8936 headless link 테스트 환경 격리 2026-05-12 18:05:24 +09:00
kyy
298b919d1a 누락 키 수정 및 린트 적용 2026-05-12 18:04:41 +09:00
937f2f9820 Merge remote-tracking branch 'origin/dev' into dev 2026-05-12 18:03:08 +09:00
e8a4d7544f 테넌트 CSV 조직 설정 동기화 보완 2026-05-12 18:02:55 +09:00
kyy
878867f6cc 대시보드 기능 추가 2026-05-12 17:17:51 +09:00
kyy
250bc297fa 볼륨 마운트 추가 2026-05-12 17:17:51 +09:00
68eeac90f7 Merge pull request 'feature/common-core' (#768) from feature/common-core into dev
Reviewed-on: baron/baron-sso#768
2026-05-12 15:14:44 +09:00
kyy
ee41083b73 adminfront ci 스크립트 common 디렉터리 추가 2026-05-12 15:10:09 +09:00
kyy
45ce440569 code-check import 오류 수정 2026-05-12 15:01:03 +09:00
kyy
084e8594ff userfront code check 오류 수정 2026-05-12 14:50:19 +09:00
kyy
f810efd420 5e649c279f 기준 code-check 오류 수정 2026-05-12 13:51:13 +09:00
kyy
7259c62251 dev 병합 code-check 오류 수정 2026-05-12 13:51:13 +09:00
kyy
6709bf3029 누락 및 불필요한 키 수정 2026-05-12 13:51:11 +09:00
kyy
3626584046 RP 대시보드 기능 추가 2026-05-12 13:49:51 +09:00
kyy
a2a6938246 common 정렬 헬퍼 공통화 및 devfront 목록 정렬 추가 2026-05-12 13:49:51 +09:00
kyy
a0713df85a tailwind content 경로에 common 레이어 추가 2026-05-12 13:49:51 +09:00
kyy
48853aae99 theme 미적용 오류 수정 2026-05-12 13:49:51 +09:00
kyy
5149fdc246 button badge input card 공용화 2026-05-12 13:49:51 +09:00
kyy
85e1a172dd common shell frame/state helper 공용화 2026-05-12 13:49:51 +09:00
kyy
7d7f17ab69 front 공통 theme token 및 base style 정리 2026-05-12 13:49:51 +09:00
kyy
1c083dd586 common auth/session bootstrap과 renew policy 공용화 2026-05-12 13:49:51 +09:00
kyy
1419c8db27 gitkeep 파일 제거 2026-05-12 13:49:51 +09:00
kyy
0655206f05 common utils 경로로 cn helper 공용화 2026-05-12 13:49:51 +09:00
kyy
d371bd32c8 common query client 기본 옵션 공용화 2026-05-12 13:49:51 +09:00
kyy
c0c5a23dc1 common/locales 기반 i18n 스캐너와 문서 정리 2026-05-12 13:49:51 +09:00
kyy
27f48baadc 컨테이너/실행 환경 보정 2026-05-12 13:49:51 +09:00
kyy
b8a25135fc 각 프런트의 i18n 연결 및 locale 정리 2026-05-12 13:49:51 +09:00
kyy
efbf970a18 공통 i18n 레이어 추가 2026-05-12 13:49:51 +09:00
d4c48da426 코드체크 업데이트 2026-05-12 13:41:43 +09:00
5e649c279f 동기화 기초구조 마련 2026-05-12 12:25:31 +09:00
3063450ee0 조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인 2026-05-11 20:14:56 +09:00
d3853fac2a Assert frontend biome cache ignores 2026-05-11 13:35:15 +09:00
a98721e4f7 Format orgfront vite config 2026-05-11 13:34:55 +09:00
9ea7db8b3c Format devfront vite config 2026-05-11 13:34:41 +09:00
7a798d8466 Fix frontend biome generated cache ignores 2026-05-11 13:34:29 +09:00
2bf0248e95 Fix frontend biome generated cache ignores 2026-05-11 13:34:22 +09:00
132cdf3eda Fix frontend biome generated cache ignores 2026-05-11 13:34:15 +09:00
360b5a6f2a vitest 타입 에러 해결 2026-05-11 13:28:53 +09:00
46b661cff0 devfront 구동 검증 2026-05-11 13:19:35 +09:00
843b4100ad adminfront 조직 통계오류 보정. Kratos Projection용 통계테이블 구조 추가 2026-05-11 13:01:55 +09:00
9a64a16cb9 callback 검증 보강. seed-tenant 추가보강 2026-05-11 11:03:11 +09:00
f46a7cc088 Merge pull request 'feature/afdf-session' (#725) from feature/afdf-session into dev
Reviewed-on: baron/baron-sso#725
2026-05-08 16:38:01 +09:00
kyy
636da587e3 5ee9a46663 반영 code-check 오류 수정 2026-05-08 16:11:50 +09:00
kyy
8307f65f6a ef286330a2 반영 code-check 오류 수정 2026-05-08 15:34:00 +09:00
kyy
e78b2bea50 dev 병합 code-check 오류 수정 2026-05-08 15:33:56 +09:00
kyy
59cb482219 devfront 앱 목록 로고 표시 2026-05-08 15:31:21 +09:00
kyy
888863094d 자동로그인 가이드 문서 업데이트 2026-05-08 15:31:21 +09:00
kyy
94db1dab08 세션 만료 임계값 5분->10분 조정 2026-05-08 15:31:21 +09:00
5ee9a46663 Merge pull request 'adminfront 테스트 실패 해결 및 UI/Lint 수정' (#724) from feature/multi-tenant-and-ui-improvements into dev
Reviewed-on: baron/baron-sso#724
2026-05-08 15:29:48 +09:00
7a9dff372b adminfront 타입 오류 수정 (UserCreatePage metadata 타입 명시) 2026-05-08 15:26:19 +09:00
819ac00040 adminfront 사용자 관리 세부 로직 및 테스트 데이터 보정
- UserDetailPage/UserCreatePage의 데이터 제출(onSubmit) 시 한맥가족의 대표 조직 관련 필드 매핑 로직 보정
- POST(Create)와 PUT(Update) 간의 백엔드 API 기대 페이로드 차이에 맞춘 조건부 필드 설정
- tests/users.spec.ts의 한맥가족 대표 조직 변경 테스트용 모크 데이터 보정
2026-05-08 15:18:14 +09:00
8bf8520d62 adminfront 사용자 관리 추가 수정 (대표 조직 독점 선택 및 보호)
- 한맥가족 대표 조직(isOwner) 선택 시 독점(Exclusive) 선택 로직 적용
- 주 소속(isPrimary) 테넌트의 경우 대표 조직 해제 불가하도록 Switch 비활성화 처리
- UserDetailPage 및 UserCreatePage 공통 로직 보정
2026-05-08 15:09:58 +09:00
262c988226 adminfront 테스트 실패 해결 및 UI/Lint 수정
- 테넌트 목록 테이블 헤더 스타일 보정(nowrap) 및 삭제 버튼 추가
- 초기 설정(Seed) 테넌트 삭제 보호 로직 적용
- 사용자 상태 변경 및 대표 조직 지정 UI를 Switch로 변경하여 테스트와 동기화
- Playwright 테스트 코드의 선택자 및 상호작용 로직 업데이트
- Biome을 통한 린트 오류 및 타입 안정성(AxiosError) 확보
- 프론트엔드 모노레포 통합 마스터 플랜 문서 추가
2026-05-08 14:54:48 +09:00
ef286330a2 Merge pull request 'feature/multi-tenant-and-ui-improvements' (#723) from feature/multi-tenant-and-ui-improvements into dev
Reviewed-on: baron/baron-sso#723
2026-05-08 14:30:18 +09:00
ab66f13afd Merge branch 'dev' into feature/multi-tenant-and-ui-improvements 2026-05-08 14:28:38 +09:00
074c3e30d1 멀티 테넌트 멤버 집계 해결 2026-05-07 18:05:24 +09:00
f6cf261fd5 fix: resolve tenant member removal and move aggregation bugs
- adminfront: Update removeMutation to correctly pass 'isRemoveTenant: true' and the specific tenant slug instead of empty string
- backend: Fix 'Move' operation (Normal Update) in UpdateUser to correctly remove the old primary company code from the 'companyCodes' array and sync the deletion to Keto, ensuring accurate member count aggregation
2026-05-07 15:43:08 +09:00
9e45313b5d style: enhance visibility of Tenant view mode toggle buttons
- Add primary background and text color to active TabsTrigger state
- Add border and darker background to TabsList container
- Increase font weight for better readability
2026-05-07 15:30:11 +09:00
80bb6abb72 feat: add hierarchical view toggle to Tenant list page
- Implement view switcher (List vs Hierarchy) using Tabs
- Add TenantHierarchyView component with sidebar tree navigation
- Display 'Same Level Organizations' and 'Sub-Organizations' for the selected node
- Reuse buildTenantFullTree logic for consistent hierarchical data
2026-05-07 15:24:49 +09:00
e378fdd3e8 feat: simplify Tenant list UI by removing actions column and making tenant name clickable 2026-05-07 14:18:15 +09:00
37a878fb93 Restart Ory after staging config render 2026-05-07 14:06:53 +09:00
43b4bd5a83 Render Kratos return URLs for staging 2026-05-07 14:01:45 +09:00
57a00c0236 Fix SMS login code flow for phone relay 2026-05-07 13:53:47 +09:00
404e5179e8 Merge pull request 'feature/multi-tenant-and-ui-improvements' (#718) from feature/multi-tenant-and-ui-improvements into dev
Reviewed-on: baron/baron-sso#718
2026-05-07 13:53:45 +09:00
b540482bf5 Merge remote-tracking branch 'origin/dev' into fix/issue-637 2026-05-07 13:53:14 +09:00
c398237c35 feat: enhance multi-tenant UI and fix member aggregation
- adminfront: Update TenantListPage and UserListPage sorting logic to support all columns dynamically
- adminfront: Add inline 'Move', 'Remove', and 'Delete' actions directly inside the Tenant Org Chart member table
- adminfront: Remove redundant 'ACTIONS' column and profile icon from UserListPage, making the name clickable
- adminfront: Remove 'New Member' creation link from Tenant Org Chart 'Add Member' dropdown
- backend: Fix CountByCompanyCodes query to accurately aggregate user counts using both primary company_code and company_codes array
2026-05-07 13:50:13 +09:00
281b690c38 Merge pull request 'feature/back-channel-logout' (#715) from feature/back-channel-logout into dev
Reviewed-on: baron/baron-sso#715
2026-05-07 11:28:40 +09:00
kyy
629a1fe9a4 orgfront 앱 코드 2026-05-07 11:05:07 +09:00
kyy
5615e9a4fb orgfront 테스트/픽스처 2026-05-07 11:05:07 +09:00
kyy
49778af905 orgfront playwrite 설정 2026-05-07 11:05:07 +09:00
kyy
7acf3cf5be orgfront 코드체크 추가 2026-05-07 11:05:07 +09:00
kyy
d1859d593d dev 반영 code check 오류 수정 2026-05-07 11:05:07 +09:00
kyy
0d259db7ce devfront 영어 적용 누락 수정 2026-05-07 11:05:07 +09:00
kyy
64cdef81a6 i18n 누락 정리 및 하드코딩 텍스트 치환 2026-05-07 11:05:07 +09:00
kyy
9a87af93f1 회원가입 약관 전문 locale fallback 및 영문 본문 보강 2026-05-07 11:05:07 +09:00
kyy
cbaa208f79 server side app 백채널 로그아웃 구현 가이드 2026-05-07 11:05:07 +09:00
kyy
53cad429a1 PCKE 구현 가이드 문서 추가 2026-05-07 11:05:07 +09:00
kyy
3e8adbfbfd 백채널 로그아웃 URI 허용 범위 확장 2026-05-07 11:05:07 +09:00
2cba9c9c1f go 버전업 && ory 설정파일들 자동 생성 스크립트 추가 2026-05-07 11:01:25 +09:00
45a14163bf ory스택 버전업 및 하드코딩URL 제거 2026-05-07 10:27:31 +09:00
5096930d68 fix: display recursive member count in TenantListPage
- Replace raw memberCount with recursiveMemberCount calculated via buildTenantFullTree to correctly aggregate member counts for parent tenants (e.g., 'hanmac-family') that only contain members in their sub-tenants.
2026-05-06 17:47:33 +09:00
3064126709 feat: enhance organization chart member management
- Replace window.prompt with a searchable user selection modal for adding members to groups.
- Introduce a 'Move' feature to seamlessly transfer users between groups within the same tenant.
- Improve UX with dedicated Dialogs for member operations (Add, Move, Remove) in the TenantGroupsPage.
2026-05-06 16:35:20 +09:00
13dee9ae9b adminfront 개요 통계 추가 2026-05-06 16:14:52 +09:00
97fb89b831 style: perfectly align title, description, and search controls in a single row
- Use flex-row and items-baseline to seamlessly integrate the title and description.
- Remove input labels to streamline the search bar vertically.
- Apply mr-auto to the title group to push the search controls exactly to the right while keeping everything on the same horizontal baseline.
2026-05-06 16:11:10 +09:00
92c547db3c style: align search bar with title horizontally
- Remove justify-between to place the search bar and filter controls immediately next to the registry title and count description.
2026-05-06 15:59:45 +09:00
71de98e0d9 style: move search bar to header in list pages
- Align search bar and filters with the title and subtitle in UserListPage and TenantListPage by moving them to the CardHeader.
2026-05-06 14:49:03 +09:00
6d05bb212b fix: enqueue KetoOutboxActionDelete for isRemoveTenant
- Ensure Keto permissions are correctly revoked when a user is removed from a tenant.
2026-05-06 14:44:03 +09:00
5f9a61de98 feat: implement multi-tenant member management and UI improvements
- Add multi-tenant support (isAddTenant, isRemoveTenant) to backend UpdateUser API.
- Update UserRepository to support searching in company_codes array.
- Implement table sorting and align search bar layout in adminfront.
- Add 'Assign Existing Member' and 'Exclude from Organization' features to TenantUsersPage.
- Auto-populate tenantSlug in UserCreatePage via query parameters.
- Add necessary localization keys for new UI elements.

Resolves #644, #639, #642, #641
2026-05-06 14:20:35 +09:00
6cdd0fd81e worksmobile 관리화면 보완. 2026-05-06 10:37:34 +09:00
3169dd958a Merge branch 'feature/worksmobile' into dev 2026-05-06 09:31:04 +09:00
2495fcb13d worksmobile 연동 & ory stack 26.2.0으로 업그레이드 2026-05-06 09:30:00 +09:00
c398fc13a4 Merge pull request 'feature/df-healess-alter' (#695) from feature/df-healess-alter into dev
Reviewed-on: baron/baron-sso#695
2026-05-04 16:00:35 +09:00
kyy
a2b328f3b0 code-check 오류 수정 2026-05-04 15:52:08 +09:00
kyy
9f78698f54 headless login SSA 백엔드 작업 2026-05-04 15:52:08 +09:00
kyy
b888b33cde headless login SSA UX 재구성 2026-05-04 15:52:08 +09:00
0978adcee5 Merge pull request 'feature/df-custom-claim' (#692) from feature/df-custom-claim into dev
Reviewed-on: baron/baron-sso#692
2026-05-04 13:28:09 +09:00
kyy
128ac94575 code check 오류 수정 2026-05-04 13:17:40 +09:00
kyy
428ea888a7 dev 병합 TypeScript 컴파일 오류 수정 2026-05-04 11:45:28 +09:00
kyy
f9f0ed0f14 OIDC back-channel logout 백엔드 전송 기능 추가 2026-05-04 11:31:50 +09:00
kyy
a72df2e839 back-channel logout 서비스 및 핸들러 테스트 추가 2026-05-04 11:29:53 +09:00
kyy
0664640c6f Devfront back-channel logout 설정 UI 및 i18n 추가 2026-05-04 11:29:51 +09:00
kyy
068d0adbd4 code-check 오류 수정 2026-05-04 11:28:53 +09:00
kyy
67b3420d00 관계 옵션별 정보 툴팁 추가 2026-05-04 11:20:09 +09:00
kyy
894565d87e 감사로그 조회 전체 새로고침 현상 수정 2026-05-04 11:20:09 +09:00
kyy
6d5a861d17 관계 탭 일시적 노출 현상 수정 2026-05-04 11:20:09 +09:00
kyy
52936b2b88 테넌트 접근 제한/커스텀 클레임 관계 설정 2026-05-04 11:20:07 +09:00
kyy
613d198690 개발자 권한 신청/커스텀 클레임 i18n 적용 2026-05-04 11:19:39 +09:00
kyy
0fb761f284 PKCE 앱 클라이언트 시크릿 버튼 제거 및 안내 문구 추가 2026-05-04 11:19:39 +09:00
kyy
572ac39e60 RP 생성 admin 관계 중복 부여 수정 2026-05-04 11:19:39 +09:00
kyy
68e7fb9ba2 개발자 권한 앱 생성 오류 수정 2026-05-04 11:19:39 +09:00
kyy
0844befb35 devfront ID Token Claims 백엔드 반영 2026-05-04 11:19:37 +09:00
kyy
e484d8c100 커스텀 클레임 test 코드 추가 2026-05-04 11:18:11 +09:00
kyy
20afede89c 커스텀 클레임 ui/ux 추가 2026-05-04 11:18:11 +09:00
3dcdd97882 org chart 자동로그인 보완. seed-tenant 삭제불가 조치 2026-04-30 17:02:24 +09:00
6eb4c293ff Keep orgfront auto login fallback explicit 2026-04-30 16:41:32 +09:00
d16f6cdcb4 Align RP auto login launch behavior 2026-04-30 16:36:40 +09:00
28a440734c Scope orgfront playwright tests 2026-04-30 16:26:06 +09:00
ef679d41ea Stabilize adminfront tenant tests 2026-04-30 16:17:14 +09:00
c6190bbab6 Format orgfront code check targets 2026-04-30 16:07:18 +09:00
7d893431d1 Format devfront auto login test 2026-04-30 16:02:39 +09:00
790be37930 Format adminfront code check targets 2026-04-30 15:59:57 +09:00
6c45eca3d3 Fix locale resources for code check 2026-04-30 15:56:49 +09:00
f7e4d43b16 Implement tenant import and RP auto login policies 2026-04-30 15:45:34 +09:00
24807eab0f chore: ignore orgfront vite cache 2026-04-30 09:34:57 +09:00
4b5defcf12 merge: orgfront integration into dev 2026-04-30 09:34:06 +09:00
9ce7a67f58 feat: integrate orgfront and expose internal ids 2026-04-30 09:33:39 +09:00
02375af08d orgfront 병합 시작 2026-04-30 08:16:45 +09:00
01e7b15c46 org chart 연동기능 추가 2026-04-29 21:00:51 +09:00
438f844f2b Merge pull request 'feature/df-tenant-claim' (#646) from feature/df-tenant-claim into dev
Reviewed-on: baron/baron-sso#646
2026-04-28 15:27:56 +09:00
kyy
5e0b041d0a 러너 패키지 설치 오류 수정 2026-04-28 15:22:30 +09:00
kyy
f4d894fe7d adminfront ci test 스크립트 수정 2026-04-28 15:15:47 +09:00
kyy
7607d8d9b9 adminfront codecheck 오류 수정2 2026-04-28 14:54:33 +09:00
kyy
0c5a302105 adminfront codecheck 오류 수정 2026-04-28 14:19:12 +09:00
kyy
eae3e0bd2a dev 병합 code-check 오류 수정 2026-04-28 13:24:11 +09:00
kyy
6be0914b65 테넌트 접근 제한 테스트 추가 2026-04-28 13:24:11 +09:00
kyy
d0340fc062 테넌트 접근 제한 안내화면 개선 2026-04-28 13:24:11 +09:00
kyy
955128a25a 테넌트 접근 제한 로직 보강 2026-04-28 13:24:11 +09:00
kyy
367368805a 테넌트 접속 제한 백엔드 로직 수정 2026-04-28 13:24:11 +09:00
kyy
3f85f6cfe3 RP 테넌트 접근 정책 변경 시 기존 consent 자동 폐기 2026-04-28 13:24:11 +09:00
kyy
b9232687b5 스코프 순서 및 테넌트 검색 수정 2026-04-28 13:24:11 +09:00
kyy
373751996a 테넌트 입력 자동완성형 변경 2026-04-28 13:24:11 +09:00
kyy
d86c4111ad RP 테넌트 제한 backend 구현 2026-04-28 13:24:11 +09:00
kyy
f97b244a59 RP 정책 설정 UI 수정 2026-04-28 13:24:11 +09:00
kyy
5acf248285 접근 제한 UX 구현 2026-04-28 13:24:11 +09:00
0c80063311 Merge pull request 'fix/issue-637' (#645) from fix/issue-637 into dev
Reviewed-on: baron/baron-sso#645
2026-04-28 13:14:57 +09:00
e3f9bbf925 style: format dart files to pass formatting check 2026-04-28 13:03:29 +09:00
ff7a786c21 fix: verify local token in _silentSessionRecovery to prevent 401 loop on expired JWT 2026-04-28 11:51:41 +09:00
bbf29bf400 fix: clear stale auth flags and improve user name fallback logic (#637)
- Clear AuthTokenStore in _silentSessionRecovery when session is invalid (Case 2)

- Use .trim().isNotEmpty for userName fallback to handle empty strings (Case 1)
2026-04-28 11:33:40 +09:00
08aa745e30 make drop 초기화 추가. 한맥그룹 기본값 추가 2026-04-27 17:51:46 +09:00
3fe32b1dfe Merge pull request 'allowed_origins 롤백' (#631) from code/issue-519 into dev
Reviewed-on: baron/baron-sso#631
2026-04-27 14:19:18 +09:00
2f350517b0 allowed_origins 롤백 2026-04-27 14:16:04 +09:00
8bddce43c1 Merge pull request 'code/issue-519' (#630) from code/issue-519 into dev
Reviewed-on: baron/baron-sso#630
2026-04-27 13:47:48 +09:00
9378a5a75d chore: Flutter 코드 포맷팅 적용 및 미사용 코드(Dead Code) 정리 2026-04-27 13:19:14 +09:00
3de28410ae fix: 회원가입 화면(Userfront) 모바일 뷰에서 인증 입력창 사라지는 반응형 레이아웃 버그 수정 2026-04-27 11:56:49 +09:00
093d2f2af0 refactor: 미사용 Descope 연동 코드 및 환경 변수 제거 (resolves #519) 2026-04-27 11:31:14 +09:00
881 changed files with 181633 additions and 17192 deletions

View File

@@ -16,3 +16,4 @@
**/*.log
**/*.swp
**/.DS_Store
**/.pnpm-store

View File

@@ -7,7 +7,6 @@ APP_ENV=stage # 애플리케이션 실행 환경 (dev, stage, production)
TZ=Asia/Seoul
# IDP_PROVIDER는 우선순위 순으로 콤마 구분 (예: Kratos/Hydra 우선, Descope 백업)
IDP_PROVIDER=ory
# --- Infrastructure Ports ---
@@ -33,6 +32,39 @@ BACKEND_LOG_LEVEL=
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
# --- NAVER WORKS API ---
WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com
WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token
# --- NAVER WORKS Drive backup upload ---
# Drive API 업로드에는 `file` scope가 필요합니다.
# 운영에서는 Drive 권한이 위임된 사용자/OAuth access token을 우선 사용하세요.
# 서비스 계정 JWT 방식은 WORKS 앱 정책에서 Drive API scope 위임이 허용된 경우에만 사용할 수 있습니다.
WORKS_DRIVE_TARGET=sharedrive
WORKS_DRIVE_SHARED_DRIVE_ID=
WORKS_DRIVE_PARENT_FILE_ID=
WORKS_DRIVE_USER_ID=me
WORKS_DRIVE_GROUP_ID=
WORKS_DRIVE_SHARED_FOLDER_ID=
WORKS_DRIVE_ACCESS_TOKEN=
WORKS_DRIVE_ACCESS_TOKEN_FILE=
WORKS_DRIVE_ACCESS_TOKEN_CMD=
WORKS_DRIVE_OAUTH_SCOPE=file
WORKS_DRIVE_OAUTH_CLIENT_ID=
WORKS_DRIVE_OAUTH_CLIENT_SECRET=
WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT=
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE=./config/worksmobile-driveapp-private-key.pem
WORKS_DRIVE_OAUTH_REFRESH_TOKEN=
WORKS_DRIVE_OAUTH_REDIRECT_URI=
WORKS_DRIVE_SPLIT_SIZE=9000M
WORKS_DRIVE_MAX_SINGLE_FILE_BYTES=0
WORKS_DRIVE_FORCE_SPLIT=false
WORKS_DRIVE_OVERWRITE=false
WORKS_DRIVE_DRY_RUN=false
WORKS_DRIVE_UPLOAD_REPORTS=true
WORKS_DRIVE_REPORT_FOLDER_NAME=reports
# Audit System Configuration
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
@@ -77,20 +109,20 @@ HYDRA_DB=ory_hydra
KETO_DB=ory_keto
# Ory Kratos Configuration
KRATOS_VERSION=v25.4.0-distroless
KRATOS_VERSION=v26.2.0-distroless
# KRATOS_PUBLIC_PORT=4433 # Internal only
# KRATOS_ADMINFRONT_PORT=4434 # Internal only
KRATOS_UI_NODE_VERSION=v25.4.0
KRATOS_UI_NODE_VERSION=v26.2.0
# KRATOS_UI_PORT=4455 # Internal only
# Ory Hydra Configuration
HYDRA_VERSION=v25.4.0-distroless
HYDRA_VERSION=v26.2.0-distroless
# HYDRA_PUBLIC_PORT=4441 # Internal only
# HYDRA_ADMINFRONT_PORT=4445 # Internal only
# Ory Keto Configuration
KETO_VERSION=v25.4.0-distroless
KETO_VERSION=v26.2.0-distroless
# KETO_READ_PORT=4466 # Internal only
# KETO_WRITE_PORT=4467 # Internal only
KETO_READ_URL=http://keto:4466
@@ -110,16 +142,21 @@ KRATOS_UI_URL=http://localhost:5000
HYDRA_ADMIN_URL=http://hydra:4445
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
# 선택: Hydra 화면 핸드오프 URL을 USERFRONT_URL 기준 기본값과 다르게 둘 때만 설정합니다.
# HYDRA_LOGIN_URL=https://sso.hmac.kr/login
# HYDRA_CONSENT_URL=https://sso.hmac.kr/consent
# HYDRA_ERROR_URL=https://sso.hmac.kr/error
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
KRATOS_ALLOWED_RETURN_URLS_JSON=["http://localhost:5000","http://localhost:5000/","https://sso.hmac.kr","https://sso.hmac.kr/","https://sso.hmac.kr/ko","https://sso.hmac.kr/ko/","https://sso.hmac.kr/en","https://sso.hmac.kr/en/","https://sso.hmac.kr/auth/callback","https://sso.hmac.kr/ko/auth/callback","https://sso.hmac.kr/en/auth/callback","http://localhost:5173/auth/callback","http://localhost:5174/auth/callback","http://localhost:5175/auth/callback","https://sso.hmac.kr/orgfront/auth/callback"]
# Oathkeeper JWKS (내부 통신용)
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
# Oathkeeper 실행 사용자/프로브 설정
OATHKEEPER_VERSION=v25.4.0
OATHKEEPER_VERSION=v26.2.0
OATHKEEPER_UID=1001
OATHKEEPER_GID=1001
OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready
@@ -141,5 +178,9 @@ VITE_OIDC_CLIENT_ID=devfront
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
DEVFRONT_URL=http://localhost:5174
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback
VITE_ORGCHART_URL=
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback
VITE_ORGCHART_URL=
# promtail에서 로그를 전송받을 Loki 서버 엔드포인트 URL
LOKI_URL=http://loki:3100/loki/api/v1/push

View File

@@ -18,6 +18,30 @@ jobs:
- name: Install dependencies
run: sudo apt-get update && sudo apt-get install -y jq curl
- name: Validate RC build configuration
env:
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }}
DEVFRONT_URL: ${{ vars.DEVFRONT_URL }}
ORGFRONT_URL: ${{ vars.ORGFRONT_URL }}
VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }}
run: |
set -euo pipefail
required_action_env="
HARBOR_ENDPOINT HARBOR_HOSTNAME HARBOR_ROBOT_ACCOUNT HARBOR_ROBOT_KEY
ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY
"
for key in ${required_action_env}; do
if [ -z "${!key:-}" ]; then
echo "::error::Missing required RC build value: ${key}. Check Gitea repo variables/secrets."
exit 1
fi
done
- name: Login to Docker Registry
uses: docker/login-action@v3
with:
@@ -89,28 +113,52 @@ jobs:
- name: Build and push adminfront RC image
uses: docker/build-push-action@v5
with:
context: ./adminfront
context: .
file: ./adminfront/Dockerfile
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
build-args: |
VITE_ADMIN_PUBLIC_URL=${{ vars.ADMINFRONT_URL }}
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
VITE_OIDC_CLIENT_ID=adminfront
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
provenance: false
sbom: false
- name: Build and push devfront RC image
uses: docker/build-push-action@v5
with:
context: ./devfront
context: .
file: ./devfront/Dockerfile
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
build-args: |
VITE_DEVFRONT_PUBLIC_URL=${{ vars.DEVFRONT_URL }}
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
VITE_OIDC_CLIENT_ID=devfront
provenance: false
sbom: false
- name: Build and push orgfront RC image
uses: docker/build-push-action@v5
with:
context: .
file: ./orgfront/Dockerfile
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
build-args: |
VITE_ORGFRONT_PUBLIC_URL=${{ vars.ORGFRONT_URL }}
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
VITE_OIDC_CLIENT_ID=orgfront
provenance: false
sbom: false
- name: Build and push userfront RC image
uses: docker/build-push-action@v5
with:
context: ./userfront
context: .
file: ./userfront/Dockerfile
target: production
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
provenance: false

File diff suppressed because it is too large Load Diff

View File

@@ -42,19 +42,13 @@ jobs:
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}"
for image in backend userfront adminfront devfront orgfront; do
echo "Re-tagging ${image} 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/${image}:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${RE_TAG}"
done
echo "final_image_tag=${RE_TAG}" >> "$GITHUB_OUTPUT"
@@ -68,6 +62,9 @@ jobs:
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
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
@@ -89,7 +86,7 @@ jobs:
ssh-keyscan -H "${PROD_HOST}" >> ~/.ssh/known_hosts
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}'"
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${DEPLOY_PATH}/adminfront'"
# Create the main .env file for Baron SSO on the remote server
# Note: All values are pulled from Gitea secrets and variables
@@ -101,8 +98,12 @@ jobs:
"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 }}" \
"PROD_BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
"BACKEND_PORT=3000" \
"USERFRONT_PORT=${{ vars.PROD_FRONTEND_PORT }}" \
"ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}" \
"DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}" \
"ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}" \
"DB_USER=${{ vars.PROD_DB_USER }}" \
"DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}" \
"DB_NAME=${{ vars.PROD_DB_NAME }}" \
@@ -117,11 +118,35 @@ jobs:
"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 }}" \
"USERFRONT_URL=${{ vars.PROD_FRONTEND_URL }}" \
"ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}" \
"DEVFRONT_URL=${{ vars.DEVFRONT_URL }}" \
"ORGFRONT_URL=${{ vars.ORGFRONT_URL }}" \
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
"VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}" \
"ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}" \
"DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}" \
"ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}" \
> .env
required_dotenv_keys="
APP_ENV TZ DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_USER CLICKHOUSE_PASSWORD
PROD_BACKEND_PORT BACKEND_PORT USERFRONT_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER
USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY
ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
"
for key in ${required_dotenv_keys}; do
if ! grep -Eq "^${key}=.+" .env; then
echo "::error::Missing required production .env value: ${key}. Check Gitea repo variables/secrets."
exit 1
fi
done
# Copy compose template and .env file to the remote server
scp adminfront/seed-tenant.csv "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/adminfront/"
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"
@@ -130,6 +155,9 @@ jobs:
"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 ORGFRONT_IMAGE_NAME='${ORGFRONT_IMAGE_NAME}'; \
export IMAGE_TAG='${IMAGE_TAG}'; \
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \

View File

@@ -0,0 +1,83 @@
name: Staging Build Check
on:
pull_request:
paths:
- ".gitea/workflows/staging_build_check.yml"
- "docker/staging_pull_compose.template.yaml"
- "adminfront/**"
- "devfront/**"
- "userfront/**"
- "backend/**"
- "common/**"
- "scripts/**"
- "locales/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
workflow_dispatch:
jobs:
build-check:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- service: adminfront
- service: devfront
- service: userfront
- service: backend
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Prepare staging build inputs
run: |
set -euo pipefail
cat <<'EOF' > .env
APP_ENV=stage
TZ=Asia/Seoul
IDP_PROVIDER=ory
ADMINFRONT_URL=https://adminfront.staging.example.com
DEVFRONT_URL=https://devfront.staging.example.com
USERFRONT_URL=https://userfront.staging.example.com
ORGFRONT_URL=https://orgfront.staging.example.com
BACKEND_URL=https://backend.staging.example.com
BACKEND_PUBLIC_URL=https://backend.staging.example.com
VITE_OIDC_AUTHORITY=https://sso.staging.example.com/oidc
WORKS_ADMIN_API_BASE_URL=https://works-admin.staging.example.com/api
WORKS_ADMIN_OAUTH_TOKEN_URL=https://works-admin.staging.example.com/oauth/token
ORY_POSTGRES_USER=ory
ORY_POSTGRES_PASSWORD=ory-password
COOKIE_SECRET=staging-build-cookie-secret
JWT_SECRET=staging-build-jwt-secret
NAVER_CLOUD_ACCESS_KEY=dummy
NAVER_CLOUD_SECRET_KEY=dummy
NAVER_CLOUD_SERVICE_ID=dummy
NAVER_SENDER_PHONE_NUMBER=00000000000
AWS_REGION=ap-northeast-2
AWS_ACCESS_KEY_ID=dummy
AWS_SECRET_ACCESS_KEY=dummy
AWS_SES_SENDER=dummy@example.com
REDIS_ADDR=redis:6389
CLICKHOUSE_PORT_NATIVE=9000
CLICKHOUSE_USER=baron
CLICKHOUSE_PASSWORD=password
HYDRA_PUBLIC_URL=https://hydra.staging.example.com
KRATOS_BROWSER_URL=https://sso.staging.example.com
KRATOS_ADMIN_URL=http://kratos:4434
KRATOS_UI_URL=https://sso.staging.example.com
EOF
cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml
- name: Build ${{ matrix.service }} with staging compose
env:
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
run: |
set -euo pipefail
docker compose -f staging_pull_compose.yaml build --pull --progress=plain "${{ matrix.service }}"

View File

@@ -48,6 +48,8 @@ jobs:
APP_ENV=stage
BACKEND_LOG_LEVEL=debug
CLIENT_LOG_DEBUG=true
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
TZ=Asia/Seoul
IDP_PROVIDER=ory
@@ -63,6 +65,7 @@ jobs:
BACKEND_PORT=${{ vars.BACKEND_PORT }}
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
@@ -77,7 +80,7 @@ jobs:
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }}
ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }}
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 }}
@@ -91,7 +94,7 @@ jobs:
USERFRONT_URL=${{ vars.USERFRONT_URL }}
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
VITE_ORGCHART_URL=${{ vars.VITE_ORGCHART_URL }}
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
BACKEND_URL=${{ vars.BACKEND_URL }}
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
@@ -124,15 +127,26 @@ jobs:
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
# Frontend OIDC configs for Staging
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback,http://172.16.10.176:5173/auth/callback,https://sadmin.hmac.kr/auth/callback
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback,http://172.16.10.176:5174/auth/callback,https://sdev.hmac.kr/auth/callback
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback
# Frontend/Ory URL configs for Staging
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}
DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}
ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}
KRATOS_ALLOWED_RETURN_URLS_JSON=${{ vars.KRATOS_ALLOWED_RETURN_URLS_JSON }}
KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.KRATOS_ALLOWED_RETURN_URLS_EXTRA }}
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
# Monitoring & Alerts
SMS_WEBHOOK_PORT=${{ vars.SMS_WEBHOOK_PORT || '8080' }}
MONITOR_RECIPIENT_PHONES=${{ vars.MONITOR_RECIPIENT_PHONES || '01012345678,01098765432' }}
LOKI_URL=${{ vars.LOKI_URL || 'http://loki:3100/loki/api/v1/push' }}
EOF
if ! grep -Eq "^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.+" .env; then
sed -i "s/^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.*/ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600/" .env
fi
# 코드 업데이트 (Git)
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \
if [ ! -d .git ]; then
@@ -161,8 +175,9 @@ jobs:
done
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
chmod -R 777 docker/ory || true
# Ory 컨테이너가 직접 읽는 설정은 env 기반으로 완성한 뒤 mount합니다.
bash scripts/render_ory_config.sh
chmod -R 777 config/.generated/ory || true
cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml
@@ -171,11 +186,58 @@ jobs:
# 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등)
docker compose -f staging_pull_compose.yaml build --pull
docker compose -f staging_pull_compose.yaml up -d --remove-orphans
docker compose -f staging_pull_compose.yaml up -d --remove-orphans --renew-anon-volumes
docker compose -f staging_pull_compose.yaml up -d --force-recreate kratos hydra keto oathkeeper
docker compose -f staging_pull_compose.yaml up -d --force-recreate ory_stack_check
docker compose -f staging_pull_compose.yaml up -d init-rp
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
sleep 10
check_container_http() {
name="$1"
port="$2"
max="${FRONTEND_HEALTH_MAX_ATTEMPTS:-60}"
i=1
while [ "${i}" -le "${max}" ]; do
if docker exec "${name}" sh -c "if command -v wget >/dev/null 2>&1; then wget -qO- 'http://127.0.0.1:${port}/' >/dev/null; elif command -v node >/dev/null 2>&1; then node -e \"fetch('http://127.0.0.1:${port}/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"; else exit 127; fi" >/dev/null 2>&1; then
echo "Frontend ready: ${name}:${port}"
return 0
fi
echo "Waiting for frontend: ${name}:${port} (${i}/${max})"
i=$((i + 1))
sleep 2
done
echo "ERROR: frontend not ready: ${name}:${port}" >&2
docker logs "${name}" --tail 200 >&2 || true
return 1
}
check_container_url() {
name="$1"
url="$2"
max="${FRONTEND_HEALTH_MAX_ATTEMPTS:-60}"
i=1
while [ "${i}" -le "${max}" ]; do
if docker exec "${name}" sh -c "if command -v wget >/dev/null 2>&1; then wget -qO- '${url}' >/dev/null; elif command -v node >/dev/null 2>&1; then node -e \"fetch('${url}').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"; else exit 127; fi" >/dev/null 2>&1; then
echo "Container URL ready: ${name} ${url}"
return 0
fi
echo "Waiting for container URL: ${name} ${url} (${i}/${max})"
i=$((i + 1))
sleep 2
done
echo "ERROR: container URL not ready: ${name} ${url}" >&2
docker logs "${name}" --tail 200 >&2 || true
return 1
}
check_container_url baron_backend http://127.0.0.1:3000/health
check_container_http baron_userfront 5000
check_container_http baron_gateway 5000
check_container_http baron_adminfront 5173
check_container_http baron_devfront 5173
check_container_http baron_orgfront 5175
echo "===== INIT-RP LOGS ====="
docker compose -f staging_pull_compose.yaml logs init-rp || true

View File

@@ -27,6 +27,7 @@ jobs:
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
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
# Staging-specific variables
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
@@ -57,6 +58,8 @@ jobs:
APP_ENV=stage
BACKEND_LOG_LEVEL=debug
CLIENT_LOG_DEBUG=true
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
TZ=Asia/Seoul
IDP_PROVIDER=ory
@@ -66,12 +69,13 @@ jobs:
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
CLICKHOUSE_PASSWORD=${{ vars.CLICKHOUSE_PASSWORD }}
CLICKHOUSE_PASSWORD=${{ secrets.CLICKHOUSE_PASSWORD }}
BACKEND_PORT=${{ vars.BACKEND_PORT }}
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
@@ -86,7 +90,7 @@ jobs:
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }}
ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }}
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 }}
@@ -98,6 +102,7 @@ jobs:
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
USERFRONT_URL=${{ vars.USERFRONT_URL }}
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
BACKEND_URL=${{ vars.BACKEND_URL }}
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
@@ -133,12 +138,42 @@ jobs:
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}
DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}
ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
EOF
if ! grep -Eq "^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.+" .env; then
sed -i "s/^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.*/ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600/" .env
fi
required_dotenv_keys="
APP_ENV BACKEND_LOG_LEVEL CLIENT_LOG_DEBUG WORKS_ADMIN_API_BASE_URL WORKS_ADMIN_OAUTH_TOKEN_URL TZ IDP_PROVIDER
DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_HOST CLICKHOUSE_USER CLICKHOUSE_PASSWORD
BACKEND_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT USERFRONT_PORT OATHKEEPER_API_URL
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR CORS_ALLOWED_ORIGINS PROFILE_CACHE_TTL
ORGFRONT_ORGCHART_CACHE_TTL_SECONDS
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER ADMIN_EMAIL ADMIN_PASSWORD
USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL
ORY_POSTGRES_TAG ORY_POSTGRES_USER ORY_POSTGRES_PASSWORD ORY_POSTGRES_DB KRATOS_DB HYDRA_DB KETO_DB
KRATOS_VERSION KRATOS_UI_NODE_VERSION HYDRA_VERSION KETO_VERSION ORY_SDK_URL KRATOS_PUBLIC_URL
KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL JWKS_URL
OATHKEEPER_VERSION OATHKEEPER_UID OATHKEEPER_GID OATHKEEPER_HEALTH_URL OATHKEEPER_HEALTH_INTERVAL_SECONDS
OATHKEEPER_HEALTH_TIMEOUT_SECONDS OATHKEEPER_HEALTH_ENABLED CSRF_COOKIE_NAME CSRF_COOKIE_SECRET
VITE_OIDC_AUTHORITY ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
"
for key in ${required_dotenv_keys}; do
if ! grep -Eq "^${key}=.+" .env; then
echo "::error::Missing required staging .env value: ${key}. Check Gitea repo variables/secrets."
exit 1
fi
done
# 파일 복사
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker"
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/adminfront"
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/scripts"
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
@@ -151,9 +186,11 @@ jobs:
scp -r gateway "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
fi
scp adminfront/seed-tenant.csv "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/adminfront/"
scp scripts/render_ory_config.sh "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/scripts/"
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"
scp compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
# 배포 실행
echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \
@@ -162,6 +199,7 @@ jobs:
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
export ORGFRONT_IMAGE_NAME='${ORGFRONT_IMAGE_NAME}'; \
export IMAGE_TAG='${IMAGE_TAG}'; \
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
@@ -173,6 +211,9 @@ jobs:
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
bash scripts/render_ory_config.sh; \
chmod -R 777 config/.generated/ory || true; \
envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \

View File

@@ -0,0 +1,273 @@
name: Userfront E2E Full Nightly
on:
schedule:
- cron: "0 18 * * *"
workflow_dispatch:
permissions:
contents: write
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
- 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: Run common lint checks
run: |
make code-check-lint
full-test-policy:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.policy.outputs.should_run }}
reason: ${{ steps.policy.outputs.reason }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Decide whether full E2E is needed
id: policy
run: |
set -euo pipefail
target_sha="${GITHUB_SHA}"
should_run="true"
reason="manual-dispatch"
if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then
reason="missing-full-result"
git fetch origin "+refs/heads/badges:refs/remotes/origin/badges" || true
if git show-ref --verify --quiet refs/remotes/origin/badges && \
git cat-file -e "refs/remotes/origin/badges:dev/${target_sha}/badges.json" 2>/dev/null; then
full_message="$(
git show "refs/remotes/origin/badges:dev/${target_sha}/badges.json" |
node -e "let input=''; process.stdin.on('data', c => input += c); process.stdin.on('end', () => { const data = JSON.parse(input); const keys = ['userfront-chrome', 'userfront-firefox', 'userfront-safari']; const messages = keys.map((key) => data.badges?.[key]?.message || 'unknown'); process.stdout.write(messages.join(',')); });"
)"
if [ -n "${full_message}" ] && ! printf '%s' "${full_message}" | grep -q "unknown"; then
should_run="false"
reason="full-result-exists:${full_message}"
fi
fi
fi
echo "should_run=${should_run}" >> "$GITHUB_OUTPUT"
echo "reason=${reason}" >> "$GITHUB_OUTPUT"
echo "target_sha=${target_sha}"
echo "should_run=${should_run}"
echo "reason=${reason}"
userfront-e2e-full:
needs:
- lint
- full-test-policy
if: ${{ needs.lint.result == 'success' && needs.full-test-policy.outputs.should_run == 'true' }}
runs-on: ubuntu-latest
timeout-minutes: 80
outputs:
chromium_desktop: ${{ steps.full-results.outputs.chromium_desktop }}
chromium_mobile: ${{ steps.full-results.outputs.chromium_mobile }}
firefox_desktop: ${{ steps.full-results.outputs.firefox_desktop }}
firefox_mobile: ${{ steps.full-results.outputs.firefox_mobile }}
webkit_desktop: ${{ steps.full-results.outputs.webkit_desktop }}
webkit_mobile: ${{ steps.full-results.outputs.webkit_mobile }}
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: userfront-e2e/package-lock.json
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Sync userfront locales
run: |
/bin/sh ./scripts/sync_userfront_locales.sh
- name: Install userfront-e2e dependencies
run: |
cd userfront-e2e
npm ci
- name: Build userfront WASM
run: |
cd userfront
rm -rf build/web
flutter build web --wasm --release
cd ..
node userfront/scripts/optimize-web-build.mjs userfront/build/web
- name: Provision full browser matrix
run: |
cd userfront-e2e
npx playwright install --with-deps
- name: Run full userfront-e2e tests
id: full-results
run: |
mkdir -p reports
cd userfront-e2e
workers="${PLAYWRIGHT_WORKERS:-4}"
case "$workers" in
''|*[!0-9]*|0) workers=4 ;;
esac
any_failure=0
run_project() {
output_name="$1"
project_name="$2"
log_path="../reports/userfront-e2e-full-${project_name}.log"
set +e
echo "[userfront-e2e-full] PLAYWRIGHT_WORKERS=${workers} npx playwright test --project=${project_name}" | tee "$log_path"
PLAYWRIGHT_WORKERS="$workers" npx playwright test --project="$project_name" --reporter=list 2>&1 | tee -a "$log_path"
exit_code=${PIPESTATUS[0]}
set -e
if [ "$exit_code" -eq 0 ]; then
result="success"
else
result="failure"
any_failure=1
fi
echo "${output_name}=${result}" >> "$GITHUB_OUTPUT"
}
run_project chromium_desktop chromium-desktop
run_project chromium_mobile chromium-mobile-webapp
run_project firefox_desktop firefox-desktop
echo "firefox_mobile=skipped" >> "$GITHUB_OUTPUT"
run_project webkit_desktop webkit-desktop
run_project webkit_mobile webkit-mobile-webapp
exit "$any_failure"
- name: Upload userfront-e2e full artifacts
if: ${{ always() }}
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: userfront-e2e-full-report
path: |
reports/userfront-e2e-full-*.log
userfront-e2e/playwright-report
userfront-e2e/test-results
if-no-files-found: ignore
badge-updater:
needs:
- lint
- full-test-policy
- userfront-e2e-full
if: ${{ always() && needs.lint.result == 'success' && needs.full-test-policy.outputs.should_run == 'true' && github.ref == 'refs/heads/dev' }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
- name: Restore published badge state
run: |
git fetch origin "+refs/heads/badges:refs/remotes/origin/badges" || true
if git show-ref --verify --quiet refs/remotes/origin/badges && \
git cat-file -e refs/remotes/origin/badges:latest/badges.json 2>/dev/null; then
mkdir -p docs/badges
git archive --format=tar refs/remotes/origin/badges latest | tar -x
cp latest/* docs/badges/
rm -rf latest
else
echo "No published badge state found."
fi
- name: Update full E2E badge files
env:
USERFRONT_E2E_RESULT: ${{ needs.userfront-e2e-full.result }}
USERFRONT_E2E_FULL: "true"
USERFRONT_E2E_CHROMIUM_DESKTOP_RESULT: ${{ needs.userfront-e2e-full.outputs.chromium_desktop }}
USERFRONT_E2E_CHROMIUM_MOBILE_RESULT: ${{ needs.userfront-e2e-full.outputs.chromium_mobile }}
USERFRONT_E2E_FIREFOX_DESKTOP_RESULT: ${{ needs.userfront-e2e-full.outputs.firefox_desktop }}
USERFRONT_E2E_FIREFOX_MOBILE_RESULT: ${{ needs.userfront-e2e-full.outputs.firefox_mobile }}
USERFRONT_E2E_WEBKIT_DESKTOP_RESULT: ${{ needs.userfront-e2e-full.outputs.webkit_desktop }}
USERFRONT_E2E_WEBKIT_MOBILE_RESULT: ${{ needs.userfront-e2e-full.outputs.webkit_mobile }}
BADGE_UPDATE_CODE_CHECK: "false"
BADGE_SOURCE_BRANCH: dev
BADGE_SOURCE_SHA: ${{ github.sha }}
run: |
node scripts/update_code_check_badges.mjs
cat docs/badges/badges.json
- name: Publish full E2E badge assets
run: |
if [ -z "$(git status --porcelain docs/badges)" ]; then
echo "No badge changes."
exit 0
fi
BADGE_BRANCH=badges
BADGE_WORKTREE="$(mktemp -d)"
BADGE_LATEST_DIR="${BADGE_WORKTREE}/latest"
BADGE_SHA_DIR="${BADGE_WORKTREE}/dev/${GITHUB_SHA}"
trap 'rm -rf "${BADGE_WORKTREE}"' EXIT
git config user.name "gitea-actions"
git config user.email "gitea-actions@hmac.kr"
git fetch origin "+refs/heads/${BADGE_BRANCH}:refs/remotes/origin/${BADGE_BRANCH}" || true
if git show-ref --verify --quiet "refs/remotes/origin/${BADGE_BRANCH}"; then
git worktree add --detach "${BADGE_WORKTREE}" "origin/${BADGE_BRANCH}"
else
git worktree add --detach "${BADGE_WORKTREE}"
git -C "${BADGE_WORKTREE}" checkout --orphan "${BADGE_BRANCH}"
git -C "${BADGE_WORKTREE}" rm -rf . || true
fi
find "${BADGE_WORKTREE}" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
mkdir -p "${BADGE_LATEST_DIR}" "${BADGE_SHA_DIR}"
cp docs/badges/*.svg "${BADGE_LATEST_DIR}/"
cp docs/badges/badges.json "${BADGE_LATEST_DIR}/badges.json"
cp docs/badges/*.svg "${BADGE_SHA_DIR}/"
cp docs/badges/badges.json "${BADGE_SHA_DIR}/badges.json"
git -C "${BADGE_WORKTREE}" add .
if [ -z "$(git -C "${BADGE_WORKTREE}" status --porcelain)" ]; then
echo "No published badge changes."
exit 0
fi
git -C "${BADGE_WORKTREE}" commit -m "chore: publish userfront e2e full badge [skip ci]"
git -C "${BADGE_WORKTREE}" push origin HEAD:${BADGE_BRANCH}

17
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# General
.env
.env_backup
.temp
.DS_Store
.idea/
@@ -8,6 +9,7 @@
.codex/
.serena/
.generated/
config/.generated/
*.swp
*.log
*.out
@@ -15,6 +17,9 @@
.npm-cache/
reports
reports/*
config/*.pem
common/node_modules
common/.baron-deps-install.lock
# Docker Services Data (Volumes)
postgres_data/
@@ -39,6 +44,18 @@ userfront/.env
# Frontend test artifacts
adminfront/test-results/
adminfront/test-results.nobody-backup/
devfront/test-results/
orgfront/test-results/
adminfront/playwright-report/
devfront/playwright-report/
orgfront/playwright-report/
adminfront/coverage/
devfront/coverage/
orgfront/coverage/
orgfront/node_modules/
orgfront/dist/
orgfront/.vite/
.pnpm-store
.playwright-mcp
node_modules

263
Makefile
View File

@@ -10,7 +10,13 @@ endif
COMPOSE_INFRA := compose.infra.yaml
COMPOSE_ORY := compose.ory.yaml
COMPOSE_APP := docker-compose.yaml
AUTH_CONFIG_ENV := .generated/auth-config.env
AUTH_CONFIG_ENV := config/.generated/auth-config.env
DEV_SERVICES ?= backend adminfront devfront orgfront userfront
DEV_NETWORKS := baron_net ory-net hydranet kratosnet public_net
INFRA_CONTAINERS := baron_postgres baron_clickhouse baron_redis baron_gateway
ORY_CONTAINERS := ory_postgres ory_kratos ory_hydra ory_keto ory_oathkeeper ory_clickhouse ory_vector
APP_CONTAINERS := baron_backend baron_adminfront baron_devfront baron_orgfront baron_userfront
DROP_CONTAINERS := $(INFRA_CONTAINERS) $(ORY_CONTAINERS) $(APP_CONTAINERS) ory_stack_check
COMPOSE_CLI_ENV_ARGS :=
ifneq (,$(wildcard ./.env))
@@ -18,10 +24,32 @@ COMPOSE_CLI_ENV_ARGS += --env-file .env
endif
COMPOSE_CLI_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
COMPOSE_DROP_ENV_ARGS :=
ifneq (,$(wildcard ./.env))
COMPOSE_DROP_ENV_ARGS += --env-file .env
endif
DUMP_SERVICES ?= all
RESTORE_SERVICES ?= all
DUMP_MODE ?= maintenance
BACKUP_USE_DOCKER ?= true
BACKUP_TOOLS_IMAGE ?= baron-sso-backup-tools:local
BACKUP_TOOLS_DOCKERFILE ?= docker/backup-tools/Dockerfile
BACKUP_DOCKER_ENV_ARGS :=
ifneq (,$(wildcard ./.env))
BACKUP_DOCKER_ENV_ARGS += --env-file .env
endif
ifneq (,$(wildcard ./$(AUTH_CONFIG_ENV)))
BACKUP_DOCKER_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
endif
BACKUP_DOCKER_RUN = docker run --rm $(BACKUP_DOCKER_ENV_ARGS) -e BACKUP_REPO_ROOT=/workspace -v /var/run/docker.sock:/var/run/docker.sock -v "$(CURDIR)":/workspace -v /tmp:/tmp -w /workspace $(BACKUP_TOOLS_IMAGE)
.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app backup-tools-build dump restore dump-verify restore-verify dump-list restore-plan upload-cloud dump-upload-cloud
# --- 인증 설정 빌드/검증 ---
build-auth-config:
@echo "Building auth config..."
@mkdir -p .generated
@mkdir -p config/.generated
@bash scripts/auth_config.sh build
validate-auth-config: build-auth-config
@@ -32,40 +60,109 @@ verify-auth-config: validate-auth-config
@echo "Verifying auth config wiring..."
@bash scripts/auth_config.sh verify
render-ory-config: validate-auth-config
@echo "Rendering Ory config..."
@bash scripts/render_ory_config.sh
# --- 기본 실행 ---
# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음)
up-all: validate-auth-config
up: up-all
up-all: ensure-networks render-ory-config
@echo "Starting ALL stacks (infra + ory + app)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos
# --- 개별 스택 실행 ---
up-infra:
up-infra: ensure-networks
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
docker compose -f $(COMPOSE_INFRA) up -d
up-ory: validate-auth-config
up-ory: ensure-networks render-ory-config
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos
up-app: validate-auth-config
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d
up-app: ensure-networks render-ory-config
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d
up-backend: validate-auth-config
up-backend: ensure-networks render-ory-config
@echo "Starting Backend only..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d backend
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d backend
up-dev: up-infra up-ory
ensure-networks:
@echo "Ensuring Docker networks..."
@for network in $(DEV_NETWORKS); do \
if ! docker network inspect "$$network" >/dev/null 2>&1; then \
echo "Creating Docker network $$network..."; \
docker network create "$$network"; \
else \
echo "Docker network $$network already exists."; \
fi; \
done
ensure-infra: ensure-networks
@echo "Ensuring Infra stack..."
@missing=0; \
for container in $(INFRA_CONTAINERS); do \
if [ "$$(docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null)" != "true" ]; then \
missing=1; \
break; \
fi; \
done; \
if [ "$$missing" -eq 1 ]; then \
echo "Starting missing Infra stack containers in daemon mode..."; \
docker compose -f $(COMPOSE_INFRA) up -d; \
else \
echo "Infra stack is already running."; \
fi
ensure-ory: ensure-networks render-ory-config
@echo "Ensuring Ory stack..."
@missing=0; \
for container in $(ORY_CONTAINERS); do \
if [ "$$(docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null)" != "true" ]; then \
missing=1; \
break; \
fi; \
done; \
if [ "$$missing" -eq 1 ]; then \
echo "Starting missing Ory stack containers in daemon mode..."; \
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \
else \
echo "Ory stack is already running. Restarting Kratos to apply rendered dev config..."; \
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos; \
fi
up-dev: ensure-infra ensure-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)."
dev: up-dev
@echo "Starting development app containers in foreground attach mode..."
BACKEND_LOG_LEVEL=info CLIENT_LOG_DEBUG=false VITE_CLIENT_LOG_DEBUG=false docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
dev-debug: up-dev
@echo "Starting development app containers in foreground attach debug mode..."
BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true VITE_CLIENT_LOG_DEBUG=true USERFRONT_FLUTTER_RUN_FLAGS=--debug docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
# --- 종료 (Down) ---
down:
@echo "Stopping ALL stacks (infra + ory + app)..."
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
drop:
@echo "Dropping Baron SSO local Docker stack containers, volumes, and local images..."
-docker compose $(COMPOSE_DROP_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down -v --rmi local
@echo "Removing any remaining fixed-name Baron SSO containers..."
@for container in $(DROP_CONTAINERS); do \
docker rm -f "$$container" >/dev/null 2>&1 || true; \
done
@echo "Drop complete. External Docker networks are preserved."
down-app:
@echo "Stopping App stack..."
docker compose -f $(COMPOSE_APP) down
@@ -106,21 +203,66 @@ logs-ory:
logs-app:
docker compose -f $(COMPOSE_APP) logs -f
# --- 백업/복구 ---
backup-tools-build:
docker build -f $(BACKUP_TOOLS_DOCKERFILE) -t $(BACKUP_TOOLS_IMAGE) .
ifeq ($(BACKUP_USE_DOCKER),true)
dump: backup-tools-build
$(BACKUP_DOCKER_RUN) bash -lc 'DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh'
restore: backup-tools-build
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh'
dump-verify: backup-tools-build
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh'
restore-verify: backup-tools-build
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh'
dump-list: backup-tools-build
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh'
restore-plan: backup-tools-build
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh'
upload-cloud: backup-tools-build
$(BACKUP_DOCKER_RUN) bash -lc 'WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh'
else
dump:
DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh
restore:
BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh
dump-verify:
BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh
restore-verify:
BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh
dump-list:
BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh
restore-plan:
BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh
upload-cloud:
WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh
endif
dump-upload-cloud: dump upload-cloud
# --- 로컬 통합 코드 체크 ---
PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright
PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE
PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE
PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE
ifeq ($(CI),)
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi'
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
else
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium
endif
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-orgfront-tests code-check-userfront-e2e-tests
CODE_CHECK_TEST_JOBS ?= 1
PLAYWRIGHT_WORKERS ?= 1
@@ -138,7 +280,8 @@ code-check-test-jobs:
code-check-userfront-tests \
code-check-userfront-e2e-tests \
code-check-adminfront-tests \
code-check-devfront-tests
code-check-devfront-tests \
code-check-orgfront-tests
code-check-i18n:
@echo "==> i18n resource check"
@@ -175,24 +318,45 @@ code-check-sync-userfront-locales:
code-check-userfront-install:
@echo "==> install userfront dependencies"
cd userfront && flutter pub get
@if command -v flutter >/dev/null 2>&1; then \
cd userfront && flutter pub get; \
else \
echo "WARNING: flutter not found, skipping userfront dependencies install."; \
fi
code-check-userfront-lint:
@echo "==> userfront format/analyze"
cd userfront && dart format --output=none --set-exit-if-changed lib test
cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos
@if command -v dart >/dev/null 2>&1; then \
cd userfront && dart format --output=none --set-exit-if-changed lib test; \
else \
echo "WARNING: dart not found, skipping userfront format check."; \
fi
@if command -v flutter >/dev/null 2>&1; then \
cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos; \
else \
echo "WARNING: flutter not found, skipping userfront analyze."; \
fi
code-check-front-lint:
@echo "==> adminfront biome lint/format check"
rm -rf adminfront/playwright-report adminfront/test-results
cd adminfront && npm ci
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
cd adminfront && CI=true npx pnpm install --frozen-lockfile --ignore-scripts
cd adminfront && npx biome lint .
cd adminfront && npx biome format .
@echo "==> devfront biome lint/format check"
rm -rf devfront/playwright-report devfront/test-results
cd devfront && npm ci
cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
@if [ -d devfront/node_modules ]; then \
echo "devfront/node_modules already present; skipping npm install."; \
else \
cd devfront && npm ci --ignore-scripts; \
fi
cd devfront && npx biome lint .
cd devfront && npx biome format .
@echo "==> orgfront biome lint/format check"
rm -rf orgfront/playwright-report orgfront/test-results
cd orgfront && npm ci --ignore-scripts
cd orgfront && npx biome lint .
cd orgfront && npx biome format .
code-check-backend-tests:
@echo "==> backend tests"
@@ -200,7 +364,11 @@ code-check-backend-tests:
code-check-userfront-tests:
@echo "==> userfront tests (isolated workspace)"
@tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-tests.XXXXXX)"; \
@if ! command -v flutter >/dev/null 2>&1; then \
echo "WARNING: flutter not found, skipping userfront tests."; \
exit 0; \
fi; \
tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-tests.XXXXXX)"; \
trap 'rm -rf "$$tmp_dir"' EXIT INT TERM; \
mkdir -p "$$tmp_dir/scripts"; \
cp scripts/sync_userfront_locales.sh "$$tmp_dir/scripts/"; \
@@ -229,7 +397,14 @@ code-check-devfront-tests:
@mkdir -p reports/devfront
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
@status=0; \
(cd devfront && npm ci) || status=$$?; \
preview_pattern='[v]ite preview --host 127.0.0.1 --strictPort --port 4174'; \
pkill -f "$$preview_pattern" >/dev/null 2>&1 || true; \
trap 'pkill -f "$$preview_pattern" >/dev/null 2>&1 || true' EXIT INT TERM; \
if [ -d devfront/node_modules ]; then \
echo "devfront/node_modules already present; skipping npm install."; \
else \
(cd devfront && npm ci --ignore-scripts) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
@@ -240,11 +415,31 @@ code-check-devfront-tests:
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
exit $$status
code-check-orgfront-tests:
@echo "==> orgfront tests"
@mkdir -p reports/orgfront
@rm -rf reports/orgfront/playwright-report reports/orgfront/test-results
@status=0; \
(cd orgfront && npm ci --ignore-scripts) || status=$$?; \
if [ $$status -eq 0 ]; then \
(cd orgfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd orgfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \
[ -d orgfront/playwright-report ] && cp -R orgfront/playwright-report reports/orgfront/ || true; \
[ -d orgfront/test-results ] && cp -R orgfront/test-results reports/orgfront/ || true; \
exit $$status
code-check-userfront-e2e-tests:
@echo "==> userfront wasm playwright e2e tests (isolated workspace)"
@mkdir -p reports/userfront-e2e
@rm -rf reports/userfront-e2e/playwright-report reports/userfront-e2e/test-results
@tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-e2e-tests.XXXXXX)"; \
@if ! command -v flutter >/dev/null 2>&1; then \
echo "WARNING: flutter not found, skipping userfront e2e tests."; \
exit 0; \
fi; \
mkdir -p reports/userfront-e2e; \
rm -rf reports/userfront-e2e/playwright-report reports/userfront-e2e/test-results; \
tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-e2e-tests.XXXXXX)"; \
trap 'rm -rf "$$tmp_dir"' EXIT INT TERM; \
mkdir -p "$$tmp_dir/scripts"; \
cp scripts/sync_userfront_locales.sh "$$tmp_dir/scripts/"; \
@@ -277,7 +472,7 @@ code-check-userfront-e2e-tests:
(cd "$$tmp_dir/userfront" && flutter build web --wasm --release) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_CHROMIUM)) || status=$$?; \
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \

322
README.md
View File

@@ -1,5 +1,11 @@
# Baron SSO
[![dev](https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/dev-sha.svg)](https://gitea.hmac.kr/baron/baron-sso/src/branch/dev) [![Code Check](https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/code-check.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [![Biome](https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/biome.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [![backend](https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/backend-tests.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
[![userfront](https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/userfront.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [![adminfront](https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/adminfront.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [![devfront](https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/devfront.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev) [![orgfront](https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/orgfront.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/code_check.yml?branch=dev)
[![chrome](https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/userfront-chrome.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/userfront_e2e_full_nightly.yml?branch=dev) [![firefox](https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/userfront-firefox.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/userfront_e2e_full_nightly.yml?branch=dev) [![safari](https://gitea.hmac.kr/baron/baron-sso/raw/branch/badges/latest/userfront-safari.svg)](https://gitea.hmac.kr/baron/baron-sso/actions/workflows/userfront_e2e_full_nightly.yml?branch=dev)
badge는 `Code Check``badges` 브랜치의 `latest/``dev/<commit-sha>/`에 발행합니다. 최신 HTML/LCOV/JSON summary는 Gitea `Code Check`의 패키지별 `*-vitest-coverage-report` artifact에서 확인할 수 있습니다.
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
## 📂 프로젝트 구조 (Project Structure)
@@ -66,7 +72,7 @@ flowchart
```
### 1. Backend (Go Fiber)
- **Language**: Go 1.25+
- **Language**: Go 1.26.2+
- **Framework**: Fiber v2.25+
- **Database**:
- **ClickHouse**: 감사 로그 (고성능 데이터 수집)
@@ -95,6 +101,99 @@ flowchart
- RP 등록 및 관리
- RP별 Consent 관리
## 관리 데이터 Export/Import 정책
AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접 검토하고 재반입할 수 있는 흐름을 기준으로 설계합니다. 기본 원칙은 내부 UUID를 불필요하게 노출하지 않고, 사람이 이해하기 쉬운 `slug`와 이름을 우선 사용하는 것입니다.
### 공통 원칙
- CSV는 Excel 호환을 위해 UTF-8 BOM을 포함해 내려받습니다.
- 기본 export는 시스템 내부 ID를 제외합니다.
- 같은 데이터를 정확히 재동기화해야 하는 운영 작업에서는 `includeIds=true` 옵션으로 내부 ID 컬럼을 포함할 수 있습니다.
- import는 preview/검토 단계를 거친 뒤 실행하는 것을 기본으로 합니다.
- 기존 데이터와 충돌 가능성이 있는 row는 자동 적용하지 않고 관리자 선택 또는 확인 상태로 표시합니다.
- 삭제는 export/import로 암묵 처리하지 않습니다. 삭제가 필요하면 별도 삭제 기능을 사용합니다.
### Tenant Export
- 기본 컬럼은 운영자가 다시 import하기 쉬운 형태를 유지합니다.
- `includeIds=false`가 기본이며, 이 경우 내부 `tenant_id`는 제외합니다.
- `includeIds=true`를 사용하면 기존 테넌트 update 또는 staging/production 간 매핑 확인에 필요한 ID를 포함합니다.
- 주요 의미:
- `tenant_id`: 내부 UUID. 기본 export에서는 제외됩니다.
- `name`: 테넌트 표시 이름입니다.
- `type`: `PERSONAL`, `COMPANY`, `COMPANY_GROUP`, `USER_GROUP` 중 하나입니다.
- `parent_tenant_id`: 상위 테넌트 내부 ID입니다.
- `parent_tenant_slug`: 상위 테넌트를 slug로 연결할 때 사용합니다.
- `slug`: 운영상 사람이 다루는 테넌트 식별자입니다.
- `memo`: 설명 또는 비고입니다.
- `email_domain`: 테넌트에 연결된 이메일 도메인입니다. 여러 도메인은 `;`, `,`, 줄바꿈으로 구분할 수 있습니다.
### Tenant Import
- 필수 컬럼은 `name`, `type`, `slug`입니다.
- 허용되는 header alias:
- `tenant_id`: `id`, `tenantid`, `tenant_id`
- `parent_tenant_id`: `parentid`, `parent_id`, `parenttenantid`, `parent_tenant_id`
- `parent_tenant_slug`: `parenttenantslug`, `parent_tenant_slug`
- `memo`: `memo`, `description`
- `email_domain`: `email-domain`, `emaildomain`, `email_domain`, `domain`, `domains`
- `tenant_id`가 있고 기존 테넌트가 있으면 update 대상으로 봅니다.
- `tenant_id`가 없으면 `slug` 기준으로 기존 테넌트를 찾고, 없으면 신규 생성 후보로 봅니다.
- `parent_tenant_slug`가 같은 import 파일 안에 있으면 부모 row를 먼저 처리하도록 정렬합니다.
- import preview는 이름/slug 유사도 기반 후보를 보여주며, 관리자가 기존 테넌트 사용, 신규 생성, skip 중 선택할 수 있어야 합니다.
- 외부 시스템에서 가져온 `tenant_id`처럼 현재 DB에 없는 ID는 충돌로 표시하고, 관리자가 새 slug 또는 기존 테넌트 매핑을 결정해야 합니다.
### User Export
- 기본 컬럼은 `Email`, `Name`, `Phone`, `Status`, `tenant_slug`, `Position`, `JobTitle`, `CreatedAt`입니다.
- `includeIds=true`이면 `user_id`, `tenant_id`를 함께 포함합니다.
- 사용자 role은 export 기본 컬럼에서 제외합니다. role은 일괄 변경의 실수 위험이 크므로 명시적 관리 화면 또는 별도 정책으로 다룹니다.
- 사용자 metadata는 `Meta:<key>` 컬럼으로 뒤에 추가됩니다.
- `includeIds=false`일 때는 `id`, `user_id`, `tenant_id`, `tenantid` 성격의 metadata key를 export에서 제외합니다.
- tenant admin의 export는 관리 가능한 테넌트 범위로 제한됩니다.
### User Import
- 사용자 CSV의 기본 컬럼은 `email`, `name`, `phone`, `role`, `tenant_slug`, `department`, `position`, `jobTitle`입니다.
- `email``name`은 CSV parsing 단계의 필수값입니다.
- backend 생성 단계에서는 `tenantSlug`도 필수입니다.
- `tenant`, `tenant_slug`, `companyCode` header는 사용자 소속 테넌트 slug로 매핑됩니다.
- `tenant_id`, `tenant_name`, `tenant_type`, `parent_tenant_id`, `parent_tenant_slug`, `parent_tenant_name`, `tenant_memo`, `email_domain` 컬럼이 있으면 사용자 import 과정에서 필요한 테넌트 생성/매핑 preview에 사용합니다.
- 위 기본 컬럼에 속하지 않는 컬럼은 사용자 metadata로 들어갑니다.
- 테넌트에 `userSchema`가 있으면 import 중 metadata required/validation/loginId 규칙을 적용합니다.
- 테넌트 schema에서 `isLoginId`로 지정된 metadata 값은 custom login ID로 동기화하며, 이메일/전화번호/예약어와 충돌하지 않아야 합니다.
### 한맥가족 User Import Email 정책
- 전체 시스템에서 `users.email`은 unique입니다.
- `active`, `temporary_leave`, `suspended`, `preboarding`, `baron_guest`, `extended_leave`, `archived` 등 모든 사용자 상태가 unique 검사 대상입니다. 특히 `preboarding`, `baron_guest`, `archived` 사용자는 email/local-part 선점 대상입니다.
- 한맥가족 테넌트 root(`hanmac-family`)와 그 하위 subtree에서는 이메일 도메인과 무관하게 `@` 앞 local-part도 unique 해야 합니다.
- 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다.
- `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다.
- 이름 기반 local-part 기본 규칙은 `이름 부분 초성 + 성 로마자`입니다.
- 예: `한치영` -> `치영`의 초성 `c + y` + 성 `han` -> `cyhan`
- 이미 `cyhan`, `cyhan1`이 있으면 다음 후보인 `cyhan2`를 제안합니다.
- 외부 로마자화 패키지는 backend 의존성으로 추가하지 않고, 내부 한글 음절 분해와 성씨/초성 매핑을 사용합니다.
- import preview의 row 상태:
- `valid`: unique와 이름 기반 권장 규칙을 모두 만족합니다.
- `suggested`: 도메인만 있거나 suffix 제안이 필요한 row입니다.
- `needsReview`: 이름 매핑이 애매해 관리자가 직접 확인해야 합니다.
- `ruleMismatch`: 최종 local-part가 `이름 이니셜 + 성 + 숫자 suffix` 규칙과 다릅니다. 예외 진행은 가능하지만 관리자에게 표시해야 합니다.
- `blockingError`: local-part 중복, email 형식 오류, 필수값 누락처럼 생성을 차단해야 하는 상태입니다.
- 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다.
- bulk import는 preview에서 제안/수정된 최종 email을 사용하되, backend가 생성 직전에 다시 unique 규칙을 검증합니다.
### User Status 정책
| 상태 | 표시명 | Baron 사용 | Works 처리 | 일반 조직도 |
| --- | --- | --- | --- | --- |
| `active` | 재직 | 가능 | 생성/갱신 | 노출 |
| `temporary_leave` | 단기휴무 | 가능 | 계정 유지 | 노출 |
| `suspended` | 정지 | 불가 | suspend | 노출 |
| `preboarding` | 입사대기 | 불가 | 생성 안 함 | 비노출 |
| `baron_guest` | Baron 게스트 | 가능 | 생성 금지, 기존 계정 delete/deprovision | 비노출 |
| `extended_leave` | 장기휴직 | 불가 | delete/deprovision | 비노출 |
| `archived` | 보관 | 불가 | delete/deprovision | 비노출 |
- 기존 `inactive` 입력은 `preboarding`으로, `leave_of_absence` 입력은 `temporary_leave`로 호환 처리합니다.
- 이슈 #862의 초기 명칭 `baron_only`는 구현 명칭으로 사용하지 않고 `baron_guest`로 정리합니다.
- backend bootstrap은 남아 있는 legacy `users.status` 값을 `inactive -> preboarding`, `leave_of_absence -> temporary_leave`, `baron_only -> baron_guest`로 자동 정규화합니다.
- `archived` 사용자는 과거 이력 보존용 계정이며 AdminFront 같은 관리자 화면에서만 감사/운영/중복 확인 목적으로 조회할 수 있습니다.
### 4. 주요 시나리오 (Core Scenarios)
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
@@ -279,6 +378,59 @@ flowchart TD
Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비지니스로직은 Backend를 통해서, 기본 인증 로직은 Ory Stack을 통해 진행됩니다.
### SSOT 및 Redis Cache 전략
Baron SSO는 “하나의 DB가 모든 데이터의 원본”인 구조가 아닙니다. 데이터 성격별로 원장이 다르며, Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane입니다. Redis와 PostgreSQL projection은 성능과 운영 편의를 위한 read model/cache로만 사용하고, 원장과 불일치할 수 있다는 전제를 명시합니다.
#### 데이터별 원본 위치
| 데이터 | SSOT | 보조 저장소/캐시 | 비고 |
| --- | --- | --- | --- |
| Identity subject, credentials, recovery/verification address | Ory Kratos `identities` | Redis identity mirror, PostgreSQL `users.id` 참조 | Kratos identity ID가 사용자 subject이며 WORKS `externalKey` 기준입니다. |
| 로그인 식별자 | Kratos traits, `user_login_ids` | Redis identity mirror | Kratos는 인증 식별자, PostgreSQL은 중복/정책 검증용 index입니다. |
| 사용자 이름, 이메일, 전화번호, role 기본값 | Kratos traits | PostgreSQL `users`, Redis mirror | 인증/profile 계산에 필요한 최소 identity 값만 Kratos에 유지합니다. |
| Baron 사용자 상태, soft delete, 운영 메타데이터 | PostgreSQL `users`, `users.metadata` | Redis mirror 조합 응답 | `users.deleted_at`은 Baron 운영 상태이며 Kratos identity 삭제와 같은 의미가 아닙니다. |
| 테넌트 tree, slug, 조직/부서/직무/직책 | PostgreSQL `tenants`, `users`, membership metadata | Redis/API response cache 가능 | 관계형 조직 데이터는 Kratos traits가 아니라 Backend DB가 원장입니다. |
| 권한/관계 | Ory Keto relation tuple | PostgreSQL outbox/status | Backend를 통해 relation command를 보내고 처리 상태를 추적합니다. |
| OAuth2/OIDC client, consent, token state | Ory Hydra | PostgreSQL `client_consents`, audit/read model | Hydra가 프로토콜 원장이며 로컬 테이블은 운영 조회/감사용입니다. |
| RP별 사용자 custom claim 값 | PostgreSQL `rp_user_metadata` | ID token/userinfo projection | RP 관리자 범위 데이터이며 전역 claim과 분리합니다. |
| 전역 사용자 custom claim 값 | PostgreSQL `users.metadata.global_custom_claims` | ID token projection | 전체 사용자 대상 claim으로 adminfront 사용자 상세에서만 관리합니다. |
| WORKS Mobile mapping/outbox/job 상태 | PostgreSQL `worksmobile_*` | WORKS API 비교 응답 cache 가능 | 외부 SaaS 연동 상태이며 identity 원장이 아닙니다. |
| 감사 로그/사용량 | ClickHouse, Oathkeeper/Ory 로그 | 화면별 summary cache 가능 | command와 보안 이벤트의 감사 원장입니다. |
| Headless JWKS 검증 상태 | Redis `headless:jwks:*` cache | DevFront 상태 카드 | RP public key 문서 자체는 외부 `jwksUri`가 원본입니다. |
| 로그인 코드, pending login, verification token | Redis short-lived key | 없음 | 만료 가능한 휘발성 상태입니다. 백업/복구 대상이 아닙니다. |
#### SSOT 보장 원칙
1. Kratos/Hydra/Keto/WORKS로 향하는 쓰기 command는 Backend를 통과합니다.
2. Backend는 원장 write 성공 후 원장 ID를 기준으로 재조회하고, PostgreSQL read model 또는 Redis mirror를 write-through 갱신합니다.
3. write-through 갱신 실패 시 원장 write를 되돌린 것으로 간주하지 않습니다. 대신 mirror/cache 상태를 `stale` 또는 `failed`로 표시하고 drift report와 refresh 대상으로 둡니다.
4. Kratos Admin API 또는 Kratos DB를 Backend 밖에서 직접 수정하는 경로는 운영 정책상 금지합니다. 정비/DR처럼 예외가 필요한 경우에는 Redis mirror를 stale로 표시하고, full refresh와 drift report를 완료하기 전까지 cache 결과를 신뢰하지 않습니다.
5. PostgreSQL projection은 Kratos partial list를 full snapshot처럼 취급하지 않습니다. Kratos 목록 조회가 partial이면 로컬 사용자를 삭제/숨김 처리하지 않습니다.
6. frontend 대량 조회는 cursor 기반을 원칙으로 합니다. `limit=5000&offset=0` 같은 단일 대량 offset 조회는 사용자 수가 늘면 partial data를 전체처럼 보이게 만들 수 있으므로 신규 구현에서 금지합니다.
7. Redis cache miss가 발생한 단건 조회는 가능한 경우 SSOT로 fallback하고, fallback 성공 시 Redis를 갱신합니다. 목록 조회는 mirror 상태가 `ready`가 아니면 화면/API에 경고 상태를 함께 전달해야 합니다.
#### Redis 사용 원칙
Redis는 원장이 아니라 cache/mirror 계층입니다. Redis 데이터 유실은 장애지만 데이터 유실 사고로 보지 않고, 원장 재조회와 refresh로 재수렴해야 합니다.
| Redis 데이터 | 역할 | TTL/보존 정책 | 장애 시 처리 |
| --- | --- | --- | --- |
| `identity:mirror:{identityID}` | Kratos identity summary 단건 cache | 장기 mirror. refresh 상태와 함께 운영 | Kratos `GetIdentity` fallback 후 write-through |
| `identity:index:*` | identity 목록/검색 cursor index | mirror refresh 주기로 재작성 | `stale` 표시 후 full refresh |
| `identity:mirror:state` | mirror 상태, count, last error | 영구 상태 key | adminfront에서 경고 표시 |
| `headless:jwks:*` | RP headless login JWKS cache | JWKS TTL과 prefetch 정책 | kid miss/검증 실패/TTL 만료 시 재조회 |
| login/verification/pending 계열 key | 인증 흐름의 단기 상태 | 짧은 TTL 필수 | 만료 또는 유실 시 사용자가 흐름 재시작 |
| 일반 API response cache | 선택적 성능 cache | 짧은 TTL, invalidation 우선 | miss 시 Backend DB 또는 Ory 원장 조회 |
운영 Redis 설정은 `maxmemory``maxmemory_policy`가 명시되어야 합니다. identity mirror처럼 재수렴 가능한 데이터와 pending login처럼 사용자 흐름에 영향을 주는 단기 key가 같은 Redis를 공유하므로, eviction 발생 여부와 TTL 없는 key 증가를 운영 화면에서 볼 수 있어야 합니다.
#### Redis 모니터링 계획
Redis 적정 설정 판단에 필요한 운영 지표를 adminfront에 노출하는 후속 작업은 이슈 [#1046](https://gitea.hmac.kr/baron/baron-sso/issues/1046)으로 분리했습니다.
표시 대상은 Redis 연결/버전/uptime, `used_memory`, `maxmemory`, `maxmemory_policy`, keyspace hit/miss, expired/evicted keys, prefix별 key count, TTL 분포, `identity:mirror:state`, headless JWKS cache failure 요약입니다. 이 화면은 `super_admin` 전용으로 두고, Redis key value 자체는 노출하지 않습니다.
---
## 🚀 시작하기 (Getting Started)
@@ -319,11 +471,13 @@ USERFRONT_URL=https://sso.example.com
- `KRATOS_BROWSER_URL`: 보통 `${OATHKEEPER_PUBLIC_URL}/auth`
- `KRATOS_UI_URL`: UserFront UI URL (로컬 예: `http://localhost:5000`)
- `ADMINFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5173/auth/callback`)
- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/callback`)
- `DEVFRONT_CALLBACK_URLS`: 콤마 구분 콜백 목록 (예: `http://localhost:5174/auth/callback`)
- 주의: callback URL 끝에 `/`가 붙으면 `make validate-auth-config`에서 실패 처리됩니다.
- `KRATOS_ALLOWED_RETURN_URLS_EXTRA`: 추가 허용 return URL (선택)
- 빈값: `[]`
- 다중값: `["https://a.example.com/callback","https://b.example.com/callback"]` 또는 `https://a.example.com/callback,https://b.example.com/callback`
- `KRATOS_ALLOWED_RETURN_URLS_JSON`: stage/prod에서 권장하는 전체 허용 return URL 목록
- 공개 도메인, `/ko`, `/en`, `/auth/callback`, `/ko/auth/callback`, `/en/auth/callback`, 각 front callback을 포함해야 합니다.
- `CLIENT_LOG_DEBUG`: 클라이언트 로그 디버그 모드 강제 (기본: 비운영 `true`, 운영 `false`)
- 운영(`APP_ENV=production|prod`)에서 `true|1|on|yes` 설정 시 `INFO/DEBUG` 클라이언트 로그 수집 허용
- 미설정(기본) 시 운영에서는 `WARN/ERROR`만 수집
@@ -358,7 +512,7 @@ USERFRONT_URL=https://sso.example.com
```bash
make validate-auth-config
```
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `.generated/auth-config.env`를 생성합니다.
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `config/.generated/auth-config.env`를 생성합니다.
### 전체 스택 실행 (Running the Stack)
@@ -400,7 +554,7 @@ make validate-auth-config
make verify-auth-config
```
- 생성 파일: `.generated/auth-config.env` (compose 실행 시 자동 주입)
- 생성 파일: `config/.generated/auth-config.env` (compose 실행 시 자동 주입)
- 게이트웨이 경유 환경은 URL 문자열 완전일치 대신 매핑 유효성(`direct_match` / `mapped_match`) 기준으로 검증합니다.
- 관련 정책 문서: `docs/oidc_redirect_mapping_validation_policy.md`
@@ -415,8 +569,8 @@ make up-app
직접 Compose를 사용하려면 다음처럼 env 파일을 함께 주입하세요.
```bash
docker compose --env-file .env --env-file .generated/auth-config.env -f compose.infra.yaml -f compose.ory.yaml up -d
docker compose --env-file .env --env-file .generated/auth-config.env -f docker-compose.yaml up -d
docker compose --env-file .env --env-file config/.generated/auth-config.env -f compose.infra.yaml -f compose.ory.yaml up -d
docker compose --env-file .env --env-file config/.generated/auth-config.env -f docker-compose.yaml up -d
```
- **gateway (UserFront 프록시)**: http://localhost:5000 접속
@@ -426,6 +580,155 @@ docker compose --env-file .env --env-file .generated/auth-config.env -f docker-c
- **Hydra Public**: http://localhost:4444
- **Kratos UI (UserFront)**: http://localhost:5000
### 전체 백업/복구
전체 백업/복구는 CSV export/import가 아니라 Baron SSO와 Ory Stack 저장소를 같은 시점의 재해 복구 단위로 보존하는 절차입니다. 사용자 UUID, Kratos identity ID, Hydra/Keto 원장, WORKS 연동 mapping이 어긋나면 안 되므로 운영 복구는 DB dump와 설정 snapshot을 함께 다룹니다.
#### 백업 실행
```bash
# 전체 백업
make dump
# 출력 위치를 직접 지정
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 일부 서비스만 백업
make dump DUMP_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config
make dump DUMP_SERVICES=ory-postgres,ory-clickhouse
# 생성된 백업 검증
make dump-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# WORKS Drive로 외부 분산 저장
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 지정 경로로 dump 후 바로 WORKS Drive 업로드
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 로컬 백업 목록
make dump-list
```
기본값은 `DUMP_SERVICES=all`, `DUMP_MODE=maintenance`입니다. `DUMP_SERVICES`는 다음 값을 콤마로 조합할 수 있습니다.
| 값 | 대상 |
| --- | --- |
| `postgres` | Baron Postgres (`baron_postgres`, `${DB_NAME:-baron_sso}`) |
| `ory-postgres` | Ory Postgres의 `${KRATOS_DB:-ory_kratos}`, `${HYDRA_DB:-ory_hydra}`, `${KETO_DB:-ory_keto}` |
| `clickhouse` | Baron ClickHouse (`baron_clickhouse`) |
| `ory-clickhouse` | Ory ClickHouse (`ory_clickhouse`) |
| `config` | `.env` redacted copy, generated Ory config, gateway, 주요 compose 파일 |
백업 산출물은 기본적으로 `backups/baron-sso-backup-YYYYMMDD-HHMMSSZ/` 아래에 생성됩니다.
```text
manifest.json
checksums.sha256
postgres/
clickhouse/
config/
reports/
```
#### WORKS Drive 외부 업로드
`make dump`, `make restore`, `make upload-cloud`는 기본적으로 `docker/backup-tools/Dockerfile`에서 빌드한 `baron-sso-backup-tools:local` 컨테이너 안에서 실행됩니다. 호스트에는 Docker와 Docker socket 접근 권한만 필요하고, `zstd`, `jq`, `curl`, `openssl`, `postgresql-client` 같은 백업/복구 도구는 backup-tools image에 포함됩니다.
`make upload-cloud`는 기존 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 묶은 뒤 WORKS Drive에 업로드합니다. 압축 포맷은 `.tar.zst`로 고정되어 있고, 압축/해제는 backup-tools 컨테이너 내부의 `zstd`로 수행합니다.
백업이 완료되면 `reports/backup-report.md`도 생성됩니다. 이 report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록됩니다. `make upload-cloud`는 `reports/*.md`만 WORKS Drive 대상 폴더 아래의 `reports` 하위 폴더로 업로드하며, 업로드 파일명은 `backup-report-YYYYMMDD-HHMMSSZ.md`처럼 업로드 시각을 붙입니다. `reports/cloud-upload.json`은 로컬 업로드 실행 기록으로만 남기고 Drive에는 업로드하지 않습니다.
```bash
# 권장: 백업 경로를 명시해서 dump와 upload를 분리
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 또는 같은 BACKUP 경로로 연속 실행
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 실제 업로드 전 endpoint와 target만 확인
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ WORKS_DRIVE_DRY_RUN=true
# 예외적으로 호스트 도구로 직접 실행
make restore BACKUP_USE_DOCKER=false BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ CONFIRM_RESTORE=baron-sso
```
주요 변수:
| 변수 | 설명 |
| --- | --- |
| `WORKS_DRIVE_TARGET` | `sharedrive`, `mydrive`, `group`, `sharedfolder` 중 하나. 기본값은 `sharedrive`입니다. |
| `WORKS_DRIVE_SHARED_DRIVE_ID` | `WORKS_DRIVE_TARGET=sharedrive`일 때 공용 드라이브 ID입니다. |
| `WORKS_DRIVE_PARENT_FILE_ID` | 업로드할 대상 폴더의 WORKS Drive `fileId`입니다. 폴더 이름이나 경로가 아니며, 비우면 대상 drive/folder root에 업로드합니다. |
| `WORKS_DRIVE_USER_ID` | `mydrive` 또는 `sharedfolder` 대상 사용자 ID입니다. 기본값은 `me`입니다. |
| `WORKS_DRIVE_GROUP_ID` | `WORKS_DRIVE_TARGET=group`일 때 조직/그룹 ID입니다. |
| `WORKS_DRIVE_SHARED_FOLDER_ID` | `WORKS_DRIVE_TARGET=sharedfolder`일 때 공유받은 폴더 ID입니다. |
| `WORKS_DRIVE_ACCESS_TOKEN` | Drive API 호출용 Bearer token입니다. Drive API는 `file` scope가 필요합니다. |
| `WORKS_DRIVE_ACCESS_TOKEN_FILE` | access token을 파일에서 읽을 때 사용합니다. |
| `WORKS_DRIVE_ACCESS_TOKEN_CMD` | access token을 명령 출력으로 주입할 때 사용합니다. |
| `WORKS_DRIVE_OAUTH_SCOPE` | Drive 업로드 앱 OAuth token에 사용할 scope입니다. 기본값은 `file`입니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_ID` | Drive 업로드 앱의 OAuth client ID입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_CLIENT_ID`와 분리합니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_SECRET` | Drive 업로드 앱의 OAuth client secret입니다. |
| `WORKS_DRIVE_OAUTH_REFRESH_TOKEN` | Drive 업로드 앱의 refresh token입니다. 명시 access token이 없으면 이 값으로 access token을 갱신합니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT` | Drive 업로드 앱의 service account입니다. JWT `sub`에 들어갑니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE` | Drive 업로드 앱 private key 파일입니다. 예: `./config/worksmobile-driveapp-private-key.pem` |
| `WORKS_DRIVE_SPLIT_SIZE` | 분할 업로드 시 part 크기입니다. 기본값은 `9000M`입니다. |
| `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` | 이 값보다 archive가 크면 split part로 나눕니다. 기본값 `0`은 자동 분할 비활성입니다. |
| `WORKS_DRIVE_FORCE_SPLIT` | `true`이면 크기와 무관하게 split part로 업로드합니다. |
| `WORKS_DRIVE_OVERWRITE` | WORKS Drive upload URL 생성 요청의 overwrite 플래그입니다. 기본값은 `false`입니다. |
| `WORKS_DRIVE_UPLOAD_REPORTS` | `true`이면 `reports/*.md`를 Drive의 report 폴더로 함께 업로드합니다. 기본값은 `true`입니다. |
| `WORKS_DRIVE_REPORT_FOLDER_NAME` | Markdown report를 업로드할 하위 폴더 이름입니다. 기본값은 `reports`입니다. |
Drive API는 업로드 URL 생성 후 해당 URL에 multipart `Filedata`로 실제 파일을 전송하는 2단계 방식입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 Drive 업로드용 `WORKS_DRIVE_OAUTH_*`는 서로 다른 앱/키로 관리합니다. token 우선순위는 `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 서비스 계정 JWT fallback 순서입니다. 운영에서는 Drive API 권한과 `file` scope 위임 정책을 먼저 확인해야 합니다.
#### 복구 계획과 복구 실행
```bash
# 복구 전 계획 확인
make restore-plan BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
CONFIRM_RESTORE=baron-sso
# 복구 실행
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
CONFIRM_RESTORE=baron-sso
# .tar.zst archive를 직접 복구 입력으로 사용
make restore DUMP_FILE=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ.tar.zst \
RESTORE_SERVICES=all \
CONFIRM_RESTORE=baron-sso
# report 경로를 명시
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
CONFIRM_RESTORE=baron-sso \
RESTORE_REPORT=reports/restore/baron-sso-restore-report.json
# 복구 후 기본 검증
make restore-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
```
복구는 반드시 빈 volume 또는 restore 전용 stack에서 수행하는 것을 기본 정책으로 합니다. `make restore`는 `BACKUP` 또는 `DUMP_FILE` 중 하나와 `CONFIRM_RESTORE=baron-sso`가 없으면 실패하고, 기본적으로 non-empty Postgres 대상에는 복구하지 않습니다. 승인된 restore rehearsal에서만 `ALLOW_NON_EMPTY_RESTORE=true`를 사용하세요. `DUMP_FILE=.tar.zst` 해제도 backup-tools 컨테이너에서 수행하므로 호스트 `zstd` 설치에 의존하지 않습니다.
`make restore`는 복구 report를 JSON과 Markdown으로 남깁니다. `BACKUP` 디렉터리 입력의 기본 JSON report는 `<BACKUP>/reports/restore-report.json`이고, `DUMP_FILE` archive 입력의 기본 JSON report는 `reports/restore/<archive-name>-restore-report.json`입니다. 같은 경로에 `.md` 확장자의 Markdown 요약도 함께 생성됩니다. `RESTORE_REPORT`로 직접 지정할 수 있습니다. report에는 입력 archive, 복구 서비스, checksum 검증 상태, 복구 후 대상 row count 비교 결과가 기록됩니다.
`config` 복구는 운영 파일을 직접 덮어쓰지 않고 `config-restored/`에 풀어 수동 검토하도록 합니다. migration은 자동 실행하지 않으며, Ory Stack과 backend 기동 후 super admin login, 대표 OIDC login, WORKS comparison dry-run을 통과하기 전까지 WORKS relay를 자동 재개하지 않습니다.
#### 백업/복구 범위
필수 백업 대상:
- Baron Postgres: users, tenants, user_login_ids, user_groups, RP metadata, WORKS mapping/outbox 등
- Ory Postgres: Kratos identity/credentials/session, Hydra client/consent/token state, Keto relation tuple
- Baron ClickHouse: 감사 로그와 RP usage event
- Ory ClickHouse: Oathkeeper/Ory 계열 접근 로그
- 설정 snapshot: `.env` redacted copy, generated Ory config, gateway, compose 파일
기본 제외 대상:
- Redis: pending login, short code, cache 등 휘발성 데이터이므로 복구 후 재수렴 대상으로 봅니다.
- 프론트 빌드 산출물: 소스와 이미지 태그로 재생성합니다.
- coverage, reports, test-results 같은 로컬 개발 산출물
상세 설계와 운영 정책은 `docs/backup-restore-design.md`를 기준으로 유지합니다.
### MCP 서버 (Hydra/Kratos/Keto)
MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다.
프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요.
@@ -463,11 +766,12 @@ KETO_WRITE_URL = "http://keto:4467"
```
## 🌐 i18n 구조 (간략)
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다.
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다.
- **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 현재 `userfront`와 전역 i18n 검증 기준 리소스입니다.
- **Common locales**: `common/locales/template.toml`, `common/locales/ko.toml`, `common/locales/en.toml``ui.common.*`, `msg.common.*` 같은 React 공통 문구 레이어입니다.
- **React(Admin/Dev/Org)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`, `orgfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`를 사용하며 `common locale -> app locale override` 순서로 TOML을 `?raw` 로드합니다.
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml``tools/i18n-scanner/gen-flutter-i18n.js``userfront/lib/i18n_data.dart`에 사전 생성합니다.
- **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다.
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다.
- **검증**: `node tools/i18n-scanner/index.js` `root locales``common/locales` 코드-키-로케일 동기화 상태를 함께 점검합니다.
## 🧪 Code Check CI
워크플로우 파일: `.gitea/workflows/code_check.yml`

View File

@@ -14,7 +14,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
- Descope SDK Integration (Enchanted Link, Magic Link)
### 2. Backend (Go Fiber)
- **Language**: Go 1.25+
- **Language**: Go 1.26.2+
- **Framework**: Fiber v2.25+
- **Database**:
- **ClickHouse**: Audit Logs (High performance ingestion)

View File

@@ -1,20 +1,40 @@
FROM node:lts
FROM node:lts AS build
WORKDIR /workspace
ENV CI=true
ENV ADMINFRONT_BUILD_OUT_DIR=/workspace/adminfront/dist
RUN corepack enable && corepack prepare pnpm@10.5.2 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY common ./common
COPY adminfront ./adminfront
ARG VITE_ADMIN_PUBLIC_URL
ARG VITE_OIDC_AUTHORITY
ARG VITE_OIDC_CLIENT_ID
ARG ORGFRONT_URL
ENV VITE_ADMIN_PUBLIC_URL=$VITE_ADMIN_PUBLIC_URL
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
ENV ORGFRONT_URL=$ORGFRONT_URL
RUN pnpm install --frozen-lockfile --ignore-scripts
WORKDIR /workspace/adminfront
RUN npm run build
FROM node:24-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
ENV FRONTEND_DIST_DIR=/app/dist
ENV PORT=5173
# 패키지 정보 복사 및 의존성 설치
COPY package*.json ./
RUN npm ci
COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs
COPY --from=build /workspace/adminfront/dist ./dist
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
RUN npm install -g serve
# 소스 코드 복사
COPY . .
# Vite 기본 포트
EXPOSE 5173
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
RUN chmod +x ./scripts/runtime-mode.sh
CMD ["sh", "./scripts/runtime-mode.sh"]
CMD ["node", "./serve_frontend_prod.mjs"]

View File

@@ -1,30 +1,7 @@
{
"$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
},
"root": true,
"extends": ["../common/config/biome.base.json"],
"files": {
"ignore": [
"dist",
"node_modules",
"tsconfig*.json",
"test-results",
"playwright-report"
]
"includes": [".vite"]
}
}

View File

@@ -1,474 +0,0 @@
> adminfront@0.0.0 i18n-scan
> cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js
ko.toml에 없는 키
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
- ui.admin.users.list.table.msg.admin.users.detail.no_history
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
- ui.admin.users.list.table.msg.common.copied
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.email
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
- ui.admin.users.list.table.ui.admin.users.detail.history_title
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
- ui.admin.users.list.table.ui.admin.users.detail.password_done
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
- ui.admin.users.list.table.ui.common.generate
- ui.admin.users.list.table.ui.common.status.blocked
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
- ui.admin.users.list.table.ui.dev.clients.registry.description
- ui.admin.users.list.table.ui.dev.clients.scopes.email
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
- ui.admin.users.list.table.ui.dev.session.refresh
- ui.admin.users.list.table.ui.dev.session.refreshing
en.toml에 없는 키
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
- ui.admin.users.list.table.msg.admin.users.detail.no_history
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
- ui.admin.users.list.table.msg.common.copied
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.email
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
- ui.admin.users.list.table.ui.admin.users.detail.history_title
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
- ui.admin.users.list.table.ui.admin.users.detail.password_done
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
- ui.admin.users.list.table.ui.common.generate
- ui.admin.users.list.table.ui.common.status.blocked
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
- ui.admin.users.list.table.ui.dev.clients.registry.description
- ui.admin.users.list.table.ui.dev.clients.scopes.email
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
- ui.admin.users.list.table.ui.dev.session.refresh
- ui.admin.users.list.table.ui.dev.session.refreshing
template.toml에 없는 코드 사용 키
- msg.admin.users.detail.history_desc
- msg.admin.users.detail.no_history
- msg.admin.users.detail.no_tenants
- msg.admin.users.detail.reset_auto_desc
- msg.admin.users.detail.security_desc
- msg.admin.users.detail.tenant_slug_help
- msg.admin.users.detail.tenants_desc
- msg.common.copied
- msg.dev.clients.general.public_key.allowed_algorithms_tooltip
- msg.dev.clients.general.public_key.cache.missing_algorithm_badge
- msg.dev.clients.general.public_key.cache.missing_algorithm_reason
- msg.dev.clients.general.public_key.cache.missing_algorithms_help
- msg.dev.clients.general.public_key.cache.missing_algorithms_title
- msg.dev.clients.general.public_key.cache.parsed_keys_empty
- msg.dev.clients.general.public_key.cache.parsed_keys_help
- msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
- msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
- msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
- msg.dev.clients.general.public_key.cache_empty
- msg.dev.clients.general.public_key.cache_help
- msg.dev.clients.general.public_key.cache_refresh_failed
- msg.dev.clients.general.public_key.cache_refreshed
- msg.dev.clients.general.public_key.cache_revoke_confirm
- msg.dev.clients.general.public_key.cache_revoke_failed
- msg.dev.clients.general.public_key.cache_revoked
- msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
- msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
- ui.admin.users.create.form.is_login_id
- ui.admin.users.detail.form.email
- ui.admin.users.detail.form.is_login_id
- ui.admin.users.detail.form.role_rp_admin
- ui.admin.users.detail.form.tenant_slug
- ui.admin.users.detail.generate_button
- ui.admin.users.detail.history_title
- ui.admin.users.detail.manual_confirm
- ui.admin.users.detail.manual_password
- ui.admin.users.detail.password_done
- ui.admin.users.detail.reset_auto
- ui.admin.users.detail.reset_execute
- ui.admin.users.detail.reset_manual
- ui.admin.users.detail.save_tenants
- ui.admin.users.detail.tabs.info
- ui.admin.users.detail.tabs.security
- ui.admin.users.detail.tabs.tenants
- ui.admin.users.detail.updated_at
- ui.dev.clients.general.public_key.allowed_algorithms_info
- ui.dev.clients.general.public_key.cache.cached_at
- ui.dev.clients.general.public_key.cache.error
- ui.dev.clients.general.public_key.cache.expires_at
- ui.dev.clients.general.public_key.cache.failures
- ui.dev.clients.general.public_key.cache.kids
- ui.dev.clients.general.public_key.cache.last_checked_at
- ui.dev.clients.general.public_key.cache.last_success
- ui.dev.clients.general.public_key.cache.parsed_key_n
- ui.dev.clients.general.public_key.cache.parsed_keys
- ui.dev.clients.general.public_key.cache.status
- ui.dev.clients.general.public_key.cache.title
- ui.dev.clients.general.public_key.cache.uri
- ui.dev.clients.general.public_key.revoke_cache
코드에서 사용되지 않는 키
- err.backend.authorization_pending
- err.backend.bad_request
- err.backend.conflict
- err.backend.expired_token
- err.backend.forbidden
- err.backend.internal_error
- err.backend.invalid_code
- err.backend.invalid_or_expired_code
- err.backend.invalid_session
- err.backend.invalid_session_reference
- err.backend.not_found
- err.backend.not_supported
- err.backend.password_or_email_mismatch
- err.backend.rate_limited
- err.backend.service_unavailable
- err.backend.slow_down
- msg.admin.groups.create.description
- msg.admin.groups.create.title
- msg.admin.groups.list.import_error
- msg.admin.groups.list.import_success
- msg.admin.header.subtitle
- msg.admin.idp_env_prod
- msg.admin.notice.idp_policy
- msg.admin.notice.scope
- msg.admin.overview.idp_fallback
- msg.admin.overview.idp_primary
- msg.admin.overview.playbook.description
- msg.admin.overview.playbook.idp_body
- msg.admin.overview.playbook.idp_title
- msg.admin.overview.playbook.tenant_body
- msg.admin.overview.playbook.tenant_title
- msg.admin.overview.quick_links.description
- msg.admin.overview.summary.audit_events_24h
- msg.admin.overview.summary.oidc_clients
- msg.admin.overview.summary.policy_gate
- msg.admin.overview.summary.total_tenants
- msg.admin.scope_admin
- msg.admin.session_ttl
- msg.admin.tenant_headers
- msg.admin.users.create.form.login_id_help
- msg.admin.users.detail.delete_error
- msg.admin.users.detail.password_generated_help
- msg.admin.users.detail.reset_password_confirm
- msg.admin.users.detail.security.password_hint
- msg.admin.users.detail.update_success
- msg.common.copied_to_clipboard
- msg.dev.audit.forbidden
- msg.dev.clients.general.public_key.auth_method_client_secret_basic_help
- msg.dev.clients.general.public_key.auth_method_none_help
- msg.dev.clients.general.public_key.auth_method_private_key_jwt_help
- msg.dev.clients.general.public_key.guide_example
- msg.dev.clients.general.public_key.guide_intro
- msg.dev.clients.general.public_key.guide_step_1
- msg.dev.clients.general.public_key.guide_step_2
- msg.dev.clients.general.public_key.guide_step_3
- msg.dev.clients.general.public_key.jwks_inline_help
- msg.dev.clients.general.public_key.request_object_alg_help
- msg.dev.clients.general.public_key.source_help
- msg.dev.clients.general.public_key.validation.headless_requires_alg
- msg.dev.clients.general.public_key.validation.headless_requires_private_key_jwt
- msg.dev.clients.general.public_key.validation.headless_requires_public_key
- msg.dev.clients.general.public_key.validation.invalid_jwks_inline
- msg.dev.clients.general.public_key.validation.missing_jwks_inline
- msg.dev.clients.general.public_key.validation.private_key_jwt_requires_public_key
- msg.userfront.signup.privacy_full
- msg.userfront.signup.tos_full
- non.existent.key
- test.key
- ui.admin.api_keys.list.breadcrumb.list
- ui.admin.api_keys.list.breadcrumb.section
- ui.admin.audit.breadcrumb.logs
- ui.admin.audit.breadcrumb.section
- ui.admin.groups.import_csv
- ui.admin.overview.kicker
- ui.admin.overview.playbook.title
- ui.admin.overview.quick_links.add_tenant
- ui.admin.overview.quick_links.api_key_management
- ui.admin.overview.quick_links.user_management
- ui.admin.overview.quick_links.view_audit_logs
- ui.admin.tenants.breadcrumb.list
- ui.admin.tenants.breadcrumb.section
- ui.admin.tenants.create.breadcrumb.action
- ui.admin.tenants.create.breadcrumb.section
- ui.admin.tenants.detail.breadcrumb_list
- ui.admin.tenants.detail.title
- ui.admin.users.create.breadcrumb.new
- ui.admin.users.create.breadcrumb.section
- ui.admin.users.create.form.login_id
- ui.admin.users.create.form.login_id_placeholder
- ui.admin.users.detail.breadcrumb.section
- ui.admin.users.detail.contact_title
- ui.admin.users.detail.form.department_placeholder
- ui.admin.users.detail.form.job_title_placeholder
- ui.admin.users.detail.form.login_id
- ui.admin.users.detail.form.login_id_placeholder
- ui.admin.users.detail.form.name_placeholder
- ui.admin.users.detail.form.phone_placeholder
- ui.admin.users.detail.form.position_placeholder
- ui.admin.users.detail.form.status_active
- ui.admin.users.detail.form.status_inactive
- ui.admin.users.detail.generate_password
- ui.admin.users.detail.password_mode_generated
- ui.admin.users.detail.password_mode_manual
- ui.admin.users.detail.password_result_title
- ui.admin.users.detail.reset_password_apply
- ui.admin.users.detail.security.password
- ui.admin.users.detail.security.password_placeholder
- ui.admin.users.detail.security.title
- ui.admin.users.detail.status_title
- ui.admin.users.detail.tenants_section.additional
- ui.admin.users.detail.tenants_section.primary
- ui.admin.users.detail.tenants_section.title
- ui.admin.users.detail.title
- ui.admin.users.detail.toggle_password_visibility
- ui.admin.users.list.breadcrumb.list
- ui.admin.users.list.breadcrumb.section
- ui.admin.users.list.empty
- ui.admin.users.list.fetch_error
- ui.admin.users.list.registry.count
- ui.admin.users.list.subtitle
- ui.admin.users.list.table.login_id
- ui.admin.users.list.table.msg.admin.users.detail.history_desc
- ui.admin.users.list.table.msg.admin.users.detail.no_history
- ui.admin.users.list.table.msg.admin.users.detail.no_tenants
- ui.admin.users.list.table.msg.admin.users.detail.reset_auto_desc
- ui.admin.users.list.table.msg.admin.users.detail.security_desc
- ui.admin.users.list.table.msg.admin.users.detail.tenant_slug_help
- ui.admin.users.list.table.msg.admin.users.detail.tenants_desc
- ui.admin.users.list.table.msg.common.copied
- ui.admin.users.list.table.msg.dev.clients.general.public_key.allowed_algorithms_tooltip
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_badge
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.missing_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.parsed_keys_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithm_reason
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache.unsupported_algorithms_title
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_empty
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_help
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refresh_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_refreshed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_confirm
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoke_failed
- ui.admin.users.list.table.msg.dev.clients.general.public_key.cache_revoked
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.missing_parsed_algorithms
- ui.admin.users.list.table.msg.dev.clients.general.public_key.validation.unsupported_parsed_algorithms
- ui.admin.users.list.table.ui.admin.users.create.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.email
- ui.admin.users.list.table.ui.admin.users.detail.form.is_login_id
- ui.admin.users.list.table.ui.admin.users.detail.form.role_rp_admin
- ui.admin.users.list.table.ui.admin.users.detail.form.tenant_slug
- ui.admin.users.list.table.ui.admin.users.detail.generate_button
- ui.admin.users.list.table.ui.admin.users.detail.history_title
- ui.admin.users.list.table.ui.admin.users.detail.manual_confirm
- ui.admin.users.list.table.ui.admin.users.detail.manual_password
- ui.admin.users.list.table.ui.admin.users.detail.password_done
- ui.admin.users.list.table.ui.admin.users.detail.reset_auto
- ui.admin.users.list.table.ui.admin.users.detail.reset_execute
- ui.admin.users.list.table.ui.admin.users.detail.reset_manual
- ui.admin.users.list.table.ui.admin.users.detail.save_tenants
- ui.admin.users.list.table.ui.admin.users.detail.tabs.info
- ui.admin.users.list.table.ui.admin.users.detail.tabs.security
- ui.admin.users.list.table.ui.admin.users.detail.tabs.tenants
- ui.admin.users.list.table.ui.admin.users.detail.updated_at
- ui.admin.users.list.table.ui.common.generate
- ui.admin.users.list.table.ui.common.status.blocked
- ui.admin.users.list.table.ui.dev.clients.general.public_key.allowed_algorithms_info
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_client_secret_basic
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_none
- ui.admin.users.list.table.ui.dev.clients.general.public_key.auth_method_private_key_jwt
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.cached_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.error
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.expires_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.failures
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.kids
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_checked_at
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.last_success
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_key_n
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.parsed_keys
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.status
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.title
- ui.admin.users.list.table.ui.dev.clients.general.public_key.cache.uri
- ui.admin.users.list.table.ui.dev.clients.general.public_key.guide_toggle
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_disabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.headless_enabled
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline
- ui.admin.users.list.table.ui.dev.clients.general.public_key.jwks_inline_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg
- ui.admin.users.list.table.ui.dev.clients.general.public_key.request_object_alg_placeholder
- ui.admin.users.list.table.ui.dev.clients.general.public_key.revoke_cache
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source
- ui.admin.users.list.table.ui.dev.clients.general.public_key.source_uri
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable
- ui.admin.users.list.table.ui.dev.clients.general.security.trusted_rp_enable_help
- ui.admin.users.list.table.ui.dev.clients.help.docs_body
- ui.admin.users.list.table.ui.dev.clients.help.subtitle
- ui.admin.users.list.table.ui.dev.clients.registry.description
- ui.admin.users.list.table.ui.dev.clients.scopes.email
- ui.admin.users.list.table.ui.dev.clients.scopes.openid
- ui.admin.users.list.table.ui.dev.clients.scopes.profile
- ui.admin.users.list.table.ui.dev.session.refresh
- ui.admin.users.list.table.ui.dev.session.refreshing
- ui.common.generate
- ui.common.status.blocked
- ui.dev.clients.general.public_key.auth_method
- ui.dev.clients.general.public_key.auth_method_client_secret_basic
- ui.dev.clients.general.public_key.auth_method_none
- ui.dev.clients.general.public_key.auth_method_private_key_jwt
- ui.dev.clients.general.public_key.guide_toggle
- ui.dev.clients.general.public_key.headless_disabled
- ui.dev.clients.general.public_key.headless_enabled
- ui.dev.clients.general.public_key.jwks_inline
- ui.dev.clients.general.public_key.jwks_inline_placeholder
- ui.dev.clients.general.public_key.request_object_alg
- ui.dev.clients.general.public_key.request_object_alg_placeholder
- ui.dev.clients.general.public_key.source
- ui.dev.clients.general.public_key.source_uri
- ui.dev.clients.general.security.trusted_rp_enable
- ui.dev.clients.general.security.trusted_rp_enable_help
- ui.dev.clients.help.docs_body
- ui.dev.clients.help.subtitle
- ui.dev.clients.registry.description
- ui.dev.clients.scopes.email
- ui.dev.clients.scopes.openid
- ui.dev.clients.scopes.profile
- ui.dev.session.refresh
- ui.dev.session.refreshing
요약
- [Sync Error] ko.toml 누락 키 84개
- [Sync Error] en.toml 누락 키 84개
- [Missing Key] template.toml 누락 키 59개

File diff suppressed because it is too large Load Diff

View File

@@ -13,52 +13,56 @@
"lint:fix": "biome check . --write",
"format": "biome format . --write",
"preview": "vite preview",
"test": "npx playwright test",
"test:unit": "vitest run",
"test:ui": "npx playwright test --ui",
"test": "playwright test",
"test:coverage": "vitest run --coverage --bail 1",
"test:unit": "vitest run --bail 1",
"test:ui": "playwright test --ui",
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-scroll-area": "^1.1.2",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@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",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10",
"@tanstack/react-virtual": "^3.13.24",
"axios": "^1.16.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.28.2",
"tailwind-merge": "^3.4.0",
"zod": "^3.24.1"
"lucide-react": "^1.14.0",
"oidc-client-ts": "^3.5.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hook-form": "^7.75.0",
"react-oidc-context": "^3.3.1",
"react-router-dom": "^7.15.0",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@playwright/test": "^1.58.0",
"@biomejs/biome": "2.4.16",
"@playwright/test": "^1.60.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/node": "^25.7.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.23",
"@vitest/coverage-v8": "4.1.6",
"autoprefixer": "^10.5.0",
"jsdom": "^28.1.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.14",
"playwright": "1.60.0",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"vite": "^8.0.3",
"vitest": "^4.0.18"
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vitest": "^4.1.6"
}
}

View File

@@ -1,5 +1,12 @@
import { createRequire } from "node:module";
import { defineConfig, devices } from "@playwright/test";
const require = createRequire(import.meta.url);
const { shouldIncludeWebKit } =
require("../scripts/playwrightHostDeps.cjs") as {
shouldIncludeWebKit: () => boolean;
};
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
@@ -7,6 +14,7 @@ const port = Number.parseInt(process.env.PORT ?? "5173", 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI && !process.env.PORT;
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
/**
* Read environment variables from file.
@@ -49,7 +57,12 @@ export default defineConfig({
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
use: {
...devices["Desktop Chrome"],
launchOptions: chromiumExecutablePath
? { executablePath: chromiumExecutablePath }
: undefined,
},
},
{
@@ -57,10 +70,14 @@ export default defineConfig({
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
...(shouldIncludeWebKit()
? [
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
]
: []),
],
/* Run your local dev server before starting the tests */
@@ -68,10 +85,10 @@ export default defineConfig({
? undefined
: {
command: process.env.CI
? `npm run build && npm run preview -- --host 127.0.0.1 --port ${port}`
: `npm run dev -- --host 127.0.0.1 --port ${port}`,
url: defaultBaseUrl,
? `pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort`
: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`,
url: `http://127.0.0.1:${port}`,
reuseExistingServer,
timeout: 120 * 1000,
timeout: 180 * 1000,
},
});

3722
adminfront/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.5556 0H6.44444C2.88528 0 0 2.88528 0 6.44444V23.5556C0 27.1147 2.88528 30 6.44444 30H23.5556C27.1147 30 30 27.1147 30 23.5556V6.44444C30 2.88528 27.1147 0 23.5556 0Z" fill="white"/>
<path d="M9.01667 23.2633H12.3633C12.4481 23.2637 12.5307 23.2363 12.5985 23.1853C12.6663 23.1344 12.7156 23.0627 12.7389 22.9811L17.0489 8.12111C17.0658 8.06285 17.0689 8.00146 17.058 7.9418C17.047 7.88214 17.0224 7.82583 16.986 7.77733C16.9495 7.72883 16.9023 7.68947 16.8481 7.66236C16.7938 7.63525 16.734 7.62112 16.6733 7.62111H13.3267C13.2419 7.62113 13.1595 7.64866 13.0918 7.69955C13.0241 7.75045 12.9747 7.82196 12.9511 7.90333L8.64222 22.7633C8.62512 22.8215 8.62182 22.8829 8.63258 22.9425C8.64334 23.0022 8.66787 23.0586 8.70422 23.1071C8.74057 23.1556 8.78773 23.195 8.84197 23.2222C8.89621 23.2493 8.95603 23.2634 9.01667 23.2633Z" fill="#028B3A"/>
<path d="M18.0122 23.2633H21.3589C21.4436 23.2633 21.526 23.2358 21.5938 23.1849C21.6615 23.134 21.7109 23.0625 21.7344 22.9811L26.0433 8.12111C26.0602 8.06285 26.0633 8.00146 26.0524 7.9418C26.0415 7.88214 26.0168 7.82583 25.9804 7.77733C25.944 7.72883 25.8968 7.68947 25.8425 7.66236C25.7883 7.63525 25.7284 7.62112 25.6678 7.62111H22.3211C22.2364 7.62131 22.1541 7.64891 22.0864 7.69977C22.0187 7.75064 21.9693 7.82205 21.9456 7.90333L17.6367 22.7633C17.6195 22.8216 17.6163 22.8831 17.6271 22.9428C17.6379 23.0026 17.6625 23.059 17.699 23.1076C17.7355 23.1561 17.7828 23.1955 17.8372 23.2225C17.8915 23.2496 17.9515 23.2635 18.0122 23.2633Z" fill="#88E518"/>
<path d="M12.3633 23.2633H8.64222C8.55741 23.2637 8.47481 23.2363 8.40701 23.1853C8.33921 23.1344 8.28993 23.0627 8.26666 22.9811L3.95666 8.12111C3.93977 8.06285 3.93667 8.00146 3.94759 7.9418C3.95851 7.88214 3.98316 7.82583 4.01959 7.77733C4.05602 7.72883 4.10322 7.68947 4.15748 7.66236C4.21174 7.63525 4.27156 7.62112 4.33222 7.62111H8.05444C8.13911 7.62131 8.22145 7.64891 8.28915 7.69977C8.35684 7.75064 8.40625 7.82205 8.43 7.90333L12.7389 22.7633C12.756 22.8216 12.7593 22.8831 12.7485 22.9428C12.7377 23.0026 12.713 23.059 12.6765 23.1076C12.6401 23.1561 12.5928 23.1955 12.5384 23.2225C12.484 23.2496 12.4241 23.2635 12.3633 23.2633Z" fill="#7EE3A1"/>
<path d="M21.3589 23.2633H17.6367C17.5519 23.2637 17.4693 23.2363 17.4015 23.1853C17.3337 23.1344 17.2844 23.0627 17.2611 22.9811L12.9511 8.12111C12.9342 8.06285 12.9311 8.00146 12.942 7.9418C12.953 7.88214 12.9776 7.82583 13.014 7.77733C13.0505 7.72883 13.0977 7.68947 13.1519 7.66236C13.2062 7.63525 13.266 7.62112 13.3267 7.62111H17.0489C17.1336 7.62113 17.216 7.64866 17.2838 7.69955C17.3515 7.75045 17.4009 7.82196 17.4244 7.90333L21.7344 22.7633C21.7513 22.8216 21.7544 22.883 21.7435 22.9426C21.7326 23.0023 21.7079 23.0586 21.6715 23.1071C21.6351 23.1556 21.5879 23.195 21.5336 23.2221C21.4794 23.2492 21.4195 23.2633 21.3589 23.2633Z" fill="#03C75A"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -3,6 +3,19 @@ set -eu
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
if [ -z "${VITE_ADMIN_PUBLIC_URL:-}" ] && [ -n "${ADMINFRONT_URL:-}" ]; then
export VITE_ADMIN_PUBLIC_URL="$ADMINFRONT_URL"
fi
if [ -z "${VITE_ADMIN_PUBLIC_URL:-}" ] && [ -n "${ADMINFRONT_CALLBACK_URLS:-}" ]; then
first_admin_callback="${ADMINFRONT_CALLBACK_URLS%%,*}"
case "$first_admin_callback" in
http://*/auth/callback | https://*/auth/callback)
export VITE_ADMIN_PUBLIC_URL="${first_admin_callback%/auth/callback}"
;;
esac
fi
case "$app_env" in
production|prod|stage|staging)
mode="production"
@@ -12,14 +25,119 @@ case "$app_env" in
;;
esac
if [ "${1:-}" = "--print-admin-public-url" ]; then
printf '%s\n' "${VITE_ADMIN_PUBLIC_URL:-}"
exit 0
fi
if [ "${1:-}" = "--print-mode" ]; then
printf '%s\n' "$mode"
exit 0
fi
ensure_frontend_dependencies() {
APP_PACKAGE_NAME="adminfront"
# Detect workspace root
if [ -f "/workspace/pnpm-workspace.yaml" ]; then
WORKSPACE_ROOT="/workspace"
elif [ -f "../../pnpm-workspace.yaml" ]; then
WORKSPACE_ROOT="../.."
else
WORKSPACE_ROOT=""
fi
# Manage dependencies from the real workspace tree if possible, otherwise use current dir.
if [ -n "$WORKSPACE_ROOT" ]; then
WORKSPACE_DIR="$WORKSPACE_ROOT"
LOCK_FILE="$WORKSPACE_ROOT/pnpm-lock.yaml"
COMMON_PACKAGE_FILE="$WORKSPACE_ROOT/common/package.json"
INSTALL_CMD="cd $WORKSPACE_ROOT && CI=true pnpm install --filter ${APP_PACKAGE_NAME}... --frozen-lockfile --ignore-scripts"
elif [ -f "pnpm-lock.yaml" ]; then
WORKSPACE_DIR="."
LOCK_FILE="pnpm-lock.yaml"
COMMON_PACKAGE_FILE="/workspace/common/package.json"
INSTALL_CMD="CI=true pnpm install --frozen-lockfile --ignore-scripts"
else
WORKSPACE_DIR="."
LOCK_FILE="package-lock.json"
COMMON_PACKAGE_FILE="/workspace/common/package.json"
INSTALL_CMD="npm ci"
fi
if [ ! -f "$WORKSPACE_DIR/package.json" ]; then
return 0
fi
lock_mode=""
lock_file="$WORKSPACE_DIR/.baron-deps-install.lock"
acquire_install_lock() {
if command -v flock >/dev/null 2>&1; then
lock_mode="flock"
exec 9>"$lock_file"
flock 9
trap 'release_install_lock' EXIT INT TERM
return 0
fi
lock_mode="mkdir"
while ! mkdir "$lock_file" 2>/dev/null; do
sleep 1
done
trap 'release_install_lock' EXIT INT TERM
}
release_install_lock() {
trap - EXIT INT TERM
if [ "$lock_mode" = "flock" ]; then
flock -u 9 || true
exec 9>&-
return 0
fi
if [ "$lock_mode" = "mkdir" ]; then
rmdir "$lock_file" >/dev/null 2>&1 || true
fi
}
if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
else
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
fi
deps_stamp="node_modules/.baron-deps-hash"
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
if [ "$installed_hash" != "$deps_hash" ]; then
echo "Installing frontend dependencies..."
acquire_install_lock
if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
else
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
fi
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
if [ "$installed_hash" = "$deps_hash" ]; then
release_install_lock
return 0
fi
eval "$INSTALL_CMD"
mkdir -p node_modules
printf '%s\n' "$deps_hash" > "$deps_stamp"
release_install_lock
fi
}
ensure_frontend_dependencies
if [ "$mode" = "production" ]; then
echo "Running in production mode with Vite preview..."
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
echo "Running in production mode with custom static server..."
export ADMINFRONT_BUILD_OUT_DIR="${ADMINFRONT_BUILD_OUT_DIR:-/tmp/baron-sso-adminfront-dist}"
exec sh -c "npm run build && node ./scripts/serve-prod.mjs"
fi
echo "Running in development mode..."

View File

@@ -0,0 +1,160 @@
import { readFile, stat } from "node:fs/promises";
import { createServer } from "node:http";
import { extname, join, normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const _rootDir = fileURLToPath(new URL("..", import.meta.url));
const distDir = resolve(
process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist",
);
const host = process.env.HOST ?? "0.0.0.0";
const port = Number(process.env.PORT ?? process.env.ADMINFRONT_PORT ?? 5173);
const backendTarget = new URL(
process.env.API_PROXY_TARGET || "http://localhost:3000",
);
const contentTypes = {
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".map": "application/json; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".svg": "image/svg+xml",
};
function getContentType(filePath) {
return (
contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"
);
}
function sendJson(res, statusCode, body) {
res.writeHead(statusCode, {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(JSON.stringify(body));
}
function toSafePath(pathname) {
const decoded = decodeURIComponent(pathname);
const relative = decoded.replace(/^\/+/, "");
const safe = normalize(relative).replace(/^(\.\.(?:[\\/]|$))+/, "");
return join(distDir, safe);
}
async function tryReadFile(filePath) {
try {
return await readFile(filePath);
} catch {
return null;
}
}
async function proxyToBackend(req, res, pathname, search) {
const target = new URL(pathname + search, backendTarget);
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (!value) continue;
if (key === "host" || key === "content-length" || key === "connection") {
continue;
}
if (Array.isArray(value)) {
headers.set(key, value.join(", "));
continue;
}
headers.set(key, value);
}
const hasBody = !["GET", "HEAD"].includes(req.method ?? "GET");
const response = await fetch(target, {
method: req.method,
headers,
body: hasBody ? req : undefined,
duplex: hasBody ? "half" : undefined,
});
const responseHeaders = new Headers(response.headers);
responseHeaders.delete("content-length");
responseHeaders.delete("transfer-encoding");
responseHeaders.delete("connection");
res.writeHead(response.status, Object.fromEntries(responseHeaders.entries()));
if (req.method === "HEAD") {
res.end();
return;
}
const arrayBuffer = await response.arrayBuffer();
res.end(Buffer.from(arrayBuffer));
}
async function serveStatic(req, res, pathname) {
const indexPath = join(distDir, "index.html");
const filePath = toSafePath(pathname);
let resolvedPath = filePath;
try {
const fileStat = await stat(resolvedPath);
if (fileStat.isDirectory()) {
resolvedPath = join(resolvedPath, "index.html");
}
} catch {
resolvedPath = indexPath;
}
let body = await tryReadFile(resolvedPath);
if (!body) {
body = await tryReadFile(indexPath);
resolvedPath = indexPath;
}
if (!body) {
sendJson(res, 500, { error: "dist_not_found" });
return;
}
res.writeHead(200, {
"Content-Type": getContentType(resolvedPath),
"Cache-Control": resolvedPath.endsWith("index.html")
? "no-cache"
: "public, max-age=31536000, immutable",
});
if (req.method === "HEAD") {
res.end();
return;
}
res.end(body);
}
createServer(async (req, res) => {
try {
const url = new URL(
req.url ?? "/",
`http://${req.headers.host ?? "localhost"}`,
);
const { pathname, search } = url;
if (pathname === "/api" || pathname.startsWith("/api/")) {
await proxyToBackend(req, res, pathname, search);
return;
}
const normalizedPath = pathname === "/" ? "/index.html" : pathname;
await serveStatic(req, res, normalizedPath);
} catch (error) {
sendJson(res, 500, {
error: "internal_server_error",
message: error instanceof Error ? error.message : String(error),
});
}
}).listen(port, host, () => {
console.log(
`Adminfront production server listening on http://${host}:${port}`,
);
});

View File

@@ -0,0 +1,13 @@
id,name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync
038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,,,,
5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr,,,
9caf62e1-297d-4e8f-870b-61780998bbeb,삼안,COMPANY,hanmac-family,saman,네이버웍스 삼안 SAMAN_DOMAIN_ID, samaneng.com,,,
369c1843-56af-4344-9c21-0e01197ab861,한맥기술,COMPANY,hanmac-family,hanmac,네이버웍스 한맥 HANMAC_DOMAIN_ID, hanmaceng.co.kr,,,
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,brsw.kr,,,
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,hanmac-family,halla,네이버웍스 한라 HALLA_DOMAIN_ID,hallasanup.com,,,
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com,,,
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr,,,
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr,,,
4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,MH_manager,USER_GROUP,hanmac-family,mhd,맨아워 대시보드 권한 보유자그룹,,private,,no
e41adf79-3d15-4807-8303-afbdb0f2bab7,SW_uploader,USER_GROUP,hanmac-family,sw-uploader,소프트웨어 배포 권한 그룹,,private,,no
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,,
1 id name type parent_tenant_slug slug memo email_domain visibility org_unit_type worksmobile_sync
2 038326b6-954a-48a7-a85f-efd83f62b82a 한맥가족 COMPANY_GROUP hanmac-family 한맥가족 기본 루트 테넌트
3 5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee 총괄기획&기술개발센터 COMPANY hanmac-family gpdtdc 네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID baroncs.co.kr
4 9caf62e1-297d-4e8f-870b-61780998bbeb 삼안 COMPANY hanmac-family saman 네이버웍스 삼안 SAMAN_DOMAIN_ID samaneng.com
5 369c1843-56af-4344-9c21-0e01197ab861 한맥기술 COMPANY hanmac-family hanmac 네이버웍스 한맥 HANMAC_DOMAIN_ID hanmaceng.co.kr
6 96369f12-6b66-4b2a-a916-d1c99d326f02 바론그룹 COMPANY_GROUP hanmac-family baron-group 네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID brsw.kr
7 5a03efd2-e62f-4243-800d-58334bf48b2f 한라산업개발 COMPANY hanmac-family halla 네이버웍스 한라 HALLA_DOMAIN_ID hallasanup.com
8 c18a8284-0008-48aa-9cdf-9f47ab79a2a9 (주)장헌 COMPANY baron-group jangheon jangheon.com
9 b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 장헌산업 COMPANY baron-group jangheon-sanup jangheon.co.kr
10 e57cb22c-383e-4489-8c2f-0c5431917e86 (주)피티씨 COMPANY baron-group ptc pre-cast.co.kr
11 4d0f26b9-702c-4bc6-8996-46e9eedfdeb7 MH_manager USER_GROUP hanmac-family mhd 맨아워 대시보드 권한 보유자그룹 private no
12 e41adf79-3d15-4807-8303-afbdb0f2bab7 SW_uploader USER_GROUP hanmac-family sw-uploader 소프트웨어 배포 권한 그룹 private no
13 9607eb7b-04d2-42ab-80fe-780fe21c7e8f Personal PERSONAL personal 개인 사용자 기본 루트 테넌트

View File

@@ -1,11 +1,7 @@
import { QueryClient } from "@tanstack/react-query";
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: 1,
},
},
defaultOptions: queryClientDefaultOptions,
});

View File

@@ -0,0 +1,62 @@
import { matchRoutes } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { buildAdminAuthRedirectUris } from "../lib/authConfig";
import { adminRoutes } from "./routes";
describe("admin routes", () => {
it("accepts the auth callback path generated from the public admin URL", () => {
const { redirectUri } = buildAdminAuthRedirectUris(
"https://sadmin.hmac.kr",
);
const callbackPath = new URL(redirectUri).pathname;
const matches = matchRoutes(adminRoutes, callbackPath);
expect(callbackPath).toBe("/auth/callback");
expect(matches?.at(-1)?.route.path).toBe("/auth/callback");
});
it("registers the super-admin Ory SSOT system route", () => {
const matches = matchRoutes(adminRoutes, "/system/ory-ssot");
expect(matches?.at(-1)?.route.path).toBe("system/ory-ssot");
});
it("registers the super-admin data integrity management route", () => {
const matches = matchRoutes(adminRoutes, "/system/data-integrity");
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
});
it("routes global custom claim settings before user detail id matching", () => {
const matches = matchRoutes(adminRoutes, "/users/custom-claims");
const leafRoute = matches?.at(-1)?.route;
expect(leafRoute?.path).toBe("users/custom-claims");
expect(getRouteElementName(leafRoute?.element)).toBe(
"GlobalCustomClaimsPage",
);
});
it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
const rootRoute = adminRoutes.find((route) => route.path === "/");
const protectedShellRoute = rootRoute?.children?.[0];
expect(getRouteElementName(rootRoute?.element)).toBe("AuthGuard");
expect(getRouteElementName(protectedShellRoute?.element)).toBe("AppLayout");
expect(protectedShellRoute?.children?.at(0)?.index).toBe(true);
});
});
function getRouteElementName(element: unknown) {
if (
typeof element === "object" &&
element !== null &&
"type" in element &&
typeof element.type === "function"
) {
return element.type.name;
}
return undefined;
}

View File

@@ -1,64 +1,81 @@
import type { RouteObject } from "react-router-dom";
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 AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import UserProjectionPage from "../features/projections/UserProjectionPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
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 { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage";
import GlobalCustomClaimsPage from "../features/users/GlobalCustomClaimsPage";
import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
import { ADMIN_AUTH_CALLBACK_PATH } from "../lib/authConfig";
export const adminRoutes: RouteObject[] = [
{
path: "/login",
element: <LoginPage />,
},
{
path: ADMIN_AUTH_CALLBACK_PATH,
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AuthGuard />,
children: [
{
element: <AppLayout />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/custom-claims", element: <GlobalCustomClaimsPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
],
},
{
path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />,
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/ory-ssot", element: <UserProjectionPage /> },
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
],
},
],
},
];
export const router = createBrowserRouter(
[
{
path: "/login",
element: <LoginPage />,
},
{
path: "/auth/callback",
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AppLayout />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ 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: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
],
},
{
path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />,
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
],
},
],
adminRoutes,
// React Router v7 플래그는 Provider에서 적용합니다.
);

View File

@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import type * as React from "react";
import { fetchMe } from "../../lib/adminApi";
import { normalizeAdminRole } from "../../lib/roles";
interface RoleGuardProps {
children: React.ReactNode;
@@ -29,8 +30,10 @@ export function RoleGuard({
if (isLoading) return null;
const userRole = profile?.role || "user";
const hasAccess = roles.includes(userRole);
const userRole = normalizeAdminRole(profile?.role);
const hasAccess = roles
.map((role) => normalizeAdminRole(role))
.includes(userRole);
if (!hasAccess) {
return <>{fallback}</>;

View File

@@ -0,0 +1,32 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LanguageSelector from "./LanguageSelector";
vi.mock("../../lib/i18n", () => ({
t: (_key: string, fallback?: string) => fallback ?? "",
}));
describe("LanguageSelector", () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it("updates locale without reloading the page", () => {
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
window.localStorage.setItem("locale", "ko");
render(<LanguageSelector />);
fireEvent.change(screen.getByRole("combobox"), {
target: { value: "en" },
});
expect(window.localStorage.getItem("locale")).toBe("en");
expect(
dispatchSpy.mock.calls.some(
([event]) => event instanceof Event && event.type === "localechange",
),
).toBe(true);
});
});

View File

@@ -1,7 +1,7 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
import { t } from "../../lib/i18n";
const LOCALE_STORAGE_KEY = "locale";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
@@ -28,17 +28,33 @@ function resolveLocale(): Locale {
function LanguageSelector() {
const [locale, setLocale] = useState<Locale>(resolveLocale());
useEffect(() => {
const syncLocale = () => {
setLocale(resolveLocale());
};
window.addEventListener("localechange", syncLocale);
window.addEventListener("storage", syncLocale);
return () => {
window.removeEventListener("localechange", syncLocale);
window.removeEventListener("storage", syncLocale);
};
}, []);
const handleChange = (next: Locale) => {
if (next === locale) {
return;
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
setLocale(next);
window.location.reload();
window.dispatchEvent(new Event("localechange"));
};
return (
<select
id="admin-language-selector"
name="admin-language-selector"
value={locale}
onChange={(event) => handleChange(event.target.value as Locale)}
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"

View File

@@ -0,0 +1,34 @@
import { act, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import LocaleRefreshBoundary from "./LocaleRefreshBoundary";
let renderCount = 0;
function RenderCounter() {
renderCount += 1;
return <span>{renderCount}</span>;
}
describe("LocaleRefreshBoundary", () => {
beforeEach(() => {
window.localStorage.clear();
renderCount = 0;
});
it("re-renders children when locale changes", async () => {
render(
<LocaleRefreshBoundary>
<RenderCounter />
</LocaleRefreshBoundary>,
);
expect(screen.getByText("1")).toBeInTheDocument();
await act(async () => {
window.localStorage.setItem("locale", "en");
window.dispatchEvent(new Event("localechange"));
});
expect(screen.getByText("2")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,27 @@
import { Fragment, type ReactNode, useEffect, useState } from "react";
type LocaleRefreshBoundaryProps = {
children: ReactNode;
};
function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) {
const [localeVersion, setLocaleVersion] = useState(0);
useEffect(() => {
const syncLocale = () => {
setLocaleVersion((current) => current + 1);
};
window.addEventListener("localechange", syncLocale);
window.addEventListener("storage", syncLocale);
return () => {
window.removeEventListener("localechange", syncLocale);
window.removeEventListener("storage", syncLocale);
};
}, []);
return <Fragment key={localeVersion}>{children}</Fragment>;
}
export default LocaleRefreshBoundary;

View File

@@ -0,0 +1,186 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import AppLayout from "./AppLayout";
const authState = {
isAuthenticated: true,
isLoading: false,
user: {
access_token: "access-token",
expires_at: Math.floor(Date.now() / 1000) + 120,
profile: {
sub: "admin-1",
name: "Admin User",
email: "admin@example.com",
},
},
signinSilent: vi.fn(async () => undefined),
removeUser: vi.fn(),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({
id: "admin-1",
name: "Fetched Admin",
email: "fetched@example.com",
role: "super_admin",
tenantId: "tenant-1",
manageableTenants: [
{
id: "tenant-1",
name: "GPDTDC",
slug: "gpdtdc",
type: "COMPANY",
},
{
id: "tenant-2",
name: "기술연구팀",
slug: "gpdtdc-rnd",
type: "ORGANIZATION",
},
],
})),
}));
function renderLayout(entry = "/users") {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/" element={<AppLayout />}>
<Route path="users" element={<div>Users outlet</div>} />
<Route path="users/:id" element={<div>User detail outlet</div>} />
<Route
path="tenants/:tenantId"
element={<div>Tenant outlet</div>}
/>
<Route path="worksmobile" element={<div>Worksmobile outlet</div>} />
<Route path="login" element={<div>Login outlet</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin AppLayout", () => {
beforeEach(() => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
authState.isAuthenticated = true;
authState.isLoading = false;
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
authState.signinSilent.mockClear();
authState.removeUser.mockClear();
window.localStorage.clear();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders admin navigation, fetched profile, and outlet content", async () => {
renderLayout();
expect(await screen.findByText("Fetched Admin")).toBeInTheDocument();
expect(screen.getByText("Admin Control")).toBeInTheDocument();
expect(screen.getByText("Users outlet")).toBeInTheDocument();
expect(screen.getByText("Tenants")).toBeInTheDocument();
expect(screen.getByText("Org Chart")).toBeInTheDocument();
expect(screen.getByText("Worksmobile")).toBeInTheDocument();
expect(screen.getByText("Ory SSOT System")).toBeInTheDocument();
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
const navigation = screen.getByRole("navigation");
const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) =>
link.textContent?.trim(),
);
expect(navLabels).toEqual([
"Overview",
"Tenants",
"Org Chart",
"Worksmobile",
"Ory SSOT System",
"Data Integrity",
"Users",
"Auth Guard",
"API Keys",
"Audit Logs",
]);
const worksmobileIcon = screen.getByTestId("worksmobile-nav-icon");
expect(worksmobileIcon.tagName.toLowerCase()).toBe("svg");
expect(worksmobileIcon).toHaveAttribute("fill", "none");
expect(worksmobileIcon.querySelectorAll("path")).toHaveLength(4);
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
});
it("toggles the sidebar and persists the collapsed state", async () => {
renderLayout();
const collapseButton = await screen.findByRole("button", {
name: "사이드바 접기",
});
fireEvent.click(collapseButton);
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
"true",
);
expect(
screen.getByRole("button", { name: "사이드바 펼치기" }),
).toBeInTheDocument();
});
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
renderLayout();
const themeButton = await screen.findByRole("button", {
name: "테마 전환",
});
fireEvent.click(themeButton);
expect(document.documentElement.classList.contains("dark")).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
expect(screen.getByText("Manageable Tenants")).toBeInTheDocument();
const sessionSwitch = screen.getByRole("switch");
fireEvent.click(sessionSwitch);
expect(window.localStorage.getItem("baron_session_expiry_enabled")).toBe(
"false",
);
fireEvent.click(screen.getByText("기술연구팀"));
expect(await screen.findByText("Tenant outlet")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
fireEvent.click(screen.getAllByText("내 정보")[0]);
expect(await screen.findByText("User detail outlet")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
fireEvent.click(screen.getAllByText("Logout")[1]);
expect(window.confirm).toHaveBeenCalled();
expect(authState.removeUser).toHaveBeenCalled();
}, 10_000);
it("attempts silent renewal on user activity when session is near expiry", async () => {
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
renderLayout();
await screen.findByText("Fetched Admin");
fireEvent.keyDown(window, { key: "Tab" });
expect(authState.signinSilent).toHaveBeenCalled();
});
});

View File

@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import {
Building2,
ChevronDown,
Database,
Key,
KeyRound,
LayoutDashboard,
@@ -9,6 +10,7 @@ import {
Moon,
Network,
NotebookTabs,
ShieldCheck,
ShieldHalf,
Sun,
User as UserIcon,
@@ -18,29 +20,141 @@ import * as React from "react";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
AppSidebar,
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellSidebarCollapsed,
readShellTheme,
type ShellSidebarNavItem,
type ShellTranslator,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
writeShellSidebarCollapsed,
} from "../../../../common/shell";
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
import { fetchMe } from "../../lib/adminApi";
import { debugLog } from "../../lib/debugLog";
import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";
interface NavItem {
label: string;
to: string;
icon: React.ComponentType<{ size?: number | string }>;
isExternal?: boolean;
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const staticNavItems: ShellSidebarNavItem[] = [
{
labelKey: "ui.admin.nav.overview",
labelFallback: "Overview",
to: "/",
icon: LayoutDashboard,
end: true,
},
{
labelKey: "ui.admin.nav.users",
labelFallback: "Users",
to: "/users",
icon: Users,
},
{
labelKey: "ui.admin.nav.auth_guard",
labelFallback: "Auth Guard",
to: "/auth",
icon: KeyRound,
},
{
labelKey: "ui.admin.nav.api_keys",
labelFallback: "API Keys",
to: "/api-keys",
icon: Key,
},
{
labelKey: "ui.admin.nav.audit_logs",
labelFallback: "Audit Logs",
to: "/audit-logs",
icon: NotebookTabs,
},
];
type SessionStatusProps = {
expiresAtSec?: number | null;
t: ShellTranslator;
};
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
}
const staticNavItems: NavItem[] = [
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard },
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
function SessionStatusBadge(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return (
<span
className={[
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionStatus.text}
</span>
);
}
function SessionStatusText(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return <>{sessionStatus.text}</>;
}
function LineWorksNavIcon({ size = 18 }: { size?: number | string }) {
const iconSize = typeof size === "number" ? size : Number.parseFloat(size);
return (
<svg
aria-hidden="true"
data-testid="worksmobile-nav-icon"
width={Number.isFinite(iconSize) ? iconSize : size}
height={Number.isFinite(iconSize) ? iconSize : size}
viewBox="0 0 30 30"
fill="none"
className="shrink-0 text-current"
>
<path
d="M9.01667 23.2633H12.3633C12.4481 23.2637 12.5307 23.2363 12.5985 23.1853C12.6663 23.1344 12.7156 23.0627 12.7389 22.9811L17.0489 8.12111C17.0658 8.06285 17.0689 8.00146 17.058 7.9418C17.047 7.88214 17.0224 7.82583 16.986 7.77733C16.9495 7.72883 16.9023 7.68947 16.8481 7.66236C16.7938 7.63525 16.734 7.62112 16.6733 7.62111H13.3267C13.2419 7.62113 13.1595 7.64866 13.0918 7.69955C13.0241 7.75045 12.9747 7.82196 12.9511 7.90333L8.64222 22.7633C8.62512 22.8215 8.62182 22.8829 8.63258 22.9425C8.64334 23.0022 8.66787 23.0586 8.70422 23.1071C8.74057 23.1556 8.78773 23.195 8.84197 23.2222C8.89621 23.2493 8.95603 23.2634 9.01667 23.2633Z"
fill="currentColor"
/>
<path
d="M18.0122 23.2633H21.3589C21.4436 23.2633 21.526 23.2358 21.5938 23.1849C21.6615 23.134 21.7109 23.0625 21.7344 22.9811L26.0433 8.12111C26.0602 8.06285 26.0633 8.00146 26.0524 7.9418C26.0415 7.88214 26.0168 7.82583 25.9804 7.77733C25.944 7.72883 25.8968 7.68947 25.8425 7.66236C25.7883 7.63525 25.7284 7.62112 25.6678 7.62111H22.3211C22.2364 7.62131 22.1541 7.64891 22.0864 7.69977C22.0187 7.75064 21.9693 7.82205 21.9456 7.90333L17.6367 22.7633C17.6195 22.8216 17.6163 22.8831 17.6271 22.9428C17.6379 23.0026 17.6625 23.059 17.699 23.1076C17.7355 23.1561 17.7828 23.1955 17.8372 23.2225C17.8915 23.2496 17.9515 23.2635 18.0122 23.2633Z"
fill="currentColor"
/>
<path
d="M12.3633 23.2633H8.64222C8.55741 23.2637 8.47481 23.2363 8.40701 23.1853C8.33921 23.1344 8.28993 23.0627 8.26666 22.9811L3.95666 8.12111C3.93977 8.06285 3.93667 8.00146 3.94759 7.9418C3.95851 7.88214 3.98316 7.82583 4.01959 7.77733C4.05602 7.72883 4.10322 7.68947 4.15748 7.66236C4.21174 7.63525 4.27156 7.62112 4.33222 7.62111H8.05444C8.13911 7.62131 8.22145 7.64891 8.28915 7.69977C8.35684 7.75064 8.40625 7.82205 8.43 7.90333L12.7389 22.7633C12.756 22.8216 12.7593 22.8831 12.7485 22.9428C12.7377 23.0026 12.713 23.059 12.6765 23.1076C12.6401 23.1561 12.5928 23.1955 12.5384 23.2225C12.484 23.2496 12.4241 23.2635 12.3633 23.2633Z"
fill="currentColor"
/>
<path
d="M21.3589 23.2633H17.6367C17.5519 23.2637 17.4693 23.2363 17.4015 23.1853C17.3337 23.1344 17.2844 23.0627 17.2611 22.9811L12.9511 8.12111C12.9342 8.06285 12.9311 8.00146 12.942 7.9418C12.953 7.88214 12.9776 7.82583 13.014 7.77733C13.0505 7.72883 13.0977 7.68947 13.1519 7.66236C13.2062 7.63525 13.266 7.62112 13.3267 7.62111H17.0489C17.1336 7.62113 17.216 7.64866 17.2838 7.69955C17.3515 7.75045 17.4009 7.82196 17.4244 7.90333L21.7344 22.7633C21.7513 22.8216 21.7544 22.883 21.7435 22.9426C21.7326 23.0023 21.7079 23.0586 21.6715 23.1071C21.6351 23.1556 21.5879 23.195 21.5336 23.2221C21.4794 23.2492 21.4195 23.2633 21.3589 23.2633Z"
fill="currentColor"
/>
</svg>
);
}
function AppLayout() {
const auth = useAuth();
@@ -50,47 +164,22 @@ function AppLayout() {
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null);
const isDevRoleOverrideEnabled =
import.meta.env.MODE === "development" ||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
const isMockRoleEnabled =
isDevRoleOverrideEnabled &&
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRoleOverride = isMockRoleEnabled
? window.localStorage.getItem("X-Mock-Role")
: null;
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const isDevelopmentRuntime = import.meta.env.MODE === "development";
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileOpen, setIsProfileOpen] = useState(false);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => {
const stored = window.localStorage.getItem("baron_session_expiry_enabled");
return stored !== "false";
});
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
const {
data: profile,
isLoading: isProfileLoading,
error: profileError,
} = useQuery({
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
readShellSidebarCollapsed(false),
);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
);
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: async () => {
console.debug("[AppLayout] Fetching profile...");
debugLog("[AppLayout] Fetching profile...");
try {
const data = await fetchMe();
console.debug("[AppLayout] Profile fetched successfully:", data.email);
debugLog("[AppLayout] Profile fetched successfully:", data.email);
return data;
} catch (err) {
console.error("[AppLayout] Failed to fetch profile:", err);
@@ -104,71 +193,84 @@ function AppLayout() {
._IS_TEST_MODE === true,
});
const navItems = React.useMemo(() => {
const navItems = React.useMemo<ShellSidebarNavItem[]>(() => {
const items = [...staticNavItems];
const isTest =
const _isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
const effectiveRole = mockRoleOverride || profile?.role;
const isSuperAdmin = isTest || effectiveRole === "super_admin";
const isTenantAdmin = effectiveRole === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0;
const effectiveRole = profile?.role;
const isSuperAdmin = isSuperAdminRole(effectiveRole);
const _manageableCount = profile?.manageableTenants?.length ?? 0;
const showWorksmobile = canAccessWorksmobile({
...profile,
role: effectiveRole ?? profile?.role,
});
const filteredItems = items.filter((item) => {
if (isTest) return true;
if (item.to === "/api-keys") return isSuperAdmin;
return true;
});
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
{ includeInternal: true },
);
if (isSuperAdmin) {
filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants",
labelKey: "ui.admin.nav.tenants",
labelFallback: "Tenants",
to: "/tenants",
icon: Building2,
});
filteredItems.splice(2, 0, {
label: "ui.admin.nav.org_chart",
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
} else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) {
filteredItems.splice(1, 0, {
label: "ui.admin.nav.my_tenant",
to: `/tenants/${profile.tenantId}`,
icon: Building2,
});
} else if (manageableCount > 1) {
filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants",
to: "/tenants",
icon: Building2,
if (showWorksmobile) {
filteredItems.splice(3, 0, {
labelKey: "ui.admin.nav.worksmobile",
labelFallback: "Worksmobile",
to: "/worksmobile",
icon: LineWorksNavIcon,
});
}
filteredItems.splice(
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
0,
{
label: "ui.admin.nav.org_chart",
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
icon: Network,
isExternal: true,
},
);
filteredItems.splice(4, 0, {
labelKey: "ui.admin.nav.ory_ssot",
labelFallback: "Ory SSOT System",
to: "/system/ory-ssot",
icon: Database,
});
filteredItems.splice(5, 0, {
labelKey: "ui.admin.nav.data_integrity",
labelFallback: "Data Integrity",
to: "/system/data-integrity",
icon: ShieldCheck,
});
} else {
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
// Non-superadmins
filteredItems.splice(1, 0, {
label: "ui.admin.nav.org_chart",
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
if (showWorksmobile) {
filteredItems.splice(2, 0, {
labelKey: "ui.admin.nav.worksmobile",
labelFallback: "Worksmobile",
to: "/worksmobile",
icon: LineWorksNavIcon,
});
}
}
return filteredItems;
}, [mockRoleOverride, profile]);
}, [profile]);
const handleLogout = () => {
if (
@@ -185,7 +287,7 @@ function AppLayout() {
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
console.debug("[AppLayout] Auth state check:", {
debugLog("[AppLayout] Auth state check:", {
isLoading: auth.isLoading,
isAuthenticated: auth.isAuthenticated,
isTest,
@@ -204,16 +306,28 @@ function AppLayout() {
}, [auth.user]);
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);
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
if (!isDevelopmentRuntime) {
return;
}
const rerenderDevelopmentShell = () => {
// Re-render when locale changes
};
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
return () => {
window.removeEventListener(
LOCALE_CHANGED_EVENT,
rerenderDevelopmentShell,
);
};
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
@@ -279,6 +393,10 @@ function AppLayout() {
]);
useEffect(() => {
if (isDevelopmentRuntime) {
return;
}
const maybeKeepSessionAlive = async () => {
const now = Date.now();
if (
@@ -378,71 +496,110 @@ function AppLayout() {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const profileName =
profile?.name?.trim() ||
auth.user?.profile.name?.toString().trim() ||
auth.user?.profile.preferred_username?.toString().trim() ||
t("ui.dev.profile.unknown_name", "Unknown User");
const profileEmail =
profile?.email?.trim() ||
auth.user?.profile.email?.toString().trim() ||
t("ui.dev.profile.unknown_email", "unknown@example.com");
const profileInitial = profileName.charAt(0).toUpperCase();
const profileRoleKey = mockRoleOverride || profile?.role || "user";
const expiresAtSec = auth.user?.expires_at;
const remainingMs =
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
const remainingTotalSec =
remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null;
const remainingMinutes =
remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null;
const remainingSeconds =
remainingTotalSec !== null ? remainingTotalSec % 60 : null;
let sessionToneClass =
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
let sessionText = t("ui.dev.session.active", "세션 활성");
if (remainingMs === null) {
sessionToneClass = "border-border bg-card text-muted-foreground";
sessionText = t("ui.dev.session.unknown", "알 수 없음");
} else if (remainingMs <= 0) {
sessionToneClass =
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
sessionText = t("ui.dev.session.expired", "세션 만료");
} else if (
remainingMinutes !== null &&
remainingSeconds !== null &&
remainingMinutes <= 5
) {
sessionToneClass =
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
sessionText = t(
"ui.dev.session.expiring",
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes,
seconds: remainingSeconds,
},
);
} else {
sessionText = t(
"ui.dev.session.remaining",
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes ?? 0,
seconds: remainingSeconds ?? 0,
},
);
}
const profileSummary = buildShellProfileSummary({
profileName:
profile?.name ||
auth.user?.profile.name?.toString() ||
auth.user?.profile.preferred_username?.toString(),
profileEmail: profile?.email || auth.user?.profile.email?.toString(),
fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
});
const profileRoleKey = profile?.role || "user";
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
writeShellSessionExpiryEnabled(next);
return next;
});
};
const handleSidebarToggle = () => {
setIsSidebarCollapsed((prev) => {
const next = !prev;
writeShellSidebarCollapsed(next);
return next;
});
};
const sidebarNavContent = (
<div className={shellLayoutClasses.navList}>
{navItems.map((item) => {
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
const label = t(labelKey, labelFallback);
if (isExternal) {
return (
<a
key={to}
href={to}
target="_blank"
rel="noopener noreferrer"
className={[
shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
shellLayoutClasses.navItemIdle,
].join(" ")}
title={label}
aria-label={label}
>
<Icon size={18} />
<span className={isSidebarCollapsed ? "sr-only" : ""}>
{label}
</span>
</a>
);
}
return (
<NavLink
key={to}
to={to}
end={item.end ?? to === "/"}
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
item.isActive !== undefined
? item.isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle
: isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
title={label}
aria-label={label}
>
<Icon size={18} />
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
</NavLink>
);
})}
</div>
);
const sidebarFooterContent = (
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
onClick={handleLogout}
className={
isSidebarCollapsed
? shellLayoutClasses.logoutButtonCollapsed
: shellLayoutClasses.logoutButton
}
title={t("ui.shell.nav.logout", "Logout")}
>
<LogOut size={18} />
<span className={isSidebarCollapsed ? "sr-only" : ""}>
{t("ui.shell.nav.logout", "Logout")}
</span>
</button>
</div>
);
if (auth.isLoading) {
return (
@@ -453,87 +610,29 @@ function AppLayout() {
}
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">
{t("ui.admin.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.admin.title", "Admin Control")}
</h1>
</div>
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-col gap-1">
{navItems.map((item: NavItem) => {
const { label, to, icon: Icon, isExternal } = item;
const isOrgChart = location.pathname === "/tenants/org-chart";
const isTenantsRoot = to === "/tenants";
const isCustomActive = isTenantsRoot
? location.pathname.startsWith("/tenants") && !isOrgChart
: to === "/"
? location.pathname === "/"
: location.pathname.startsWith(to);
<div
className={
isSidebarCollapsed
? shellLayoutClasses.rootCollapsed
: shellLayoutClasses.root
}
>
<AppSidebar
brandLabel={t("ui.admin.brand", "Baron 로그인")}
brandTitle={t("ui.admin.title", "Admin Control")}
brandIcon={<ShieldHalf size={20} />}
navContent={sidebarNavContent}
footerContent={sidebarFooterContent}
collapsed={isSidebarCollapsed}
onToggleCollapsed={handleSidebarToggle}
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
/>
if (isExternal) {
return (
<a
key={to}
href={to}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
>
<Icon size={18} />
<span>{t(label, label)}</span>
</a>
);
}
return (
<NavLink
key={to}
to={to}
className={() =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isCustomActive
? "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>{t(label, label)}</span>
</NavLink>
);
})}
</div>
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
>
<LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
</button>
</div>
</nav>
</aside>
<div className="relative">
<header className="sticky top-0 z-50 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">
<div className={shellLayoutClasses.contentWide}>
<header className={shellLayoutClasses.headerElevated}>
<div className={shellLayoutClasses.headerInner}>
<div className={shellLayoutClasses.headerTitleWrap}>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.admin.header.plane", "ADMIN PLANE")}
</p>
@@ -542,12 +641,12 @@ function AppLayout() {
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<div className={shellLayoutClasses.headerActions}>
<LanguageSelector />
<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"
className={shellLayoutClasses.actionButton}
aria-label={t("ui.common.theme_toggle", "테마 전환")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
@@ -556,14 +655,10 @@ function AppLayout() {
: t("ui.common.theme_dark", "Dark")}
</button>
{isSessionExpiryEnabled ? (
<span
className={[
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
sessionToneClass,
].join(" ")}
>
{sessionText}
</span>
<SessionStatusBadge
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : null}
<div className="relative" ref={profileMenuRef}>
<button
@@ -572,17 +667,17 @@ function AppLayout() {
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
aria-haspopup="menu"
aria-expanded={isProfileOpen}
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
aria-label={t("ui.shell.profile.menu_aria", "계정 메뉴 열기")}
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{profileInitial}
<div className={shellLayoutClasses.profileInitial}>
{profileSummary.initial}
</div>
<div className="hidden min-w-0 text-left md:block">
<p className="truncate text-xs font-medium text-foreground">
{profileName}
{profileSummary.name}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{profileEmail}
{profileSummary.email}
</p>
</div>
<ChevronDown
@@ -592,45 +687,50 @@ function AppLayout() {
</button>
{isProfileOpen ? (
<div
role="menu"
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
>
<div role="menu" className={shellLayoutClasses.profileMenu}>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")}
{t("ui.shell.profile.menu_title", "Account")}
</p>
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
<div className={shellLayoutClasses.profileCard}>
<div>
<p className="truncate text-sm font-semibold text-foreground">
{profileName}
{profileSummary.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{profileEmail}
{profileSummary.email}
</p>
</div>
<div className="flex items-center pt-1">
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
{t(
`ui.admin.role.${profileRoleKey}`,
`ui.shell.role.${profileRoleKey}`,
profileRoleKey.toUpperCase(),
)}
</span>
</div>
</div>
<div className="mt-2 rounded-lg border border-border px-3 py-3">
<div className={shellLayoutClasses.settingsCard}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
{t(
"ui.shell.session.auto_extend",
"세션 만료 관리",
)}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionText
: t(
"ui.dev.session.disabled",
"세션 만료 비활성화",
)}
{isSessionExpiryEnabled ? (
<SessionStatusText
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : (
t(
"ui.shell.session.disabled",
"세션 만료 비활성화",
)
)}
</p>
</div>
<button
@@ -707,7 +807,7 @@ function AppLayout() {
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
>
<UserIcon size={16} className="text-muted-foreground" />
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
<span>{t("ui.shell.nav.profile", "내 정보")}</span>
</button>
<button
type="button"
@@ -718,7 +818,7 @@ function AppLayout() {
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
>
<LogOut size={16} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
<span>{t("ui.shell.nav.logout", "Logout")}</span>
</button>
</div>
) : null}
@@ -726,10 +826,9 @@ function AppLayout() {
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<Outlet />
<main className={shellLayoutClasses.mainMinWidth}>
<Outlet context={isSidebarCollapsed} />
</main>
<RoleSwitcher />
</div>
</div>
);

View File

@@ -1,175 +0,0 @@
import { ChevronDown, ChevronUp, Wrench } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { t } from "../../lib/i18n";
const RoleSwitcher: FC = () => {
const [currentRole, setCurrentRole] = useState<string>("");
const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false);
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
return window.localStorage.getItem("RoleSwitcher-Collapsed") === "true";
});
useEffect(() => {
const savedRole = window.localStorage.getItem("X-Mock-Role");
const savedEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
setIsOverrideEnabled(savedEnabled);
if (savedRole) {
setCurrentRole(savedRole);
}
}, []);
const toggleCollapse = () => {
const nextState = !isCollapsed;
setIsCollapsed(nextState);
window.localStorage.setItem("RoleSwitcher-Collapsed", String(nextState));
};
const switchRole = (role: string) => {
window.localStorage.setItem("X-Mock-Role", role);
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
setCurrentRole(role);
setIsOverrideEnabled(true);
window.location.reload();
};
const clearRoleOverride = () => {
window.localStorage.removeItem("X-Mock-Role-Enabled");
setIsOverrideEnabled(false);
window.location.reload();
};
if (import.meta.env.MODE === "production") return null;
const roleLabels: Record<string, string> = {
super_admin: t("ui.admin.role.super_admin", "SUPER ADMIN"),
tenant_admin: t("ui.admin.role.tenant_admin", "TENANT ADMIN"),
rp_admin: t("ui.admin.role.rp_admin", "RP ADMIN"),
user: t("ui.admin.role.user", "TENANT MEMBER"),
};
return (
<div
style={{
position: "fixed",
bottom: "20px",
right: "20px",
zIndex: 9999,
background: "#1A1F2C",
color: "white",
padding: "8px 12px",
borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
display: "flex",
flexDirection: "column",
gap: isCollapsed ? "0" : "8px",
fontSize: "12px",
transition: "all 0.3s ease",
border: "1px solid #333",
}}
>
<button
type="button"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
cursor: "pointer",
fontWeight: "bold",
paddingBottom: isCollapsed ? "0" : "4px",
borderBottom: isCollapsed ? "none" : "1px solid #444",
background: "transparent",
border: "none",
width: "100%",
color: "inherit",
textAlign: "inherit",
}}
onClick={toggleCollapse}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<Wrench size={14} className="text-blue-400" />
{!isCollapsed && (
<span>{t("ui.admin.dev_role_switcher", "DEV Role Switcher")}</span>
)}
{isCollapsed && (
<span style={{ fontSize: "10px", color: "#888" }}>
{isOverrideEnabled && currentRole
? currentRole.toUpperCase()
: "REAL ROLE"}
</span>
)}
</div>
{isCollapsed ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{!isCollapsed && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
marginTop: "4px",
}}
>
<button
type="button"
onClick={clearRoleOverride}
style={{
background: !isOverrideEnabled ? "#3b82f6" : "#333",
color: "white",
border: "none",
padding: "4px 8px",
borderRadius: "4px",
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
{t("ui.admin.dev_role_switcher_real", "실제 역할 사용")}
</span>
{!isOverrideEnabled && (
<span style={{ marginLeft: "8px" }}></span>
)}
</button>
{(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map(
(role) => (
<button
key={role}
type="button"
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",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
</span>
{isOverrideEnabled && currentRole === role && (
<span style={{ marginLeft: "8px" }}></span>
)}
</button>
),
)}
</div>
)}
</div>
);
};
export default RoleSwitcher;

View File

@@ -0,0 +1,49 @@
import type React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
let container: HTMLDivElement | null = null;
const render = async (element: React.ReactElement) => {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(element);
});
return root;
};
afterEach(() => {
if (container) {
container.remove();
container = null;
}
});
describe("Avatar", () => {
it("renders image and fallback with merged classes", async () => {
const root = await render(
<Avatar className="custom-root" data-testid="avatar">
<AvatarImage
alt="Admin user"
className="custom-image"
src="/avatar.png"
/>
<AvatarFallback className="custom-fallback">AU</AvatarFallback>
</Avatar>,
);
const avatar = container?.querySelector("[data-testid='avatar']");
const fallback = container?.textContent;
expect(avatar?.className).toContain("custom-root");
expect(fallback).toContain("AU");
await act(async () => {
root.unmount();
});
});
});

View File

@@ -44,4 +44,4 @@ const AvatarFallback = React.forwardRef<
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };
export { Avatar, AvatarFallback, AvatarImage };

View File

@@ -1,38 +1,21 @@
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
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> {}
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: CommonBadgeVariant;
}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
<div
className={cn(getCommonBadgeClasses({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };
export { Badge };

View File

@@ -1,41 +1,16 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react";
import {
type CommonButtonSize,
type CommonButtonVariant,
getCommonButtonClasses,
} from "../../../../common/ui/button";
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> {
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: CommonButtonVariant;
size?: CommonButtonSize;
asChild?: boolean;
}
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
className={cn(getCommonButtonClasses({ variant, size }), className)}
ref={ref}
{...props}
/>
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = "Button";
export { Button, buttonVariants };
export { Button };

View File

@@ -1,72 +1,58 @@
import type * as React from "react";
import {
commonCardClass,
commonCardContentClass,
commonCardDescriptionClass,
commonCardFooterClass,
commonCardHeaderClass,
commonCardTitleClass,
} from "../../../../common/ui/card";
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}
/>
);
return <div className={cn(commonCardClass, className)} {...props} />;
}
function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
);
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
}
function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
);
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
}
function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return (
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
}
function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
return <div className={cn(commonCardContentClass, className)} {...props} />;
}
function CardFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
);
return <div className={cn(commonCardFooterClass, className)} {...props} />;
}
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};

View File

@@ -0,0 +1,19 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Checkbox } from "./checkbox";
describe("Checkbox Component", () => {
it("adds a fallback id for browser autofill diagnostics", () => {
render(<Checkbox aria-label="Select row" />);
expect(screen.getByRole("checkbox")).toHaveAttribute("id");
});
it("keeps explicit id and name values", () => {
render(<Checkbox id="explicit-checkbox" name="explicit-name" />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toHaveAttribute("id", "explicit-checkbox");
expect(checkbox).toHaveAttribute("name", "explicit-name");
});
});

View File

@@ -7,13 +7,18 @@ export interface CheckboxProps
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => {
({ className, onCheckedChange, id, name, ...props }, ref) => {
const fallbackId = React.useId();
const fieldId = id ?? (name ? undefined : fallbackId);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onCheckedChange?.(e.target.checked);
};
return (
<input
id={fieldId}
name={name}
type="checkbox"
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary",

View File

@@ -0,0 +1,23 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "./dialog";
describe("Dialog FocusScope integration", () => {
it("mounts an open dialog without a ref update loop", () => {
render(
<Dialog open>
<DialogContent>
<DialogTitle>Focus scope check</DialogTitle>
<DialogDescription>Dialog content is mounted.</DialogDescription>
</DialogContent>
</Dialog>,
);
expect(screen.getByText("Focus scope check")).toBeInTheDocument();
});
});

View File

@@ -1,55 +1,222 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "../../lib/utils";
const Dialog = DialogPrimitive.Root;
type DialogContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
};
const DialogTrigger = DialogPrimitive.Trigger;
const DialogContext = React.createContext<DialogContextValue | null>(null);
const DialogPortal = DialogPrimitive.Portal;
function useDialogContext(componentName: string) {
const context = React.useContext(DialogContext);
if (!context) {
throw new Error(`${componentName} must be used within Dialog`);
}
return context;
}
const DialogClose = DialogPrimitive.Close;
function composeEventHandlers<E extends React.SyntheticEvent>(
theirs: ((event: E) => void) | undefined,
ours: (event: E) => void,
) {
return (event: E) => {
theirs?.(event);
if (!event.defaultPrevented) {
ours(event);
}
};
}
type DialogProps = {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
};
function Dialog({
open,
defaultOpen = false,
onOpenChange,
children,
}: DialogProps) {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const isControlled = open !== undefined;
const currentOpen = isControlled ? open : internalOpen;
const setOpen = React.useCallback(
(nextOpen: boolean) => {
if (!isControlled) {
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen);
},
[isControlled, onOpenChange],
);
const value = React.useMemo(
() => ({ open: currentOpen, setOpen }),
[currentOpen, setOpen],
);
return (
<DialogContext.Provider value={value}>{children}</DialogContext.Provider>
);
}
type DialogTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
asChild?: boolean;
};
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
({ asChild = false, children, onClick, ...props }, ref) => {
const { setOpen } = useDialogContext("DialogTrigger");
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (!event.defaultPrevented) {
setOpen(true);
}
};
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<{
onClick?: React.MouseEventHandler<HTMLElement>;
}>;
return React.cloneElement(child, {
...props,
onClick: composeEventHandlers(
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
() => setOpen(true),
),
});
}
return (
<button type="button" ref={ref} onClick={handleOpen} {...props}>
{children}
</button>
);
},
);
DialogTrigger.displayName = "DialogTrigger";
const DialogPortal = ({ children }: { children?: React.ReactNode }) => {
if (typeof document === "undefined") {
return null;
}
return createPortal(children, document.body);
};
DialogPortal.displayName = "DialogPortal";
const DialogClose = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
({ asChild = false, children, onClick, ...props }, ref) => {
const { setOpen } = useDialogContext("DialogClose");
const handleClose = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (!event.defaultPrevented) {
setOpen(false);
}
};
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<{
onClick?: React.MouseEventHandler<HTMLElement>;
}>;
return React.cloneElement(child, {
...props,
onClick: composeEventHandlers(
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
() => setOpen(false),
),
});
}
return (
<button type="button" ref={ref} onClick={handleClose} {...props}>
{children}
</button>
);
},
);
DialogClose.displayName = "DialogClose";
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement>
>(({ className, onMouseDown, ...props }, ref) => {
const { setOpen } = useDialogContext("DialogOverlay");
return (
<button
type="button"
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed inset-0 z-50 border-0 bg-black/80 p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
data-state="open"
aria-label="Close dialog"
onMouseDown={composeEventHandlers(onMouseDown, (event) => {
if (event.target === event.currentTarget) {
setOpen(false);
}
})}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
/>
);
});
DialogOverlay.displayName = "DialogOverlay";
const DialogContent = React.forwardRef<
HTMLDialogElement,
React.HTMLAttributes<HTMLDialogElement>
>(({ className, children, onKeyDown, ...props }, ref) => {
const { open, setOpen } = useDialogContext("DialogContent");
React.useEffect(() => {
if (!open) {
return;
}
const onDocumentKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("keydown", onDocumentKeyDown);
return () => document.removeEventListener("keydown", onDocumentKeyDown);
}, [open, setOpen]);
if (!open) {
return null;
}
return (
<DialogPortal>
<DialogOverlay />
<dialog
ref={ref}
open
aria-modal="true"
data-state="open"
className={cn(
"fixed left-[50%] top-[50%] z-50 m-0 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 backdrop:bg-transparent data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
onKeyDown={onKeyDown}
{...props}
>
{children}
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</dialog>
</DialogPortal>
);
});
DialogContent.displayName = "DialogContent";
const DialogHeader = ({
className,
@@ -80,10 +247,10 @@ const DialogFooter = ({
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
<h2
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
@@ -92,29 +259,29 @@ const DialogTitle = React.forwardRef<
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
DialogTitle.displayName = "DialogTitle";
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
DialogDescription.displayName = "DialogDescription";
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -183,18 +183,18 @@ DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
DropdownMenuTrigger,
};

View File

@@ -9,6 +9,20 @@ describe("Input Component", () => {
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
});
it("adds a fallback id for browser autofill diagnostics", () => {
render(<Input placeholder="Enter text" />);
expect(screen.getByPlaceholderText("Enter text")).toHaveAttribute("id");
});
it("keeps explicit id and name values", () => {
render(<Input id="explicit-id" name="explicit-name" />);
const input = screen.getByRole("textbox");
expect(input).toHaveAttribute("id", "explicit-id");
expect(input).toHaveAttribute("name", "explicit-name");
});
it("handles value changes", async () => {
const onChange = vi.fn();
const user = userEvent.setup();

View File

@@ -1,18 +1,21 @@
import * as React from "react";
import { commonInputClass } from "../../../../common/ui/input";
import { cn } from "../../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
({ className, type, id, name, ...props }, ref) => {
const fallbackId = React.useId();
const fieldId = id ?? (name ? undefined : fallbackId);
return (
<input
id={fieldId}
name={name}
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,
)}
className={cn(commonInputClass, className)}
ref={ref}
{...props}
/>

View File

@@ -146,13 +146,13 @@ SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectGroup,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,41 @@
import type React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { Separator } from "./separator";
let container: HTMLDivElement | null = null;
const render = async (element: React.ReactElement) => {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(element);
});
return root;
};
afterEach(() => {
if (container) {
container.remove();
container = null;
}
});
describe("Separator", () => {
it("renders a horizontal separator with custom classes", async () => {
const root = await render(
<Separator className="custom-separator" data-testid="separator" />,
);
const separator = container?.querySelector("[data-testid='separator']");
expect(separator?.className).toContain("h-px");
expect(separator?.className).toContain("custom-separator");
await act(async () => {
root.unmount();
});
});
});

View File

@@ -1,26 +1,68 @@
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",
interface SwitchProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
(
{
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;
checked,
defaultChecked = false,
disabled,
onCheckedChange,
onClick,
...props
},
ref,
) => {
const isControlled = checked !== undefined;
const [internalChecked, setInternalChecked] =
React.useState(defaultChecked);
const currentChecked = isControlled ? checked : internalChecked;
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented || disabled) {
return;
}
const nextChecked = !currentChecked;
if (!isControlled) {
setInternalChecked(nextChecked);
}
onCheckedChange?.(nextChecked);
};
return (
<button
type="button"
role="switch"
aria-checked={currentChecked}
data-state={currentChecked ? "checked" : "unchecked"}
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,
)}
disabled={disabled}
onClick={handleClick}
ref={ref}
{...props}
>
<span
data-state={currentChecked ? "checked" : "unchecked"}
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",
)}
/>
</button>
);
},
);
Switch.displayName = "Switch";
export { Switch };

View File

@@ -1,16 +1,23 @@
import * as React from "react";
import {
commonTableBodyClass,
commonTableCaptionClass,
commonTableCellClass,
commonTableClass,
commonTableFooterClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { cn } from "../../lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
<div className={commonTableWrapperClass}>
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
</div>
));
Table.displayName = "Table";
@@ -19,7 +26,11 @@ const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
<thead
ref={ref}
className={cn(commonTableHeaderClass, className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
@@ -27,11 +38,7 @@ const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
));
TableBody.displayName = "TableBody";
@@ -41,7 +48,7 @@ const TableFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("bg-muted/50 font-medium text-foreground", className)}
className={cn(commonTableFooterClass, className)}
{...props}
/>
));
@@ -51,14 +58,7 @@ 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}
/>
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
));
TableRow.displayName = "TableRow";
@@ -66,14 +66,7 @@ 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-foreground align-middle sticky top-0 bg-inherit",
className,
)}
{...props}
/>
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
));
TableHead.displayName = "TableHead";
@@ -81,11 +74,7 @@ const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-6 align-middle text-sm", className)}
{...props}
/>
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
));
TableCell.displayName = "TableCell";
@@ -95,7 +84,7 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
className={cn(commonTableCaptionClass, className)}
{...props}
/>
));
@@ -103,11 +92,11 @@ TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -84,4 +84,4 @@ const TabsContent = React.forwardRef<
});
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };
export { Tabs, TabsContent, TabsList, TabsTrigger };

View File

@@ -0,0 +1,19 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Textarea } from "./textarea";
describe("Textarea Component", () => {
it("adds a fallback id for browser autofill diagnostics", () => {
render(<Textarea aria-label="Description" />);
expect(screen.getByRole("textbox")).toHaveAttribute("id");
});
it("keeps explicit id and name values", () => {
render(<Textarea id="explicit-textarea" name="explicit-name" />);
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveAttribute("id", "explicit-textarea");
expect(textarea).toHaveAttribute("name", "explicit-name");
});
});

View File

@@ -5,9 +5,14 @@ export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
({ className, id, name, ...props }, ref) => {
const fallbackId = React.useId();
const fieldId = id ?? (name ? undefined : fallbackId);
return (
<textarea
id={fieldId}
name={name}
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,

View File

@@ -0,0 +1,72 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createApiKey } from "../../lib/adminApi";
import ApiKeyCreatePage from "./ApiKeyCreatePage";
vi.mock("../../lib/adminApi", () => ({
createApiKey: vi.fn(async () => ({
apiKey: {
id: "api-key-id",
name: "org-context-client",
client_id: "client-id",
scopes: ["audit:read", "user:read", "org-context:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
},
clientSecret: "secret",
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ApiKeyCreatePage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("ApiKeyCreatePage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders org-context:read as a selectable API key scope", () => {
renderPage();
expect(screen.getByText("조직 Context 조회")).toBeInTheDocument();
expect(screen.getByText("ID: org-context:read")).toBeInTheDocument();
});
it("includes org-context:read in the create request when selected", async () => {
const user = userEvent.setup();
renderPage();
await user.type(
screen.getByLabelText("서비스 또는 목적 식별 이름"),
"org-context-client",
);
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
await user.click(screen.getByRole("button", { name: /API 키 발급하기/ }));
await waitFor(() => {
expect(createApiKey).toHaveBeenCalledWith(
expect.objectContaining({
name: "org-context-client",
scopes: expect.arrayContaining(["org-context:read"]),
}),
);
});
});
});

View File

@@ -28,51 +28,7 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
const AVAILABLE_SCOPES = [
{
id: "audit:read",
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
labelFallback: "감사 로그 조회",
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
},
{
id: "audit:write",
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
labelFallback: "감사 로그 생성",
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
},
{
id: "user:read",
labelKey: "ui.admin.api_keys.scopes.user_read.title",
labelFallback: "사용자 조회",
descKey: "msg.admin.api_keys.scopes.user_read.desc",
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
},
{
id: "user:write",
labelKey: "ui.admin.api_keys.scopes.user_write.title",
labelFallback: "사용자 관리",
descKey: "msg.admin.api_keys.scopes.user_write.desc",
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
},
{
id: "tenant:read",
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
labelFallback: "테넌트 조회",
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
descFallback: "등록된 모든 조직 정보를 조회합니다.",
},
{
id: "tenant:write",
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
labelFallback: "테넌트 관리",
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
descFallback: "테넌트 정보를 직접 제어합니다.",
},
];
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
function ApiKeyCreatePage() {
const navigate = useNavigate();
@@ -298,7 +254,7 @@ function ApiKeyCreatePage() {
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{AVAILABLE_SCOPES.map((scope) => {
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
const isSelected = selectedScopes.includes(scope.id);
return (
<button

View File

@@ -0,0 +1,125 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchApiKeys,
rotateApiKeySecret,
updateApiKeyScopes,
} from "../../lib/adminApi";
import ApiKeyListPage from "./ApiKeyListPage";
vi.mock("../../lib/i18n", () => ({
t: (_key: string, fallback?: string) => fallback ?? "",
}));
vi.mock("../../lib/adminApi", () => ({
fetchApiKeys: vi.fn(async () => ({
items: [
{
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
},
],
total: 1,
})),
deleteApiKey: vi.fn(async () => undefined),
updateApiKeyScopes: vi.fn(async () => ({
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read", "org-context:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
})),
rotateApiKeySecret: vi.fn(async () => ({
apiKey: {
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
},
clientSecret: "rotated-secret",
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ApiKeyListPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("ApiKeyListPage", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("updates scopes without changing client_id", async () => {
const user = userEvent.setup();
renderPage();
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /권한 수정/ }));
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
await user.click(screen.getByRole("button", { name: /권한 저장/ }));
await waitFor(() => {
expect(updateApiKeyScopes).toHaveBeenCalledWith("api-key-id", {
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
});
});
});
it("rotates only the secret and shows the one-time secret", async () => {
const user = userEvent.setup();
renderPage();
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Secret 재발급/ }));
await waitFor(() => {
expect(rotateApiKeySecret).toHaveBeenCalledWith("api-key-id");
});
expect(
await screen.findByDisplayValue("rotated-secret"),
).toBeInTheDocument();
expect(fetchApiKeys).toHaveBeenCalled();
});
it("refresh button refetches the list without navigation", async () => {
const user = userEvent.setup();
renderPage();
await screen.findByText("client-id-stable");
const refreshButton = screen.getByRole("button", { name: /새로고침/ });
expect(refreshButton).toHaveAttribute("type", "button");
await user.click(refreshButton);
await waitFor(() => {
expect(fetchApiKeys).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -1,7 +1,19 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Key, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
Copy,
Edit3,
Key,
Plus,
RefreshCw,
RotateCcw,
Save,
Trash2,
} from "lucide-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import { commonStickyTableHeaderClass } from "../../../../common/ui/table";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
@@ -11,6 +23,15 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import {
Table,
TableBody,
@@ -19,10 +40,27 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
import {
type ApiKeySummary,
deleteApiKey,
fetchApiKeys,
rotateApiKeySecret,
updateApiKeyScopes,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
function ApiKeyListPage() {
const [editingKey, setEditingKey] = React.useState<ApiKeySummary | null>(
null,
);
const [draftScopes, setDraftScopes] = React.useState<string[]>([]);
const [rotatedSecret, setRotatedSecret] = React.useState<{
key: ApiKeySummary;
clientSecret: string;
} | null>(null);
const query = useQuery({
queryKey: ["api-keys", { limit: 50, offset: 0 }],
queryFn: () => fetchApiKeys(50, 0),
@@ -35,6 +73,27 @@ function ApiKeyListPage() {
},
});
const updateScopesMutation = useMutation({
mutationFn: ({ id, scopes }: { id: string; scopes: string[] }) =>
updateApiKeyScopes(id, { scopes }),
onSuccess: () => {
setEditingKey(null);
setDraftScopes([]);
query.refetch();
},
});
const rotateSecretMutation = useMutation({
mutationFn: (id: string) => rotateApiKeySecret(id),
onSuccess: (data) => {
setRotatedSecret({
key: data.apiKey,
clientSecret: data.clientSecret,
});
query.refetch();
},
});
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
@@ -62,42 +121,80 @@ function ApiKeyListPage() {
deleteMutation.mutate(id);
};
const openScopeEditor = (key: ApiKeySummary) => {
setEditingKey(key);
setDraftScopes(key.scopes);
};
const toggleDraftScope = (scopeId: string) => {
setDraftScopes((current) =>
current.includes(scopeId)
? current.filter((scope) => scope !== scopeId)
: [...current, scopeId],
);
};
const saveScopes = () => {
if (!editingKey || draftScopes.length === 0) return;
updateScopesMutation.mutate({ id: editingKey.id, scopes: draftScopes });
};
const handleRotateSecret = (key: ApiKeySummary) => {
if (
!window.confirm(
t(
"msg.admin.api_keys.list.rotate_confirm",
'API 키 "{{name}}"의 Secret을 재발급할까요? 기존 Secret은 더 이상 사용할 수 없습니다.',
{ name: key.name },
),
)
) {
return;
}
rotateSecretMutation.mutate(key.id);
};
const copyRotatedSecret = () => {
if (!rotatedSecret) return;
navigator.clipboard.writeText(rotatedSecret.clientSecret);
};
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
{t(
"msg.admin.api_keys.list.subtitle",
"서버 간 통신(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} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/api-keys/new">
<Plus size={16} />
{t("ui.admin.api_keys.list.add", "API 키 생성")}
</Link>
</Button>
</div>
</header>
<PageHeader
sticky
titleAs="h2"
icon={<Key size={20} />}
title={t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
description={t(
"msg.admin.api_keys.list.subtitle",
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
)}
actions={
<>
<Button
type="button"
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/api-keys/new">
<Plus size={16} />
{t("ui.admin.api_keys.list.add", "API 키 생성")}
</Link>
</Button>
</>
}
/>
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
<CardTitle className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
</CardTitle>
<CardDescription>
@@ -119,7 +216,7 @@ function ApiKeyListPage() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead>
{t("ui.admin.api_keys.list.table.name", "NAME")}
@@ -189,15 +286,40 @@ function ApiKeyListPage() {
: t("ui.common.never", "Never")}
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openScopeEditor(key)}
>
<Edit3 size={14} />
{t(
"ui.admin.api_keys.list.edit_scopes",
"권한 수정",
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleRotateSecret(key)}
disabled={rotateSecretMutation.isPending}
>
<RotateCcw size={14} />
{t(
"ui.admin.api_keys.list.rotate_secret",
"Secret 재발급",
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -207,6 +329,137 @@ function ApiKeyListPage() {
</div>
</CardContent>
</Card>
<Dialog
open={editingKey !== null}
onOpenChange={() => setEditingKey(null)}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.api_keys.list.edit_scopes", "권한 수정")}
</DialogTitle>
<DialogDescription>
{editingKey
? t(
"msg.admin.api_keys.list.edit_scopes_desc",
"{{clientId}}의 CLIENT_ID는 유지하고 권한만 변경합니다.",
{ clientId: editingKey.client_id },
)
: null}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
const isSelected = draftScopes.includes(scope.id);
return (
<button
key={scope.id}
type="button"
onClick={() => toggleDraftScope(scope.id)}
className={cn(
"flex flex-col items-start gap-2 rounded-lg border-2 p-4 text-left transition-all",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/30",
)}
>
<span className="font-bold text-sm">
{t(scope.labelKey, scope.labelFallback)}
</span>
<span className="text-[11px] text-muted-foreground leading-snug">
{t(scope.descKey, scope.descFallback)}
</span>
<code className="text-[9px] font-mono opacity-60 uppercase tracking-tighter">
ID: {scope.id}
</code>
</button>
);
})}
</div>
{draftScopes.length === 0 && (
<p className="text-sm text-destructive">
{t(
"msg.admin.api_keys.create.scope_required",
"최소 하나 이상의 권한을 선택해야 합니다.",
)}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingKey(null)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={saveScopes}
disabled={
updateScopesMutation.isPending || draftScopes.length === 0
}
>
<Save size={16} />
{t("ui.admin.api_keys.list.save_scopes", "권한 저장")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={rotatedSecret !== null}
onOpenChange={() => setRotatedSecret(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t(
"ui.admin.api_keys.list.rotate_secret_done",
"Secret 재발급 완료",
)}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.api_keys.list.rotate_secret_notice",
"새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다.",
)}
</DialogDescription>
</DialogHeader>
{rotatedSecret && (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">
CLIENT ID
</p>
<code className="block rounded-md bg-muted px-3 py-2 text-sm">
{rotatedSecret.key.client_id}
</code>
</div>
<div className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">
X-Baron-Key-Secret
</p>
<div className="relative">
<Input
readOnly
value={rotatedSecret.clientSecret}
className="font-mono pr-12"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={copyRotatedSecret}
>
<Copy size={16} />
</Button>
</div>
</div>
</div>
)}
<DialogFooter>
<Button onClick={() => setRotatedSecret(null)}>
{t("ui.common.confirm", "확인")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,59 @@
export type ApiKeyScopeOption = {
id: string;
labelKey: string;
labelFallback: string;
descKey: string;
descFallback: string;
};
export const AVAILABLE_API_KEY_SCOPES: ApiKeyScopeOption[] = [
{
id: "audit:read",
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
labelFallback: "감사 로그 조회",
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
},
{
id: "audit:write",
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
labelFallback: "감사 로그 생성",
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
},
{
id: "user:read",
labelKey: "ui.admin.api_keys.scopes.user_read.title",
labelFallback: "사용자 조회",
descKey: "msg.admin.api_keys.scopes.user_read.desc",
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
},
{
id: "user:write",
labelKey: "ui.admin.api_keys.scopes.user_write.title",
labelFallback: "사용자 관리",
descKey: "msg.admin.api_keys.scopes.user_write.desc",
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
},
{
id: "tenant:read",
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
labelFallback: "테넌트 조회",
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
descFallback: "등록된 모든 조직 정보를 조회합니다.",
},
{
id: "tenant:write",
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
labelFallback: "테넌트 관리",
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
descFallback: "테넌트 정보를 직접 제어합니다.",
},
{
id: "org-context:read",
labelKey: "ui.admin.api_keys.scopes.org_context_read.title",
labelFallback: "조직 Context 조회",
descKey: "msg.admin.api_keys.scopes.org_context_read.desc",
descFallback: "외부 연동앱이 OrgFront SSOT 조직 JSON을 조회합니다.",
},
];

View File

@@ -1,15 +1,9 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronDown,
ChevronUp,
Copy,
ListChecks,
RefreshCw,
Search,
Terminal,
} from "lucide-react";
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
import * as React from "react";
import { PageHeader } from "../../../../common/core/components/page";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
@@ -19,92 +13,18 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { Input } from "../../components/ui/input";
import type { AuditLog } from "../../lib/adminApi";
import { fetchAuditLogs } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
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 };
}
import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable";
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 [searchActorId, setSearchActorId] = React.useState("");
const [searchAction, setSearchAction] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const deferredSearchActorId = React.useDeferredValue(searchActorId.trim());
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
const {
data,
isLoading,
@@ -115,8 +35,23 @@ function AuditLogsPage() {
isFetching,
refetch,
} = useInfiniteQuery({
queryKey: ["audit-logs"],
queryFn: ({ pageParam }) => fetchAuditLogs(50, pageParam),
queryKey: [
"audit-logs",
deferredSearchActorId,
deferredSearchAction,
statusFilter,
],
queryFn: ({ pageParam }) => {
const search = [deferredSearchActorId, deferredSearchAction]
.filter(Boolean)
.join(" ");
return fetchAuditLogs(
50,
pageParam,
search || undefined,
statusFilter === "all" ? undefined : statusFilter,
);
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
@@ -127,477 +62,133 @@ function AuditLogsPage() {
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">
{t("msg.admin.audit.loading", "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">
{t("msg.admin.audit.load_error", "Error loading logs: {{error}}", {
error: errMsg,
})}
</div>
);
}
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div>
<h2 className="text-3xl font-semibold">
{t("ui.admin.audit.title", "감사 로그")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
{t(
"msg.admin.audit.subtitle",
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button>
<ListChecks size={16} />
{t("ui.admin.audit.export_csv", "Export CSV")}
</Button>
</div>
</header>
<Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.audit.registry.title", "Log Registry")}
</CardTitle>
<CardDescription>
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", {
<div className="space-y-6">
<PageHeader
title={t("ui.common.audit.title", "감사 로그")}
description={t(
"msg.admin.audit.subtitle",
"관리자 작업 이력을 조회합니다.",
)}
icon={<NotebookTabs size={20} />}
actions={
<>
<Badge variant="muted">
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
count: logs.length,
})}
</Badge>
<Button
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button>
<Download size={16} />
{t("ui.common.export_csv", "CSV 내보내기")}
</Button>
</>
}
/>
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-bold flex items-center gap-2">
{t("ui.common.audit.registry.title", "Audit registry")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.audit.registry.description",
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0">
<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={t(
"ui.admin.audit.filters.placeholder",
"필터 추가 (예: status:failure)",
)}
className="w-full bg-transparent text-sm text-foreground outline-none"
/>
<Button size="sm" variant="outline" onClick={handleAddFilter}>
{t("ui.common.add", "추가")}
</Button>
</div>
{filters.length === 0 ? (
<span className="text-xs text-[var(--color-muted)]">
{t("msg.admin.audit.filters.empty", "필터 없음")}
</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)]"
{isLoading ? (
<div className="p-8 text-center" data-testid="audit-loading">
{t("msg.common.audit.loading", "Loading audit logs...")}
</div>
) : error ? (
<div
className="p-8 text-center text-red-500"
data-testid="audit-error"
>
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
error:
(error as AxiosError<{ error?: string }>).response?.data
?.error ?? (error as Error).message,
})}
</div>
) : (
<CardContent className="space-y-4 pt-0">
<SearchFilterBar
primary={
<form
onSubmit={(e) => {
e.preventDefault();
refetch();
}}
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
>
<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={t(
"ui.admin.audit.filters.remove",
"{{filter}} 필터 제거",
{ filter },
)}
>
×
</button>
</span>
))
)}
</div>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[140px]">
{t("ui.admin.audit.table.time", "TIME")}
</TableHead>
<TableHead className="w-[160px]">
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.request", "REQUEST")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.path", "PATH")}
</TableHead>
<TableHead className="w-[120px]">
{t("ui.admin.audit.table.status", "STATUS")}
</TableHead>
<TableHead>
{t(
"ui.admin.audit.table.action_target",
"Action / Target",
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
data-testid="audit-search-user-id"
value={searchActorId}
onChange={(event) => setSearchActorId(event.target.value)}
placeholder={t(
"ui.common.audit.filters.user_id",
"Filter by User ID",
)}
</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={7}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!isLoading && logs.length === 0 && (
<TableRow>
<TableCell colSpan={7}>
{t(
"msg.admin.audit.empty",
"아직 수집된 감사 로그가 없습니다.",
)}
</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={t(
"ui.admin.audit.copy.actor_id",
"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={t(
"ui.admin.audit.copy.request_id",
"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">
{t(
"ui.admin.audit.target",
"Target · {{target}}",
{
target: details.target,
},
)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.target",
"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]">
{t(
"ui.admin.audit.details.request",
"Request",
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.request_id",
"Request ID · {{value}}",
{
value: formatCellValue(
details.request_id,
),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.event_id",
"Event ID · {{value}}",
{
value: formatCellValue(row.event_id),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.ip",
"IP · {{value}}",
{
value: formatCellValue(row.ip_address),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.admin.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.admin.audit.details.actor_id",
"Actor ID · {{value}}",
{
value:
row.user_id ||
details.actor_id ||
"-",
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.tenant",
"Tenant · {{value}}",
{
value: formatCellValue(
details.tenant_id,
),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.device",
"Device · {{value}}",
{
value: formatCellValue(row.device_id),
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t(
"ui.admin.audit.details.result",
"Result",
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.error",
"Error · {{value}}",
{
value: formatCellValue(details.error),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.before",
"Before · {{value}}",
{
value: formatCellValue(details.before),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.after",
"After · {{value}}",
{
value: formatCellValue(details.after),
},
)}
</div>
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
</div>
<div className="pt-4 text-center flex-shrink-0">
{hasNextPage ? (
<Button
variant="outline"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.admin.audit.load_more", "Load more")}
</Button>
) : (
<span className="text-xs text-[var(--color-muted)]">
{t("msg.admin.audit.end", "End of audit feed")}
</span>
)}
</div>
</CardContent>
/>
</div>
<Input
data-testid="audit-search-action"
value={searchAction}
onChange={(event) =>
setSearchAction(event.target.value.toUpperCase())
}
placeholder={t(
"ui.common.audit.filters.action",
"Filter by Action (e.g. ROTATE_SECRET)",
)}
/>
<select
id="audit-filter-status"
name="audit-filter-status"
data-testid="audit-filter-status"
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
<option value="all">
{t("ui.common.audit.filters.status_all", "All Status")}
</option>
<option value="success">
{t("ui.common.status.success", "Success")}
</option>
<option value="failure">
{t("ui.common.status.failure", "Failure")}
</option>
</select>
</form>
}
/>
<VirtualizedAuditLogTable
logs={logs}
t={t}
loading={isLoading}
hasNextPage={Boolean(hasNextPage)}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
/>
</CardContent>
)}
</Card>
</div>
);

View File

@@ -0,0 +1,475 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import * as React from "react";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
resolveAuditTarget,
} from "../../../../common/core/audit";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
import { getCommonButtonClasses } from "../../../../common/ui/button";
import {
commonStickyTableHeaderClass,
commonTableBodyClass,
commonTableCellClass,
commonTableClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableShellClass,
commonTableViewportClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { Button } from "../../components/ui/button";
import type { AuditLog } from "../../lib/adminApi";
type AuditTranslate = (
key: string,
fallback: string,
vars?: Record<string, string | number>,
) => string;
type VirtualizedAuditLogTableProps = {
logs: AuditLog[];
t: AuditTranslate;
loading: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
className?: string;
};
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(" ");
}
function statusVariant(status: string): CommonBadgeVariant {
return status === "success" || status === "ok" ? "success" : "warning";
}
export function VirtualizedAuditLogTable({
logs,
t,
loading,
hasNextPage,
isFetchingNextPage,
onLoadMore,
className,
}: VirtualizedAuditLogTableProps) {
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
const viewportRef = React.useRef<HTMLDivElement>(null);
const isTest =
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
(typeof window !== "undefined" &&
(window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE);
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
const rowVirtualizer = useVirtualizer({
count: logs.length,
getScrollElement: () => viewportRef.current,
estimateSize: () => 80,
measureElement: (el) => el.getBoundingClientRect().height,
overscan: isTest ? logs.length : 10,
initialRect: isTest ? { width: 1010, height: 1000 } : undefined,
});
const virtualRows = rowVirtualizer.getVirtualItems();
React.useEffect(() => {
if (isTest) {
return;
}
const lastItem = virtualRows[virtualRows.length - 1];
if (!lastItem) return;
if (
lastItem.index >= logs.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
onLoadMore();
}
}, [
virtualRows,
logs.length,
hasNextPage,
isFetchingNextPage,
onLoadMore,
isTest,
]);
const tableMinWidth = 1010;
const renderRow = (
row: AuditLog,
index: number,
virtualRow?: { start: number; end: number },
) => {
if (!row) return null;
const details = parseAuditDetails(row.details);
const actorLabel = resolveAuditActor(row, details);
const actionLabel = resolveAuditAction(row, details);
const targetLabel = resolveAuditTarget(details);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
const { date, time } = formatAuditDateParts(row.timestamp);
return (
<tr
key={rowKey}
data-index={index}
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
className={cx(
commonTableRowClass,
"bg-card/40",
virtualRow ? "absolute left-0 w-full" : "",
)}
style={
virtualRow
? {
transform: `translateY(${virtualRow.start}px)`,
}
: undefined
}
>
<td colSpan={6} className="p-0">
<div className={cx("flex items-center", expanded && "border-b")}>
<div
className={cx(
commonTableCellClass,
"w-[190px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
</div>
<div className={cx(commonTableCellClass, "w-[180px] shrink-0")}>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{actorLabel}
</code>
{actorLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.actor_id",
"Copy User ID",
)}
onClick={() => handleCopy(actorLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</div>
<div
className={cx(
commonTableCellClass,
"w-[180px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="font-semibold text-foreground">{actionLabel}</div>
</div>
<div
className={cx(
commonTableCellClass,
"w-[260px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="flex items-center gap-2">
<span className="break-all">{targetLabel}</span>
{targetLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.target",
"Copy Client ID",
)}
onClick={() => handleCopy(targetLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</div>
<div className={cx(commonTableCellClass, "w-[120px] shrink-0")}>
<span
className={getCommonBadgeClasses({
variant: statusVariant(row.status),
})}
>
{row.status}
</span>
</div>
<div
className={cx(
commonTableCellClass,
"w-[80px] shrink-0 text-right",
)}
>
<button
type="button"
className={getCommonButtonClasses({
variant: "ghost",
size: "sm",
})}
onClick={() => {
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}));
// Re-measure after state change
setTimeout(() => rowVirtualizer.measure(), 0);
}}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
</div>
{expanded && (
<div className={cx(commonTableCellClass, "bg-card/20 text-xs")}>
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.request", "Request")}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.request_id",
"Request ID · {{value}}",
{ value: formatAuditValue(details.request_id) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.event_id",
"Event ID · {{value}}",
{ value: formatAuditValue(row.event_id) },
)}
</div>
<div>
{t("ui.common.audit.details.ip", "IP · {{value}}", {
value: formatAuditValue(row.ip_address),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.method", "Method · {{value}}", {
value: formatAuditValue(details.method),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.path", "Path · {{value}}", {
value: formatAuditValue(details.path),
})}
</div>
<div>
{t(
"ui.common.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.common.audit.details.actor_id",
"User ID · {{value}}",
{ value: actorLabel },
)}
</div>
<div>
{t("ui.common.audit.details.tenant", "Tenant · {{value}}", {
value: formatAuditValue(details.tenant_id),
})}
</div>
<div>
{t("ui.common.audit.details.device", "Device · {{value}}", {
value: formatAuditValue(row.device_id),
})}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.target",
"Client ID · {{value}}",
{ value: targetLabel },
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.result", "Result")}
</div>
<div className="break-all">
{t("ui.common.audit.details.error", "Error · {{value}}", {
value: formatAuditValue(details.error),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.before", "Before · {{value}}", {
value: formatAuditValue(details.before),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.after", "After · {{value}}", {
value: formatAuditValue(details.after),
})}
</div>
</div>
</div>
</div>
)}
</td>
</tr>
);
};
return (
<div className={cx(commonTableShellClass, className)}>
<div
ref={viewportRef}
className={cx(commonTableViewportClass, "flex-1")}
data-testid="audit-table-viewport"
>
<div
className={commonTableWrapperClass}
style={{ minWidth: tableMinWidth }}
>
<table
className={cx(commonTableClass, "table-fixed w-full")}
style={{ borderCollapse: "separate", borderSpacing: 0 }}
>
<thead
className={cx(
commonTableHeaderClass,
commonStickyTableHeaderClass,
)}
>
<tr className={commonTableRowClass}>
<th className={cx(commonTableHeadClass, "w-[190px]")}>
{t("ui.common.audit.table.time", "Time")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.user_id", "User ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.action", "Action")}
</th>
<th className={cx(commonTableHeadClass, "w-[260px]")}>
{t("ui.common.audit.table.client_id", "Client ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[120px]")}>
{t("ui.common.audit.table.status", "Status")}
</th>
<th className={cx(commonTableHeadClass, "w-[80px]")} />
</tr>
</thead>
<tbody
className={commonTableBodyClass}
style={
!isTest
? {
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}
: undefined
}
>
{isTest
? logs.map((row, index) => renderRow(row, index))
: virtualRows.map((virtualRow) =>
renderRow(
logs[virtualRow.index],
virtualRow.index,
virtualRow,
),
)}
{logs.length === 0 && !loading && (
<tr>
<td
colSpan={6}
className={cx(
commonTableCellClass,
"text-center py-8 text-muted-foreground",
)}
>
{t("ui.common.audit.table.no_logs", "No audit logs found")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div className="flex-shrink-0 border-t bg-background/50 p-4 text-center backdrop-blur-sm">
{hasNextPage ? (
<div className="flex flex-col items-center gap-2">
{isFetchingNextPage && (
<span className="animate-pulse text-xs text-muted-foreground">
{t("msg.common.loading", "Loading more...")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={onLoadMore}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.common.audit.load_more", "더 보기")}
</Button>
</div>
) : logs.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t("msg.common.audit.end", "End of audit feed")}
</span>
) : null}
</div>
</div>
);
}

View File

@@ -2,13 +2,14 @@ import { ShieldHalf } from "lucide-react";
import { useEffect } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import { debugLog } from "../../lib/debugLog";
function AuthCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
useEffect(() => {
console.debug("[AuthCallbackPage] State:", {
debugLog("[AuthCallbackPage] State:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
error: auth.error,

View File

@@ -0,0 +1,56 @@
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AuthGuard from "./AuthGuard";
const authState = {
activeNavigator: undefined,
error: undefined as Error | undefined,
isAuthenticated: false,
isLoading: false,
removeUser: vi.fn(async () => undefined),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
function renderAuthGuard(initialEntry = "/users") {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/" element={<AuthGuard />}>
<Route path="users" element={<div>Users outlet</div>} />
</Route>
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>
</MemoryRouter>,
);
}
describe("AuthGuard", () => {
beforeEach(() => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = false;
authState.activeNavigator = undefined;
authState.error = undefined;
authState.isAuthenticated = false;
authState.isLoading = false;
authState.removeUser.mockClear();
window.localStorage.clear();
});
it("clears stale auth state and returns to login when OIDC reports an error", async () => {
window.localStorage.setItem("admin_session", "stale-token");
authState.error = new Error("stale session");
renderAuthGuard();
await waitFor(() => {
expect(authState.removeUser).toHaveBeenCalled();
});
await screen.findByText("Login outlet");
expect(window.localStorage.getItem("admin_session")).toBeNull();
});
});

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import { clearStoredAdminAuthSession } from "../../lib/auth";
export default function AuthGuard() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const handledAuthErrorRef = useRef(false);
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
useEffect(() => {
if (!auth.error || handledAuthErrorRef.current || isTest) {
return;
}
handledAuthErrorRef.current = true;
clearStoredAdminAuthSession();
void Promise.resolve(
auth.removeUser ? auth.removeUser() : undefined,
).finally(() => {
navigate("/login", { replace: true });
});
}, [auth, auth.error, isTest, navigate]);
if (isTest) {
return <Outlet />;
}
if (auth.isLoading || auth.activeNavigator) {
return <div>Loading...</div>;
}
if (auth.error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
<div className="mb-4 text-destructive">
<h2 className="text-xl font-bold"> </h2>
<p>{auth.error.message}</p>
</div>
</div>
);
}
if (!auth.isAuthenticated) {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
return (
<Navigate
to={`/login?returnTo=${encodeURIComponent(returnTo)}`}
replace
/>
);
}
return <Outlet />;
}

View File

@@ -0,0 +1,38 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import AuthPage from "./AuthPage";
vi.mock("../../lib/i18n", () => createI18nMock());
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<AuthPage />
</QueryClientProvider>,
);
}
describe("AuthPage", () => {
beforeEach(() => {
window.localStorage.setItem("locale", "en");
});
it("renders localized auth guard labels in English", () => {
renderPage();
expect(screen.getByText("Auth Guard")).toBeInTheDocument();
expect(screen.getByText("ReBAC permission checker")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Check permission" }),
).toBeInTheDocument();
});
});

View File

@@ -1,109 +1,22 @@
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",
},
];
import { ShieldHalf } from "lucide-react";
import { PageHeader } from "../../../../common/core/components/page";
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
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>
<div className="space-y-6">
<PageHeader
titleAs="h2"
icon={<ShieldHalf size={20} />}
title={t("ui.admin.auth_guard.title", "Auth Guard")}
description={t(
"ui.admin.auth_guard.subtitle",
"Verify admin privileges and ReBAC relationships against the policy engine.",
)}
/>
<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>
<PermissionChecker />
</div>
);
}

View File

@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "./LoginPage";
const mockSigninRedirect = vi.fn();
const mockUseAuth = vi.fn();
vi.mock("react-oidc-context", () => ({
useAuth: () => mockUseAuth(),
}));
function renderLoginPage(initialEntry: string) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<LoginPage />
</MemoryRouter>,
);
}
describe("LoginPage", () => {
beforeEach(() => {
Object.defineProperty(window, "crypto", {
configurable: true,
value: {},
});
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: false,
});
mockSigninRedirect.mockReset();
mockUseAuth.mockReturnValue({
activeNavigator: undefined,
error: undefined,
isAuthenticated: false,
isLoading: false,
signinRedirect: mockSigninRedirect,
});
});
it("shows an actionable error instead of starting PKCE when WebCrypto is unavailable", async () => {
renderLoginPage("/login?returnTo=%2F");
await userEvent.click(
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
);
expect(mockSigninRedirect).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
/SSO 로그인을 시작할 수 없습니다/,
);
});
it("preserves the returnTo query when starting SSO manually", async () => {
Object.defineProperty(window, "crypto", {
configurable: true,
value: { subtle: {} },
});
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: true,
});
renderLoginPage("/login?returnTo=%2Fusers%3Fpage%3D2");
await userEvent.click(
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
);
expect(mockSigninRedirect).toHaveBeenCalledWith({
state: {
returnTo: "/users?page=2",
},
});
});
});

View File

@@ -1,5 +1,5 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useRef } from "react";
import { AlertTriangle, ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
@@ -10,17 +10,39 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { canStartBrowserPkceLogin } from "../../lib/authConfig";
import { debugLog } from "../../lib/debugLog";
const insecurePkceMessage =
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
function isPkceSetupFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message);
}
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const [loginError, setLoginError] = useState<string | null>(null);
const returnTo = searchParams.get("returnTo") || "/";
const shouldAutoLogin = searchParams.get("auto") === "1";
const authErrorMessage = useMemo(() => {
const message = auth.error?.message;
if (!message) {
return null;
}
if (message.includes("Crypto.subtle")) {
return insecurePkceMessage;
}
return message;
}, [auth.error?.message]);
const visibleLoginError = loginError || authErrorMessage;
useEffect(() => {
console.debug("[LoginPage] Auth state check:", {
debugLog("[LoginPage] Auth state check:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
returnTo,
@@ -41,21 +63,46 @@ function LoginPage() {
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
return;
}
if (!canStartBrowserPkceLogin()) {
setLoginError(insecurePkceMessage);
return;
}
autoStartedRef.current = true;
void auth.signinRedirect({
state: {
returnTo,
},
});
void auth
.signinRedirect({
state: {
returnTo,
},
})
.catch((error) => {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Auto login redirect failed", error);
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = () => {
void auth.signinRedirect({
state: {
returnTo: "/",
},
});
const handleSSOLogin = async () => {
try {
setLoginError(null);
if (!canStartBrowserPkceLogin()) {
setLoginError(insecurePkceMessage);
return;
}
await auth.signinRedirect({
state: {
returnTo,
},
});
} catch (error) {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Redirect login failed", error);
}
};
return (
@@ -84,8 +131,7 @@ function LoginPage() {
variant="ghost"
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
onClick={() => {
window.location.href =
window.location.origin + window.location.pathname;
void handleSSOLogin();
}}
>
@@ -123,6 +169,16 @@ function LoginPage() {
)}
</Button>
{visibleLoginError ? (
<div
role="alert"
className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm leading-5 text-destructive"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{visibleLoginError}</span>
</div>
) : null}
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
15 .
<br />

View File

@@ -0,0 +1,188 @@
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, XCircle } from "lucide-react";
import { useState } from "react";
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 apiClient from "../../../lib/apiClient";
import { t } from "../../../lib/i18n";
type CheckPermissionResponse = {
allowed: boolean;
query: {
namespace: string;
object: string;
relation: string;
subject: string;
};
};
function PermissionChecker() {
const [namespace, setNamespace] = useState("Tenant");
const [object, setObject] = useState("");
const [relation, setRelation] = useState("manage");
const [subject, setSubject] = useState("");
const checkMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.get<CheckPermissionResponse>(
"/v1/admin/debug/check-permission",
{
params: { namespace, object, relation, subject },
},
);
return data;
},
});
const result = checkMutation.data;
return (
<Card className="border-primary/20 bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-lg font-bold">
{t("ui.admin.auth_guard.checker.title", "ReBAC permission checker")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.auth_guard.checker.description",
"Check in real time whether a subject has access to a resource through Ory Keto.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label>
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
</Label>
<select
id="permission-checker-namespace"
name="permission-checker-namespace"
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="Tenant">
{t("ui.admin.auth_guard.checker.namespace.tenant", "Tenant")}
</option>
<option value="TenantGroup">
{t(
"ui.admin.auth_guard.checker.namespace.tenant_group",
"TenantGroup",
)}
</option>
<option value="RelyingParty">
{t(
"ui.admin.auth_guard.checker.namespace.relying_party",
"RelyingParty",
)}
</option>
<option value="System">
{t("ui.admin.auth_guard.checker.namespace.system", "System")}
</option>
</select>
</div>
<div className="space-y-2">
<Label>
{t("ui.admin.auth_guard.checker.relation", "Relation")}
</Label>
<Input
placeholder={t(
"ui.admin.auth_guard.checker.relation_placeholder",
"view, manage, admins...",
)}
value={relation}
onChange={(e) => setRelation(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>
{t("ui.admin.auth_guard.checker.object_id", "Object ID")}
</Label>
<Input
placeholder={t(
"ui.admin.auth_guard.checker.object_id_placeholder",
"Tenant UUID, etc.",
)}
value={object}
onChange={(e) => setObject(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>
{t("ui.admin.auth_guard.checker.subject", "Subject (User:ID)")}
</Label>
<Input
placeholder={t(
"ui.admin.auth_guard.checker.subject_placeholder",
"User:uuid or Namespace:ID#Relation",
)}
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
</div>
<div className="flex justify-center">
<Button
onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending}
className="w-full px-12 md:w-auto"
>
{checkMutation.isPending
? t("ui.admin.auth_guard.checker.checking", "Checking...")
: t("ui.admin.auth_guard.checker.check", "Check permission")}
</Button>
</div>
{checkMutation.isSuccess && result && (
<div
className={`flex flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 animate-in zoom-in duration-300 ${
result.allowed
? "border-green-500/50 bg-green-500/10 text-green-600"
: "border-destructive/50 bg-destructive/10 text-destructive"
}`}
>
{result.allowed ? (
<>
<CheckCircle2 size={48} />
<div className="text-lg font-bold">
{t("ui.admin.auth_guard.checker.allowed", "Access ALLOWED")}
</div>
<p className="text-center text-sm opacity-80">
{t(
"ui.admin.auth_guard.checker.allowed_description",
"The subject has access to the requested resource, including inherited permissions.",
)}
</p>
</>
) : (
<>
<XCircle size={48} />
<div className="text-lg font-bold">
{t("ui.admin.auth_guard.checker.denied", "Access DENIED")}
</div>
<p className="text-center text-sm opacity-80">
{t(
"ui.admin.auth_guard.checker.denied_description",
"The subject does not have access to the requested resource.",
)}
</p>
</>
)}
</div>
)}
</CardContent>
</Card>
);
}
export default PermissionChecker;

View File

@@ -0,0 +1,192 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import AuditLogsPage from "../audit/AuditLogsPage";
import AuthCallbackPage from "../auth/AuthCallbackPage";
import AuthGuard from "../auth/AuthGuard";
const authState = {
isAuthenticated: true,
isLoading: false,
activeNavigator: undefined as string | undefined,
error: null as Error | null,
user: {
access_token: "access-token",
state: undefined as unknown,
},
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../../../common/core/components/audit", () => ({
AuditLogTable: ({
logs,
}: {
logs: Array<{ user_id: string; event_type: string }>;
}) => (
<div>
{logs.map((log) => (
<div key={`${log.user_id}-${log.event_type}`}>
<span>{log.user_id}</span>
<span>{log.event_type}</span>
</div>
))}
</div>
),
}));
vi.mock("../../lib/adminApi", () => ({
fetchAuditLogs: vi.fn(async () => ({
items: [
{
event_id: "event-1",
timestamp: "2026-05-01T00:00:00Z",
user_id: "admin-1",
event_type: "USER_UPDATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
details: JSON.stringify({ action: "USER_UPDATE", actor: "Admin" }),
},
{
event_id: "event-2",
timestamp: "2026-05-01T01:00:00Z",
user_id: "admin-2",
event_type: "LOGIN_FAILED",
status: "failure",
ip_address: "127.0.0.2",
user_agent: "Vitest",
details: "{}",
},
],
limit: 50,
})),
}));
function renderWithProviders(ui: React.ReactElement, entry = "/") {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin audit and auth coverage smoke", () => {
beforeEach(() => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = false;
authState.isAuthenticated = true;
authState.isLoading = false;
authState.activeNavigator = undefined;
authState.error = null;
authState.user = {
access_token: "access-token",
state: undefined,
};
window.localStorage.clear();
});
it("renders audit log table with fetched events", async () => {
renderWithProviders(<AuditLogsPage />);
expect(await screen.findByText("감사 로그")).toBeInTheDocument();
expect(await screen.findByText("admin-1")).toBeInTheDocument();
expect(screen.getByText("USER_UPDATE")).toBeInTheDocument();
});
it("renders AuthGuard loading, error, redirect, test, and outlet states", async () => {
authState.isLoading = true;
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
</Routes>,
"/secure",
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
authState.isLoading = false;
authState.error = new Error("OIDC failed");
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
</Routes>,
"/secure",
);
expect(screen.getByText("인증 오류")).toBeInTheDocument();
authState.error = null;
authState.isAuthenticated = false;
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>,
"/secure?x=1",
);
expect(screen.getByText("Login outlet")).toBeInTheDocument();
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
</Routes>,
"/secure",
);
expect(screen.getByText("Secure outlet")).toBeInTheDocument();
});
it("stores callback token and navigates by auth result", async () => {
authState.isAuthenticated = true;
authState.user = {
access_token: "callback-token",
state: { returnTo: "/users" },
};
renderWithProviders(
<Routes>
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/users" element={<div>Users outlet</div>} />
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>,
"/auth/callback",
);
expect(await screen.findByText("Users outlet")).toBeInTheDocument();
expect(window.localStorage.getItem("admin_session")).toBe("callback-token");
authState.isAuthenticated = false;
authState.error = new Error("callback failed");
renderWithProviders(
<Routes>
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>,
"/auth/callback",
);
expect(await screen.findByText("Login outlet")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,506 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import * as adminApi from "../../lib/adminApi";
import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage";
import TenantListPage from "../tenants/routes/TenantListPage";
import UserCreatePage from "../users/UserCreatePage";
import UserDetailPage from "../users/UserDetailPage";
const tenantItems = [
{
id: "tenant-root",
type: "COMPANY_GROUP",
name: "한맥 가족",
slug: "hanmac-family",
description: "root",
status: "active",
memberCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-company",
type: "COMPANY",
parentId: "tenant-root",
name: "GPDTDC",
slug: "gpdtdc",
description: "company",
status: "active",
memberCount: 2,
config: {
userSchema: [
{
key: "employee_id",
label: "사번",
type: "text",
required: false,
},
],
},
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-leaf",
type: "ORGANIZATION",
parentId: "tenant-company",
name: "기술연구팀",
slug: "gpdtdc-rnd",
description: "leaf",
status: "active",
memberCount: 1,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
];
const userDetail = {
id: "user-1",
email: "engineer@example.com",
name: "Engineer User",
phone: "010-0000-0000",
role: "user",
status: "active",
tenantSlug: "gpdtdc-rnd",
tenantId: "tenant-leaf",
department: "기술연구팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: {
employee_id: "EMP001",
sub_email: ["engineer.sub@example.com"],
},
tenant: tenantItems[2],
appointments: [
{
tenantId: "tenant-leaf",
tenantSlug: "gpdtdc-rnd",
tenantName: "기술연구팀",
isPrimary: true,
isOwner: false,
isAdmin: false,
isManager: true,
department: "기술연구팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: { employee_id: "EMP001" },
},
],
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-02T00:00:00Z",
};
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../components/auth/RoleGuard", () => ({
RoleGuard: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({
id: "admin-1",
role: "super_admin",
name: "Admin User",
email: "admin@example.com",
})),
fetchAllTenants: vi.fn(async () => ({
items: tenantItems,
total: tenantItems.length,
})),
fetchTenants: vi.fn(async () => ({
items: tenantItems,
limit: 500,
offset: 0,
total: tenantItems.length,
nextCursor: null,
})),
fetchTenant: vi.fn(async (id: string) => {
return tenantItems.find((tenant) => tenant.id === id) ?? tenantItems[1];
}),
createUser: vi.fn(async () => ({
id: "created-user",
email: "created@example.com",
generatedPassword: "GeneratedPassword!1",
})),
fetchUser: vi.fn(async () => userDetail),
fetchUserRpHistory: vi.fn(async () => [
{
client_id: "orgfront",
client_name: "OrgFront",
last_login_at: "2026-05-01T00:00:00Z",
login_count: 3,
},
]),
fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({ items: [] })),
fetchPasswordPolicy: vi.fn(async () => ({
minLength: 12,
lowercase: true,
uppercase: true,
number: true,
nonAlphanumeric: true,
minCharacterTypes: 3,
})),
updateUser: vi.fn(async () => userDetail),
deleteUser: vi.fn(async () => undefined),
updateTenant: vi.fn(async () => tenantItems[1]),
deleteTenantsBulk: vi.fn(async () => ({ deleted: 1 })),
exportTenantsCSV: vi.fn(async () => new Blob(["name,slug\nGPDTDC,gpdtdc"])),
importTenantsCSV: vi.fn(async () => ({
created: 1,
updated: 0,
failed: 0,
errors: [],
})),
fetchWorksmobileOverview: vi.fn(async () => ({
tenant: tenantItems[1],
config: {
enabled: true,
tokenConfigured: true,
adminTenantId: "works-admin",
domainMappings: { "example.com": 1001 },
},
recentJobs: [
{
id: "job-1",
resourceType: "USER",
resourceId: "user-1",
action: "SYNC",
status: "failed",
retryCount: 1,
lastError: "temporary failure",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:10:00Z",
},
],
})),
fetchWorksmobileComparison: vi.fn(async () => ({
users: [
{
resourceType: "USER",
baronId: "user-1",
baronName: "Engineer User",
baronEmail: "engineer@example.com",
baronPrimaryOrgId: "tenant-leaf",
baronPrimaryOrgName: "기술연구팀",
worksmobileId: "works-user-1",
worksmobileName: "Engineer User",
worksmobileEmail: "engineer@example.com",
worksmobileDomainId: 1001,
worksmobilePrimaryOrgId: "works-org-1",
worksmobilePrimaryOrgName: "기술연구팀",
status: "matched",
},
{
resourceType: "USER",
baronId: "user-2",
baronName: "New User",
baronEmail: "new@example.com",
worksmobileJobStatus: "failed",
worksmobileJobRetryCount: 2,
worksmobileLastError: "worksmobile api failed",
status: "missing_in_worksmobile",
},
{
resourceType: "USER",
baronId: "user-3",
baronName: "Next User",
baronEmail: "next@example.com",
status: "missing_in_worksmobile",
},
],
groups: [
{
resourceType: "ORG_UNIT",
baronId: "tenant-leaf",
baronSlug: "gpdtdc-rnd",
baronName: "기술연구팀",
worksmobileId: "works-org-1",
worksmobileName: "기술연구팀",
status: "needs_update",
},
],
})),
fetchWorksmobileCredentialBatches: vi.fn(async () => [
{
batchId: "credential-batch-1",
operation: "worksmobile_user_sync",
userCount: 1,
processedCount: 1,
failedCount: 1,
hasPasswords: true,
failures: [
{
userId: "failed-user",
email: "failed-user@samaneng.com",
status: "failed",
retryCount: 2,
lastError: "worksmobile api failed",
updatedAt: "2026-06-01T04:05:00Z",
},
],
createdAt: "2026-06-01T04:00:00Z",
updatedAt: "2026-06-01T04:00:00Z",
},
{
batchId: "credential-batch-pending",
operation: "worksmobile_user_sync",
userCount: 2,
pendingCount: 1,
processingCount: 1,
processedCount: 0,
failedCount: 0,
hasPasswords: true,
createdAt: "2026-06-01T04:10:00Z",
updatedAt: "2026-06-01T04:10:00Z",
},
]),
enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })),
retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })),
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => ({
blob: new Blob(["id"]),
filename: "worksmobile_initial_passwords.csv",
})),
enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })),
enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })),
enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })),
resetWorksmobileUserPassword: vi.fn(async () => ({ id: "job-reset" })),
deleteWorksmobileCredentialBatchPasswords: vi.fn(async () => ({
batchId: "credential-batch-1",
userCount: 1,
hasPasswords: false,
})),
}));
function renderWithProviders(ui: React.ReactElement, entry = "/") {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("adminfront large page coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
if (typeof window !== "undefined") {
(window as any)._IS_TEST_MODE = true;
}
});
it("renders user creation form with tenant context", async () => {
renderWithProviders(
<Routes>
<Route path="/users/new" element={<UserCreatePage />} />
</Routes>,
"/users/new?tenantSlug=gpdtdc-rnd",
);
expect(await screen.findByText("사용자 추가")).toBeInTheDocument();
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
});
it("renders user detail form and RP history", async () => {
renderWithProviders(
<Routes>
<Route path="/users/:id" element={<UserDetailPage />} />
</Routes>,
"/users/user-1",
);
expect(await screen.findByDisplayValue("Engineer User")).toBeInTheDocument();
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
expect(screen.getByDisplayValue("engineer@example.com")).toBeInTheDocument();
});
it("renders tenant list hierarchy", async () => {
renderWithProviders(
<Routes>
<Route path="/tenants" element={<TenantListPage />} />
</Routes>,
"/tenants",
);
expect(await screen.findByText("GPDTDC")).toBeInTheDocument();
expect(screen.getByText("기술연구팀")).toBeInTheDocument();
});
it("renders worksmobile comparison screens", async () => {
cleanup();
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/worksmobile"
element={<TenantWorksmobilePage />}
/>
</Routes>,
"/tenants/tenant-company/worksmobile",
);
expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument();
expect(await screen.findByText("Baron / Works 비교")).toBeInTheDocument();
expect(
await screen.findByText("최근 실패: worksmobile api failed"),
).toBeInTheDocument();
expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "초기 비밀번호 CSV" })).toBeNull();
});
it("does not automatically download the selected Worksmobile user credential batch after create enqueue", async () => {
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/worksmobile"
element={<TenantWorksmobilePage />}
/>
</Routes>,
"/tenants/tenant-company/worksmobile",
);
await screen.findByText("New User");
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
fireEvent.click(
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
);
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
target: { value: "InitialPassword!1" },
});
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
await waitFor(() =>
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
"tenant-company",
"user-2",
undefined,
"InitialPassword!1",
),
);
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
});
it("continues selected Worksmobile user create enqueue after one row fails", async () => {
vi.mocked(adminApi.enqueueWorksmobileUserSync)
.mockRejectedValueOnce(new Error("sync failed"))
.mockResolvedValueOnce({ id: "job-user-3" } as never);
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/worksmobile"
element={<TenantWorksmobilePage />}
/>
</Routes>,
"/tenants/tenant-company/worksmobile",
);
await screen.findByText("New User");
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
fireEvent.click(screen.getByRole("checkbox", { name: "Next User 선택" }));
fireEvent.click(
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
);
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
target: { value: "InitialPassword!1" },
});
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
await waitFor(() =>
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
);
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
1,
"tenant-company",
"user-2",
undefined,
"InitialPassword!1",
);
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
2,
"tenant-company",
"user-3",
undefined,
"InitialPassword!1",
);
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
});
it("renders and retries Worksmobile jobs from history", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/worksmobile"
element={<TenantWorksmobilePage />}
/>
</Routes>,
"/tenants/tenant-company/worksmobile",
);
fireEvent.click(screen.getByRole("tab", { name: "이력" }));
expect((await screen.findAllByText("user-1")).length).toBeGreaterThan(0);
expect(screen.getByText("failed")).toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: "" })[0]);
await waitFor(() =>
expect(adminApi.retryWorksmobileJob).toHaveBeenCalledWith(
"tenant-company",
"job-1",
),
);
});
it("opens Worksmobile password management for matched users", async () => {
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/worksmobile"
element={<TenantWorksmobilePage />}
/>
</Routes>,
"/tenants/tenant-company/worksmobile",
);
await screen.findByText("Worksmobile 연동");
fireEvent.click(screen.getAllByRole("button", { name: "양쪽 다 있음" })[0]);
await screen.findAllByText("Engineer User");
fireEvent.click(
screen.getByRole("button", {
name: "Engineer User 비밀번호 관리",
}),
);
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining(
"https://auth.worksmobile.com/integrate/password/manage",
),
"_blank",
"noopener,noreferrer",
);
const [url] = openSpy.mock.calls[0] ?? [];
const parsed = new URL(String(url));
expect(parsed.searchParams.get("targetUserTenantId")).toBe("works-admin");
expect(parsed.searchParams.get("targetUserDomainId")).toBe("1001");
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
});
});

View File

@@ -0,0 +1,129 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import TenantCreatePage from "../tenants/routes/TenantCreatePage";
import { TenantProfilePage } from "../tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../tenants/routes/TenantSchemaPage";
const tenants = [
{
id: "tenant-root",
type: "COMPANY_GROUP",
name: "한맥 가족",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
domains: ["hmac.kr"],
config: { visibility: "public" },
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-company",
type: "COMPANY",
parentId: "tenant-root",
name: "GPDTDC",
slug: "gpdtdc",
description: "실 조직",
status: "active",
memberCount: 2,
domains: ["gpdtdc.example.com"],
config: {
visibility: "public",
userSchema: [
{
key: "employee_id",
label: "사번",
type: "text",
required: false,
adminOnly: false,
isLoginId: true,
indexed: true,
},
],
},
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
];
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({
id: "admin-1",
role: "super_admin",
})),
fetchAllTenants: vi.fn(async () => ({
items: tenants,
total: tenants.length,
})),
fetchTenant: vi.fn(async (id: string) => {
return tenants.find((tenant) => tenant.id === id) ?? tenants[1];
}),
createTenant: vi.fn(async () => tenants[1]),
updateTenant: vi.fn(async () => tenants[1]),
deleteTenant: vi.fn(async () => undefined),
approveTenant: vi.fn(async () => tenants[1]),
}));
function renderWithProviders(ui: React.ReactElement, entry: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin tenant detail page coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders tenant create page with parent context", async () => {
renderWithProviders(
<Routes>
<Route path="/tenants/new" element={<TenantCreatePage />} />
</Routes>,
"/tenants/new?parentId=tenant-root",
);
expect(await screen.findByText("테넌트 생성")).toBeInTheDocument();
expect(screen.getByText("Tenant Profile")).toBeInTheDocument();
expect(screen.getByText("정책 메모")).toBeInTheDocument();
});
it("renders tenant profile and schema management pages", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId"
element={
<>
<TenantProfilePage />
<TenantSchemaPage />
</>
}
/>
</Routes>,
"/tenants/tenant-company",
);
expect(await screen.findByDisplayValue("GPDTDC")).toBeInTheDocument();
expect(screen.getByDisplayValue("gpdtdc")).toBeInTheDocument();
expect(await screen.findByText("사용자 스키마 확장")).toBeInTheDocument();
expect(screen.getByDisplayValue("employee_id")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,116 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import TenantGroupsPage from "../tenants/routes/TenantGroupsPage";
const tenant = {
id: "tenant-company",
type: "COMPANY",
name: "GPDTDC",
slug: "gpdtdc",
description: "",
status: "active",
memberCount: 2,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
};
const members = [
{
id: "user-1",
name: "Member User",
email: "member@example.com",
},
];
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchTenant: vi.fn(async () => tenant),
fetchUsers: vi.fn(async () => ({
items: [
{
id: "user-1",
name: "Member User",
email: "member@example.com",
role: "user",
status: "active",
},
{
id: "user-2",
name: "Candidate User",
email: "candidate@example.com",
role: "user",
status: "active",
},
],
total: 2,
})),
fetchGroups: vi.fn(async () => [
{
id: "group-root",
tenantId: "tenant-company",
name: "연구소",
description: "root group",
members,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "group-child",
tenantId: "tenant-company",
parentId: "group-root",
name: "플랫폼팀",
description: "child group",
members: [],
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
createGroup: vi.fn(async () => undefined),
deleteGroup: vi.fn(async () => undefined),
addGroupMember: vi.fn(async () => undefined),
removeGroupMember: vi.fn(async () => undefined),
}));
function renderWithProviders(ui: React.ReactElement, entry: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantGroupsPage coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders group hierarchy and selected group members", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/groups"
element={<TenantGroupsPage />}
/>
</Routes>,
"/tenants/tenant-company/groups",
);
expect((await screen.findAllByText("연구소")).length).toBeGreaterThan(0);
expect(screen.getAllByText("플랫폼팀").length).toBeGreaterThan(0);
expect(screen.getByText("새 그룹 생성")).toBeInTheDocument();
expect(screen.getByText("조직 단위 레벨")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,196 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
const exportUsersCSVMock = vi.hoisted(() =>
vi.fn(async () => ({
blob: new Blob(["email,name\nmember@example.com,Member User\n"], {
type: "text/csv",
}),
filename: "users_export_20260609.csv",
})),
);
const tenants = [
{
id: "tenant-root",
type: "COMPANY_GROUP",
name: "한맥 가족",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-company",
type: "COMPANY",
parentId: "tenant-root",
name: "GPDTDC",
slug: "gpdtdc",
description: "",
status: "active",
memberCount: 2,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-leaf",
type: "ORGANIZATION",
parentId: "tenant-company",
name: "기술연구팀",
slug: "gpdtdc-rnd",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
];
const users = [
{
id: "user-owner",
name: "Owner User",
email: "owner@example.com",
role: "tenant_admin",
status: "active",
},
{
id: "user-admin",
name: "Admin User",
email: "admin@example.com",
role: "tenant_admin",
status: "active",
},
{
id: "user-member",
name: "Member User",
email: "member@example.com",
role: "user",
status: "active",
tenantSlug: "gpdtdc-rnd",
tenant: tenants[2],
},
];
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("react-oidc-context", () => ({
useAuth: () => ({
user: {
profile: {
sub: "admin-1",
},
},
}),
}));
vi.mock("../../lib/adminApi", () => ({
fetchTenantOwners: vi.fn(async () => [users[0]]),
fetchTenantAdmins: vi.fn(async () => [users[1]]),
addTenantOwner: vi.fn(async () => undefined),
addTenantAdmin: vi.fn(async () => undefined),
removeTenantOwner: vi.fn(async () => undefined),
removeTenantAdmin: vi.fn(async () => undefined),
fetchUsers: vi.fn(async () => ({
items: users,
total: users.length,
})),
fetchAllTenants: vi.fn(async () => ({
items: tenants,
total: tenants.length,
})),
updateTenant: vi.fn(async () => tenants[2]),
updateUser: vi.fn(async () => users[2]),
exportTenantsCSV: vi.fn(async () => ({
blob: new Blob(["name,slug"]),
filename: "tenants.csv",
})),
exportUsersCSV: exportUsersCSVMock,
}));
function renderWithProviders(ui: React.ReactElement, entry: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin tenant tab coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
"blob:tenant-users-export",
);
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
});
it("renders tenant owners and admins lists", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/permissions"
element={<TenantAdminsAndOwnersTab />}
/>
</Routes>,
"/tenants/tenant-company/permissions",
);
expect(await screen.findByText("Owner User")).toBeInTheDocument();
expect(screen.getByText("Admin User")).toBeInTheDocument();
expect(screen.getByText("owner@example.com")).toBeInTheDocument();
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
});
it("renders tenant hierarchy and selected organization members", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/organization"
element={<TenantUserGroupsTab />}
/>
</Routes>,
"/tenants/tenant-company/organization",
);
expect((await screen.findAllByText("GPDTDC")).length).toBeGreaterThan(0);
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
expect(await screen.findByText("Member User")).toBeInTheDocument();
});
it("exports selected organization users by tenant slug", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/organization"
element={<TenantUserGroupsTab />}
/>
</Routes>,
"/tenants/tenant-company/organization",
);
expect(await screen.findByText("Member User")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("tenant-current-users-export-btn"));
await waitFor(() => {
expect(exportUsersCSVMock).toHaveBeenCalledWith("", "gpdtdc", false);
});
});
});

View File

@@ -0,0 +1,250 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
deleteOrphanUserLoginIDs,
fetchDataIntegrityReport,
fetchMe,
fetchOrySSOTSystemStatus,
fetchOrphanUserLoginIDs,
flushIdentityCache,
} from "../../lib/adminApi";
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
import { createI18nMock } from "../../test/i18nMock";
import DataIntegrityPage from "./DataIntegrityPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
const integrityReport = {
status: "fail",
checkedAt: "2026-05-14T00:00:00Z",
summary: {
totalChecks: 2,
passed: 1,
warnings: 0,
failures: 1,
},
sections: [
{
key: "tenant_integrity",
label: "테넌트 정합성",
status: "fail",
checks: [
{
key: "duplicate_tenant_slugs",
label: "중복 테넌트 slug",
description: "active tenant slug의 대소문자 무시 중복을 검사합니다.",
status: "fail",
severity: "error",
count: 1,
},
],
},
],
};
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchDataIntegrityReport: vi.fn(async () => integrityReport),
fetchOrphanUserLoginIDs: vi.fn(async () => ({
items: [
{
id: "login-id-1",
userId: "user-1",
userEmail: "missing@example.com",
tenantId: "tenant-1",
tenantSlug: "deleted-tenant",
fieldKey: "emp_id",
loginId: "EMP001",
reasons: ["deleted_tenant"],
},
],
total: 1,
})),
fetchOrySSOTSystemStatus: vi.fn(async () => ({
userProjection: {
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
},
identityCache: {
status: "ready",
redisReady: true,
observedCount: 151,
keyCount: 153,
lastRefreshedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
},
})),
flushIdentityCache: vi.fn(async () => ({
status: "success",
flushedKeys: 153,
updatedAt: "2026-05-11T03:02:00Z",
})),
deleteOrphanUserLoginIDs: vi.fn(async () => ({
deletedCount: 1,
deleted: [
{
id: "login-id-1",
userId: "user-1",
tenantId: "tenant-1",
fieldKey: "emp_id",
loginId: "EMP001",
reasons: ["deleted_tenant"],
},
],
skippedIds: [],
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<DataIntegrityPage />
</QueryClientProvider>,
);
}
describe("DataIntegrityPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
window.localStorage.setItem("locale", "ko");
});
it("renders integrity report for super_admin", async () => {
renderPage();
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "정합성 검사" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "Ory SSOT 시스템" }),
).toBeInTheDocument();
expect(
await screen.findByText(
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
),
).toBeInTheDocument();
expect(await screen.findByText("테넌트 정합성")).toBeInTheDocument();
expect(screen.getByText("중복 테넌트 slug")).toBeInTheDocument();
expect(screen.getAllByText("1").length).toBeGreaterThan(0);
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("renders Ory SSOT cache management inside data integrity", async () => {
renderPage();
fireEvent.click(
await screen.findByRole("tab", { name: "Ory SSOT 시스템" }),
);
expect(
(await screen.findAllByText("Ory SSOT 시스템")).length,
).toBeGreaterThan(0);
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
expect(screen.getByText("152")).toBeInTheDocument();
expect(screen.getByText("151")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
await waitFor(() => {
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
});
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
it("shows orphan login ID targets and deletes selected rows", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const { container } = renderPage();
expect(await screen.findByText("유령 로그인 ID 정리")).toBeInTheDocument();
expect(await screen.findByText("EMP001")).toBeInTheDocument();
expect(screen.getByText("삭제된 테넌트")).toBeInTheDocument();
expectNoAnonymousFormFields(container);
expect(fetchOrphanUserLoginIDs).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("checkbox", { name: "EMP001 선택" }));
fireEvent.click(screen.getByRole("button", { name: "선택 삭제" }));
await waitFor(() => {
expect(deleteOrphanUserLoginIDs).toHaveBeenCalled();
});
expect(vi.mocked(deleteOrphanUserLoginIDs).mock.calls[0][0]).toEqual([
"login-id-1",
]);
});
it("disables recheck button and shows manual recheck progress", async () => {
let finishRecheck: (value: typeof integrityReport) => void = () => {};
const pendingRecheck = new Promise<typeof integrityReport>((resolve) => {
finishRecheck = resolve;
});
renderPage();
expect(await screen.findByText("중복 테넌트 slug")).toBeInTheDocument();
vi.mocked(fetchDataIntegrityReport).mockImplementationOnce(
() => pendingRecheck,
);
fireEvent.click(screen.getByRole("button", { name: "다시 검사" }));
expect(screen.getByRole("button", { name: "검사 중" })).toBeDisabled();
expect(
screen.getByText("정합성 검사를 실행 중입니다."),
).toBeInTheDocument();
finishRecheck(integrityReport);
await waitFor(() => {
expect(screen.getByRole("button", { name: "다시 검사" })).toBeEnabled();
});
expect(screen.getByText("검사가 완료되었습니다.")).toBeInTheDocument();
});
it("blocks non-super admins", async () => {
currentRole = "tenant_admin";
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(fetchMe).toHaveBeenCalled();
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
});
it("renders localized integrity labels in English", async () => {
window.localStorage.setItem("locale", "en");
renderPage();
expect(await screen.findByText("Data Integrity Check")).toBeInTheDocument();
expect(
await screen.findByText(
"Review integrity status and inspect checks across the admin data model.",
),
).toBeInTheDocument();
expect(await screen.findByText("Tenant integrity")).toBeInTheDocument();
expect(
await screen.findByText("Duplicate tenant slug"),
).toBeInTheDocument();
expect(
await screen.findByText(
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",
),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,640 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
Database,
ShieldAlert,
} from "lucide-react";
import { useState } from "react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
type DataIntegrityCheck,
type DataIntegrityStatus,
deleteOrphanUserLoginIDs,
fetchDataIntegrityReport,
fetchOrphanUserLoginIDs,
type OrphanUserLoginID,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
import { UserProjectionContent } from "../projections/UserProjectionPage";
function statusLabel(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return t("ui.admin.integrity.status.pass", "정상");
case "warning":
return t("ui.admin.integrity.status.warning", "주의");
case "fail":
return t("ui.admin.integrity.status.fail", "실패");
default:
return status;
}
}
function statusBadgeVariant(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "success";
case "warning":
return "warning";
default:
return "warning";
}
}
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(getAdminDateLocale(), {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
}
function CheckIcon({ check }: { check: DataIntegrityCheck }) {
if (check.status === "pass") {
return <CheckCircle2 className="text-emerald-600" size={18} />;
}
if (check.status === "warning") {
return <AlertTriangle className="text-amber-600" size={18} />;
}
return <ShieldAlert className="text-destructive" size={18} />;
}
function reasonLabel(reason: string) {
switch (reason) {
case "missing_user":
return t("ui.admin.integrity.reason.missing_user", "사용자 없음");
case "deleted_user":
return t("ui.admin.integrity.reason.deleted_user", "삭제된 사용자");
case "missing_tenant":
return t("ui.admin.integrity.reason.missing_tenant", "테넌트 없음");
case "deleted_tenant":
return t("ui.admin.integrity.reason.deleted_tenant", "삭제된 테넌트");
default:
return reason;
}
}
function integritySectionLabel(key: string, fallback: string) {
switch (key) {
case "tenant_integrity":
return t("ui.admin.integrity.section.tenant_integrity", fallback);
case "user_integrity":
return t("ui.admin.integrity.section.user_integrity", fallback);
default:
return fallback;
}
}
function integritySectionDescription(key: string) {
switch (key) {
case "tenant_integrity":
return t(
"msg.admin.integrity.section.tenant_integrity.description",
"테넌트 slug 중복과 부모 관계 이상을 확인합니다.",
);
case "user_integrity":
return t(
"msg.admin.integrity.section.user_integrity.description",
"사용자와 로그인 ID 참조의 고아 레코드를 확인합니다.",
);
default:
return "";
}
}
function integrityCheckLabel(key: string, fallback: string) {
switch (key) {
case "duplicate_tenant_slugs":
return t(
"ui.admin.integrity.check.duplicate_tenant_slugs.title",
fallback,
);
case "orphan_tenant_parents":
return t(
"ui.admin.integrity.check.orphan_tenant_parents.title",
fallback,
);
case "orphan_user_tenant_memberships":
return t(
"ui.admin.integrity.check.orphan_user_tenant_memberships.title",
fallback,
);
case "orphan_user_login_id_tenants":
return t(
"ui.admin.integrity.check.orphan_user_login_id_tenants.title",
fallback,
);
case "orphan_user_login_id_users":
return t(
"ui.admin.integrity.check.orphan_user_login_id_users.title",
fallback,
);
default:
return fallback;
}
}
function integrityCheckDescription(key: string, fallback: string) {
switch (key) {
case "duplicate_tenant_slugs":
return t(
"msg.admin.integrity.check.duplicate_tenant_slugs.description",
fallback,
);
case "orphan_tenant_parents":
return t(
"msg.admin.integrity.check.orphan_tenant_parents.description",
fallback,
);
case "orphan_user_tenant_memberships":
return t(
"msg.admin.integrity.check.orphan_user_tenant_memberships.description",
fallback,
);
case "orphan_user_login_id_tenants":
return t(
"msg.admin.integrity.check.orphan_user_login_id_tenants.description",
fallback,
);
case "orphan_user_login_id_users":
return t(
"msg.admin.integrity.check.orphan_user_login_id_users.description",
fallback,
);
default:
return fallback;
}
}
function recheckStatusText(status: "idle" | "running" | "success" | "error") {
switch (status) {
case "running":
return t(
"msg.admin.integrity.recheck.running",
"정합성 검사를 실행 중입니다.",
);
case "success":
return t("msg.admin.integrity.recheck.success", "검사가 완료되었습니다.");
case "error":
return t("msg.admin.integrity.recheck.error", "검사에 실패했습니다.");
default:
return "";
}
}
function pageTabClassName(active: boolean) {
return `relative px-6 py-3 text-sm font-medium transition-colors ${
active
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`;
}
function OrphanLoginIDTable({
items,
selectedIds,
onToggle,
}: {
items: OrphanUserLoginID[];
selectedIds: string[];
onToggle: (id: string) => void;
}) {
if (items.length === 0) {
return (
<div className="rounded border border-border/60 px-3 py-6 text-center text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.empty",
"삭제할 유령 로그인 ID가 없습니다.",
)}
</div>
);
}
const selectedSet = new Set(selectedIds);
return (
<div className="overflow-x-auto rounded border border-border/60">
<table className="w-full min-w-[760px] text-sm">
<thead className="bg-muted/50 text-left text-muted-foreground">
<tr>
<th className="w-12 px-3 py-2">
{t("ui.admin.integrity.table.select", "선택")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.login_id", "Login ID")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.field", "Field")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.user", "User")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.tenant", "Tenant")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.reason", "사유")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => (
<tr key={item.id}>
<td className="px-3 py-2">
<input
name={`orphan-login-id-select-${item.id}`}
type="checkbox"
aria-label={t(
"ui.admin.integrity.table.select_item",
"{{loginId}} 선택",
{ loginId: item.loginId },
)}
checked={selectedSet.has(item.id)}
onChange={() => onToggle(item.id)}
className="h-4 w-4 rounded border-input"
/>
</td>
<td className="px-3 py-2 font-medium">{item.loginId}</td>
<td className="px-3 py-2 text-muted-foreground">
{item.fieldKey}
</td>
<td className="px-3 py-2">
<div>{item.userEmail || "-"}</div>
<div className="text-xs text-muted-foreground">
{item.userId}
</div>
</td>
<td className="px-3 py-2">
<div>{item.tenantSlug || "-"}</div>
<div className="text-xs text-muted-foreground">
{item.tenantId}
</div>
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{item.reasons.map((reason) => (
<Badge key={reason} variant="warning">
{reasonLabel(reason)}
</Badge>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function DataIntegrityContent() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
"integrity",
);
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error"
>("idle");
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ["data-integrity-report"],
queryFn: fetchDataIntegrityReport,
});
const orphanLoginIDsQuery = useQuery({
queryKey: ["orphan-user-login-ids"],
queryFn: fetchOrphanUserLoginIDs,
});
const deleteMutation = useMutation({
mutationFn: deleteOrphanUserLoginIDs,
onSuccess: async () => {
setSelectedOrphanIds([]);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["data-integrity-report"] }),
queryClient.invalidateQueries({ queryKey: ["orphan-user-login-ids"] }),
]);
},
});
const orphanItems = orphanLoginIDsQuery.data?.items ?? [];
const toggleOrphanID = (id: string) => {
setSelectedOrphanIds((current) =>
current.includes(id)
? current.filter((selectedID) => selectedID !== id)
: [...current, id],
);
};
const handleDeleteSelected = () => {
if (selectedOrphanIds.length === 0) {
return;
}
const confirmed = window.confirm(
t(
"msg.admin.integrity.orphan_login_ids.delete_confirm",
"선택한 {{count}}개의 유령 로그인 ID를 삭제하시겠습니까?",
{ count: selectedOrphanIds.length },
),
);
if (confirmed) {
deleteMutation.mutate(selectedOrphanIds);
}
};
const isManualRechecking = recheckStatus === "running";
const handleRecheck = async () => {
if (isManualRechecking) {
return;
}
setRecheckStatus("running");
const result = await refetch();
setRecheckStatus(result.isError ? "error" : "success");
};
const recheckMessage = recheckStatusText(recheckStatus);
return (
<main className="space-y-6">
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Database size={20} />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.integrity.title", "데이터 정합성 검증")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.subtitle",
"Review integrity status and inspect checks across the admin data model.",
)}
</p>
</div>
</div>
{activeTab === "integrity" ? (
<div className="flex flex-col items-end gap-1">
<Button
type="button"
variant="outline"
onClick={handleRecheck}
disabled={isLoading || isFetching || isManualRechecking}
>
<Database size={16} />
{isManualRechecking
? t("ui.admin.integrity.recheck.running", "검사 중")
: t("ui.admin.integrity.recheck.run", "다시 검사")}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
{recheckMessage}
</output>
) : null}
</div>
) : null}
</header>
<div
className="flex border-b border-border"
role="tablist"
aria-label="데이터 정합성 탭"
>
<button
type="button"
role="tab"
aria-selected={activeTab === "integrity"}
className={pageTabClassName(activeTab === "integrity")}
onClick={() => setActiveTab("integrity")}
>
{t("ui.admin.integrity.tab_checks", "정합성 검사")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "projection"}
className={pageTabClassName(activeTab === "projection")}
onClick={() => setActiveTab("projection")}
>
{t("ui.admin.integrity.tab_ory_ssot", "Ory SSOT 시스템")}
</button>
</div>
{activeTab === "integrity" ? (
<div className="space-y-4 pb-6 animate-in fade-in duration-500">
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.integrity.report.load_error",
"정합성 리포트를 불러오지 못했습니다.",
)}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.read_model.description",
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
)}
</p>
</div>
{data ? (
<Badge variant={statusBadgeVariant(data.status)}>
{statusLabel(data.status)}
</Badge>
) : null}
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.integrity.loading", "불러오는 중")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.passed", "정상")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.failures", "실패 건수")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
</section>
<div className="space-y-4">
{(data?.sections ?? []).map((section) => (
<section
key={section.key}
className="rounded-lg border border-border bg-card p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{integritySectionLabel(section.key, section.label)}
</h3>
<p className="text-sm text-muted-foreground">
{integritySectionDescription(section.key)}
</p>
</div>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
</Badge>
</div>
<div className="divide-y divide-border">
{section.checks.map((check) => (
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">
{integrityCheckLabel(check.key, check.label)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{integrityCheckDescription(
check.key,
check.description,
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
</div>
<section className="rounded-lg border border-border bg-card p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
</p>
</div>
<Button
type="button"
variant="destructive"
onClick={handleDeleteSelected}
disabled={
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
>
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
</Button>
</div>
{orphanLoginIDsQuery.isError ? (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t(
"msg.admin.integrity.orphan_login_ids.load_error",
"유령 로그인 ID 대상을 불러오지 못했습니다.",
)}
</div>
) : null}
{deleteMutation.data ? (
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{t(
"msg.admin.integrity.orphan_login_ids.delete_success",
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
{ count: deleteMutation.data.deletedCount },
)}
</div>
) : null}
<OrphanLoginIDTable
items={orphanItems}
selectedIds={selectedOrphanIds}
onToggle={toggleOrphanID}
/>
</section>
</div>
) : (
<div className="animate-in fade-in duration-500">
<UserProjectionContent embedded />
</div>
)}
</main>
);
}
export default function DataIntegrityPage() {
return (
<RoleGuard
roles={["super_admin"]}
fallback={
<main className="p-6 md:p-8">
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="text-lg font-semibold">
{t("ui.admin.integrity.forbidden.title", "접근 권한이 없습니다")}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.forbidden.description",
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
)}
</p>
</section>
</main>
}
>
<DataIntegrityContent />
</RoleGuard>
);
}

View File

@@ -0,0 +1,266 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchAdminRPUsageDaily,
fetchDataIntegrityReport,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import AuthPage from "../auth/AuthPage";
import GlobalOverviewPage from "./GlobalOverviewPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchAdminOverviewStats: vi.fn(async () => ({
totalTenants: 10,
totalUsers: 152,
oidcClients: 3,
auditEvents24h: 18,
})),
fetchAllTenants: vi.fn(async () => ({
items: [
{
id: "group-1",
type: "COMPANY_GROUP",
name: "한맥그룹",
slug: "hanmac-group",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "company-1",
type: "COMPANY",
name: "한맥",
slug: "hanmac",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "org-1",
type: "ORGANIZATION",
name: "개발팀",
slug: "dev-team",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "personal-1",
type: "PERSONAL",
name: "개인",
slug: "personal",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
],
limit: 1000,
offset: 0,
total: 4,
})),
fetchAdminRPUsageDaily: vi.fn(async () => ({
days: 14,
period: "day",
items: [
{
date: "2026-05-05",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "orgfront",
clientName: "OrgFront",
loginRequests: 12,
otherRequests: 4,
uniqueSubjects: 8,
},
{
date: "2026-05-06",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "adminfront",
clientName: "AdminFront",
loginRequests: 7,
otherRequests: 3,
uniqueSubjects: 5,
},
{
date: "2026-09-28",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "devfront",
clientName: "DevFront",
loginRequests: 2,
otherRequests: 1,
uniqueSubjects: 2,
},
],
})),
fetchDataIntegrityReport: vi.fn(async () => ({
status: "fail",
checkedAt: "2026-05-14T00:00:00Z",
summary: {
totalChecks: 5,
passed: 4,
warnings: 0,
failures: 1,
},
sections: [
{
key: "tenant_integrity",
label: "테넌트 정합성",
status: "pass",
checks: [],
},
{
key: "user_integrity",
label: "사용자 정합성",
status: "fail",
checks: [],
},
],
})),
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin overview and auth guard pages", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
});
it("renders usage trend chart without quick navigation or permission checker", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
await screen.findByText("회사별 앱별 로그인 요청 현황"),
).toBeInTheDocument();
expect(
await screen.findByLabelText("일 단위 RP 요청 현황"),
).toBeInTheDocument();
expect(await screen.findByText("05.05")).toBeInTheDocument();
expect(await screen.findByText("05.06")).toBeInTheDocument();
expect(screen.queryByText("빠른 작업")).not.toBeInTheDocument();
expect(screen.queryByText("빠른 이동")).not.toBeInTheDocument();
expect(screen.queryByText("테넌트 추가")).not.toBeInTheDocument();
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
});
it("renders overview tenant count from the fully fetched tenant list", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
(await screen.findByText("전체 테넌트 수")).parentElement,
).toHaveTextContent("4");
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
"3",
);
expect(screen.getByText("전체 사용자 수").parentElement).toHaveTextContent(
"152",
);
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
"18",
);
});
it("limits the overview graph choices to company tenants", async () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인 요청 현황");
expect(
await screen.findByRole("checkbox", { name: "한맥 (hanmac)" }),
).toBeInTheDocument();
expect(
screen.queryByText("한맥그룹 (hanmac-group)"),
).not.toBeInTheDocument();
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
});
it("changes the RP usage perspective and targets a permitted company", async () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인 요청 현황");
fireEvent.click(screen.getByRole("button", { name: "주" }));
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "월" }));
fireEvent.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" }));
await waitFor(() => {
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
days: 90,
period: "month",
});
});
expect(
screen.queryByText("한맥그룹 (hanmac-group)"),
).not.toBeInTheDocument();
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
});
it("shows the latest integrity summary at the bottom for super admins only", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(await screen.findByText("정합성 최종 검증")).toBeInTheDocument();
expect(screen.getByText("실패 1건")).toBeInTheDocument();
expect(screen.getByText("테넌트 정합성")).toBeInTheDocument();
expect(screen.getByText("사용자 정합성")).toBeInTheDocument();
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("does not fetch or show the integrity summary for non-super admins", async () => {
currentRole = "tenant_admin";
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인 요청 현황");
expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument();
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
});
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
renderWithProviders(<AuthPage />);
expect(screen.getByText("인증 가드")).toBeInTheDocument();
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
expect(
screen.queryByText("IDP session placeholder"),
).not.toBeInTheDocument();
expect(screen.queryByText("Admin login")).not.toBeInTheDocument();
});
});

View File

@@ -1,200 +1,607 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
ArrowUpRight,
AlertTriangle,
CheckCircle2,
Database,
Key,
PlusCircle,
LayoutDashboard,
ShieldCheck,
Users,
} from "lucide-react";
import { Link } from "react-router-dom";
import { type ReactNode, useMemo, useState } from "react";
import {
OverviewAxisNotes,
OverviewMetric,
OverviewSelectionChips,
} from "../../../../common/core/components/overview";
import { RoleGuard } from "../../components/auth/RoleGuard";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
type DataIntegrityStatus,
fetchAdminOverviewStats,
fetchAdminRPUsageDaily,
fetchAllTenants,
fetchDataIntegrityReport,
type RPUsageDailyMetric,
type RPUsagePeriod,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
function GlobalOverviewPage() {
return (
<div className="space-y-8 animate-in fade-in duration-500">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.overview.title", "Dashboard")}
</h2>
<p className="text-muted-foreground">
type DailyPoint = {
date: string;
loginRequests: number;
otherRequests: number;
};
type SeriesSummary = {
key: string;
clientLabel: string;
loginRequests: number;
uniqueSubjects: number;
};
function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
const byDate = new Map<string, DailyPoint>();
for (const row of rows) {
const current =
byDate.get(row.date) ??
({
date: row.date,
loginRequests: 0,
otherRequests: 0,
} satisfies DailyPoint);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
byDate.set(row.date, current);
}
return Array.from(byDate.values()).sort((a, b) =>
a.date.localeCompare(b.date),
);
}
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
const bySeries = new Map<string, SeriesSummary>();
for (const row of rows) {
const key = row.clientId;
const current =
bySeries.get(key) ??
({
key,
clientLabel: row.clientName || row.clientId,
loginRequests: 0,
uniqueSubjects: 0,
} satisfies SeriesSummary);
current.loginRequests += row.loginRequests;
current.uniqueSubjects = Math.max(
current.uniqueSubjects,
row.uniqueSubjects,
);
bySeries.set(key, current);
}
return Array.from(bySeries.values())
.sort((a, b) => b.loginRequests - a.loginRequests)
.slice(0, 5);
}
function parseDateParts(date: string) {
const parts = date.split("-");
if (parts.length === 3) {
return {
year: Number(parts[0]),
month: Number(parts[1]),
day: Number(parts[2]),
monthText: parts[1],
dayText: parts[2],
};
}
return null;
}
function getISOWeekNumber(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
function getISOWeekThursday(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
return date;
}
function formatPeriodLabel(date: string, period: RPUsagePeriod) {
const parts = parseDateParts(date);
if (!parts) {
return date;
}
if (period === "month") {
return `${parts.monthText}`;
}
if (period === "week") {
const weekNumber = String(
getISOWeekNumber(parts.year, parts.month, parts.day),
).padStart(2, "0");
const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day);
const weekMonth = weekThursday.getUTCMonth() + 1;
const weekDay = weekThursday.getUTCDate();
const weekMonthText = String(weekMonth).padStart(2, "0");
const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7)));
return `${weekNumber}(${weekMonthText}${weekOfMonth}주)`;
}
return `${parts.monthText}.${parts.dayText}`;
}
function formatOverviewDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function integrityStatusText(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return t("ui.admin.integrity.status.pass", "정상");
case "warning":
return t("ui.admin.integrity.status.warning", "주의");
default:
return t("ui.admin.integrity.status.fail", "실패");
}
}
function integrityStatusClass(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "text-emerald-700 dark:text-emerald-300";
case "warning":
return "text-amber-700 dark:text-amber-300";
default:
return "text-destructive";
}
}
function IntegrityOverviewSummary() {
const { data, isError } = useQuery({
queryKey: ["admin-overview-integrity"],
queryFn: fetchDataIntegrityReport,
retry: false,
});
if (isError) {
return (
<section className="border-t border-border/60 pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertTriangle size={16} />
<span>
{t(
"msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
"ui.admin.integrity.fetch_error",
"정합성 최종 검증 결과를 불러오지 못했습니다.",
)}
</span>
</div>
</section>
);
}
if (!data) {
return null;
}
return (
<section className="border-t border-border/60 pt-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2">
{data.status === "pass" ? (
<CheckCircle2 size={18} className="text-emerald-600" />
) : (
<AlertTriangle size={18} className="text-amber-600" />
)}
<h3 className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.integrity.summary.title", "정합성 최종 검증")}
</h3>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm">
<span
className={`font-semibold ${integrityStatusClass(data.status)}`}
>
{integrityStatusText(data.status)}
</span>
<span className="tabular-nums">
{t("ui.admin.integrity.summary.failures_text", "실패 {{count}}건", {
count: data.summary.failures,
})}
</span>
<span className="text-muted-foreground">
{formatOverviewDateTime(data.checkedAt)}
</span>
</div>
</div>
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
{data.sections.map((section) => (
<div
key={section.key}
className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2"
>
<span>{integritySectionLabel(section.key, section.label)}</span>
<span
className={`font-medium ${integrityStatusClass(section.status)}`}
>
{integrityStatusText(section.status)}
</span>
</div>
))}
</div>
</section>
);
}
function integritySectionLabel(key: string, fallback: string) {
switch (key) {
case "tenant_integrity":
return t("ui.admin.integrity.section.tenant_integrity", fallback);
case "user_integrity":
return t("ui.admin.integrity.section.user_integrity", fallback);
default:
return fallback;
}
}
function RPUsageMixedChart({
rows,
periodControls,
filters,
period,
}: {
rows: RPUsageDailyMetric[];
periodControls: ReactNode;
filters: ReactNode;
period: RPUsagePeriod;
}) {
const daily = summarizeDaily(rows);
const series = summarizeSeries(rows);
const chartWidth = 720;
const chartHeight = 230;
const padX = 48;
const padTop = 32;
const padBottom = 34;
const innerWidth = chartWidth - padX * 2;
const innerHeight = chartHeight - padTop - padBottom;
const maxValue = Math.max(
1,
...daily.map((point) => point.loginRequests + point.otherRequests),
...daily.map((point) => point.loginRequests),
);
const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth;
const barWidth = Math.min(28, Math.max(10, slot * 0.42));
const y = (value: number) =>
padTop + innerHeight - (value / maxValue) * innerHeight;
const x = (index: number) => padX + slot * index + slot / 2;
const linePoints = daily
.map((point, index) => `${x(index)},${y(point.loginRequests)}`)
.join(" ");
return (
<section className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황")}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
{periodControls}
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<RoleGuard roles={["super_admin"]}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.total_tenants", "총 테넌트")}
</CardTitle>
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Users size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")}
</CardTitle>
<div className="rounded-full bg-blue-500/10 p-2 text-blue-500">
<ShieldCheck size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
OIDC
</p>
</CardContent>
</Card>
</RoleGuard>
{filters}
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{daily.length === 0 ? (
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
RP .
</div>
) : (
<div className="space-y-3">
<div className="overflow-x-auto">
<svg
role="img"
aria-label="일 단위 RP 요청 현황"
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
className="h-[235px] min-w-[720px] w-full"
>
<title> RP </title>
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const gridY = padTop + innerHeight * ratio;
const label = Math.round(maxValue * (1 - ratio));
return (
<g key={ratio}>
<line
x1={padX}
x2={chartWidth - padX}
y1={gridY}
y2={gridY}
stroke="currentColor"
className="text-border"
strokeWidth="1"
/>
<text
x={padX - 12}
y={gridY + 4}
textAnchor="end"
className="fill-muted-foreground text-[11px]"
>
{label}
</text>
</g>
);
})}
{daily.map((point, index) => {
const center = x(index);
const otherHeight =
(point.otherRequests / maxValue) * innerHeight;
return (
<g key={point.date}>
<rect
x={center - barWidth / 2}
y={padTop + innerHeight - otherHeight}
width={barWidth}
height={otherHeight}
rx="3"
className="fill-sky-500/70"
/>
<text
x={center}
y={chartHeight - 12}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
{formatPeriodLabel(point.date, period)}
</text>
</g>
);
})}
<polyline
points={linePoints}
fill="none"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{daily.map((point, index) => (
<circle
key={`${point.date}-login`}
cx={x(index)}
cy={y(point.loginRequests)}
r="4"
className="fill-emerald-500 stroke-background"
strokeWidth="2"
/>
))}
</svg>
</div>
<OverviewAxisNotes
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
/>
</div>
)}
{series.length > 0 && (
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
{series.map((item) => (
<div
key={item.key}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<span className="font-medium">{item.clientLabel}</span>
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{t(
"ui.common.chart.series_summary.login_users",
"로그인 {{login}} / 사용자 {{subjects}}",
{
login: item.loginRequests.toLocaleString(),
subjects: item.uniqueSubjects.toLocaleString(),
},
)}
</span>
</div>
))}
</div>
)}
</section>
);
}
function GlobalOverviewPage() {
const [period, setPeriod] = useState<RPUsagePeriod>("day");
const [selectedTenantIds, setSelectedTenantIds] = useState<string[]>([]);
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
const statsQuery = useQuery({
queryKey: ["admin-overview-stats"],
queryFn: fetchAdminOverviewStats,
retry: false,
});
const tenantsQuery = useQuery({
queryKey: ["admin-overview-tenant-options"],
queryFn: () => fetchAllTenants(),
retry: false,
});
const tenantOptions = useMemo(() => {
return (tenantsQuery.data?.items ?? []).filter(
(tenant) => tenant.type === "COMPANY",
);
}, [tenantsQuery.data?.items]);
const usageQuery = useQuery({
queryKey: ["admin-rp-usage-daily", usageDays, period],
queryFn: () =>
fetchAdminRPUsageDaily({
days: usageDays,
period,
}),
retry: false,
});
const stats = statsQuery.data;
const visibleTenantCount = tenantsQuery.data?.items.length;
const usageRows = usageQuery.data?.items ?? [];
const filteredUsageRows = useMemo(() => {
if (selectedTenantIds.length === 0) {
return usageRows;
}
const selectedSet = new Set(selectedTenantIds);
return usageRows.filter((row) => selectedSet.has(row.tenantId));
}, [selectedTenantIds, usageRows]);
const metric = (value: number | undefined) =>
value === undefined ? "-" : value.toLocaleString();
const periodControls = (
<fieldset className="flex h-8 items-center gap-1" aria-label="집계 단위">
{[
["day", t("ui.common.chart.period.day", "일")],
["week", t("ui.common.chart.period.week", "주")],
["month", t("ui.common.chart.period.month", "월")],
].map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={period === value}
onClick={() => setPeriod(value as RPUsagePeriod)}
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
period === value
? "bg-primary text-primary-foreground"
: "bg-muted/60 hover:bg-muted"
}`}
>
{label}
</button>
))}
</fieldset>
);
const chartFilters = (
<div>
<OverviewSelectionChips
allLabel="전체"
options={tenantOptions.map((tenant) => ({
id: tenant.id,
label: `${tenant.name} (${tenant.slug})`,
}))}
selectedIds={selectedTenantIds}
onSelectAll={() => setSelectedTenantIds([])}
onToggle={(tenantId) => {
setSelectedTenantIds((current) =>
current.includes(tenantId)
? current.filter((item) => item !== tenantId)
: [...current, tenantId],
);
}}
/>
</div>
);
return (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<LayoutDashboard size={20} />
</div>
<div className="space-y-1">
<h2 className="text-3xl font-semibold">
{t("ui.common.overview.title", "운영 현황")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.summary.audit_events_24h",
"최근 감사 로그 (24h)",
"msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
)}
</CardTitle>
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500">
<Activity size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.policy_gate", "정책 상태")}
</CardTitle>
<div className="rounded-full bg-green-500/10 p-2 text-green-500">
<Database size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600 dark:text-green-500">
Active
</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold tracking-tight">
{t("ui.admin.overview.quick_links.title", "빠른 작업")}
</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<RoleGuard roles={["super_admin"]}>
<Link
to="/tenants/new"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<PlusCircle size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-primary">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</RoleGuard>
<Link
to="/users"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500 transition-colors group-hover:bg-blue-500 group-hover:text-white">
<Users size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-blue-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
<RoleGuard roles={["super_admin"]}>
<Link
to="/api-keys"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-purple-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 text-purple-500 transition-colors group-hover:bg-purple-500 group-hover:text-white">
<Key size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-purple-500">
API
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</RoleGuard>
<Link
to="/audit-logs"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-orange-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500/10 text-orange-500 transition-colors group-hover:bg-orange-500 group-hover:text-white">
<Activity size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-orange-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
<RoleGuard roles={["super_admin"]}>
<OverviewMetric
icon={<Users size={14} />}
label={t(
"ui.admin.overview.summary.total_tenants",
"전체 테넌트 수",
)}
value={metric(visibleTenantCount ?? stats?.totalTenants)}
/>
<OverviewMetric
icon={<ShieldCheck size={14} />}
label={t(
"ui.admin.overview.summary.oidc_clients",
"OIDC 클라이언트",
)}
value={metric(stats?.oidcClients)}
/>
<OverviewMetric
icon={<Users size={14} />}
label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")}
value={metric(stats?.totalUsers)}
/>
</RoleGuard>
<OverviewMetric
icon={<Activity size={14} />}
label={t(
"ui.admin.overview.summary.audit_events_24h",
"24시간 이벤트",
)}
value={metric(stats?.auditEvents24h)}
/>
<OverviewMetric
icon={<Database size={14} />}
label={t("ui.admin.overview.summary.policy_gate", "정책 상태")}
value="Active"
/>
</div>
{usageQuery.isError ? (
<section className="space-y-2">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.overview.chart.title",
"회사별 앱별 로그인 요청 현황",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
{periodControls}
</div>
{chartFilters}
<div className="text-sm text-muted-foreground">
RP Query API . backend
`rp_usage_daily_aggregate`
.
</div>
</section>
) : (
<RPUsageMixedChart
rows={filteredUsageRows}
periodControls={periodControls}
filters={chartFilters}
period={period}
/>
)}
<RoleGuard roles={["super_admin"]}>
<div className="pt-4">
<PermissionChecker />
</div>
<IntegrityOverviewSummary />
</RoleGuard>
</div>
);

View File

@@ -1,142 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, ShieldAlert, XCircle } from "lucide-react";
import { useState } from "react";
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 apiClient from "../../../lib/apiClient";
type CheckPermissionResponse = {
allowed: boolean;
query: {
namespace: string;
object: string;
relation: string;
subject: string;
};
};
function PermissionChecker() {
const [namespace, setNamespace] = useState("Tenant");
const [object, setObject] = useState("");
const [relation, setRelation] = useState("manage");
const [subject, setSubject] = useState("");
const checkMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.get<CheckPermissionResponse>(
"/v1/admin/debug/check-permission",
{
params: { namespace, object, relation, subject },
},
);
return data;
},
});
const result = checkMutation.data;
return (
<Card className="bg-[var(--color-panel)] border-primary/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldAlert size={20} className="text-primary" />
ReBAC
</CardTitle>
<CardDescription>
(Subject) (Object) Ory
Keto를 .
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label>Namespace</Label>
<select
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="Tenant">Tenant</option>
<option value="TenantGroup">TenantGroup</option>
<option value="RelyingParty">RelyingParty</option>
<option value="System">System</option>
</select>
</div>
<div className="space-y-2">
<Label>Relation</Label>
<Input
placeholder="view, manage, admins..."
value={relation}
onChange={(e) => setRelation(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Object ID</Label>
<Input
placeholder="Tenant UUID 등"
value={object}
onChange={(e) => setObject(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Subject (User:ID)</Label>
<Input
placeholder="User:uuid 또는 Namespace:ID#Relation"
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
</div>
<div className="flex justify-center">
<Button
onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending}
className="w-full md:w-auto px-12"
>
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
</Button>
</div>
{checkMutation.isSuccess && result && (
<div
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
result.allowed
? "bg-green-500/10 border-green-500/50 text-green-600"
: "bg-destructive/10 border-destructive/50 text-destructive"
}`}
>
{result.allowed ? (
<>
<CheckCircle2 size={48} />
<div className="text-xl font-bold">Access ALLOWED</div>
<p className="text-sm opacity-80 text-center">
. (
)
</p>
</>
) : (
<>
<XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div>
<p className="text-sm opacity-80 text-center">
.
</p>
</>
)}
</div>
)}
</CardContent>
</Card>
);
}
export default PermissionChecker;

View File

@@ -0,0 +1,120 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchOrySSOTSystemStatus,
flushIdentityCache,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import UserProjectionPage from "./UserProjectionPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchOrySSOTSystemStatus: vi.fn(async () => ({
userProjection: {
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
},
identityCache: {
status: "ready",
redisReady: true,
observedCount: 151,
lastRefreshedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
keyCount: 153,
},
})),
flushIdentityCache: vi.fn(async () => ({
status: "success",
flushedKeys: 153,
updatedAt: "2026-05-11T03:02:00Z",
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<UserProjectionPage />
</QueryClientProvider>,
);
}
describe("UserProjectionPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
window.localStorage.setItem("locale", "ko");
});
it("renders Ory SSOT and Redis identity cache status for super_admin", async () => {
renderPage();
expect(await screen.findByText("Ory SSOT 시스템")).toBeInTheDocument();
expect(
await screen.findByText(
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
),
).toBeInTheDocument();
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
expect(screen.getByText("관측 identity")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
expect(screen.getByText("151")).toBeInTheDocument();
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
it("flushes only the Redis identity cache for super_admin", async () => {
renderPage();
await screen.findByText("Ory SSOT 시스템");
expect(screen.queryByRole("button", { name: /재동기화/ })).toBeNull();
expect(
screen.queryByRole("button", { name: /초기화 후 재구축/ }),
).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
await waitFor(() => {
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
});
});
it("blocks non-super admins", async () => {
currentRole = "tenant_admin";
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(screen.queryByText("Ory SSOT 시스템")).not.toBeInTheDocument();
expect(fetchOrySSOTSystemStatus).not.toHaveBeenCalled();
});
it("renders localized labels in English", async () => {
window.localStorage.setItem("locale", "en");
renderPage();
expect(await screen.findByText("Ory SSOT System")).toBeInTheDocument();
expect(
await screen.findByText(
"Review Kratos source-of-truth and Redis identity cache status separately.",
),
).toBeInTheDocument();
expect(screen.getByText("Redis cache flush")).toBeInTheDocument();
expect((await screen.findAllByText("ready")).length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,340 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Database, Trash2 } from "lucide-react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
fetchOrySSOTSystemStatus,
flushIdentityCache,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(getAdminDateLocale(), {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
}
function StatusBadge({ ready, status }: { ready: boolean; status: string }) {
if (ready) {
return (
<Badge variant="success">
{t("ui.admin.ory_ssot.status.ready", "ready")}
</Badge>
);
}
if (status === "failed") {
return (
<Badge variant="warning">
{t("ui.admin.ory_ssot.status.failed", "failed")}
</Badge>
);
}
return (
<Badge variant="secondary">
{status ? status : t("ui.admin.ory_ssot.status.not_ready", "not ready")}
</Badge>
);
}
export function UserProjectionContent({
embedded = false,
}: {
embedded?: boolean;
}) {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["ory-ssot-system-status"],
queryFn: fetchOrySSOTSystemStatus,
});
const flushMutation = useMutation({
mutationFn: flushIdentityCache,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ["ory-ssot-system-status"],
});
},
});
const handleFlush = () => {
const confirmed = window.confirm(
t(
"msg.admin.ory_ssot.flush_confirm",
"Flush only Redis identity cache keys?",
),
);
if (confirmed) flushMutation.mutate();
};
const projection = data?.userProjection;
const identityCache = data?.identityCache;
const header = (
<header
className={
embedded
? "flex flex-shrink-0 flex-wrap items-start justify-between gap-4"
: "flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur"
}
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Database size={20} />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.ory_ssot.subtitle",
"Review Kratos source-of-truth and Redis identity cache status separately.",
)}
</p>
</div>
</div>
<Button
type="button"
variant="destructive"
onClick={handleFlush}
disabled={flushMutation.isPending}
>
<Trash2 size={16} />
{t(
"ui.admin.ory_ssot.actions.flush_identity_cache",
"Redis cache flush",
)}
</Button>
</header>
);
const body = (
<>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.ory_ssot.load_error",
"Failed to load Ory SSOT system status.",
)}
</section>
) : null}
{flushMutation.data ? (
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{t(
"msg.admin.ory_ssot.flush_success",
"Flushed {{count}} Redis identity cache keys.",
{ count: flushMutation.data.flushedKeys },
)}
</section>
) : null}
{flushMutation.error ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(flushMutation.error as Error)?.message ||
t(
"msg.admin.ory_ssot.flush_error",
"Redis identity cache flush failed.",
)}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold">
{t(
"ui.admin.ory_ssot.projection_card.title",
"Backend user read model",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.ory_ssot.projection_card.description",
"PostgreSQL read model status used by admin search and statistics.",
)}
</p>
</div>
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.loading", "Loading")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.status", "Status")}
</dt>
<dd className="mt-1">
<StatusBadge
ready={projection?.ready ?? false}
status={projection?.status ?? "unknown"}
/>
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.local_users", "Local users")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{projection?.projectedUsers ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.ory_ssot.summary.last_synced",
"Last read-model refresh",
)}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(projection?.lastSyncedAt)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.updated_at", "Updated at")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(projection?.updatedAt)}
</dd>
</div>
</dl>
)}
{projection?.lastError ? (
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
<span>{projection.lastError}</span>
</div>
) : null}
</section>
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold">
{t("ui.admin.ory_ssot.cache_card.title", "Redis identity cache")}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.ory_ssot.cache_card.description",
"Redis mirror/cache status for Kratos identity list and lookup operations.",
)}
</p>
</div>
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.loading", "Loading")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.status", "Status")}
</dt>
<dd className="mt-1">
<StatusBadge
ready={
Boolean(identityCache?.redisReady) &&
identityCache?.status === "ready"
}
status={identityCache?.status ?? "unknown"}
/>
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.ory_ssot.summary.observed_identities",
"Observed identities",
)}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{identityCache?.observedCount ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.cache_keys", "Cache keys")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{identityCache?.keyCount ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.ory_ssot.summary.last_refreshed",
"Last refreshed",
)}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(identityCache?.lastRefreshedAt)}
</dd>
</div>
</dl>
)}
{identityCache?.lastError ? (
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
<span>{identityCache.lastError}</span>
</div>
) : null}
</section>
</>
);
if (embedded) {
return (
<div className="space-y-4 pb-6">
{header}
{body}
</div>
);
}
return (
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
{header}
{body}
</main>
);
}
export default function UserProjectionPage() {
return (
<RoleGuard
roles={["super_admin"]}
fallback={
<main className="p-6 md:p-8">
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="text-lg font-semibold">
{t("ui.admin.ory_ssot.forbidden.title", "Access denied")}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.admin.ory_ssot.forbidden.description",
"This screen is only available to super_admin users.",
)}
</p>
</section>
</main>
}
>
<UserProjectionContent />
</RoleGuard>
);
}

View File

@@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DomainTagInput } from "./DomainTagInput";
describe("DomainTagInput", () => {
it("shows a clear duplicate tenant warning and adds the domain after confirmation", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onConfirmedConflictsChange = vi.fn();
render(
<DomainTagInput
value={[]}
onChange={onChange}
tenants={[
{
id: "tenant-1",
name: "한맥가족",
slug: "hanmac-family",
type: "COMPANY",
description: "",
status: "active",
domains: ["samaneng.com"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
]}
currentTenantId="tenant-2"
confirmedConflicts={[]}
onConfirmedConflictsChange={onConfirmedConflictsChange}
placeholder="example.com"
/>,
);
await user.type(
screen.getByPlaceholderText("example.com"),
"samaneng.com ",
);
expect(
await screen.findByText(
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "계속 진행" }));
expect(onChange).toHaveBeenCalledWith(["samaneng.com"]);
expect(onConfirmedConflictsChange).toHaveBeenCalledWith(["samaneng.com"]);
});
});

View File

@@ -0,0 +1,187 @@
import { X } from "lucide-react";
import { useState } from "react";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import type { TenantSummary } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
type DomainConflict,
findDomainConflict,
formatDomainConflictMessage,
normalizeDomainTokens,
} from "../utils/domainTags";
type DomainTagInputProps = {
id?: string;
value: string[];
onChange: (domains: string[]) => void;
tenants?: TenantSummary[];
currentTenantId?: string;
confirmedConflicts?: string[];
onConfirmedConflictsChange?: (domains: string[]) => void;
placeholder?: string;
};
export function DomainTagInput({
id,
value,
onChange,
tenants = [],
currentTenantId,
confirmedConflicts = [],
onConfirmedConflictsChange,
placeholder,
}: DomainTagInputProps) {
const [input, setInput] = useState("");
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
null,
);
const addConfirmedConflict = (domain: string) => {
if (!confirmedConflicts.includes(domain)) {
onConfirmedConflictsChange?.([...confirmedConflicts, domain]);
}
};
const removeConfirmedConflict = (domain: string) => {
if (confirmedConflicts.includes(domain)) {
onConfirmedConflictsChange?.(
confirmedConflicts.filter((item) => item !== domain),
);
}
};
const addDomain = (domain: string, confirmed = false) => {
if (value.includes(domain)) {
return;
}
onChange([...value, domain]);
if (confirmed) {
addConfirmedConflict(domain);
}
};
const tokenizeInput = () => {
const tokens = normalizeDomainTokens(input);
if (tokens.length === 0) {
setInput("");
return;
}
for (const token of tokens) {
if (value.includes(token)) {
continue;
}
const conflict = findDomainConflict(token, tenants, currentTenantId);
if (conflict && !confirmedConflicts.includes(token)) {
setPendingConflict(conflict);
setInput("");
return;
}
addDomain(token, confirmedConflicts.includes(token));
}
setInput("");
};
const removeDomain = (domain: string) => {
onChange(value.filter((item) => item !== domain));
removeConfirmedConflict(domain);
};
return (
<>
<div className="flex min-h-10 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-within:ring-1 focus-within:ring-ring">
{value.map((domain) => (
<Badge
key={domain}
variant={confirmedConflicts.includes(domain) ? "warning" : "muted"}
className="gap-1 rounded-md"
>
<span>{domain}</span>
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
onClick={() => removeDomain(domain)}
aria-label={t("ui.common.remove", "삭제")}
>
<X size={12} />
</button>
</Badge>
))}
<Input
id={id}
value={input}
onChange={(event) => setInput(event.target.value)}
onBlur={tokenizeInput}
onKeyDown={(event) => {
if (
event.key === " " ||
event.key === "Enter" ||
event.key === "," ||
event.key === ";"
) {
event.preventDefault();
tokenizeInput();
}
}}
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0"
placeholder={value.length === 0 ? placeholder : undefined}
/>
</div>
<Dialog
open={pendingConflict !== null}
onOpenChange={(open) => {
if (!open) {
setPendingConflict(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.domain_conflict.title", "도메인 충돌")}
</DialogTitle>
<DialogDescription>
{pendingConflict
? t(
"ui.admin.tenants.domain_conflict.description",
formatDomainConflictMessage(pendingConflict),
)
: ""}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setPendingConflict(null)}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
type="button"
onClick={() => {
if (pendingConflict) {
addDomain(pendingConflict.domain, true);
}
setPendingConflict(null);
}}
>
{t("ui.common.continue", "계속 진행")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,295 +0,0 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
AlertCircle,
CheckCircle2,
Download,
FileText,
Loader2,
Upload,
} from "lucide-react";
import * as React from "react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { toast } from "../../../components/ui/use-toast";
import {
type ImportResult,
fetchImportProgress,
importOrgChart,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
interface OrgChartUploadModalProps {
tenantId: string;
onSuccess?: () => void;
}
export function OrgChartUploadModal({
tenantId,
onSuccess,
}: OrgChartUploadModalProps) {
const [open, setOpen] = React.useState(false);
const [file, setFile] = React.useState<File | null>(null);
const [result, setResult] = React.useState<ImportResult | null>(null);
const [progressId, setProgressId] = React.useState<string | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const mutation = useMutation({
mutationFn: ({ file, pid }: { file: File; pid: string }) =>
importOrgChart(tenantId, file, pid),
onSuccess: (data) => {
setResult(data);
setProgressId(null);
if (data.errors.length === 0) {
toast.success(
t(
"msg.admin.org.import_success",
"조직도가 성공적으로 업로드되었습니다.",
),
);
} else {
toast.error(
t(
"msg.admin.org.import_partial_success",
"일부 데이터 업로드 중 오류가 발생했습니다.",
),
);
}
onSuccess?.();
},
onError: (error: AxiosError<{ error?: string }>) => {
setProgressId(null);
toast.error(t("msg.admin.org.import_error", "조직도 업로드 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
const { data: progressData } = useQuery({
queryKey: ["importProgress", progressId],
queryFn: () =>
progressId ? fetchImportProgress(tenantId, progressId) : null,
enabled: !!progressId && mutation.isPending,
refetchInterval: 500,
});
const percent =
progressData && progressData.total > 0
? Math.round((progressData.current / progressData.total) * 100)
: 0;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
setResult(null);
}
};
const handleUpload = () => {
if (file) {
const pid = Math.random().toString(36).substring(2, 15);
setProgressId(pid);
mutation.mutate({ file, pid });
}
};
const downloadTemplate = () => {
const headers = "이메일,이름,소속,직급,직무,구분,그룹,디비젼,팀,셀";
const example = `test1@example.com,홍길동,한맥,수석,기획,팀장,전략그룹,기획실,인사팀,-
test2@example.com,이몽룡,삼안,선임,개발,팀원,기술본부,개발실,개발1팀,A셀`;
const blob = new Blob([`\uFEFF${headers}\n${example}`], {
type: "text/csv;charset=utf-8",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "org_chart_template.csv";
a.click();
URL.revokeObjectURL(url);
};
return (
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setFile(null);
setResult(null);
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Upload size={14} />
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV/XLSX)")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.org.import_title", "조직도 일괄 등록")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.org.import_description",
"CSV 또는 XLSX 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
)}
</DialogDescription>
</DialogHeader>
{!result ? (
<div className="space-y-4 py-4">
<div className="flex justify-between items-center">
<Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="gap-2"
disabled={mutation.isPending}
>
<Download size={14} />
{t("ui.admin.org.download_template", "템플릿 다운로드")}
</Button>
<input
type="file"
accept=".csv, .xlsx, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
disabled={mutation.isPending}
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
size="sm"
disabled={mutation.isPending}
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button>
</div>
{file && (
<div className="rounded-lg border p-4 bg-muted/20 flex flex-col gap-3">
<div className="flex items-center gap-3">
<FileText className="text-primary" />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground">
{(file.size / 1024).toFixed(1)} KB
</div>
</div>
</div>
{mutation.isPending && progressId && (
<div className="w-full mt-2 animate-in fade-in slide-in-from-bottom-2 duration-500">
<div className="flex justify-between text-xs mb-1 font-medium text-muted-foreground">
<span> ...</span>
<span>
{percent}% ({progressData?.current || 0} /{" "}
{progressData?.total || 0})
</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden relative">
<div
className="bg-primary h-full rounded-full transition-all duration-300 ease-out absolute top-0 left-0"
style={{ width: `${Math.max(5, percent)}%` }}
/>
</div>
</div>
)}
</div>
)}
</div>
) : (
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="p-4 rounded-lg bg-primary/5 border border-primary/10">
<div className="text-sm font-medium text-muted-foreground">
</div>
<div className="text-2xl font-bold">{result.totalRows}</div>
</div>
<div className="p-4 rounded-lg bg-green-500/5 border border-green-500/10">
<div className="text-sm font-medium text-muted-foreground">
</div>
<div className="text-2xl font-bold text-green-600">
{result.processed}
</div>
</div>
<div className="p-4 rounded-lg bg-blue-500/5 border border-blue-500/10">
<div className="text-sm font-medium text-muted-foreground">
/
</div>
<div className="text-xl font-bold text-blue-600">
{result.userCreated} / {result.userUpdated}
</div>
</div>
<div className="p-4 rounded-lg bg-orange-500/5 border border-orange-500/10">
<div className="text-sm font-medium text-muted-foreground">
()
</div>
<div className="text-2xl font-bold text-orange-600">
{result.tenantCreated}
</div>
</div>
</div>
{result.errors.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-destructive">
<AlertCircle size={16} />
({result.errors.length})
</div>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 bg-destructive/5 text-xs font-mono space-y-1">
{result.errors.map((err, idx) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: Errors are a static list returned from the server.
key={idx}
className="text-destructive border-b border-destructive/10 pb-1 last:border-0"
>
{" "}
{err}
</div>
))}
</div>
</div>
)}
</div>
)}
<DialogFooter>
{!result ? (
<Button
onClick={handleUpload}
disabled={!file || mutation.isPending}
className="w-full sm:w-auto relative"
>
{mutation.isPending ? (
<>
<Loader2 size={16} className="mr-2 animate-spin" />
({percent}%)
</>
) : (
t("ui.admin.org.start_import", "임포트 시작")
)}
</Button>
) : (
<Button onClick={() => setOpen(false)} className="w-full sm:w-auto">
{t("ui.common.close", "닫기")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,21 @@
import type { TenantSummary } from "../../../lib/adminApi";
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
export function filterParentTenants(
tenants: TenantSummary[],
search: string,
companyOnly: boolean,
excludeTenantId = "",
) {
const normalizedSearch = search.trim().toLowerCase();
return tenants.filter((tenant) => {
if (excludeTenantId && tenant.id === excludeTenantId) return false;
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
if (!normalizedSearch) return true;
return [tenant.name, tenant.slug, tenant.type]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedSearch));
});
}

View File

@@ -0,0 +1,137 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import { ParentTenantSelector } from "./ParentTenantSelector";
const tenants: TenantSummary[] = [
{
id: "company-1",
type: "COMPANY",
name: "Saman Engineering",
slug: "saman",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "group-1",
type: "COMPANY_GROUP",
name: "Hanmac Family",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("ParentTenantSelector picker", () => {
it("opens an org-chart picker modal and applies tenant selection messages", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
/>,
);
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src");
expect(pickerSrc).toContain("/login");
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
fireEvent(
window,
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
selections: [
{
type: "tenant",
id: "company-1",
name: "Saman Engineering",
},
],
},
},
}),
);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
});
it("keeps the current tenant out of picker message selections", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
excludeTenantId="company-1"
/>,
);
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
fireEvent(
window,
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
selections: [
{
type: "tenant",
id: "company-1",
name: "Saman Engineering",
},
],
},
},
}),
);
await waitFor(() => expect(onChange).not.toHaveBeenCalled());
});
it("selects a non-hanmac parent from the local tenant picker", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
orgChartPickerLabel="한맥가족에서 선택"
localPickerLabel="다른 테넌트 선택"
localTenantFilter={(tenant) => tenant.slug !== "hanmac-family"}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "다른 테넌트 선택" }));
fireEvent.change(
screen.getByPlaceholderText("테넌트 이름 또는 슬러그 검색"),
{ target: { value: "saman" } },
);
fireEvent.click(screen.getByRole("button", { name: /Saman Engineering/ }));
expect(onChange).toHaveBeenCalledWith("company-1");
});
});

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import { filterParentTenants } from "./ParentTenantSelector.helpers";
const tenants: TenantSummary[] = [
{
id: "company-1",
type: "COMPANY",
name: "Saman Engineering",
slug: "saman",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "group-1",
type: "COMPANY_GROUP",
name: "Hanmac Family",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "org-1",
type: "ORGANIZATION",
name: "기획부",
slug: "planning",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("filterParentTenants", () => {
it("searches parent candidates by name and slug", () => {
expect(
filterParentTenants(tenants, "saman", false).map((t) => t.id),
).toEqual(["company-1"]);
expect(
filterParentTenants(tenants, "family", false).map((t) => t.id),
).toEqual(["group-1"]);
});
it("can limit parent candidates to company and company group tenants", () => {
expect(filterParentTenants(tenants, "", true).map((t) => t.id)).toEqual([
"company-1",
"group-1",
]);
});
});

View File

@@ -0,0 +1,257 @@
import { Building2, X } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Label } from "../../../components/ui/label";
import type { TenantSummary } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
parseOrgChartTenantSelection,
} from "../../users/orgChartPicker";
import { filterParentTenants } from "./ParentTenantSelector.helpers";
type ParentTenantSelectorProps = {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
tenants: TenantSummary[];
noneLabel: string;
helpText?: string;
excludeTenantId?: string;
labelAction?: ReactNode;
contextLabel?: string;
orgChartPickerLabel?: string;
localPickerLabel?: string;
localTenantFilter?: (tenant: TenantSummary) => boolean;
compact?: boolean;
controlTestId?: string;
};
export function ParentTenantSelector({
id,
label,
value,
onChange,
tenants,
noneLabel,
helpText,
excludeTenantId,
labelAction,
contextLabel,
orgChartPickerLabel,
localPickerLabel,
localTenantFilter,
compact = false,
controlTestId,
}: ParentTenantSelectorProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [localPickerOpen, setLocalPickerOpen] = useState(false);
const [localSearch, setLocalSearch] = useState("");
const selectedTenant = tenants.find((tenant) => tenant.id === value);
const localCandidates = filterParentTenants(
localTenantFilter ? tenants.filter(localTenantFilter) : tenants,
localSearch,
false,
excludeTenantId,
);
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
);
useEffect(() => {
if (!pickerOpen) return;
const onMessage = (event: MessageEvent) => {
const selection = parseOrgChartTenantSelection(event.data);
if (!selection) return;
if (excludeTenantId && selection.id === excludeTenantId) return;
onChange(selection.id);
setPickerOpen(false);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [excludeTenantId, onChange, pickerOpen]);
return (
<div className={compact ? "space-y-1" : "space-y-2"}>
<div
className={
compact
? "flex min-h-5 flex-wrap items-center justify-between gap-2"
: "flex min-h-8 flex-wrap items-center justify-between gap-2"
}
>
<Label className="text-sm font-semibold">{label}</Label>
{labelAction}
</div>
<input id={id} name={id} type="hidden" value={value} readOnly />
<div
data-testid={controlTestId}
className={
compact
? "flex h-10 min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2"
: "flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2"
}
>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={compact ? "h-8 shrink-0 px-2" : undefined}
>
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
(compact ? undefined : selectedTenant?.name) ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.picker_description",
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
)}
</DialogDescription>
</DialogHeader>
<iframe
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
/>
</DialogContent>
</Dialog>
{localPickerLabel && (
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>
</DialogTrigger>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{localPickerLabel ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.local_picker_description",
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<input
id="parent-tenant-local-search"
name="parent-tenant-local-search"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={localSearch}
onChange={(event) => setLocalSearch(event.target.value)}
placeholder={t(
"ui.admin.tenants.parent.local_search_placeholder",
"테넌트 이름 또는 슬러그 검색",
)}
/>
<div className="max-h-[360px] space-y-2 overflow-y-auto">
{localCandidates.map((tenant) => (
<Button
key={tenant.id}
type="button"
variant="outline"
className="h-auto w-full justify-start px-3 py-2 text-left"
onClick={() => {
onChange(tenant.id);
setLocalPickerOpen(false);
setLocalSearch("");
}}
>
<span>
<span className="block text-sm font-medium">
{tenant.name}
</span>
<span className="block text-xs text-muted-foreground">
{tenant.slug} · {tenant.type}
</span>
</span>
</Button>
))}
{localCandidates.length === 0 && (
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.parent.local_picker_empty",
"선택할 수 있는 테넌트가 없습니다.",
)}
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
)}
{selectedTenant ? (
<>
<span
className={
compact
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
: "text-xs text-muted-foreground"
}
title={`${selectedTenant.name} · ${selectedTenant.slug} · ${selectedTenant.type}`}
>
{compact
? `${selectedTenant.name} · ${selectedTenant.slug}`
: `${selectedTenant.slug} · ${selectedTenant.type}`}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
onClick={() => onChange("")}
aria-label={noneLabel}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<span
className={
compact
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
: "text-xs text-muted-foreground"
}
>
{noneLabel}
</span>
)}
{contextLabel && (
<span className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground">
{contextLabel}
</span>
)}
</div>
{helpText && (
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
)}
</div>
);
}

View File

@@ -5,13 +5,13 @@ import {
Plus,
Search,
ShieldCheck,
Trash2,
UserPlus,
Users,
} from "lucide-react";
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import { useParams } from "react-router-dom";
import { useNavigate, useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -27,7 +27,6 @@ import {
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
@@ -40,7 +39,6 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
type TenantAdmin,
addTenantAdmin,
addTenantOwner,
fetchTenantAdmins,
@@ -48,20 +46,34 @@ import {
fetchUsers,
removeTenantAdmin,
removeTenantOwner,
type TenantAdmin,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type DialogMode = "owner" | "admin";
function mergePendingMembers(
members: TenantAdmin[],
pendingMembers: TenantAdmin[],
) {
const existingIds = new Set(members.map((member) => member.id));
return [
...members,
...pendingMembers.filter((member) => !existingIds.has(member.id)),
];
}
export function TenantAdminsAndOwnersTab() {
const auth = useAuth();
const currentUserId = auth.user?.profile.sub;
const { tenantId } = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const _currentUserId = auth.user?.profile.sub;
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? "";
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
if (!tenantId) return null;
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
const ownersQuery = useQuery({
queryKey: ["tenant-owners", tenantId],
@@ -95,18 +107,22 @@ export function TenantAdminsAndOwnersTab() {
// Optimistically add to the list to prevent immediate double clicks
const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) {
const optimisticOwner = {
id: userId,
name: addedUser.name,
email: addedUser.email,
};
setPendingOwners((old) =>
old.some((owner) => owner.id === userId)
? old
: [...old, optimisticOwner],
);
queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId],
(old) => {
if (!old)
return [
{ id: userId, name: addedUser.name, email: addedUser.email },
];
if (!old) return [optimisticOwner];
if (old.some((o) => o.id === userId)) return old;
return [
...old,
{ id: userId, name: addedUser.name, email: addedUser.email },
];
return [...old, optimisticOwner];
},
);
}
@@ -125,6 +141,7 @@ export function TenantAdminsAndOwnersTab() {
setSearchTerm("");
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
if (context?.previousOwners) {
queryClient.setQueryData(
["tenant-owners", tenantId],
@@ -148,6 +165,7 @@ export function TenantAdminsAndOwnersTab() {
"tenant-owners",
tenantId,
]);
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId],
(old) => (old ? old.filter((o) => o.id !== userId) : []),
@@ -167,7 +185,7 @@ export function TenantAdminsAndOwnersTab() {
),
);
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
if (context?.previousOwners) {
queryClient.setQueryData(
["tenant-owners", tenantId],
@@ -194,18 +212,22 @@ export function TenantAdminsAndOwnersTab() {
const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) {
const optimisticAdmin = {
id: userId,
name: addedUser.name,
email: addedUser.email,
};
setPendingAdmins((old) =>
old.some((admin) => admin.id === userId)
? old
: [...old, optimisticAdmin],
);
queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId],
(old) => {
if (!old)
return [
{ id: userId, name: addedUser.name, email: addedUser.email },
];
if (!old) return [optimisticAdmin];
if (old.some((a) => a.id === userId)) return old;
return [
...old,
{ id: userId, name: addedUser.name, email: addedUser.email },
];
return [...old, optimisticAdmin];
},
);
}
@@ -223,6 +245,7 @@ export function TenantAdminsAndOwnersTab() {
setSearchTerm("");
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
if (context?.previousAdmins) {
queryClient.setQueryData(
["tenant-admins", tenantId],
@@ -246,6 +269,7 @@ export function TenantAdminsAndOwnersTab() {
"tenant-admins",
tenantId,
]);
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId],
(old) => (old ? old.filter((a) => a.id !== userId) : []),
@@ -262,7 +286,7 @@ export function TenantAdminsAndOwnersTab() {
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
);
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
if (context?.previousAdmins) {
queryClient.setQueryData(
["tenant-admins", tenantId],
@@ -284,7 +308,7 @@ export function TenantAdminsAndOwnersTab() {
}
};
const handleRemoveOwner = (userId: string, userName: string) => {
const _handleRemoveOwner = (userId: string, userName: string) => {
if (
window.confirm(
t(
@@ -298,7 +322,7 @@ export function TenantAdminsAndOwnersTab() {
}
};
const handleRemoveAdmin = (userId: string, userName: string) => {
const _handleRemoveAdmin = (userId: string, userName: string) => {
if (
window.confirm(
t(
@@ -312,8 +336,12 @@ export function TenantAdminsAndOwnersTab() {
}
};
const currentOwners = ownersQuery.data || [];
const currentAdmins = adminsQuery.data || [];
if (!tenantId) return null;
const serverOwners = ownersQuery.data || [];
const serverAdmins = adminsQuery.data || [];
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
const currentAdmins = mergePendingMembers(serverAdmins, pendingAdmins);
const searchResults = usersQuery.data?.items || [];
const isDialogOpen = dialogMode !== null;
@@ -363,7 +391,7 @@ export function TenantAdminsAndOwnersTab() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.owners.table_name", "이름")}
@@ -371,22 +399,19 @@ export function TenantAdminsAndOwnersTab() {
<TableHead className="font-bold">
{t("ui.admin.tenants.owners.table_email", "이메일")}
</TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.owners.table_actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ownersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center">
<TableCell colSpan={2} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentOwners.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
colSpan={2}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
@@ -404,7 +429,8 @@ export function TenantAdminsAndOwnersTab() {
currentOwners.map((owner) => (
<TableRow
key={owner.id}
className="hover:bg-muted/30 transition-colors group"
className="hover:bg-muted/30 transition-colors group cursor-pointer"
onClick={() => navigate(`/users/${owner.id}`)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
@@ -417,46 +443,6 @@ export function TenantAdminsAndOwnersTab() {
<TableCell className="text-muted-foreground italic">
{owner.email}
</TableCell>
<TableCell className="text-right">
<span className="relative inline-block group/tt">
<Button
variant="ghost"
size="icon"
className={`opacity-0 group-hover:opacity-100 transition-all ${
owner.id === currentUserId ||
currentOwners.length <= 1
? "opacity-50 cursor-not-allowed pointer-events-none"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}`}
onClick={() =>
handleRemoveOwner(owner.id, owner.name)
}
disabled={
removeOwnerMutation.isPending ||
owner.id === currentUserId ||
currentOwners.length <= 1
}
>
<Trash2 className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
{owner.id === currentUserId
? t(
"msg.admin.tenants.owners.remove_self",
"본인의 권한은 회수할 수 없습니다.",
)
: currentOwners.length <= 1
? t(
"msg.admin.tenants.owners.remove_last",
"마지막 소유자는 회수할 수 없습니다.",
)
: t(
"ui.admin.tenants.owners.remove_title",
"소유자 권한 회수",
)}
</span>
</span>
</TableCell>
</TableRow>
))
)}
@@ -494,7 +480,7 @@ export function TenantAdminsAndOwnersTab() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.admins.table_name", "이름")}
@@ -502,22 +488,19 @@ export function TenantAdminsAndOwnersTab() {
<TableHead className="font-bold">
{t("ui.admin.tenants.admins.table_email", "이메일")}
</TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.admins.table_actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.isLoading ? (
<TableRow>
<TableCell colSpan={3} className="h-32 text-center">
<TableCell colSpan={2} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentAdmins.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
colSpan={2}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
@@ -535,7 +518,8 @@ export function TenantAdminsAndOwnersTab() {
currentAdmins.map((admin) => (
<TableRow
key={admin.id}
className="hover:bg-muted/30 transition-colors group"
className="hover:bg-muted/30 transition-colors group cursor-pointer"
onClick={() => navigate(`/users/${admin.id}`)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
@@ -548,46 +532,6 @@ export function TenantAdminsAndOwnersTab() {
<TableCell className="text-muted-foreground italic">
{admin.email}
</TableCell>
<TableCell className="text-right">
<span className="relative inline-block group/tt">
<Button
variant="ghost"
size="icon"
className={`opacity-0 group-hover:opacity-100 transition-all ${
admin.id === currentUserId ||
currentAdmins.length <= 1
? "opacity-50 cursor-not-allowed pointer-events-none"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}`}
onClick={() =>
handleRemoveAdmin(admin.id, admin.name)
}
disabled={
removeAdminMutation.isPending ||
admin.id === currentUserId ||
currentAdmins.length <= 1
}
>
<Trash2 className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
{admin.id === currentUserId
? t(
"msg.admin.tenants.admins.remove_self",
"본인의 권한은 회수할 수 없습니다.",
)
: currentAdmins.length <= 1
? t(
"msg.admin.tenants.admins.remove_last",
"마지막 관리자는 회수할 수 없습니다.",
)
: t(
"ui.admin.tenants.admins.remove_title",
"관리자 권한 회수",
)}
</span>
</span>
</TableCell>
</TableRow>
))
)}

View File

@@ -1,9 +1,8 @@
import { useMutation, useQuery } 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 { useCallback, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -12,29 +11,97 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Checkbox } from "../../../components/ui/checkbox";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { createTenant, fetchTenants } from "../../../lib/adminApi";
import { createTenant, fetchAllTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import {
formatDomainConflictMessage,
type ServerDomainConflict,
} from "../utils/domainTags";
import {
mergeTenantOrgConfig,
ORG_UNIT_TYPE_OPTIONS,
shouldAllowHanmacOrgConfig,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
} from "../utils/orgConfig";
type AdminFrontTestHooks = {
selectTenantParent?: (tenantId: string) => Promise<void>;
};
function TenantCreatePage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [parentId, setParentId] = useState("");
const [parentId, setParentId] = useState(
() => searchParams.get("parentId") ?? "",
);
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
const [orgUnitType, setOrgUnitType] = useState("");
const [visibility, setVisibility] = useState<TenantVisibility>("public");
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
const [domains, setDomains] = useState<string[]>([]);
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
[],
);
const parentQuery = useQuery({
queryKey: ["tenants", { limit: 1000 }],
queryFn: () => fetchTenants(1000, 0),
queryKey: ["tenants", "all"],
queryFn: () => fetchAllTenants(),
});
const tenants = parentQuery.data?.items ?? [];
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
const canConfigureHanmacOrg = useMemo(() => {
if (!selectedParentTenant) return false;
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
return true;
}
return shouldAllowHanmacOrgConfig(selectedParentTenant, tenants);
}, [selectedParentTenant, tenants]);
const canEditTenantDetails =
parentStepConfirmed || Boolean(selectedParentTenant);
const parentContextLabel = selectedParentTenant
? canConfigureHanmacOrg
? t(
"ui.admin.tenants.create.parent_context.hanmac",
"한맥가족 하위 테넌트",
)
: t("ui.admin.tenants.create.parent_context.general", "일반 하위 테넌트")
: parentStepConfirmed
? t("ui.admin.tenants.create.parent_context.root", "최상위 테넌트")
: t(
"ui.admin.tenants.create.parent_context.pick_required",
"상위 테넌트 선택 필요",
);
const handleParentChange = useCallback((nextParentId: string) => {
setParentId(nextParentId);
setParentStepConfirmed(false);
}, []);
if (typeof window !== "undefined") {
const testWindow = window as Window &
typeof globalThis & {
__adminfrontTestHooks?: AdminFrontTestHooks;
};
const hooks = testWindow.__adminfrontTestHooks ?? {};
hooks.selectTenantParent = async (tenantId: string) => {
handleParentChange(tenantId);
};
testWindow.__adminfrontTestHooks = hooks;
}
const mutation = useMutation({
mutationFn: () =>
mutationFn: (overrideForceDomains?: string[]) =>
createTenant({
name,
type,
@@ -42,14 +109,41 @@ function TenantCreatePage() {
parentId: parentId || undefined,
description: description || undefined,
status,
domains: domains
.split(",")
.map((d) => d.trim())
.filter((d) => d !== ""),
domains,
config: canConfigureHanmacOrg
? mergeTenantOrgConfig(undefined, {
orgUnitType,
visibility,
worksmobileExcluded,
})
: undefined,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
}),
onSuccess: () => {
navigate("/tenants");
},
onError: (
err: AxiosError<{
code?: string;
error?: string;
conflicts?: ServerDomainConflict[];
}>,
) => {
const conflicts = err.response?.data?.conflicts ?? [];
if (
err.response?.data?.code === "tenant_domain_conflict" &&
conflicts.length > 0
) {
const nextForceDomains = Array.from(
new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]),
);
const message = conflicts.map(formatDomainConflictMessage).join("\n");
if (window.confirm(message)) {
setForceDomainConflicts(nextForceDomains);
mutation.mutate(nextForceDomains);
}
}
},
});
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
@@ -87,152 +181,292 @@ function TenantCreatePage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tenant-name" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
id="tenant-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.name_placeholder",
"테넌트 이름을 입력하세요",
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-type" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="tenant-type"
name="type"
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"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="parentId" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
</Label>
<select
id="parentId"
name="parentId"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{parentQuery.data?.items?.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label>
<Input
id="tenant-slug"
name="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.slug_placeholder",
"tenant-slug",
)}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="tenant-description"
className="text-sm font-semibold"
<div
data-testid="tenant-parent-org-config-layout"
className="grid gap-4 md:grid-cols-4"
>
<div
data-testid="tenant-parent-picker-slot"
className={
canConfigureHanmacOrg ? "md:col-span-2" : "md:col-span-4"
}
>
{t("ui.admin.tenants.create.form.description", "설명")}
</Label>
<Textarea
id="tenant-description"
name="description"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-domains" className="text-sm font-semibold">
{t(
"ui.admin.tenants.create.form.domains_label",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<Input
id="tenant-domains"
name="domains"
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.domains_placeholder",
"example.com, example.kr",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.tenants.create.form.domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
<ParentTenantSelector
id="parentId"
label={t(
"ui.admin.tenants.create.form.parent",
"상위 테넌트 (선택)",
)}
value={parentId}
onChange={handleParentChange}
tenants={tenants}
noneLabel={t("ui.common.none", "없음")}
contextLabel={parentContextLabel}
orgChartPickerLabel={t(
"ui.admin.tenants.create.form.pick_hanmac_parent",
"한맥가족에서 선택",
)}
localPickerLabel={t(
"ui.admin.tenants.create.form.pick_other_parent",
"다른 테넌트 선택",
)}
localTenantFilter={(tenant) =>
tenant.slug.toLowerCase() !== "hanmac-family" &&
!shouldAllowHanmacOrgConfig(tenant, tenants)
}
labelAction={
!selectedParentTenant ? (
<Button
type="button"
variant={parentStepConfirmed ? "default" : "outline"}
size="sm"
onClick={() => setParentStepConfirmed(true)}
>
{t(
"ui.admin.tenants.create.form.root_tenant",
"최상위 테넌트로 생성",
)}
</Button>
) : null
}
/>
<button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
data-testid="tenant-test-select-hanmac-parent"
hidden
onClick={() => handleParentChange("family-1")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
test-select-hanmac-parent
</button>
</div>
{canConfigureHanmacOrg && (
<>
<div
data-testid="tenant-org-unit-type-slot"
className="space-y-2"
>
<Label
htmlFor="tenant-org-unit-type"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.org_unit_type",
"조직 세부타입",
)}
</Label>
<select
id="tenant-org-unit-type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={orgUnitType}
onChange={(event) => setOrgUnitType(event.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div data-testid="tenant-visibility-slot" className="space-y-2">
<Label
htmlFor="tenant-visibility"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
id="tenant-visibility"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={visibility}
onChange={(event) =>
setVisibility(event.target.value as TenantVisibility)
}
>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div
data-testid="tenant-worksmobile-excluded-slot"
className="flex min-h-9 items-center gap-2 rounded-md border border-input px-3 py-2"
>
<Checkbox
id="worksmobileExcluded"
checked={worksmobileExcluded}
onCheckedChange={(checked) =>
setWorksmobileExcluded(checked === true)
}
/>
<Label
htmlFor="worksmobileExcluded"
className="cursor-pointer text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.worksmobile_excluded",
"WORKS 연동 제외",
)}
</Label>
</div>
</>
)}
</div>
{canEditTenantDetails && (
<>
<div className="space-y-2">
<Label htmlFor="tenant-name" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
id="tenant-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.name_placeholder",
"테넌트 이름을 입력하세요",
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor="tenant-type"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="tenant-type"
name="type"
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"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label>
<Input
id="tenant-slug"
name="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.slug_placeholder",
"tenant-slug",
)}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="tenant-description"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.create.form.description", "설명")}
</Label>
<Textarea
id="tenant-description"
name="description"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="tenant-domains"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.create.form.domains_label",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={setDomains}
tenants={tenants}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder={t(
"ui.admin.tenants.create.form.domains_placeholder",
"example.com, example.kr",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.tenants.create.form.domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
</>
)}
{!canEditTenantDetails && (
<div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.create.pick_parent_first",
"상위 테넌트를 먼저 선택하세요.",
)}
</div>
)}
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
@@ -268,7 +502,7 @@ function TenantCreatePage() {
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => mutation.mutate()}
onClick={() => mutation.mutate(undefined)}
disabled={mutation.isPending || name.trim() === ""}
>
{t("ui.common.create", "생성")}

View File

@@ -1,9 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { Copy } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
function TenantDetailPage() {
const params = useParams<{ tenantId: string }>();
@@ -21,20 +22,50 @@ function TenantDetailPage() {
queryFn: fetchMe,
});
const canAccessSchema =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
const profileRole = normalizeAdminRole(profile?.role);
const canAccessSchema = profileRole === "super_admin";
const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization");
const isWorksmobileTab = location.pathname.includes("/worksmobile");
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ??
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
</h2>
<div
className="flex flex-wrap items-center gap-3"
data-testid="tenant-detail-title-row"
>
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ??
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
</h2>
{tenantQuery.data?.id && (
<div
className="flex items-center gap-1.5"
data-testid="tenant-detail-uuid"
>
<code className="select-all rounded-md border border-border bg-muted/40 px-2 py-1 font-mono text-xs text-foreground">
{tenantQuery.data.id}
</code>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
void navigator.clipboard?.writeText(tenantQuery.data.id);
}}
aria-label="테넌트 UUID 복사"
title="테넌트 UUID 복사"
data-testid="tenant-detail-copy-uuid"
>
<Copy className="h-4 w-4" />
</Button>
</div>
)}
</div>
<p className="text-sm text-[var(--color-muted)]">
{t(
"ui.admin.tenants.detail.header_subtitle",
@@ -51,6 +82,7 @@ function TenantDetailPage() {
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
!isPermissionsTab &&
!location.pathname.includes("/schema") &&
!isWorksmobileTab &&
!isOrganizationTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"

View File

@@ -0,0 +1,51 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import TenantDetailPage from "./TenantDetailPage";
vi.mock("../../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
fetchTenant: vi.fn(async () => ({
id: "hanmac-family-id",
name: "한맥 가족",
slug: "hanmac-family",
parentId: null,
})),
}));
function renderTenantDetailPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/hanmac-family-id"]}>
<Routes>
<Route path="/tenants/:tenantId/*" element={<TenantDetailPage />}>
<Route index element={<div>profile</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantDetailPage Worksmobile navigation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not render Worksmobile as a tenant detail tab", async () => {
renderTenantDetailPage();
await screen.findByText("프로필");
expect(screen.queryByRole("link", { name: /Worksmobile/i })).toBeNull();
});
});

View File

@@ -2,13 +2,16 @@ import {
type UseMutationResult,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowRightLeft,
ChevronDown,
ChevronRight,
Plus,
RefreshCw,
Search,
Shield,
Trash2,
UserMinus,
@@ -18,6 +21,7 @@ import {
import type React from "react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -27,8 +31,17 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
Table,
TableBody,
@@ -39,15 +52,16 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
type GroupSummary,
addGroupMember,
createGroup,
deleteGroup,
fetchGroups,
fetchTenant,
fetchUsers,
type GroupSummary,
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
type UserGroupNode = GroupSummary & {
children: UserGroupNode[];
@@ -224,6 +238,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
function TenantGroupsPage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const _queryClient = useQueryClient();
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
@@ -232,13 +247,37 @@ function TenantGroupsPage() {
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
// Modal States
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false);
const [memberActionTargetUserId, setMemberActionTargetUserId] = useState<
string | null
>(null);
const [userSearchTerm, setUserSearchTerm] = useState("");
const [groupSearchTerm, setGroupSearchTerm] = useState("");
// 테넌트 정보 조회 (slug 획득)
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const tenantSlug = tenantQuery.data?.slug;
// 해당 테넌트의 사용자 목록 조회
const usersQuery = useQuery({
queryKey: ["users", { tenantSlug }],
queryFn: () => fetchUsers(1000, 0, undefined, tenantSlug),
enabled: !!tenantSlug,
});
const users = usersQuery.data?.items ?? [];
// 그룹 목록 조회
const groupsQuery = useQuery({
queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId),
enabled: tenantId.length > 0,
});
// 그룹 생성
const createMutation = useMutation({
mutationFn: () =>
@@ -319,306 +358,545 @@ function TenantGroupsPage() {
},
});
// 멤버 이동 (Remove -> Add)
const moveMemberMutation = useMutation({
mutationFn: async ({
sourceGroupId,
targetGroupId,
userId,
}: {
sourceGroupId: string;
targetGroupId: string;
userId: string;
}) => {
await removeGroupMember(tenantId, sourceGroupId, userId);
await addGroupMember(tenantId, targetGroupId, userId);
},
onSuccess: () => {
toast.success(
t("msg.admin.groups.members.move_success", "멤버가 이동되었습니다."),
);
groupsQuery.refetch();
setIsMoveMemberModalOpen(false);
setMemberActionTargetUserId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
const groupTree = groupsQuery.data
? buildGroupTree(groupsQuery.data, tenantId)
: [];
const handleAddSubGroup = (parentId: string) => {
setNewGroupParentId(parentId);
// Optionally scroll to the create form or highlight it
};
const handleAddMember = (groupId: string) => {
const userId = window.prompt(
t(
"msg.admin.groups.prompt.user_id",
"추가할 사용자의 UUID를 입력하세요:",
),
);
if (userId) {
addMemberMutation.mutate({ groupId, userId });
}
};
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return (
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
{/* 그룹 생성 폼 */}
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader className="flex-shrink-0">
<CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.create.description",
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1 overflow-auto">
<div className="space-y-1">
<Label htmlFor="name">
{t("ui.admin.groups.form.name_label", "그룹 이름")}
</Label>
<Input
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t(
"ui.admin.groups.form.name_placeholder",
"예: 개발팀, 인사팀",
)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="unitType">
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
</Label>
<Input
id="unitType"
value={newGroupUnitType}
onChange={(e) => setNewGroupUnitType(e.target.value)}
placeholder={t(
"ui.admin.groups.form.unit_level_placeholder",
"예: 본부, 팀, 셀",
)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="parentId">
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
</Label>
<select
id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-background 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"
value={newGroupParentId || ""}
onChange={(e) => setNewGroupParentId(e.target.value || null)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{groupsQuery.data?.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="desc">
{t("ui.admin.groups.form.desc_label", "설명")}
</Label>
<Input
id="desc"
value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)}
placeholder={t(
"ui.admin.groups.form.desc_placeholder",
"그룹 용도 설명",
)}
/>
</div>
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
</CardContent>
</Card>
{/* 그룹 목록 (트리 뷰) */}
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.groups.list.title", "User Groups")}
<>
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
{/* 그룹 생성 폼 */}
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader className="flex-shrink-0">
<CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.groups.list.subtitle",
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
"ui.admin.groups.create.description",
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
)}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<OrgChartUploadModal
tenantId={tenantId}
onSuccess={() => groupsQuery.refetch()}
/>
<Button
variant="ghost"
size="sm"
onClick={() => groupsQuery.refetch()}
>
<RefreshCw size={14} />
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.admin.groups.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.groups.table.members", "MEMBERS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.isLoading && (
<TableRow>
<TableCell colSpan={3}>
{t("msg.admin.groups.list.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!groupsQuery.isLoading && groupTree.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.list.empty",
"아직 등록된 그룹이 없습니다.",
)}
</TableCell>
</TableRow>
)}
{groupTree.map((node) => (
<UserGroupTreeNode
key={node.id}
node={node}
level={0}
onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId}
onDelete={(id) => {
if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
"그룹을 삭제하시겠습니까?",
),
)
) {
deleteMutation.mutate(id);
}
}}
onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
/>
))}
</TableBody>
</Table>
</CardHeader>
<CardContent className="space-y-4 flex-1 overflow-auto">
<div className="space-y-1">
<Label htmlFor="name">
{t("ui.admin.groups.form.name_label", "그룹 이름")}
</Label>
<Input
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t(
"ui.admin.groups.form.name_placeholder",
"예: 개발팀, 인사팀",
)}
/>
</div>
</div>
</CardContent>
</Card>
<div className="space-y-1">
<Label htmlFor="unitType">
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
</Label>
<Input
id="unitType"
value={newGroupUnitType}
onChange={(e) => setNewGroupUnitType(e.target.value)}
placeholder={t(
"ui.admin.groups.form.unit_level_placeholder",
"예: 본부, 팀, 셀",
)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="parentId">
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
</Label>
<select
id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-background 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"
value={newGroupParentId || ""}
onChange={(e) => setNewGroupParentId(e.target.value || null)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{groupsQuery.data?.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="desc">
{t("ui.admin.groups.form.desc_label", "설명")}
</Label>
<Input
id="desc"
value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)}
placeholder={t(
"ui.admin.groups.form.desc_placeholder",
"그룹 용도 설명",
)}
/>
</div>
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
</CardContent>
</Card>
{/* 그룹 목록 (트리 뷰) */}
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.groups.list.title", "User Groups")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.groups.list.subtitle",
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
)}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => groupsQuery.refetch()}
>
<RefreshCw size={14} />
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead>
{t("ui.admin.groups.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.groups.table.members", "MEMBERS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.isLoading && (
<TableRow>
<TableCell colSpan={3}>
{t("msg.admin.groups.list.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!groupsQuery.isLoading && groupTree.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.list.empty",
"아직 등록된 그룹이 없습니다.",
)}
</TableCell>
</TableRow>
)}
{groupTree.map((node) => (
<UserGroupTreeNode
key={node.id}
node={node}
level={0}
onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId}
onDelete={(id) => {
if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
"그룹을 삭제하시겠습니까?",
),
)
) {
deleteMutation.mutate(id);
}
}}
onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
/>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
{currentGroup && (
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader className="flex-shrink-0">
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" />
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
name: currentGroup.name,
})}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"그룹에 속한 멤버들을 확인하고 관리합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex justify-end mb-4 flex-shrink-0">
<Button
size="sm"
onClick={() => setIsAddMemberModalOpen(true)}
disabled={addMemberMutation.isPending}
>
<UserPlus size={14} className="mr-1" />
{t("ui.common.add", "멤버 추가")}
</Button>
</div>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead>
{t("ui.admin.groups.members.table.name", "이름")}
</TableHead>
<TableHead>
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.actions", "관리")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-4 text-muted-foreground"
>
{t(
"msg.admin.groups.members.empty",
"멤버가 없습니다.",
)}
</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">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setMemberActionTargetUserId(user.id);
setIsMoveMemberModalOpen(true);
}}
disabled={moveMemberMutation.isPending}
title={t("ui.common.move", "이동")}
>
<ArrowRightLeft
size={14}
className="text-primary"
/>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (
window.confirm(
t(
"msg.admin.groups.members.remove_confirm",
"'{{name}}' 님을 이 그룹에서 제외하시겠습니까?",
{ name: user.name },
),
)
) {
removeMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
});
}
}}
disabled={removeMemberMutation.isPending}
title={t("ui.common.remove", "제거")}
>
<UserMinus
size={14}
className="text-destructive"
/>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
)}
</div>
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
{currentGroup && (
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader className="flex-shrink-0">
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" />
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
name: currentGroup.name,
})}
</CardTitle>
<CardDescription>
{/* Add Member Modal */}
<Dialog
open={isAddMemberModalOpen}
onOpenChange={(val) => {
setIsAddMemberModalOpen(val);
if (!val) setUserSearchTerm("");
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"그룹에 속한 멤버들을 확인하고 관리합니다.",
"msg.admin.groups.members.add_modal_desc",
"이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.",
)}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex justify-end mb-4 flex-shrink-0">
<Button
size="sm"
onClick={() => handleAddMember(currentGroup.id)}
disabled={addMemberMutation.isPending}
>
<UserPlus size={14} className="mr-1" />
{t("ui.common.add", "멤버 추가")}
</Button>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "검색...")}
className="pl-9 h-9"
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
/>
</div>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.admin.groups.members.table.name", "이름")}
</TableHead>
<TableHead>
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.remove", "제거")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-4 text-muted-foreground"
>
{t(
"msg.admin.groups.members.empty",
"멤버가 없습니다.",
)}
</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({
<ScrollArea className="h-[250px] rounded-md border p-2">
<div className="space-y-1">
{usersQuery.isLoading ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("ui.common.loading", "로딩 중...")}
</div>
) : (
users
.filter((u) => {
const term = userSearchTerm.toLowerCase();
return (
u.name.toLowerCase().includes(term) ||
u.email.toLowerCase().includes(term)
);
})
.filter(
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
) // Exclude existing members
.map((user) => (
<div
key={user.id}
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">
{user.email}
</p>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => {
if (currentGroup) {
addMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
})
});
}
disabled={removeMemberMutation.isPending}
>
<UserMinus size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
}}
disabled={addMemberMutation.isPending}
>
{t("ui.common.add", "추가")}
</Button>
</div>
))
)}
{users.length > 0 &&
users.filter(
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
).length === 0 && (
<div className="p-4 text-center text-sm text-muted-foreground">
{t(
"msg.admin.groups.members.all_added",
"모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.",
)}
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddMemberModalOpen(false)}
>
{t("ui.common.close", "닫기")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Move Member Modal */}
<Dialog
open={isMoveMemberModalOpen}
onOpenChange={(val) => {
setIsMoveMemberModalOpen(val);
if (!val) {
setMemberActionTargetUserId(null);
setGroupSearchTerm("");
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("ui.admin.groups.members.move_modal_title", "부서 이동")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.groups.members.move_modal_desc",
"선택한 멤버를 이동할 대상 그룹을 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search_group", "그룹 검색...")}
className="pl-9 h-9"
value={groupSearchTerm}
onChange={(e) => setGroupSearchTerm(e.target.value)}
/>
</div>
</CardContent>
</Card>
)}
</div>
<ScrollArea className="h-[250px] rounded-md border p-2">
<div className="space-y-1">
{groupsQuery.isLoading ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("ui.common.loading", "로딩 중...")}
</div>
) : groupsQuery.data && groupsQuery.data.length > 0 ? (
groupsQuery.data
.filter((g) =>
g.name
.toLowerCase()
.includes(groupSearchTerm.toLowerCase()),
)
.filter((g) => g.id !== currentGroup?.id) // Exclude current group
.map((group) => (
<div
key={group.id}
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
>
<div className="flex items-center gap-2">
<Users size={14} className="text-muted-foreground" />
<span className="font-medium">{group.name}</span>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
if (currentGroup && memberActionTargetUserId) {
moveMemberMutation.mutate({
sourceGroupId: currentGroup.id,
targetGroupId: group.id,
userId: memberActionTargetUserId,
});
}
}}
disabled={moveMemberMutation.isPending}
>
{t("ui.common.move", "이동")}
</Button>
</div>
))
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("msg.admin.groups.list.no_results", "그룹이 없습니다.")}
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsMoveMemberModalOpen(false)}
>
{t("ui.common.cancel", "취소")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
filterTenantsByScope,
getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
tenantMatchesListSearch,
} from "./tenantListView";
function tenant(
id: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
name,
slug,
parentId,
type: parentId ? "ORGANIZATION" : "COMPANY",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
};
}
const tenants = [
tenant("company-1", "한맥기술", "hanmac"),
tenant("dept-1", "기술기획", "planning", "company-1"),
tenant("team-1", "플랫폼팀", "platform", "dept-1"),
tenant("company-2", "삼안", "saman"),
];
describe("TenantListPage tenant list helpers", () => {
it("selects a parent tenant together with every descendant", () => {
expect(
resolveTenantSelectionIds({
currentIds: [],
tenant: tenants[0],
checked: true,
tenants,
deletableTenants: tenants,
}),
).toEqual(["company-1", "dept-1", "team-1"]);
});
it("removes a parent tenant together with every descendant", () => {
expect(
resolveTenantSelectionIds({
currentIds: ["company-1", "dept-1", "team-1", "company-2"],
tenant: tenants[0],
checked: false,
tenants,
deletableTenants: tenants,
}),
).toEqual(["company-2"]);
});
it("filters to descendants of the selected scope tenant", () => {
expect(
filterTenantsByScope(tenants, "company-1").map((item) => item.id),
).toEqual(["dept-1", "team-1"]);
});
it("searches tenants by name, slug, and UUID", () => {
expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "삼안")).toBe(false);
});
it("can return tree rows or same-level table rows", () => {
expect(getTenantViewRows(tenants, "tree").map((row) => row.depth)).toEqual([
0, 1, 2, 0,
]);
expect(getTenantViewRows(tenants, "table").map((row) => row.depth)).toEqual(
[0, 0, 0, 0],
);
});
it("marks only direct search matches when tree search includes ancestors", () => {
const treeRows = getTenantViewRows(
tenants.filter((item) => item.id !== "company-2"),
"tree",
"",
true,
);
expect(treeRows.map((row) => row.id)).toEqual([
"company-1",
"dept-1",
"team-1",
]);
expect(getTenantSearchMatchIds(treeRows, "platform")).toEqual(["team-1"]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -18,76 +18,143 @@ import { toast } from "../../../components/ui/use-toast";
import {
approveTenant,
deleteTenant,
fetchAllTenants,
fetchTenant,
fetchTenants,
updateTenant,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import {
formatDomainConflictMessage,
type ServerDomainConflict,
} from "../utils/domainTags";
import {
getOrgUnitTypeOptionsForTenantType,
mergeTenantOrgConfig,
readTenantOrgConfig,
removeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
} from "../utils/orgConfig";
import { isSeedTenant } from "../utils/protectedTenants";
export function TenantProfilePage() {
const { tenantId } = useParams<{ tenantId: string }>();
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? "";
const navigate = useNavigate();
const queryClient = useQueryClient();
if (!tenantId) {
return (
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
);
}
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchTenants(1000, 0),
queryFn: () => fetchAllTenants(),
});
const availableParents =
parentQuery.data?.items?.filter((t) => t.id !== tenantId) || [];
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
const [domains, setDomains] = useState<string[]>([]);
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
[],
);
const [parentId, setParentId] = useState("");
const [orgUnitType, setOrgUnitType] = useState("");
const [tenantVisibility, setTenantVisibility] =
useState<TenantVisibility>("public");
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
useEffect(() => {
if (tenantQuery.data) {
const orgConfig = readTenantOrgConfig(tenantQuery.data.config);
setName(tenantQuery.data.name);
setType(tenantQuery.data.type || "COMPANY");
setSlug(tenantQuery.data.slug);
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
setDomains(tenantQuery.data.domains ?? []);
setForceDomainConflicts([]);
setParentId(tenantQuery.data.parentId ?? "");
setOrgUnitType(orgConfig.orgUnitType);
setTenantVisibility(orgConfig.visibility);
setWorksmobileExcluded(orgConfig.worksmobileExcluded);
}
}, [tenantQuery.data]);
const allTenants = parentQuery.data?.items ?? [];
const orgConfigCandidate = tenantQuery.data
? {
...tenantQuery.data,
parentId: parentId || undefined,
slug,
}
: undefined;
const canEditOrgConfig = orgConfigCandidate
? shouldAllowHanmacOrgConfig(orgConfigCandidate, [
...allTenants,
orgConfigCandidate,
])
: false;
const orgUnitTypeOptions = getOrgUnitTypeOptionsForTenantType(type);
const updateMutation = useMutation({
mutationFn: () =>
updateTenant(tenantId, {
mutationFn: (overrideForceDomains?: string[]) => {
const baseConfig = tenantQuery.data?.config;
const config = canEditOrgConfig
? mergeTenantOrgConfig(baseConfig, {
orgUnitType,
visibility: tenantVisibility,
worksmobileExcluded,
})
: removeTenantOrgConfig(baseConfig);
return updateTenant(tenantId, {
name,
type,
slug,
description: description || undefined,
status,
parentId: parentId || undefined,
domains: domains
.split(",")
.map((d) => d.trim())
.filter((d) => d !== ""),
}),
domains,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
config,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
toast.success(t("msg.info.saved_success", "저장되었습니다."));
},
onError: (err: AxiosError<{ error?: string }>) => {
onError: (
err: AxiosError<{
code?: string;
error?: string;
conflicts?: ServerDomainConflict[];
}>,
) => {
const conflicts = err.response?.data?.conflicts ?? [];
if (
err.response?.data?.code === "tenant_domain_conflict" &&
conflicts.length > 0
) {
const nextForceDomains = Array.from(
new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]),
);
const message = conflicts.map(formatDomainConflictMessage).join("\n");
if (window.confirm(message)) {
setForceDomainConflicts(nextForceDomains);
updateMutation.mutate(nextForceDomains);
}
return;
}
toast.error(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
@@ -126,8 +193,20 @@ export function TenantProfilePage() {
?.response?.data?.error;
const loadError = (tenantQuery.error as AxiosError<{ error?: string }>)
?.response?.data?.error;
const isProtectedSeedTenant = tenantQuery.data
? isSeedTenant(tenantQuery.data)
: false;
if (!tenantId) {
return (
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
);
}
const handleDelete = () => {
if (isProtectedSeedTenant) {
return;
}
if (
window.confirm(
t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", {
@@ -151,143 +230,242 @@ export function TenantProfilePage() {
return (
<>
<Card className="bg-[var(--color-panel)] mt-6">
<CardHeader>
<CardTitle>
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription>
<Card className="mt-4 bg-[var(--color-panel)]">
<CardHeader className="px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="text-lg">
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-3 px-5 pb-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">
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<div
data-testid="tenant-profile-primary-row"
className="grid gap-3 lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.8fr)_minmax(320px,1.4fr)]"
>
<div data-testid="tenant-name-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div data-testid="tenant-slug-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
<ParentTenantSelector
id="parentId"
label={t(
"ui.admin.tenants.profile.form.parent",
"상위 테넌트 (선택)",
)}
value={parentId}
onChange={setParentId}
tenants={parentQuery.data?.items ?? []}
noneLabel={t("ui.common.none", "없음 (최상위)")}
excludeTenantId={tenantId}
compact
controlTestId="tenant-parent-picker-control"
/>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
</Label>
<select
id="type"
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"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="parentId" className="text-sm font-semibold">
{t("ui.admin.tenants.profile.form.parent", "상위 테넌트 (선택)")}
</Label>
<select
id="parentId"
name="parentId"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
>
<option value="">{t("ui.common.none", "없음 (최상위)")}</option>
{availableParents.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.slug})
<div
data-testid="tenant-profile-config-row"
className="grid gap-3 lg:grid-cols-[minmax(190px,1fr)_minmax(150px,0.8fr)_minmax(150px,0.8fr)_minmax(190px,0.9fr)]"
>
<div data-testid="tenant-type-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
</Label>
<select
id="type"
data-testid="tenant-type-select"
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"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
{t(
"ui.admin.tenants.profile.form.parent_help",
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
)}
</p>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
{canEditOrgConfig && (
<>
<div
data-testid="tenant-org-unit-type-slot"
className="space-y-1"
>
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.org_unit_type",
"조직 세부타입",
)}
</Label>
<select
id="tenant-org-unit-type"
name="tenant-org-unit-type"
data-testid="tenant-org-unit-type-select"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={orgUnitType}
onChange={(event) => setOrgUnitType(event.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{orgUnitTypeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div data-testid="tenant-visibility-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
id="tenant-visibility"
name="tenant-visibility"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={tenantVisibility}
onChange={(event) =>
setTenantVisibility(
event.target.value as TenantVisibility,
)
}
>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div
data-testid="tenant-worksmobile-excluded-slot"
className="space-y-1"
>
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.worksmobile_sync",
"WORKS 연동",
)}
</Label>
<select
id="worksmobileExcluded"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={worksmobileExcluded ? "excluded" : "enabled"}
onChange={(event) =>
setWorksmobileExcluded(event.target.value === "excluded")
}
>
<option value="enabled">
{t(
"ui.admin.tenants.profile.worksmobile_enabled",
"연동",
)}
</option>
<option value="excluded">
{t(
"ui.admin.tenants.profile.worksmobile_excluded",
"제외",
)}
</option>
</select>
</div>
</>
)}
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.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">
{t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<Input
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder="example.com, example.kr"
/>
<p className="text-xs text-muted-foreground">
{t(
"ui.admin.tenants.profile.allowed_domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
<div className="grid gap-3 lg:grid-cols-[minmax(260px,0.9fr)_minmax(360px,1.4fr)_auto]">
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.description", "설명")}
</Label>
<Textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={setDomains}
tenants={parentQuery.data?.items ?? []}
currentTenantId={tenantId}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder="example.com, example.kr"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.status", "상태")}
</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
size="sm"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
</div>
{errorMsg && (
@@ -298,11 +476,19 @@ export function TenantProfilePage() {
</CardContent>
</Card>
<div className="mt-8 flex flex-wrap items-center justify-between gap-3">
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending}
disabled={deleteMutation.isPending || isProtectedSeedTenant}
title={
isProtectedSeedTenant
? t(
"msg.admin.tenants.seed_delete_blocked",
"초기 설정 테넌트는 삭제할 수 없습니다.",
)
: undefined
}
>
<Trash2 size={16} />
{t("ui.common.delete", "삭제")}
@@ -322,7 +508,7 @@ export function TenantProfilePage() {
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => updateMutation.mutate()}
onClick={() => updateMutation.mutate(undefined)}
disabled={
updateMutation.isPending ||
tenantQuery.isLoading ||

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { createSchemaField, normalizeSchemaField } from "./tenantSchemaFields";
describe("TenantSchemaPage schema field helpers", () => {
it("creates text fields without varchar maxLength policy", () => {
const field = createSchemaField();
expect(field.type).toBe("text");
expect("maxLength" in field).toBe(false);
expect(field.indexed).toBe(false);
});
it("does not add maxLength to legacy text schema fields", () => {
const field = normalizeSchemaField({
key: "emp_id",
label: "사번",
type: "text",
});
expect("maxLength" in field).toBe(false);
});
it("forces indexed when a field can be used as login ID", () => {
const field = normalizeSchemaField({
key: "emp_id",
label: "사번",
type: "text",
indexed: false,
isLoginId: true,
});
expect(field.indexed).toBe(true);
expect(field.isLoginId).toBe(true);
});
});

View File

@@ -16,33 +16,13 @@ import { Label } from "../../../components/ui/label";
import { toast } from "../../../components/ui/use-toast";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type SchemaFieldType =
| "text"
| "number"
| "boolean"
| "date"
| "float"
| "datetime";
type SchemaField = {
id: string;
key: string;
label: string;
type: SchemaFieldType;
required: boolean;
adminOnly: boolean;
validation?: string;
unsigned?: boolean;
isLoginId?: boolean;
};
function createFieldId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
import { normalizeAdminRole } from "../../../lib/roles";
import {
createSchemaField,
isSchemaFieldType,
normalizeSchemaField,
type SchemaField,
} from "./tenantSchemaFields";
export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
@@ -53,8 +33,8 @@ export function TenantSchemaPage() {
queryFn: fetchMe,
});
const canAccess =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
const profileRole = normalizeAdminRole(profile?.role);
const canAccess = profileRole === "super_admin";
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
@@ -71,27 +51,7 @@ export function TenantSchemaPage() {
const rawSchema = tenantQuery.data?.config?.userSchema;
if (Array.isArray(rawSchema)) {
setFields(
rawSchema.map((field) => ({
id: typeof field?.id === "string" ? field.id : createFieldId(),
key: typeof field?.key === "string" ? field.key : "",
label: typeof field?.label === "string" ? field.label : "",
type:
field?.type === "number" ||
field?.type === "boolean" ||
field?.type === "date" ||
field?.type === "float" ||
field?.type === "datetime"
? field.type
: "text",
required: Boolean(field?.required),
adminOnly: Boolean(field?.adminOnly),
validation:
typeof field?.validation === "string" ? field.validation : "",
unsigned: Boolean(field?.unsigned),
isLoginId: Boolean(field?.isLoginId),
})),
);
setFields(rawSchema.map(normalizeSchemaField));
}
}, [tenantQuery.data]);
@@ -158,19 +118,7 @@ export function TenantSchemaPage() {
}
const addField = () => {
setFields([
...fields,
{
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
unsigned: false,
},
]);
setFields([...fields, createSchemaField()]);
};
const removeField = (index: number) => {
@@ -257,20 +205,21 @@ export function TenantSchemaPage() {
{t("ui.admin.tenants.schema.field.type", "유형")}
</Label>
<select
id={`tenant-schema-field-type-${field.key || index}`}
name={`tenant-schema-field-type-${field.key || index}`}
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
value={field.type}
onChange={(e) => {
const nextType = e.target.value;
if (
nextType === "text" ||
nextType === "number" ||
nextType === "boolean" ||
nextType === "date" ||
nextType === "float" ||
nextType === "datetime"
) {
if (isSchemaFieldType(nextType)) {
updateField(index, {
type: nextType as SchemaFieldType,
type: nextType,
isLoginId:
nextType === "text" ? field.isLoginId : false,
indexed:
nextType === "text"
? field.indexed || field.isLoginId || false
: field.indexed,
});
}
}}
@@ -319,6 +268,7 @@ export function TenantSchemaPage() {
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-required-${field.key || index}`}
type="checkbox"
checked={field.required}
onChange={(e) =>
@@ -332,6 +282,7 @@ export function TenantSchemaPage() {
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-admin-only-${field.key || index}`}
type="checkbox"
checked={field.adminOnly}
onChange={(e) =>
@@ -348,10 +299,15 @@ export function TenantSchemaPage() {
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-login-id-${field.key || index}`}
type="checkbox"
checked={field.isLoginId || false}
onChange={(e) =>
updateField(index, { isLoginId: e.target.checked })
updateField(index, {
isLoginId: e.target.checked,
indexed: e.target.checked ? true : field.indexed,
type: e.target.checked ? "text" : field.type,
})
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
@@ -362,9 +318,28 @@ export function TenantSchemaPage() {
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-indexed-${field.key || index}`}
type="checkbox"
checked={field.indexed || field.isLoginId || false}
disabled={field.isLoginId}
onChange={(e) =>
updateField(index, { indexed: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
/>
<span className="text-sm font-medium">
{t(
"ui.admin.tenants.schema.field.indexed",
"검색 인덱스 필요",
)}
</span>
</label>
{(field.type === "number" || field.type === "float") && (
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-unsigned-${field.key || index}`}
type="checkbox"
checked={field.unsigned}
onChange={(e) =>

View File

@@ -1,6 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, Building2, Plus } from "lucide-react";
import { Building2, Plus } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
commonStickyTableHeaderClass,
commonTableShellClass,
commonTableViewportClass,
} from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -18,7 +23,7 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { fetchTenants } from "../../../lib/adminApi";
import { fetchAllTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantSubTenantsPage() {
@@ -27,7 +32,7 @@ function TenantSubTenantsPage() {
const { data } = useQuery({
queryKey: ["sub-tenants", tenantId],
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
queryFn: () => fetchAllTenants({ parentId: tenantId ?? undefined }),
enabled: !!tenantId,
});
@@ -58,10 +63,10 @@ function TenantSubTenantsPage() {
</Button>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead>
{t("ui.admin.tenants.sub.table.name", "NAME")}
@@ -72,16 +77,13 @@ function TenantSubTenantsPage() {
<TableHead>
{t("ui.admin.tenants.sub.table.status", "STATUS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.tenants.sub.table.action", "ACTION")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subTenants.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
@@ -92,7 +94,11 @@ function TenantSubTenantsPage() {
</TableRow>
)}
{subTenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableRow
key={tenant.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<TableCell className="font-semibold">
{tenant.name}
</TableCell>
@@ -108,16 +114,6 @@ function TenantSubTenantsPage() {
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
<ArrowRight size={12} className="ml-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>

View File

@@ -0,0 +1,148 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../../test/i18nMock";
import TenantUsersPage from "./TenantUsersPage";
const exportUsersCSVMock = vi.hoisted(() => vi.fn());
const updateUserMock = vi.hoisted(() => vi.fn());
const fetchUsersMock = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/i18n", () => createI18nMock());
vi.mock("../../../lib/adminApi", () => ({
fetchTenant: vi.fn(async () => ({
id: "tenant-team-id",
name: "기술기획팀",
slug: "tech-planning",
})),
fetchUsers: fetchUsersMock,
exportUsersCSV: exportUsersCSVMock,
updateUser: updateUserMock,
}));
function renderTenantUsersPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/tenant-team-id/users"]}>
<Routes>
<Route
path="/tenants/:tenantId/users"
element={<TenantUsersPage />}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantUsersPage export", () => {
beforeEach(() => {
exportUsersCSVMock.mockReset();
updateUserMock.mockReset();
fetchUsersMock.mockReset();
fetchUsersMock.mockResolvedValue({
items: [
{
id: "user-1",
name: "Alice",
email: "alice@example.com",
role: "user",
status: "active",
},
],
total: 1,
});
exportUsersCSVMock.mockResolvedValue({
blob: new Blob(["email,name\nalice@example.com,Alice\n"], {
type: "text/csv",
}),
filename: "users_export_20260609.csv",
});
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
"blob:tenant-users-export",
);
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
});
it("exports only the currently opened tenant users by tenant slug", async () => {
renderTenantUsersPage();
await screen.findByText("Alice");
fireEvent.click(screen.getByTestId("tenant-users-export-menu-item"));
await waitFor(() => {
expect(exportUsersCSVMock).toHaveBeenCalledWith(
"",
"tech-planning",
false,
);
});
});
it("queues searched users and adds all queued users to the tenant at once", async () => {
fetchUsersMock
.mockResolvedValueOnce({ items: [], total: 0 })
.mockResolvedValueOnce({
items: [
{
id: "user-2",
name: "Bob",
email: "bob@example.com",
role: "user",
status: "active",
},
{
id: "user-3",
name: "Carol",
email: "carol@example.com",
role: "user",
status: "active",
},
],
total: 2,
})
.mockResolvedValue({ items: [], total: 0 });
updateUserMock.mockResolvedValue({});
renderTenantUsersPage();
const addButton = await screen.findByTestId(
"tenant-member-add-existing-btn",
);
await waitFor(() => expect(addButton).not.toBeDisabled());
fireEvent.click(addButton);
fireEvent.change(screen.getByTestId("tenant-member-search-input"), {
target: { value: "bo" },
});
fireEvent.click(await screen.findByText("Bob"));
fireEvent.click(await screen.findByText("Carol"));
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Bob",
);
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Carol",
);
fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn"));
await waitFor(() => {
expect(updateUserMock).toHaveBeenCalledWith("user-2", {
tenantSlug: "tech-planning",
isAddTenant: true,
});
expect(updateUserMock).toHaveBeenCalledWith("user-3", {
tenantSlug: "tech-planning",
isAddTenant: true,
});
});
});
});

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