1
0
forked from baron/baron-sso

875 Commits

Author SHA1 Message Date
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
44a853408e Merge pull request 'feature/df-cosent-skip' (#626) from feature/df-cosent-skip into dev
Reviewed-on: baron/baron-sso#626
2026-04-24 15:03:53 +09:00
kyy
081cd6739a backend code-check 오류 수정 2026-04-24 14:59:40 +09:00
kyy
7fd750b587 consent 자동 승인 경로 tenantID 전달 누락 수정 2026-04-24 14:51:13 +09:00
kyy
26180ae5d1 consent 2차 검증 추가 2026-04-24 14:38:52 +09:00
9072bbc42d Merge pull request 'feature/issue-609-multi-tenant-oidc-claims' (#625) from feature/issue-609-multi-tenant-oidc-claims into dev
Reviewed-on: baron/baron-sso#625
2026-04-24 12:59:27 +09:00
f810427b21 chore(auth): restrict OIDC generated claims debug logs to dev environment
- Prevent overly verbose logging of ID token payloads in production by checking APP_ENV
2026-04-24 12:00:00 +09:00
8e28a9d74b fix(infra): resolve CORS error and Nginx 502 Bad Gateway
- Update Hydra and Kratos CORS config to specify allowed origins explicitly instead of using wildcard with allow_credentials: true
- Fix Nginx upstream resolution for Oathkeeper to use correct container hostname (ory_oathkeeper)
2026-04-24 11:59:49 +09:00
cfba44cec2 feat: support dynamic multi-tenant OIDC claims injection (#609)
- Inject  claim based on OIDC Client metadata
- Extract namespaced tenant metadata from traits and flatten it to root
- Expose all joined tenants metadata under  and  arrays
- Fix missing AuditLog generation during auto-accepted Consent
- Associate correct  during auth events AuditLog recording
- Add unit and integration tests for dynamic claims
2026-04-23 17:59:21 +09:00
81f4ddb2b4 Merge pull request 'feature/df-cosent-skip' (#620) from feature/df-cosent-skip into dev
Reviewed-on: baron/baron-sso#620
2026-04-23 16:54:03 +09:00
kyy
2ee1ee4037 dev 병합 code-check 오류 수정 2026-04-23 16:49:11 +09:00
kyy
487ed20286 consent 페이지 반복 노출 현상 수정 2026-04-23 16:00:58 +09:00
991577258b Merge pull request 'fix: 회원가입 페이지 UI 텍스트 포맷팅 노출 오류 수정 (Issue #610)' (#618) from fix-test-fixtures into dev
Reviewed-on: baron/baron-sso#618
2026-04-23 10:43:13 +09:00
97fee9dbae Merge pull request 'feature/df-developer' (#616) from feature/df-developer into dev
Reviewed-on: baron/baron-sso#616
2026-04-22 17:32:17 +09:00
kyy
c40202f502 dev 병합 code check 수정 2026-04-22 17:27:33 +09:00
kyy
9e73059d2a 개발자 등록 신청 입력 안내 및 역할 표기 개선 2026-04-22 15:47:38 +09:00
kyy
5d334069c7 개발자 권한 신청 및 관리 기능 E2E 테스트 추가 2026-04-22 15:47:38 +09:00
kyy
685923a03e 개발자 권한 신청 승인/취소 및 RP 생성 흐름 개선 2026-04-22 15:47:37 +09:00
kyy
2216d9c4e4 개발자 신청 API 단일화 및 RP 권한 자동 부여 구현 2026-04-22 15:46:20 +09:00
kyy
4dc274a5d7 클라이언트 빈 목록 대응 개발자 신청 인라인 링크 및 모달 구현 2026-04-22 15:46:20 +09:00
kyy
4139bb7064 개발자 신청 API 구현 및 RP 생성 시 Keto 권한 자동 부여 로직 추가 2026-04-22 15:46:20 +09:00
kyy
18e9a2aa4a 개발자 권한 신청 도메인 모델 및 서비스 레이어 구현 2026-04-22 15:46:20 +09:00
7ab79a8bc3 fix: 회원가입 페이지 UI 텍스트 포맷팅 노출 오류 수정 (Issue #610)
- Dart에서 인식하지 못하는 TOML 파싱용 정규식([[:space:]]) 수정
- 이스케이프된 개행 문자(\\n)를 실제 개행 문자로 치환하는 로직 추가
2026-04-22 10:58:30 +09:00
b05700f7cc Merge pull request 'fix-test-fixtures' (#606) from fix-test-fixtures into dev
Reviewed-on: baron/baron-sso#606
2026-04-22 09:36:47 +09:00
750776f0a0 style(userfront): format flutter files to satisfy CI 2026-04-21 18:06:57 +09:00
797e6cc90a fix(devfront): add explicit button type and improve test stability 2026-04-21 18:05:32 +09:00
a1d516cd61 test: fix TestPasswordLogin_OIDC_Success to expect sessionJwt in OIDC flow 2026-04-21 17:18:45 +09:00
7f955e2122 style: fix formatting issues caught by biome in adminfront 2026-04-21 17:11:08 +09:00
4427ab1f85 fix: resolve admin session infinite reload loop and sync auth state
- Prevent infinite redirection loop by clearing oidc-client user state on 401 errors.
- Sync apiClient request interceptor to use userManager.getUser() for reliable token retrieval.
- Add extensive console logs for better session issue diagnosis.
- Fix TS error in LoginPage by updating button variant.
- Revert 'ae03fe1' (updated playwright fixtures to real domain) as requested.
2026-04-21 17:06:03 +09:00
ae03fe1475 chore: update playwright fixtures to use real SSO domain
Since the OIDC authority was updated to https://sso.hmac.kr/oidc, the Playwright mocks and localStorage seed values must match exactly for tests to pass in the new configuration.
2026-04-21 15:02:53 +09:00
e7156450ba fix: restore missing POST /users route in admin API
Details:
- The route `admin.Post("/users")` was accidentally merged into a comment line for `admin.Get("/users/export")`. This caused the 405 Method Not Allowed error when trying to create users.
- Restored the route on its own line.
2026-04-21 14:40:09 +09:00
0f79b7635b fix: resolve OIDC session state issue and synchronize portal sessions
Details:
- Backend: Extract Kratos session cookies and propagate via SetCookies in AuthInfo.
- Backend: Include sessionJwt and token during OIDC flows in PasswordLogin.
- UserFront: Add _silentSessionRecovery in main.dart to recover session via cookies if localStorage token is missing.
- UserFront: Update AuthProxyService, AuthTokenStore, AuthNotifier to support silent recovery and immediate local state update before redirect.
- AdminFront/DevFront: Fix OIDC authority to point directly to Gateway proxy and add recovery/error UI components.
2026-04-21 14:10:27 +09:00
1024ad17d3 Merge pull request 'feature/df-rebac' (#595) from feature/df-rebac into dev
Reviewed-on: baron/baron-sso#595
2026-04-20 16:40:27 +09:00
kyy
141c8e0ab5 dev 브런치 반영 code-check 오류 수정 2026-04-20 16:34:04 +09:00
kyy
1f464b60a4 감사로그 조회 에러 수정 2026-04-20 15:48:43 +09:00
kyy
ea387ff6f2 관계 조회 권한 사용자 검색 안내 강화 2026-04-20 15:48:43 +09:00
kyy
7e0680a71c 동의 및 사용자 탭 에러 메세지 수정 2026-04-20 15:48:42 +09:00
kyy
e15de6d334 일반 사용자의 DevFront 접근 및 RP 관리자 권한 연동 2026-04-20 15:48:42 +09:00
kyy
51e46a4d00 RP 관계 범위의 콘솔 접근 허용 2026-04-20 15:48:42 +09:00
kyy
0b8eaec636 수동 할당에서 생성자 관계 숨김 2026-04-20 15:48:42 +09:00
kyy
2a9b044992 RP 수정 권한 안내 표시 2026-04-20 15:48:42 +09:00
kyy
6322ff5630 DevFront RP 관계 설정 문서 작성 2026-04-20 15:48:42 +09:00
kyy
a79c350831 devfront 관계 탭 사용자 검색·다중선택 UX 개선 2026-04-20 15:48:42 +09:00
kyy
f955d23ef1 dev API 관계 사용자 검색 및 관계 목록 사용자 정보 추가 2026-04-20 15:48:42 +09:00
kyy
f494d8e50a relationships 탭 i18n 누락 및 탭 순서 불일치 2026-04-20 15:48:42 +09:00
kyy
034789b8cb devfront ReBAC 전환 테스트 2026-04-20 15:48:42 +09:00
kyy
8d0982b89c devfront RP 상세 탭 i18n 및 순서 일관화 2026-04-20 15:48:42 +09:00
kyy
dd93a3450a Dev API에 RP operator relation 조회/부여/회수 추가 2026-04-20 15:48:42 +09:00
kyy
91299b1a0a RP 생성/삭제 운영 relation 세트 반영 2026-04-20 15:48:42 +09:00
kyy
8f7c328d22 dev/rp 권한 체크 permit 기준으로 변환 2026-04-20 15:48:42 +09:00
kyy
790f006f93 네임스페이스 확장 및 정책 문서 동기화 2026-04-20 15:48:42 +09:00
6b93cc945a Merge pull request 'add/deploy' (#584) from add/deploy into dev
Reviewed-on: baron/baron-sso#584
2026-04-20 10:07:06 +09:00
55be717ff6 fix(adminfront): resolve biome noNonNullAssertion lint in TenantSchemaPage 2026-04-20 10:00:46 +09:00
de2c684096 fix: follow rules of hooks in TenantSchemaPage 2026-04-20 09:56:49 +09:00
b757a137c3 fix: follow rules of hooks in TenantSchemaPage 2026-04-20 09:32:32 +09:00
Lectom C Han
114f203ecd fix(audit): stop default read logging and dedupe dashboard timeline
- skip read audit logging unless a path is explicitly allowlisted
- exclude audit-facing endpoints from backend audit collection
- remove duplicate auth timeline fetch logic from dashboard screen
- add regression tests for default GET skip and dashboard timeline dedup

Co-Authored-By: First Fluke <our.first.fluke@gmail.com>
2026-04-17 18:04:09 +09:00
a9a448e7fb test: update translation text for organization members locator 2026-04-17 18:01:07 +09:00
582591e532 style: apply biome formatting to e2e tests 2026-04-17 17:42:41 +09:00
ad5a49b62f test: update adminfront e2e tests for new organization UI 2026-04-17 17:39:28 +09:00
9f3506c530 fix(adminfront): resolve biome accessibility and typing lints 2026-04-17 16:49:23 +09:00
acab84c358 refactor(adminfront): 조직도 UX 전면 개편 및 중복 렌더링 버그 수정
- 1안(좌우 분할 디렉토리 뷰) 적용으로 트리와 상세 패널 분리
- 조직도 중복 나열 버그 원인인 불필요한 groupNodes 강제 병합 로직 제거
- 멤버 탭과 하위 조직 탭을 통합한 세로 스크롤 뷰 구현
- 다국어 키 {count} 처리 문제 우회용으로 텍스트 직접 렌더링 수정
- 우측 설정 메뉴에 상세 프로필로 이동 링크 추가 및 쓸모없는 상세 페이지 라우팅 제거
- '기존 사용자 배정'만 남기고 사용자 '신규 생성' 제거
- DropdownMenu 컴포넌트 추가 및 관련 UI 업데이트
2026-04-17 16:32:22 +09:00
b72d04f184 Merge pull request 'add/deploy' (#580) from add/deploy into dev
Reviewed-on: baron/baron-sso#580
2026-04-17 13:06:29 +09:00
c15c55744b https://baron-orgchart.hmac.kr/auth/callback 2026-04-17 12:00:00 +09:00
54f7bb1b84 ORGFRONT_CALLBACK_URLS add 2026-04-17 11:37:42 +09:00
4766ef4729 Merge pull request 'VITE_ORGCHART_URL=${{ vars.VITE_ORGCHART_URL }} add' (#578) from add/deploy into dev
Reviewed-on: baron/baron-sso#578
2026-04-17 11:28:53 +09:00
7fbb4095fc VITE_ORGCHART_URL=${{ vars.VITE_ORGCHART_URL }} add 2026-04-17 11:28:08 +09:00
627b9b7a54 Merge pull request 'VITE_ORGCHART_URL' (#577) from add/deploy into dev
Reviewed-on: baron/baron-sso#577
2026-04-17 11:16:23 +09:00
4ef6f96b14 VITE_ORGCHART_URL 2026-04-17 11:13:19 +09:00
b956f6ccdf Merge pull request 'ORGFRONT_CALLBACK_URLS' (#575) from add/deploy into dev
Reviewed-on: baron/baron-sso#575
2026-04-17 10:38:41 +09:00
d70f11c904 ORGFRONT_CALLBACK_URLS 2026-04-17 10:34:03 +09:00
4c13cf6ef5 Merge pull request 'id lowercase' (#574) from add/deploy into dev
Reviewed-on: baron/baron-sso#574
2026-04-17 10:27:27 +09:00
149b52f831 id lowercase 2026-04-17 10:25:45 +09:00
24f6433b2b Merge pull request 'add/deploy' (#573) from add/deploy into dev
Reviewed-on: baron/baron-sso#573
2026-04-17 10:04:02 +09:00
699aa7cfc8 orgFront inrp 등록 2026-04-17 10:03:38 +09:00
35ce7853fa Baron SSO 다중 인스턴스 배포 템플릿 2026-04-16 14:04:37 +09:00
001f29ca5f Merge pull request 'feature/org-chart-tab-separation' (#568) from feature/org-chart-tab-separation into dev
Reviewed-on: baron/baron-sso#568
2026-04-16 09:08:48 +09:00
c114c4187d test(backend): implement missing repository interface methods in mocks
- Implement FindByCompanyCodes and FindByTenantIDs in various test mock repositories to satisfy the UserRepository interface.
2026-04-15 17:58:50 +09:00
ef996c8394 fix(adminfront): fix lint error in AppLayout.tsx
- Define NavItem interface to properly type navigation items
- Remove unsafe 'as any' cast in navigation mapping
2026-04-15 17:48:41 +09:00
a49aa2d31f refactor(adminfront): remove internal org-chart and delegate to orgfront
- Remove TenantOrgChartPage and related internal routes
- Update AppLayout to render external links for org-chart navigation
- Add orgfront service configuration to docker-compose.yaml
2026-04-15 17:44:54 +09:00
726ac71214 fix(user): preserve multi-tenant companyCodes and fix Kratos code parsing
- UpdateUser: Implement 'Preserve & Merge' logic to fetch existing joined
  tenants from Keto and merge them with UI requests, preventing the
  loss of multi-tenant affiliations.
- Keto Sync: Expand the self-healing background job to iterate over all
  companyCodes, ensuring 'members' relations are created for every
  joined tenant (fixes #554).
- AuthHandler: Update extractFirstString to gracefully handle numeric
  JSON types, fixing an issue where Kratos login codes were lost during
  Courier webhook processing.
2026-04-15 16:01:31 +09:00
948dc2236b feat(orgchart): Introduce standalone orgchart RP and shared link public API
This commit includes:
- Added SharedLink data model and Keto-bypassed public API for orgchart view
- Configured 'orgfront' as a new OAuth2 client in hydra
- Applied MH Dashboard premium beige theme to OrgChart
- Implemented user lookup fallback to company code
2026-04-14 18:01:27 +09:00
4fb27d791b Merge pull request 'feature/headless-url' (#556) from feature/headless-url into dev
Reviewed-on: baron/baron-sso#556
2026-04-14 17:20:22 +09:00
kyy
24208893d6 headless link login 애플리케이션 표시 2026-04-14 16:28:01 +09:00
kyy
c5317abada headless login 접속환경 Headless(Server)로 표시 2026-04-14 16:28:01 +09:00
kyy
92f8e9a61a headless password login 접속 이력 반영 2026-04-14 16:28:01 +09:00
772e3ed5e3 Merge pull request 'feature/org-chart-tab-separation' (#547) from feature/org-chart-tab-separation into dev
Reviewed-on: baron/baron-sso#547
2026-04-13 13:26:07 +09:00
a1d508ed69 test(backend): fix tenant_handler_test by adjusting mock call for new ListTenants logic
Adjusts parameter matching on mockSvc.ListTenants to use mock.Anything and mockUserRepo methods, ensuring the test safely covers the newly added allTenants internal sub-query flow without fragile strict args.
2026-04-13 13:23:09 +09:00
010719eee9 feat(backend): allow regular users and tenant admins to list their full tenant trees
Changes the /v1/admin/tenants endpoint to be accessible by all authenticated users (requireAnyUser). In the handler, it dynamically resolves the user's affiliations and filters the response to return the complete hierarchical tree (root, parent, child, sibling nodes) for any tenant they belong to.
2026-04-13 11:56:35 +09:00
0cd43f0aea feat(adminfront): restore top-right tenant filter buttons in new org chart
Re-adds the group tabs ('전체', '한맥엔지니어링', etc.) to the top-right corner to allow users to filter the org chart by root tenant, restoring the lost functionality from the previous multi-tenant upgrade.
2026-04-13 11:30:40 +09:00
9ff49230fc feat(adminfront): map multi-tenant users onto Tenant Full Tree
Reimplements the Org Chart using the Tenant Full Tree API and maps users to all their respective tenants (including joinedTenants). Multi-tenant users are duplicated correctly across nodes they belong to.
2026-04-13 11:20:41 +09:00
b08516f557 Revert "feat(adminfront): rebuild Org Chart strictly based on Tenant Full Tree"
This reverts commit 3bd8724d45.
2026-04-13 11:07:26 +09:00
3bd8724d45 feat(adminfront): rebuild Org Chart strictly based on Tenant Full Tree
Replaces the legacy user-department-based org chart generation with one driven by the actual Tenant hierarchy from the database (via fetchTenants) while still mapping users dynamically to their respective nodes.
2026-04-13 11:01:44 +09:00
ea44785ef0 feat: expand manageableSlugs to include entire tenant tree for users
Allows users and tenant admins to view users across all tenants within their hierarchy (both parent and child organizations) instead of just their direct tenant.
2026-04-13 10:51:29 +09:00
d3a82d1653 feat: allow regular users to view their own tenant's org chart
Changes the /users endpoint to allow RoleUser access and securely restricts the returned data to only users within their affiliated tenants. Removes the unnecessary back button from the Org Chart view since it's now a top-level nav item.
2026-04-13 10:47:56 +09:00
984adcfa62 feat(adminfront): add org chart to sidebar navigation
Adds the Organization Chart tab to the main sidebar for all users. Removes the 'View Org Chart' button from the Tenant List page. Enhances active state logic for nested routes.
2026-04-13 10:38:04 +09:00
6f934da428 Merge pull request 'adminfront test error' (#546) from fix/rebac-env-sync-issue into dev
Reviewed-on: baron/baron-sso#546
2026-04-13 09:59:16 +09:00
fd8bd01f33 fix(ci): enforce playwright deps installation in CI
Remove conditional skipping of 'npx playwright install --with-deps' to ensure OS-level dependencies are installed even when browser binaries are cached.
2026-04-13 09:40:04 +09:00
4fdfbfe53e Merge pull request 'fix/rebac-env-sync-issue' (#543) from fix/rebac-env-sync-issue into dev
Reviewed-on: baron/baron-sso#543
2026-04-10 18:06:43 +09:00
12b8958a72 fix(adminfront): fix react hooks violation in UserDetailPage causing infinite loading
- Fix 'Rendered fewer hooks than expected' crash by moving useMemo hook definition above all early returns
- Resolves e2e test timeout failures in users_schema.spec.ts and users.spec.ts
2026-04-10 17:44:00 +09:00
6c6fab3ea3 fix(adminfront): fix tenant slug display and metadata save issue in user detail page
- Fix 'System Global' displaying by replacing user.tenantSlug with user.companyCode returned by backend
- Prevent primary tenant from being unlinked when saving 'Tenant Profile' tab by ensuring userAffiliatedTenants always includes the primary tenant, preventing react-hook-form from clearing the tenantSlug value.
2026-04-10 17:26:25 +09:00
85d3f4099d Merge pull request 'fix/rebac-env-sync-issue' (#542) from fix/rebac-env-sync-issue into dev
Reviewed-on: baron/baron-sso#542
2026-04-10 16:32:12 +09:00
f33f023b90 style(userfront): format dart files to fix ci pipeline 2026-04-10 16:00:37 +09:00
a96ef3d204 fix(i18n): fix duplicate TOML section for ui.admin.tenants
- Prevent template overwrite caused by duplicate section parsing logic
- Ensures '테넌트 목록' correctly renders instead of '테넌트 구성원 ({{count}})'
2026-04-10 15:58:56 +09:00
addb0fe042 fix(adminfront): fix e2e test assertions and biome linter errors
- Fix strict biome errors in OrgChartUploadModal and TenantOrgChartPage (key indices, any types, aria roles, hook dependencies)
- Fix duplicate translation blocks that broke the i18n parser
- Fix UI label assertions in Playwright e2e tests due to updated i18n keys
2026-04-10 15:12:09 +09:00
4293013d4f chore(i18n): sync adminfront locales with new keys
- Adminfront 화면에 표시될 한국어/영어 문구(테넌트 검색, 총 n개 로그 등) 추가
2026-04-10 14:49:19 +09:00
ec0c6eae26 fix(adminfront): resolve typescript build errors on CI
- Fix missing position and jobTitle properties in BulkUserItem interface
- Fix incorrect reference to undefined total property in AuditLogsPage (reverted to logs.length)
2026-04-10 14:43:56 +09:00
ea90327507 fix(tests): resolve failing go tests and segfaults due to missing mock interface implementations
- MockKratosAdminService 및 MockTenantService에 새로 추가된 인터페이스 메소드(CreateUser, ListIdentitySessions 등) 구현 추가
- 회원가입 테스트(auth_handler_signup_test.go) 시, isAffiliateTenant 검증 과정에서 TenantService가 nil일 때 발생하는 segfault 방지 로직 보강
- Mock 객체 반환값 타입 불일치 및 testify/mock 매개변수 에러 등 테스트 의존성 전반 수정
2026-04-10 14:40:16 +09:00
c312d4cc6d chore(i18n): sync locales with new UI keys
- 누락되었던 테넌트 조직, 사용자 상태, API Key 갯수 등 8개의 번역 키 추가
2026-04-10 14:06:02 +09:00
5a84e9f6cc Merge branch 'dev' into fix/rebac-env-sync-issue 2026-04-10 13:52:07 +09:00
349cdf5fcd feat(org): 조직도 렌더링 및 동기화 로직 대폭 개선, 역할(Role) 강제화, 탭 정리
- 조직도 렌더링 시 너비 동적 계산 및 스크롤 문제 해결
- 하위 조직(Leaf)을 부모 박스 내부에 임베딩하여 2열로 깔끔하게 표시되도록 조직도 UI 전면 개편
- 사용자 생성/수정 및 CSV 업로드 시 직급(Position)과 직무(JobTitle)가 정상적으로 Kratos 및 로컬 DB에 동기화되도록 백엔드 API 수정
- CSV 조직도 업로드 시 계층 구분을 '/' 대신 ' > '로 변경하여 이름에 '/'가 포함된 부서(예: 평면/셀)가 분리되지 않도록 보호
- 잘못 입력된 과거 직책 데이터(팀장, 그룹장 등)를 'user' 권한으로 일괄 초기화하고, 이후 'role' 필드에 시스템 권한(user, tenant_admin, super_admin) 외의 값이 들어오지 않도록 백엔드 정규화 로직 강화
- 사용자 목록 페이지의 페이지네이션 제한을 50명에서 1000명으로 상향 조정
- 테넌트 목록 페이지에 이름/슬러그 기반 검색 기능 추가
- 관리자 UI 전반에서 불필요한 배지(Admin only, System 등) 제거 및 테넌트 상세 페이지의 미사용 '외부 연동' 탭 삭제
2026-04-10 13:48:12 +09:00
5211842d47 조직도 기능 추가 2026-04-10 11:38:47 +09:00
93d44c055b Merge pull request 'feature/headless-url' (#541) from feature/headless-url into dev
Reviewed-on: baron/baron-sso#541
2026-04-10 11:10:28 +09:00
kyy
2ef851086d headless URL 호스트 해석 보정 2026-04-10 10:57:38 +09:00
kyy
969d32eaca runtime host 0.0.0.0 수정 2026-04-10 10:57:38 +09:00
74063494a1 Merge pull request 'feature/uf-appcard' (#539) from feature/uf-appcard into dev
Reviewed-on: baron/baron-sso#539
2026-04-09 17:12:56 +09:00
kyy
46262c80c1 병합 code check 오류 수정 2026-04-09 17:08:54 +09:00
kyy
c6ddf7c485 code check 오류 수정 2026-04-09 16:45:26 +09:00
kyy
06a6875cdb App 카드 로고 이미지 표시 2026-04-09 14:37:49 +09:00
kyy
df09694ed6 앱 로고 URL 검증 및 미리보기 상태 표시 2026-04-09 14:37:49 +09:00
kyy
1e53b66abb 로그인 화면 플랫 UI 수정 2026-04-09 14:37:49 +09:00
kyy
332b657add 다크 모드 전역 상태와 테마 기반 추가 2026-04-09 14:37:49 +09:00
kyy
873d56e35f 테마 영속화 테스트 추가 2026-04-09 14:37:49 +09:00
kyy
dce418d0b9 대시보드 다크 모드/테마 토글 적용 2026-04-09 14:37:49 +09:00
kyy
3d7d4767bf 테마 토글 라벨 번역과 영문 문구 정리 2026-04-09 14:37:49 +09:00
kyy
f4b1c449b1 App 카드 크기 조정 및 상세 보기 수정 2026-04-09 14:37:49 +09:00
kyy
24f477a28e adminfront 로그인 페이지에 auto redirect SSO 진입 추가 2026-04-09 14:37:49 +09:00
kyy
c7b213bf17 devfront 로그인 페이지 auto redirect SSO 진입 추가 2026-04-09 14:37:49 +09:00
kyy
c3605cc86b App 현황 카드 클릭 시 init_url 우선 진입 지원 2026-04-09 14:37:49 +09:00
kyy
f5c4ffa92f linked RP 응답에 1st-party 앱 자동 로그인 init_url 추가 2026-04-09 14:37:49 +09:00
6971b69b79 feat(admin): add org chart bulk import button to main tenants list page (#500) 2026-04-07 16:42:23 +09:00
337337a554 Merge pull request 'feature/uf-session' (#532) from feature/uf-session into dev
Reviewed-on: baron/baron-sso#532
2026-04-07 16:14:12 +09:00
kyy
3b56346c23 로컬 code-check 오류 수정 2026-04-07 16:07:40 +09:00
kyy
9e473ae8a8 userfront 접속이력 타임라인 oathkeeper 세션 ID 보강 2026-04-07 16:07:40 +09:00
kyy
6e312cc5fd 접속이력 토글/스위치 조정 2026-04-07 16:07:40 +09:00
kyy
2fb7bae5f6 접속이력 테이블 배치 작업 2026-04-07 16:07:40 +09:00
kyy
c95105f018 접속이력 브라우저 컬럼 추가 2026-04-07 16:07:40 +09:00
kyy
7b2004e05c 접속이력 및 활성 세션 UI 통합 및 i18n 반영 2026-04-07 16:07:40 +09:00
kyy
e6ade9ce77 작업 정리 문서 저장 경로 변경 2026-04-07 16:07:40 +09:00
kyy
6843b96fe0 userfront 접속이력 UI 세션 상태 필터 반영 2026-04-07 16:07:40 +09:00
kyy
763c04398e 접속이력 OIDC 접속 로그 누락 수정 2026-04-07 16:07:40 +09:00
02255116f4 feat(org): enhance bulk import to support multi-level hierarchy, auto-provision users, and map matrix organizations (#500) 2026-04-07 15:34:25 +09:00
b3a7f47cf7 feat(auth): lock affiliation type on frontend based on verified email domain (#500) 2026-04-07 14:03:02 +09:00
4e7f3e7235 feat(auth): enforce explicit tenant selection and dynamic filtering (#500)
- Refactor `GetActiveTenants` to filter dynamically based on the email domain, removing hardcoded affiliate slugs.
- Update `Signup` to require an explicit `CompanyCode` choice for internal domains, removing automatic provisioning and implicit tenant assignment.
- Add markdown diagram detailing the revised, secure B2B2B dynamic provisioning and inheritance flow.
2026-04-07 11:58:50 +09:00
0c1b512a9a Merge pull request 'userfront i18n placeholder 치환과 번역 렌더링 오류 수정' (#523) from feature/i18n-enhancement into dev
Reviewed-on: baron/baron-sso#523
2026-04-06 17:53:21 +09:00
kyy
d086b7ea3c userfront i18n placeholder 치환과 번역 렌더링 오류 수정 2026-04-06 17:48:27 +09:00
97a60ead91 fix: resolve syntax error in signup_screen caused by redundant closing brace 2026-04-06 17:37:34 +09:00
beae6bb4b1 Merge pull request 'feature/i18n-enhancement' (#521) from feature/i18n-enhancement into dev
Reviewed-on: baron/baron-sso#521
2026-04-06 17:26:32 +09:00
kyy
886e99bfa9 dev 병합 후 code-check 2026-04-06 17:21:41 +09:00
43ec19e94f feat: remove auto-selection of affiliate by email domain and clean up UI 2026-04-06 17:20:02 +09:00
kyy
69d7f053be i18n 경로 오류 및 placeholder 표시 수정 2026-04-06 17:08:22 +09:00
332ac9c0d8 feat: dynamic frontend tenant dropdown 2026-04-06 16:56:33 +09:00
46db7ac026 fix: handle json parse exceptions on 404/500 signup responses gracefully 2026-04-06 16:29:08 +09:00
2e04c5a893 Merge pull request 'feature/session-manager' (#520) from feature/session-manager into dev
Reviewed-on: baron/baron-sso#520
2026-04-06 16:13:49 +09:00
c78604df06 feat: implement dynamic tenant provisioning and remove hardcoded company mappings 2026-04-06 16:13:03 +09:00
kyy
1b8dc2c4ab dev 브런치 병합 후 code check 2026-04-06 16:03:49 +09:00
kyy
e3d279cb83 code check 오류 수정 2026-04-06 15:08:29 +09:00
kyy
890ddd9b3c 세션 종료 시 모든 세션 종료 에러 수정 2026-04-06 15:02:42 +09:00
kyy
4ad7518328 devfront 세션 종료 로그아웃 2026-04-06 15:02:42 +09:00
kyy
2ca26cafb2 세션 IP 표시와 로그아웃 처리 보강 2026-04-06 15:02:42 +09:00
kyy
6a3bb19e7d 세션 만료 관리 토글 동작을 실제 정책에 맞게 분리 2026-04-06 15:02:42 +09:00
kyy
8942c78fb4 활서 세션 카드 audit 메타데이터 기록 보강 2026-04-06 15:02:42 +09:00
kyy
fe70fd216b 세션 카드 디버그용 시나리오 및 테스트 추가 2026-04-06 15:02:42 +09:00
kyy
6b115799c3 활성 세션 카드 규칙 통일화 2026-04-06 15:02:42 +09:00
kyy
1524da2d6a 세션 종료 시 Hydra 토큰 세션도 함께 무효화 2026-04-06 15:02:42 +09:00
kyy
a2f2b2dd71 사용자 활성 세션 조회·종료 API 추가 2026-04-06 15:02:41 +09:00
cdf2c36915 Merge pull request 'fix/rebac-env-sync-issue' (#517) from fix/rebac-env-sync-issue into dev
Reviewed-on: baron/baron-sso#517
2026-04-06 13:23:14 +09:00
003f12f008 test: add mock outbox expectations for super_admin relations in tenant service tests 2026-04-06 13:11:30 +09:00
aaa3dc2fb9 fix: use vite preview in staging to support api proxy in frontends 2026-04-06 11:47:33 +09:00
583755c189 fix: improve keto sync reliability and initial rebac permissions for super admin 2026-04-06 10:10:27 +09:00
bd296f9425 Merge pull request 'feat/fam-tanent' (#511) from feat/fam-tanent into dev
Reviewed-on: baron/baron-sso#511
2026-04-03 15:20:01 +09:00
95aba376b1 chore: fix workspace formatting (devfront, backend, adminfront) 2026-04-03 15:12:27 +09:00
59d53bc1b2 chore(adminfront): fix Biome formatting issues 2026-04-03 15:03:08 +09:00
a65f9afc69 chore(adminfront): fix Biome lint error in user E2E test 2026-04-03 15:00:57 +09:00
94520db699 test(adminfront): fix Playwright E2E tests for user and schema management 2026-04-03 14:48:04 +09:00
bae35dd8a5 chore(ci): disable auto-open of playwright html reporter 2026-04-03 11:48:38 +09:00
02a52fdb38 test(adminfront): fix flaky tests caused by missing dynamic loginId field and greedy URL mocks 2026-04-03 11:43:16 +09:00
692670a081 fix(adminfront): fix type errors in UserDetailPage causing build failures in CI 2026-04-03 10:27:26 +09:00
8dacb9ddba perf(ci): use vite build and preview in playwright to speed up E2E tests 2026-04-03 10:09:42 +09:00
e200f4a59a chore(i18n): fix untranslated keys and placeholders to pass quality checks 2026-04-03 10:04:58 +09:00
f0bf58d336 fix(ci): update Node 24 and playwright workers, fix user detail queries on create 2026-04-03 09:46:26 +09:00
462ae91a9e chore(i18n): fix duplicate sections in TOML files causing parsing errors in tests 2026-04-03 09:43:31 +09:00
993882233b 18n 2026-04-02 18:10:25 +09:00
d08df8767d chore(i18n): filter out scanner false-positives and restore toml syntax 2026-04-02 18:09:28 +09:00
ec42739764 chore(devfront): fix biome formatting and lints 2026-04-02 17:43:45 +09:00
81d70c87f1 chore(i18n): fix toml nesting syntax for missing translation keys 2026-04-02 17:32:05 +09:00
d9019ffdc9 i18 2026-04-02 17:25:11 +09:00
2b49fd92b7 chore(i18n): auto-patch missing translation keys from scanner output 2026-04-02 17:16:24 +09:00
797c6b0b8a chore: fix frontend lints and format issues
- Resolve 'noDelete' by using undefined assignment in TenantSchemaPage
- Resolve React list key warning by using client_id in UserDetailPage
- Run biome formatter across modified components
2026-04-02 16:46:54 +09:00
b582c82c6f feat: implement multi-identifier architecture (Issue #496)
- Database: Add user_login_ids table for 1:N identifier mapping and remove legacy login_id column
- Kratos: Update identity schema to use custom_login_ids array instead of a single id trait
- Backend: Implement syncCustomLoginIDs to collect isLoginId fields across tenant schemas
- Backend: Add backtracking logic to auto-assign session tenant based on used login identifier
- Backend: Add 409 Conflict exception handling for Create/Update operations
- AdminFront: Refactor UserDetailPage to a tabbed grid layout (Info, Tenants, Security)
- AdminFront: Show '로그인 ID' badge on tenant schema fields used for authentication
- UserFront: Remove legacy optional 'Login ID' input from signup flow
- Tests: Add multi-identifier repository tests and update handler tests
2026-04-02 16:07:33 +09:00
Lectom C Han
71a006cd7b fix(headless-login): honor public base url for audience checks
- resolve headless audience against BACKEND_PUBLIC_URL first
- keep forwarded header support for https absolute audiences
- add regression tests for https success and http mismatch rejection
- write BACKEND_PUBLIC_URL into staging workflow env generation
2026-04-01 21:05:41 +09:00
Lectom C Han
3186fab596 chore(git): ignore serena workspace files 2026-04-01 20:37:33 +09:00
Lectom C Han
1a4ce959ad chore(git): stop tracking serena workspace files 2026-04-01 20:34:53 +09:00
Lectom C Han
4b0fbdde98 레포 업데이트 2026-04-01 20:32:09 +09:00
Lectom C Han
8bab8d44cc chore(headless-login): add request correlation logs 2026-04-01 19:42:09 +09:00
Lectom C Han
c3ae316570 fix(headless-login): simplify jwks policy checks 2026-04-01 19:24:26 +09:00
Lectom C Han
51f09bf53c fix(headless-login): show full parsed jwks key values
- return the full RSA n value in parsedKeys responses
- render parsed key fields with labels and multiline key material in DevFront
- lock the behavior with backend and Playwright regression tests
2026-04-01 18:51:39 +09:00
Lectom C Han
e2379658c2 fix(headless-login): show parsed jwks n preview
- reproduce the missing n preview with the actual parsedKeys response shape
- read nPreview from DevFront instead of the old n field
- keep the preview text as provided by backend summaries
2026-04-01 18:41:35 +09:00
Lectom C Han
9facd24a00 feat(headless-login): add jwks cache visibility and refresh flow
- replace inline headless jwks support with jwksUri-only validation
- add cached jwks refresh worker, manual refresh/revoke endpoints, and parsed key summaries
- expose allowed algorithms and key previews in DevFront with regression coverage
2026-04-01 18:33:22 +09:00
f51cdba51a Merge pull request 'feature/df-headless-login' (#499) from feature/df-headless-login into dev
Reviewed-on: baron/baron-sso#499
2026-04-01 15:13:16 +09:00
kyy
d9e8fee64b dev 브런치 병합 code-check 오류 수정 2026-04-01 15:06:46 +09:00
kyy
e5ebd26182 local 브런치 code-check 오류 수정 2026-04-01 14:47:35 +09:00
kyy
391773ac90 adminfront/devfront 세션 만료 관리 슬라이딩 갱신 로직 추가 2026-04-01 14:47:35 +09:00
kyy
32a0efbf1b adminfront/devfront 상단바 프로필 메뉴 UI 통일 2026-04-01 14:47:35 +09:00
kyy
8d505cec0e Headless Login 앱 타입 오표기 수정 2026-04-01 14:47:35 +09:00
1f9512a5a7 Merge pull request 'feat/id_login' (#497) from feat/id_login into dev
Reviewed-on: baron/baron-sso#497
2026-04-01 13:59:24 +09:00
37bc1bba22 chore: add missing i18n keys and fix devfront formatting 2026-04-01 13:58:06 +09:00
8a4dc1a320 i18 2026-04-01 13:55:57 +09:00
ded1e1f5c4 fix(backend): fix merge conflict artifact and undefined explicitLoginID in UserHandler 2026-04-01 13:45:56 +09:00
634f869a84 Merge branch 'dev' into feat/id_login 2026-04-01 13:40:45 +09:00
6c1da03e91 style(userfront): apply dart format 2026-04-01 13:36:34 +09:00
5bf3ef3222 test(e2e): skip coordinate-based WASM tests on mobile 2026-04-01 13:34:23 +09:00
5502e35dc5 chore(adminfront): fix any types and biome lint errors in adminApi.ts 2026-04-01 13:22:11 +09:00
fdffeacf50 fix(backend): fix loginIdField not being synced when companyCode is empty 2026-04-01 13:13:26 +09:00
54a853a5c6 fix(backend): fix syncLoginID to allow fields named 'id' to be synced from custom schema 2026-04-01 13:03:39 +09:00
27a7d226eb fix(backend): map Kratos traits id to loginId in UserSummary API response 2026-04-01 11:29:13 +09:00
a5fdeabd09 fix: resolve tenant user assignment bug (#490)
- Fix frontend payload mapping (tenantSlug -> companyCode) in adminApi.ts.
- Fix backend group member fetching to avoid dummy members in UserGroupService.List.
- Fix backend foreign key violation on group creation by distinguishing between tenant parent and group parent in UserGroupService.Create.
2026-04-01 11:19:09 +09:00
Lectom C Han
94362bf8eb headless login으로 리펙토링 2026-04-01 10:50:31 +09:00
6b30580f36 fix(backend): force keto outbox sync on explicit tenant assignment to self-heal missing relations 2026-03-31 17:51:53 +09:00
bc73b85909 feat(backend): auto-sync user group keto relation based on department in user update 2026-03-31 13:50:23 +09:00
d9b0ec410c Merge pull request 'feature/password-reset' (#492) from feature/password-reset into dev
Reviewed-on: baron/baron-sso#492
2026-03-31 13:22:52 +09:00
5029b8049b fix(backend): prevent duplicate key constraint on empty login id when syncing users 2026-03-31 13:11:32 +09:00
kyy
b406a8dc04 adminfront/devfront 러너 수정 2026-03-31 13:09:56 +09:00
kyy
e927fa8ea0 dev 반영 code-check 오류 수정 2026-03-31 13:03:16 +09:00
kyy
98bb6be549 code check 오류 수정 2026-03-31 11:51:22 +09:00
kyy
68114eea66 비밀번호 재설정 중복 완료 요청 문제 수정 2026-03-31 11:51:21 +09:00
kyy
df145b2957 Trusted RP 명칭을 Headless Login으로 일괄 변경 2026-03-31 11:49:47 +09:00
kyy
2364ff59d2 관리자 비밀번호 변경을 Kratos 해시 업데이트 방식으로 수정 2026-03-31 11:49:47 +09:00
kyy
4d8b9d9f87 프로필 비밀번호 변경 정책 안내 추가 2026-03-31 11:49:47 +09:00
kyy
468ca475ed 본인 계정 비밀번호 초기화 기능 제한 2026-03-31 11:49:47 +09:00
Lectom C Han
33afe1eddf fix(auth): separate pkce and headless trusted rp config 2026-03-31 10:44:04 +09:00
Lectom C Han
4b34ab8161 fix(web): upgrade plugin-react for vite 8 2026-03-30 21:58:28 +09:00
Lectom C Han
b4342b355f feat(auth): add trusted rp headless login flows 2026-03-30 21:46:15 +09:00
Lectom C Han
26890dfabb test(dev): harden client secret regression coverage
- cover get fallback paths for hydra metadata redis and postgres
- cover create rotate and trusted RP update secret persistence
- keep regression coverage isolated from broken handler package tests
2026-03-30 21:38:04 +09:00
Lectom C Han
45dfaf5905 fix(dev): persist trusted rp secret after update
- store client_secret after trusted RP update responses
- add regression test for secret recovery on later detail fetch
2026-03-30 21:13:22 +09:00
Lectom C Han
34dba6689c docs(wiki): migrate auth and test references to gitea wiki
- replace local auth and test-plan references with wiki URLs
- delete duplicated local markdown files now covered by wiki
- keep operational docs pointing to the new wiki pages
2026-03-30 18:44:00 +09:00
Lectom C Han
e4680b0fe8 fix(web): upgrade vite and restore devfront build
- switch adminfront and devfront to vite 8
- fix devfront TypeScript baseline build errors
- require Node.js 24 LTS or newer in package metadata and docs

Co-Authored-By: First Fluke <our.first.fluke@gmail.com>
2026-03-30 18:20:46 +09:00
Lectom C Han
d2a4770967 fix(compose): standardize adminfront port env name
- replace legacy ADMIN_PORT usage with ADMINFRONT_PORT
- add policy test to prevent compose variable drift
2026-03-30 18:02:50 +09:00
Lectom C Han
72551e5f9d fix(auth): add sessionStorage fallback for web auto-login
- add shared token store backend with local/session/memory fallback
- cover fallback behavior with flutter unit tests
- add wasm e2e coverage for sessionStorage login state
- document mobile installed webapp auto-login policy
2026-03-30 18:02:34 +09:00
2f893a6d9e Merge pull request 'feature/df-trusted-rp' (#467) from feature/df-trusted-rp into dev
Reviewed-on: baron/baron-sso#467
2026-03-30 14:09:48 +09:00
kyy
c96a5350a7 code-check 오류 수정 2026-03-30 13:29:36 +09:00
kyy
cfe97ecb1e Trusted RP 생성 흐름 테스트 추가 2026-03-30 13:08:10 +09:00
kyy
3a057ee860 Trusted RP 설정 UX 및 안내 문구 개선 2026-03-30 13:03:04 +09:00
kyy
3ffc345c2c RP 공개키 등록 및 Trusted RP 판정 로직 구현 2026-03-30 09:20:48 +09:00
kyy
cf3d049367 RP 공개키 등록 UI 및 SSH-RSA 자동 변환 기능 구현 2026-03-30 09:20:48 +09:00
2a162f0efe Merge pull request 'temp-branch' (#464) from temp-branch into dev
Reviewed-on: baron/baron-sso#464
2026-03-27 21:40:38 +09:00
dcc5708d17 린트 적용용 2026-03-27 21:37:40 +09:00
809ece6a68 chore: ignore playwright artifacts in biome and fix minor devfront imports 2026-03-27 21:27:03 +09:00
2e14c9d6fe test(backend): update expected error message for invalid company code to match korean translation 2026-03-27 21:18:51 +09:00
543607069e fix(userfront): correctly display general signup errors in a dialog instead of password field 2026-03-27 20:59:00 +09:00
13469b14fb fix: refine error messages for signup failure and company code 2026-03-27 20:39:49 +09:00
603b9e0032 fix(backend): resolve signup issues by fixing tenant slug case-sensitivity and exposing Kratos errors 2026-03-27 20:03:50 +09:00
987b4797bb style(userfront): add missing curly braces for flow control structures 2026-03-27 19:21:33 +09:00
8f78dbf68c test(adminfront): add E2E tests for login ID validation and conflict handling 2026-03-27 19:06:51 +09:00
b3f33cfa30 Merge pull request 'temp-branch' (#461) from temp-branch into dev
Reviewed-on: baron/baron-sso#461
2026-03-27 19:02:42 +09:00
aa8dc05311 chore(i18n): fix sync errors by adding missing keys to ko and en locales 2026-03-27 18:48:19 +09:00
17168bceae Merge branch 'dev' into temp-branch 2026-03-27 18:42:59 +09:00
a75ae1de9a format error 수정 2026-03-27 18:41:49 +09:00
f8d10c90b8 style(userfront): apply dart format 2026-03-27 18:10:32 +09:00
6192220ec1 fix(adminfront): fix Biome lint errors by removing explicit any types 2026-03-27 18:02:53 +09:00
5ae0e19e31 style: apply backend go fmt and frontend biome auto-fixes 2026-03-27 17:57:03 +09:00
2383c6a6be Merge commit '6a50dc280f9c7e71fe09481ee76c4c9c6d5fe710' into temp-branch 2026-03-27 17:41:59 +09:00
ffba1563a7 chore: add missing i18n keys to satisfy i18n-scanner 2026-03-27 17:41:22 +09:00
6a50dc280f chore: add missing i18n keys to satisfy i18n-scanner 2026-03-27 17:38:52 +09:00
641e4aba0d fix(adminfront): fix auth redirection in tests and add custom field validation
- Skip auth redirect to `/login` when `_IS_TEST_MODE` is true to prevent test timeouts.
- Update `UserSchemaField` type and `TenantMetadataFields` to support regex validation patterns.
- Fix translation key for tenant profile metadata section title.
- Enhance OIDC mock data and mock endpoints in `users_schema.spec.ts`.
- Remove unreliable `waitForLoadState("networkidle")` to speed up test execution.
2026-03-27 17:30:30 +09:00
75cc6737bd feat: add robust login ID collision prevention and UI validation (#440)
- Add `ValidateLoginID` to enforce ID collision and security rules (prevents phone number collision, email format usage, and reserved words).
- Add `POST /api/v1/auth/signup/check-login-id` endpoint for real-time ID availability checks.
- Add `checkLoginIDAvailability` API call to userfront's `AuthProxyService`.
- Implement "Check Duplication" button and error/success messaging for the Login ID field in the signup screen.
- Add "000000" magic code bypass for `VerifySignupCode` in non-production environments to streamline testing.
2026-03-27 11:19:28 +09:00
aa60a22d57 feat: restore explicit loginId field and add to userfront signup flow
- Revert the removal of loginId from adminfront and backend.
- Prevent phone normalization logic from mangling custom employee ID login fields.
- Add an explicit 'loginId' optional input field to the userfront signup UI.
- Update AuthProxyService.signup and backend AuthHandler.Signup to transmit and map the 'loginId' parameter properly.
2026-03-26 14:22:43 +09:00
85b2049a61 fix(backend): improve LoginID synchronization from custom metadata fields
- Centralize LoginID sync logic in syncLoginID helper
- Support namespaced metadata in CreateUser, UpdateUser, and BulkCreateUsers
- Ensure UpdateUser and UpdateMe always sync LoginID from configured field even if not in update request
- Add phone number normalization consistency for custom LoginIDs
- Add unit tests for namespaced metadata LoginID sync
2026-03-26 12:46:33 +09:00
0fcacc3f51 Merge pull request 'feature/df-i18n' (#455) from feature/df-i18n into dev
Reviewed-on: baron/baron-sso#455
2026-03-25 17:58:31 +09:00
kyy
31b4e6b5f3 code-check 오류 수정 2026-03-25 17:52:14 +09:00
kyy
ced369cdbc i18n 값 품질 검사 추가 및 devfront locale placeholder 정리 2026-03-25 17:52:14 +09:00
kyy
cab204281b devfront i18n 미적용 및 placeholer 번역값 수정 2026-03-25 17:52:14 +09:00
6337d975ea fix: Admin UI에서 전송한 커스텀 필드(metadata)가 백엔드 Kratos 트레이츠에 빈 배열로 깨져서 저장되는 문제 해결 (#440) 2026-03-25 17:43:30 +09:00
dc4a5921c6 chore: 정상 동작이지만 노이즈를 유발하는 Kratos 세션 체크 실패(401) 로그 메시지 수정 2026-03-25 17:29:10 +09:00
5d81027b34 fix: UpdateMe 핸들러 내 계층형 메타데이터 처리 및 로그인 ID 동기화 로직 보강 2026-03-25 17:14:40 +09:00
b3f0548c10 fix: 불필요한 역할 오버라이딩(Overriding real profile role) 로그 제거 2026-03-25 17:11:47 +09:00
ab9cbfc897 fix: 권한이나 소속이 변경되지 않았을 때 Keto 권한 릴레이션이 불필요하게 삭제 후 재생성되는 버그 수정 2026-03-25 17:01:55 +09:00
aad4ea84a1 fix: 내 정보(UpdateMe) 수정 시 커스텀 필드 로그인 ID 동기화 및 Metadata 필드 추가 (#440) 2026-03-25 16:28:19 +09:00
6a4c37603d fix: Admin UI 커스텀 필드 로그인 ID 반영 문제 및 비밀번호 초기화 동작 개선 (#440)
- 사용자 정보 수정(UpdateUser) 시 메타데이터(커스텀 필드)를 명시적 loginId 값보다 우선하여 동기화하도록 로직 순서 변경
- Admin UI 사용자 상세의 비밀번호 초기화 기능이 즉시 폼에 덮어씌워지는 문제 해결을 위해, 별도의 확인 절차 후 즉각 독립적인 API 호출을 통해 재설정되도록 개선
2026-03-25 16:26:01 +09:00
d83646a7ef fix: 소유자 및 관리자 추가 시 중복 등록 방지 로직 추가 (#440) 2026-03-25 16:05:39 +09:00
c244917737 fix: adminfront utils.ts 내 generateSecurePassword 함수 추가 2026-03-25 15:46:11 +09:00
7c21e500d6 fix: CreateUser 핸들러 내 누락된 LoginID 필드 추가 및 오타 수정 2026-03-25 15:34:29 +09:00
d10f80d41d feat: 커스텀 필드 기반 로그인 ID 연동 기능 추가 (#440)
- Kratos Identity 스키마에 로그인 전용 `id` 속성 추가
- 테넌트 Config의 `loginIdField` 설정에 따라 User의 `login_id` 및 Kratos `traits.id` 동기화 로직 구현
- Admin UI 테넌트 스키마 설정 내 '로그인 ID로 사용' 체크박스 추가
- Admin UI 사용자 생성/수정/조회 화면에 로그인 ID 관리 필드 및 컬럼 반영
- Userfront 로그인 화면 접속 시 테넌트 설정에 따라 동적 로그인 ID 라벨 적용
- 관련 다국어(ko/en) 번역 추가 및 로그인 ID 설계 문서 업데이트
2026-03-25 15:27:44 +09:00
8cadd82a2b Merge pull request 'staging init-rp 클라이언트 ID 이스케이프 버그 수정' (#451) from feature/uf-enhance into dev
Reviewed-on: baron/baron-sso#451
2026-03-25 10:06:27 +09:00
kyy
39bd003c48 staging init-rp 클라이언트 ID 이스케이프 버그 수정 2026-03-25 09:53:54 +09:00
6fbf1a17b8 Merge pull request 'feature/af-ui' (#449) from feature/af-ui into dev
Reviewed-on: baron/baron-sso#449
2026-03-24 17:18:05 +09:00
d97feffebb Merge branch 'dev' into feature/af-ui 2026-03-24 17:15:03 +09:00
ab999c1e16 i18n 검증 완료 2026-03-24 17:12:24 +09:00
e1b2882086 Merge pull request '중복 AdminFront/DevFront 항목 생성 차단 및 목록 숨김' (#448) from feature/uf-enhance into dev
Reviewed-on: baron/baron-sso#448
2026-03-24 16:59:39 +09:00
79b7abd67b Merge branch 'dev' into feature/af-ui 2026-03-24 16:48:56 +09:00
kyy
96be117851 중복 AdminFront/DevFront 항목 생성 차단 및 목록 숨김 2026-03-24 16:46:46 +09:00
1d37bf5420 test code제외 2026-03-24 16:40:28 +09:00
ddef0f7f05 레거시 수정 2026-03-24 16:37:47 +09:00
a669d57e4a test code i18n x 2026-03-24 16:37:01 +09:00
311e491683 누락된 키 추가: 테넌트 수정 화면에 새롭게 추가한 '상위 테넌트' 관련 UI 키 2종을 로컬 설정 파일에 반영했습니다 2026-03-24 16:33:01 +09:00
870c11381c Merge pull request 'feature/uf-enhance' (#447) from feature/uf-enhance into dev
Reviewed-on: baron/baron-sso#447
2026-03-24 16:11:29 +09:00
kyy
5a768d938a code check 오류 수정 2026-03-24 15:54:22 +09:00
kyy
839fabd056 비밀번호 Caps Lock 활성화 안내 문구 표시 2026-03-24 15:54:21 +09:00
kyy
1951336307 oathkeeper-introspect 연동 앱 목록 노출 제외 2026-03-24 15:54:21 +09:00
kyy
650c65c888 일반 사용자 가입 시 External 하드코딩 제거 및 소속 표시 개선 2026-03-24 15:54:21 +09:00
kyy
5bb10ba1e6 접속이력 테이블 Session ID 컬럼 최대폭 제한 추가 2026-03-24 15:54:21 +09:00
kyy
118e004294 /api/v1/user/me 세션 시각을 추가하고 userfront 대시보드 Unknown 세션 시간 문제 수정 2026-03-24 15:54:21 +09:00
a4f283e4e6 #445 #430 #426 #427 2026-03-24 14:22:05 +09:00
39efd68296 Merge pull request '배포 시 세션 유실 방지를 위한 워크플로우 및 RP 초기화 로직 개선' (#438) from feature/uf-enhance into dev
Reviewed-on: baron/baron-sso#438
2026-03-24 10:15:14 +09:00
kyy
d1c5ad8d33 배포 시 세션 유실 방지를 위한 워크플로우 및 RP 초기화 로직 개선 2026-03-24 10:03:34 +09:00
64e7c5241e Merge pull request 'df/af url 설정 변수 설정' (#433) from feature/uf-enhance into dev
Reviewed-on: baron/baron-sso#433
2026-03-23 17:05:58 +09:00
b0e18cc724 refactor(adminfront): rename legacy companyCode to tenantSlug across frontend (#426) 2026-03-23 16:59:05 +09:00
kyy
3c5b0d5d99 df/af url 설정 변수 설정 2026-03-23 16:57:54 +09:00
b2f96b216d fix(adminfront): remove any type casting for react-router v7 flags (#427) 2026-03-23 16:32:50 +09:00
79ab9f3e7f Merge pull request 'df/af 페이지 도메인 주소 기본 적용' (#431) from feature/uf-enhance into dev
Reviewed-on: baron/baron-sso#431
2026-03-23 16:31:21 +09:00
kyy
643740ca21 df/af 페이지 도메인 주소 기본 적용 2026-03-23 16:23:55 +09:00
83553b5601 Merge pull request 'feature/uf-enhance' (#429) from feature/uf-enhance into dev
Reviewed-on: baron/baron-sso#429
2026-03-23 16:02:37 +09:00
kyy
e98ab39dfe code check 오류 수정 2026-03-23 15:36:00 +09:00
d608bdb5a8 refactor(adminfront): replace sonner with custom use-toast matching devfront UX policy 2026-03-23 15:32:32 +09:00
kyy
3c54c46898 adminfront/devfront 앱 실행 기본 URL 설정 2026-03-23 14:50:38 +09:00
kyy
101baa68f6 회원가입 단계별 데스크탑 레이아웃 개선 및 토글 추가 2026-03-23 14:50:38 +09:00
kyy
3269bbb981 회원가입 완료 알림창 문제 수정 2026-03-23 14:50:38 +09:00
kyy
397605d5e5 uf 회원가입 약관동의 테스크탑 레이아웃 개선 2026-03-23 14:50:37 +09:00
kyy
2571233c4a Consent 화면 앱 이름 누락 수정 2026-03-23 14:49:15 +09:00
kyy
5c995a5b4d uf 액션 토스트 공통화 및 SnackBar 제거 2026-03-23 14:49:14 +09:00
kyy
0bb41ae354 프로필 자동저장 제거 및 명시적 저장 UX 개선 2026-03-23 14:49:14 +09:00
Lectom C Han
318948c2fb fix(i18n): update userfront english copy 2026-03-23 13:44:55 +09:00
2de22869c0 Merge pull request 'adminfront ui 개편' (#415) from feature/af-ui into dev
Reviewed-on: baron/baron-sso#415
2026-03-23 09:07:04 +09:00
d0e4f8f86a adminfront ui 개편 2026-03-20 10:58:52 +09:00
918f133867 Merge pull request 'feature/df-authz' (#407) from feature/df-authz into dev
Reviewed-on: baron/baron-sso#407
2026-03-19 17:53:12 +09:00
kyy
89ac5738e5 dev 추가 반영 후 검증 수정 2026-03-19 17:38:54 +09:00
kyy
4aa2c441c6 i18n 누락 키 추가 및 로케일 템플릿 동기화 2026-03-19 17:28:32 +09:00
kyy
691d9e5dd6 userfront 동의 화면 i18n 적용 및 locale 키 추가 2026-03-19 17:28:32 +09:00
kyy
c8291da699 QR 스캔 뒤로가기 fallback 추가로 진입 복귀 보장 2026-03-19 17:28:32 +09:00
kyy
44231b1c2e 역할별 403 권한 안내 문구 일관화 2026-03-19 17:28:32 +09:00
kyy
07f4c1258c 테넌트 목록 조회 API 추가 2026-03-19 17:28:32 +09:00
kyy
77c6610e70 테넌트 조회 API 연동 2026-03-19 17:28:32 +09:00
kyy
2ebe2c5613 테넌트 조회 Mock API 추가 2026-03-19 17:28:32 +09:00
kyy
0f82932b35 테넌트 전환 UI 구현 및 프로필 연동 2026-03-19 17:28:32 +09:00
kyy
759bc1ff68 다중 테넌트 선택 로케일 적용 2026-03-19 17:28:32 +09:00
kyy
e909776c5d 테넌트 스위칭 기능 E2E 테스트 추가 2026-03-19 17:28:32 +09:00
409cb09143 Merge pull request 'feature/af-issue380' (#406) from feature/af-issue380 into dev
Reviewed-on: baron/baron-sso#406
2026-03-19 17:19:45 +09:00
a70e637bca fix: consolidate duplicated sections in i18n toml files to prevent userfront layout break 2026-03-19 17:14:28 +09:00
067f9f9baf test: increase playwright poll timeout for password login failure and reset tests 2026-03-19 14:52:40 +09:00
a6536364ec i18n add 2026-03-19 14:43:51 +09:00
896a51df3d feat: add schema check on bulk user move and support for float/datetime in custom metadata 2026-03-19 14:02:56 +09:00
3e335eb9cf feat: add schema tab access control and user password generator 2026-03-19 13:40:50 +09:00
046acd3ca6 style: fix table header stickiness and improve visibility for all list pages 2026-03-19 13:30:16 +09:00
aff21f195b style: make page headers sticky and improve table header contrast/visibility 2026-03-19 13:23:02 +09:00
926c26b1ad feat: apply sticky header and inner scroll to audit logs page 2026-03-19 13:15:50 +09:00
f072d37362 feat: apply sticky header and inner scroll pattern to all table pages 2026-03-19 13:13:27 +09:00
83991b13ca feat: implement sticky header and inner scrolling for user list page 2026-03-19 12:57:00 +09:00
4d11d3e554 feat: disable remove button for self and last admin/owner in UI 2026-03-19 11:21:03 +09:00
a6c552236f feat: prevent self-removal and last admin/owner removal in tenant handler 2026-03-19 11:12:42 +09:00
4b42249609 Merge pull request 'fix: remove shared VITE_OIDC_CLIENT_ID from staging .env so adminfront uses correct client ID' (#400) from fix/login into dev
Reviewed-on: baron/baron-sso#400
2026-03-19 09:59:49 +09:00
edf914f100 fix: remove shared VITE_OIDC_CLIENT_ID from staging .env so adminfront uses correct client ID 2026-03-19 09:58:44 +09:00
1dc3a01257 Merge pull request 'fix: force recreate init-rp container to ensure OIDC clients are updated' (#399) from fix/login into dev
Reviewed-on: baron/baron-sso#399
2026-03-19 09:47:02 +09:00
f2c2a8511c fix: force recreate init-rp container to ensure OIDC clients are updated 2026-03-19 09:45:30 +09:00
e3e36d6e40 Merge pull request 'fix: add new staging domains and IP to redirect URIs and vite allowedHosts' (#398) from fix/login into dev
Reviewed-on: baron/baron-sso#398
2026-03-19 09:33:15 +09:00
caa1152268 fix: add new staging domains and IP to redirect URIs and vite allowedHosts 2026-03-19 09:31:46 +09:00
d95fec4651 Merge pull request 'fix: use non-distroless image for init-rp to allow shell execution' (#397) from fix/login into dev
Reviewed-on: baron/baron-sso#397
2026-03-19 09:24:50 +09:00
ff37ad918a fix: use alpine image and download hydra binary for init-rp to fix distroless shell issue 2026-03-19 09:22:35 +09:00
57702fc672 fix: use non-distroless image for init-rp to allow shell execution 2026-03-18 17:05:19 +09:00
9cee09c140 Merge pull request 'fix: load env_file in init-rp for staging compose' (#396) from fix/login into dev
Reviewed-on: baron/baron-sso#396
2026-03-18 17:02:09 +09:00
f8384bda9e fix: load env_file in init-rp for staging compose 2026-03-18 17:00:37 +09:00
46f74ec7ad Merge pull request 'fix: add allowedHosts to Vite config for staging domains' (#395) from fix/login into dev
Reviewed-on: baron/baron-sso#395
2026-03-18 16:49:34 +09:00
161c0163d3 fix: add allowedHosts to Vite config for staging domains 2026-03-18 16:48:59 +09:00
09f681b8dd Merge pull request 'fix: add init-rp to staging compose to register OIDC clients' (#394) from fix/login into dev
Reviewed-on: baron/baron-sso#394
2026-03-18 16:47:20 +09:00
f59206e589 fix: add init-rp to staging compose to register OIDC clients 2026-03-18 16:45:19 +09:00
51bbde5dd7 Merge pull request '호출 주소 변경' (#393) from fix/login into dev
Reviewed-on: baron/baron-sso#393
2026-03-18 16:17:50 +09:00
406eef240d 호출 주소 변경 2026-03-18 16:16:46 +09:00
d3442cdc33 Merge pull request '│ 130 + VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }} │' (#391) from fix/login into dev
Reviewed-on: baron/baron-sso#391
2026-03-18 14:49:34 +09:00
6c7581d558 │ 130 + VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }} │
│ 131 + ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}                                                                   │
│ 132 + DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}
추가
2026-03-18 14:49:04 +09:00
e535f1838b Merge pull request '로그인 설정 업데이트' (#390) from fix/login into dev
Reviewed-on: baron/baron-sso#390
2026-03-18 14:24:11 +09:00
99def9a0e5 로그인 설정 업데이트 2026-03-18 13:32:09 +09:00
5ffc0198a3 Merge pull request 'fix/login' (#389) from fix/login into dev
Reviewed-on: baron/baron-sso#389
2026-03-18 13:05:55 +09:00
725ba375d2 Merge branch 'dev' into fix/login 2026-03-18 13:05:10 +09:00
a89b25f54c 이슈 #335 해결 2026-03-18 12:59:40 +09:00
1af3103cde 로그인 invalid_request 오류 해결 2026-03-18 12:31:25 +09:00
3d26eebcdf Merge pull request 'feature/af-issue363' (#387) from feature/af-issue363 into dev
Reviewed-on: baron/baron-sso#387
2026-03-18 10:12:44 +09:00
551cd62808 Biome check devfront (lint + format) 2026-03-18 10:06:39 +09:00
d305398326 Playwright 테스트 오류 수정 2026-03-18 09:05:50 +09:00
ec8abf39aa Merge branch 'dev' into feature/af-issue363 2026-03-18 09:05:23 +09:00
2463b5a67f i18n 검증 2026-03-17 16:45:02 +09:00
42f0caa6fd 폼 선택자(Form Selector) 안정화, 다국어 번역 키 불일치 수정, 컴포넌트 테스트 용이성(Testability) 개선, Strict Mode 위반 해결, OIDC 모킹(Mocking) 강화 2026-03-17 15:48:48 +09:00
27e0f4c9dd 번역 추가 2026-03-17 10:35:16 +09:00
9bce34026c 번역 추가 2026-03-17 10:34:53 +09:00
8bc5b5a49b 린트 4 2026-03-17 10:17:25 +09:00
f239ac984f 린트 적용3 2026-03-17 09:48:00 +09:00
9d888a1162 Merge pull request 'feature/df-authz' (#386) from feature/df-authz into dev
Reviewed-on: baron/baron-sso#386
2026-03-16 17:00:23 +09:00
kyy
fb27fbf3b1 i18n 누락 키 보완 및 biome 포맷 정리 2026-03-16 16:44:35 +09:00
kyy
9a4681b2c0 내 정보 및 역할별 권한 범위 안내 제공 2026-03-16 16:44:35 +09:00
kyy
1ff12075f6 [#365] Devfront 프로필 메뉴 개선 및 상세 페이지 구현 2026-03-16 16:44:35 +09:00
kyy
95ae991af4 E2E 역할 전환 테스트 레이스 컨디션 해결 및 안정성 보강 2026-03-16 16:44:35 +09:00
kyy
eac16cfcd9 4단계 역할 정규화 및 dev 권한 스코프 검증 강화 2026-03-16 16:44:35 +09:00
kyy
0f8b19a9b1 관리자 권한 판별 로직 보완으로 메뉴/접근 제어 일치화 2026-03-16 16:44:35 +09:00
kyy
1b4b5d433f 역할 라벨 및 dev 접근 안내 문구 키 정리 2026-03-16 16:44:35 +09:00
kyy
e2d3e389f3 역할 전환 E2E 및 권한 안내 검증 테스트 추가 2026-03-16 16:44:35 +09:00
45ae1bb1c0 린트 적용 2026-03-05 17:50:34 +09:00
c2b55081a6 린트 적용 2026-03-05 17:20:46 +09:00
3113fc09ff 내정보 멀티 테턴트 표시 2026-03-05 17:18:49 +09:00
c1479a32a7 fix: ensure member counts are accurate by syncing membership relations in all user management actions 2026-03-04 18:05:17 +09:00
03e8ed4822 fix: resolve build errors and fix member count synchronization issues in bulk/org-chart import 2026-03-04 17:42:58 +09:00
16ba7ee47a fix(backend): correct parameter order in UserRepo.List call for CSV export 2026-03-04 17:28:37 +09:00
5be5ffd42f fix(backend): add UTF-8 BOM to CSV export for Excel compatibility 2026-03-04 17:22:34 +09:00
f258b1d457 feat: implement native drag and drop for organization hierarchy and add hover info tooltips 2026-03-04 16:50:41 +09:00
f55374f827 feat: implement bulk user group/tenant move functionality 2026-03-04 16:15:13 +09:00
5034785582 fix(backend): fix CSV export authentication by moving role validation inside the handler 2026-03-04 16:10:52 +09:00
d6a6e13678 fix(backend): add missing os import in UserHandler 2026-03-04 16:06:36 +09:00
5649ba2a76 fix(backend): allow role mocking via query parameter for CSV export downloads 2026-03-04 15:59:00 +09:00
9720b77898 feat: implement user data CSV export with dynamic metadata columns 2026-03-04 15:54:11 +09:00
c126634e16 test: add backend and e2e tests for bulk actions and tree search 2026-03-04 15:49:05 +09:00
a5102d9b25 feat: implement bulk user actions and organization tree search with auto-expansion 2026-03-04 15:43:00 +09:00
d3b4e3ef5e feat: remove automatic default group creation during tenant registration 2026-03-04 15:26:58 +09:00
4392810ec7 fix: revert tab name and improve group list error handling 2026-03-04 15:21:31 +09:00
71473b0f6c feat: display UserGroups in the tenant organization tab 2026-03-04 15:21:22 +09:00
539a87e93a docs: rename tab_organization to '조직 관리' for clarity 2026-03-04 15:15:00 +09:00
e97c5418b9 fix: align UserGroup ReBAC syncing with Tenant namespace design 2026-03-04 15:01:53 +09:00
6506bd192d fix(adminfront): remove duplicate importOrgChart declaration (again) 2026-03-04 14:47:36 +09:00
b6c7a9dc43 fix(adminfront): allow tenant admin to view list when managing multiple tenants and fix dev profile fetching 2026-03-04 14:44:45 +09:00
0a784e5534 fix(backend): fix UserGroup model and NewTenantService signature in bootstrap and tests 2026-03-04 14:31:05 +09:00
9ac99d2e0c feat: implement CSV-based organization chart import and enhance group hierarchy 2026-03-04 14:20:49 +09:00
a973cd746b feat: automatically create default user groups upon tenant registration/approval 2026-03-04 14:16:43 +09:00
39b41a4c42 feat: enforce tenant isolation for audit logs and enhance user list filtering for multi-tenant admins 2026-03-04 14:12:39 +09:00
9da97554ce fix: ensure all manageable tenants are visible for tenant admin and refine overview UI 2026-03-04 14:04:18 +09:00
145b807ebf feat: implement role-based UI filtering for overview and navigation 2026-03-04 14:01:12 +09:00
20a8a19e12 fix(adminfront): ensure tenant admin can always access their tenant even if manageable list is empty 2026-03-04 13:58:08 +09:00
2b4b40c0d9 feat: enhance tenant admin experience with form locking and column visibility settings 2026-03-04 13:55:54 +09:00
88720b48c4 feat: handle multiple manageable tenants for tenant admin 2026-03-04 13:45:54 +09:00
d9ed46f4b9 fix(adminfront): add missing React import in TenantListPage 2026-03-04 13:43:35 +09:00
5b89ed5685 fix(adminfront): add missing React import in AppLayout 2026-03-04 13:42:07 +09:00
d1c3bba3e0 feat: optimize tenant admin view and enhance user list with dynamic columns and metadata search 2026-03-04 13:32:01 +09:00
02acdf835f test: add unit and e2e tests for bulk user creation and schema validation 2026-03-04 13:26:44 +09:00
03a4c553f8 fix(backend): resolve unused variable error and optimize tenant lookup in bulk create 2026-03-04 12:39:42 +09:00
7c28bd4867 feat: implement CSV bulk user upload functionality 2026-03-04 11:26:37 +09:00
db88c7ab1c feat: implement enhanced user schema management with validation and admin_only fields 2026-03-04 10:09:52 +09:00
9946108313 Merge pull request 'test/df-authz' (#364) from test/df-authz into dev
Reviewed-on: baron/baron-sso#364
2026-03-04 09:56:07 +09:00
kyy
dc9332809b Biome 및 Go 포맷 적용 2026-03-04 09:44:12 +09:00
69470e8e4a feat(adminfront): remove unused tenant dashboard and update global overview quick links 2026-03-04 09:41:49 +09:00
kyy
0ad57ab69c devfront e2e 테스트 시나리오 보강 2026-03-04 09:29:37 +09:00
kyy
1eb9c15e90 df 감사 로그 무결성 검증 테스트 추가 2026-03-04 09:29:37 +09:00
cb16a8a9a5 Merge pull request 'feature/af-is309' (#362) from feature/af-is309 into dev
Reviewed-on: baron/baron-sso#362
2026-03-03 17:30:47 +09:00
d7071084b7 Merge commit 'f9e5171eb8f38fde9e3e67deb400c846b57fd5e6' into feature/af-is309 2026-03-03 17:23:22 +09:00
a4f483332c profile mock 추가 2026-03-03 17:19:24 +09:00
4cd0de5174 린트 적용용 2026-03-03 17:04:03 +09:00
1932180cc8 chore(i18n): sync locales and fix formatting issues 2026-03-03 16:45:44 +09:00
badaaa0d1b 테넌트 도메인 동작 확인 2026-03-03 15:49:50 +09:00
5423f920b7 feat(backend): implement dynamic multi-tenancy routing and CORS 2026-03-03 15:27:05 +09:00
f9e5171eb8 Merge pull request 'feature/df-audit-logs' (#361) from feature/df-audit-logs into dev
Reviewed-on: baron/baron-sso#361
2026-03-03 15:24:26 +09:00
kyy
dcda5e5bfe 소속 입력 후 즉시 새로고침 케이스 플래키 완화 및 중복 저장 검증 2026-03-03 15:13:30 +09:00
kyy
f485c07f54 감사 로그 403 권한 안내 문구 및 locale 키 반영 2026-03-03 14:51:54 +09:00
kyy
11a06fa94d AuditRepository 인터페이스 변경에 맞춰 mock 메서드 추가 2026-03-03 14:51:41 +09:00
a6e7f1253c 테넌트 관리자(Tenant Admin)의 본인 소유 테넌트 목록 조회 및 관리 기능 개선 2026-03-03 14:33:58 +09:00
kyy
7cf07a5627 로그인 콜백-가드 흐름 및 API 인증 처리 안정화 2026-03-03 14:11:54 +09:00
kyy
7c1dbaf206 로그인 챌린지 루프 방지 가드 추가 2026-03-03 14:11:19 +09:00
kyy
b2dae93ac1 감사 로그 403 시 권한 안내 문구로 표시 2026-03-03 14:10:44 +09:00
kyy
c9b780659f Dev 권한/테넌트 격리 회귀 테스트 보강 2026-03-03 14:09:43 +09:00
kyy
20c97843c3 클라이언트 대시보드 통계 실지표 연동 및 백엔드 API 구현 2026-03-03 14:06:27 +09:00
1c3985ce19 feat(adminfront): add user profile dropdown and enhance session sync 2026-03-03 12:56:57 +09:00
86ef9c6f60 테넌트 소유자, 관리자 분리 2026-03-03 12:38:27 +09:00
kyy
8db37c377a 감사로그 화면 연동과 상태변경 로그 분리 및 CSV/UI 개선 2026-02-27 17:51:14 +09:00
kyy
914b1b0d49 DevFront 감사로그 조회 API 추가와 액션 필터링 및 테스트 보강 2026-02-27 17:50:53 +09:00
kyy
45d1d3b910 DevFront 감사로그 번역 키 추가 및 로케일 동기화 2026-02-27 17:50:25 +09:00
7bb1f3f702 Merge pull request 'feature/adminfront' (#347) from feature/adminfront into dev
Reviewed-on: baron/baron-sso#347
2026-02-27 15:17:07 +09:00
890a4b77dd Merge commit 'd50e583c30dae4fe46c478e153aa28c144dfc6d3' into feature/adminfront 2026-02-27 15:12:23 +09:00
e1ebb78331 [vite] http proxy error: /api/v1/admin/tenants?limit=1000&offset=0 해결 2026-02-27 15:04:13 +09:00
d50e583c30 Merge pull request 'feature/df-i18n-ui' (#348) from feature/df-i18n-ui into dev
Reviewed-on: baron/baron-sso#348
2026-02-27 15:02:24 +09:00
kyy
dcb43a1134 playwrite 빈 포트 할당으로 포트 충돌 방지 2026-02-27 14:50:58 +09:00
kyy
00e36b429a i18n 및 flutter 린트/포멧 정리 2026-02-27 14:50:58 +09:00
kyy
5859118936 비로그인 상태 OIDC 연동 시 발생하는 인증 루프 수정 2026-02-27 14:50:58 +09:00
kyy
6482e8d3e0 애플리케이션 브레드크럼 경로 개선 2026-02-27 14:50:58 +09:00
kyy
c584c28f1a 상태 뱃지 스타일(UI) 통일 2026-02-27 14:50:58 +09:00
kyy
f6647230f7 User Consent 필터(기능) 개선 2026-02-27 14:50:58 +09:00
kyy
cba839ad1e App Layout 텍스트(UI) 일치화 2026-02-27 14:50:58 +09:00
kyy
1bb1120015 다국어(i18n) 번역 리소스 업데이트 2026-02-27 14:50:58 +09:00
f6769fa1db 테스트 오류 수정 2026-02-27 14:40:07 +09:00
6d9899acbb af 오류 수정정 2026-02-27 14:05:45 +09:00
497ffff216 userfront-e2e-tests 오류 수정 2026-02-27 13:53:26 +09:00
77af59b7a8 Merge commit 'ac778f836fb78550dce8088a567dc8bf5ffb8d2e' into feature/adminfront 2026-02-27 12:41:01 +09:00
cd3124f91d 린트 적용 2026-02-27 12:33:23 +09:00
89f07f3bcd i18n 검증 2026-02-27 11:33:09 +09:00
f97b989455 테넌트 crud 테스트 코드 추가 2026-02-27 11:28:39 +09:00
f02ba3cbbd 계층 표시 테스트 코드 추가 2026-02-27 10:52:11 +09:00
ca45a14bae 테넌트 목록 및 조직 계층 구조 개선 2026-02-27 10:29:15 +09:00
ac778f836f Merge pull request 'feature/df-consent' (#343) from feature/df-consent into dev
Reviewed-on: baron/baron-sso#343
2026-02-26 14:07:23 +09:00
kyy
9a409689ee i18n 누락 키 추가 및 flutter 린트 적용 2026-02-26 14:05:39 +09:00
kyy
39062e1773 Consent 목록 CSV 내보내기 구현 2026-02-26 14:05:39 +09:00
kyy
5a7231eba6 필터 및 상태 관련 다국어 키 추가 2026-02-26 14:05:39 +09:00
kyy
d60bc1d5d5 연동 앱 및 Consent 목록 상세 필터 기능 추가 2026-02-26 14:05:39 +09:00
kyy
c8f39c15e0 conent 이력 조회 soft delete 및 상태 필드 추가 2026-02-26 14:05:39 +09:00
kyy
099a8c768c consent 철회 확인 모달 및 상태 필터 UI 추가 2026-02-26 14:05:39 +09:00
2b20a85bc5 Merge pull request 'feature/ui-refactor' (#339) from feature/ui-refactor into dev
Reviewed-on: baron/baron-sso#339
2026-02-25 17:12:59 +09:00
kyy
24f9fb6904 flutter 린트 적용 2026-02-25 17:07:08 +09:00
kyy
6d80b04a55 locales sync 및 포맷 반영 2026-02-25 16:48:11 +09:00
kyy
3cfece2a33 devfront UI 일관성 및 정렬 정책 적용 2026-02-25 16:37:54 +09:00
kyy
85538ae672 회원가입 줄바꿈 오류 수정 2026-02-25 16:37:54 +09:00
600961f33d slug 명칭 한글 수정 2026-02-25 14:17:45 +09:00
93a92cdcb4 Merge pull request 'SSO 로그인 방식을 팝업 기반으로 변경' (#334) from feature/devfront-refactor into dev
Reviewed-on: baron/baron-sso#334
2026-02-25 13:25:23 +09:00
kyy
3423178250 SSO 로그인 방식을 팝업 기반으로 변경 2026-02-25 13:12:45 +09:00
19f5096470 Merge pull request 'feature/devfront-refactor' (#330) from feature/devfront-refactor into dev
Reviewed-on: baron/baron-sso#330
2026-02-24 17:44:05 +09:00
kyy
242c088730 make code-check 적용 2026-02-24 17:28:39 +09:00
kyy
75cf8355de i18n 키 정리 및 캐시/산출물 정리 2026-02-24 17:24:30 +09:00
kyy
45dc68427a 연동 앱 목록 및 편집 페이지 변경 2026-02-24 16:50:07 +09:00
kyy
558d88593a 기능 변화에 따른 로케일 수정 2026-02-24 16:50:07 +09:00
kyy
38e0b6d80e 용어 개선에 따른 로케일 업데이트 2026-02-24 16:50:07 +09:00
kyy
75cde56761 연동 앱(RP) 용어 개선 및 i18n 적용 2026-02-24 16:50:07 +09:00
kyy
c9a364a8ba 만료 시간 수정 및 상단 네이게이션 바 추가 2026-02-24 16:50:07 +09:00
kyy
077ff0e6a4 세션 만료 로케일 적용 2026-02-24 16:50:06 +09:00
Lectom C Han
7fee9c597a 로컬/CI 테스트 동기화 2026-02-24 16:42:55 +09:00
Lectom C Han
8a074f16e7 merge feat/304-userfront-wasm-e2e into dev 2026-02-24 15:40:51 +09:00
Lectom C Han
aeb418fb9f feat: add env-aware client log policy and const lint fixes 2026-02-24 15:38:55 +09:00
bc07619452 Merge pull request 'feat/org-chart-rebac' (#327) from feat/org-chart-rebac into dev
Reviewed-on: baron/baron-sso#327
2026-02-24 15:33:49 +09:00
fb78bea6f2 uf 린트 적용 2026-02-24 15:24:25 +09:00
Lectom C Han
4ffe5110dd e2e 구조변경 2026-02-24 15:23:36 +09:00
beb288951c 플러터 의존성 추가 2026-02-24 15:21:43 +09:00
255a47987a af 테스트 삭제 2026-02-24 15:14:27 +09:00
aeae296c48 af lint 2026-02-24 13:13:03 +09:00
67457a23a1 uf lint 적용 2026-02-24 13:08:23 +09:00
d63b943039 미사용 모듈 제거 2026-02-24 13:06:29 +09:00
b2f0ea48e5 빌드 오류 해결 2026-02-24 13:03:29 +09:00
c54dcccf49 Merge branch 'dev' into feat/org-chart-rebac 2026-02-24 12:42:02 +09:00
3fdcaa5832 Merge pull request 'RP 카드 뱃지 가독성 문제' (#325) from feature/devfront-refactor into dev
Reviewed-on: baron/baron-sso#325
2026-02-24 11:27:06 +09:00
kyy
7582ba4208 RP 카드 뱃지 가독성 문제 2026-02-24 11:22:45 +09:00
8282c222de uf 린트 적용 2026-02-24 11:09:28 +09:00
70bb29827e df 린트 적용 2026-02-24 11:03:52 +09:00
6a7676f95c af 린트 적용 2026-02-24 11:02:30 +09:00
Lectom C Han
cb6edb850a iOS QR js기반 바이패스, QR승인 클릭절차 생략 2026-02-24 10:50:17 +09:00
Lectom C Han
5da74dac3a 링크로 로그인 수정 2026-02-24 10:32:06 +09:00
Lectom C Han
fb7e46054e 바론 아이콘 적용 2026-02-23 22:33:27 +09:00
Lectom C Han
c61e32eec5 lint 실패 해결 2026-02-23 22:20:09 +09:00
Lectom C Han
bb3231effe lint 실패 해결 2026-02-23 22:11:27 +09:00
Lectom C Han
2bdfc2eb51 userfront 로그인 후 /dashboard로 이동하게 변경 2026-02-23 22:06:00 +09:00
c4d3a9f7a1 최신 내용 반영 2026-02-23 17:52:36 +09:00
d525895ae7 af 린트 적용 2026-02-23 17:45:24 +09:00
19d3bade30 Merge pull request 'feature/devfront-client-type-policy' (#301) from feature/devfront-client-type-policy into dev
Reviewed-on: baron/baron-sso#301
2026-02-23 17:45:08 +09:00
kyy
a0dc14b1b7 devfront biome 포맷 정리 2026-02-23 17:41:26 +09:00
4011a65683 i18n add 2026-02-23 17:33:06 +09:00
kyy
6a735ad501 i18n 및 gofmt 테스트 적용 2026-02-23 17:32:56 +09:00
2da570f42c adminfront 린트 적용 2026-02-23 17:23:11 +09:00
4dc4e19c27 test code 수정 2026-02-23 16:50:26 +09:00
kyy
88728f01e6 애플리케이션 설정 실패 시 메세지 제공 2026-02-23 16:48:17 +09:00
kyy
12b1bc4aca PRIVATE 클라이언트 권한 판정 로직 수정 2026-02-23 16:47:41 +09:00
kyy
0c4a48a7d3 활성 세션 Active 앱 수 반영 2026-02-23 16:47:05 +09:00
68becb43bc 린트 적용 2026-02-23 16:18:01 +09:00
04938d7cd9 Slug 오류 수정 2026-02-23 15:53:29 +09:00
73b0453ad4 유저 그룹 계층형 보기 2026-02-23 15:53:18 +09:00
kyy
dfb5c2bce5 클라이언트 생성일 표시 기능 추가 2026-02-23 15:19:52 +09:00
1c6fb4ef83 feat(adminfront): Implement hierarchical tree view for user group management (Issue #296)\n\n- Refactor TenantGroupsPage.tsx to display user groups in a collapsible tree structure.\n- Add buildGroupTree utility and UserGroupTreeNode recursive component.\n- Update group creation form with parent group selection and unit type.\n- Enhance mutation callbacks with toast notifications for better user feedback.\n- Fix: Add parentId to TenantSummary type in adminApi.ts for type safety. 2026-02-23 13:58:34 +09:00
kyy
2c42986a8b Private/PKCE 앱 유형 및 관리자 권한 정책 적용 2026-02-23 13:14:33 +09:00
kyy
2624931a7f 앱 유형 명칭 Private/PKCE 반영 및 UI 로직 업데이트 2026-02-23 13:14:02 +09:00
kyy
5dd5dd1086 앱 유형 명칭 Private/PKCE 로케일 파일 업데이트 2026-02-23 13:12:54 +09:00
7d57e5844f 린트 적용 2026-02-23 12:54:21 +09:00
c6470d3bae fix(i18n): sync missing translation keys 2026-02-23 12:46:47 +09:00
0ccd1db649 test: 프론트엔드/백엔드 테스트 커버리지 및 시나리오 보강 (Issue #291)
- FE: Vitest 환경 구축 및 공통 UI 컴포넌트(Badge, Button) 테스트 추가
- FE: Playwright E2E 테스트(Auth, Tenant CRUD 및 Validation) 시나리오 보강
- BE: Testcontainers 기반 Repository 통합 테스트(PostgreSQL) 추가
- BE: TenantRepository 계층 구조(Hierarchy), DB 제약조건(Unique) 테스트
- BE: UserRepository 통합 테스트(CRUD, Delete) 추가
- BE: PasswordPolicy 유틸리티 테스트 보강
- BE: TenantService 엣지 케이스(중복 슬러그, 권한 등) 검증 로직 추가
- Fix: 하위 테넌트 생성 시 ParentID 누락 문제 해결
2026-02-23 11:23:48 +09:00
ed2684c45f Merge pull request 'feature/devfront-logout' (#290) from feature/devfront-logout into dev
Reviewed-on: baron/baron-sso#290
2026-02-23 09:56:08 +09:00
kyy
a5b5b3bf8c Playwright 테스트 내 '연동 앱 목록' 문구 수정 반영 2026-02-23 09:40:53 +09:00
kyy
c53f7b86db devfront 공백 라인 정리 2026-02-23 09:07:26 +09:00
kyy
2e34899757 devfront biome 포맷 정리 2026-02-23 09:02:58 +09:00
919bcd27e8 프론트엔드 UI/UX를 전면 개편 2026-02-20 17:56:53 +09:00
2ec2653bfb Ory Keto ReBAC Policy & Relation Tuple Architecture 2026-02-20 17:56:05 +09:00
kyy
d756602d48 msg.dev.logout_confirm 수정 2026-02-20 17:53:45 +09:00
kyy
8d8c64ec27 로그아웃 기능 추가 2026-02-20 17:49:57 +09:00
kyy
c9a42ee232 로그아웃 로케일 설정 2026-02-20 17:49:57 +09:00
kyy
870e88360e 중복 scope name 삭제 및 DELETE 추가 2026-02-20 17:49:57 +09:00
kyy
9dbd539855 Devfront의 scope table delete 추가 2026-02-20 17:49:57 +09:00
kyy
3b66e4ae16 devfront 용어 클라이언트 -> 앱 으로 변경 2026-02-20 17:49:57 +09:00
Lectom C Han
0509a7dda5 build: collect playwright artifacts in reports and refresh flutter lock 2026-02-20 10:39:41 +09:00
Lectom C Han
9f50a6e14a chore: ignore playwright report artifacts 2026-02-20 10:36:42 +09:00
Lectom C Han
62a2047374 build: add local code-check target and stabilize adminfront e2e 2026-02-20 10:35:13 +09:00
Lectom C Han
c117e10f48 ci: enforce flutter/biome format checks and fix front lint violations 2026-02-20 10:25:45 +09:00
Lectom C Han
226a236bf2 CI lint에 format도 추가 2026-02-20 10:20:38 +09:00
Lectom C Han
9e3e08dd24 CI test playwright 분기 2026-02-20 10:16:55 +09:00
Lectom C Han
a14c67f189 ci: simplify adminfront runner flow and stabilize e2e auth state 2026-02-20 10:14:53 +09:00
Lectom C Han
cee5bf13af Merge remote-tracking branch 'origin/dev' into dev 2026-02-20 10:09:54 +09:00
Lectom C Han
3e05c4e5f6 docs: add standalone tenant policy and update keto rebac policy 2026-02-20 10:00:55 +09:00
f844f2586a Merge pull request 'featur/tenantsign' (#284) from featur/tenantsign into dev
Reviewed-on: baron/baron-sso#284
2026-02-20 09:46:44 +09:00
Lectom C Han
8ed3bd8c77 CI test 업데이트 2026-02-20 09:43:19 +09:00
Lectom C Han
5d8697e361 docs: enrich tenant policy with core architecture principles for integrity and OIDC pooling 2026-02-20 09:41:18 +09:00
f8b8b262cb Merge commit '9a4808f0e5d9810bd4d3361e98dd6fe4ec18b96e' into featur/tenantsign 2026-02-20 09:36:10 +09:00
Lectom C Han
9a4808f0e5 adminfront, devfront codecheck CI 추가 2026-02-20 08:51:53 +09:00
Lectom C Han
bc5a18bc1e docs: update integrated tenant and ReBAC policy 2026-02-20 08:35:52 +09:00
Lectom C Han
4c9f71147c locale_store 리팩토링 2026-02-20 08:16:56 +09:00
Lectom C Han
2811ecf268 테스트 인벤토리 문서 전수 목록화 및 이슈 상태 정리 2026-02-19 20:21:55 +09:00
e40dd8120e adminfront 로그인 해결 2026-02-19 18:02:47 +09:00
ba7cca3c60 린트 1 2026-02-19 17:14:41 +09:00
8664c5f17a Merge commit '4696d7ce8228ebf46600a2c3bae27c3a8de01dda' into featur/tenantsign 2026-02-19 17:09:22 +09:00
352a4fab0b Merge commit '85998bd82ecc8870af3c260f4a6f251ea1d12231' into featur/tenantsign 2026-02-19 17:08:27 +09:00
19c67a0d91 테스트 코드 추가2 2026-02-19 17:00:39 +09:00
4696d7ce82 Merge pull request 'feature/login-i18n' (#279) from feature/login-i18n into dev
Reviewed-on: baron/baron-sso#279
2026-02-19 16:53:44 +09:00
kyy
1522c58904 userfront tests 수정 2026-02-19 16:49:59 +09:00
05c33249d9 테스트 코드 추가 2026-02-19 16:43:54 +09:00
kyy
410c770738 userfront 린트 적용 2026-02-19 16:31:43 +09:00
kyy
3eb7ed01ee flutter 린트 적용 2026-02-19 16:31:13 +09:00
kyy
466e7f1e54 Flutter Web WASM 빌드 오류 수정 및 라이브러리 마이그레이션 2026-02-19 16:31:13 +09:00
kyy
86f3e7a21c SPA 라우팅 보완 2026-02-19 16:31:13 +09:00
kyy
b43ace8b2d 리다이렉트 책임 단일화 및 인증 흐름 추적 로그 추가 2026-02-19 16:31:13 +09:00
kyy
43a4909ddf WASM 빌드 호환성을 위한 Web API Interop 교체 2026-02-19 16:31:13 +09:00
kyy
95136cd5df 라우터 구조 개선 및 리다이렉트 시 파라미터 유실 수정 2026-02-19 16:31:13 +09:00
kyy
b675159510 sso-test reuturn url 추가 2026-02-19 16:31:13 +09:00
kyy
478fb009a5 OIDC 클라이언트 설정 오류 수정 및 자동 등록 프로세스 개선 2026-02-19 16:31:13 +09:00
kyy
1b5b2b9d1a Kratos 관리자 계정의 로컬 DB 자동 생성 및 동기화 로직 추가 2026-02-19 16:31:13 +09:00
kyy
fdbc55f35c Kratos 연결 재시도 로직 추가 및 관리자 ID 반환 구현 2026-02-19 16:31:13 +09:00
kyy
ebd95166ae 관리자 계정 생성 및 로컬 DB 동기화 실행 순서 조정 2026-02-19 16:31:13 +09:00
kyy
2948cc151b 중복 설정 변수 제거 2026-02-19 16:31:13 +09:00
Lectom C Han
85998bd82e 테스트 보강 및 dev 충돌/CI 정책 정리 2026-02-19 16:26:15 +09:00
Lectom C Han
3025be52d5 ory stack 설정 검사 추가. make 명령으로 실행 필요. 2026-02-19 16:16:45 +09:00
65c45c7571 Merge pull request 'featur/tenantsign' (#278) from featur/tenantsign into dev
Reviewed-on: baron/baron-sso#278
2026-02-19 15:57:48 +09:00
c852af3168 린트 적용 2 2026-02-19 15:50:35 +09:00
b7b29bb504 린트 적용 2026-02-19 15:21:47 +09:00
f1959f123b Merge commit 'dcdc69ebfe60a8f539317ab9d77f6667dd1c5e48' into dev 2026-02-19 15:11:03 +09:00
5cb713a009 가입 전략 수립 2026-02-19 15:10:36 +09:00
e6bfcf465f VITE_OIDC_CLIENT_ID 변경 2026-02-19 15:10:09 +09:00
Lectom C Han
dcdc69ebfe feature/i18n 코드 통합 & 인프라 검증로직 추가 2026-02-19 14:28:48 +09:00
Lectom C Han
33660cfdcf OIDC로 빠지는 분기 점검 login_challenge 복구 fallback 추가 2026-02-19 13:54:40 +09:00
Lectom C Han
f617467082 디버깅 로그 추가 2026-02-19 13:25:45 +09:00
Lectom C Han
6fd0e5c800 리다이렉트 후속 로직 업데이트 2026-02-19 12:40:56 +09:00
Lectom C Han
1a5b04d688 계정 생성, 비밀번호변경, 삭제 테스트케이스 추가 2026-02-19 11:29:12 +09:00
Lectom C Han
7808d81bb4 #269 진행. 리다이렉트 등 파라미터 전체 전달 2026-02-19 10:05:07 +09:00
37e8fc4991 Merge pull request 'feature/user-group2' (#268) from feature/user-group2 into dev
Reviewed-on: baron/baron-sso#268
2026-02-13 16:06:51 +09:00
11ae96df78 fix(ci): rename test file and update workflow path to fix build error 2026-02-13 16:04:22 +09:00
b15cad759c locale_storage_web_test.dart를 locale_storage_platform_test.dart로 변경 2026-02-13 15:53:12 +09:00
b7e0010d36 @TestOn('browser') 어노테이션 제거 2026-02-13 15:48:43 +09:00
f7cdf367ae userfront test 수정 2026-02-13 15:43:32 +09:00
41eaac62c9 린트 적용 2026-02-13 15:35:52 +09:00
837883756f test 코드 수정 2026-02-13 15:34:31 +09:00
f0b1a88005 린트적용2 2026-02-13 15:20:17 +09:00
e322cbffb8 lint 적용 2026-02-13 15:18:38 +09:00
ad5895a1ea I18 추가 2026-02-13 15:15:21 +09:00
779b627b64 fix: align report.js logic with index.js to resolve i18n check failure 2026-02-13 14:59:42 +09:00
51eb76acf7 Merge pull request 'feature/user-group2' (#266) from feature/user-group2 into dev
Reviewed-on: baron/baron-sso#266
2026-02-13 14:56:59 +09:00
92f084fd59 test(backend): add unit tests for user group management and fix interface inconsistencies 2026-02-13 14:55:45 +09:00
7f8d52ac3f fix: resolve i18n resource check failures and restore logout functionality 2026-02-13 14:33:26 +09:00
75107c5c41 Merge pull request 'feature/user-group2' (#265) from feature/user-group2 into dev
Reviewed-on: baron/baron-sso#265
2026-02-13 14:24:52 +09:00
ce703005e1 Merge branch 'feature/user-group' into dev 2026-02-13 14:22:32 +09:00
594fd24adb feat: 구현: 유저 그룹 중심 권한 통합 및 미들웨어 정책 고도화 2026-02-13 14:16:13 +09:00
Lectom C Han
14af8f3fa9 fix(userfront): restore web auth redirect variables after merge regression 2026-02-13 12:35:08 +09:00
Lectom C Han
485bbafe71 Merge branch 'dev' into feature/i18n 2026-02-13 12:06:37 +09:00
Lectom C Han
4178152e29 fix(userfront): serve mjs/wasm with correct mime types 2026-02-13 12:06:23 +09:00
Lectom C Han
1bc2cfb507 feat(backend): backfill error code on legacy error responses 2026-02-13 11:20:15 +09:00
Lectom C Han
db71364e80 feat(i18n): apply ORY bypass whitelist policy and add error-code tests 2026-02-13 10:47:33 +09:00
Lectom C Han
c1645b2d4b i18n 광역 적용 2026-02-13 10:23:50 +09:00
Lectom C Han
dfa2fc2406 feat: i18n 개선 및 userfront 로그인/로케일 보완 2026-02-13 10:23:50 +09:00
Lectom C Han
b6d3b69cda i18n refresh and frontend fixes 2026-02-13 10:23:50 +09:00
Lectom C Han
2441c64598 flutter 앱 description 수정, backend 포트포워딩 제거 2026-02-13 10:23:50 +09:00
Lectom C Han
a8a219d7ef refactor: backend tenant_group 제거 및 리팩터 반영 2026-02-12 22:14:34 +09:00
Lectom C Han
b0792113ae refactor: backend tenant_group 제거 및 관련 정리 2026-02-12 22:12:57 +09:00
Lectom C Han
d590acfff9 fix: staging userfront build context for nginx.conf 2026-02-12 22:08:49 +09:00
Lectom C Han
47193e56ff Merge feature/i18n into dev (userfront only) 2026-02-12 21:53:42 +09:00
Lectom C Han
7c936da7ed feat: i18n 개선 및 userfront 로그인/로케일 보완 2026-02-12 21:25:26 +09:00
6a31082dbc Merge pull request 'feature/df-content' (#253) from feature/df-content into dev
Reviewed-on: baron/baron-sso#253
2026-02-12 14:32:07 +09:00
kyy
c8506e70d5 RP 카드 상태 라벨 글자색 변경 2026-02-12 14:26:38 +09:00
kyy
be320c98bd 관리자 계정 생성 및 안내 문구 추가 2026-02-12 14:18:09 +09:00
kyy
11ce54172f 브런치 병합 devfront 에러 수정 2026-02-12 13:23:54 +09:00
kyy
cc1b74ffb6 백엔드 호출 제거 및 문서화 2026-02-12 13:23:54 +09:00
kyy
6a011a7c1d devfront 로그인 페이지 UI 구현 2026-02-12 13:23:54 +09:00
kyy
f0362df47d API 변경에 따른 클라이언트 관리 페이지 수정 2026-02-12 13:23:54 +09:00
kyy
43b694cb53 신규 의존성 추가 2026-02-12 13:23:54 +09:00
kyy
c23e0c925a 인증 상태에 따른 레이아웃 및 UI 개선 2026-02-12 13:23:54 +09:00
kyy
7a25cf40aa API 클라이언트 및 개발 서버 설정 변경 2026-02-12 13:23:54 +09:00
kyy
66c9b859dc 크로스 도메인 OIDC 인증을 위한 환경 구성 수정 2026-02-12 13:23:54 +09:00
kyy
8415069c0a Devfront를 위한 OIDC 프록시 엔드포인트 구현 2026-02-12 13:23:54 +09:00
kyy
2f1caa7b03 OIDC 인증 라우트 및 로그인/콜백 페이지 구현 2026-02-12 13:23:54 +09:00
Lectom C Han
66106f20f6 [userfront] source 개념삭제. redirect 동작 정리 2026-02-12 11:41:16 +09:00
b9ad54d459 usergroup 2026-02-12 11:41:01 +09:00
Lectom C Han
5bdb08d673 스테이징 코드 패치 강제 2026-02-12 11:07:11 +09:00
Lectom C Han
fbe1851e65 kratos 실행 옵션 변경 2026-02-12 11:00:29 +09:00
f1adc73769 Merge pull request 'feature/tenant-group' (#249) from feature/tenant-group into dev
Reviewed-on: baron/baron-sso#249
2026-02-12 10:49:53 +09:00
d12b431894 Merge branch 'feature/tenant-group-239' into dev 2026-02-12 10:46:05 +09:00
74884f6616 린트 적용 2026-02-12 10:39:47 +09:00
Lectom C Han
7131d667ab 스테이징 진단 로그 추가 2026-02-12 10:09:32 +09:00
Lectom C Han
d2c7d490f6 스테이징 누락 서비스 추가 2026-02-12 10:05:39 +09:00
Lectom C Han
84833612ee MIME 타입 추가, wasm mjs 2026-02-12 08:52:58 +09:00
Lectom C Han
812903bf51 log 볼륨 마운트 방식 변경 2026-02-12 08:48:28 +09:00
21b9594de5 fix: userfront 빌드 설정(--wasm 제거) 및 리다이렉션 로직 최종 반영 #243 2026-02-11 17:51:11 +09:00
6f397895d7 fix: 루트 locales와 프론트엔드 locales 동기화 및 i18n 체크 대응 #239 2026-02-11 17:50:44 +09:00
b59e70031d fix: i18n 리소스 체크 실패 대응 (template.toml 누락 키 추가 및 미사용 키 정리) #239 2026-02-11 17:40:59 +09:00
6ae35e1bd7 test: ReBAC 권한 상속 및 미들웨어 검증 테스트 코드 추가 #244 2026-02-11 17:31:34 +09:00
dc09c2dc6b fix: userfront 빌드 시 발생하는 Null Safety 에러 수정 #243 2026-02-11 16:46:44 +09:00
787d7aa0f0 fix: SSO 로그인 리다이렉션 로직의 신뢰성 강화 및 환경변수 설정 보완 #243 2026-02-11 16:34:52 +09:00
ab2a5462d4 refactor: 보안 정책 준수를 위해 Keto 포트 외부 노출 제거 및 내부 통신으로 변경 #239 2026-02-11 16:25:02 +09:00
bb7f3a7b25 fix: 팝업 로그인 시에도 redirect_uri를 항상 포함하여 콜백 처리 보장 #243 2026-02-11 15:57:58 +09:00
516a7b3635 fix: SSO 로그인 성공 시 adminfront로의 리다이렉션 및 postMessage 흐름 강화 #243 2026-02-11 15:50:13 +09:00
50209a1506 fix: SSO 팝업 로그인 시 postMessage 흐름 보장 및 콜백 페이지 팝업 대응 #243 2026-02-11 15:41:27 +09:00
6c7e80eb3e fix: Nginx에서 .mjs 파일을 올바른 MIME type(application/javascript)으로 서빙하도록 수정 #239 2026-02-11 15:33:21 +09:00
2c6322db60 refactor: 별도의 /ssologin 경로 대신 기존 /signin 경로를 사용하도록 수정 #243 2026-02-11 15:26:37 +09:00
9d7c28b1c1 feat: SSO 로그인 리다이렉션 흐름 구현 (userfront & adminfront 연동) #243 2026-02-11 15:20:17 +09:00
3163514227 fix: 환경변수 USERFRONT_URL 직접 참조 및 SSO 경로(/ssologin) 수정 #243 2026-02-11 14:57:32 +09:00
2864a946f2 feat: 어드민 로그인 방식을 SSO 팝업 연동 방식으로 변경 #243 2026-02-11 14:32:37 +09:00
eee482197c fix: 세션 토큰 필드명 불일치 및 URL 파싱 오류(auth_handler) 수정 #239 2026-02-11 14:23:20 +09:00
474c8971a7 refactor: 어드민 프론트엔드에서 애플리케이션(RP) 목록 기능 제거 #239 2026-02-11 13:54:16 +09:00
8c27c74b38 fix: 환경변수 내 큰따옴표(")로 인한 URL 파싱 에러 수정 (utils.GetEnv 도입) #239 2026-02-11 13:51:57 +09:00
68df43f3a8 feat: 테넌트/RP 관리자 할당 UI 및 ReBAC 권한 검증 도구 구현 #244 2026-02-11 13:26:26 +09:00
8856485265 feat: 어드민 프론트엔드 로그인 및 로그아웃 기능 구현 #243 2026-02-11 12:46:09 +09:00
afaac1781c feat: 테넌트 그룹 기반 권한 상속 고도화 및 개발자 포털 보안 강화 #239 2026-02-11 12:41:03 +09:00
dc0d1a8e63 fix: AppLayout.tsx 머지 충돌 해결 및 i18n 키 추가 #239 2026-02-11 11:17:16 +09:00
5ffc447265 fix: devfront에서도 Vite import-analysis 에러 수정을 위해 locale 파일 경로 수정 #239 2026-02-11 11:12:10 +09:00
f83af9c8cc fix: Vite import-analysis 에러 수정을 위해 locale 파일을 src 내부로 복사 및 경로 수정 #239 2026-02-11 11:04:50 +09:00
10aa6f837f fix: Keto 연결 설정 오류 수정 및 ReBAC 권한 상속 정책 추가 #239 2026-02-11 10:58:10 +09:00
4ef7ab78e2 feat: 테넌트 그룹 관리 UI 및 상세 멤버십 관리 기능 구현 #239 2026-02-11 10:47:44 +09:00
1548e60361 feat: 테넌트 그룹(Tenant Group) 기능 구현 #239 2026-02-11 10:31:48 +09:00
Lectom C Han
655a32fd97 기존 컨테이너 검사하기 2026-02-11 10:03:01 +09:00
Lectom C Han
c36bcfd01e 비밀번호 불일치 해결 2026-02-11 09:52:33 +09:00
Lectom C Han
13f002b25b .env 참조 경로 변경 2026-02-11 09:42:58 +09:00
Lectom C Han
71b93cae3b .env 참조 경로 변경 2026-02-11 09:39:37 +09:00
Lectom C Han
4202544a51 docker-compose 배포용 1개파일로 통합 2026-02-11 09:36:32 +09:00
Lectom C Han
20d877add1 git 경로 조정 2026-02-11 09:01:49 +09:00
Lectom C Han
731d4dfd93 배포시 불필요한 변수 제거 2026-02-11 08:55:50 +09:00
Lectom C Han
e0cf51893a git pull 방식 코드 배포 적용 2026-02-11 08:51:46 +09:00
Lectom C Han
490567356f 미적용 키 업데이트 2026-02-10 19:23:06 +09:00
Lectom C Han
05cdc7d8ae i18n refresh and frontend fixes 2026-02-10 19:16:13 +09:00
Lectom C Han
390a349de6 i18n 대대적 변경 2026-02-10 19:16:13 +09:00
Lectom C Han
8df95c8a13 i18n 대대적 변경 2026-02-10 19:13:00 +09:00
Lectom C Han
2fd4631331 flutter 앱 description 수정, backend 포트포워딩 제거 2026-02-10 17:35:16 +09:00
kyy
798d37bed9 Merge pull request 'Http 환경 CopyButton 오류 수정' (#236) from feature/devfront-content into dev
Reviewed-on: ai-team/baron-sso#236
2026-02-10 12:43:10 +09:00
kyy
28477aafc6 Http 환경 CopyButton 오류 수정 2026-02-10 12:40:19 +09:00
eec583b0a1 Merge pull request 'dev-stagingcd-fix' (#235) from dev-stagingcd-fix into dev
Reviewed-on: ai-team/baron-sso#235
2026-02-10 12:31:48 +09:00
90fcd99b2f Merge branch 'feature/stagingcd' into dev 2026-02-10 11:29:41 +09:00
kyy
3a019f8e23 Merge pull request '초기 coverage 값 수정' (#227) from feature/hydra-content into dev
Reviewed-on: ai-team/baron-sso#227
2026-02-10 10:53:55 +09:00
kyy
7003c0ada7 초기 coverage 값 수정 2026-02-10 10:53:21 +09:00
kyy
93699bb1b3 Merge pull request 'feature/hydra-content' (#226) from feature/hydra-content into dev
Reviewed-on: ai-team/baron-sso#226
2026-02-10 10:34:00 +09:00
3a05ba94e0 1 del 2026-02-10 10:32:07 +09:00
b3371f48bb CLICKHOUSE_HOST add 2026-02-10 10:30:44 +09:00
a1ece93a5c REDIS_ADDR 문법 오류 수정 2026-02-10 10:17:46 +09:00
kyy
07aae642a7 golangci lint 적용 2026-02-10 10:15:44 +09:00
f0cd60bf56 문법 오류 수정 2026-02-10 10:03:39 +09:00
kyy
6569faee76 App 현황 표시 UI개선 2026-02-10 09:58:29 +09:00
0d3aac0a58 $기호 정제 2026-02-10 09:56:48 +09:00
93f6182cce $빠진거 추가 2026-02-10 09:56:28 +09:00
27248cfa53 $ 제거 2026-02-10 09:34:41 +09:00
kyy
0741baf60b df 클라이언트 상세 조회 테스트 추가 2026-02-10 09:07:47 +09:00
kyy
68459151c3 백엔드 Hydra 테스트 가이드 문서 업데이트 2026-02-10 09:07:47 +09:00
kyy
dd9e6394ad Hydra 연동 관련 핸들러 테스트 케이스 추가 2026-02-10 09:07:47 +09:00
kyy
15a27a6620 핸들러 및 서비스 테스트 리팩토링 2026-02-10 09:07:47 +09:00
kyy
f16cb9a344 Actions 백엔드 테스트 커버리지 워크플로우 추가 2026-02-10 09:07:47 +09:00
Lectom C Han
9cdd89c1c1 환경변수 descope관련 값 제거, 비밀번호 변경기능 규격 변경 2026-02-09 18:01:59 +09:00
abba85af75 "" 제거 2026-02-09 17:40:51 +09:00
c5dc531f1b 안쓰는 변수 비활 2026-02-09 17:26:31 +09:00
440785dfe8 환경변수 추가 2026-02-09 16:58:36 +09:00
2ca612da7f oathkeeper url r고정 2026-02-09 14:32:52 +09:00
0e88bc3986 중복 "github.com/coreos/go-oidc/v3/oidc"
제거
2026-02-09 14:32:21 +09:00
c5f9445d67 nginx 5000 fix 2026-02-09 14:27:28 +09:00
c7c4eb1b82 Oathkeeper 명시적 URL 2026-02-09 14:27:05 +09:00
880287088f oathkeeper 이름 통일 2026-02-09 13:56:48 +09:00
561ae56cbb depends_on 조정 2026-02-09 13:52:05 +09:00
5d66e983cd 환경변수 추가 2026-02-09 13:08:18 +09:00
kyy
8e45422606 Merge pull request 'feature/hydra-content' (#221) from feature/hydra-content into dev
Reviewed-on: ai-team/baron-sso#221
2026-02-09 12:32:30 +09:00
eb34d387ad -e add 2026-02-09 11:26:15 +09:00
kyy
9b7b2da497 golangci 린트 적용 2026-02-09 11:23:13 +09:00
7f0a44e4ee sql command 수정 2026-02-09 11:21:15 +09:00
kyy
05b6ff6d9e 앱 현황 섹션 통합 및 Linked API 기반 UI 개편 2026-02-09 11:19:10 +09:00
kyy
bb78c8e572 Linked API에 Audit Log 스캔을 통한 과거 이력 보강 로직 추가 2026-02-09 11:18:32 +09:00
3d3e6a0c9c 디버그용 문장 추가 2026-02-09 11:16:30 +09:00
b49a39f9b2 healthcheck 시간 추가 2026-02-09 11:04:48 +09:00
658113d779 설정 파일 오류 수정 2026-02-09 10:59:05 +09:00
kyy
4b7264217d Apply stashed changes after rebase 2026-02-09 09:56:10 +09:00
754 changed files with 167395 additions and 11434 deletions

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
.git
.gitea
.codex
.env
.env.*
**/.dart_tool
**/.packages
**/build
**/node_modules
**/dist
**/.next
**/.cache
**/coverage
**/tmp
**/logs
**/*.log
**/*.swp
**/.DS_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 ---
@@ -28,6 +27,8 @@ DB_NAME=baron_sso
# Must be 32 bytes. Generate with `openssl rand -hex 32`
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
JWT_SECRET=super-secret-key-must-be-32-bytes!
# Optional backend slog override: debug, info, warn, error
BACKEND_LOG_LEVEL=
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
@@ -38,11 +39,6 @@ AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
# Redis Cache Configuration
PROFILE_CACHE_TTL=30m # User Profile Redis 캐시 만료 시간
# Descope Project ID (Required for Auth)
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
DESCOPE_MANAGEMENT_KEY=your_descope_management_key_here
DESCOPE_TEST_ACCOUNT=tester@baroncs.co.kr
# --- Naver Cloud Services ---
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
NAVER_CLOUD_SECRET_KEY=ncp_iam_...
@@ -64,7 +60,8 @@ ADMIN_PASSWORD=adminPasswordIsNotSimple
USERFRONT_URL=https://sso.hmac.kr
# Services proxied via Nginx
BACKEND_URL=${USERFRONT_URL}/api
BACKEND_PUBLIC_URL=${USERFRONT_URL}
BACKEND_URL=${USERFRONT_URL}
OATHKEEPER_PUBLIC_URL=${USERFRONT_URL}
# ory-stack 변수들
@@ -79,22 +76,24 @@ 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
KETO_WRITE_URL=http://keto:4467
# Kratos Selfservice UI upstreams (override for deployments)
ORY_SDK_URL=http://kratos:4433
@@ -110,12 +109,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
@@ -127,3 +135,15 @@ OATHKEEPER_HEALTH_ENABLED=true
COOKIE_SECRET=localcookie123
CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
CSRF_COOKIE_SECRET=localcsrf123
# AdminFront OIDC 설정
ADMINFRONT_URL=http://localhost:5173
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
# DevFront OIDC 설정
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,https://sso.hmac.kr/orgfront/auth/callback
VITE_ORGCHART_URL=

7
.gitea/coverage.json Normal file
View File

@@ -0,0 +1,7 @@
{
"Path": "./backend/coverage.out",
"Thresholds": {
"baron-sso-backend/internal/handler": 10,
"baron-sso-backend/internal/service": 10
}
}

View File

@@ -0,0 +1,29 @@
name: Backend Test Coverage Check
on:
push:
branches:
- dev
jobs:
coverage:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Run tests with coverage
working-directory: ./backend
run: |
go test -v -coverprofile=coverage.out -covermode=atomic ./internal/handler/... ./internal/service/...
- name: Check coverage
uses: vladopajic/go-test-coverage@v2
with:
config: ./.gitea/coverage.json

View File

@@ -106,10 +106,15 @@ jobs:
provenance: false
sbom: false
- name: Temporarily update userfront nginx port
run: |
sed -i 's/listen 5000;/listen 80;/g' userfront/nginx.conf
sed -i 's/proxy_pass http:\/\/baron_backend:3000;/proxy_pass http:\/\/baron_backend:3010;/g' userfront/nginx.conf
- name: Build and push orgfront RC image
uses: docker/build-push-action@v5
with:
context: ./orgfront
file: ./orgfront/Dockerfile
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
provenance: false
sbom: false
- name: Build and push userfront RC image
uses: docker/build-push-action@v5

File diff suppressed because it is too large Load Diff

View File

@@ -89,7 +89,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
@@ -109,8 +109,6 @@ jobs:
"COOKIE_SECRET=${{ secrets.PROD_COOKIE_SECRET }}" \
"JWT_SECRET=${{ secrets.PROD_JWT_SECRET }}" \
"REDIS_ADDR=${{ vars.PROD_REDIS_ADDR }}" \
"DESCOPE_PROJECT_ID=${{ vars.DESCOPE_PROJECT_ID }}" \
"DESCOPE_MANAGEMENT_KEY=${{ secrets.DESCOPE_MANAGEMENT_KEY }}" \
"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 }}" \
@@ -124,6 +122,7 @@ jobs:
> .env
# 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"

View File

@@ -0,0 +1,222 @@
name: Release Baron SSO to Staging
on:
workflow_dispatch:
inputs:
target_branch:
description: "Branch to deploy"
required: true
default: "dev"
jobs:
deploy-staging:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
- name: Deploy to Staging by git pull
env:
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
STAGE_HOST: ${{ vars.STAGE_HOST }}
STAGE_USER: ${{ vars.STAGE_USER }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
set -euo pipefail
echo "DEBUG: STAGE_USER='${STAGE_USER}'"
echo "DEBUG: STAGE_HOST='${STAGE_HOST}'"
echo "DEBUG: DEPLOY_PATH='${DEPLOY_PATH}'"
echo "DEBUG: TARGET_BRANCH='${TARGET_BRANCH}'"
# Sanity check
if [ -z "${STAGE_USER}" ] || [ -z "${STAGE_HOST}" ] || [ -z "${DEPLOY_PATH}" ] || [ -z "${TARGET_BRANCH}" ]; then
echo "::error::Missing required vars (STAGE_USER/STAGE_HOST/DEPLOY_PATH/TARGET_BRANCH)."
exit 1
fi
ssh-keyscan -H "${STAGE_HOST}" >> ~/.ssh/known_hosts
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}'"
# .env 파일 생성
cat <<'EOF' > .env
APP_ENV=stage
BACKEND_LOG_LEVEL=debug
CLIENT_LOG_DEBUG=true
TZ=Asia/Seoul
IDP_PROVIDER=ory
# DB & Clickhouse
DB_PORT=${{ vars.DB_PORT }}
CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }}
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
CLICKHOUSE_PASSWORD=${{ 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 }}
DB_USER=${{ vars.DB_USER }}
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
DB_NAME=${{ vars.DB_NAME }}
COOKIE_SECRET=${{ secrets.STG_COOKIE_SECRET }}
JWT_SECRET=${{ secrets.STG_JWT_SECRET }}
REDIS_ADDR=${{ vars.REDIS_ADDR }}
CORS_ALLOWED_ORIGINS=${{ vars.CORS_ALLOWED_ORIGINS }}
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }}
AWS_REGION=${{ vars.AWS_REGION }}
AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
USERFRONT_URL=${{ vars.USERFRONT_URL }}
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
BACKEND_URL=${{ vars.BACKEND_URL }}
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
ORY_POSTGRES_USER=${{ vars.ORY_POSTGRES_USER }}
ORY_POSTGRES_PASSWORD=${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
ORY_POSTGRES_DB=${{ vars.ORY_POSTGRES_DB }}
KRATOS_DB=${{ vars.KRATOS_DB }}
HYDRA_DB=${{ vars.HYDRA_DB }}
KETO_DB=${{ vars.KETO_DB }}
KRATOS_VERSION=${{ vars.KRATOS_VERSION }}
KRATOS_UI_NODE_VERSION=${{ vars.KRATOS_UI_NODE_VERSION }}
HYDRA_VERSION=${{ vars.HYDRA_VERSION }}
KETO_VERSION=${{ vars.KETO_VERSION }}
ORY_SDK_URL=${{ vars.ORY_SDK_URL }}
KRATOS_PUBLIC_URL=${{ vars.KRATOS_PUBLIC_URL }}
KRATOS_ADMIN_URL=${{ vars.KRATOS_ADMIN_URL }}
KRATOS_BROWSER_URL=${{ vars.KRATOS_BROWSER_URL }}
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
JWKS_URL=${{ vars.JWKS_URL }}
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
OATHKEEPER_GID=${{ vars.OATHKEEPER_GID }}
OATHKEEPER_HEALTH_URL=${{ vars.OATHKEEPER_HEALTH_URL }}
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }}
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
# 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 }}
EOF
# 코드 업데이트 (Git)
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \
if [ ! -d .git ]; then
git init
git remote add origin ssh://git@172.16.10.175:222/baron/baron-sso.git
else
git remote set-url origin ssh://git@172.16.10.175:222/baron/baron-sso.git
fi
git fetch --depth 1 origin '${TARGET_BRANCH}' && \
git reset --hard FETCH_HEAD && \
git clean -fd && \
git checkout -B '${TARGET_BRANCH}' FETCH_HEAD"
# .env 파일 복사
scp .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
# 배포 실행
ssh "${STAGE_USER}@${STAGE_HOST}" "DEPLOY_PATH='${DEPLOY_PATH}' bash -s" <<'EOSSH'
set -euo pipefail
cd "${DEPLOY_PATH}"
set -a; . ./.env; set +a;
# 네트워크 생성
for net in baron_net public_net ory-net hydranet kratosnet; do
docker network inspect "${net}" >/dev/null 2>&1 || docker network create "${net}"
done
# 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
docker compose -f staging_pull_compose.yaml pull
# 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등)
docker compose -f staging_pull_compose.yaml build --pull
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}" node -e "fetch('http://127.0.0.1:${port}/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/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_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
echo "========================"
kratos_migrate_cid="$(docker compose -f staging_pull_compose.yaml ps -q kratos-migrate || true)"
if [ -n "${kratos_migrate_cid}" ]; then
if [ "$(docker inspect -f '{{.State.ExitCode}}' "${kratos_migrate_cid}")" -ne 0 ]; then
echo 'Kratos Migrate Failed. Logs:'
docker logs "${kratos_migrate_cid}"
exit 1
fi
else
echo "WARN: kratos-migrate container not found; skipping exit-code check."
fi
EOSSH

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 }}
@@ -45,26 +46,38 @@ jobs:
# Sanity check
if [ -z "${STAGE_USER}" ] || [ -z "${STAGE_HOST}" ] || [ -z "${DEPLOY_PATH}" ]; then
echo "::error::Missing required vars (STAGE_USER/STAGE_HOST/DEPLOY_PATH). Check Gitea repo variables."
echo "::error::Missing required vars (STAGE_USER/STAGE_HOST/DEPLOY_PATH)."
exit 1
fi
ssh-keyscan -H "${STAGE_HOST}" >> ~/.ssh/known_hosts
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}'"
# Create .env for Staging using a HEREDOC to prevent shell expansion issues
# .env 파일 생성
cat <<'EOF' > .env
APP_ENV=stage
BACKEND_LOG_LEVEL=debug
CLIENT_LOG_DEBUG=true
TZ=Asia/Seoul
IDP_PROVIDER=ory
# DB & Clickhouse
DB_PORT=${{ vars.DB_PORT }}
CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }}
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
CLICKHOUSE_PASSWORD=${{ vars.CLICKHOUSE_PASSWORD }}
BACKEND_PORT=${{ vars.BACKEND_PORT }}
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
DB_USER=${{ vars.DB_USER }}
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
DB_NAME=${{ vars.DB_NAME }}
@@ -75,9 +88,6 @@ jobs:
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
DESCOPE_PROJECT_ID=${{ vars.DESCOPE_PROJECT_ID }}
DESCOPE_MANAGEMENT_KEY=${{ secrets.DESCOPE_MANAGEMENT_KEY }}
DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }}
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
@@ -89,6 +99,8 @@ 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 }}
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
@@ -119,41 +131,73 @@ jobs:
OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }}
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
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
# Copy artifacts to remote
# Using compose.infra.yaml as base for staging (assuming simplified structure compared to prod)
# OR use docker-compose.template.yaml if staging follows prod structure strictly
# 파일 복사
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker"
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/adminfront"
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
if [ -d "docker/init-metadata" ]; then
scp -r docker/init-metadata "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
fi
if [ -d "gateway" ]; then
scp -r gateway "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
fi
scp adminfront/seed-tenant.csv "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/adminfront/"
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"
# Ory compose files might be needed too
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
# Deploy
# 배포 실행
echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \
"export DEPLOY_PATH='${DEPLOY_PATH}'; \
export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
export ORGFRONT_IMAGE_NAME='${ORGFRONT_IMAGE_NAME}'; \
export IMAGE_TAG='${IMAGE_TAG}'; \
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \
set -e; \
cd \"\${DEPLOY_PATH}\"; \
docker login \"\${HARBOR_ENDPOINT}\" -u \"\${HARBOR_ROBOT_ACCOUNT}\" --password-stdin; \
set -a; \
. ./.env; \
set +a; \
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
# Assuming template usage similar to prod
set -a; . ./.env; set +a; \
# 네트워크 생성
for net in baron_net public_net ory-net hydranet kratosnet; do
docker network inspect \"\$net\" >/dev/null 2>&1 || docker network create \"\$net\"
done
envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \
# Pull & Up
# Assuming staging runs both infra, ory, and app stack
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
chmod -R 777 docker/ory
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml pull; \
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml up -d"
# [주의] DB 초기화 스크립트는 '새로운 볼륨'에서만 실행됨.
# DB 초기화 문제를 확실히 해결하기 위해 기존 볼륨을 날리고 다시 띄움 (데이터 삭제됨 주의)
# 스테이징이므로 초기화 진행. 데이터 보존이 필요하면 이 줄 제거하고 수동으로 DB 만들어야 함.
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml down -v || true
docker compose -f compose.infra.yml -f compose.ory.yml -f docker-compose.yml up -d --remove-orphans; \
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
sleep 10; \
if [ \$(docker inspect -f '{{.State.ExitCode}}' baron-sso-staging-kratos-migrate-1) -ne 0 ]; then \
echo 'Kratos Migrate Failed. Logs:'; \
docker logs baron-sso-staging-kratos-migrate-1; \
exit 1; \
fi"

20
.gitignore vendored
View File

@@ -6,14 +6,22 @@
.vscode/
.codex
.codex/
.serena/
.generated/
config/.generated/
*.swp
*.log
*.out
*.exe
.npm-cache/
reports
reports/*
config/*.pem
# Docker Services Data (Volumes)
postgres_data/
clickhouse_data/
docker/ory/oathkeeper/logs/
# Backend (Go)
backend/main
@@ -30,3 +38,15 @@ userfront/.dart_tool/
userfront/.packages
userfront/.pub/
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/
orgfront/node_modules/
orgfront/dist/
orgfront/.vite/

View File

@@ -0,0 +1,23 @@
- generic [ref=e4]:
- generic [ref=e5]:
- img [ref=e7]
- generic [ref=e9]:
- heading "Baron SSO" [level=1] [ref=e10]
- paragraph [ref=e11]: Developer Control Plane
- generic [ref=e12]:
- generic [ref=e13]:
- heading "개발자 포털 로그인" [level=3] [ref=e14]:
- img [ref=e15]
- text: 개발자 포털 로그인
- paragraph [ref=e18]: Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
- generic [ref=e19]:
- button "SSO 계정으로 로그인" [ref=e20] [cursor=pointer]:
- img [ref=e21]
- text: SSO 계정으로 로그인
- img [ref=e23]
- paragraph [ref=e27]:
- text: 개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
- text: 민감한 작업 시 재인증을 요구할 수 있습니다.
- paragraph [ref=e32]:
- text: 인증 정보가 없거나 로그인이 되지 않는 경우
- text: 시스템 관리자에게 문의하세요.

319
Makefile
View File

@@ -10,41 +10,137 @@ endif
COMPOSE_INFRA := compose.infra.yaml
COMPOSE_ORY := compose.ory.yaml
COMPOSE_APP := docker-compose.yaml
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))
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
.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 down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app
# --- 인증 설정 빌드/검증 ---
build-auth-config:
@echo "Building auth config..."
@mkdir -p config/.generated
@bash scripts/auth_config.sh build
validate-auth-config: build-auth-config
@echo "Validating auth config..."
@bash scripts/auth_config.sh validate
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:
up: up-all
up-all: ensure-networks render-ory-config
@echo "Starting ALL stacks (infra + ory + app)..."
docker compose -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
# --- 개별 스택 실행 ---
up-infra:
up-infra: ensure-networks
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
docker compose -f $(COMPOSE_INFRA) up -d
up-ory:
up-ory: ensure-networks render-ory-config
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
docker compose -f $(COMPOSE_ORY) up -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
up-app:
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
docker compose -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:
up-backend: ensure-networks render-ory-config
@echo "Starting Backend only..."
docker compose -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."; \
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..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
# --- 종료 (Down) ---
down-all:
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
@@ -84,3 +180,202 @@ logs-ory:
logs-app:
docker compose -f $(COMPOSE_APP) logs -f
# --- 로컬 통합 코드 체크 ---
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
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'
.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
FLUTTER_TEST_CONCURRENCY ?= 1
code-check: code-check-lint code-check-test-jobs
@echo "code-check complete."
code-check-lint: code-check-i18n code-check-i18n-values code-check-front-lint code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint
code-check-test-jobs:
@echo "==> run CI-equivalent test jobs (parallel)"
@$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \
code-check-backend-tests \
code-check-userfront-tests \
code-check-userfront-e2e-tests \
code-check-adminfront-tests \
code-check-devfront-tests \
code-check-orgfront-tests
code-check-i18n:
@echo "==> i18n resource check"
@mkdir -p reports
node tools/i18n-scanner/index.js
node tools/i18n-scanner/report.js
@cat reports/i18n-report.txt
code-check-i18n-values:
@echo "==> i18n value quality check"
@mkdir -p reports
node tools/i18n-scanner/value-check.js
@cat reports/i18n-value-report.txt
code-check-go-lint:
@echo "==> go lint/format check"
@if command -v golangci-lint >/dev/null 2>&1; then \
cd backend && golangci-lint fmt -E gofmt -E gofumpt -d; \
elif command -v docker >/dev/null 2>&1; then \
docker run --rm \
-v "$$(pwd)/backend:/app" \
-w /app \
golangci/golangci-lint:v2.10.1 \
golangci-lint fmt -E gofmt -E gofumpt -d; \
else \
echo "ERROR: golangci-lint not found and docker is unavailable."; \
echo "Install golangci-lint v2.10.1 or Docker to match CI lint step."; \
exit 1; \
fi
code-check-sync-userfront-locales:
@echo "==> sync userfront locales"
/bin/sh ./scripts/sync_userfront_locales.sh
code-check-userfront-install:
@echo "==> install userfront dependencies"
cd userfront && flutter pub get
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
code-check-front-lint:
@echo "==> adminfront biome lint/format check"
rm -rf adminfront/playwright-report adminfront/test-results
cd adminfront && npm ci --ignore-scripts
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
@echo "==> devfront biome lint/format check"
rm -rf devfront/playwright-report devfront/test-results
cd devfront && npm ci --ignore-scripts
cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
@echo "==> orgfront biome lint/format check"
rm -rf orgfront/playwright-report orgfront/test-results
cd orgfront && npm ci --ignore-scripts
cd orgfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd orgfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
code-check-backend-tests:
@echo "==> backend tests"
cd backend && GOCACHE=/tmp/baron-sso-go-cache go test -v ./...
code-check-userfront-tests:
@echo "==> userfront tests (isolated workspace)"
@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/"; \
cp -R locales "$$tmp_dir/locales"; \
if command -v rsync >/dev/null 2>&1; then \
rsync -a --delete \
--exclude '.dart_tool' \
--exclude 'build' \
--exclude '.pub-cache' \
--exclude '.flutter-plugins' \
--exclude '.flutter-plugins-dependencies' \
userfront/ "$$tmp_dir/userfront/"; \
else \
cp -R userfront "$$tmp_dir/userfront"; \
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
fi; \
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY)
code-check-adminfront-tests:
@echo "==> adminfront tests"
PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests
code-check-devfront-tests:
@echo "==> devfront tests"
@mkdir -p reports/devfront
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
@status=0; \
(cd devfront && npm ci --ignore-scripts) || status=$$?; \
if [ $$status -eq 0 ]; then \
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd devfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
[ -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)"; \
trap 'rm -rf "$$tmp_dir"' EXIT INT TERM; \
mkdir -p "$$tmp_dir/scripts"; \
cp scripts/sync_userfront_locales.sh "$$tmp_dir/scripts/"; \
cp -R locales "$$tmp_dir/locales"; \
if command -v rsync >/dev/null 2>&1; then \
rsync -a --delete \
--exclude '.dart_tool' \
--exclude 'build' \
--exclude '.pub-cache' \
--exclude '.flutter-plugins' \
--exclude '.flutter-plugins-dependencies' \
userfront/ "$$tmp_dir/userfront/"; \
rsync -a --delete \
--exclude 'node_modules' \
--exclude 'playwright-report' \
--exclude 'test-results' \
userfront-e2e/ "$$tmp_dir/userfront-e2e/"; \
else \
cp -R userfront "$$tmp_dir/userfront"; \
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
cp -R userfront-e2e "$$tmp_dir/userfront-e2e"; \
rm -rf "$$tmp_dir/userfront-e2e/node_modules" "$$tmp_dir/userfront-e2e/playwright-report" "$$tmp_dir/userfront-e2e/test-results"; \
fi; \
status=0; \
(cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh) || status=$$?; \
if [ $$status -eq 0 ]; then \
(cd "$$tmp_dir/userfront-e2e" && npm ci) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(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=$$?; \
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();});")"; \
echo "==> userfront-e2e using PORT=$$port"; \
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \
[ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \
[ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \
exit $$status

440
README.md
View File

@@ -2,6 +2,27 @@
**Baron 로그인**은 화이트 라벨링된 가족사의 모든 소프트웨어 Auth를 총괄하는 사용자 인증/인가 허브입니다.
## 📂 프로젝트 구조 (Project Structure)
```
baron_sso/
├── backend/ # Go Fiber 애플리케이션
│ ├── cmd/server/ # 진입점 (Entry point)
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
│ └── Dockerfile
├── userfront/ # Flutter 애플리케이션
│ ├── src/ # UI 및 로직
│ └── pubspec.yaml
├── adminfront/ # React 기반 관리
│ ├── src/ # UI 및 로직
│ └── pubspec.yaml
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
├── docker-compose.yaml # 앱 서비스 (Front, Back)
├── .env.sample # 환경 설정 템플릿
└── README.md # 본 파일
```
* Ory Stack으로 모든 구성요소를 self-hosting 합니다.
* Backend는 Go (Fiber)로 구성된 Ory Stack의 유일한 Command 전송 포인트입니다. 모든 Command는 ClickHouse로 강제 전송되며 Audit Log 시스템을 구성합니다.
* Front는 Backend를 통해서만 연동하며 자체가 Ory Stack의 RP기도 합니다. 크게 3개 계층으로 분리됩니다.
@@ -13,6 +34,7 @@
* AdminFront: 사용자 관리 등 Admin 기능
* DevFront: RP 관리 등 개발자 기능
## 🏗 아키텍처 (Architecture)
### 0. Ory Stack
@@ -44,7 +66,7 @@ flowchart
```
### 1. Backend (Go Fiber)
- **Language**: Go 1.25+
- **Language**: Go 1.26.2+
- **Framework**: Fiber v2.25+
- **Database**:
- **ClickHouse**: 감사 로그 (고성능 데이터 수집)
@@ -55,7 +77,7 @@ flowchart
- userfront가 바라보는 backend
### 2. UserFront(Flutter Web/App)
- **Framework**: Flutter 3.32+
- **Framework**: Flutter 3.38.0+
- **Key Packages**: `flutter_riverpod`, `go_router`
- **Features**:
- 탭 기반 로그인 UI (비밀번호 기반 / 링크 기반 / QR 기반 등)
@@ -73,6 +95,82 @@ 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입니다.
- 한맥가족 테넌트 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 규칙을 검증합니다.
### 4. 주요 시나리오 (Core Scenarios)
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
@@ -81,6 +179,122 @@ flowchart
2.1 향후 App Push 등 2차 인증 강화수단 검토 필요
3. **QR Login**: 최초 진입 시 사전 로그인되어 있는 웹/앱을 이용해 QR 코드를 스캔하여, QR코드가 로딩된 Device를 로그인 상태로 전환
### 5. Headless Login ID/Password Flow
- 목적: headless login을 허용한 클라이언트가 자체 로그인 화면에서 `ID/password`를 수집하되, Baron Backend가 OIDC 로그인 흐름만 계속 진행하고 RP에는 `sessionJwt`를 직접 넘기지 않습니다.
- 대상 엔드포인트: `POST /api/v1/auth/headless/password/login`
- 관련 구현:
- `backend/internal/handler/auth_handler.go`
- `backend/internal/domain/hydra_models.go`
- `backend/internal/handler/auth_handler_login_test.go`
#### 호출 순서
1. RP 브라우저가 Hydra Public의 `/oauth2/auth`를 호출해 OIDC 인증을 시작합니다.
2. Hydra가 로그인 단계로 넘긴 `login_challenge`를 RP가 확보합니다.
3. RP backend가 자기 private key로 `client_assertion` JWT를 서명합니다.
4. RP backend가 Baron Backend의 `POST /api/v1/auth/headless/password/login``client_id`, `client_assertion`, `login_challenge`, `loginId`, `password`를 전송합니다.
5. Baron Backend가 Hydra login request와 RP 설정을 검증한 뒤 Kratos sign-in 및 Hydra login accept를 수행합니다.
6. 성공 시 Baron Backend는 `redirectTo`만 반환하고, RP 브라우저는 그 URL로 이동해 OIDC 흐름을 이어갑니다.
#### 요청 바디
```json
{
"client_id": "headless-login-client",
"client_assertion": "<signed-jwt>",
"login_challenge": "<hydra-login-challenge>",
"loginId": "employee001",
"password": "secret"
}
```
#### 성공 응답
```json
{
"status": "ok",
"provider": "ory",
"redirectTo": "https://rp.example.com/callback?code=..."
}
```
#### RP / Hydra 선행 조건
- Hydra login request의 `client.client_id`와 요청 바디의 `client_id`가 반드시 같아야 합니다.
- client가 headless login 선행 조건을 만족해야 합니다.
- `headless_token_endpoint_auth_method == "private_key_jwt"` 또는 top-level `token_endpoint_auth_method == "private_key_jwt"`
- `headless_jwks_uri`가 존재해야 합니다.
- inline `headless_jwks`는 더 이상 지원하지 않습니다.
- `headless_login_enabled == true`가 필요합니다.
- `metadata.status == "inactive"`인 client는 차단됩니다.
#### `client_assertion` 규칙
- 구현상 `client_assertion`은 현재 필수입니다.
- 허용 서명 알고리즘:
- `RS256`, `RS384`, `RS512`
- `PS256`, `PS384`, `PS512`
- `ES256`, `ES384`, `ES512`
- `EdDSA`
- JWT claim의 `iss``sub`는 모두 `client_id`와 같아야 합니다.
- `exp`는 현재 시각 이후여야 합니다.
- `nbf`, `iat`가 있으면 미래 시각이면 안 됩니다.
- `aud`는 다음 둘 중 하나와 일치해야 합니다.
- `https://<backend-origin>/api/v1/auth/headless/password/login`
- `/api/v1/auth/headless/password/login`
- 서명 검증용 public key는 `headless_jwks_uri`에서만 읽습니다.
#### 내부 JWKS 캐시 정책
- Baron Backend는 `headless_jwks_uri`를 직접 외부 스펙으로 저장하고, 실제 JWKS 문서는 내부 캐시에 저장해 사용합니다.
- 등록/수정 이후에는 내부 캐시 동기화를 시도하고, 성공/실패 상태를 DevFront에서 확인할 수 있습니다.
- 로그인 시 재조회는 다음 조건으로 제한합니다.
- 캐시에 `kid`가 없을 때
- `kid`는 있지만 서명 검증이 실패할 때
- 캐시 TTL이 만료되었을 때
- 그 외에는 내부 캐시를 사용합니다.
- 백그라운드 worker가 TTL보다 짧은 주기로 `jwksUri`를 선제 점검해 첫 사용자 실패를 줄입니다.
#### DevFront 운영 액션
- Settings > `Public Key Registration` 카드에서 다음 정보를 확인할 수 있습니다.
- 최근 JWKS 캐시 갱신 시각
- 최근 검증 성공 시각
- 최근 에러와 연속 실패 횟수
- 현재 cached `kid` 목록
- 파싱된 key summary
- `kid`
- `kty`
- `use`
- `alg`
- RSA key의 `n` preview (앞/뒤 일부만 표시)
- 수동 운영 액션:
- `Refresh JWKS Cache`
- `Revoke JWKS Cache`
- RP가 키를 교체했으면 실제 트래픽 전에 `Refresh JWKS Cache`를 먼저 호출하는 것을 권장합니다.
#### 일반 로그인과의 차이
- `POST /api/v1/auth/password/login`
- UserFront 기본 비밀번호 로그인용입니다.
- `login_challenge`가 없으면 `sessionJwt`를 반환합니다.
- `login_challenge`가 있으면 Hydra accept 후 `redirectTo`를 반환합니다.
- `POST /api/v1/auth/headless/password/login`
- headless login 허용 클라이언트 전용입니다.
- `client_assertion` 검증이 추가됩니다.
- 항상 `sessionJwt` 없이 `redirectTo`만 반환합니다.
#### 실패 패턴 요약
- `400 bad_request`
- 필수 필드 누락
- `client_assertion` 누락
- `401 invalid_client_assertion`
- `jwksUri` 조회 실패
- 서명 불일치
- `aud`/`iss`/`sub`/`exp` 검증 실패
- `401 invalid_client_assertion` with explicit message
- `Headless login requires jwksUri. Inline jwks is not supported.`
- `Configured jwksUri returned no keys for headless login.`
- `Failed to refresh headless login jwks from jwksUri.`
- `403 forbidden`
- `client_id` 불일치
- `headless_login_enabled` 미설정
- inactive client
- `401 password_or_email_mismatch`
- 사용자 인증 실패
### 전체 연결 구조도
@@ -147,7 +361,7 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비
### 사전 요구사항 (Prerequisites)
- Docker & Docker Compose
- Flutter SDK (로컬 개발용)
- Flutter SDK (로컬 개발용, 3.38.0+)
- Go (로컬 백엔드 개발용)
### 환경 설정 (Environment Setup)
@@ -155,17 +369,74 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비
```bash
cp .env.sample .env
```
2. **중요**: `.env` 파일을 열어 **Descope Project ID**를 입력해야 합니다.
```env
DESCOPE_PROJECT_ID=P2t...
```
3. **IDP 우선순위와 Ory 엔드포인트를 지정**합니다. 기본값은 Ory 우선 + Descope 폴백입니다.
```env
IDP_PROVIDER=ory,descope
KRATOS_ADMIN_URL=http://kratos:4434
HYDRA_ADMIN_URL=http://hydra:4445
HYDRA_PUBLIC_URL=http://hydra:4444
```
2. `.env`를 작성합니다. (아래 작성 규칙 필수)
### `.env` 작성 규칙 (중요)
- `KEY=value` 한 줄만 사용하고, **값 뒤에 같은 줄 주석을 붙이지 않습니다.**
- 주석이 필요하면 반드시 **윗줄에 별도 주석 라인**으로 작성합니다.
- URL 값 끝에 공백이 들어가면 Hydra/Kratos 기동 실패로 이어질 수 있습니다.
잘못된 예:
```env
USERFRONT_URL=https://sso.example.com # 이렇게 같은 줄 주석 금지
```
올바른 예:
```env
# UserFront 공개 URL
USERFRONT_URL=https://sso.example.com
```
### `.env` 핵심 변수 가이드
- `IDP_PROVIDER`: 기본 `ory`
- `USERFRONT_URL`: 브라우저 기준 공개 도메인 (예: `https://sso.example.com`)
- `OATHKEEPER_PUBLIC_URL`: 보통 `${USERFRONT_URL}`
- `HYDRA_PUBLIC_URL`: 보통 `${OATHKEEPER_PUBLIC_URL}/oidc`
- `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/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`만 수집
- `BACKEND_LOG_LEVEL`: Backend `slog` 레벨 override (선택)
- 허용 값: `debug`, `info`, `warn`, `error`
- 미설정 시 `APP_ENV` 기준으로 결정됩니다.
- `dev|local|development`: `debug`
- 그 외(`stage`, `production`, `prod` 등): `info`
- `USERFRONT_DEBUG_LOG`: UserFront 측 디버그 로그 fallback 플래그
- `CLIENT_LOG_DEBUG`가 없을 때만 UserFront에서 대체로 참조
### 클라이언트 로그 정책 (중요)
- 기본 원칙: 운영 환경에서는 민감정보 보호를 우선하며, 과도한 로그 수집을 제한합니다.
- 환경별 동작:
- 비운영(`dev/local/stage` 등): 디버그 로그 허용 (`INFO/DEBUG/WARN/ERROR`)
- 운영(`production/prod`) + `CLIENT_LOG_DEBUG` 미설정: `WARN/ERROR`만 수집
- 운영(`production/prod`) + `CLIENT_LOG_DEBUG=true`: 디버그 로그 허용
- 민감정보는 환경과 무관하게 마스킹합니다.
- 예: `password`, `newPassword`, `token`, `authorization`, `cookie`, `sessionJwt`
- 문자열 패턴(`token=...`, `authorization:...`, JSON body 내 민감 key)도 마스킹
- 상세 정책 문서: `docs/client-log-policy.md`
### Backend 로그 정책 (중요)
- Backend 서버 로그는 `APP_ENV` 기준으로 기본 레벨이 정해집니다.
- `dev|local|development`: `debug`
- 그 외(`stage`, `production`, `prod` 등): `info`
- 운영/스테이징에서 장애 분석이 필요할 때만 `BACKEND_LOG_LEVEL=debug`를 일시적으로 설정하는 것을 권장합니다.
- headless login 같은 경로의 상세 진단 필드는 `debug` 레벨에서만 추가로 남습니다.
- 상세 정책 문서: `docs/backend-log-policy.md`
### `.env` 작성 후 권장 점검
```bash
make validate-auth-config
```
위 검증은 callback/allowed_return_urls/게이트웨이 매핑 규칙을 점검하고 `config/.generated/auth-config.env`를 생성합니다.
### 전체 스택 실행 (Running the Stack)
@@ -182,15 +453,49 @@ docker network create public_net #서비스용
#### 2. 인프라 및 Ory Stack 실행
데이터베이스와 Ory 서비스(Kratos, Hydra, Keto 등)를 실행합니다.
```bash
docker compose -f compose.infra.yaml -f compose.ory.yaml up -d
# 권장: Make 실행 (인증 설정 검증 포함)
make up-dev
```
#### 3. 애플리케이션 실행
userfront와 backend 서비스를 실행합니다.
```bash
docker compose -f docker-compose.yaml up -d
make up-app
```
(또는 전체 스택 한번에 실행: `make up-all`)
### Make 기반 인증 설정 검증 (권장)
`up-*` 타깃은 실행 전 인증 리다이렉트 설정을 자동 검증합니다.
```bash
# 1) 인증 설정 생성
make build-auth-config
# 2) 정적 검증 (callback / allowed_return_urls / 게이트웨이 매핑)
make validate-auth-config
# 3) 배선 + (가능 시) 런타임 Hydra client 검증
make verify-auth-config
```
- 생성 파일: `config/.generated/auth-config.env` (compose 실행 시 자동 주입)
- 게이트웨이 경유 환경은 URL 문자열 완전일치 대신 매핑 유효성(`direct_match` / `mapped_match`) 기준으로 검증합니다.
- 관련 정책 문서: `docs/oidc_redirect_mapping_validation_policy.md`
### 권장 실행 순서
```bash
cp .env.sample .env
# .env 편집
make validate-auth-config
make up-dev
make up-app
```
직접 Compose를 사용하려면 다음처럼 env 파일을 함께 주입하세요.
```bash
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
```
(또는 한번에 실행: `docker compose -f compose.infra.yaml -f compose.ory.yaml -f docker-compose.yaml up -d`)
- **gateway (UserFront 프록시)**: http://localhost:5000 접속
- **backend**: http://localhost:3000 (API)
@@ -235,6 +540,71 @@ KETO_READ_URL = "http://keto:4466"
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`로 로드합니다.
- **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`로 코드-키-로케일 동기화 상태를 점검합니다.
## 🧪 Code Check CI
워크플로우 파일: `.gitea/workflows/code_check.yml`
### 트리거
- `push` (`dev` 브랜치)
- `pull_request` (`dev` 대상)
- `workflow_dispatch` (수동 실행)
### workflow_dispatch 입력값
- `run_lint`: Go/Flutter lint 실행 여부
- `run_backend_tests`: backend 테스트 실행 여부
- `run_userfront_tests`: userfront 테스트 실행 여부
- `run_userfront_e2e_tests`: userfront WASM Playwright E2E 실행 여부
- `run_adminfront_tests`: adminfront 테스트 실행 여부
- `run_devfront_tests`: devfront 테스트 실행 여부
### 실행 잡
- `lint`
- `backend-tests`
- `userfront-tests`
- `userfront-e2e-tests`
- `adminfront-tests`
- `devfront-tests`
### 프런트 테스트 브라우저 프리비저닝 정책
- `userfront-tests`
- `flutter test`(VM)만 실행
- `locale_storage` 정책 테스트는 엔진 단위로 통합되어 별도 브라우저 실행이 필요하지 않음
- `adminfront-tests`, `devfront-tests`
- Playwright 기반 테스트
- `npx playwright install --with-deps`로 브라우저/OS 의존성을 사전 설치
### 실패 보고서 확인 방법
테스트가 실패하면 다음이 자동 생성됩니다.
- Job Summary: 실패 원인 요약(Markdown) 즉시 확인
- Artifact: 상세 로그/리포트 다운로드
- `backend-test-failure-report`
- `userfront-test-failure-report`
- `adminfront-test-failure-report`
- `devfront-test-failure-report`
### userfront `locale_storage` 테스트 정책
- `locale_storage_platform_test.dart``LocaleStorageEngine` 기반 정책 테스트로 통합되었습니다.
- 일반 `flutter test`(VM) 실행에 포함되며, 브라우저 전용 `kIsWeb` 케이스를 사용하지 않습니다.
- 단일 파일만 확인하려면 다음 명령을 사용합니다.
- `flutter test test/locale_storage_platform_test.dart`
### userfront WASM Playwright E2E
- 워크스페이스: `userfront-e2e/`
- 빌드+실행:
- `cd userfront-e2e && npm run test:wasm`
- 빌드 결과가 이미 있을 때:
- `cd userfront-e2e && npm test`
- Makefile 타깃:
- `make code-check-userfront-e2e-tests`
- 전수 인벤토리:
- `https://gitea.hmac.kr/baron/baron-sso/wiki/UserFront-WASM-E2E-Inventory.-`
### 로컬 개발 (Manual)
Docker 없이는 개발할 수 없지만 Backend 및 [user/admin/dev]Front 코드는 개발모드로 수정하며 개발가능.
백그라운드로 infra 및 ory stack이 구동중이라는 가정
@@ -251,6 +621,8 @@ go run cmd/server/main.go
cd userfront
flutter pub get
flutter run -d chrome
# 정책: 웹 빌드는 기본적으로 WASM 사용
flutter build web --wasm
```
**adminfront:**
@@ -266,34 +638,12 @@ cd devfront
npm install
npm run dev
```
---
## 📂 프로젝트 구조 (Project Structure)
```
baron_sso/
├── backend/ # Go Fiber 애플리케이션
│ ├── cmd/server/ # 진입점 (Entry point)
│ ├── internal/ # 도메인, 핸들러, 저장소(Repository)
│ └── Dockerfile
├── userfront/ # Flutter 애플리케이션
│ ├── src/ # UI 및 로직
│ └── pubspec.yaml
├── adminfront/ # React 기반 관리
│ ├── src/ # UI 및 로직
│ └── pubspec.yaml
├── gateway/ # Nginx 기반 Gateway (UserFront 프록시)
├── compose.ory-stack.yaml # DB 서비스 (Postgres, ClickHouse)
├── compose.infra.yaml # DB 서비스 (Postgres, ClickHouse)
├── docker-compose.yaml # 앱 서비스 (Front, Back)
├── .env.sample # 환경 설정 템플릿
└── README.md # 본 파일
```
## 📝 상태 및 로드맵 (Status & Roadmap)
- [x] **Phase 1**: 초기 설정 및 아키텍처 설계 (완료)
- [x] **Phase 2**: Backend Audit API 구현 (일부 완료)
- [ ] **Phase 3**: userfront 로그인 UI 인증 로직 (예정)
- [ ] **Phase 4**: adminfront 기능 추가 (예정)
- [ ] **Phase 5**: 대시보드 및 통합 런처 구현 (예정)
## 버그 대응 대원칙 (필수)
- 모든 버그 수정은 반드시 **재현 테스트를 먼저 작성**합니다. (Failing test first)
- 재현 테스트 없이 코드만 먼저 고치는 행위를 금지합니다.
- 수정 후에는 **해당 재현 테스트가 통과할 때까지 반복**해서 원인 분석/수정/검증을 수행합니다.
- “테스트 통과”는 최소 기준입니다. 실제 재현 시나리오(로그인, 새로고침, 리다이렉트 등)까지 확인한 뒤에만 이슈를 종료합니다.
- 관련 변경이 발생하면 테스트 문서(`docs/test-plan/*`, `docs/trouble-shooting/*`)를 함께 업데이트합니다.

View File

@@ -6,7 +6,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
## 🏗 Architecture
### 1. Frontend (Flutter Web)
- **Framework**: Flutter 3.32+
- **Framework**: Flutter 3.38.0+
- **Organization**: `kr.co.baroncs`
- **Key Packages**: `descope`, `flutter_riverpod`, `go_router`
- **Features**:
@@ -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)
@@ -32,7 +32,7 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
### Prerequisites
- Docker & Docker Compose
- Flutter SDK (for local development)
- Flutter SDK (for local development, 3.38.0+)
- Go (for local backend development)
### Environment Setup
@@ -40,11 +40,8 @@ It leverages **Descope** for secure, passwordless authentication (Enchanted Link
```bash
cp .env.sample .env
```
2. **Crucial**: Edit `.env` and provide your **Descope Project ID**.
```env
DESCOPE_PROJECT_ID=P2t...
```
3. Set the **IDP priority and Ory admin endpoints**. The default is Ory first with Descope as fallback.
2. Set the **IDP priority and Ory admin endpoints**. The default is Ory first with Descope as fallback.
```env
IDP_PROVIDER=ory,descope
KRATOS_ADMIN_URL=http://kratos:4434

View File

@@ -16,10 +16,5 @@ COPY . .
EXPOSE 5173
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
CMD sh -c "if [ \"$APP_ENV\" = 'production' ]; then \
echo 'Running in production mode...'; \
npm run build && serve -s dist -l 5173; \
else \
echo 'Running in development mode...'; \
npm run dev -- --host 0.0.0.0; \
fi"
RUN chmod +x ./scripts/runtime-mode.sh
CMD ["sh", "./scripts/runtime-mode.sh"]

View File

@@ -0,0 +1,3 @@
"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time"
"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"
"Doe","Eric","eric.doe","eric@naver.com","eric2@company.com","Eric","Contract Employee","Manager","org.1|org.2|org.3|org.4|myteam","Manager","02-1234-0000","+1","9765412345","Japanese","General affairs","New York","Facebook","Eric","lunar","19840704","20240704","AB002","20240704 14:00"
1 LastName FirstName ID Personal email Sub email Nickname User type Level Organization Position CompanyMainPhone Mobile/Country code Mobile/Numbers Language Responsibilities Workplace SNS SNS_ID Birthday (solar, lunar) Birthday Entry Date Employee number Account activation time
2 Doe John john.doe john@naver.com john1@company.com; john2@company.com John Permanent Employee Manager org.1|org.2|org.3|myteam Manager 02-0000-0000 +1 9144812222 English Sales management New York Facebook john solar 19830415 20230415 AB001 20230415 08:00
3 Doe Eric eric.doe eric@naver.com eric2@company.com Eric Contract Employee Manager org.1|org.2|org.3|org.4|myteam Manager 02-1234-0000 +1 9765412345 Japanese General affairs New York Facebook Eric lunar 19840704 20240704 AB002 20240704 14:00

View File

@@ -3,7 +3,7 @@
관리자 포털을 위한 React/Vite 기반 웹입니다. 이슈 #60 스펙을 바탕으로 라우팅, 서버 상태, 스타일 토큰을 세팅했고 특정 벤더에 종속되지 않는 IDP 연동 훅 포인트를 남겨두었습니다.
## 주요 스택
- React 19, Vite 7, TypeScript(strict)
- React 19, Vite 8, TypeScript(strict)
- React Router v6 (data router)
- TanStack Query v5
- Tailwind CSS v3 + shadcn/ui 컴포넌트(seed: Button/Card/Badge/Input/Table/Avatar)

View File

@@ -19,6 +19,14 @@
"enabled": true
},
"files": {
"ignore": ["dist", "node_modules", "tsconfig*.json"]
"ignore": [
"dist",
".vite",
"node_modules",
"tsconfig*.json",
"test-results",
"test-results.nobody-backup",
"playwright-report"
]
}
}

50
adminfront/gpdtdc_org.csv Normal file
View File

@@ -0,0 +1,50 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","",""
"인재성장","2","","","","hr@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"전산관리TF","4","한치영(cyhan@samaneng.com)","","","it@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"기술기획","8","김원기(ba.56669@baroncs.co.kr)","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"경영기획","0","","","","t_266py@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"ERP기획","0","","","","t_136ud@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"디자인기획","0","","","","t_618gm@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"협업증진","0","","","","t_752rp@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"솔루션통합","0","","","","t_683tq@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"네이버웍스관리용","2","슈퍼관리자(su-@samaneng.com)","","","su3@baroncs.co.kr","N","N","N","Y","","",""
"기술개발센터","0","","","","t_536fc@baroncs.co.kr","Y","N","Y","Y","","",""
"일반구조물 div","0","","","","t_568cz@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"DfMA","0","","","","t_538ub@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"일반구조물","0","","","","t_601cu@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"구조물계획","0","","","","t_388gh@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"하부구조","0","","","","t_131xd@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"CM기획","0","","","","t_349dy@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"터널","0","","","","t_068jk@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
"CC","0","","","","t_116me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"공정관리","0","","","","t_628of@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
"단가산출","0","","","","t_002sq@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
"상하수도","0","","","","t_323pd@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"천지인","0","","","","t_859sx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"천지인셀","0","","","","t_827ax@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
"용지도셀","0","","","","t_896yy@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
"단지설계 개발","0","","","","t_602uo@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
"인프라솔루션 개발","0","","","","t_566mk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"비탈면/구조물","0","","","","t_726dh@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"Way Draw","0","","","","t_504jn@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"Primal 평면","0","","","","t_284vk@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"Watch BIM","0","","","","t_170el@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
"구조물S/W","0","","","","t_019ge@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"Strana","0","","","","t_595rj@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"그래픽스","0","","","","t_934zk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"Modeler","0","","","","t_932vs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"HmEG","0","","","","t_614xb@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"EG-BIM Draw","0","","","","t_563cv@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"Abut&시공통합관제","0","","","","t_762fs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
"웹솔루션","0","","","","t_797wn@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"솔루션개발","0","","","","t_923oe@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
"ERP","0","","","","t_481sa@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
"웹디자인","0","","","","t_587ef@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
"GSIM개발","0","","","","t_929kx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"bCMf","0","","","","t_833jy@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
"GSIM","0","","","","t_263tv@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
"PM","0","","","","t_335nb@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
"수자원","0","","","","t_233cs@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"스마트건설","0","","","","t_842me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
"시공BIM","0","","","","t_942jh@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 총괄기획실 0 gpd@baroncs.co.kr Y N Y Y
3 인재성장 2 hr@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
4 전산관리TF 4 한치영(cyhan@samaneng.com) it@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
5 기술기획 8 김원기(ba.56669@baroncs.co.kr) tech-planning@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
6 경영기획 0 t_266py@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
7 ERP기획 0 t_136ud@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
8 디자인기획 0 t_618gm@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
9 협업증진 0 t_752rp@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
10 솔루션통합 0 t_683tq@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
11 네이버웍스관리용 2 슈퍼관리자(su-@samaneng.com) su3@baroncs.co.kr N N N Y
12 기술개발센터 0 t_536fc@baroncs.co.kr Y N Y Y
13 일반구조물 div 0 t_568cz@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
14 DfMA 0 t_538ub@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
15 일반구조물 0 t_601cu@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
16 구조물계획 0 t_388gh@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
17 하부구조 0 t_131xd@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
18 CM기획 0 t_349dy@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
19 터널 0 t_068jk@baroncs.co.kr Y N Y Y 일반구조물 div(t_568cz@baroncs.co.kr)
20 CC 0 t_116me@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
21 공정관리 0 t_628of@baroncs.co.kr Y N Y Y CC(t_116me@baroncs.co.kr)
22 단가산출 0 t_002sq@baroncs.co.kr Y N Y Y CC(t_116me@baroncs.co.kr)
23 상하수도 0 t_323pd@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
24 천지인 0 t_859sx@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
25 천지인셀 0 t_827ax@baroncs.co.kr Y N Y Y 천지인(t_859sx@baroncs.co.kr)
26 용지도셀 0 t_896yy@baroncs.co.kr Y N Y Y 천지인(t_859sx@baroncs.co.kr)
27 단지설계 개발 0 t_602uo@baroncs.co.kr Y N Y Y 천지인(t_859sx@baroncs.co.kr)
28 인프라솔루션 개발 0 t_566mk@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
29 비탈면/구조물 0 t_726dh@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
30 Way Draw 0 t_504jn@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
31 Primal 평면 0 t_284vk@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
32 Watch BIM 0 t_170el@baroncs.co.kr Y N Y Y 인프라솔루션 개발(t_566mk@baroncs.co.kr)
33 구조물S/W 0 t_019ge@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
34 Strana 0 t_595rj@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
35 그래픽스 0 t_934zk@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
36 Modeler 0 t_932vs@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
37 HmEG 0 t_614xb@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
38 EG-BIM Draw 0 t_563cv@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
39 Abut&시공통합관제 0 t_762fs@baroncs.co.kr Y N Y Y 그래픽스(t_934zk@baroncs.co.kr)
40 웹솔루션 0 t_797wn@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
41 솔루션개발 0 t_923oe@baroncs.co.kr Y N Y Y 웹솔루션(t_797wn@baroncs.co.kr)
42 ERP 0 t_481sa@baroncs.co.kr Y N Y Y 웹솔루션(t_797wn@baroncs.co.kr)
43 웹디자인 0 t_587ef@baroncs.co.kr Y N Y Y 웹솔루션(t_797wn@baroncs.co.kr)
44 GSIM개발 0 t_929kx@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
45 bCMf 0 t_833jy@baroncs.co.kr Y N Y Y GSIM개발(t_929kx@baroncs.co.kr)
46 GSIM 0 t_263tv@baroncs.co.kr Y N Y Y GSIM개발(t_929kx@baroncs.co.kr)
47 PM 0 t_335nb@baroncs.co.kr Y N Y Y GSIM개발(t_929kx@baroncs.co.kr)
48 수자원 0 t_233cs@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
49 스마트건설 0 t_842me@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)
50 시공BIM 0 t_942jh@baroncs.co.kr Y N Y Y 기술개발센터(t_536fc@baroncs.co.kr)

View File

@@ -0,0 +1,50 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","",""
"인재성장","2","","","","talent-growth@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"전산관리TF","4","","","","it-admin-tf@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"기술기획","8","","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"경영기획","0","","","","management-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"ERP기획","0","","","","erp-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"디자인기획","0","","","","design-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"협업증진","0","","","","collaboration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"솔루션통합","0","","","","solution-integration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
"네이버웍스관리용","2","","","","su2@baroncs.co.kr","N","N","N","Y","","",""
"기술개발센터","0","","","","tdc@baroncs.co.kr","Y","N","Y","Y","","",""
"일반구조물 div","0","","","","structural-division@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"DfMA","0","","","","dfma@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"일반구조물","0","","","","structural-design@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"구조물계획","0","","","","structure-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"하부구조","0","","","","substructure@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"CM기획","0","","","","cm-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"터널","0","","","","tunnel@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
"CC","0","","","","cost-control@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"공정관리","0","","","","schedule-control@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
"단가산출","0","","","","cost-estimate@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
"상하수도","0","","","","water-sewer@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"천지인","0","","","","cheonjijin@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"천지인셀","0","","","","cheonjijin-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"용지도셀","0","","","","land-map-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"단지설계 개발","0","","","","site-design-dev@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
"인프라솔루션 개발","0","","","","infra-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"비탈면/구조물","0","","","","slope-structures@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Way Draw","0","","","","way-draw@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Primal 평면","0","","","","primal-plan@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"Watch BIM","0","","","","watch-bim@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
"구조물S/W","0","","","","structural-software@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"Strana","0","","","","strana@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"그래픽스","0","","","","graphics@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"Modeler","0","","","","modeler@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"HmEG","0","","","","hmeg@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"EG-BIM Draw","0","","","","eg-bim-draw@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"Abut&시공통합관제","0","","","","abut-control@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
"웹솔루션","0","","","","web-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"솔루션개발","0","","","","solution-dev@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"ERP","0","","","","erp@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"웹디자인","0","","","","web-design@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
"GSIM개발","0","","","","gsim-dev@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"bCMf","0","","","","bcmf@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"GSIM","0","","","","gsim@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"PM","0","","","","project-management@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
"수자원","0","","","","water-resources@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"스마트건설","0","","","","smart-construction@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
"시공BIM","0","","","","construction-bim@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(tdc@baroncs.co.kr)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 총괄기획실 0 gpd@baroncs.co.kr Y N Y Y
3 인재성장 2 talent-growth@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
4 전산관리TF 4 it-admin-tf@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
5 기술기획 8 tech-planning@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
6 경영기획 0 management-planning@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
7 ERP기획 0 erp-planning@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
8 디자인기획 0 design-planning@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
9 협업증진 0 collaboration@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
10 솔루션통합 0 solution-integration@baroncs.co.kr Y N Y Y 총괄기획실(gpd@baroncs.co.kr)
11 네이버웍스관리용 2 su2@baroncs.co.kr N N N Y
12 기술개발센터 0 tdc@baroncs.co.kr Y N Y Y
13 일반구조물 div 0 structural-division@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
14 DfMA 0 dfma@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
15 일반구조물 0 structural-design@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
16 구조물계획 0 structure-planning@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
17 하부구조 0 substructure@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
18 CM기획 0 cm-planning@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
19 터널 0 tunnel@baroncs.co.kr Y N Y Y 일반구조물 div(structural-division@baroncs.co.kr)
20 CC 0 cost-control@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
21 공정관리 0 schedule-control@baroncs.co.kr Y N Y Y CC(cost-control@baroncs.co.kr)
22 단가산출 0 cost-estimate@baroncs.co.kr Y N Y Y CC(cost-control@baroncs.co.kr)
23 상하수도 0 water-sewer@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
24 천지인 0 cheonjijin@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
25 천지인셀 0 cheonjijin-cell@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
26 용지도셀 0 land-map-cell@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
27 단지설계 개발 0 site-design-dev@baroncs.co.kr Y N Y Y 천지인(cheonjijin@baroncs.co.kr)
28 인프라솔루션 개발 0 infra-solutions@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
29 비탈면/구조물 0 slope-structures@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
30 Way Draw 0 way-draw@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
31 Primal 평면 0 primal-plan@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
32 Watch BIM 0 watch-bim@baroncs.co.kr Y N Y Y 인프라솔루션 개발(infra-solutions@baroncs.co.kr)
33 구조물S/W 0 structural-software@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
34 Strana 0 strana@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
35 그래픽스 0 graphics@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
36 Modeler 0 modeler@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
37 HmEG 0 hmeg@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
38 EG-BIM Draw 0 eg-bim-draw@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
39 Abut&시공통합관제 0 abut-control@baroncs.co.kr Y N Y Y 그래픽스(graphics@baroncs.co.kr)
40 웹솔루션 0 web-solutions@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
41 솔루션개발 0 solution-dev@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
42 ERP 0 erp@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
43 웹디자인 0 web-design@baroncs.co.kr Y N Y Y 웹솔루션(web-solutions@baroncs.co.kr)
44 GSIM개발 0 gsim-dev@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
45 bCMf 0 bcmf@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
46 GSIM 0 gsim@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
47 PM 0 project-management@baroncs.co.kr Y N Y Y GSIM개발(gsim-dev@baroncs.co.kr)
48 수자원 0 water-resources@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
49 스마트건설 0 smart-construction@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)
50 시공BIM 0 construction-bim@baroncs.co.kr Y N Y Y 기술개발센터(tdc@baroncs.co.kr)

View File

@@ -0,0 +1,474 @@
> 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

@@ -3,19 +3,27 @@
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"dev": "vite --host 0.0.0.0",
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"lint": "biome check .",
"lint:fix": "biome check . --write",
"format": "biome format . --write",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui"
"test": "node ./node_modules/playwright/cli.js test",
"test:unit": "vitest run",
"test:ui": "node ./node_modules/playwright/cli.js 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-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-scroll-area": "^1.1.2",
"@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",
@@ -24,9 +32,11 @@
"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"
@@ -34,18 +44,21 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@playwright/test": "^1.58.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/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.23",
"jsdom": "^28.1.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.2.5"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
"vite": "^8.0.3",
"vitest": "^4.0.18"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,20 @@
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;
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;
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
@@ -13,6 +28,10 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
testDir: "./tests",
timeout: 60 * 1000,
expect: {
timeout: 15000,
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
@@ -20,16 +39,17 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
workers: configuredWorkers ?? (process.env.CI ? 1 : undefined),
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
reporter: [["html", { open: "never" }], ["list"]],
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:5173",
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
trace: "retain-on-failure",
locale: "ko-KR",
},
/* Configure projects for major browsers */
@@ -44,16 +64,25 @@ 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 */
webServer: {
command: "npm run dev",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
},
webServer: process.env.BASE_URL
? 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,
reuseExistingServer,
timeout: 120 * 1000,
},
});

44
adminfront/saman_org.csv Normal file
View File

@@ -0,0 +1,44 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"기술개발센터","1","","","","tdc@samaneng.com","N","N","N","Y","","",""
"경영전략본부","0","","","","business-strategy@samaneng.com","Y","N","Y","Y","","",""
"기획부","1","변역근(ykbyun@samaneng.com)","","","planning@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"업무팀","0","","","","t_226wn@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"PQ팀","0","","","","t_978bl@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"재무회계팀","0","","","","t_186qz@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"대외협력팀","0","","","","t_466et@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"인사총무부","0","","","","t_784bn@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"네이버웍스관리용","1","슈퍼관리자(su-@samaneng.com)","","","su1@samaneng.com","N","N","N","Y","","",""
"자산경영실","0","","","","t_563wl@samaneng.com","Y","N","Y","Y","","",""
"안전품질관리실","0","","","","t_793co@samaneng.com","Y","N","Y","Y","","",""
"사업개발실","0","","","","t_468yk@samaneng.com","Y","N","Y","Y","","",""
"CM본부","0","","","","t_838vr@samaneng.com","Y","N","Y","Y","","",""
"CM사업부","0","","","","t_205ud@samaneng.com","Y","N","Y","Y","","","CM본부(t_838vr@samaneng.com)"
"호남지역총괄본부","0","","","","t_143ep@samaneng.com","Y","N","Y","Y","","","CM사업부(t_205ud@samaneng.com)"
"플랜트본부","0","","","","t_009bl@samaneng.com","Y","N","Y","Y","","",""
"플랜트1부","0","","","","t_595bv@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
"플랜트2부","0","","","","t_677ei@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
"항만부","0","","","","t_446wi@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
"국토개발본부","0","","","","t_405cl@samaneng.com","Y","N","Y","Y","","",""
"도시계획부","0","","","","t_403or@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
"도시개발부","0","","","","t_733kg@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
"조경레저부","0","","","","t_931rr@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
"도로본부","0","","","","t_402qv@samaneng.com","Y","N","Y","Y","","",""
"도로부","0","","","","t_560mk@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"지반터널부","0","","","","t_918nd@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"교통계획부","0","","","","t_879qs@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"구조부","0","","","","t_772wv@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
"안전진단팀","0","","","","t_875hr@samaneng.com","Y","N","Y","Y","","","구조부(t_772wv@samaneng.com)"
"철도본부","0","","","","t_772tf@samaneng.com","Y","N","Y","Y","","",""
"철도1부","0","","","","t_879yn@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
"철도2부","0","","","","t_025sm@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
"환경평가부","0","","","","t_974cd@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
"물환경본부","0","","","","t_857zu@samaneng.com","Y","N","Y","Y","","",""
"물환경1부","0","","","","t_881eq@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
"물환경2부","0","","","","t_308je@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
"물환경3부","0","","","","t_187qk@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
"수자원본부","0","","","","t_415tw@samaneng.com","Y","N","Y","Y","","",""
"수자원1부","0","","","","t_237op@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
"수자원2부","0","","","","t_989os@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
"수력부","0","","","","t_175zq@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
"해외사업본부","0","","","","t_436jd@samaneng.com","Y","N","Y","Y","","",""
"해외사업부","0","","","","t_099um@samaneng.com","Y","N","Y","Y","","","해외사업본부(t_436jd@samaneng.com)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 기술개발센터 1 tdc@samaneng.com N N N Y
3 경영전략본부 0 business-strategy@samaneng.com Y N Y Y
4 기획부 1 변역근(ykbyun@samaneng.com) planning@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
5 업무팀 0 t_226wn@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
6 PQ팀 0 t_978bl@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
7 재무회계팀 0 t_186qz@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
8 대외협력팀 0 t_466et@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
9 인사총무부 0 t_784bn@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
10 네이버웍스관리용 1 슈퍼관리자(su-@samaneng.com) su1@samaneng.com N N N Y
11 자산경영실 0 t_563wl@samaneng.com Y N Y Y
12 안전품질관리실 0 t_793co@samaneng.com Y N Y Y
13 사업개발실 0 t_468yk@samaneng.com Y N Y Y
14 CM본부 0 t_838vr@samaneng.com Y N Y Y
15 CM사업부 0 t_205ud@samaneng.com Y N Y Y CM본부(t_838vr@samaneng.com)
16 호남지역총괄본부 0 t_143ep@samaneng.com Y N Y Y CM사업부(t_205ud@samaneng.com)
17 플랜트본부 0 t_009bl@samaneng.com Y N Y Y
18 플랜트1부 0 t_595bv@samaneng.com Y N Y Y 플랜트본부(t_009bl@samaneng.com)
19 플랜트2부 0 t_677ei@samaneng.com Y N Y Y 플랜트본부(t_009bl@samaneng.com)
20 항만부 0 t_446wi@samaneng.com Y N Y Y 플랜트본부(t_009bl@samaneng.com)
21 국토개발본부 0 t_405cl@samaneng.com Y N Y Y
22 도시계획부 0 t_403or@samaneng.com Y N Y Y 국토개발본부(t_405cl@samaneng.com)
23 도시개발부 0 t_733kg@samaneng.com Y N Y Y 국토개발본부(t_405cl@samaneng.com)
24 조경레저부 0 t_931rr@samaneng.com Y N Y Y 국토개발본부(t_405cl@samaneng.com)
25 도로본부 0 t_402qv@samaneng.com Y N Y Y
26 도로부 0 t_560mk@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
27 지반터널부 0 t_918nd@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
28 교통계획부 0 t_879qs@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
29 구조부 0 t_772wv@samaneng.com Y N Y Y 도로본부(t_402qv@samaneng.com)
30 안전진단팀 0 t_875hr@samaneng.com Y N Y Y 구조부(t_772wv@samaneng.com)
31 철도본부 0 t_772tf@samaneng.com Y N Y Y
32 철도1부 0 t_879yn@samaneng.com Y N Y Y 철도본부(t_772tf@samaneng.com)
33 철도2부 0 t_025sm@samaneng.com Y N Y Y 철도본부(t_772tf@samaneng.com)
34 환경평가부 0 t_974cd@samaneng.com Y N Y Y 철도본부(t_772tf@samaneng.com)
35 물환경본부 0 t_857zu@samaneng.com Y N Y Y
36 물환경1부 0 t_881eq@samaneng.com Y N Y Y 물환경본부(t_857zu@samaneng.com)
37 물환경2부 0 t_308je@samaneng.com Y N Y Y 물환경본부(t_857zu@samaneng.com)
38 물환경3부 0 t_187qk@samaneng.com Y N Y Y 물환경본부(t_857zu@samaneng.com)
39 수자원본부 0 t_415tw@samaneng.com Y N Y Y
40 수자원1부 0 t_237op@samaneng.com Y N Y Y 수자원본부(t_415tw@samaneng.com)
41 수자원2부 0 t_989os@samaneng.com Y N Y Y 수자원본부(t_415tw@samaneng.com)
42 수력부 0 t_175zq@samaneng.com Y N Y Y 수자원본부(t_415tw@samaneng.com)
43 해외사업본부 0 t_436jd@samaneng.com Y N Y Y
44 해외사업부 0 t_099um@samaneng.com Y N Y Y 해외사업본부(t_436jd@samaneng.com)

View File

@@ -0,0 +1,44 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"기술개발센터","1","","","","tech-dev-center@samaneng.com","N","N","N","Y","","",""
"경영전략본부","0","","","","business-strategy@samaneng.com","Y","N","Y","Y","","",""
"기획부","1","","","","planning@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"업무팀","0","","","","operations@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"PQ팀","0","","","","pq-team@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"재무회계팀","0","","","","finance@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"대외협력팀","0","","","","external-relations@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
"인사총무부","0","","","","hr-admin@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
"네이버웍스관리용","1","","","","nw-admin-saman@samaneng.com","N","N","N","Y","","",""
"자산경영실","0","","","","asset-management@samaneng.com","Y","N","Y","Y","","",""
"안전품질관리실","0","","","","safety-quality@samaneng.com","Y","N","Y","Y","","",""
"사업개발실","0","","","","business-development@samaneng.com","Y","N","Y","Y","","",""
"CM본부","0","","","","cm-headquarters@samaneng.com","Y","N","Y","Y","","",""
"CM사업부","0","","","","cm-division@samaneng.com","Y","N","Y","Y","","","CM본부(cm-headquarters@samaneng.com)"
"호남지역총괄본부","0","","","","honam-headquarters@samaneng.com","Y","N","Y","Y","","","CM사업부(cm-division@samaneng.com)"
"플랜트본부","0","","","","plant-headquarters@samaneng.com","Y","N","Y","Y","","",""
"플랜트1부","0","","","","plant-1@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
"플랜트2부","0","","","","plant-2@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
"항만부","0","","","","harbor@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
"국토개발본부","0","","","","land-development@samaneng.com","Y","N","Y","Y","","",""
"도시계획부","0","","","","urban-planning@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
"도시개발부","0","","","","urban-development@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
"조경레저부","0","","","","landscape-leisure@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
"도로본부","0","","","","road-headquarters@samaneng.com","Y","N","Y","Y","","",""
"도로부","0","","","","road@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"지반터널부","0","","","","geotech-tunnel@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"교통계획부","0","","","","transport-planning@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"구조부","0","","","","structures@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
"안전진단팀","0","","","","safety-inspection@samaneng.com","Y","N","Y","Y","","","구조부(structures@samaneng.com)"
"철도본부","0","","","","railway-headquarters@samaneng.com","Y","N","Y","Y","","",""
"철도1부","0","","","","railway-1@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
"철도2부","0","","","","railway-2@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
"환경평가부","0","","","","environment-assessment@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
"물환경본부","0","","","","water-environment-hq@samaneng.com","Y","N","Y","Y","","",""
"물환경1부","0","","","","water-environment-1@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
"물환경2부","0","","","","water-environment-2@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
"물환경3부","0","","","","water-environment-3@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
"수자원본부","0","","","","water-resources-hq@samaneng.com","Y","N","Y","Y","","",""
"수자원1부","0","","","","water-resources-1@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
"수자원2부","0","","","","water-resources-2@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
"수력부","0","","","","hydropower@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
"해외사업본부","0","","","","overseas-headquarters@samaneng.com","Y","N","Y","Y","","",""
"해외사업부","0","","","","overseas-business@samaneng.com","Y","N","Y","Y","","","해외사업본부(overseas-headquarters@samaneng.com)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 기술개발센터 1 tech-dev-center@samaneng.com N N N Y
3 경영전략본부 0 business-strategy@samaneng.com Y N Y Y
4 기획부 1 planning@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
5 업무팀 0 operations@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
6 PQ팀 0 pq-team@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
7 재무회계팀 0 finance@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
8 대외협력팀 0 external-relations@samaneng.com Y N Y Y 기획부(planning@samaneng.com)
9 인사총무부 0 hr-admin@samaneng.com Y N Y Y 경영전략본부(business-strategy@samaneng.com)
10 네이버웍스관리용 1 nw-admin-saman@samaneng.com N N N Y
11 자산경영실 0 asset-management@samaneng.com Y N Y Y
12 안전품질관리실 0 safety-quality@samaneng.com Y N Y Y
13 사업개발실 0 business-development@samaneng.com Y N Y Y
14 CM본부 0 cm-headquarters@samaneng.com Y N Y Y
15 CM사업부 0 cm-division@samaneng.com Y N Y Y CM본부(cm-headquarters@samaneng.com)
16 호남지역총괄본부 0 honam-headquarters@samaneng.com Y N Y Y CM사업부(cm-division@samaneng.com)
17 플랜트본부 0 plant-headquarters@samaneng.com Y N Y Y
18 플랜트1부 0 plant-1@samaneng.com Y N Y Y 플랜트본부(plant-headquarters@samaneng.com)
19 플랜트2부 0 plant-2@samaneng.com Y N Y Y 플랜트본부(plant-headquarters@samaneng.com)
20 항만부 0 harbor@samaneng.com Y N Y Y 플랜트본부(plant-headquarters@samaneng.com)
21 국토개발본부 0 land-development@samaneng.com Y N Y Y
22 도시계획부 0 urban-planning@samaneng.com Y N Y Y 국토개발본부(land-development@samaneng.com)
23 도시개발부 0 urban-development@samaneng.com Y N Y Y 국토개발본부(land-development@samaneng.com)
24 조경레저부 0 landscape-leisure@samaneng.com Y N Y Y 국토개발본부(land-development@samaneng.com)
25 도로본부 0 road-headquarters@samaneng.com Y N Y Y
26 도로부 0 road@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
27 지반터널부 0 geotech-tunnel@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
28 교통계획부 0 transport-planning@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
29 구조부 0 structures@samaneng.com Y N Y Y 도로본부(road-headquarters@samaneng.com)
30 안전진단팀 0 safety-inspection@samaneng.com Y N Y Y 구조부(structures@samaneng.com)
31 철도본부 0 railway-headquarters@samaneng.com Y N Y Y
32 철도1부 0 railway-1@samaneng.com Y N Y Y 철도본부(railway-headquarters@samaneng.com)
33 철도2부 0 railway-2@samaneng.com Y N Y Y 철도본부(railway-headquarters@samaneng.com)
34 환경평가부 0 environment-assessment@samaneng.com Y N Y Y 철도본부(railway-headquarters@samaneng.com)
35 물환경본부 0 water-environment-hq@samaneng.com Y N Y Y
36 물환경1부 0 water-environment-1@samaneng.com Y N Y Y 물환경본부(water-environment-hq@samaneng.com)
37 물환경2부 0 water-environment-2@samaneng.com Y N Y Y 물환경본부(water-environment-hq@samaneng.com)
38 물환경3부 0 water-environment-3@samaneng.com Y N Y Y 물환경본부(water-environment-hq@samaneng.com)
39 수자원본부 0 water-resources-hq@samaneng.com Y N Y Y
40 수자원1부 0 water-resources-1@samaneng.com Y N Y Y 수자원본부(water-resources-hq@samaneng.com)
41 수자원2부 0 water-resources-2@samaneng.com Y N Y Y 수자원본부(water-resources-hq@samaneng.com)
42 수력부 0 hydropower@samaneng.com Y N Y Y 수자원본부(water-resources-hq@samaneng.com)
43 해외사업본부 0 overseas-headquarters@samaneng.com Y N Y Y
44 해외사업부 0 overseas-business@samaneng.com Y N Y Y 해외사업본부(overseas-headquarters@samaneng.com)

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env sh
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"
;;
*)
mode="development"
;;
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() {
if [ ! -f package.json ] || [ ! -f package-lock.json ]; then
return 0
fi
if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum package.json package-lock.json | sha256sum | awk '{print $1}')"
else
deps_hash="$(cksum package.json package-lock.json | 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 from package-lock.json..."
npm ci
mkdir -p node_modules
printf '%s\n' "$deps_hash" > "$deps_stamp"
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"
fi
echo "Running in development mode..."
exec npm run dev -- --host 0.0.0.0

View File

@@ -0,0 +1,11 @@
id,name,type,parent_tenant_slug,slug,memo,email_domain
038326b6-954a-48a7-a85f-efd83f62b82a,한맥가족,COMPANY_GROUP,,hanmac-family,한맥가족 기본 루트 테넌트,
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
5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,총괄기획&기술개발센터,COMPANY,hanmac-family,gpdtdc,네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID, baroncs.co.kr
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,jangheon.com
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr
5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,COMPANY,baron-group,hanlla,,hanllasanup.co.kr
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,
1 id name type parent_tenant_slug slug memo email_domain
2 038326b6-954a-48a7-a85f-efd83f62b82a 한맥가족 COMPANY_GROUP hanmac-family 한맥가족 기본 루트 테넌트
3 9caf62e1-297d-4e8f-870b-61780998bbeb 삼안 COMPANY hanmac-family saman 네이버웍스 삼안 SAMAN_DOMAIN_ID samaneng.com
4 369c1843-56af-4344-9c21-0e01197ab861 한맥기술 COMPANY hanmac-family hanmac 네이버웍스 한맥 HANMAC_DOMAIN_ID hanmaceng.co.kr
5 5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee 총괄기획&기술개발센터 COMPANY hanmac-family gpdtdc 네이버웍스 총괄기획&기술개발센터 GPDTDC_DOMAIN_ID baroncs.co.kr
6 96369f12-6b66-4b2a-a916-d1c99d326f02 바론그룹 COMPANY_GROUP hanmac-family baron-group 네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID
7 c18a8284-0008-48aa-9cdf-9f47ab79a2a9 (주)장헌 COMPANY baron-group jangheon jangheon.com
8 b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 장헌산업 COMPANY baron-group jangheon-sanup jangheon.co.kr
9 5a03efd2-e62f-4243-800d-58334bf48b2f 한라산업개발 COMPANY baron-group hanlla hanllasanup.co.kr
10 e57cb22c-383e-4489-8c2f-0c5431917e86 (주)피티씨 COMPANY baron-group ptc pre-cast.co.kr
11 9607eb7b-04d2-42ab-80fe-780fe21c7e8f Personal PERSONAL personal 개인 사용자 기본 루트 테넌트

View File

@@ -0,0 +1,24 @@
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 user projection management route", () => {
const matches = matchRoutes(adminRoutes, "/system/projections/users");
expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
});
});

View File

@@ -1,52 +1,72 @@
import { createBrowserRouter } from "react-router-dom";
import type { RouteObject } 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 AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import LoginPage from "../features/auth/LoginPage";
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 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: <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: "worksmobile", element: <TenantWorksmobilePage /> },
],
},
{
path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />,
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/projections/users", element: <UserProjectionPage /> },
],
},
];
export const router = createBrowserRouter(
[
{
path: "/",
element: <AppLayout />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "dashboard", element: <DashboardPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "schema", element: <TenantSchemaPage /> },
],
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
],
},
],
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
{
future: {
v7_startTransition: true,
},
} as unknown as Parameters<typeof createBrowserRouter>[1],
adminRoutes,
// React Router v7 플래그는 Provider에서 적용합니다.
);

View File

@@ -0,0 +1,40 @@
import { useQuery } from "@tanstack/react-query";
import type * as React from "react";
import { fetchMe } from "../../lib/adminApi";
interface RoleGuardProps {
children: React.ReactNode;
roles: string[];
fallback?: React.ReactNode;
}
/**
* RoleGuard conditionally renders children based on the current user's role.
*
* Usage:
* <RoleGuard roles={['super_admin']}>
* <button>System Only Action</button>
* </RoleGuard>
*/
export function RoleGuard({
children,
roles,
fallback = null,
}: RoleGuardProps) {
const { data: profile, isLoading } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
staleTime: 5 * 60 * 1000, // 5 minutes
});
if (isLoading) return null;
const userRole = profile?.role || "user";
const hasAccess = roles.includes(userRole);
if (!hasAccess) {
return <>{fallback}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { t } from "../../lib/i18n";
const LOCALE_STORAGE_KEY = "locale";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
function resolveLocale(): Locale {
if (typeof window === "undefined") {
return "ko";
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored === "ko" || stored === "en") {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale === "ko" || pathLocale === "en") {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
return browserLang.startsWith("ko") ? "ko" : "en";
}
function LanguageSelector() {
const [locale, setLocale] = useState<Locale>(resolveLocale());
const handleChange = (next: Locale) => {
if (next === locale) {
return;
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
setLocale(next);
window.location.reload();
};
return (
<select
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"
aria-label={t("ui.common.language", "언어")}
>
<option value="ko">{t("ui.common.language_ko", "한국어")}</option>
<option value="en">{t("ui.common.language_en", "English")}</option>
</select>
);
}
export default LanguageSelector;

View File

@@ -1,148 +1,748 @@
import { useQuery } from "@tanstack/react-query";
import {
BadgeCheck,
Building2,
Key,
KeyRound,
LayoutDashboard,
Moon,
NotebookTabs,
ShieldHalf,
Sun,
Users,
Building2,
ChevronDown,
Database,
Key,
KeyRound,
LayoutDashboard,
LogOut,
Moon,
Network,
NotebookTabs,
ShieldHalf,
Sun,
User as UserIcon,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
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 { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
import { fetchMe } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";
const navItems = [
{ label: "Overview", to: "/", icon: LayoutDashboard },
{ label: "Tenant Dashboard", to: "/dashboard", icon: ShieldHalf },
{ label: "Tenants", to: "/tenants", icon: Building2 },
{ label: "Users", to: "/users", icon: Users },
{ label: "API Keys (M2M)", to: "/api-keys", icon: Key },
{ label: "Audit Logs", to: "/audit-logs", icon: NotebookTabs },
{ label: "Auth Guard", to: "/auth", icon: KeyRound },
interface NavItem {
label: string;
to: string;
icon: React.ComponentType<{ size?: number | string }>;
isExternal?: boolean;
}
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 AppLayout() {
const [theme, setTheme] = useState<"light" | "dark">(() => {
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const profileMenuRef = useRef<HTMLDivElement>(null);
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 [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({
queryKey: ["me"],
queryFn: async () => {
console.debug("[AppLayout] Fetching profile...");
try {
const data = await fetchMe();
console.debug("[AppLayout] Profile fetched successfully:", data.email);
return data;
} catch (err) {
console.error("[AppLayout] Failed to fetch profile:", err);
throw err;
}
},
enabled:
(auth.isAuthenticated && !auth.isLoading) ||
import.meta.env.MODE === "development" ||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true,
});
const navItems = React.useMemo(() => {
const items = [...staticNavItems];
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 orgfrontUrl = buildAuthenticatedOrgChartUrl(
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
);
const filteredItems = items.filter((item) => {
if (isTest) return true;
if (item.to === "/api-keys") return isSuperAdmin;
return true;
});
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
}, [theme]);
if (isSuperAdmin) {
filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants",
to: "/tenants",
icon: Building2,
});
filteredItems.splice(2, 0, {
label: "ui.admin.nav.org_chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
filteredItems.splice(4, 0, {
label: "ui.admin.nav.user_projection",
to: "/system/projections/users",
icon: Database,
});
} 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,
});
}
filteredItems.splice(
manageableCount <= 1 && profile?.tenantId ? 2 : 2,
0,
{
label: "ui.admin.nav.org_chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
},
);
} else {
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
filteredItems.splice(1, 0, {
label: "ui.admin.nav.org_chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
}
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
return filteredItems;
}, [mockRoleOverride, profile]);
const handleLogout = () => {
if (
window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?"))
) {
window.localStorage.removeItem("admin_session");
auth.removeUser();
navigate("/login");
}
};
useEffect(() => {
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
console.debug("[AppLayout] Auth state check:", {
isLoading: auth.isLoading,
isAuthenticated: auth.isAuthenticated,
isTest,
});
if (!auth.isLoading && !auth.isAuthenticated && !isTest) {
console.warn("[AppLayout] Not authenticated, redirecting to /login");
navigate("/login");
}
}, [auth.isLoading, auth.isAuthenticated, navigate]);
useEffect(() => {
if (auth.user?.access_token) {
window.localStorage.setItem("admin_session", auth.user.access_token);
}
}, [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);
}, [theme]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
profileMenuRef.current &&
!profileMenuRef.current.contains(event.target as Node)
) {
setIsProfileOpen(false);
}
};
return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]">
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6">
<div className="flex items-center gap-3 md:flex-col md:items-start">
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]">
<ShieldHalf size={20} />
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Baron
</p>
<h1 className="text-lg font-semibold">
Admin Control
</h1>
</div>
</div>
<div className="hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2">
<BadgeCheck size={14} />
Scoped to /admin
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start">
<span className="rounded-full border border-border px-3 py-1">
IDP env: prod
</span>
<span className="rounded-full border border-border px-3 py-1">
Tenant-aware headers
</span>
</div>
<div className="flex flex-col gap-1">
{navItems.map(({ label, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{label}</span>
</NavLink>
))}
</div>
</nav>
<div className="hidden space-y-2 px-5 pb-6 text-xs text-[var(--color-muted)] md:block">
<p> /admin .</p>
<p>
IDP API로만 , ·
.
</p>
</div>
</aside>
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
<div className="relative">
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
Admin Plane
</p>
<span className="text-lg font-semibold">
Tenant isolation & least privilege by default
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<button
type="button"
onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20"
aria-label="테마 전환"
>
{theme === "light" ? (
<Sun size={16} />
) : (
<Moon size={16} />
)}
{theme === "light" ? "Light" : "Dark"}
</button>
<span className="rounded-full border border-border px-3 py-2 text-muted-foreground">
Session TTL: 15m admin
</span>
</div>
</div>
</header>
<main className="px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
<RoleSwitcher />
</div>
</div>
useEffect(() => {
const maybeRenewSession = async () => {
const now = Date.now();
if (
!shouldAttemptSlidingSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
try {
await auth.signinSilent();
} catch (error) {
console.error("세션 자동 연장에 실패했습니다.", error);
} finally {
isRenewInFlightRef.current = false;
}
};
const handleUserAction = () => {
void maybeRenewSession();
};
window.addEventListener("pointerdown", handleUserAction);
window.addEventListener("keydown", handleUserAction);
return () => {
window.removeEventListener("pointerdown", handleUserAction);
window.removeEventListener("keydown", handleUserAction);
};
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
]);
useEffect(() => {
const maybeKeepSessionAlive = async () => {
const now = Date.now();
if (
!shouldAttemptUnlimitedSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
try {
await auth.signinSilent();
} catch (error) {
console.error("세션 무제한 유지 갱신에 실패했습니다.", error);
} finally {
isRenewInFlightRef.current = false;
}
};
const timer = window.setInterval(() => {
void maybeKeepSessionAlive();
}, 30_000);
void maybeKeepSessionAlive();
return () => {
window.clearInterval(timer);
};
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
]);
useEffect(() => {
const routeKey = `${location.pathname}${location.search}${location.hash}`;
if (lastVisitedRouteRef.current === null) {
lastVisitedRouteRef.current = routeKey;
return;
}
if (lastVisitedRouteRef.current === routeKey) {
return;
}
lastVisitedRouteRef.current = routeKey;
const now = Date.now();
if (
!shouldAttemptSlidingSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
void auth
.signinSilent()
.catch((error) => {
console.error("세션 자동 연장에 실패했습니다.", error);
})
.finally(() => {
isRenewInFlightRef.current = false;
});
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
location.hash,
location.pathname,
location.search,
]);
const toggleTheme = () => {
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 handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
window.localStorage.setItem("baron_session_expiry_enabled", String(next));
return next;
});
};
if (auth.isLoading) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary/30 border-t-primary" />
</div>
);
}
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);
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 min-w-0">
<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">
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.admin.header.plane", "ADMIN PLANE")}
</p>
<span className="text-lg font-semibold">
{t("ui.admin.header.subtitle", "Manage your organization")}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<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"
aria-label={t("ui.common.theme_toggle", "테마 전환")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
{theme === "light"
? t("ui.common.theme_light", "Light")
: 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>
) : null}
<div className="relative" ref={profileMenuRef}>
<button
type="button"
onClick={() => setIsProfileOpen((prev) => !prev)}
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", "계정 메뉴 열기")}
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary">
{profileInitial}
</div>
<div className="hidden min-w-0 text-left md:block">
<p className="truncate text-xs font-medium text-foreground">
{profileName}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{profileEmail}
</p>
</div>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${isProfileOpen ? "rotate-180" : ""}`}
/>
</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"
>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")}
</p>
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3">
<div>
<p className="truncate text-sm font-semibold text-foreground">
{profileName}
</p>
<p className="truncate text-xs text-muted-foreground">
{profileEmail}
</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}`,
profileRoleKey.toUpperCase(),
)}
</span>
</div>
</div>
<div className="mt-2 rounded-lg border border-border px-3 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionText
: t(
"ui.dev.session.disabled",
"세션 만료 비활성화",
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={isSessionExpiryEnabled}
onClick={handleSessionExpiryToggle}
className={[
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition",
isSessionExpiryEnabled ? "bg-primary" : "bg-muted",
].join(" ")}
>
<span
className={[
"inline-block h-5 w-5 rounded-full bg-white transition",
isSessionExpiryEnabled
? "translate-x-5"
: "translate-x-1",
].join(" ")}
/>
</button>
</div>
</div>
{profile?.manageableTenants &&
profile.manageableTenants.length > 0 ? (
<div className="mt-2 rounded-lg border border-border px-3 py-3">
<p className="mb-2 text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t(
"ui.admin.profile.manageable_tenants",
"Manageable Tenants",
)}
</p>
<div className="max-h-40 space-y-1 overflow-y-auto pr-1">
{profile.manageableTenants.map((tenant) => (
<button
key={tenant.id}
type="button"
onClick={() => {
setIsProfileOpen(false);
navigate(`/tenants/${tenant.id}`);
}}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-foreground transition hover:bg-muted/20"
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground">
{tenant.type === "USER_GROUP" ? (
<Users size={13} />
) : (
<Building2 size={13} />
)}
</div>
<div className="min-w-0">
<p className="truncate font-medium">
{tenant.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{tenant.slug}
</p>
</div>
</button>
))}
</div>
</div>
) : null}
<button
type="button"
onClick={() => {
setIsProfileOpen(false);
navigate(
`/users/${profile?.id || auth.user?.profile.sub}`,
);
}}
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>
</button>
<button
type="button"
onClick={() => {
setIsProfileOpen(false);
handleLogout();
}}
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>
</button>
</div>
) : null}
</div>
</div>
</div>
</header>
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10">
<Outlet />
</main>
<RoleSwitcher />
</div>
</div>
);
}
export default AppLayout;

View File

@@ -1,66 +1,173 @@
import React, { useState, useEffect } from 'react';
import { ChevronDown, ChevronUp, Wrench } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { t } from "../../lib/i18n";
const RoleSwitcher: React.FC = () => {
const [currentRole, setCurrentRole] = useState<string>('super_admin');
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(() => {
// localStorage에서 역할 읽기
const savedRole = window.localStorage.getItem('X-Mock-Role');
const savedRole = window.localStorage.getItem("X-Mock-Role");
const savedEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
setIsOverrideEnabled(savedEnabled);
if (savedRole) {
setCurrentRole(savedRole);
} else {
// 기본값 설정
window.localStorage.setItem('X-Mock-Role', 'super_admin');
}
}, []);
const toggleCollapse = () => {
const nextState = !isCollapsed;
setIsCollapsed(nextState);
window.localStorage.setItem("RoleSwitcher-Collapsed", String(nextState));
};
const switchRole = (role: string) => {
// localStorage 설정
window.localStorage.setItem('X-Mock-Role', role);
window.localStorage.setItem("X-Mock-Role", role);
window.localStorage.setItem("X-Mock-Role-Enabled", "true");
setCurrentRole(role);
// 페이지 새로고침하여 권한 적용
setIsOverrideEnabled(true);
window.location.reload();
};
if (import.meta.env.MODE === 'production') return null;
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: '10px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
fontSize: '12px'
}}>
<div style={{ fontWeight: 'bold', borderBottom: '1px solid #444', paddingBottom: '4px', marginBottom: '4px' }}>
🛠 DEV Role Switcher
</div>
{(['super_admin', 'tenant_admin', 'rp_admin', 'tenant_member'] as const).map(role => (
<button
key={role}
onClick={() => switchRole(role)}
<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={{
background: currentRole === role ? '#3b82f6' : '#333',
color: 'white',
border: 'none',
padding: '4px 8px',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.2s'
display: "flex",
flexDirection: "column",
gap: "6px",
marginTop: "4px",
}}
>
{role.toUpperCase().replace('_', ' ')} {currentRole === role ? '✅' : ''}
</button>
))}
<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>
);
};

View File

@@ -0,0 +1,26 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Badge } from "./badge";
describe("Badge Component", () => {
it("renders correctly with children", () => {
render(<Badge>Active</Badge>);
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("applies variant classes correctly", () => {
const { rerender } = render(<Badge variant="secondary">Secondary</Badge>);
let badge = screen.getByText("Secondary");
expect(badge).toHaveClass("bg-secondary");
rerender(<Badge variant="outline">Default</Badge>);
badge = screen.getByText("Default");
expect(badge).toHaveClass("text-foreground");
});
it("applies custom className", () => {
render(<Badge className="custom-class">Custom</Badge>);
const badge = screen.getByText("Custom");
expect(badge).toHaveClass("custom-class");
});
});

View File

@@ -0,0 +1,38 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
describe("Button Component", () => {
it("renders correctly with children", () => {
render(<Button>Click me</Button>);
expect(
screen.getByRole("button", { name: /click me/i }),
).toBeInTheDocument();
});
it("applies variant classes correctly", () => {
const { rerender } = render(<Button variant="destructive">Delete</Button>);
const button = screen.getByRole("button", { name: /delete/i });
expect(button).toHaveClass("bg-destructive");
rerender(<Button variant="outline">Cancel</Button>);
const outlineButton = screen.getByRole("button", { name: /cancel/i });
expect(outlineButton).toHaveClass("border-input");
});
it("calls onClick when clicked", async () => {
const onClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={onClick}>Click me</Button>);
await user.click(screen.getByRole("button", { name: /click me/i }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("is disabled when the disabled prop is passed", () => {
render(<Button disabled>Disabled Button</Button>);
const button = screen.getByRole("button", { name: /disabled button/i });
expect(button).toBeDisabled();
});
});

View File

@@ -0,0 +1,35 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "./card";
describe("Card Component", () => {
it("renders card structure correctly", () => {
render(
<Card>
<CardHeader>
<CardTitle>Card Title</CardTitle>
<CardDescription>Card Description</CardDescription>
</CardHeader>
<CardContent>Card Content</CardContent>
<CardFooter>Card Footer</CardFooter>
</Card>,
);
expect(screen.getByText("Card Title")).toBeInTheDocument();
expect(screen.getByText("Card Description")).toBeInTheDocument();
expect(screen.getByText("Card Content")).toBeInTheDocument();
expect(screen.getByText("Card Footer")).toBeInTheDocument();
});
it("applies custom className to Card", () => {
const { container } = render(<Card className="custom-card" />);
expect(container.firstChild).toHaveClass("custom-card");
});
});

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface CheckboxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
onCheckedChange?: (checked: boolean | "indeterminate") => void;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onCheckedChange?.(e.target.checked);
};
return (
<input
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",
className,
)}
onChange={handleChange}
ref={ref}
{...props}
/>
);
},
);
Checkbox.displayName = "Checkbox";
export { Checkbox };

View File

@@ -0,0 +1,120 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
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
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",
className,
)}
{...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;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,200 @@
"use client";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-tighter opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Input } from "./input";
describe("Input Component", () => {
it("renders correctly", () => {
render(<Input placeholder="Enter text" />);
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
});
it("handles value changes", async () => {
const onChange = vi.fn();
const user = userEvent.setup();
render(<Input placeholder="Enter text" onChange={onChange} />);
const input = screen.getByPlaceholderText("Enter text");
await user.type(input, "Hello");
expect(onChange).toHaveBeenCalled();
expect(input).toHaveValue("Hello");
});
it("is disabled when the disabled prop is passed", () => {
render(<Input disabled />);
const input = screen.getByRole("textbox");
expect(input).toBeDisabled();
});
});

View File

@@ -0,0 +1,27 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Label } from "./label";
describe("Label Component", () => {
it("renders correctly with children", () => {
render(<Label>Username</Label>);
expect(screen.getByText("Username")).toBeInTheDocument();
});
it("applies custom className", () => {
render(<Label className="custom-label">Password</Label>);
const label = screen.getByText("Password");
expect(label).toHaveClass("custom-label");
});
it("is associated with an input via htmlFor", () => {
render(
<>
<Label htmlFor="test-input">Label Text</Label>
<input id="test-input" />
</>,
);
const label = screen.getByText("Label Text");
expect(label).toHaveAttribute("for", "test-input");
});
});

View File

@@ -0,0 +1,158 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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=top]:slide-in-from-bottom-2 data-[state=bottom]:slide-in-from-top-2",
position === "popper" &&
"data-[state=open]:slide-in-from-top-2 data-[state=bottom]:translate-y-1 data-[state=left]:-translate-x-1 data-[state=right]:translate-x-1 data-[state=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-content-available-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@@ -5,7 +5,7 @@ const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<div className="relative w-full">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
@@ -69,7 +69,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-6 text-left text-xs font-bold uppercase tracking-[0.08em] text-muted-foreground align-middle",
"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}

View File

@@ -0,0 +1,87 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const TabsContext = React.createContext<{
value?: string;
onValueChange?: (value: string) => void;
}>({});
const Tabs = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
value?: string;
onValueChange?: (value: string) => void;
}
>(({ className, value, onValueChange, ...props }, ref) => {
return (
<TabsContext.Provider value={{ value, onValueChange }}>
<div ref={ref} className={cn("w-full", className)} {...props} />
</TabsContext.Provider>
);
});
Tabs.displayName = "Tabs";
const TabsList = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
{...props}
/>
));
TabsList.displayName = "TabsList";
const TabsTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & { value: string }
>(({ className, value, ...props }, ref) => {
const { value: activeValue, onValueChange } = React.useContext(TabsContext);
const isSelected = activeValue === value;
return (
<button
ref={ref}
type="button"
role="tab"
aria-selected={isSelected}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
)}
data-state={isSelected ? "active" : "inactive"}
onClick={() => onValueChange?.(value)}
{...props}
/>
);
});
TabsTrigger.displayName = "TabsTrigger";
const TabsContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { value: string }
>(({ className, value, ...props }, ref) => {
const { value: activeValue } = React.useContext(TabsContext);
const isSelected = activeValue === value;
if (!isSelected) return null;
return (
<div
ref={ref}
role="tabpanel"
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
);
});
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,35 @@
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
import { cn } from "../../lib/utils";
import { useToastState } from "./use-toast";
export function Toaster() {
const toasts = useToastState();
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-[320px]">
{toasts.map((t) => (
<div
key={t.id}
className={cn(
"flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-in slide-in-from-right-full duration-300",
t.type === "success" &&
"bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
t.type === "error" &&
"bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
t.type === "info" &&
"bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200",
)}
>
{t.type === "success" && (
<CheckCircle2 className="h-5 w-5 shrink-0" />
)}
{t.type === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
{t.type === "info" && <Info className="h-5 w-5 shrink-0" />}
<p className="text-sm font-medium leading-none">{t.message}</p>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import * as React from "react";
type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
message: string;
type: ToastType;
}
let subscribers: ((toasts: Toast[]) => void)[] = [];
let toasts: Toast[] = [];
const notify = () => {
for (const sub of subscribers) {
sub(toasts);
}
};
const toastBase = (message: string, type: ToastType = "success") => {
const id = Math.random().toString(36).substring(2, 9);
toasts = [...toasts, { id, message, type }];
notify();
setTimeout(() => {
toasts = toasts.filter((t) => t.id !== id);
notify();
}, 3000);
};
export const toast = Object.assign(toastBase, {
success: (message: string, options?: { description?: string }) => {
const finalMessage = options?.description
? `${message}
${options.description}`
: message;
toastBase(finalMessage, "success");
},
error: (message: string, options?: { description?: string }) => {
const finalMessage = options?.description
? `${message}
${options.description}`
: message;
toastBase(finalMessage, "error");
},
info: (message: string, options?: { description?: string }) => {
const finalMessage = options?.description
? `${message}
${options.description}`
: message;
toastBase(finalMessage, "info");
},
});
export const useToastState = () => {
const [state, setState] = React.useState<Toast[]>(toasts);
React.useEffect(() => {
subscribers.push(setState);
return () => {
subscribers = subscribers.filter((sub) => sub !== setState);
};
}, []);
return state;
};

View File

@@ -1,6 +1,14 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { AlertCircle, Check, ChevronLeft, Copy, Loader2, Save, ShieldCheck } from "lucide-react";
import {
AlertCircle,
Check,
ChevronLeft,
Copy,
Loader2,
Save,
ShieldCheck,
} from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router-dom";
@@ -13,24 +21,69 @@ import {
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
type ApiKeyCreateRequest,
type ApiKeyCreateResponse,
createApiKey,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { createApiKey, type ApiKeyCreateRequest, type ApiKeyCreateResponse } from "../../lib/adminApi";
const AVAILABLE_SCOPES = [
{ id: "audit:read", label: "감사 로그 조회", desc: "시스템 내의 모든 이력을 조회할 수 있습니다." },
{ id: "audit:write", label: "감사 로그 생성", desc: "외부 앱의 로그를 Baron SSO로 전송합니다." },
{ id: "user:read", label: "사용자 조회", desc: "사용자 목록 및 프로필을 읽을 수 있습니다." },
{ id: "user:write", label: "사용자 관리", desc: "사용자 생성, 수정, 삭제 작업을 수행합니다." },
{ id: "tenant:read", label: "테넌트 조회", desc: "등록된 모든 조직 정보를 조회합니다." },
{ id: "tenant:write", label: "테넌트 관리", desc: "테넌트 정보를 직접 제어합니다." },
{
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: "테넌트 정보를 직접 제어합니다.",
},
];
function ApiKeyCreatePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [createdResult, setCreatedResult] = React.useState<ApiKeyCreateResponse | null>(null);
const [selectedScopes, setSelectedScopes] = React.useState<string[]>(["audit:read", "user:read"]);
const [createdResult, setCreatedResult] =
React.useState<ApiKeyCreateResponse | null>(null);
const [selectedScopes, setSelectedScopes] = React.useState<string[]>([
"audit:read",
"user:read",
]);
const {
register,
@@ -47,19 +100,29 @@ function ApiKeyCreatePage() {
setCreatedResult(data);
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "API 키 생성에 실패했습니다.");
setError(
err.response?.data?.error ||
t("msg.admin.api_keys.create.error", "API 키 생성에 실패했습니다."),
);
},
});
const toggleScope = (scopeId: string) => {
setSelectedScopes((prev) =>
prev.includes(scopeId) ? prev.filter((s) => s !== scopeId) : [...prev, scopeId]
prev.includes(scopeId)
? prev.filter((s) => s !== scopeId)
: [...prev, scopeId],
);
};
const onSubmit = (data: { name: string }) => {
if (selectedScopes.length === 0) {
setError("최소 하나 이상의 권한을 선택해야 합니다.");
setError(
t(
"msg.admin.api_keys.create.scope_required",
"최소 하나 이상의 권한을 선택해야 합니다.",
),
);
return;
}
setError(null);
@@ -77,9 +140,24 @@ function ApiKeyCreatePage() {
<div className="mx-auto w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center">
<ShieldCheck size={32} />
</div>
<h2 className="text-3xl font-bold tracking-tight">API </h2>
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.api_keys.create.success.title", "API 키 생성 완료")}
</h2>
<p className="text-muted-foreground">
(Secret) <span className="text-destructive font-bold"> </span> .
{t(
"msg.admin.api_keys.create.success.notice",
"아래의 비밀번호(Secret)는 보안을 위해 ",
)}
<span className="text-destructive font-bold">
{t(
"msg.admin.api_keys.create.success.notice_emphasis",
"지금 한 번만",
)}
</span>{" "}
{t(
"msg.admin.api_keys.create.success.notice_suffix",
"표시됩니다.",
)}
</p>
</div>
@@ -87,7 +165,10 @@ function ApiKeyCreatePage() {
<CardHeader className="bg-primary/5 border-b">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<AlertCircle size={16} className="text-primary" />
릿
{t(
"ui.admin.api_keys.create.success.copy_secret",
"보안 시크릿 복사",
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-8 pb-8 space-y-6">
@@ -96,14 +177,14 @@ function ApiKeyCreatePage() {
X-Baron-Key-Secret
</Label>
<div className="relative group">
<Input
readOnly
value={createdResult.clientSecret}
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
<Input
readOnly
value={createdResult.clientSecret}
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
/>
<Button
variant="ghost"
size="icon"
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-primary/10"
onClick={() => handleCopy(createdResult.clientSecret)}
>
@@ -111,13 +192,21 @@ function ApiKeyCreatePage() {
</Button>
</div>
<p className="text-[11px] text-center text-muted-foreground italic">
( ) .
{t(
"msg.admin.api_keys.create.success.copy_hint",
"복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.",
)}
</p>
</div>
<div className="pt-4 flex flex-col gap-2">
<Button size="lg" className="w-full font-bold" asChild>
<Link to="/api-keys">. </Link>
<Link to="/api-keys">
{t(
"ui.admin.api_keys.create.success.go_list",
"저장했습니다. 목록으로 이동",
)}
</Link>
</Button>
</div>
</CardContent>
@@ -130,12 +219,24 @@ function ApiKeyCreatePage() {
<div className="max-w-3xl mx-auto space-y-10">
<header className="flex items-center justify-between">
<div className="space-y-1">
<Button variant="ghost" size="sm" className="-ml-3 text-muted-foreground" onClick={() => navigate("/api-keys")}>
<Button
variant="ghost"
size="sm"
className="-ml-3 text-muted-foreground"
onClick={() => navigate("/api-keys")}
>
<ChevronLeft size={16} className="mr-1" />
{t("ui.common.back", "돌아가기")}
</Button>
<h2 className="text-3xl font-bold tracking-tight"> API </h2>
<p className="text-muted-foreground"> .</p>
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.api_keys.create.title", "새 API 키 생성")}
</h2>
<p className="text-muted-foreground">
{t(
"msg.admin.api_keys.create.subtitle",
"내부 시스템 연동을 위한 보안 인증 키를 구성합니다.",
)}
</p>
</div>
</header>
@@ -143,20 +244,41 @@ function ApiKeyCreatePage() {
{/* 섹션 1: 이름 설정 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">1</span>
<h3 className="font-semibold text-lg"> </h3>
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
1
</span>
<h3 className="font-semibold text-lg">
{t("ui.admin.api_keys.create.section_name", "키 이름 지정")}
</h3>
</div>
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium"> </Label>
<Label htmlFor="name" className="text-sm font-medium">
{t(
"ui.admin.api_keys.create.name_label",
"서비스 또는 목적 식별 이름",
)}
</Label>
<Input
id="name"
placeholder="예: Jenkins-CI, Grafana-Dashboard"
placeholder={t(
"ui.admin.api_keys.create.name_placeholder",
"예: Jenkins-CI, Grafana-Dashboard",
)}
className="text-base py-5"
{...register("name", { required: "이름은 필수입니다." })}
{...register("name", {
required: t(
"msg.admin.api_keys.create.name_required",
"이름은 필수입니다.",
),
})}
/>
{errors.name && <p className="text-sm text-destructive mt-1">{errors.name.message}</p>}
{errors.name && (
<p className="text-sm text-destructive mt-1">
{errors.name.message}
</p>
)}
</div>
</CardContent>
</Card>
@@ -165,8 +287,15 @@ function ApiKeyCreatePage() {
{/* 섹션 2: 권한 선택 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">2</span>
<h3 className="font-semibold text-lg"> (Scopes) </h3>
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
2
</span>
<h3 className="font-semibold text-lg">
{t(
"ui.admin.api_keys.create.section_scopes",
"권한 범위(Scopes) 선택",
)}
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{AVAILABLE_SCOPES.map((scope) => {
@@ -178,24 +307,39 @@ function ApiKeyCreatePage() {
onClick={() => toggleScope(scope.id)}
className={cn(
"flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all",
isSelected
? "border-primary bg-primary/5 shadow-md"
: "border-border bg-card hover:border-muted-foreground/30"
isSelected
? "border-primary bg-primary/5 shadow-md"
: "border-border bg-card hover:border-muted-foreground/30",
)}
>
<div className="flex items-center justify-between w-full">
<span className={cn("font-bold text-sm", isSelected ? "text-primary" : "")}>{scope.label}</span>
<div className={cn(
"h-5 w-5 rounded-md flex items-center justify-center border",
isSelected ? "bg-primary border-primary" : "border-muted-foreground/30"
)}>
{isSelected && <Check size={12} className="text-primary-foreground" />}
<span
className={cn(
"font-bold text-sm",
isSelected ? "text-primary" : "",
)}
>
{t(scope.labelKey, scope.labelFallback)}
</span>
<div
className={cn(
"h-5 w-5 rounded-md flex items-center justify-center border",
isSelected
? "bg-primary border-primary"
: "border-muted-foreground/30",
)}
>
{isSelected && (
<Check size={12} className="text-primary-foreground" />
)}
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{scope.desc}
{t(scope.descKey, scope.descFallback)}
</p>
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">ID: {scope.id}</code>
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">
ID: {scope.id}
</code>
</button>
);
})}
@@ -210,15 +354,26 @@ function ApiKeyCreatePage() {
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="flex items-center justify-between p-6 bg-muted/30 rounded-2xl border">
<div>
<p className="text-sm font-bold"> {selectedScopes.length} .</p>
<p className="text-xs text-muted-foreground"> .</p>
<p className="text-sm font-bold">
{t(
"msg.admin.api_keys.create.scopes_count",
"총 {{count}}개의 권한이 할당됩니다.",
{ count: selectedScopes.length },
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.api_keys.create.scopes_hint",
"생성 즉시 활성화되어 사용 가능합니다.",
)}
</p>
</div>
<Button
onClick={handleSubmit(onSubmit)}
size="lg"
<Button
onClick={handleSubmit(onSubmit)}
size="lg"
className="px-8 font-bold shadow-lg shadow-primary/20"
disabled={mutation.isPending}
>
@@ -227,7 +382,7 @@ function ApiKeyCreatePage() {
) : (
<Save className="mr-2 h-5 w-5" />
)}
API
{t("ui.admin.api_keys.create.submit", "API 키 발급하기")}
</Button>
</div>
</div>
@@ -236,4 +391,4 @@ function ApiKeyCreatePage() {
);
}
export default ApiKeyCreatePage;
export default ApiKeyCreatePage;

View File

@@ -20,6 +20,7 @@ import {
TableRow,
} from "../../components/ui/table";
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
function ApiKeyListPage() {
const query = useQuery({
@@ -37,30 +38,42 @@ function ApiKeyListPage() {
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "API 키 목록 조회에 실패했습니다." : null;
!errorMsg && query.isError
? t(
"msg.admin.api_keys.list.fetch_error",
"API 키 목록 조회에 실패했습니다.",
)
: null;
const items = query.data?.items ?? [];
const handleDelete = (id: string, name: string) => {
if (!window.confirm(`API 키 "${name}"를 삭제할까요?`)) {
if (
!window.confirm(
t(
"msg.admin.api_keys.list.delete_confirm",
'API 키 "{{name}}"를 삭제할까요?',
{ name },
),
)
) {
return;
}
deleteMutation.mutate(id);
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-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">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>API Keys</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold">API (M2M)</h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
(Machine-to-Machine) API
.
{t(
"msg.admin.api_keys.list.subtitle",
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
@@ -70,99 +83,128 @@ function ApiKeyListPage() {
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/api-keys/new">
<Plus size={16} />
API
{t("ui.admin.api_keys.list.add", "API 키 생성")}
</Link>
</Button>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<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>API Key Registry</CardTitle>
<CardTitle>
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
</CardTitle>
<CardDescription>
{query.data?.total ?? 0} API
{t(
"msg.admin.apikeys.registry.count",
"총 {{count}}개의 활성 키가 등록되어 있습니다.",
{ count: query.data?.items?.length ?? 0 },
)}
</CardDescription>
</div>
<Badge variant="muted">System</Badge>
</CardHeader>
<CardContent>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
{errorMsg ?? fallbackError}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>CLIENT ID</TableHead>
<TableHead>SCOPES</TableHead>
<TableHead>LAST USED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}> ...</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}> API .</TableCell>
</TableRow>
)}
{items.map((key) => (
<TableRow key={key.id}>
<TableCell className="font-semibold">
<div className="flex items-center gap-2">
<Key size={14} className="text-[var(--color-muted)]" />
{key.name}
</div>
</TableCell>
<TableCell>
<code>{key.client_id}</code>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{key.scopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="text-[10px]"
<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.api_keys.list.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}>
{t(
"msg.admin.api_keys.list.empty",
"등록된 API 키가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{items.map((key) => (
<TableRow key={key.id}>
<TableCell className="font-semibold">
<div className="flex items-center gap-2">
<Key
size={14}
className="text-[var(--color-muted)]"
/>
{key.name}
</div>
</TableCell>
<TableCell>
<code>{key.client_id}</code>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{key.scopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="text-[10px]"
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
: t("ui.common.never", "Never")}
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
: "Never"}
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,55 @@
import { ShieldHalf } from "lucide-react";
import { useEffect } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
function AuthCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
useEffect(() => {
console.debug("[AuthCallbackPage] State:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
error: auth.error,
});
if (auth.isAuthenticated) {
// Save token to localStorage for existing API clients that might still use it
const user = auth.user;
if (user?.access_token) {
window.localStorage.setItem("admin_session", user.access_token);
}
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
"returnTo" in auth.user.state &&
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/";
console.info(
"[AuthCallbackPage] Auth successful, navigating to",
returnTo,
);
navigate(returnTo, { replace: true });
} else if (auth.error) {
console.error("[AuthCallbackPage] Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [auth.isAuthenticated, auth.error, navigate, auth.user, auth.isLoading]);
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-lg animate-pulse">
<ShieldHalf size={32} />
</div>
<div className="text-lg font-semibold"> ...</div>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
</div>
);
}
export default AuthCallbackPage;

View File

@@ -1,109 +1,25 @@
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 { KeyRound } from "lucide-react";
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>
<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.
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Admin auth
</p>
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<KeyRound size={22} className="text-primary" />
</h2>
<p className="text-sm text-muted-foreground">
ReBAC .
</p>
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<ArrowRight size={16} />
<span className="text-xs uppercase tracking-[0.18em]">
TTL discipline
</span>
</div>
<h3 className="mt-2 text-xl font-semibold">
Keep admin sessions short
</h3>
<p className="text-sm text-[var(--color-muted)]">
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
with step-up MFA when critical actions (rotate secret, export logs)
happen.
</p>
</div>
</section>
</div>
<PermissionChecker />
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const returnTo = searchParams.get("returnTo") || "/";
const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => {
console.debug("[LoginPage] Auth state check:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
returnTo,
});
if (auth.isAuthenticated) {
console.info(
"[LoginPage] User is authenticated, redirecting to",
returnTo,
);
navigate(returnTo, { replace: true });
}
}, [auth.isAuthenticated, navigate, returnTo, auth.isLoading]);
useEffect(() => {
if (!shouldAutoLogin) {
return;
}
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
return;
}
autoStartedRef.current = true;
void auth.signinRedirect({
state: {
returnTo,
},
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = () => {
void auth.signinRedirect({
state: {
returnTo: "/",
},
});
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
<div className="w-full max-w-md space-y-8">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
<ShieldHalf size={32} />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
Admin Control Plane
</p>
</div>
</div>
{auth.error && (
<div className="rounded-lg bg-destructive/15 p-4 text-sm text-destructive border border-destructive/20 animate-in fade-in slide-in-from-top-1">
<div className="font-bold flex items-center gap-2 mb-1">
<ShieldHalf size={16} />
</div>
<p className="opacity-90">{auth.error.message}</p>
<Button
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;
}}
>
</Button>
</div>
)}
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2">
<LogIn size={20} className="text-primary" />
</CardTitle>
<CardDescription>
Baron (SSO) .
</CardDescription>
</CardHeader>
<CardContent className="pt-4 pb-8 space-y-3">
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={auth.isLoading}
>
{auth.isLoading ? (
<>
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
...
</>
) : (
<>
<ShieldHalf size={22} />
SSO
<ExternalLink size={16} className="opacity-50" />
</>
)}
</Button>
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
15 .
<br />
.
</p>
</CardContent>
</Card>
<div className="flex justify-center gap-4">
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
<br />
.
</p>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -0,0 +1,142 @@
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="border-primary/20 bg-[var(--color-panel)]">
<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 px-12 md:w-auto"
>
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
</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-xl font-bold">Access ALLOWED</div>
<p className="text-center text-sm opacity-80">
. (
)
</p>
</>
) : (
<>
<XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div>
<p className="text-center text-sm opacity-80">
.
</p>
</>
)}
</div>
)}
</CardContent>
</Card>
);
}
export default PermissionChecker;

View File

@@ -0,0 +1,186 @@
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 } from "../../lib/adminApi";
import AuthPage from "../auth/AuthPage";
import GlobalOverviewPage from "./GlobalOverviewPage";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
fetchAdminOverviewStats: vi.fn(async () => ({
totalTenants: 10,
oidcClients: 3,
auditEvents24h: 18,
})),
fetchTenants: vi.fn(async () => ({
items: [
{
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: 3,
})),
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,
},
],
})),
}));
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(() => {
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 summary metrics from the admin stats API", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
(await screen.findByText("전체 테넌트 수")).parentElement,
).toHaveTextContent("10");
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
"3",
);
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
"18",
);
});
it("changes the RP usage perspective and targets a permitted organization", 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.change(screen.getByLabelText("조직 검색"), {
target: { value: "개발" },
});
fireEvent.change(screen.getByLabelText("대상 조직"), {
target: { value: "org-1" },
});
await waitFor(() => {
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
days: 90,
period: "month",
tenantId: "org-1",
});
});
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
});
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,168 +1,496 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
ArrowUpRight,
Box,
BarChart3,
Database,
ShieldCheck,
Users,
} from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { type ReactNode, useMemo, useState } from "react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
type RPUsageDailyMetric,
type RPUsagePeriod,
type TenantSummary,
fetchAdminOverviewStats,
fetchAdminRPUsageDaily,
fetchTenants,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
const summaryCards = [
{
label: "Total Tenants",
value: "-",
hint: "Tenant-aware core",
icon: Users,
},
{
label: "OIDC Clients",
value: "-",
hint: "Hydra registry",
icon: ShieldCheck,
},
{
label: "Audit Events (24h)",
value: "-",
hint: "ClickHouse stream",
icon: Activity,
},
{
label: "Policy Gate",
value: "Planned",
hint: "Keto + Admin checks",
icon: Database,
},
];
type DailyPoint = {
date: string;
loginRequests: number;
otherRequests: number;
};
type SeriesSummary = {
key: string;
tenantLabel: string;
clientLabel: string;
loginRequests: number;
otherRequests: 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.tenantId}:${row.clientId}`;
const current =
bySeries.get(key) ??
({
key,
tenantLabel: row.tenantName || row.tenantId || "-",
clientLabel: row.clientName || row.clientId,
loginRequests: 0,
otherRequests: 0,
uniqueSubjects: 0,
} satisfies SeriesSummary);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
current.uniqueSubjects = Math.max(
current.uniqueSubjects,
row.uniqueSubjects,
);
bySeries.set(key, current);
}
return Array.from(bySeries.values())
.sort(
(a, b) =>
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
)
.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 OverviewMetric({
icon,
label,
value,
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm">
<span className="text-muted-foreground">{icon}</span>
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums">{value}</span>
</span>
);
}
function RPUsageMixedChart({
rows,
filters,
period,
}: {
rows: RPUsageDailyMetric[];
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-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 size={18} className="text-primary" />
<h3 className="text-base font-semibold">
/
</h3>
</div>
{filters}
</div>
{daily.length === 0 ? (
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
RP .
</div>
) : (
<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>
<g transform="translate(510 10)">
<rect
x="0"
y="3"
width="10"
height="10"
rx="2"
className="fill-sky-500/70"
/>
<text x="16" y="12" className="fill-muted-foreground text-[11px]">
</text>
<line
x1="78"
x2="98"
y1="8"
y2="8"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
/>
<text
x="104"
y="12"
className="fill-muted-foreground text-[11px]"
>
</text>
</g>
{[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>
)}
{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 items-center gap-2">
<span className="truncate font-medium">{item.clientLabel}</span>
<span className="truncate text-muted-foreground">
{item.tenantLabel}
</span>
<span className="ml-auto whitespace-nowrap tabular-nums">
{item.loginRequests.toLocaleString()} / {" "}
{item.otherRequests.toLocaleString()} / {" "}
{item.uniqueSubjects.toLocaleString()}
</span>
</div>
))}
</div>
)}
</section>
);
}
function GlobalOverviewPage() {
return (
<div className="space-y-10">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Global Overview
</p>
<h2 className="text-3xl font-semibold">
Tenant-independent control plane
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="muted">IDP: Ory primary</Badge>
<Badge variant="muted">Fallback: Descope</Badge>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map(({ label, value, hint, icon: Icon }) => (
<Card key={label} className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{label}</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Icon size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{value}</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">{hint}</p>
</CardContent>
</Card>
const [period, setPeriod] = useState<RPUsagePeriod>("day");
const [tenantSearch, setTenantSearch] = useState("");
const [selectedTenantId, setSelectedTenantId] = useState("");
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: () => fetchTenants(1000, 0),
retry: false,
});
const tenantOptions = useMemo(() => {
const term = tenantSearch.trim().toLowerCase();
return (tenantsQuery.data?.items ?? [])
.filter(
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
)
.filter((tenant) => {
if (!term) return true;
return (
tenant.name.toLowerCase().includes(term) ||
tenant.slug.toLowerCase().includes(term) ||
tenant.id.toLowerCase().includes(term)
);
});
}, [tenantSearch, tenantsQuery.data?.items]);
const usageQuery = useQuery({
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId],
queryFn: () =>
fetchAdminRPUsageDaily({
days: usageDays,
period,
tenantId: selectedTenantId || undefined,
}),
retry: false,
});
const stats = statsQuery.data;
const usageRows = usageQuery.data?.items ?? [];
const metric = (value: number | undefined) =>
value === undefined ? "-" : value.toLocaleString();
const chartFilters = (
<div className="flex flex-wrap items-center gap-2">
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
{[
["day", "일"],
["week", "주"],
["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>
))}
</div>
<input
aria-label="조직 검색"
value={tenantSearch}
onChange={(event) => setTenantSearch(event.target.value)}
placeholder="조직 검색"
className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44"
/>
<select
aria-label="대상 조직"
value={selectedTenantId}
onChange={(event) => setSelectedTenantId(event.target.value)}
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-52"
>
<option value=""> </option>
{tenantOptions.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name} ({tenant.slug})
</option>
))}
</select>
</div>
);
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl">Admin playbook</CardTitle>
<CardDescription>
, , .
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-[var(--color-muted)]">
<div className="flex items-start gap-3">
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
<ShieldCheck size={14} />
</div>
<div>
<p className="font-semibold text-foreground">
Backend-only IDP access
</p>
<p>
IDP backend를 , Hydra/Kratos
admin .
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
<Box size={14} />
</div>
<div>
<p className="font-semibold text-foreground">
Tenant isolation
</p>
<p>
Tenant , Keto
.
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl"> </CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/tenants/new">
<ArrowUpRight size={16} />
</Link>
</Button>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/audit-logs">
<ArrowUpRight size={16} />
</Link>
</Button>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/dashboard">
<ArrowUpRight size={16} />
</Link>
</Button>
</CardContent>
</Card>
return (
<div className="space-y-4 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-2xl font-semibold tracking-tight">
{t("ui.admin.overview.title", "Dashboard")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
)}
</p>
</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(stats?.totalTenants)}
/>
<OverviewMetric
icon={<ShieldCheck size={14} />}
label={t(
"ui.admin.overview.summary.oidc_clients",
"OIDC 클라이언트",
)}
value={metric(stats?.oidcClients)}
/>
</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-center justify-between gap-3">
<h3 className="text-base font-semibold">
/
</h3>
{chartFilters}
</div>
<div className="text-sm text-muted-foreground">
RP Query API . backend
`rp_usage_daily_aggregate`
.
</div>
</section>
) : (
<RPUsageMixedChart
rows={usageRows}
filters={chartFilters}
period={period}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,99 @@
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 {
fetchUserProjectionStatus,
reconcileUserProjection,
resetUserProjection,
} from "../../lib/adminApi";
import UserProjectionPage from "./UserProjectionPage";
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchUserProjectionStatus: vi.fn(async () => ({
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
})),
reconcileUserProjection: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
updatedAt: "2026-05-11T03:01:00Z",
})),
resetUserProjection: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
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);
});
it("renders projection status for super_admin", async () => {
renderPage();
expect(
await screen.findByText("사용자 Projection 관리"),
).toBeInTheDocument();
expect(
await screen.findByText("Kratos users projection"),
).toBeInTheDocument();
expect(screen.getByText("ready")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
expect(fetchUserProjectionStatus).toHaveBeenCalled();
});
it("runs reconcile and reset actions for super_admin", async () => {
renderPage();
await screen.findByText("사용자 Projection 관리");
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
await waitFor(() => {
expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
await waitFor(() => {
expect(resetUserProjection).toHaveBeenCalledTimes(1);
});
});
it("blocks non-super admins", async () => {
currentRole = "tenant_admin";
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(
screen.queryByText("사용자 Projection 관리"),
).not.toBeInTheDocument();
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,206 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Database, RefreshCw, RotateCcw } from "lucide-react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
fetchUserProjectionStatus,
reconcileUserProjection,
resetUserProjection,
} from "../../lib/adminApi";
function formatDateTime(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: "medium",
}).format(date);
}
function ProjectionStatusBadge({
ready,
status,
}: {
ready: boolean;
status: string;
}) {
if (ready) {
return <Badge variant="success">ready</Badge>;
}
if (status === "failed") {
return <Badge variant="warning">failed</Badge>;
}
return <Badge variant="secondary">{status || "not ready"}</Badge>;
}
function UserProjectionContent() {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["user-projection-status"],
queryFn: fetchUserProjectionStatus,
});
const invalidate = async () => {
await queryClient.invalidateQueries({
queryKey: ["user-projection-status"],
});
};
const reconcileMutation = useMutation({
mutationFn: reconcileUserProjection,
onSuccess: invalidate,
});
const resetMutation = useMutation({
mutationFn: resetUserProjection,
onSuccess: invalidate,
});
const handleReset = () => {
const confirmed = window.confirm(
"사용자 projection을 Kratos 기준으로 다시 구축하시겠습니까?",
);
if (confirmed) {
resetMutation.mutate();
}
};
const isWorking = reconcileMutation.isPending || resetMutation.isPending;
const actionResult = reconcileMutation.data ?? resetMutation.data;
const actionError = reconcileMutation.error ?? resetMutation.error;
return (
<main className="space-y-6 p-6 md:p-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">System</p>
<h2 className="text-2xl font-semibold tracking-tight">
Projection
</h2>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => reconcileMutation.mutate()}
disabled={isWorking}
>
<RefreshCw size={16} />
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isWorking}
>
<RotateCcw size={16} />
</Button>
</div>
</div>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
"projection 상태를 불러오지 못했습니다."}
</section>
) : null}
{actionResult ? (
<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">
{actionResult.syncedUsers} projection을 .
</section>
) : null}
{actionError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(actionError as Error)?.message || "projection 작업에 실패했습니다."}
</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 className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
<Database size={18} />
</div>
<div>
<h3 className="text-base font-semibold">Kratos users projection</h3>
<p className="text-sm text-muted-foreground">
Backend DB read model .
</p>
</div>
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground"> </div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground"></dt>
<dd className="mt-1">
<ProjectionStatusBadge
ready={data?.ready ?? false}
status={data?.status ?? "unknown"}
/>
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
Projection
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.projectedUsers ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.lastSyncedAt)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.updatedAt)}
</dd>
</div>
</dl>
)}
{data?.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>{data.lastError}</span>
</div>
) : null}
</section>
</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"> </h2>
<p className="mt-2 text-sm text-muted-foreground">
super_admin .
</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

@@ -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";
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,240 @@
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,
} 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";
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;
};
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));
});
}
export function ParentTenantSelector({
id,
label,
value,
onChange,
tenants,
noneLabel,
helpText,
excludeTenantId,
labelAction,
contextLabel,
orgChartPickerLabel,
localPickerLabel,
localTenantFilter,
}: 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="space-y-2">
<div className="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 className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
>
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
selectedTenant?.name ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</Button>
{localPickerLabel && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setLocalPickerOpen(true)}
>
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>
)}
{selectedTenant ? (
<>
<span className="text-xs text-muted-foreground">
{selectedTenant.slug} · {selectedTenant.type}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => onChange("")}
aria-label={noneLabel}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<span className="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>
)}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<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>
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<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
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>
</div>
);
}

View File

@@ -0,0 +1,729 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Crown,
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 { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
type TenantAdmin,
addTenantAdmin,
addTenantOwner,
fetchTenantAdmins,
fetchTenantOwners,
fetchUsers,
removeTenantAdmin,
removeTenantOwner,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type DialogMode = "owner" | "admin";
export function TenantAdminsAndOwnersTab() {
const auth = useAuth();
const currentUserId = auth.user?.profile.sub;
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
if (!tenantId) return null;
const ownersQuery = useQuery({
queryKey: ["tenant-owners", tenantId],
queryFn: () => fetchTenantOwners(tenantId),
enabled: !!tenantId,
});
const adminsQuery = useQuery({
queryKey: ["tenant-admins", tenantId],
queryFn: () => fetchTenantAdmins(tenantId),
enabled: !!tenantId,
});
const usersQuery = useQuery({
queryKey: ["admin-users-search", searchTerm],
queryFn: () => fetchUsers(20, 0, searchTerm),
enabled: dialogMode !== null && searchTerm.length >= 2,
});
const addOwnerMutation = useMutation({
mutationFn: (userId: string) => addTenantOwner(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-owners", tenantId],
});
const previousOwners = queryClient.getQueryData<TenantAdmin[]>([
"tenant-owners",
tenantId,
]);
// Optimistically add to the list to prevent immediate double clicks
const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) {
queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId],
(old) => {
if (!old)
return [
{ id: userId, name: addedUser.name, email: addedUser.email },
];
if (old.some((o) => o.id === userId)) return old;
return [
...old,
{ id: userId, name: addedUser.name, email: addedUser.email },
];
},
);
}
return { previousOwners };
},
onSuccess: () => {
// Delay invalidation slightly to give the backend outbox time to process
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-owners", tenantId],
});
}, 1000);
toast.success(
t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."),
);
setSearchTerm("");
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
if (context?.previousOwners) {
queryClient.setQueryData(
["tenant-owners", tenantId],
context.previousOwners,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const removeOwnerMutation = useMutation({
mutationFn: (userId: string) => removeTenantOwner(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-owners", tenantId],
});
const previousOwners = queryClient.getQueryData<TenantAdmin[]>([
"tenant-owners",
tenantId,
]);
queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId],
(old) => (old ? old.filter((o) => o.id !== userId) : []),
);
return { previousOwners };
},
onSuccess: () => {
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-owners", tenantId],
});
}, 1000);
toast.success(
t(
"msg.admin.tenants.owners.remove_success",
"소유자 권한이 회수되었습니다.",
),
);
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
if (context?.previousOwners) {
queryClient.setQueryData(
["tenant-owners", tenantId],
context.previousOwners,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const addAdminMutation = useMutation({
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-admins", tenantId],
});
const previousAdmins = queryClient.getQueryData<TenantAdmin[]>([
"tenant-admins",
tenantId,
]);
const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) {
queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId],
(old) => {
if (!old)
return [
{ id: userId, name: addedUser.name, email: addedUser.email },
];
if (old.some((a) => a.id === userId)) return old;
return [
...old,
{ id: userId, name: addedUser.name, email: addedUser.email },
];
},
);
}
return { previousAdmins };
},
onSuccess: () => {
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-admins", tenantId],
});
}, 1000);
toast.success(
t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."),
);
setSearchTerm("");
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
if (context?.previousAdmins) {
queryClient.setQueryData(
["tenant-admins", tenantId],
context.previousAdmins,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const removeAdminMutation = useMutation({
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-admins", tenantId],
});
const previousAdmins = queryClient.getQueryData<TenantAdmin[]>([
"tenant-admins",
tenantId,
]);
queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId],
(old) => (old ? old.filter((a) => a.id !== userId) : []),
);
return { previousAdmins };
},
onSuccess: () => {
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-admins", tenantId],
});
}, 1000);
toast.success(
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
);
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
if (context?.previousAdmins) {
queryClient.setQueryData(
["tenant-admins", tenantId],
context.previousAdmins,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const handleAddUser = (userId: string) => {
if (dialogMode === "owner") {
addOwnerMutation.mutate(userId);
} else if (dialogMode === "admin") {
addAdminMutation.mutate(userId);
}
};
const handleRemoveOwner = (userId: string, userName: string) => {
if (
window.confirm(
t(
"msg.admin.tenants.owners.remove_confirm",
"소유자를 삭제하시겠습니까?",
{ name: userName },
),
)
) {
removeOwnerMutation.mutate(userId);
}
};
const handleRemoveAdmin = (userId: string, userName: string) => {
if (
window.confirm(
t(
"msg.admin.tenants.admins.remove_confirm",
"관리자를 삭제하시겠습니까?",
{ name: userName },
),
)
) {
removeAdminMutation.mutate(userId);
}
};
const currentOwners = ownersQuery.data || [];
const currentAdmins = adminsQuery.data || [];
const searchResults = usersQuery.data?.items || [];
const isDialogOpen = dialogMode !== null;
const dialogTitle =
dialogMode === "owner"
? t("ui.admin.tenants.owners.dialog_title", "새 소유자 추가")
: t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가");
const dialogDescription =
dialogMode === "owner"
? t(
"ui.admin.tenants.owners.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
)
: t(
"ui.admin.tenants.admins.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
);
return (
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
{/* Owners Card */}
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<Crown className="h-6 w-6 text-yellow-500" />
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.owners.subtitle",
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("owner")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
</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">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.owners.table_name", "이름")}
</TableHead>
<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">
<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}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.owners.empty",
"등록된 소유자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentOwners.map((owner) => (
<TableRow
key={owner.id}
className="hover:bg-muted/30 transition-colors group"
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{owner.name.charAt(0)}
</div>
<span>{owner.name}</span>
</div>
</TableCell>
<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>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
{/* Admins Card */}
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.admins.subtitle",
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("admin")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
</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">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.admins.table_name", "이름")}
</TableHead>
<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">
<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}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.admins.empty",
"등록된 관리자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentAdmins.map((admin) => (
<TableRow
key={admin.id}
className="hover:bg-muted/30 transition-colors group"
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{admin.name.charAt(0)}
</div>
<span>{admin.name}</span>
</div>
</TableCell>
<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>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Common Dialog for adding users */}
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
setDialogMode(null);
setSearchTerm("");
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{dialogTitle}
</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.admins.dialog_search_placeholder",
"사용자 검색 (최소 2자)...",
)}
className="pl-10 h-11"
autoFocus
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
{searchTerm.length < 2 ? (
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p className="text-sm">
{t(
"ui.admin.tenants.admins.dialog_search_hint",
"검색어를 입력해 주세요.",
)}
</p>
</div>
) : usersQuery.isLoading ? (
<div className="p-10 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</div>
) : searchResults.length === 0 ? (
<div className="p-10 text-center text-muted-foreground">
{t(
"ui.admin.tenants.admins.dialog_no_results",
"검색 결과가 없습니다.",
)}
</div>
) : (
<div className="divide-y divide-border">
{searchResults.map((user) => {
const isAlreadyOwner = currentOwners.some(
(o) => o.id === user.id,
);
const isAlreadyAdmin = currentAdmins.some(
(a) => a.id === user.id,
);
const isAlreadyMember =
dialogMode === "owner" ? isAlreadyOwner : isAlreadyAdmin;
return (
<div
key={user.id}
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{user.name.charAt(0)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.name}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
<Button
size="sm"
variant={isAlreadyMember ? "ghost" : "outline"}
disabled={
isAlreadyMember ||
addOwnerMutation.isPending ||
addAdminMutation.isPending
}
onClick={() => handleAddUser(user.id)}
>
{isAlreadyMember ? (
<Badge variant="secondary" className="font-normal">
{dialogMode === "owner"
? t(
"ui.admin.tenants.owners.already_owner",
"이미 소유자",
)
: t(
"ui.admin.tenants.admins.already_admin",
"이미 관리자",
)}
</Badge>
) : (
<>
<Plus className="h-3 w-3 mr-1" />{" "}
{t("ui.common.add", "추가")}
</>
)}
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
export default TenantAdminsAndOwnersTab;

View File

@@ -1,9 +1,8 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -15,31 +14,111 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { createTenant } from "../../../lib/adminApi";
import { createTenant, fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import {
type ServerDomainConflict,
formatDomainConflictMessage,
} from "../utils/domainTags";
import {
ORG_UNIT_TYPE_OPTIONS,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
mergeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "../utils/orgConfig";
function TenantCreatePage() {
const navigate = useNavigate();
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [parentId, setParentId] = useState("");
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
const [orgUnitType, setOrgUnitType] = useState("");
const [visibility, setVisibility] = useState<TenantVisibility>("public");
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),
});
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 = (nextParentId: string) => {
setParentId(nextParentId);
setParentStepConfirmed(false);
};
const mutation = useMutation({
mutationFn: () =>
mutationFn: (overrideForceDomains?: string[]) =>
createTenant({
name,
type,
slug: slug || undefined,
parentId: parentId || undefined,
description: description || undefined,
status,
domains: domains
.split(",")
.map((d) => d.trim())
.filter((d) => d !== ""),
domains,
config: canConfigureHanmacOrg
? mergeTenantOrgConfig(undefined, { orgUnitType, visibility })
: 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
@@ -48,19 +127,18 @@ function TenantCreatePage() {
return (
<div className="space-y-8">
<header className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>/</span>
<span className="text-foreground">Create</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-3xl font-semibold"> </h2>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.create.title", "테넌트 생성")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
{t(
"msg.admin.tenants.create.subtitle",
"새로운 테넌트를 시스템에 등록합니다.",
)}
</p>
</div>
<Badge variant="muted">Admin only</Badge>
</div>
</header>
@@ -68,68 +146,273 @@ function TenantCreatePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} />
Tenant Profile
{t("ui.admin.tenants.create.profile.title", "Tenant Profile")}
</CardTitle>
<CardDescription>
. Slug는 .
{t(
"msg.admin.tenants.create.profile.subtitle",
"필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="tenant-slug"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)
</Label>
<Input
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder="example.com, example.kr"
/>
<p className="text-xs text-muted-foreground">
Users with these email domains will be automatically assigned to
this tenant.
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
Active
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
Inactive
</Button>
<div
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"
}
>
<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
}
/>
</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>
{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">
@@ -143,26 +426,32 @@ function TenantCreatePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles size={18} />
{t("ui.admin.tenants.create.memo.title", "정책 메모")}
</CardTitle>
<CardDescription>
Tenant Keto .
{t(
"msg.admin.tenants.create.memo.subtitle",
"Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-[var(--color-muted)]">
, .
{t(
"msg.admin.tenants.create.memo.body",
"생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.",
)}
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3">
<Button variant="outline" onClick={() => navigate("/tenants")}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => mutation.mutate()}
onClick={() => mutation.mutate(undefined)}
disabled={mutation.isPending || name.trim() === ""}
>
{t("ui.common.create", "생성")}
</Button>
</div>
</div>

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { canShowWorksmobileEntry } from "./TenantDetailPage";
describe("TenantDetailPage Worksmobile entry visibility", () => {
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
expect(
canShowWorksmobileEntry({
id: "hanmac-family-id",
slug: "hanmac-family",
parentId: undefined,
}),
).toBe(true);
expect(
canShowWorksmobileEntry({
id: "hanmac-child-id",
slug: "hanmac-family",
parentId: "root-id",
}),
).toBe(false);
expect(
canShowWorksmobileEntry({
id: "other-id",
slug: "other",
parentId: undefined,
}),
).toBe(false);
});
});

View File

@@ -1,79 +1,154 @@
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 { fetchTenant } from "../../../lib/adminApi";
import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export function canShowWorksmobileEntry(tenant?: {
id?: string;
slug?: string;
parentId?: string | null;
}) {
return tenant?.slug === "hanmac-family" && !tenant.parentId;
}
function TenantDetailPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const location = useLocation();
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId!),
enabled: !!tenantId,
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const isFederationTab = location.pathname.includes("/federation");
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const canAccessSchema =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
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">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/tenants" className="inline-flex items-center gap-2">
<ArrowLeft size={14} />
Tenants
</Link>
<span>/</span>
<span className="text-foreground">Detail</span>
<div
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>
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ?? "Loading Tenant..."}
</h2>
<p className="text-sm text-[var(--color-muted)]">
Edit tenant information or manage federation settings.
{t(
"ui.admin.tenants.detail.header_subtitle",
"테넌트 정보를 수정하거나 연동 설정을 관리합니다.",
)}
</p>
</div>
<Badge variant="muted">Admin only</Badge>
</header>
{/* Tabs */}
<div className="flex border-b">
<div className="flex border-b border-border">
<Link
to={`/tenants/${tenantId}`}
className={`px-4 py-2 text-sm font-medium ${
!isFederationTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
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"
}`}
>
Profile
{t("ui.admin.tenants.detail.tab_profile", "프로필")}
</Link>
<Link
to={`/tenants/${tenantId}/federation`}
className={`px-4 py-2 text-sm font-medium ${
isFederationTab
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
to={`/tenants/${tenantId}/permissions`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isPermissionsTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
Federation
{t("ui.admin.tenants.detail.tab_permissions", "권한")}
</Link>
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-4 py-2 text-sm font-medium ${
location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600"
: "text-gray-500 hover:text-gray-700"
to={`/tenants/${tenantId}/organization`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isOrganizationTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
Schema
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</Link>
{canAccessSchema && (
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
location.pathname.includes("/schema")
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
)}
{showWorksmobileEntry && (
<Link
to={`/tenants/${tenantId}/worksmobile`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isWorksmobileTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")}
</Link>
)}
</div>
{/* Outlet for nested routes */}
<Outlet />
<div className="animate-in fade-in duration-500">
<Outlet />
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
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 path="worksmobile" element={<div>worksmobile</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantDetailPage Worksmobile navigation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("opens Worksmobile management in the current admin route", async () => {
renderTenantDetailPage();
const link = await screen.findByRole("link", { name: /Worksmobile/i });
expect(link).toHaveAttribute(
"href",
"/tenants/hanmac-family-id/worksmobile",
);
expect(link).not.toHaveAttribute("target");
expect(link).not.toHaveAttribute("rel");
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,12 +14,31 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { toast } from "../../../components/ui/use-toast";
import {
approveTenant,
deleteTenant,
fetchTenant,
fetchTenants,
updateTenant,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import {
type ServerDomainConflict,
formatDomainConflictMessage,
} from "../utils/domainTags";
import {
ORG_UNIT_TYPE_OPTIONS,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
mergeTenantOrgConfig,
readTenantOrgConfig,
removeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "../utils/orgConfig";
import { isSeedTenant } from "../utils/protectedTenants";
export function TenantProfilePage() {
const { tenantId } = useParams<{ tenantId: string }>();
@@ -27,7 +46,9 @@ export function TenantProfilePage() {
const queryClient = useQueryClient();
if (!tenantId) {
return <div>Tenant ID is missing</div>;
return (
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
);
}
const tenantQuery = useQuery({
@@ -35,38 +56,109 @@ export function TenantProfilePage() {
queryFn: () => fetchTenant(tenantId),
});
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchTenants(1000, 0),
});
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");
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);
}
}, [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 updateMutation = useMutation({
mutationFn: () =>
updateTenant(tenantId, {
mutationFn: (overrideForceDomains?: string[]) => {
const baseConfig = tenantQuery.data?.config;
const config = canEditOrgConfig
? mergeTenantOrgConfig(baseConfig, {
orgUnitType,
visibility: tenantVisibility,
})
: removeTenantOrgConfig(baseConfig);
return updateTenant(tenantId, {
name,
type,
slug,
description: description || undefined,
status,
domains: domains
.split(",")
.map((d) => d.trim())
.filter((d) => d !== ""),
}),
parentId: parentId || undefined,
domains,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
config,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Tenant updated successfully");
toast.success(t("msg.info.saved_success", "저장되었습니다."));
},
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", "오류가 발생했습니다."),
);
},
});
@@ -75,7 +167,15 @@ export function TenantProfilePage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Tenant approved successfully");
toast.success(
t("msg.admin.tenants.approve_success", "테넌트가 승인되었습니다."),
);
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
@@ -83,6 +183,9 @@ export function TenantProfilePage() {
mutationFn: () => deleteTenant(tenantId),
onSuccess: () => {
navigate("/tenants");
toast.success(
t("msg.admin.tenants.delete_success", "테넌트가 삭제되었습니다."),
);
},
});
@@ -90,15 +193,31 @@ 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;
const handleDelete = () => {
if (window.confirm("Are you sure you want to delete this tenant?")) {
if (isProtectedSeedTenant) {
return;
}
if (
window.confirm(
t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", {
name: tenantQuery.data?.name ?? "",
}),
)
) {
deleteMutation.mutate();
}
};
const handleApprove = () => {
if (window.confirm("Approve this tenant?")) {
if (
window.confirm(
t("msg.admin.tenants.approve_confirm", "이 테넌트를 승인하시겠습니까?"),
)
) {
approveMutation.mutate();
}
};
@@ -107,9 +226,14 @@ export function TenantProfilePage() {
<>
<Card className="bg-[var(--color-panel)] mt-6">
<CardHeader>
<CardTitle>Tenant profile</CardTitle>
<CardTitle>
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription>
Changes to slug and status are applied immediately.
{t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -120,16 +244,133 @@ export function TenantProfilePage() {
)}
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span>
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<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="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
data-testid="tenant-parent-org-config-layout"
className="grid gap-4 md:grid-cols-4"
>
<div
data-testid="tenant-parent-picker-slot"
className={canEditOrgConfig ? "md:col-span-2" : "md:col-span-4"}
>
<ParentTenantSelector
id="parentId"
label={t(
"ui.admin.tenants.profile.form.parent",
"상위 테넌트 (선택)",
)}
value={parentId}
onChange={setParentId}
tenants={parentQuery.data?.items ?? []}
noneLabel={t("ui.common.none", "없음 (최상위)")}
helpText={t(
"ui.admin.tenants.profile.form.parent_help",
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
)}
excludeTenantId={tenantId}
/>
</div>
{canEditOrgConfig && (
<>
<div
data-testid="tenant-org-unit-type-slot"
className="space-y-2"
>
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.org_unit_type",
"조직 세부타입",
)}
</Label>
<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>
{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 className="text-sm font-semibold">
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<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={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>
<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">Description</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.description", "설명")}
</Label>
<Textarea
rows={3}
value={description}
@@ -138,34 +379,46 @@ export function TenantProfilePage() {
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)
{t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<Input
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={(e) => setDomains(e.target.value)}
onChange={setDomains}
tenants={parentQuery.data?.items ?? []}
currentTenantId={tenantId}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder="example.com, example.kr"
/>
<p className="text-xs text-muted-foreground">
Users with these email domains will be automatically assigned to
this tenant.
{t(
"ui.admin.tenants.profile.allowed_domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label>
<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")}
>
Active
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
Inactive
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
@@ -181,10 +434,18 @@ export function TenantProfilePage() {
<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} />
Delete
{t("ui.common.delete", "삭제")}
</Button>
<div className="flex items-center gap-2">
{status === "pending" && (
@@ -194,14 +455,14 @@ export function TenantProfilePage() {
onClick={handleApprove}
disabled={approveMutation.isPending}
>
Approve Tenant
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
</Button>
)}
<Button variant="outline" onClick={() => navigate("/tenants")}>
Cancel
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => updateMutation.mutate()}
onClick={() => updateMutation.mutate(undefined)}
disabled={
updateMutation.isPending ||
tenantQuery.isLoading ||
@@ -209,7 +470,7 @@ export function TenantProfilePage() {
}
>
<Save size={16} />
Save
{t("ui.common.save", "저장")}
</Button>
</div>
</div>

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage";
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

@@ -13,53 +13,180 @@ import {
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchTenant, updateTenant } from "../../../lib/adminApi";
import { toast } from "../../../components/ui/use-toast";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type SchemaField = {
export type SchemaFieldType =
| "text"
| "number"
| "boolean"
| "date"
| "float"
| "datetime";
export type SchemaField = {
id: string;
key: string;
label: string;
type: "text" | "number" | "boolean";
type: SchemaFieldType;
required: boolean;
adminOnly: boolean;
validation?: string;
unsigned?: boolean;
isLoginId?: boolean;
indexed?: boolean;
};
function createFieldId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function isSchemaFieldType(value: unknown): value is SchemaFieldType {
return (
value === "text" ||
value === "number" ||
value === "boolean" ||
value === "date" ||
value === "float" ||
value === "datetime"
);
}
export function normalizeSchemaField(field: unknown): SchemaField {
const source =
typeof field === "object" && field !== null
? (field as Record<string, unknown>)
: {};
const type = isSchemaFieldType(source.type) ? source.type : "text";
const isLoginId = Boolean(source.isLoginId);
return {
id: typeof source.id === "string" ? source.id : createFieldId(),
key: typeof source.key === "string" ? source.key : "",
label: typeof source.label === "string" ? source.label : "",
type,
required: Boolean(source.required),
adminOnly: Boolean(source.adminOnly),
validation: typeof source.validation === "string" ? source.validation : "",
unsigned: Boolean(source.unsigned),
isLoginId,
indexed: isLoginId || Boolean(source.indexed),
};
}
export function createSchemaField(): SchemaField {
return {
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
unsigned: false,
indexed: false,
};
}
export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
if (!tenantId) return <div>Tenant ID missing</div>;
const { data: profile, isLoading: isProfileLoading } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const canAccess =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
queryFn: () => {
if (!tenantId) throw new Error("Tenant ID is required");
return fetchTenant(tenantId);
},
enabled: !!tenantId && canAccess,
});
const [fields, setFields] = useState<SchemaField[]>([]);
useEffect(() => {
if (tenantQuery.data?.config?.userSchema) {
setFields(tenantQuery.data.config.userSchema as SchemaField[]);
const rawSchema = tenantQuery.data?.config?.userSchema;
if (Array.isArray(rawSchema)) {
setFields(rawSchema.map(normalizeSchemaField));
}
}, [tenantQuery.data]);
const updateMutation = useMutation({
mutationFn: (newFields: SchemaField[]) =>
updateTenant(tenantId, {
config: {
...tenantQuery.data?.config,
userSchema: newFields,
},
}),
mutationFn: (newFields: SchemaField[]) => {
if (!tenantId) throw new Error("Tenant ID is required");
// Remove legacy loginIdField, keep isLoginId natively in userSchema
const newConfig = { ...tenantQuery.data?.config };
newConfig.loginIdField = undefined;
newConfig.userSchema = newFields;
return updateTenant(tenantId, {
config: newConfig,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Schema updated successfully");
toast.success(
t(
"msg.admin.tenants.schema.update_success",
"스키마가 저장되었습니다.",
),
);
},
onError: (err: AxiosError<{ error?: string }>) => {
alert(err.response?.data?.error || "Failed to update schema");
toast.error(
err.response?.data?.error ||
t("msg.admin.tenants.schema.update_error", "저장에 실패했습니다."),
);
},
});
if (isProfileLoading) {
return (
<div className="p-8 text-center animate-pulse text-muted-foreground">
{t("msg.common.loading", "로딩 중...")}
</div>
);
}
if (!canAccess) {
return (
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
<h3 className="text-xl font-bold text-destructive">
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<p className="text-muted-foreground">
{t(
"msg.admin.tenants.schema.forbidden_desc",
"사용자 스키마 설정은 관리자만 접근할 수 있습니다.",
)}
</p>
</div>
);
}
if (!tenantId) {
return (
<div className="p-8 text-center text-muted-foreground">
{t("msg.admin.tenants.schema.missing_id", "테넌트 ID가 없습니다.")}
</div>
);
}
const addField = () => {
setFields([...fields, { key: "", label: "", type: "text", required: false }]);
setFields([...fields, createSchemaField()]);
};
const removeField = (index: number) => {
@@ -74,77 +201,259 @@ export function TenantSchemaPage() {
return (
<div className="space-y-6 mt-6">
<Card>
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>User Schema Extension</CardTitle>
<div className="space-y-1">
<CardTitle className="text-2xl font-bold">
{t("ui.admin.tenants.schema.title", "사용자 스키마 확장")}
</CardTitle>
<CardDescription>
Define custom attributes for users in this tenant.
{t(
"msg.admin.tenants.schema.subtitle",
"이 테넌트 사용자를 위한 커스텀 속성을 정의합니다.",
)}
</CardDescription>
</div>
<Button onClick={addField} size="sm">
<Plus size={16} className="mr-2" />
Add Field
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-6">
{fields.length === 0 && (
<div className="py-8 text-center text-muted-foreground border border-dashed rounded-md">
No custom fields defined. Click "Add Field" to begin.
<div className="py-12 text-center text-muted-foreground border border-dashed rounded-lg bg-muted/10">
{t(
"msg.admin.tenants.schema.empty",
'정의된 커스텀 필드가 없습니다. "필드 추가"를 눌러 시작하세요.',
)}
</div>
)}
{fields.map((field, index) => (
<div key={index} className="flex items-end gap-4 p-4 border rounded-md bg-muted/30">
<div className="flex-1 space-y-2">
<Label>Field Key (ID)</Label>
<Input
value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })}
placeholder="e.g. employee_id"
/>
<div
key={field.id}
className="p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
</Label>
<Input
value={field.key}
onChange={(e) =>
updateField(index, { key: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.key_placeholder",
"예: employee_id",
)}
className="h-10"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.label", "표시 라벨")}
</Label>
<Input
value={field.label}
onChange={(e) =>
updateField(index, { label: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.label_placeholder",
"예: 사번",
)}
className="h-10"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.type", "유형")}
</Label>
<select
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 (isSchemaFieldType(nextType)) {
updateField(index, {
type: nextType,
isLoginId:
nextType === "text" ? field.isLoginId : false,
indexed:
nextType === "text"
? field.indexed || field.isLoginId || false
: field.indexed,
});
}
}}
>
<option value="text">
{t(
"ui.admin.tenants.schema.field.type_text",
"텍스트 (Text)",
)}
</option>
<option value="number">
{t(
"ui.admin.tenants.schema.field.type_number",
"숫자 (Integer)",
)}
</option>
<option value="float">
{t(
"ui.admin.tenants.schema.field.type_float",
"실수 (Float)",
)}
</option>
<option value="boolean">
{t(
"ui.admin.tenants.schema.field.type_boolean",
"불리언 (Boolean)",
)}
</option>
<option value="date">
{t(
"ui.admin.tenants.schema.field.type_date",
"날짜 (Date)",
)}
</option>
<option value="datetime">
{t(
"ui.admin.tenants.schema.field.type_datetime",
"일시 (DateTime)",
)}
</option>
</select>
</div>
</div>
<div className="flex-1 space-y-2">
<Label>Display Label</Label>
<Input
value={field.label}
onChange={(e) => updateField(index, { label: e.target.value })}
placeholder="e.g. 사번"
/>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={field.required}
onChange={(e) =>
updateField(index, { required: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium">
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={field.adminOnly}
onChange={(e) =>
updateField(index, { adminOnly: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium">
{t(
"ui.admin.tenants.schema.field.admin_only",
"관리자 전용",
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={field.isLoginId || false}
onChange={(e) =>
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"
/>
<span className="text-sm font-medium text-blue-600">
{t(
"ui.admin.tenants.schema.field.is_login_id",
"로그인 ID로 사용",
)}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
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
type="checkbox"
checked={field.unsigned}
onChange={(e) =>
updateField(index, { unsigned: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium">
{t(
"ui.admin.tenants.schema.field.unsigned",
"음수 불가",
)}
</span>
</label>
)}
</div>
<div className="space-y-2">
<Input
value={field.validation}
onChange={(e) =>
updateField(index, { validation: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.validation_placeholder",
"정규식 (예: ^[0-9]+$)",
)}
className="h-9 text-xs font-mono"
/>
</div>
<div className="flex justify-end">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-10 w-10"
onClick={() => removeField(index)}
>
<Trash2 size={18} />
</Button>
</div>
</div>
<div className="w-32 space-y-2">
<Label>Type</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm"
value={field.type}
onChange={(e) => updateField(index, { type: e.target.value as any })}
>
<option value="text">Text</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
</select>
</div>
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => removeField(index)}
>
<Trash2 size={16} />
</Button>
</div>
))}
</CardContent>
</Card>
<div className="flex justify-end">
<div className="flex justify-end pt-2">
<Button
onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading}
className="px-8 h-11"
>
<Save size={16} className="mr-2" />
Save Schema Changes
<Save size={18} className="mr-2" />
{t("ui.admin.tenants.schema.save", "변경사항 저장")}
</Button>
</div>
</div>

View File

@@ -1,6 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { Building2, Plus, ArrowRight } from "lucide-react";
import { Link, useParams, useNavigate } from "react-router-dom";
import { ArrowRight, Building2, Plus } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
@@ -9,76 +18,112 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "../../../components/ui/card";
import { Button } from "../../../components/ui/button";
import { Badge } from "../../../components/ui/badge";
import { fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantSubTenantsPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const { data, isLoading } = useQuery({
const { data } = useQuery({
queryKey: ["sub-tenants", tenantId],
queryFn: () => fetchTenants(50, 0, tenantId),
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
enabled: !!tenantId,
});
const subTenants = data?.items ?? [];
return (
<Card className="mt-6 bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<Card className="mt-6 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 className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
Sub-tenants ({subTenants.length})
{t("ui.admin.tenants.sub.title", "Sub-tenants ({{count}})", {
count: subTenants.length,
})}
</CardTitle>
<CardDescription> .</CardDescription>
<CardDescription>
{t(
"msg.admin.tenants.sub.subtitle",
"현재 테넌트 하위에 생성된 조직입니다.",
)}
</CardDescription>
</div>
<Button size="sm" asChild>
<Link to={`/tenants/new?parentId=${tenantId}`}>
<Plus size={14} className="mr-1" />
</Link>
<Link to={`/tenants/new?parentId=${tenantId}`}>
<Plus size={14} className="mr-1" />
{t("ui.admin.tenants.sub.add", "하위 테넌트 추가")}
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>SLUG</TableHead>
<TableHead>STATUS</TableHead>
<TableHead className="text-right">ACTION</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subTenants.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{subTenants.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-semibold">{t.name}</TableCell>
<TableCell className="text-xs font-mono">{t.slug}</TableCell>
<TableCell>
<Badge variant={t.status === "active" ? "default" : "secondary"}>
{t.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => navigate(`/tenants/${t.id}`)}>
<ArrowRight size={12} className="ml-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<CardContent 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.tenants.sub.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.slug", "SLUG")}
</TableHead>
<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}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.sub.empty",
"하위 테넌트가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{subTenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">
{tenant.name}
</TableCell>
<TableCell className="text-xs font-mono">
{tenant.slug}
</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active" ? "default" : "secondary"
}
>
{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>
</Table>
</div>
</div>
</CardContent>
</Card>
);

View File

@@ -1,6 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { User, Mail, Phone, ShieldCheck } from "lucide-react";
import { useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Loader2,
Mail,
MoreHorizontal,
Plus,
User,
UserMinus,
UserPlus,
} from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import {
Table,
TableBody,
@@ -9,80 +32,216 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card";
import { Badge } from "../../../components/ui/badge";
import { fetchUsers, fetchTenant } from "../../../lib/adminApi";
import { toast } from "../../../components/ui/use-toast";
import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantUsersPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const queryClient = useQueryClient();
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId!),
enabled: !!tenantId,
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const companyCode = tenantQuery.data?.slug;
const tenantSlug = tenantQuery.data?.slug;
// 해당 슬러그로 사용자 검색
const usersQuery = useQuery({
queryKey: ["users", { companyCode }],
queryFn: () => fetchUsers(100, 0, companyCode),
enabled: !!companyCode,
queryKey: ["users", { tenantSlug }],
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
enabled: !!tenantSlug,
});
const removeTenantMutation = useMutation({
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
onSuccess: () => {
toast.success(
t(
"msg.admin.tenants.members.remove_success",
"조직에서 제외되었습니다.",
),
);
usersQuery.refetch();
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.admin.tenants.members.remove_error", "제외 실패"),
);
},
});
const handleRemoveMember = (userId: string, userName: string) => {
if (!tenantSlug) return;
if (
window.confirm(
t(
"msg.admin.tenants.members.remove_confirm",
"'{{name}}'님을 이 조직에서 제외하시겠습니까?",
{ name: userName },
),
)
) {
removeTenantMutation.mutate({ userId, slug: tenantSlug });
}
};
const users = usersQuery.data?.items ?? [];
return (
<Card className="mt-6 bg-[var(--color-panel)]">
<CardHeader>
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<User size={18} className="text-primary" />
Tenant Members ({users.length})
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
count: users.length,
})}
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild className="gap-2">
<Link to={`/users?addTenant=${tenantSlug}`}>
<UserPlus size={16} />
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</Link>
</Button>
<Button size="sm" asChild className="gap-2">
<Link to={`/users/new?tenantSlug=${tenantSlug}`}>
<Plus size={16} />
{t("ui.admin.tenants.members.create_new", "신규 멤버 생성")}
</Link>
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-semibold">{user.name}</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{user.role.replace("_", " ")}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "default" : "muted"}>
{user.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<CardContent 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.tenants.members.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.email", "EMAIL")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead>
<TableHead className="w-[80px] text-right">
{t("ui.admin.tenants.members.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-20">
<div className="flex flex-col items-center gap-2">
<Loader2
className="animate-spin text-muted-foreground"
size={24}
/>
<span className="text-sm text-muted-foreground">
{t("ui.common.loading", "Loading...")}
</span>
</div>
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell
colSpan={5}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.members.empty",
"소속된 사용자가 없습니다.",
)}
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-semibold">
{user.name}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{t(
`ui.common.role.${user.role}`,
user.role.replace("_", " "),
)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "muted"
}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreHorizontal size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={`/users/${user.id}`}>
<User size={14} className="mr-2" />
{t(
"ui.admin.tenants.members.view_profile",
"상세 정보",
)}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() =>
handleRemoveMember(user.id, user.name)
}
disabled={removeTenantMutation.isPending}
>
<UserMinus size={14} className="mr-2" />
{t(
"ui.admin.tenants.members.remove",
"조직에서 제외",
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);

View File

@@ -0,0 +1,347 @@
import { describe, expect, it } from "vitest";
import {
buildWorksmobilePasswordManageUrl,
canCreateWorksmobileRow,
canOpenWorksmobilePasswordManage,
canSelectWorksmobileRow,
filterWorksmobileComparisonRows,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
getDefaultWorksmobileComparisonColumns,
getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
userFilterOptions,
} from "./TenantWorksmobilePage";
describe("TenantWorksmobilePage comparison helpers", () => {
it("summarizes comparison rows by status", () => {
const summary = summarizeWorksmobileComparison([
{ resourceType: "USER", status: "matched" },
{ resourceType: "USER", status: "missing_in_worksmobile" },
{ resourceType: "USER", status: "missing_in_baron" },
{ resourceType: "USER", status: "missing_external_key" },
{ resourceType: "USER", status: "missing_in_baron" },
]);
expect(summary).toEqual({
total: 5,
matched: 1,
missingInWorksmobile: 1,
missingInBaron: 2,
missingExternalKey: 1,
});
});
it("returns Korean labels for known comparison statuses", () => {
expect(getWorksmobileComparisonStatusLabel("matched")).toBe("일치");
expect(getWorksmobileComparisonStatusLabel("missing_in_worksmobile")).toBe(
"WORKS 없음",
);
expect(getWorksmobileComparisonStatusLabel("missing_in_baron")).toBe(
"Baron 없음",
);
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
"ex_key 없음",
);
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
"unknown_status",
);
});
it("allows WORKS creation only for Baron rows missing in WORKS", () => {
expect(
canCreateWorksmobileRow({
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "user-1",
}),
).toBe(true);
expect(
canCreateWorksmobileRow({
resourceType: "USER",
status: "missing_in_worksmobile",
}),
).toBe(false);
expect(
canCreateWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-user-1",
}),
).toBe(false);
});
it("allows selection for Baron-only, WORKS-only, and matched rows", () => {
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "user-1",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-user-1",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "matched",
baronId: "user-2",
worksmobileId: "works-user-2",
}),
).toBe(true);
});
it("does not allow selection for immutable WORKS accounts", () => {
expect(
isImmutableWorksmobileAccount({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "cyhan@samaneng.com",
worksmobileId: "works-cyhan",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "CYHAN1@HANMACENG.CO.KR",
worksmobileId: "works-cyhan1",
}),
).toBe(false);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "normal@samaneng.com",
worksmobileId: "works-normal",
}),
).toBe(true);
});
it("does not allow password management for immutable WORKS accounts", () => {
expect(
canOpenWorksmobilePasswordManage(
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "su-@samaneng.com",
worksmobileDomainId: 300285955,
worksmobileId: "works-su",
},
"works-tenant-1",
),
).toBe(false);
});
it("keeps row selection keys separate from Baron action ids", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "baron-only",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-only",
},
{
resourceType: "USER",
status: "matched",
baronId: "matched-baron",
worksmobileId: "matched-works",
},
];
const selectedKeys = rows.map(getWorksmobileRowSelectionKey);
expect(selectedKeys).toEqual([
"USER:baron:baron-only",
"USER:works:works-only",
"USER:baron:matched-baron",
]);
expect(getWorksmobileSelectedActionIds(rows, selectedKeys)).toEqual([
"baron-only",
"matched-baron",
]);
});
it("uses compact comparison columns by default", () => {
expect(getDefaultWorksmobileComparisonColumns()).toEqual({
status: true,
baronId: false,
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
manage: true,
});
});
it("filters user comparison rows by selected relationship", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "baron-only",
baronName: "Baron only",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-only",
worksmobileName: "WORKS only",
},
{
resourceType: "USER",
status: "matched",
baronId: "matched",
worksmobileId: "works-matched",
},
{
resourceType: "USER",
status: "missing_external_key",
worksmobileId: "missing-external-key",
},
];
expect(filterWorksmobileComparisonRows(rows, ["baron_only"])).toEqual([
rows[0],
]);
expect(filterWorksmobileComparisonRows(rows, ["works_only"])).toEqual([
rows[1],
rows[3],
]);
expect(filterWorksmobileComparisonRows(rows, ["matched"])).toEqual([
rows[2],
]);
expect(
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
).toEqual([rows[0], rows[1], rows[3]]);
expect(filterWorksmobileComparisonRows(rows, [])).toEqual(rows);
expect(
filterWorksmobileComparisonRows(rows, [
"baron_only",
"works_only",
"matched",
]),
).toEqual(rows);
});
it("orders user comparison filter options from Baron-only first", () => {
expect(userFilterOptions.map((option) => option.value)).toEqual([
"baron_only",
"works_only",
"matched",
]);
});
it("formats WORKS account name with level on one line", () => {
expect(
formatWorksmobilePersonName({
resourceType: "USER",
status: "matched",
worksmobileName: "홍길동",
worksmobileLevelName: "책임",
}),
).toBe("홍길동 책임");
});
it("formats WORKS organization details with task and manager status", () => {
expect(
formatWorksmobileOrgDetails({
resourceType: "USER",
status: "matched",
worksmobileTask: "기술검토",
worksmobilePrimaryOrgPositionName: "팀장",
worksmobilePrimaryOrgIsManager: true,
}),
).toEqual(["직책 팀장", "직무 기술검토", "조직장"]);
});
it("builds the WORKS admin password management URL from remote user identifiers", () => {
const url = buildWorksmobilePasswordManageUrl({
tenantId: " works-tenant-1 ",
domainId: 300285955,
userIdNo: " works-user-1 ",
});
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe(
"https://auth.worksmobile.com/integrate/password/manage",
);
expect(parsed.searchParams.get("usage")).toBe("admin");
expect(parsed.searchParams.get("targetUserTenantId")).toBe(
"works-tenant-1",
);
expect(parsed.searchParams.get("targetUserDomainId")).toBe("300285955");
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
expect(parsed.searchParams.get("accessUrl")).toBe(
"https://admin.worksmobile.com/assets/self-close.html",
);
});
it("does not open WORKS password management without required identifiers", () => {
const row = {
resourceType: "USER",
status: "matched",
worksmobileDomainId: 300285955,
worksmobileId: "works-user-1",
};
expect(canOpenWorksmobilePasswordManage(row, "works-tenant-1")).toBe(true);
expect(canOpenWorksmobilePasswordManage(row, undefined)).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, worksmobileDomainId: undefined },
"works-tenant-1",
),
).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, worksmobileId: undefined },
"works-tenant-1",
),
).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, resourceType: "GROUP" },
"works-tenant-1",
),
).toBe(false);
expect(
buildWorksmobilePasswordManageUrl({
tenantId: "works-tenant-1",
domainId: 0,
userIdNo: "works-user-1",
}),
).toBe("");
});
it("allows WORKS password management for WORKS-only user rows", () => {
expect(
canOpenWorksmobilePasswordManage(
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileDomainId: 300285955,
worksmobileId: "works-user-1",
},
"works-tenant-1",
),
).toBe(true);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
findDomainConflict,
formatDomainConflictMessage,
normalizeDomainTokens,
} from "./domainTags";
describe("domainTags", () => {
it("splits domains by comma and whitespace", () => {
expect(
normalizeDomainTokens("samaneng.com, hanmaceng.co.kr login.hmac.kr"),
).toEqual(["samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"]);
});
it("finds a domain already assigned to another tenant", () => {
const conflict = findDomainConflict("hanmaceng.co.kr", [
{
id: "tenant-1",
name: "한맥기술",
slug: "hanmac",
type: "COMPANY",
description: "",
status: "active",
domains: ["hanmaceng.co.kr"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
]);
expect(conflict?.tenant.name).toBe("한맥기술");
});
it("ignores the current tenant when checking domain conflicts", () => {
const conflict = findDomainConflict(
"hanmaceng.co.kr",
[
{
id: "tenant-1",
name: "한맥기술",
slug: "hanmac",
type: "COMPANY",
description: "",
status: "active",
domains: ["hanmaceng.co.kr"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
],
"tenant-1",
);
expect(conflict).toBeNull();
});
it("formats a duplicate domain message with tenant context", () => {
expect(
formatDomainConflictMessage({
domain: "samaneng.com",
tenantName: "한맥가족",
}),
).toBe(
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
);
});
});

View File

@@ -0,0 +1,62 @@
import type { TenantSummary } from "../../../lib/adminApi";
export type DomainConflict = {
domain: string;
tenant: TenantSummary;
};
export type ServerDomainConflict = {
domain: string;
tenantId?: string;
tenantName?: string;
tenantSlug?: string;
};
export function normalizeDomainTokens(value: string): string[] {
const seen = new Set<string>();
const tokens: string[] = [];
for (const raw of value.split(/[,\s;]+/)) {
const token = raw.trim().toLowerCase();
if (!token || seen.has(token)) {
continue;
}
seen.add(token);
tokens.push(token);
}
return tokens;
}
export function findDomainConflict(
domain: string,
tenants: TenantSummary[] = [],
currentTenantId?: string,
): DomainConflict | null {
const normalized = domain.trim().toLowerCase();
if (!normalized) {
return null;
}
for (const tenant of tenants) {
if (tenant.id === currentTenantId) {
continue;
}
const domains = tenant.domains ?? [];
if (domains.some((item) => item.trim().toLowerCase() === normalized)) {
return { domain: normalized, tenant };
}
}
return null;
}
export function formatDomainConflictMessage(
conflict: DomainConflict | ServerDomainConflict,
): string {
const tenantName =
"tenant" in conflict
? conflict.tenant.name
: conflict.tenantName ||
conflict.tenantSlug ||
conflict.tenantId ||
"다른";
return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
}

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
ORG_UNIT_TYPE_OPTIONS,
mergeTenantOrgConfig,
readTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "./orgConfig";
function tenant(
id: string,
type: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
type,
name,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
describe("tenant org config", () => {
it("allows org config only for hanmac-family descendants", () => {
const family = tenant(
"family",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
);
const saman = tenant("saman", "COMPANY", "삼안", "saman", "family");
const team = tenant("team", "USER_GROUP", "기획팀", "planning", "saman");
const outsider = tenant("outsider", "COMPANY", "외부", "outsider");
const tenants = [family, saman, team, outsider];
expect(shouldAllowHanmacOrgConfig(team, tenants)).toBe(true);
expect(shouldAllowHanmacOrgConfig(family, tenants)).toBe(false);
expect(shouldAllowHanmacOrgConfig(outsider, tenants)).toBe(false);
});
it("reads and writes tenant visibility and org unit type", () => {
expect(
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
).toEqual({ orgUnitType: "팀", visibility: "private" });
expect(
readTenantOrgConfig({ visibility: "internal", orgUnitType: "센터" }),
).toEqual({ orgUnitType: "센터", visibility: "internal" });
expect(
mergeTenantOrgConfig(
{ userSchema: [], visibility: "private", orgUnitType: "팀" },
{ orgUnitType: "", visibility: "internal" },
),
).toEqual({ userSchema: [], visibility: "internal" });
});
it("includes task-force and executive-direct org unit types", () => {
expect(ORG_UNIT_TYPE_OPTIONS).toEqual(
expect.arrayContaining(["TF", "TF팀", "임원직속"]),
);
expect(readTenantOrgConfig({ orgUnitType: "TF" }).orgUnitType).toBe("TF");
expect(readTenantOrgConfig({ orgUnitType: "TF팀" }).orgUnitType).toBe(
"TF팀",
);
expect(readTenantOrgConfig({ orgUnitType: "임원직속" }).orgUnitType).toBe(
"임원직속",
);
});
});

View File

@@ -0,0 +1,96 @@
import type { TenantSummary } from "../../../lib/adminApi";
export const ORG_UNIT_TYPE_OPTIONS = [
"실",
"팀",
"TF",
"TF팀",
"센터",
"디비전",
"셀",
"본부",
"지역본부",
"부",
"임원직속",
] as const;
export const TENANT_VISIBILITY_OPTIONS = [
{ label: "공개", value: "public" },
{ label: "내부", value: "internal" },
{ label: "비공개", value: "private" },
] as const;
export type TenantVisibility =
(typeof TENANT_VISIBILITY_OPTIONS)[number]["value"];
export type TenantOrgConfig = {
orgUnitType: string;
visibility: TenantVisibility;
};
const ORG_UNIT_TYPE_SET = new Set<string>(ORG_UNIT_TYPE_OPTIONS);
const TENANT_VISIBILITY_SET = new Set<string>(
TENANT_VISIBILITY_OPTIONS.map((option) => option.value),
);
export function shouldAllowHanmacOrgConfig(
tenant: Pick<TenantSummary, "id" | "parentId" | "slug">,
tenants: Array<Pick<TenantSummary, "id" | "parentId" | "slug">>,
) {
if (tenant.slug.toLowerCase() === "hanmac-family") return false;
const byId = new Map(tenants.map((item) => [item.id, item]));
let parentId = tenant.parentId;
const visited = new Set<string>();
while (parentId) {
if (visited.has(parentId)) return false;
visited.add(parentId);
const parent = byId.get(parentId);
if (!parent) return false;
if (parent.slug.toLowerCase() === "hanmac-family") return true;
parentId = parent.parentId;
}
return false;
}
export function readTenantOrgConfig(
config: Record<string, unknown> | undefined,
): TenantOrgConfig {
const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
const rawOrgUnitType = String(config?.orgUnitType ?? "");
return {
orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
? (rawVisibility as TenantVisibility)
: "public",
};
}
export function mergeTenantOrgConfig(
config: Record<string, unknown> | undefined,
next: TenantOrgConfig,
) {
const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
const merged = { ...rest };
merged.visibility = next.visibility;
if (next.orgUnitType) {
merged.orgUnitType = next.orgUnitType;
}
return merged;
}
export function removeTenantOrgConfig(
config: Record<string, unknown> | undefined,
) {
const {
orgUnitType: _orgUnitType,
visibility: _visibility,
...rest
} = config ?? {};
return rest;
}

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants";
describe("protectedTenants", () => {
it("marks tenants from seed-tenant.csv as protected", () => {
expect(getSeedTenantSlugs()).toEqual(
expect.arrayContaining(["hanmac-family", "personal"]),
);
expect(isSeedTenant({ slug: "hanmac-family" })).toBe(true);
expect(isSeedTenant({ slug: "normal-tenant" })).toBe(false);
});
});

View File

@@ -0,0 +1,19 @@
// Vite ?raw import는 seed CSV를 빌드 타임 상수로 번들합니다.
// eslint-disable-next-line import/no-unresolved
import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
import type { TenantSummary } from "../../../lib/adminApi";
import { parseTenantCSV } from "./tenantCsvImport";
const seedTenantSlugs = new Set(
parseTenantCSV(seedTenantCSVRaw)
.map((row) => row.slug.trim().toLowerCase())
.filter(Boolean),
);
export function isSeedTenant(tenant: Pick<TenantSummary, "slug">): boolean {
return seedTenantSlugs.has(tenant.slug.trim().toLowerCase());
}
export function getSeedTenantSlugs(): string[] {
return Array.from(seedTenantSlugs);
}

View File

@@ -0,0 +1,357 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
buildTenantImportParentOptionGroups,
buildTenantImportPreview,
inferTenantImportRootParentSlug,
parseTenantCSV,
serializeTenantImportCSV,
} from "./tenantCsvImport";
const tenants: TenantSummary[] = [
{
id: "tenant-1",
type: "COMPANY",
name: "Hanmac Technology",
slug: "hanmac",
description: "",
status: "active",
domains: ["hanmac.example.com"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "tenant-2",
type: "COMPANY",
name: "Saman Engineering",
slug: "saman",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "tenant-3",
type: "COMPANY_GROUP",
name: "Hanmac Family",
slug: "hanmac-family",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "tenant-4",
type: "ORGANIZATION",
name: "기획부",
slug: "planning",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("tenantCsvImport", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("parses tenant CSV rows with the supported import columns", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
);
expect(rows).toEqual([
{
rowNumber: 2,
tenantId: "",
name: "Hanmac Tech",
type: "COMPANY",
parentTenantId: "",
parentTenantSlug: "",
slug: "hanmac-tech",
memo: "Memo",
emailDomain: "hanmac-tech.example.com",
},
]);
});
it("puts tenant_id-less rows with exact or similar matches first", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,New Tenant,COMPANY,,new-tenant,,\n,Hanmac Tech,COMPANY,,hanmac-tech,,\n,Saman Engineering,COMPANY,,saman-copy,,\n",
);
const preview = buildTenantImportPreview(rows, tenants);
expect(preview.map((row) => row.row.name)).toEqual([
"Saman Engineering",
"Hanmac Tech",
"New Tenant",
]);
expect(preview[0].candidates[0]).toMatchObject({
tenantId: "tenant-2",
reason: "exact_name",
});
expect(preview[1].candidates[0]).toMatchObject({
tenantId: "tenant-1",
reason: "similar_name",
});
expect(preview[2].candidates).toEqual([]);
});
it("serializes selected matches by filling tenant_id before upload", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: "tenant-1",
});
expect(csv).toContain(
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com",
);
});
it("serializes create resolutions by resetting external tenant id and conflicting slug", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\nlocal-tenant-id,Hanmac Technology,COMPANY,,hanmac,Memo,hanmac.example.com\n",
);
const preview = buildTenantImportPreview(rows, tenants);
expect(preview[0].conflicts).toEqual(
expect.arrayContaining(["external_tenant_id", "slug_exists"]),
);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-new-tenant-id",
slug: "hanmac-imported",
},
});
expect(csv).toContain(
"staging-new-tenant-id,Hanmac Technology,COMPANY,,,hanmac-imported,Memo,hanmac.example.com",
);
expect(csv).not.toContain("local-tenant-id");
});
it("remaps child parent_tenant_id from source ids to selected staging ids", () => {
const rows = parseTenantCSV(
[
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain",
"local-parent-id,Parent Tenant,COMPANY,,parent-local,,",
"local-child-id,Child Tenant,ORGANIZATION,local-parent-id,child-local,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-parent-id",
slug: "parent-staging",
},
3: {
mode: "create",
tenantId: "staging-child-id",
slug: "child-staging",
},
});
expect(csv).toContain(
"staging-parent-id,Parent Tenant,COMPANY,,,parent-staging,,",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,,child-staging,,",
);
expect(csv).not.toContain("local-parent-id");
expect(csv).not.toContain("local-child-id");
});
it("parses parent_tenant_slug and remaps it to selected staging ids", () => {
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"Parent Tenant,COMPANY,,parent-slug,,",
"Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-parent-id",
slug: "parent-slug",
},
3: {
mode: "create",
tenantId: "staging-child-id",
slug: "child-slug",
},
});
expect(rows[1].parentTenantSlug).toBe("parent-slug");
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
);
});
it("keeps parent_tenant_slug in the serialized CSV as a fallback for hierarchy import", () => {
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"Parent Tenant,COMPANY,,parent-slug,,",
"Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-parent-id",
slug: "parent-slug",
},
3: {
mode: "create",
tenantId: "staging-child-id",
slug: "child-slug",
},
});
expect(csv.split("\n")[0]).toBe(
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
);
});
it("parses Naver Works organization CSV columns into tenant import rows", () => {
const rows = parseTenantCSV(
[
'"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","상위 조직"',
'"기술개발센터","1","","","","tdc@samaneng.com",""',
'"기획부","1","","","","planning@samaneng.com","기술개발센터(tdc@samaneng.com)"',
'"업무팀","0","","","","t_226wn@samaneng.com","기획부(planning@samaneng.com)"',
].join("\n"),
{ rootParentSlug: "saman" },
);
expect(rows).toMatchObject([
{
name: "기술개발센터",
type: "ORGANIZATION",
slug: "tdc",
parentTenantSlug: "saman",
},
{
name: "기획부",
type: "ORGANIZATION",
slug: "planning",
parentTenantSlug: "tdc",
},
{
name: "업무팀",
type: "ORGANIZATION",
slug: "t-226wn",
parentTenantSlug: "planning",
},
]);
});
it("infers root parent slug from an organization CSV file prefix that matches an existing slug", () => {
expect(inferTenantImportRootParentSlug("saman_org.csv", tenants)).toBe(
"saman",
);
expect(
inferTenantImportRootParentSlug("/tmp/hanmac-family_org.csv", tenants),
).toBe("hanmac-family");
expect(
inferTenantImportRootParentSlug("saman_org_slugged.csv", tenants),
).toBe("saman");
expect(inferTenantImportRootParentSlug("unknown_org.csv", tenants)).toBe(
"",
);
expect(inferTenantImportRootParentSlug("tenant-import.csv", tenants)).toBe(
"",
);
});
it("groups existing parent candidates by company group, company, and organization", () => {
const groups = buildTenantImportParentOptionGroups(tenants);
expect(groups.map((group) => group.type)).toEqual([
"COMPANY_GROUP",
"COMPANY",
"ORGANIZATION",
]);
expect(
groups.map((group) => group.tenants.map((tenant) => tenant.id)),
).toEqual([["tenant-3"], ["tenant-1", "tenant-2"], ["tenant-4"]]);
});
it("keeps generated ids stable and follows edited parent slugs for child rows", () => {
const randomUUID = vi
.fn()
.mockReturnValueOnce("parent-generated-id")
.mockReturnValueOnce("child-generated-id");
vi.stubGlobal("crypto", { randomUUID });
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: { mode: "create", slug: "tech-center" },
3: { mode: "create", slug: "structure-div" },
});
expect(csv).toContain(
"parent-generated-id,기술개발센터,ORGANIZATION,,saman,tech-center,,",
);
expect(csv).toContain(
"child-generated-id,일반구조물 div,ORGANIZATION,parent-generated-id,tech-center,structure-div,,",
);
});
it("serializes explicit parent tenant selections from the import preview", () => {
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
slug: "tech-center",
parentTenantId: "tenant-2",
parentTenantSlug: "",
},
3: {
mode: "create",
slug: "structure-div",
parentTenantSlug: "tech-center",
},
});
expect(csv).toContain("기술개발센터,ORGANIZATION,tenant-2,,tech-center,,");
expect(csv).toContain(",일반구조물 div,ORGANIZATION,");
expect(csv).toContain(",tech-center,structure-div,,");
});
});

View File

@@ -0,0 +1,592 @@
import type { TenantSummary } from "../../../lib/adminApi";
export type TenantCSVRow = {
rowNumber: number;
tenantId: string;
name: string;
type: string;
parentTenantId: string;
parentTenantSlug: string;
slug: string;
memo: string;
emailDomain: string;
};
export type TenantCSVParseOptions = {
rootParentSlug?: string;
};
type TenantCSVSourceKey = keyof TenantCSVRow | "mailingList" | "parentOrg";
export type TenantImportCandidate = {
tenantId: string;
name: string;
slug: string;
score: number;
reason: "exact_name" | "exact_slug" | "similar_name";
};
export type TenantImportPreviewRow = {
row: TenantCSVRow;
candidates: TenantImportCandidate[];
defaultTenantId: string;
defaultCreateSlug: string;
conflicts: TenantImportConflict[];
};
export type TenantImportParentOptionGroupType =
| "COMPANY_GROUP"
| "COMPANY"
| "ORGANIZATION";
export type TenantImportParentOptionGroup = {
type: TenantImportParentOptionGroupType;
tenants: TenantSummary[];
};
export type TenantImportConflict =
| "external_tenant_id"
| "slug_exists"
| "parent_tenant_id_unresolved";
export type TenantImportResolution =
| {
mode: "existing";
tenantId: string;
parentTenantId?: string;
parentTenantSlug?: string;
}
| {
mode: "create";
tenantId?: string;
slug?: string;
parentTenantId?: string;
parentTenantSlug?: string;
}
| {
mode: "skip";
};
const importHeaders = [
"tenant_id",
"name",
"type",
"parent_tenant_id",
"parent_tenant_slug",
"slug",
"memo",
"email_domain",
];
const headerAliases: Record<string, TenantCSVSourceKey> = {
id: "tenantId",
tenantid: "tenantId",
tenant_id: "tenantId",
name: "name",
: "name",
type: "type",
parentid: "parentTenantId",
parent_id: "parentTenantId",
parenttenantid: "parentTenantId",
parent_tenant_id: "parentTenantId",
parenttenantslug: "parentTenantSlug",
parent_tenant_slug: "parentTenantSlug",
_조직: "parentOrg",
slug: "slug",
memo: "memo",
description: "memo",
: "memo",
_리스트: "mailingList",
"email-domain": "emailDomain",
emaildomain: "emailDomain",
email_domain: "emailDomain",
domain: "emailDomain",
domains: "emailDomain",
};
export function parseTenantCSV(
text: string,
options: TenantCSVParseOptions = {},
): TenantCSVRow[] {
const records = parseCSV(text.replace(/^\uFEFF/, ""));
if (records.length === 0) return [];
const header = new Map<TenantCSVSourceKey, number>();
records[0].forEach((column, index) => {
const normalized = normalizeHeader(column);
const key = headerAliases[normalized];
if (key) header.set(key, index);
});
const isOrgChartCSV = header.has("mailingList") || header.has("parentOrg");
const sourceRows = records.slice(1).flatMap((record, index) => {
if (record.every((value) => value.trim() === "")) return [];
const value = (key: TenantCSVSourceKey) => {
const columnIndex = header.get(key);
if (columnIndex === undefined) return "";
return (record[columnIndex] ?? "").trim();
};
return {
raw: record,
rowNumber: index + 2,
name: value("name"),
slug: value("slug") || slugFromMailingList(value("mailingList")),
mailingList: value("mailingList"),
parentOrg: value("parentOrg"),
value,
};
});
const slugByName = new Map(
sourceRows
.filter((row) => row.name && row.slug)
.map((row) => [row.name, row.slug] as const),
);
return sourceRows.map(({ rowNumber, name, slug, parentOrg, value }) => {
const parentTenantSlug =
value("parentTenantSlug") ||
slugFromParentOrg(parentOrg, slugByName) ||
(isOrgChartCSV ? options.rootParentSlug || "" : "");
return {
rowNumber,
tenantId: value("tenantId"),
name,
type: value("type") || (isOrgChartCSV ? "ORGANIZATION" : ""),
parentTenantId: value("parentTenantId"),
parentTenantSlug,
slug,
memo: value("memo"),
emailDomain: value("emailDomain"),
};
});
}
export function inferTenantImportRootParentSlug(
fileName: string,
tenants: TenantSummary[] = [],
) {
const baseName = fileName.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
const [prefix = ""] = baseName.split("_");
if (!prefix) return "";
const existingTenant = tenants.find(
(tenant) => tenant.slug.toLowerCase() === prefix,
);
return existingTenant ? prefix : "";
}
export function buildTenantImportParentOptionGroups(
tenants: TenantSummary[],
): TenantImportParentOptionGroup[] {
const orderedTypes: TenantImportParentOptionGroupType[] = [
"COMPANY_GROUP",
"COMPANY",
"ORGANIZATION",
];
return orderedTypes
.map((type) => ({
type,
tenants: tenants.filter((tenant) => tenant.type?.toUpperCase() === type),
}))
.filter((group) => group.tenants.length > 0);
}
export function buildTenantImportPreview(
rows: TenantCSVRow[],
tenants: TenantSummary[],
): TenantImportPreviewRow[] {
return rows
.map((row) => {
const candidates = findTenantCandidates(row, tenants);
const conflicts = findTenantImportConflicts(row, tenants);
return {
row,
candidates,
conflicts,
defaultTenantId:
candidates[0] && candidates[0].score >= 0.95
? candidates[0].tenantId
: "",
defaultCreateSlug: suggestUniqueTenantSlug(
row.slug || row.name,
tenants,
),
};
})
.sort((a, b) => {
const aScore = a.candidates[0]?.score ?? 0;
const bScore = b.candidates[0]?.score ?? 0;
if (bScore !== aScore) return bScore - aScore;
return a.row.rowNumber - b.row.rowNumber;
});
}
export function serializeTenantImportCSV(
previewRows: TenantImportPreviewRow[],
selectedTenantIds: Record<number, string | TenantImportResolution>,
) {
const lines = [importHeaders];
const sortedRows = [...previewRows].sort(
(a, b) => a.row.rowNumber - b.row.rowNumber,
);
const targetTenantIds = buildTargetTenantIds(sortedRows, selectedTenantIds);
for (const preview of sortedRows) {
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
if (typeof resolution === "object" && resolution.mode === "skip") {
continue;
}
const selectedTenantId =
typeof resolution === "string"
? resolution
: resolution.mode === "existing"
? resolution.tenantId
: "";
const slug =
typeof resolution === "object" && resolution.mode === "create"
? resolution.slug || preview.defaultCreateSlug
: preview.row.slug;
const hasParentTenantIdOverride =
typeof resolution === "object" &&
Object.hasOwn(resolution, "parentTenantId");
const hasParentTenantSlugOverride =
typeof resolution === "object" &&
Object.hasOwn(resolution, "parentTenantSlug");
const sourceParentTenantSlug = hasParentTenantSlugOverride
? resolution.parentTenantSlug || ""
: preview.row.parentTenantSlug;
const parentTenantId =
typeof resolution === "object"
? hasParentTenantIdOverride
? resolution.parentTenantId || ""
: remapParentTenantId(
preview.row.parentTenantId,
sourceParentTenantSlug,
targetTenantIds,
)
: preview.row.parentTenantId;
const parentTenantSlug = remapParentTenantSlug(
sourceParentTenantSlug,
targetTenantIds,
);
const tenantId =
targetTenantIds.byRowNumber.get(preview.row.rowNumber) ??
selectedTenantId ??
preview.row.tenantId;
lines.push([
tenantId,
preview.row.name,
preview.row.type,
parentTenantId,
parentTenantSlug,
slug,
preview.row.memo,
preview.row.emailDomain,
]);
}
return `${lines.map(formatCSVRecord).join("\n")}\n`;
}
function buildTargetTenantIds(
previewRows: TenantImportPreviewRow[],
selectedTenantIds: Record<number, string | TenantImportResolution>,
) {
const byRowNumber = new Map<number, string>();
const bySourceId = new Map<string, string>();
const bySourceSlug = new Map<string, string>();
const bySourceSlugToTargetSlug = new Map<string, string>();
for (const preview of previewRows) {
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
if (typeof resolution === "object" && resolution.mode === "skip") {
continue;
}
const targetTenantId =
typeof resolution === "string"
? resolution || preview.row.tenantId
: resolution.mode === "existing"
? resolution.tenantId
: resolution.tenantId || createTenantImportId();
const targetSlug =
typeof resolution === "object" && resolution.mode === "create"
? resolution.slug || preview.defaultCreateSlug
: preview.row.slug;
if (targetTenantId) {
byRowNumber.set(preview.row.rowNumber, targetTenantId);
}
if (preview.row.tenantId) {
bySourceId.set(preview.row.tenantId, targetTenantId);
}
if (preview.row.slug) {
bySourceSlug.set(preview.row.slug.toLowerCase(), targetTenantId);
bySourceSlugToTargetSlug.set(preview.row.slug.toLowerCase(), targetSlug);
}
if (targetSlug) {
bySourceSlug.set(targetSlug.toLowerCase(), targetTenantId);
bySourceSlugToTargetSlug.set(targetSlug.toLowerCase(), targetSlug);
}
}
return { byRowNumber, bySourceId, bySourceSlug, bySourceSlugToTargetSlug };
}
function remapParentTenantId(
parentTenantId: string,
parentTenantSlug: string,
targetTenantIds: {
byRowNumber: Map<number, string>;
bySourceId: Map<string, string>;
bySourceSlug: Map<string, string>;
bySourceSlugToTargetSlug: Map<string, string>;
},
) {
if (parentTenantId) {
return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId;
}
if (parentTenantSlug) {
return (
targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? ""
);
}
return "";
}
function remapParentTenantSlug(
parentTenantSlug: string,
targetTenantIds: {
bySourceSlugToTargetSlug: Map<string, string>;
},
) {
if (!parentTenantSlug) return "";
return (
targetTenantIds.bySourceSlugToTargetSlug.get(
parentTenantSlug.toLowerCase(),
) ?? parentTenantSlug
);
}
function createTenantImportId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `00000000-0000-4000-8000-${Math.random()
.toString(16)
.slice(2, 14)
.padEnd(12, "0")}`;
}
function findTenantImportConflicts(
row: TenantCSVRow,
tenants: TenantSummary[],
): TenantImportConflict[] {
const conflicts: TenantImportConflict[] = [];
const matchingId = row.tenantId
? tenants.find((tenant) => tenant.id === row.tenantId)
: undefined;
const matchingSlug = row.slug
? tenants.find(
(tenant) => normalizeToken(tenant.slug) === normalizeToken(row.slug),
)
: undefined;
if (row.tenantId && !matchingId) {
conflicts.push("external_tenant_id");
}
if (matchingSlug && matchingSlug.id !== row.tenantId) {
conflicts.push("slug_exists");
}
if (
row.parentTenantId &&
!tenants.some((tenant) => tenant.id === row.parentTenantId)
) {
conflicts.push("parent_tenant_id_unresolved");
}
return conflicts;
}
function findTenantCandidates(
row: TenantCSVRow,
tenants: TenantSummary[],
): TenantImportCandidate[] {
return tenants
.map((tenant) => {
const nameScore = similarity(row.name, tenant.name);
const slugScore =
normalizeToken(row.slug) &&
normalizeToken(row.slug) === normalizeToken(tenant.slug)
? 0.98
: 0;
const exactName =
normalizeToken(row.name) === normalizeToken(tenant.name);
const score = exactName ? 1 : Math.max(slugScore, nameScore);
const reason: TenantImportCandidate["reason"] = exactName
? "exact_name"
: slugScore >= 0.98
? "exact_slug"
: "similar_name";
return {
tenantId: tenant.id,
name: tenant.name,
slug: tenant.slug,
score,
reason,
};
})
.filter((candidate) => candidate.score >= 0.45)
.sort((a, b) => b.score - a.score)
.slice(0, 3);
}
function parseCSV(text: string): string[][] {
const rows: string[][] = [];
let current = "";
let row: string[] = [];
let quoted = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
const next = text[i + 1];
if (char === '"' && quoted && next === '"') {
current += '"';
i += 1;
continue;
}
if (char === '"') {
quoted = !quoted;
continue;
}
if (char === "," && !quoted) {
row.push(current);
current = "";
continue;
}
if ((char === "\n" || char === "\r") && !quoted) {
if (char === "\r" && next === "\n") i += 1;
row.push(current);
rows.push(row);
row = [];
current = "";
continue;
}
current += char;
}
if (current !== "" || row.length > 0) {
row.push(current);
rows.push(row);
}
return rows;
}
function formatCSVRecord(record: string[]) {
return record
.map((value) => {
if (!/[",\r\n]/.test(value)) return value;
return `"${value.replaceAll('"', '""')}"`;
})
.join(",");
}
function normalizeHeader(value: string) {
return value.trim().toLowerCase().replaceAll(" ", "_");
}
function slugFromMailingList(value: string) {
if (!value) return "";
return normalizeTenantSlug(value.split("@")[0] ?? value);
}
function slugFromParentOrg(value: string, slugByName: Map<string, string>) {
const trimmed = value.trim();
if (!trimmed) return "";
const match = trimmed.match(/\(([^)]+)\)/);
if (match?.[1]) {
return slugFromMailingList(match[1]);
}
return slugByName.get(trimmed) ?? normalizeTenantSlug(trimmed);
}
function normalizeTenantSlug(value: string) {
let slug = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-");
slug = slug.replace(/^-+|-+$/g, "");
if (slug.length > 25) {
slug = slug.slice(0, 25).replace(/-+$/g, "");
}
return slug;
}
function normalizeToken(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "")
.replace(/[^\p{L}\p{N}]/gu, "");
}
function suggestUniqueTenantSlug(value: string, tenants: TenantSummary[]) {
const base = slugify(value) || "tenant";
const used = new Set(tenants.map((tenant) => tenant.slug.toLowerCase()));
if (!used.has(base)) {
return base;
}
let index = 2;
while (used.has(`${base}-${index}`)) {
index += 1;
}
return `${base}-${index}`;
}
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function similarity(left: string, right: string) {
const a = normalizeToken(left);
const b = normalizeToken(right);
if (!a || !b) return 0;
if (a === b) return 1;
if (a.includes(b) || b.includes(a)) {
return Math.min(a.length, b.length) / Math.max(a.length, b.length);
}
const distance = levenshtein(a, b);
return 1 - distance / Math.max(a.length, b.length);
}
function levenshtein(left: string, right: string) {
const previous = Array.from({ length: right.length + 1 }, (_, i) => i);
const current = Array.from({ length: right.length + 1 }, () => 0);
for (let i = 1; i <= left.length; i += 1) {
current[0] = i;
for (let j = 1; j <= right.length; j += 1) {
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
current[j] = Math.min(
current[j - 1] + 1,
previous[j] + 1,
previous[j - 1] + cost,
);
}
previous.splice(0, previous.length, ...current);
}
return previous[right.length];
}

View File

@@ -0,0 +1,149 @@
import { useQuery } from "@tanstack/react-query";
import { Building2, Plus, Users } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import {
type TenantSummary,
fetchGroups,
fetchTenants,
} from "../../../lib/adminApi";
export default function GlobalUserGroupListPage() {
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
queryKey: ["admin-tenants"],
queryFn: () => fetchTenants(100, 0),
});
if (isTenantsLoading)
return <div className="p-8">Loading tenants and groups...</div>;
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex items-start justify-between flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
<p className="text-muted-foreground">
.
.
</p>
</div>
</header>
<div className="grid gap-6 flex-1 overflow-auto p-1">
{tenantList?.items.map((tenant) => (
<TenantGroupCard key={tenant.id} tenant={tenant} />
))}
</div>
</div>
);
}
function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
const { data: groups, isLoading } = useQuery({
queryKey: ["tenant-user-groups", tenant.id],
queryFn: () => fetchGroups(tenant.id),
});
return (
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-xl flex items-center gap-2">
<Building2 size={20} className="text-muted-foreground" />
{tenant.name}
<Badge variant="outline" className="ml-2">
{tenant.slug}
</Badge>
</CardTitle>
<CardDescription>
.
</CardDescription>
</div>
<Button size="sm" variant="outline" asChild>
<Link to={`/tenants/${tenant.id}/user-groups`}>
<Plus size={16} className="mr-2" />
</Link>
</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">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[250px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"> </TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
Loading...
</TableCell>
</TableRow>
) : groups?.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground py-4"
>
.
</TableCell>
</TableRow>
) : (
groups?.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Users size={14} className="text-primary" />
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
className="hover:underline"
>
{group.name}
</Link>
</div>
</TableCell>
<TableCell>{group.description || "-"}</TableCell>
<TableCell>{group.members?.length || 0} </TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" asChild>
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
>
</Link>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,940 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowRight,
Briefcase,
Building2,
ChevronDown,
ChevronRight,
CornerDownRight,
ExternalLink,
FolderOpen,
LayoutDashboard,
MoreHorizontal,
Network,
Plus,
RefreshCw,
Search,
Settings,
Trash2,
UserCircle,
UserPlus,
Users,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../../components/ui/tabs";
import { toast } from "../../../components/ui/use-toast";
import {
type TenantSummary,
type UserSummary,
createUser,
fetchTenants,
fetchUsers,
updateTenant,
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
// --- Icons & Helpers ---
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
case "COMPANY_GROUP":
return Briefcase;
case "PERSONAL":
return UserCircle;
case "ORGANIZATION":
case "USER_GROUP":
return Network;
default:
return Building2;
}
};
// --- Components ---
const SidebarNode: React.FC<{
node: TenantNode;
level: number;
selectedId: string;
onSelect: (id: string) => void;
searchTerm: string;
}> = ({ node, level, selectedId, onSelect, searchTerm }) => {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children && node.children.length > 0;
const isSelected = selectedId === node.id;
const TypeIcon = getTenantIcon(node.type);
// Auto-expand on search
React.useEffect(() => {
if (searchTerm) {
const matchInDescendants = (n: TenantNode): boolean => {
return n.children.some(
(c) =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
matchInDescendants(c),
);
};
if (matchInDescendants(node)) setIsExpanded(true);
}
}, [searchTerm, node]);
const isMatching =
searchTerm && node.name.toLowerCase().includes(searchTerm.toLowerCase());
return (
<div className="flex flex-col">
<button
type="button"
className={`w-full text-left flex items-center group px-2 py-1.5 rounded-md cursor-pointer transition-colors ${
isSelected
? "bg-primary text-primary-foreground font-semibold"
: "hover:bg-muted/60 text-muted-foreground hover:text-foreground"
} ${isMatching ? "ring-1 ring-primary/30 bg-primary/5" : ""}`}
onClick={() => {
onSelect(node.id);
if (hasChildren) setIsExpanded(!isExpanded);
}}
>
<div className="flex items-center flex-1 min-w-0">
{/* Indent & Expander */}
<div style={{ width: `${level * 1.2}rem` }} className="shrink-0" />
<div className="w-5 h-5 flex items-center justify-center mr-1">
{hasChildren ? (
<span className="rounded p-0.5">
{isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</span>
) : (
level > 0 && <div className="w-1 h-1 rounded-full bg-border" />
)}
</div>
<TypeIcon
size={16}
className={`shrink-0 mr-2 ${isSelected ? "text-primary-foreground" : "text-primary/70"}`}
/>
<span className="text-sm truncate">{node.name}</span>
</div>
<Badge
variant={isSelected ? "secondary" : "outline"}
className={`ml-2 text-[10px] px-1 h-4 min-w-[1.5rem] justify-center ${
isSelected ? "" : "opacity-60"
}`}
>
{node.recursiveMemberCount}
</Badge>
</button>
{isExpanded && hasChildren && (
<div className="flex flex-col">
{node.children.map((child) => (
<SidebarNode
key={child.id}
node={child}
level={level + 1}
selectedId={selectedId}
onSelect={onSelect}
searchTerm={searchTerm}
/>
))}
</div>
)}
</div>
);
};
const MemberTable: React.FC<{
tenantSlug: string;
onRefreshTrigger?: number;
allTenants?: TenantSummary[];
}> = ({ tenantSlug, onRefreshTrigger, allTenants }) => {
const queryClient = useQueryClient();
const { data, isLoading, refetch } = useQuery({
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
enabled: !!tenantSlug,
});
const members = data?.items ?? [];
const [isMoveOpen, setIsMoveOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null);
const [targetTenantSlug, setTargetTenantSlug] = useState("");
const [searchTenant, setSearchTenant] = useState("");
const moveMutation = useMutation({
mutationFn: (newSlug: string) => {
if (!selectedUser) throw new Error("No user selected");
return updateUser(selectedUser.id, { tenantSlug: newSlug });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(
t("msg.info.saved_success", "사용자 조직이 변경되었습니다."),
);
setIsMoveOpen(false);
setSelectedUser(null);
refetch();
},
onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
});
const removeMutation = useMutation({
mutationFn: (userId: string) =>
updateUser(userId, { tenantSlug, isRemoveTenant: true }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
refetch();
},
onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
});
const handleMoveClick = (user: UserSummary) => {
setSelectedUser(user);
setTargetTenantSlug("");
setIsMoveOpen(true);
};
const filteredTenants = React.useMemo(() => {
if (!allTenants) return [];
if (!searchTenant) return allTenants;
return allTenants.filter(
(t) =>
t.name.toLowerCase().includes(searchTenant.toLowerCase()) ||
t.slug.toLowerCase().includes(searchTenant.toLowerCase()),
);
}, [allTenants, searchTenant]);
if (isLoading)
return (
<div className="py-20 text-center text-muted-foreground animate-pulse">
{t("msg.common.loading", "멤버 정보를 불러오는 중...")}
</div>
);
if (members.length === 0)
return (
<div className="py-20 flex flex-col items-center justify-center text-muted-foreground opacity-50 border-2 border-dashed rounded-lg">
<Users size={48} className="mb-4" />
<p>
{t("msg.admin.users.list.empty", "이 조직에 소속된 멤버가 없습니다.")}
</p>
</div>
);
return (
<div className="border rounded-md overflow-hidden bg-[var(--color-panel)]">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead>{t("ui.admin.users.table.name", "이름")}</TableHead>
<TableHead>{t("ui.admin.users.table.email", "이메일")}</TableHead>
<TableHead className="text-right">
{t("ui.admin.users.table.role", "역할")}
</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{members.map((user) => (
<TableRow
key={user.id}
className="hover:bg-muted/30 transition-colors"
>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-xs font-mono">{user.email}</TableCell>
<TableCell className="text-right">
<Badge
variant="secondary"
className="text-[10px] font-bold uppercase"
>
{user.role}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={`/users/${user.id}`}>
<ExternalLink size={14} className="mr-2" />
{t("ui.common.detail", "상세보기")}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleMoveClick(user)}>
<ArrowRight size={14} className="mr-2" />
{t("ui.common.move_org", "타 조직으로 이동")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (
window.confirm(
t(
"msg.admin.users.confirm_remove_org",
"이 조직에서 사용자를 제외하시겠습니까?",
),
)
) {
removeMutation.mutate(user.id);
}
}}
>
<FolderOpen size={14} className="mr-2" />
{t("ui.common.remove_org", "조직에서 제외")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog open={isMoveOpen} onOpenChange={setIsMoveOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{t("ui.common.move_org", "타 조직으로 이동")}
</DialogTitle>
<DialogDescription>
{selectedUser?.name} .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder={t("ui.common.search", "조직 검색...")}
value={searchTenant}
onChange={(e) => setSearchTenant(e.target.value)}
/>
<ScrollArea className="h-48 border rounded-md p-2">
<div className="space-y-1">
{filteredTenants.map((tItem) => (
<Button
key={tItem.id}
variant={
targetTenantSlug === tItem.slug ? "secondary" : "ghost"
}
className="w-full justify-start text-sm"
onClick={() => setTargetTenantSlug(tItem.slug)}
>
{React.createElement(getTenantIcon(tItem.type), {
size: 14,
className: "mr-2 opacity-70",
})}
<span>{tItem.name}</span>
<span className="ml-2 text-[10px] text-muted-foreground opacity-50">
{tItem.slug}
</span>
</Button>
))}
{filteredTenants.length === 0 && (
<div className="py-4 text-center text-sm text-muted-foreground">
{t("msg.common.no_results", "검색 결과가 없습니다.")}
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsMoveOpen(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => moveMutation.mutate(targetTenantSlug)}
disabled={!targetTenantSlug || moveMutation.isPending}
>
{moveMutation.isPending ? "..." : t("ui.common.move", "이동")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
// --- Main Component ---
function TenantUserGroupsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [selectedNodeId, setSelectedNodeId] = useState<string>(tenantId || "");
const [treeSearch, setTreeSearch] = useState("");
const [refreshMembersCount, setRefreshMembersCount] = useState(0);
const [isUserAddOpen, setIsUserAddOpen] = useState(false);
const [isAddExistingOpen, setIsAddExistingOpen] = useState(false);
const [existingSearch, setExistingSearch] = useState("");
// Data Fetching
const {
data: allTenantsData,
isLoading: isTenantsLoading,
refetch: refetchTree,
} = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchTenants(1000, 0),
});
const { currentBase, subTree } = useMemo(() => {
const allItems = allTenantsData?.items ?? [];
return buildTenantFullTree(allItems, tenantId);
}, [allTenantsData, tenantId]);
const selectedNode = useMemo(() => {
// Find selected node in the built tree
const findNode = (nodes: TenantNode[], id: string): TenantNode | null => {
if (!currentBase) return null;
if (currentBase.id === id) return currentBase;
for (const node of nodes) {
if (node.id === id) return node;
if (node.children.length > 0) {
const found = findNode(node.children, id);
if (found) return found;
}
}
return null;
};
if (!currentBase) return null;
return findNode(currentBase.children, selectedNodeId) || currentBase;
}, [currentBase, selectedNodeId]);
// Mutations
const updateParentMutation = useMutation({
mutationFn: ({
id,
parentId,
}: { id: string; parentId: string | undefined }) =>
updateTenant(id, { parentId: parentId || "" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(
t("msg.info.saved_success", "조직 구조가 업데이트되었습니다."),
);
setIsAddExistingOpen(false);
},
});
const handleRemoveNode = (id: string, name: string) => {
if (
window.confirm(
t(
"msg.admin.tenants.remove_sub_confirm",
`${name} 조직을 하위에서 제외할까요?`,
{ name },
),
)
) {
updateParentMutation.mutate({ id, parentId: undefined });
if (selectedNodeId === id) setSelectedNodeId(tenantId || "");
}
};
if (isTenantsLoading)
return (
<div className="p-12 text-center text-muted-foreground animate-pulse">
{t("msg.common.loading", "조직 정보를 불러오는 중...")}
</div>
);
if (!currentBase)
return (
<div className="p-12 text-center text-muted-foreground">
.
</div>
);
const candidates = (allTenantsData?.items ?? []).filter(
(t) =>
t.id !== tenantId &&
t.parentId !== tenantId &&
(existingSearch === "" ||
t.name.toLowerCase().includes(existingSearch.toLowerCase()) ||
t.slug.toLowerCase().includes(existingSearch.toLowerCase())),
);
return (
<div className="flex h-[calc(100vh-theme(spacing.32))] gap-6 mt-6 overflow-hidden">
{/* --- Left Panel: Sidebar Tree --- */}
<Card className="w-80 flex flex-col shrink-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-bold flex items-center gap-2">
<Network size={16} className="text-primary" />
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => refetchTree()}
>
<RefreshCw size={14} />
</Button>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "조직 검색...")}
className="pl-8 h-8 text-xs bg-muted/40"
value={treeSearch}
onChange={(e) => setTreeSearch(e.target.value)}
/>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-2">
<ScrollArea className="h-full pr-2">
<SidebarNode
node={currentBase}
level={0}
selectedId={selectedNodeId}
onSelect={setSelectedNodeId}
searchTerm={treeSearch}
/>
</ScrollArea>
</CardContent>
<div className="p-3 border-t bg-muted/5">
<Button
variant="outline"
size="sm"
className="w-full text-xs h-8"
onClick={() => setIsAddExistingOpen(true)}
>
<Plus size={14} className="mr-1" />
{t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")}
</Button>
</div>
</Card>
{/* --- Right Panel: Selected Node Content --- */}
<div className="flex-1 flex flex-col min-w-0">
{selectedNode ? (
<Card className="flex-1 flex flex-col border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="border-b bg-muted/5 py-4 flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
{React.createElement(getTenantIcon(selectedNode.type), {
size: 24,
})}
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl font-bold">
{selectedNode.name}
</CardTitle>
<Badge
variant="outline"
className="text-[10px] font-mono opacity-60"
>
{selectedNode.slug}
</Badge>
</div>
<div className="text-sm text-muted-foreground flex items-center gap-2 mt-0.5">
<span className="flex items-center gap-1">
<Users size={12} /> {selectedNode.recursiveMemberCount}{" "}
{t("ui.admin.tenants.table.members", "명")}
</span>
<span className="opacity-30">|</span>
<Badge variant="secondary" className="text-[10px] h-4">
{t(
`domain.tenant_type.${selectedNode.type.toLowerCase()}`,
selectedNode.type,
)}
</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsUserAddOpen(true)}
>
<UserPlus size={16} className="mr-2" />
{t("ui.admin.users.list.add", "멤버 추가")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{t("ui.common.manage", "조직 관리")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={`/tenants/${selectedNode.id}`}>
<LayoutDashboard size={14} className="mr-2" />
</Link>
</DropdownMenuItem>
{!selectedNode.parentId && (
<DropdownMenuItem asChild>
<Link to={`/tenants/new?parentId=${selectedNode.id}`}>
<Plus size={14} className="mr-2" />
{t("ui.admin.tenants.sub.add", "하위 부서 생성")}
</Link>
</DropdownMenuItem>
)}
{selectedNode.id !== tenantId && (
<DropdownMenuItem
className="text-destructive"
onClick={() =>
handleRemoveNode(selectedNode.id, selectedNode.name)
}
>
<Trash2 size={14} className="mr-2" />
{t("ui.common.remove", "조직 계층에서 제외")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
<ScrollArea className="flex-1">
<div className="p-6 space-y-8">
{selectedNode.children.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-bold flex items-center gap-2">
<Network size={16} className="text-muted-foreground" />
({selectedNode.children.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{selectedNode.children?.map((child) => (
<Card
key={child.id}
className="cursor-pointer hover:border-primary/50 transition-colors bg-muted/20"
onClick={() => setSelectedNodeId(child.id)}
>
<CardHeader className="p-4 space-y-1">
<div className="flex items-center justify-between">
<div className="p-1.5 rounded bg-background">
{React.createElement(
getTenantIcon(child.type),
{
size: 14,
className: "text-primary",
},
)}
</div>
<Badge variant="outline" className="text-[9px]">
{child.recursiveMemberCount}{" "}
{t("ui.admin.tenants.table.members", "명")}
</Badge>
</div>
<CardTitle className="text-sm">
{child.name}
</CardTitle>
<CardDescription className="text-[10px] truncate">
{child.slug}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)}
<div className="space-y-4">
<h3 className="text-sm font-bold flex items-center gap-2">
<Users size={16} className="text-muted-foreground" />
{t("ui.admin.tenants.members.list_title", "소속 멤버")}
</h3>
<MemberTable
tenantSlug={selectedNode.slug}
onRefreshTrigger={refreshMembersCount}
allTenants={allTenantsData?.items ?? []}
/>
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground opacity-30 border-2 border-dashed rounded-lg">
<div className="text-center">
<FolderOpen size={64} className="mx-auto mb-4" />
<p> .</p>
</div>
</div>
)}
</div>
{/* --- Dialogs --- */}
<UserAddDialog
tenantSlug={selectedNode?.slug || ""}
tenantName={selectedNode?.name || ""}
open={isUserAddOpen}
onOpenChange={(open) => {
setIsUserAddOpen(open);
if (!open) setRefreshMembersCount((prev) => prev + 1);
}}
/>
<Dialog open={isAddExistingOpen} onOpenChange={setIsAddExistingOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")}
</DialogTitle>
<DialogDescription>
[{currentBase.name}] .
</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="검색..."
className="pl-9"
value={existingSearch}
onChange={(e) => setExistingSearch(e.target.value)}
/>
</div>
<ScrollArea className="h-60 border rounded-md">
<Table>
<TableBody>
{candidates?.map((tenantItem) => (
<TableRow
key={tenantItem.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() =>
updateParentMutation.mutate({
id: tenantItem.id,
parentId: tenantId,
})
}
>
<TableCell>
<div className="flex items-center gap-2">
{React.createElement(getTenantIcon(tenantItem.type), {
size: 14,
className: "text-muted-foreground",
})}
<div>
<p className="text-sm font-medium">
{tenantItem.name}
</p>
<p className="text-[10px] font-mono text-muted-foreground">
{tenantItem.slug}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" className="h-7 px-2">
<Plus size={14} className="mr-1" />{" "}
{t("ui.common.add", "추가")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// --- Internal Support Components ---
const UserAddDialog: React.FC<{
tenantSlug: string;
tenantName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}> = ({ tenantSlug, tenantName, open, onOpenChange }) => {
const queryClient = useQueryClient();
const [userSearch, setUserSearch] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSearch = async () => {
if (!userSearch) return;
setIsSearching(true);
try {
const res = await fetchUsers(20, 0, userSearch);
setSearchResults(res.items);
} catch (err) {
toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패"));
} finally {
setIsSearching(false);
}
};
const handleAssign = async () => {
if (!selectedUserId) return;
setIsSubmitting(true);
try {
await updateUser(selectedUserId, { tenantSlug });
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다."));
onOpenChange(false);
resetFields();
} catch (err) {
const error = err as AxiosError<{ error?: string }>;
toast.error(
error.response?.data?.error ||
t("msg.admin.users.detail.update_error", "배정 실패"),
);
} finally {
setIsSubmitting(false);
}
};
const resetFields = () => {
setUserSearch("");
setSearchResults([]);
setSelectedUserId(null);
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
onOpenChange(v);
if (!v) resetFields();
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.create.title", "멤버 추가")}
</DialogTitle>
<DialogDescription>
[{tenantName}] .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 검색...",
)}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button
variant="secondary"
onClick={handleSearch}
disabled={isSearching}
>
<Search size={16} />
</Button>
</div>
<ScrollArea className="h-60 border rounded-md">
<Table>
<TableBody>
{searchResults?.map((user) => (
<TableRow
key={user.id}
className={`cursor-pointer hover:bg-muted/50 ${selectedUserId === user.id ? "bg-primary/5" : ""}`}
onClick={() => setSelectedUserId(user.id)}
>
<TableCell>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-[10px] text-muted-foreground">
{user.email}
</p>
</div>
{selectedUserId === user.id && (
<ChevronRight size={16} className="text-primary" />
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={handleAssign}
disabled={isSubmitting || !selectedUserId}
>
{t("ui.common.add", "배정")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default TenantUserGroupsTab;

View File

@@ -0,0 +1,620 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
addGroupMember,
assignGroupRole,
fetchGroup,
fetchGroupRoles,
fetchTenants,
fetchUsers,
removeGroupMember,
removeGroupRole,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export function UserGroupDetailPage() {
const { tenantId, id } = useParams<{ tenantId: string; id: string }>();
const queryClient = useQueryClient();
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [selectedUserId, setSelectedUserId] = useState("");
const [searchUser, setSearchUser] = useState("");
const [isAddRoleOpen, setIsAddRoleOpen] = useState(false);
const [selectedTargetTenantId, setSelectedTargetTenantId] = useState("");
const [selectedRelation, setSelectedRelation] = useState("view");
const {
data: currentGroup,
isLoading: isGroupLoading,
error,
} = useQuery({
queryKey: ["user-group-detail", id],
queryFn: () => fetchGroup(tenantId ?? "", id ?? ""),
enabled: !!id && !!tenantId,
retry: false,
});
// Fetch assigned roles
const { data: groupRoles, isLoading: isRolesLoading } = useQuery({
queryKey: ["user-group-roles", id],
queryFn: () => fetchGroupRoles(tenantId ?? "", id ?? ""),
enabled: !!id && !!tenantId,
});
// Fetch all users for selection
const { data: userList } = useQuery({
queryKey: ["admin-users", searchUser],
queryFn: () => fetchUsers(20, 0, searchUser),
enabled: isAddMemberOpen,
});
// Fetch all tenants for role assignment
const { data: tenantList } = useQuery({
queryKey: ["admin-tenants"],
queryFn: () => fetchTenants(100, 0),
enabled: isAddRoleOpen,
});
const addMemberMutation = useMutation({
mutationFn: (userId: string) =>
addGroupMember(tenantId ?? "", id ?? "", userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
setIsAddMemberOpen(false);
setSelectedUserId("");
toast.success(
t("msg.admin.groups.members.add_success", "구성원이 추가되었습니다."),
);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(
error.response?.data?.error ||
error.message ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
const removeMemberMutation = useMutation({
mutationFn: (userId: string) =>
removeGroupMember(tenantId ?? "", id ?? "", userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
toast.success(
t(
"msg.admin.groups.members.remove_success",
"구성원이 제외되었습니다.",
),
);
},
});
const assignRoleMutation = useMutation({
mutationFn: () =>
assignGroupRole(
tenantId ?? "",
id ?? "",
selectedTargetTenantId,
selectedRelation,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
setIsAddRoleOpen(false);
toast.success(
t("msg.admin.groups.roles.assign_success", "역할이 할당되었습니다."),
);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(
error.response?.data?.error ||
error.message ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
const removeRoleMutation = useMutation({
mutationFn: (role: { targetTenantId: string; relation: string }) =>
removeGroupRole(
tenantId ?? "",
id ?? "",
role.targetTenantId,
role.relation,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
toast.success(
t("msg.admin.groups.roles.remove_success", "역할이 회수되었습니다."),
);
},
});
if (isGroupLoading)
return (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<span className="ml-3 text-muted-foreground">
{t("msg.common.loading", "로딩 중...")}
</span>
</div>
);
if (error || !currentGroup)
return (
<div className="p-8 text-center space-y-4">
<h3 className="text-xl font-semibold text-destructive">
</h3>
<div className="p-4 bg-destructive/10 text-destructive rounded-md text-left text-sm font-mono overflow-auto max-w-xl mx-auto border border-destructive/20">
<p>
Error:{" "}
{(error as AxiosError<{ error?: string }>)?.response?.data?.error ||
(error instanceof Error ? error.message : String(error)) ||
"Not found"}
</p>
</div>
<Button variant="outline" onClick={() => window.location.reload()}>
{t("ui.common.retry", "다시 시도")}
</Button>
<div className="pt-4 border-t">
<Link
to={`/tenants/${tenantId}/organization`}
className="text-primary hover:underline text-sm"
>
{t(
"ui.admin.groups.detail.breadcrumb_org",
"조직 관리 목록으로 돌아가기",
)}
</Link>
</div>
</div>
);
return (
<div className="space-y-8 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">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link
to={`/tenants/${tenantId}`}
className="inline-flex items-center gap-2 hover:text-foreground transition-colors"
>
<ArrowLeft size={14} />
{t("ui.admin.groups.detail.breadcrumb_tenant", "테넌트 상세")}
</Link>
<span>/</span>
<Link
to={`/tenants/${tenantId}/organization`}
className="hover:text-foreground transition-colors"
>
{t("ui.admin.groups.detail.breadcrumb_org", "조직 관리")}
</Link>
<span>/</span>
<span className="text-foreground">
{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Users size={24} className="text-primary" />
</div>
<h2 className="text-3xl font-semibold">{currentGroup.name}</h2>
{currentGroup.unitType && (
<Badge variant="secondary" className="h-6 font-normal">
{currentGroup.unitType}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{currentGroup.description ||
t("msg.common.no_description", "설명이 없습니다.")}
</p>
</div>
<div className="flex gap-2">
<Badge variant="secondary" className="font-normal">
{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
</Badge>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
{/* Members Management */}
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.groups.detail.members_title", "구성원 관리")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"이 조직에 소속된 사용자를 관리합니다.",
)}
</CardDescription>
</div>
<Dialog open={isAddMemberOpen} onOpenChange={setIsAddMemberOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<UserPlus size={16} className="mr-2" />
{t("ui.common.add", "추가")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("ui.admin.groups.detail.members_title", "구성원 추가")}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"사용자를 검색하여 조직 구성원으로 추가합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>{t("ui.common.search", "사용자 검색")}</Label>
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 또는 이름으로 검색...",
)}
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>{t("ui.common.select", "사용자 선택")}</Label>
<Select
value={selectedUserId}
onValueChange={setSelectedUserId}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"ui.common.select_placeholder",
"사용자를 선택하세요",
)}
/>
</SelectTrigger>
<SelectContent>
{userList?.items.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddMemberOpen(false)}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => addMemberMutation.mutate(selectedUserId)}
disabled={!selectedUserId || addMemberMutation.isPending}
>
{t("ui.common.add", "추가")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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 className="font-bold">
{t("ui.admin.users.list.table.name_email", "사용자")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!currentGroup.members ||
currentGroup.members.length === 0 ? (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.members.empty",
"구성원이 없습니다.",
)}
</TableCell>
</TableRow>
) : (
currentGroup.members.map((member) => (
<TableRow
key={member.id}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{member.name.charAt(0)}
</div>
<div>
<p className="font-medium text-sm">
{member.name}
</p>
<p className="text-xs text-muted-foreground">
{member.email}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm(
t(
"msg.admin.groups.members.remove_confirm",
"제거하시겠습니까?",
{ name: member.name },
),
)
) {
removeMemberMutation.mutate(member.id);
}
}}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
{/* Roles/Permissions Management (Keto Based) */}
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.groups.detail.permissions_title", "권한 관리")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.detail.permissions_subtitle",
"이 조직이 다른 테넌트에 가지는 역할을 정의합니다.",
)}
</CardDescription>
</div>
<Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Shield size={16} className="mr-2" />
{t("ui.common.assign", "할당")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t(
"ui.admin.groups.detail.permissions_title",
"테넌트 역할 할당",
)}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.groups.roles.description",
"이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</Label>
<Select
value={selectedTargetTenantId}
onValueChange={setSelectedTargetTenantId}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"ui.admin.tenants.list.select_placeholder",
"테넌트를 선택하세요",
)}
/>
</SelectTrigger>
<SelectContent>
{tenantList?.items.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.slug})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>
{t("ui.admin.users.detail.form.role", "역할 (Relation)")}
</Label>
<Select
value={selectedRelation}
onValueChange={setSelectedRelation}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="view">View ( )</SelectItem>
<SelectItem value="manage">
Manage ( )
</SelectItem>
<SelectItem value="admins">
Admin ( )
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddRoleOpen(false)}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => assignRoleMutation.mutate()}
disabled={
!selectedTargetTenantId || assignRoleMutation.isPending
}
>
{t("ui.common.assign", "할당")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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 className="font-bold">
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.users.detail.form.role", "역할")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isRolesLoading ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-8">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : !groupRoles || groupRoles.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.roles.empty",
"할당된 역할이 없습니다.",
)}
</TableCell>
</TableRow>
) : (
groupRoles.map((role, idx) => (
<TableRow
key={`${role.tenantId}-${role.relation}-${idx}`}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="font-medium text-sm">
{role.tenantName || role.tenantId}
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className="capitalize font-normal"
>
{role.relation}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm(
t("msg.admin.groups.roles.remove_confirm"),
)
) {
removeRoleMutation.mutate({
targetTenantId: role.tenantId,
relation: role.relation,
});
}
}}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,20 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
Pencil,
FileDown,
Plus,
RefreshCw,
Search,
Settings2,
Trash2,
User,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -21,7 +23,24 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import { Switch } from "../../components/ui/switch";
import {
Table,
TableBody,
@@ -30,19 +49,107 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import { deleteUser, fetchUsers } from "../../lib/adminApi";
import { toast } from "../../components/ui/use-toast";
import {
type UserSummary,
bulkDeleteUsers,
bulkUpdateUsers,
deleteUser,
exportUsersCSV,
fetchMe,
fetchTenant,
fetchTenants,
fetchUsers,
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
type UserSchemaField = {
key: string;
label: string;
type: string;
};
type SortConfig = {
key: string;
direction: "asc" | "desc";
};
function UserListPage() {
const navigate = useNavigate();
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState("");
const [searchDraft, setSearchDraft] = React.useState("");
const limit = 50;
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
const [visibleColumns, setVisibleColumns] = React.useState<
Record<string, boolean>
>({});
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
const limit = 1000;
const offset = (page - 1) * limit;
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
});
const tenants = tenantsData?.items ?? [];
// Lock company for tenant_admin
React.useEffect(() => {
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
setSelectedCompany(profile.tenantSlug);
}
}, [profile]);
const selectedTenantId = React.useMemo(() => {
return tenants.find((t) => t.slug === selectedCompany)?.id ?? "";
}, [tenants, selectedCompany]);
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenantId],
queryFn: () => fetchTenant(selectedTenantId),
enabled: selectedTenantId.length > 0,
});
const userSchema: UserSchemaField[] = Array.isArray(
tenantDetail?.config?.userSchema,
)
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
// Initialize visible columns when schema changes
React.useEffect(() => {
if (userSchema.length > 0) {
const initial: Record<string, boolean> = {};
for (const field of userSchema) {
initial[field.key] = true;
}
setVisibleColumns((prev) => {
// Only set if not already set for these keys to avoid reset on every render
const next = { ...initial, ...prev };
return next;
});
}
}, [userSchema]);
const toggleColumn = (key: string) => {
setVisibleColumns((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const query = useQuery({
queryKey: ["users", { limit, offset, search }],
queryFn: () => fetchUsers(limit, offset, search),
queryKey: ["users", { limit, offset, search, tenantSlug: selectedCompany }],
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
placeholderData: (previousData) => previousData,
});
@@ -53,6 +160,39 @@ function UserListPage() {
},
});
const exportMutation = useMutation({
mutationFn: (includeIds: boolean) =>
exportUsersCSV(search, selectedCompany, includeIds),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: () => {
toast.error(
t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
);
},
});
const statusMutation = useMutation({
mutationFn: ({ userId, status }: { userId: string; status: string }) =>
updateUser(userId, { status }),
onSuccess: () => {
query.refetch();
},
onError: () => {
toast.error(
t("msg.admin.users.status_error", "사용자 상태 변경에 실패했습니다."),
);
},
});
const handleSearch = () => {
setSearch(searchDraft);
setPage(1);
@@ -64,195 +204,620 @@ function UserListPage() {
}
};
const handleExport = (includeIds = false) => {
exportMutation.mutate(includeIds);
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "사용자 목록 조회에 실패했습니다." : null;
!errorMsg && query.isError
? t(
"msg.admin.users.list.fetch_error",
"사용자 목록 조회에 실패했습니다.",
)
: null;
const rawItems = query.data?.items ?? [];
const items = React.useMemo(() => {
const sorted = [...rawItems];
if (sortConfig) {
sorted.sort((a, b) => {
let aValue: string | number | boolean | null | undefined;
let bValue: string | number | boolean | null | undefined;
if (sortConfig.key === "name_email") {
aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || "";
} else if (sortConfig.key === "tenant_dept") {
aValue =
(a.tenant?.name || a.tenantSlug || "").toLowerCase() +
(a.department || "").toLowerCase();
bValue =
(b.tenant?.name || b.tenantSlug || "").toLowerCase() +
(b.department || "").toLowerCase();
} else {
aValue = (a as Record<string, unknown>)[sortConfig.key] as
| string
| number
| boolean;
bValue = (b as Record<string, unknown>)[sortConfig.key] as
| string
| number
| boolean;
}
if (aValue === bValue) return 0;
if (aValue === null || aValue === undefined) return 1;
if (bValue === null || bValue === undefined) return -1;
if (sortConfig.direction === "asc") {
return aValue < bValue ? -1 : 1;
}
return aValue > bValue ? -1 : 1;
});
}
return sorted;
}, [rawItems, sortConfig]);
const requestSort = (key: SortConfig["key"]) => {
let direction: "asc" | "desc" = "asc";
if (
sortConfig &&
sortConfig.key === key &&
sortConfig.direction === "asc"
) {
direction = "desc";
}
setSortConfig({ key, direction });
};
const getSortIcon = (key: SortConfig["key"]) => {
if (!sortConfig || sortConfig.key !== key) {
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
}
return sortConfig.direction === "asc" ? (
<ArrowUp size={14} className="ml-1" />
) : (
<ArrowDown size={14} className="ml-1" />
);
};
const items = query.data?.items ?? [];
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
React.useEffect(() => {
if (items.length > 0) {
console.log("User items:", items);
const toggleSelectAll = () => {
if (selectedUserIds.length === items.length) {
setSelectedUserIds([]);
} else {
setSelectedUserIds(items.map((u) => u.id));
}
}, [items]);
};
const toggleSelectUser = (id: string) => {
setSelectedUserIds((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
);
};
const bulkDeleteMutation = useMutation({
mutationFn: bulkDeleteUsers,
onSuccess: (_, variables) => {
query.refetch();
setSelectedUserIds([]);
toast.success(
t(
"msg.admin.users.bulk.delete_success",
"{{count}}명의 사용자가 삭제되었습니다.",
{ count: variables.length },
),
);
},
});
const bulkUpdateMutation = useMutation({
mutationFn: bulkUpdateUsers,
onSuccess: () => {
query.refetch();
setSelectedUserIds([]);
toast.success(
t(
"msg.admin.users.bulk.update_success",
"선택한 사용자들의 정보가 수정되었습니다.",
),
);
},
});
const handleBulkStatusChange = (status: string) => {
if (selectedUserIds.length === 0) return;
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
};
const handleBulkDelete = () => {
if (selectedUserIds.length === 0) return;
if (
window.confirm(
t(
"msg.admin.users.bulk.delete_confirm",
"{{count}}명의 사용자를 정말 삭제하시겠습니까?",
{ count: selectedUserIds.length },
),
)
) {
bulkDeleteMutation.mutate(selectedUserIds);
}
};
const handleDelete = (userId: string, userName: string) => {
if (!window.confirm(`사용자 "${userName}"을(를) 정말 삭제하시겠습니까?`)) {
if (
!window.confirm(
t(
"msg.admin.users.list.delete_confirm",
'사용자 "{{name}}"을(를) 정말 삭제하시겠습니까?',
{ name: userName },
),
)
) {
return;
}
deleteMutation.mutate(userId);
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-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">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Users</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold" data-testid="page-title">
{t("ui.admin.users.list.title", "사용자 관리")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
. (Local DB)
{t(
"msg.admin.users.list.subtitle",
"시스템 사용자를 조회하고 관리합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
</Button>
<Button asChild>
<Link to="/users/new">
<Plus size={16} />
</Link>
</Button>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>User Registry</CardTitle>
<CardDescription>
{total} .
</CardDescription>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 flex items-center gap-2">
<div className="relative flex-1 max-w-sm">
<div className="flex items-center gap-2 mr-2">
<div className="relative w-48">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="이름 또는 이메일 검색..."
className="pl-9"
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="pl-9 h-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<Button variant="secondary" onClick={handleSearch}>
<select
className="flex h-9 w-[160px] 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 disabled:opacity-50"
value={selectedCompany}
onChange={(e) => {
setSelectedCompany(e.target.value);
setPage(1);
}}
disabled={profile?.role === "tenant_admin"}
>
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name}
</option>
))}
</select>
<Button
variant="secondary"
size="sm"
onClick={handleSearch}
className="h-9"
>
{t("ui.common.search", "검색")}
</Button>
</div>
<Button
variant="outline"
size="sm"
className="h-9"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button
variant="outline"
onClick={() => handleExport(false)}
className="gap-2"
disabled={exportMutation.isPending}
>
<FileDown size={16} />
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</Button>
<Button
variant="outline"
onClick={() => handleExport(true)}
className="gap-2"
disabled={exportMutation.isPending}
>
<FileDown size={16} />
{t("ui.common.export_with_ids", "UUID 포함")}
</Button>
<UserBulkUploadModal onSuccess={() => query.refetch()} />
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings2 size={16} />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.list.columns.description",
"사용자 목록에 표시할 커스텀 필드를 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{userSchema.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
{t(
"msg.admin.users.list.columns.no_custom",
"현재 테넌트에 정의된 커스텀 필드가 없습니다.",
)}
</p>
)}
{userSchema.map((field) => (
<label
key={field.key}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
checked={visibleColumns[field.key] !== false}
onChange={() => toggleColumn(field.key)}
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{field.label}</span>
<span className="text-xs text-muted-foreground font-mono">
{field.key}
</span>
</div>
</label>
))}
</div>
<DialogFooter>
<DialogTrigger asChild>
<Button variant="secondary">
{t("ui.common.close", "닫기")}
</Button>
</DialogTrigger>
</DialogFooter>
</DialogContent>
</Dialog>
<Button asChild size="sm" className="h-9">
<Link to="/users/new">
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
</Link>
</Button>
</div>
</header>
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.users.list.registry.title", "User Registry")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.list.registry.count",
"총 {{count}}명의 사용자가 등록되어 있습니다.",
{ count: total },
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
{errorMsg ?? fallbackError}
</div>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME / EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>TENANT / DEPT</TableHead>
<TableHead>CREATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<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>
<TableCell colSpan={6} className="h-24 text-center">
...
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
.
</TableCell>
</TableRow>
)}
{items.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{user.role}</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "secondary"
<TableHead className="w-12">
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={
items.length > 0 &&
selectedUserIds.length === items.length
}
>
{user.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium text-blue-600">
{user.tenant?.name || user.companyCode || "-"}
</span>
{user.tenant && (
<span className="text-[10px] text-muted-foreground uppercase">
Slug: {user.tenant.slug}
</span>
onChange={toggleSelectAll}
/>
</TableHead>
<TableHead
className="min-w-[220px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("id")}
>
<div className="flex items-center">
{t("ui.admin.users.list.table.id", "ID")}
{getSortIcon("id")}
</div>
</TableHead>
<TableHead
className="min-w-[200px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("name_email")}
>
<div className="flex items-center">
{t(
"ui.admin.users.list.table.name_email",
"이름 / 이메일 / 전화번호",
)}
<span className="text-xs text-muted-foreground">
{user.department || "-"}
</span>
{getSortIcon("name_email")}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
aria-label={`사용자 수정: ${user.name}`}
>
<Pencil size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive"
onClick={() => handleDelete(user.id, user.name)}
disabled={deleteMutation.isPending}
aria-label={`사용자 삭제: ${user.name}`}
>
<Trash2 size={16} />
</Button>
</TableHead>
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("status")}
>
<div className="flex items-center">
{t("ui.admin.users.list.table.status", "STATUS")}
{getSortIcon("status")}
</div>
</TableCell>
</TableHead>
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("tenant_dept")}
>
<div className="flex items-center">
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
{getSortIcon("tenant_dept")}
</div>
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<TableHead
key={field.key}
className="whitespace-nowrap uppercase cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort(field.key)}
>
<div className="flex items-center">
{field.label}
{getSortIcon(field.key)}
</div>
</TableHead>
),
)}
<TableHead
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("createdAt")}
>
<div className="flex items-center">
{t("ui.admin.users.list.table.created", "CREATED")}
{getSortIcon("createdAt")}
</div>
</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
{t(
"msg.admin.users.list.empty",
"검색 결과가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{items.map((user) => (
<TableRow
key={user.id}
className={
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
}
>
<TableCell>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
checked={selectedUserIds.includes(user.id)}
onChange={() => toggleSelectUser(user.id)}
disabled={user.id === profile?.id}
title={
user.id === profile?.id
? t(
"msg.admin.users.self_delete_blocked",
"본인 계정은 삭제할 수 없습니다.",
)
: undefined
}
/>
</TableCell>
<TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
data-testid={`user-internal-id-${user.id}`}
>
{user.id}
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div
className="truncate text-sm"
data-testid={`user-contact-${user.id}`}
>
<Link
to={`/users/${user.id}`}
className="font-medium hover:underline text-primary"
>
{user.name}
</Link>
<span className="text-muted-foreground">
{" "}
{user.email}
</span>
{user.phone && (
<span className="text-muted-foreground">
{" "}
{user.phone}
</span>
)}
</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) =>
statusMutation.mutate({
userId: user.id,
status: checked ? "active" : "inactive",
})
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
}
aria-label={t(
"ui.admin.users.list.toggle_status",
"{{name}} 활성 상태",
{ name: user.name },
)}
data-testid={`user-status-toggle-${user.id}`}
/>
<span className="text-sm text-muted-foreground">
{t(`ui.common.status.${user.status}`, user.status)}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{user.tenant?.name ||
user.companyCode ||
t("ui.common.unassigned", "미배정")}
</span>
{user.department && (
<span className="text-xs text-muted-foreground">
{user.department}
</span>
)}
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<TableCell key={field.key} className="text-sm">
{String(user.metadata?.[field.key] ?? "-")}
</TableCell>
),
)}
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{/* Bulk Action Bar */}
{selectedUserIds.length > 0 && (
<div
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300"
data-testid="bulk-action-bar"
>
<span className="text-sm font-medium border-r border-background/20 pr-4 mr-2">
{t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", {
count: selectedUserIds.length,
})}
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("active")}
data-testid="bulk-active-btn"
>
{t("ui.common.status.active", "활성화")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("inactive")}
data-testid="bulk-inactive-btn"
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"
size="sm"
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
onClick={handleBulkDelete}
data-testid="bulk-delete-btn"
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
<Button
variant="ghost"
size="icon"
className="text-background/50 hover:text-background h-8 w-8 ml-2"
onClick={() => setSelectedUserIds([])}
aria-label={t("ui.common.close", "닫기")}
data-testid="bulk-close-btn"
>
<Plus size={16} className="rotate-45" />
</Button>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-end gap-2">
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
@@ -260,10 +825,13 @@ function UserListPage() {
disabled={page === 1 || query.isFetching}
>
<ChevronLeft size={16} />
Previous
{t("ui.common.previous", "Previous")}
</Button>
<div className="text-sm text-muted-foreground">
Page {page} of {totalPages}
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
page,
total: totalPages,
})}
</div>
<Button
variant="outline"
@@ -271,7 +839,7 @@ function UserListPage() {
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || query.isFetching}
>
Next
{t("ui.common.next", "Next")}
<ChevronRight size={16} />
</Button>
</div>

View File

@@ -0,0 +1,332 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { AlertTriangle, FolderTree, Loader2, Search } 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 { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import { toast } from "../../../components/ui/use-toast";
import {
type GroupSummary,
type TenantSummary,
type UserSummary,
bulkUpdateUsers,
fetchGroups,
fetchTenants,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type UserSchemaField = {
key: string;
label: string;
required?: boolean;
};
interface UserBulkMoveGroupModalProps {
userIds: string[];
selectedUsers?: UserSummary[];
onSuccess?: () => void;
}
export function UserBulkMoveGroupModal({
userIds,
selectedUsers = [],
onSuccess,
}: UserBulkMoveGroupModalProps) {
const [open, setOpen] = React.useState(false);
const [selectedTenantSlug, setSelectedTenantSlug] =
React.useState<string>("");
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
const [searchTerm, setSearchTerm] = React.useState("");
const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false);
const queryClient = useQueryClient();
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
enabled: open,
});
const tenants = tenantsData?.items ?? [];
const selectedTenant = React.useMemo(
() => tenants.find((t) => t.slug === selectedTenantSlug),
[tenants, selectedTenantSlug],
);
const selectedTenantId = selectedTenant?.id ?? "";
const { data: groups, isLoading: isGroupsLoading } = useQuery({
queryKey: ["tenant-groups", selectedTenantId],
queryFn: () => fetchGroups(selectedTenantId),
enabled: open && !!selectedTenantId,
});
const schemaWarnings = React.useMemo(() => {
if (!selectedTenant || selectedUsers.length === 0) return null;
const targetSchema =
(selectedTenant.config?.userSchema as UserSchemaField[]) || [];
const targetSchemaKeys = new Set(targetSchema.map((f) => f.key));
const requiredKeys = targetSchema
.filter((f) => f.required)
.map((f) => f.key);
const missingRequiredFields = new Set<string>();
const incompatibleFields = new Set<string>();
for (const user of selectedUsers) {
const userMeta = user.metadata || {};
// 1. Check for missing required fields
for (const key of requiredKeys) {
if (
userMeta[key] === undefined ||
userMeta[key] === null ||
userMeta[key] === ""
) {
missingRequiredFields.add(key);
}
}
// 2. Check for fields that exist in user metadata but not in the target schema (data loss)
for (const key of Object.keys(userMeta)) {
if (!targetSchemaKeys.has(key)) {
incompatibleFields.add(key);
}
}
}
if (missingRequiredFields.size === 0 && incompatibleFields.size === 0) {
return null;
}
return {
missing: Array.from(missingRequiredFields),
incompatible: Array.from(incompatibleFields),
};
}, [selectedTenant, selectedUsers]);
const mutation = useMutation({
mutationFn: bulkUpdateUsers,
onSuccess: () => {
toast.success(
t(
"msg.admin.users.bulk.move_success",
"사용자들의 부서가 이동되었습니다.",
),
);
setOpen(false);
onSuccess?.();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.users.bulk.move_error", "부서 이동 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
const handleMove = () => {
if (!selectedTenantSlug) return;
mutation.mutate({
userIds,
tenantSlug: selectedTenantSlug,
department: selectedGroupName, // can be empty for "No Department"
});
};
const filteredGroups = React.useMemo(() => {
if (!groups) return [];
if (!searchTerm) return groups;
return groups.filter((g) =>
g.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [groups, searchTerm]);
return (
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setSelectedTenantSlug("");
setSelectedGroupName("");
setAcknowledgeWarning(false);
setSearchTerm("");
}
}}
>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
>
{t("ui.admin.users.bulk.move_group", "부서 이동")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.bulk.move_description",
"선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.",
{ count: userIds.length },
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t("ui.admin.users.create.form.tenant", "테넌트 선택")}
</label>
<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={selectedTenantSlug}
onChange={(e) => {
setSelectedTenantSlug(e.target.value);
setSelectedGroupName("");
setAcknowledgeWarning(false);
}}
>
<option value="">{t("ui.common.select", "선택하세요...")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
{selectedTenantSlug && (
<div className="space-y-2">
<label className="text-sm font-medium">
{t("ui.admin.users.bulk.select_group", "부서 선택")}
</label>
<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={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<ScrollArea className="h-[200px] rounded-md border p-2">
<div className="space-y-1">
<button
type="button"
onClick={() => setSelectedGroupName("")}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === ""
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
<FolderTree size={14} />
{t("ui.admin.users.bulk.no_department", "(부서 없음)")}
</button>
{isGroupsLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="animate-spin text-muted-foreground" />
</div>
) : (
filteredGroups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => setSelectedGroupName(group.name)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === group.name
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
<FolderTree size={14} />
{group.name}
</button>
))
)}
</div>
</ScrollArea>
</div>
)}
{schemaWarnings && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 space-y-2 mt-4 text-sm">
<div className="flex items-center gap-2 text-destructive font-semibold">
<AlertTriangle size={16} />
{t("ui.admin.users.bulk.schema_warning", "스키마 호환성 경고")}
</div>
<div className="text-destructive/80 text-xs">
{schemaWarnings.missing.length > 0 && (
<p>
{t(
"msg.admin.users.bulk.schema_missing",
"대상 테넌트의 필수 필드가 누락되어 있습니다:",
)}{" "}
<strong>{schemaWarnings.missing.join(", ")}</strong>
</p>
)}
{schemaWarnings.incompatible.length > 0 && (
<p>
{t(
"msg.admin.users.bulk.schema_incompatible",
"대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:",
)}{" "}
<strong>{schemaWarnings.incompatible.join(", ")}</strong>
</p>
)}
</div>
<label className="flex items-center gap-2 cursor-pointer mt-2 pt-2 border-t border-destructive/10">
<input
type="checkbox"
checked={acknowledgeWarning}
onChange={(e) => setAcknowledgeWarning(e.target.checked)}
className="w-4 h-4 rounded border-gray-300 text-destructive focus:ring-destructive"
/>
<span className="font-medium text-destructive/90">
{t(
"ui.admin.users.bulk.acknowledge_warning",
"경고를 확인했으며 계속 진행합니다.",
)}
</span>
</label>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={handleMove}
disabled={
!selectedTenantSlug ||
mutation.isPending ||
(!!schemaWarnings && !acknowledgeWarning)
}
>
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.do_move", "이동 실행")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,629 @@
import { useMutation, useQuery } from "@tanstack/react-query";
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 { ScrollArea } from "../../../components/ui/scroll-area";
import {
type BulkUserItem,
type BulkUserResult,
bulkCreateUsers,
createTenant,
fetchTenants,
fetchUsers,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
type TenantCSVRow,
type TenantImportPreviewRow,
buildTenantImportPreview,
} from "../../tenants/utils/tenantCsvImport";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
import { parseUserCSV } from "../utils/csvParser";
import {
type HanmacImportEmailPreview,
buildHanmacImportEmailPreview,
} from "../utils/hanmacImportEmail";
interface UserBulkUploadModalProps {
onSuccess?: () => void;
}
function buildUserTenantPreviewRows(
users: BulkUserItem[],
tenants: Parameters<typeof buildTenantImportPreview>[1],
) {
const rowsByKey = new Map<string, TenantCSVRow>();
users.forEach((user, index) => {
const key = tenantImportKeyFromUser(user);
if (!key || rowsByKey.has(key)) {
return;
}
rowsByKey.set(key, {
rowNumber: index + 2,
tenantId: user.tenantImport?.sourceTenantId ?? "",
name: user.tenantImport?.name || user.tenantSlug || key,
type: user.tenantImport?.type || "COMPANY",
parentTenantId: user.tenantImport?.parentTenantId ?? "",
parentTenantSlug: user.tenantImport?.parentTenantSlug ?? "",
slug: user.tenantImport?.slug || user.tenantSlug || key,
memo: user.tenantImport?.memo ?? "",
emailDomain: user.tenantImport?.emailDomain ?? "",
});
});
return buildTenantImportPreview([...rowsByKey.values()], tenants);
}
function tenantImportKeyFromUser(user: BulkUserItem) {
return (
user.tenantImport?.sourceTenantId ||
user.tenantImport?.slug ||
user.tenantSlug ||
user.tenantImport?.name ||
""
);
}
function tenantImportKeyFromRow(row: TenantCSVRow) {
return row.tenantId || row.slug || row.name;
}
function splitTenantImportDomains(value: string) {
return value
.replaceAll("\n", ";")
.replaceAll(",", ";")
.split(";")
.map((domain) => domain.trim().toLowerCase())
.filter(Boolean);
}
function emailLocalPart(email: string) {
return email.trim().toLowerCase().split("@")[0] || "";
}
function hanmacEmailStatusLabel(preview?: HanmacImportEmailPreview) {
if (!preview) return "";
if (preview.status === "suggested") return "제안";
if (preview.status === "needsReview") return "확인 필요";
if (preview.status === "ruleMismatch") return "규칙 확인";
if (preview.status === "blockingError") return "오류";
return "";
}
function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
if (!preview) return "text-muted-foreground";
if (preview.status === "blockingError") return "text-destructive";
if (preview.status === "ruleMismatch" || preview.status === "needsReview") {
return "text-amber-600";
}
if (preview.status === "suggested") return "text-blue-600";
return "text-muted-foreground";
}
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const [open, setOpen] = React.useState(false);
const [file, setFile] = React.useState<File | null>(null);
const [parsing, setParsing] = React.useState(false);
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
const [tenantPreviewRows, setTenantPreviewRows] = React.useState<
TenantImportPreviewRow[]
>([]);
const [selectedTenantMatches, setSelectedTenantMatches] = React.useState<
Record<number, string>
>({});
const [selectedTenantCreateSlugs, setSelectedTenantCreateSlugs] =
React.useState<Record<number, string>>({});
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
const [preparing, setPreparing] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const tenantQuery = useQuery({
queryKey: ["tenants", "user-bulk-import"],
queryFn: () => fetchTenants(1000, 0),
});
const usersQuery = useQuery({
queryKey: ["users", "user-bulk-import-email-policy"],
queryFn: () => fetchUsers(10000, 0),
enabled: open,
});
const mutation = useMutation({
mutationFn: bulkCreateUsers,
onSuccess: (data) => {
setResults(data.results);
onSuccess?.();
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
parseCSV(selectedFile);
}
};
const parseCSV = (file: File) => {
setParsing(true);
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
const data = parseUserCSV(text);
setPreviewData(data);
const tenantRows = buildUserTenantPreviewRows(
data,
tenantQuery.data?.items ?? [],
);
setTenantPreviewRows(tenantRows);
setSelectedTenantMatches(
Object.fromEntries(
tenantRows.map((row) => [
row.row.rowNumber,
row.defaultTenantId || "__create__",
]),
),
);
setSelectedTenantCreateSlugs(
Object.fromEntries(
tenantRows.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
),
);
setParsing(false);
};
reader.readAsText(file);
};
const handleUpload = async () => {
if (previewData.length > 0) {
setPreparing(true);
try {
const users = await resolveUserImportTenants();
mutation.mutate(users);
} finally {
setPreparing(false);
}
}
};
const resolveUserImportTenants = async () => {
const tenants = tenantQuery.data?.items ?? [];
const tenantByKey = new Map<
string,
{ id: string; slug: string; emailDomain: string }
>();
for (const preview of tenantPreviewRows) {
const key = tenantImportKeyFromRow(preview.row);
const selected =
selectedTenantMatches[preview.row.rowNumber] ?? "__create__";
if (selected !== "__create__") {
const tenant = tenants.find((item) => item.id === selected);
if (tenant) {
tenantByKey.set(key, {
id: tenant.id,
slug: tenant.slug,
emailDomain: preview.row.emailDomain,
});
}
continue;
}
const created = await createTenant({
name: preview.row.name || preview.row.slug,
slug:
selectedTenantCreateSlugs[preview.row.rowNumber] ||
preview.defaultCreateSlug,
type: preview.row.type || "COMPANY",
parentId: preview.row.parentTenantId || undefined,
description: preview.row.memo,
domains: splitTenantImportDomains(preview.row.emailDomain),
status: "active",
});
tenantByKey.set(key, {
id: created.id,
slug: created.slug,
emailDomain: preview.row.emailDomain,
});
}
return previewData.map((user, index) => {
const key = tenantImportKeyFromUser(user);
const resolvedTenant = key ? tenantByKey.get(key) : undefined;
const emailPreview = hanmacEmailPreviews[index];
const { tenantImport: _tenantImport, ...payload } = user;
return {
...payload,
email: emailPreview?.finalEmail ?? payload.email,
tenantId: resolvedTenant?.id ?? payload.tenantId,
tenantSlug: resolvedTenant?.slug ?? payload.tenantSlug,
emailDomain: resolvedTenant?.emailDomain ?? payload.emailDomain,
};
});
};
const downloadTemplate = () => {
const headers =
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "user_bulk_template.csv";
a.click();
URL.revokeObjectURL(url);
};
const reset = () => {
setFile(null);
setPreviewData([]);
setTenantPreviewRows([]);
setSelectedTenantMatches({});
setSelectedTenantCreateSlugs({});
setResults(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const successCount = results?.filter((r) => r.success).length ?? 0;
const failCount = results ? results.length - successCount : 0;
const tenants = tenantQuery.data?.items ?? [];
const existingHanmacLocalParts = React.useMemo(() => {
const values = new Set<string>();
for (const user of usersQuery.data?.items ?? []) {
if (!isHanmacFamilyUser(user, tenants)) {
continue;
}
const localPart = emailLocalPart(user.email);
if (localPart) values.add(localPart);
}
return values;
}, [tenants, usersQuery.data?.items]);
const hanmacEmailPreviews = React.useMemo(() => {
const batchLocalParts = new Set<string>();
return previewData.map((user) => {
const tenant = tenants.find(
(item) =>
item.slug.toLowerCase() === user.tenantSlug?.trim().toLowerCase(),
);
if (!isHanmacFamilyTenant(tenant, tenants)) {
return undefined;
}
return buildHanmacImportEmailPreview(
user,
existingHanmacLocalParts,
batchLocalParts,
);
});
}, [existingHanmacLocalParts, previewData, tenants]);
const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some(
(preview) => preview?.status === "blockingError",
);
return (
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) reset();
}}
>
<DialogTrigger asChild>
<Button
variant="outline"
className="gap-2"
data-testid="bulk-import-btn"
>
<Upload size={16} />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle data-testid="bulk-upload-title">
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.bulk.description",
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
)}
</DialogDescription>
</DialogHeader>
{!results ? (
<div className="space-y-4 py-4">
<div className="flex justify-between items-center">
<Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="gap-2"
>
<Download size={14} />
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
</Button>
<input
type="file"
accept=".csv"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button>
</div>
{file && (
<div className="rounded-lg border p-4 bg-muted/20">
<div className="flex items-center gap-3 mb-2">
<FileText className="text-primary" />
<span className="font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground">
({(file.size / 1024).toFixed(1)} KB)
</span>
</div>
{parsing ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
{t("msg.common.parsing", "파싱 중...")}
</div>
) : (
<div className="text-sm text-muted-foreground">
{t(
"msg.admin.users.bulk.parsed_count",
"{{count}}명의 사용자가 감지되었습니다.",
{ count: previewData.length },
)}
</div>
)}
</div>
)}
{tenantPreviewRows.length > 0 && (
<div
className="rounded-md border p-3 text-sm"
data-testid="user-import-tenant-resolution"
>
<div className="mb-2 font-medium">
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
</div>
<div className="space-y-2">
{tenantPreviewRows.map((preview) => (
<div
key={preview.row.rowNumber}
className="grid gap-2 sm:grid-cols-[1fr_1fr]"
>
<div>
<div className="font-medium">{preview.row.name}</div>
<div className="font-mono text-xs text-muted-foreground">
{preview.row.slug}
</div>
</div>
<div className="space-y-2">
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={
selectedTenantMatches[preview.row.rowNumber] ??
"__create__"
}
onChange={(event) =>
setSelectedTenantMatches((prev) => ({
...prev,
[preview.row.rowNumber]: event.target.value,
}))
}
>
<option value="__create__">
{t(
"ui.admin.users.bulk.create_missing_tenant",
"신규 생성",
)}
</option>
{preview.candidates.map((candidate) => (
<option
key={candidate.tenantId}
value={candidate.tenantId}
>
{candidate.name} ({candidate.slug})
</option>
))}
</select>
{(selectedTenantMatches[preview.row.rowNumber] ??
"__create__") === "__create__" && (
<input
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
value={
selectedTenantCreateSlugs[
preview.row.rowNumber
] ?? ""
}
onChange={(event) =>
setSelectedTenantCreateSlugs((prev) => ({
...prev,
[preview.row.rowNumber]: event.target.value,
}))
}
/>
)}
</div>
</div>
))}
</div>
</div>
)}
{previewData.length > 0 && (
<ScrollArea className="h-[200px] rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="p-2 text-left">Email</th>
<th className="p-2 text-left">Name</th>
<th className="p-2 text-left">Tenant</th>
<th className="p-2 text-left">Status</th>
</tr>
</thead>
<tbody>
{previewData.slice(0, 10).map((u, index) => (
<tr key={`${u.email}-${index}`} className="border-t">
<td className="p-2">
<input
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
value={
hanmacEmailPreviews[index]?.finalEmail ?? u.email
}
onChange={(event) =>
setPreviewData((prev) =>
prev.map((item, itemIndex) =>
itemIndex === index
? { ...item, email: event.target.value }
: item,
),
)
}
/>
</td>
<td className="p-2">{u.name}</td>
<td className="p-2">{u.tenantSlug || "-"}</td>
<td
className={`p-2 text-xs ${hanmacEmailStatusClass(
hanmacEmailPreviews[index],
)}`}
>
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
{hanmacEmailPreviews[index]?.reason && (
<div>{hanmacEmailPreviews[index]?.reason}</div>
)}
</td>
</tr>
))}
{previewData.length > 10 && (
<tr>
<td
colSpan={4}
className="p-2 text-center text-muted-foreground italic"
>
... and {previewData.length - 10} more users
</td>
</tr>
)}
</tbody>
</table>
</ScrollArea>
)}
</div>
) : (
<div className="space-y-4 py-4">
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-green-600">
{successCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.success", "성공")}
</div>
</div>
<div className="w-px h-10 bg-border" />
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-destructive">
{failCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.fail", "실패")}
</div>
</div>
</div>
<ScrollArea className="h-[250px] rounded-md border">
<div className="p-2 space-y-2">
{results.map((r) => (
<div
key={r.email}
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
>
{r.success ? (
<CheckCircle2
size={16}
className="text-green-500 mt-0.5"
/>
) : (
<AlertCircle
size={16}
className="text-destructive mt-0.5"
/>
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{r.email}</div>
{!r.success && (
<div className="text-xs text-destructive">
{r.message}
</div>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
<DialogFooter>
{!results ? (
<Button
onClick={handleUpload}
disabled={
previewData.length === 0 ||
mutation.isPending ||
preparing ||
hasBlockingHanmacEmailRows
}
className="w-full sm:w-auto"
data-testid="bulk-start-btn"
>
{(mutation.isPending || preparing) && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
</Button>
) : (
<Button
onClick={() => setOpen(false)}
className="w-full sm:w-auto"
data-testid="bulk-close-dialog-btn"
>
{t("ui.common.close", "닫기")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,172 @@
import { describe, expect, it } from "vitest";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
buildAuthenticatedOrgChartUrl,
buildOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
isHanmacFamilyUser,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
describe("orgChartPicker", () => {
it("builds the tenant picker embed URL from ORGFRONT_URL", () => {
expect(buildOrgChartTenantPickerUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600",
);
});
it("adds tenantId to the tenant picker URL when Hanmac family scope is known", () => {
expect(
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
tenantId: "hanmac-family-id",
}),
).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600&tenantId=hanmac-family-id",
);
});
it("wraps the picker URL with the org-chart auto login entry", () => {
expect(
buildAuthenticatedOrgChartTenantPickerUrl(
"https://orgchart.example.com",
{
tenantId: "hanmac-family-id",
},
),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id",
);
});
it("builds the chart navigation URL through the org-chart auto login entry", () => {
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
);
});
it("parses the first tenant id and name from orgfront confirm messages", () => {
expect(
parseOrgChartTenantSelection({
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [
{
type: "tenant",
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
},
],
},
}),
).toEqual({
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
});
});
it("ignores non-tenant or malformed picker messages", () => {
expect(
parseOrgChartTenantSelection({
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [{ type: "user", id: "u-1", name: "User" }],
},
}),
).toBeNull();
expect(parseOrgChartTenantSelection({ type: "other" })).toBeNull();
});
it("filters Hanmac family subtree and system tenants from non-family tenant choices", () => {
const visibleTenants = filterNonHanmacFamilyTenants(
[
{
id: "system-id",
slug: "system",
name: "System",
type: "SYSTEM",
parentId: undefined,
},
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
type: "USER_GROUP",
parentId: "hanmac-company-id",
},
],
"hanmac-family-id",
);
expect(visibleTenants.map((tenant) => tenant.slug)).toEqual(["external"]);
});
it("detects existing users as Hanmac family from tenant subtree without metadata flag", () => {
const tenants = [
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "기술기획",
type: "USER_GROUP",
parentId: "hanmac-company-id",
},
];
expect(
isHanmacFamilyUser(
{
companyCode: "external",
tenant: tenants[0],
joinedTenants: [tenants[3]],
metadata: {},
},
tenants,
"hanmac-family-id",
),
).toBe(true);
});
});

View File

@@ -0,0 +1,229 @@
export type OrgChartTenantSelection = {
id: string;
name: string;
};
export type TenantFilterTarget = {
id?: string;
slug?: string;
type?: string;
parentId?: string | null;
name?: string;
};
export type HanmacFamilyUserTarget = {
companyCode?: string;
tenantSlug?: string;
tenant?: TenantFilterTarget;
joinedTenants?: TenantFilterTarget[];
metadata?: Record<string, unknown>;
};
type OrgChartPickerMessage = {
type?: unknown;
payload?: {
selections?: Array<{
type?: unknown;
id?: unknown;
name?: unknown;
}>;
};
};
type OrgChartTenantPickerOptions = {
tenantId?: string;
};
type OrgChartLoginOptions = {
returnTo?: string;
};
function isSystemTenant(tenant: TenantFilterTarget) {
const slug = tenant.slug?.trim().toLowerCase();
const type = tenant.type?.trim().toUpperCase();
return (
!tenant.id?.trim() ||
!tenant.slug?.trim() ||
type === "SYSTEM" ||
slug === "system" ||
slug === "global"
);
}
function isInTenantSubtree<T extends TenantFilterTarget>(
tenant: T,
rootTenantId: string,
tenantById: Map<string, T>,
) {
if (!rootTenantId) {
return false;
}
if (tenant.id === rootTenantId) {
return true;
}
const visitedTenantIds = new Set<string>();
let parentId = tenant.parentId ?? "";
while (parentId) {
if (parentId === rootTenantId) {
return true;
}
if (visitedTenantIds.has(parentId)) {
return false;
}
visitedTenantIds.add(parentId);
parentId = tenantById.get(parentId)?.parentId ?? "";
}
return false;
}
function resolveHanmacFamilyTenantId<T extends TenantFilterTarget>(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const envTenantId = hanmacFamilyTenantId?.trim();
if (envTenantId) return envTenantId;
return (
tenants.find((tenant) => tenant.slug?.toLowerCase() === "hanmac-family")
?.id ?? ""
);
}
export function isHanmacFamilyTenant<T extends TenantFilterTarget>(
tenant: T | undefined,
tenants: T[],
hanmacFamilyTenantId?: string,
) {
if (!tenant || !tenant.id) return false;
const rootTenantId = resolveHanmacFamilyTenantId(
tenants,
hanmacFamilyTenantId,
);
if (!rootTenantId) return false;
const tenantById = new Map(
tenants
.filter((item) => item.id?.trim())
.map((item) => [item.id as string, item]),
);
const target = tenantById.get(tenant.id) ?? tenant;
return isInTenantSubtree(target, rootTenantId, tenantById);
}
export function isHanmacFamilyUser<T extends TenantFilterTarget>(
user: HanmacFamilyUserTarget,
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const metadata = user.metadata ?? {};
if (metadata.hanmacFamily === true || metadata.userType === "hanmac") {
return true;
}
const tenantBySlug = new Map(
tenants
.filter((tenant) => tenant.slug?.trim())
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
);
const tenantCandidates = [
user.tenant,
...(user.joinedTenants ?? []),
tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""),
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
];
return tenantCandidates.some((tenant) =>
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
);
}
export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const rootTenantId = resolveHanmacFamilyTenantId(
tenants,
hanmacFamilyTenantId,
);
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
.map((tenant) => [tenant.id as string, tenant]),
);
return tenants.filter(
(tenant) =>
!isSystemTenant(tenant) &&
!isInTenantSubtree(tenant, rootTenantId, tenantById),
);
}
export function buildOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const params = new URLSearchParams({
mode: "single",
select: "tenant",
width: "400",
height: "600",
});
const tenantId = options.tenantId?.trim();
if (tenantId) {
params.set("tenantId", tenantId);
}
return `${normalizedBase}/embed/picker?${params.toString()}`;
}
export function buildAuthenticatedOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const pickerUrl = buildOrgChartTenantPickerUrl("", options);
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
}
export function buildAuthenticatedOrgChartUrl(
baseUrl?: string,
options: OrgChartLoginOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const returnTo = options.returnTo?.trim() || "/chart";
const params = new URLSearchParams({
auto: "1",
returnTo,
});
return `${normalizedBase}/login?${params.toString()}`;
}
export function parseOrgChartTenantSelection(
message: unknown,
): OrgChartTenantSelection | null {
const data = message as OrgChartPickerMessage;
if (data?.type !== "orgfront:picker:confirm") {
return null;
}
const selection = data.payload?.selections?.[0];
if (
selection?.type !== "tenant" ||
typeof selection.id !== "string" ||
typeof selection.name !== "string" ||
selection.id.trim() === ""
) {
return null;
}
return {
id: selection.id,
name: selection.name,
};
}

View File

@@ -0,0 +1,18 @@
export type UserSchemaFieldType =
| "text"
| "number"
| "boolean"
| "date"
| "float"
| "datetime";
export type UserSchemaField = {
key: string;
label?: string;
type?: UserSchemaFieldType;
required?: boolean;
adminOnly?: boolean;
validation?: string;
isLoginId?: boolean;
indexed?: boolean;
};

View File

@@ -0,0 +1,14 @@
import { t } from "../../lib/i18n";
export const userStatusValues = [
"active",
"inactive",
"suspended",
"leave_of_absence",
] as const;
export type UserStatusValue = (typeof userStatusValues)[number];
export function userStatusLabel(status: string) {
return t(`ui.common.status.${status}`, status);
}

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { parseUserCSV } from "./csvParser";
describe("parseUserCSV", () => {
it("should parse valid CSV correctly", () => {
const csv = `email,name,phone,role,tenant,department,emp_id
user1@test.com,Hong Gil Dong,010-1111-2222,user,baron,HR,E001
user2@test.com,Kim Cheol Su,,admin,baron,IT,E002`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
email: "user1@test.com",
name: "Hong Gil Dong",
phone: "010-1111-2222",
role: "user",
tenantSlug: "baron",
department: "HR",
metadata: {
emp_id: "E001",
},
});
expect(result[1].email).toBe("user2@test.com");
expect(result[1].metadata.emp_id).toBe("E002");
});
it("should return empty array for empty input", () => {
expect(parseUserCSV("")).toEqual([]);
});
it("should skip rows without email or name", () => {
const csv = `email,name
,Only Name
no-name@test.com,`;
expect(parseUserCSV(csv)).toHaveLength(0);
});
it("should handle mixed case headers", () => {
const csv = `EMAIL,Name,Tenant
test@test.com,Test,baron`;
const result = parseUserCSV(csv);
expect(result[0].email).toBe("test@test.com");
expect(result[0].tenantSlug).toBe("baron");
});
it("should parse NAVERWORKS member CSV sample into Baron bulk user fields", () => {
const csv = `"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time"
"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
email: "john1@company.com",
loginId: "john.doe",
name: "John Doe",
phone: "+19144812222",
department: "myteam",
grade: "Manager",
position: "Manager",
jobTitle: "Sales management",
tenantImport: {
name: "myteam",
parentTenantName: "org.3",
},
metadata: {
personal_email: "john@naver.com",
employee_id: "AB001",
naverworks_user_type: "Permanent Employee",
naverworks_level: "Manager",
naverworks_organization_path: "org.1|org.2|org.3|myteam",
naverworks_workplace: "New York",
},
});
});
it("should parse tenant conflict metadata for import resolution", () => {
const csv = `email,name,tenant_id,tenant_slug,tenant_name,tenant_type,parent_tenant_slug,tenant_memo,email_domain
test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-slug,Imported memo,missing.example.com`;
const result = parseUserCSV(csv);
expect(result[0]).toMatchObject({
tenantId: "local-tenant-id",
tenantSlug: "missing-slug",
emailDomain: "missing.example.com",
tenantImport: {
sourceTenantId: "local-tenant-id",
slug: "missing-slug",
name: "Missing Tenant",
type: "COMPANY",
parentTenantSlug: "parent-slug",
memo: "Imported memo",
emailDomain: "missing.example.com",
},
});
});
it("should parse one nullable additional appointment from numbered columns", () => {
const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002
nullable@test.com,Nullable User,010-1111-1111,user,primary-tenant,개발팀,책임,팀장,Backend,EMP003,,,,,,`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
tenantSlug: "primary-tenant",
department: "개발팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: {
employee_id: "EMP001",
},
additionalAppointments: [
{
tenantSlug: "second-tenant",
department: "센터",
grade: "수석",
jobTitle: "Architecture",
metadata: {
employee_id: "EMP002",
},
},
],
});
expect(result[1].additionalAppointments).toBeUndefined();
});
});

View File

@@ -0,0 +1,307 @@
import type { BulkUserAppointment, BulkUserItem } from "../../../lib/adminApi";
export function parseUserCSV(text: string): BulkUserItem[] {
const records = parseCSVRecords(text.replace(/^\uFEFF/, ""));
if (records.length < 2) {
return [];
}
const headers = records[0].map(normalizeHeader);
const data: BulkUserItem[] = [];
for (let i = 1; i < records.length; i++) {
const values = records[i].map((v) => v.trim());
if (values.every((value) => value === "")) continue;
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
metadata: {},
};
const additionalAppointment: BulkUserAppointment & {
metadata: Record<string, string>;
} = {
metadata: {},
};
for (let index = 0; index < headers.length; index++) {
const header = headers[index];
const value = values[index];
if (value === undefined || value === "") continue;
if (header === "email") {
item.email = value;
} else if (header === "name") {
item.name = value;
} else if (header === "phone") {
item.phone = value;
} else if (header === "role") {
item.role = value;
} else if (header === "tenant") {
item.tenantSlug = value;
} else if (header === "tenant_slug" || header === "companycode") {
item.tenantSlug = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
slug: value,
};
} else if (header === "tenant_id") {
item.tenantId = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
sourceTenantId: value,
};
} else if (header === "tenant_name") {
item.tenantImport = {
...(item.tenantImport ?? {}),
name: value,
};
} else if (header === "tenant_type") {
item.tenantImport = {
...(item.tenantImport ?? {}),
type: value,
};
} else if (header === "parent_tenant_id") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantId: value,
};
} else if (header === "parent_tenant_slug") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantSlug: value,
};
} else if (header === "parent_tenant_name") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantName: value,
};
} else if (header === "tenant_memo") {
item.tenantImport = {
...(item.tenantImport ?? {}),
memo: value,
};
} else if (header === "email_domain" || header === "tenant_domain") {
item.emailDomain = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
emailDomain: value,
};
} else if (header === "department") {
item.department = value;
} else if (header === "grade") {
item.grade = value;
} else if (header === "position") {
item.position = value;
} else if (header === "jobtitle") {
item.jobTitle = value;
} else if (header === "employee_id") {
item.metadata.employee_id = value;
} else if (header === "tenant_slug1") {
additionalAppointment.tenantSlug = value;
} else if (header === "department1") {
additionalAppointment.department = value;
} else if (header === "grade1") {
additionalAppointment.grade = value;
} else if (header === "position1") {
additionalAppointment.position = value;
} else if (header === "jobtitle1") {
additionalAppointment.jobTitle = value;
} else if (header === "employee_id1") {
additionalAppointment.metadata.employee_id = value;
} else if (header === "lastname") {
item.metadata.naverworks_last_name = value;
} else if (header === "firstname") {
item.metadata.naverworks_first_name = value;
} else if (header === "id") {
item.loginId = value;
item.metadata.naverworks_id = value;
} else if (header === "personalemail") {
item.metadata.personal_email = value;
} else if (header === "subemail") {
item.metadata.naverworks_sub_email = value;
item.email = firstEmailToken(value) || item.email;
} else if (header === "nickname") {
item.metadata.naverworks_nickname = value;
} else if (header === "usertype") {
item.metadata.naverworks_user_type = value;
} else if (header === "level") {
item.grade = value;
item.metadata.naverworks_level = value;
} else if (header === "organization") {
item.metadata.naverworks_organization_path = value;
const parts = splitOrganizationPath(value);
const leaf = parts.at(-1) ?? "";
const parent = parts.at(-2) ?? "";
if (leaf) {
item.department = leaf;
item.tenantImport = {
...(item.tenantImport ?? {}),
name: leaf,
parentTenantName: parent,
};
}
} else if (header === "companymainphone") {
item.metadata.naverworks_company_main_phone = value;
} else if (header === "mobilecountrycode") {
item.metadata.naverworks_mobile_country_code = value;
} else if (header === "mobilenumbers") {
item.metadata.naverworks_mobile_numbers = value;
} else if (header === "language") {
item.metadata.naverworks_language = value;
} else if (header === "responsibilities") {
item.jobTitle = value;
} else if (header === "workplace") {
item.metadata.naverworks_workplace = value;
} else if (header === "sns") {
item.metadata.naverworks_sns = value;
} else if (header === "snsid") {
item.metadata.naverworks_sns_id = value;
} else if (header === "birthdaysolarlunar") {
item.metadata.naverworks_birthday_calendar = value;
} else if (header === "birthday") {
item.metadata.naverworks_birthday = value;
} else if (header === "entrydate") {
item.metadata.naverworks_entry_date = value;
} else if (header === "employeenumber") {
item.metadata.employee_id = value;
} else if (header === "accountactivationtime") {
item.metadata.naverworks_account_activation_time = value;
} else {
item.metadata[header] = value;
}
}
applyNaverWorksFallbacks(item);
if (additionalAppointment.tenantSlug) {
item.additionalAppointments = [
cleanAdditionalAppointment(additionalAppointment),
];
}
if (item.email && item.name) {
data.push(item as BulkUserItem);
}
}
return data;
}
function cleanAdditionalAppointment(
appointment: BulkUserAppointment & { metadata: Record<string, string> },
) {
const metadata =
Object.keys(appointment.metadata).length > 0
? appointment.metadata
: undefined;
return {
...(appointment.tenantId ? { tenantId: appointment.tenantId } : {}),
...(appointment.tenantSlug ? { tenantSlug: appointment.tenantSlug } : {}),
...(appointment.tenantName ? { tenantName: appointment.tenantName } : {}),
...(appointment.isPrimary !== undefined
? { isPrimary: appointment.isPrimary }
: {}),
...(appointment.isOwner !== undefined
? { isOwner: appointment.isOwner }
: {}),
...(appointment.department ? { department: appointment.department } : {}),
...(appointment.grade ? { grade: appointment.grade } : {}),
...(appointment.position ? { position: appointment.position } : {}),
...(appointment.jobTitle ? { jobTitle: appointment.jobTitle } : {}),
...(metadata ? { metadata } : {}),
};
}
function normalizeHeader(header: string) {
return header
.trim()
.toLowerCase()
.replace(/^\uFEFF/, "")
.replace(/[^a-z0-9_]/g, "");
}
function parseCSVRecords(text: string) {
const records: string[][] = [];
let field = "";
let row: string[] = [];
let quoted = false;
for (let index = 0; index < text.length; index++) {
const char = text[index];
const next = text[index + 1];
if (char === '"') {
if (quoted && next === '"') {
field += '"';
index++;
} else {
quoted = !quoted;
}
continue;
}
if (char === "," && !quoted) {
row.push(field);
field = "";
continue;
}
if ((char === "\n" || char === "\r") && !quoted) {
if (char === "\r" && next === "\n") index++;
row.push(field);
records.push(row);
field = "";
row = [];
continue;
}
field += char;
}
if (field !== "" || row.length > 0) {
row.push(field);
records.push(row);
}
return records;
}
function firstEmailToken(value: string) {
return (
value
.split(/[;,]/)
.map((token) => token.trim())
.find((token) => token.includes("@")) ?? ""
);
}
function splitOrganizationPath(value: string) {
return value
.split("|")
.map((part) => part.trim())
.filter(Boolean);
}
function applyNaverWorksFallbacks(
item: Partial<BulkUserItem> & { metadata: Record<string, string> },
) {
if (!item.name) {
const firstName = item.metadata.naverworks_first_name ?? "";
const lastName = item.metadata.naverworks_last_name ?? "";
item.name = [firstName, lastName].filter(Boolean).join(" ").trim();
if (!item.name && item.metadata.naverworks_nickname) {
item.name = item.metadata.naverworks_nickname;
}
}
if (!item.email) {
item.email = item.metadata.personal_email;
}
if (!item.phone) {
const countryCode = item.metadata.naverworks_mobile_country_code ?? "";
const number = item.metadata.naverworks_mobile_numbers ?? "";
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
}
if (!item.grade && item.metadata.naverworks_level) {
item.grade = item.metadata.naverworks_level;
}
}

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