1
0
forked from baron/baron-sso

177 Commits

Author SHA1 Message Date
46f03e2b18 chore: document local WSL setup fixes 2026-06-18 11:45:03 +09:00
a56d68896f production 푸시 초안 2026-06-18 11:02:48 +09:00
33249eb229 orgfront refresh token 관리 추가 2026-06-18 08:00:57 +09:00
5f3167a503 feat(devfront): show client creators and headless filter 2026-06-17 22:03:15 +09:00
69e1e32fd4 Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx
#	adminfront/tests/worksmobile.spec.ts
2026-06-17 21:31:00 +09:00
49560e8a8c chore: snapshot local state before dev merge 2026-06-17 21:25:42 +09:00
08ad23d6e3 Merge pull request 'config: pnpm v11 대응을 위해 overrides 설정을 package.json에서 pnpm-workspace.yaml로 마이그레이션 (#1183)' (#1198) from feature/1183-signup-personal-default into dev
Reviewed-on: baron/baron-sso#1198
2026-06-17 10:01:21 +09:00
2a613d2a2e config: pnpm v11 대응을 위해 overrides 설정을 package.json에서 pnpm-workspace.yaml로 마이그레이션 (#1183) 2026-06-17 09:33:25 +09:00
baa6f5e17b Merge pull request 'adminfront, test: 네이버웍스 연동 E2E 테스트 내 컬럼 설정 버튼 클릭을 브라우저 네이티브 DOM 클릭(.evaluate)으로 보완 (#1183)' (#1189) from feature/1183-signup-personal-default into dev
Reviewed-on: baron/baron-sso#1189
2026-06-16 19:00:05 +09:00
b0dbd7b32f adminfront, test: 네이버웍스 연동 E2E 테스트 내 컬럼 설정 버튼 클릭을 브라우저 네이티브 DOM 클릭(.evaluate)으로 보완 (#1183) 2026-06-16 18:59:38 +09:00
7bef9c5b12 Merge pull request 'adminfront, test: 네이버웍스 연동 E2E 테스트 내 컬럼 설정 버튼 클릭을 강제(force) 옵션으로 우회 조치 (#1183)' (#1188) from feature/1183-signup-personal-default into dev
Reviewed-on: baron/baron-sso#1188
2026-06-16 18:52:32 +09:00
c76148e852 adminfront, test: 네이버웍스 연동 E2E 테스트 내 컬럼 설정 버튼 클릭을 강제(force) 옵션으로 우회 조치 (#1183) 2026-06-16 18:51:41 +09:00
0ff8cfd1d9 Merge pull request 'feature/1183-signup-personal-default' (#1187) from feature/1183-signup-personal-default into dev
Reviewed-on: baron/baron-sso#1187
2026-06-16 18:39:35 +09:00
5f153bc370 userfront: 회원가입 페이지에 dart format 표준 서식 적용 (#1183) 2026-06-16 18:38:41 +09:00
d8327afac8 lint 수정 2026-06-16 18:33:32 +09:00
adb2aa4be0 adminfront: 테넌트 상세 레이아웃 내 네이버웍스 오동작 탭을 완벽히 소거하고, 글로벌 사이드바 네이버웍스 연동 메뉴 노출 가드 동기화 완료 (#1183) 2026-06-16 18:22:11 +09:00
bfdfbab85f backend: 테넌트 상세 조회 API(GetTenant) 응답 권한 맵에 네이버웍스 연동 권한(view/manage_worksmobile) 조회 및 반환 처리 구현 완료 (#1183) 2026-06-16 18:19:15 +09:00
cbb3ac2211 adminfront: 글로벌 권한 부여 페이지(/permissions-direct) 내 권한 부여(permissions_direct) 메뉴를 Super Admin 전용으로 잠금 및 전용 배지 추가 (#1183) 2026-06-16 18:15:51 +09:00
c1c197e0e0 adminfront: 글로벌 권한 부여 페이지(/permissions-direct) 내 네이버웍스 연동 메뉴를 Super Admin 전용으로 잠금 및 전용 배지 추가 반영 완료 (#1183) 2026-06-16 18:11:18 +09:00
80ec788a2a adminfront: 네이버웍스 연동 탭 노출 조건을 인가 테이블과 동기화하고, 세부 권한 탭에 최고 관리자 전용 알림 배너 반영 완료 (#1183) 2026-06-16 18:05:08 +09:00
f353450baa adminfront: handleRelationChange 함수의 tab 매개변수 유니온 타입에 'worksmobile'을 추가하여 빌드 에러 해결 (#1183) 2026-06-16 17:59:17 +09:00
c990bd591b adminfront: 권한부여 세부 탭에 네이버웍스 연동 권한(worksmobile_viewers/managers) 지원을 추가하고, 세부 권한 부여 자격을 Super Admin 전용으로 승격 (#1183) 2026-06-16 17:53:24 +09:00
26c4666a89 backend: 일반 가입 시 임의의 개별 테넌트 신설 대신 이미 시드된 shared 'personal' 테넌트에 사용자 직접 할당 구현 (#1183) 2026-06-16 17:39:25 +09:00
b1a8df3443 userfront: 기업 가입 안내 배너 내 가이드 문구를 기획 개정안에 맞게 정밀 수정 (#1183) 2026-06-16 17:30:39 +09:00
ac3226e939 backend: 개인 테넌트 자동 생성 시 슬러그 길이 초과로 인한 가입 503 오류 원천 해결 코드 반영 (#1183) 2026-06-16 17:29:03 +09:00
544aa4472a userfront: 회원가입 단계에서 도메인 판정 및 메일 작성 버튼을 제거하고 고정식 기업 가입 안내 배너로 통합 (#1183) 2026-06-16 17:21:53 +09:00
721f8475b3 userfront: 회원가입 단계에서 검증 이메일 도메인 판정 없이 단일 뷰를 노출하고 기업 임직원 가이드 배너 추가 (#1183) 2026-06-16 17:17:48 +09:00
d3ae4c7e38 userfront: 개인/기업 소속 이정표 번역 리소스 업데이트 (#1183) 2026-06-16 17:13:53 +09:00
2cd2ce4c02 userfront: 소속 선택 단계 진입 시 검증된 이메일 도메인을 자동 판정하여 가족사인 경우 연동 문의 카드를 강제 노출하고 가입 차단 구현 (#1183) 2026-06-16 17:13:45 +09:00
40eaadd88d userfront: 회원가입 단계에서 소속 유형 선택/입력 레거시 UI를 완전히 제거하고 100% 개인가입 단일화 및 기업 문의 배너 적용 (#1183) 2026-06-16 17:03:20 +09:00
b1c853b3c3 userfront: 회원가입 화면 내 소속 구분 명칭 개편 (일반/가족사 -> 개인/기업소속) 및 설명 문구 반영 (#1183) 2026-06-16 16:59:32 +09:00
95a2730e71 backend: 회원가입 시 도메인 대조를 통한 가족사(AFFILIATE) 강제 로직을 전면 제거하고 기본 개인(Personal) 가입으로 통합 (#1183) 2026-06-16 16:56:03 +09:00
2a9ab0ddc5 userfront: 회원가입 시 기본 개인(Personal) 테넌트 가입, 기업 소속은 별도 문의 이메일 안내 카드로 개편 (#1183) 2026-06-16 16:49:14 +09:00
82d908828f Merge pull request 'feature/df-ui-locale' (#1178) from feature/df-ui-locale into dev
Reviewed-on: baron/baron-sso#1178
2026-06-16 16:10:58 +09:00
kyy
072a982b5a recent changes 관계 상세 타입 수정 2026-06-16 16:03:39 +09:00
kyy
d30a324293 recent changes 관계 상세 파싱 수정 2026-06-16 15:55:14 +09:00
kyy
4b2d9c89b3 로케일 누락 키 추가 및 lint 수정 2026-06-16 15:42:10 +09:00
kyy
79bf1c3496 orgfront URL 환경변수명을 ORGFRONT_URL로 통일 2026-06-16 15:42:10 +09:00
kyy
92ba779ff9 개요 페이지 클레임 변경 내용 표현 2026-06-16 15:42:10 +09:00
kyy
66556c9f03 devfront 설정 화면 로케일 누락 수정 2026-06-16 15:42:10 +09:00
3819a29ed8 Merge pull request 'promtail, docker: Ory/Kratos 등 baron_ 접두사 없는 컨테이너의 job/service 라벨 누락으로 인한 Loki 전송 실패 오류 해결 (#1155)' (#1174) from fix/1155-promtail-label-issue into dev
Reviewed-on: baron/baron-sso#1174
2026-06-16 10:56:11 +09:00
8b67b22fa5 Merge pull request 'RP scope 설정에 offline_access 안내 추가' (#1173) from feature/df-claim-tenant into dev
Reviewed-on: baron/baron-sso#1173
2026-06-16 10:53:21 +09:00
2d1ae96e3e promtail, docker: Ory/Kratos 등 baron_ 접두사 없는 컨테이너의 job/service 라벨 누락으로 인한 Loki 전송 실패 오류 해결 (#1155) 2026-06-16 10:31:53 +09:00
kyy
c662552157 scopes 안내 버튼의 offline_access 접근성 이름 충돌 수정 2026-06-16 10:31:40 +09:00
kyy
38091429f4 RP scope 설정에 offline_access 안내 추가 2026-06-16 10:11:34 +09:00
b2808759d2 Fix org chart manager ordering and title wrapping 2026-06-15 21:11:03 +09:00
44726e5a54 Revert "Fix worksmobile column settings layout"
This reverts commit fe59b478fc.
2026-06-15 20:49:09 +09:00
fe59b478fc Fix worksmobile column settings layout 2026-06-15 20:48:05 +09:00
4c068711bf Fix worksmobile column settings layout 2026-06-15 20:46:50 +09:00
ce8a1f46a7 Merge remote-tracking branch 'origin/feature/df-claim-tenant' into dev 2026-06-15 20:31:02 +09:00
35284d72ed 병합 이후 검토 적용 2026-06-15 20:28:10 +09:00
202c783920 Merge origin/dev into dev 2026-06-15 20:05:47 +09:00
4d468cd39f 네이버 계정 정합성 맞춤 2026-06-15 19:54:09 +09:00
kyy
006113ebc7 ID Token에 rt_expires_at 클레임 추가 2026-06-15 14:42:02 +09:00
kyy
bfd9cab260 Hydra refresh token TTL 설정 경로 정리 2026-06-15 14:18:56 +09:00
kyy
3cdb7ce19f ID Token에 rt_expires_at 클레임 추가 2026-06-15 14:18:34 +09:00
kyy
98dd924e9f 허용 테넌트 테이블로 전환 2026-06-15 13:37:08 +09:00
kyy
11403b2151 테넌트 조회 경로와 테스트 기대값 정리 2026-06-15 11:53:12 +09:00
kyy
7e6c9459a9 orgfront picker 기반 테넌트 선택 테스트 추가 2026-06-15 11:52:53 +09:00
kyy
c07fcb2e94 RP 테넌트 제한을 orgfront picker로 전환 2026-06-15 11:52:01 +09:00
50ce44c236 Merge pull request '불필요한 스크린샷 제거' (#1137) from feature/df-rp-e2e into dev
Reviewed-on: baron/baron-sso#1137
2026-06-15 10:39:19 +09:00
kyy
7ca0db5a4c 불필요한 스크린샷 제거 2026-06-15 10:35:16 +09:00
bd8d1d1294 Merge pull request 'feature/df-rp-e2e' (#1135) from feature/df-rp-e2e into dev
Reviewed-on: baron/baron-sso#1135
2026-06-15 10:27:57 +09:00
64d48b9097 Merge pull request 'feature/1058-adminfront-tab-rebac-permissions' (#1125) from feature/1058-adminfront-tab-rebac-permissions into dev
Reviewed-on: baron/baron-sso#1125
2026-06-15 10:12:44 +09:00
e0ce6b6295 i18n, userfront: TOML Bare Key 규격 비호환으로 인한 번역 파일 파싱 오류 및 테스트 실패 문제 해결 2026-06-15 10:07:48 +09:00
kyy
b18d1159c4 dashboard screen의 LinkedRp import 복구 2026-06-15 09:52:25 +09:00
b714213b78 i18n, adminfront, devfront: 'make code-check' 통과를 위한 번역 싱크 엔진 개선, 룰 오브 훅 정합성 교정 및 테스트 레이스 컨디션 해결 2026-06-15 09:49:53 +09:00
kyy
6e30570a72 linked RP launch 테스트를 provider에서 분리 2026-06-15 09:43:01 +09:00
kyy
23a3a084b8 refresh-token e2e 테스트와 설정 임시제거 2026-06-15 09:36:29 +09:00
kyy
ce40df7ea3 로케일 의존성과 inputmode 고정 검사를 제거해 테스트 안정화 2026-06-15 09:24:19 +09:00
kyy
7bf1aca2f3 브라우저별 flaky assertion 정리 2026-06-12 20:28:40 +09:00
383c6bf7b9 Merge branch 'dev' into feature/1058-adminfront-tab-rebac-permissions 2026-06-12 20:28:18 +09:00
kyy
d951bd825f Playwright 테스트 기대값을 현재 UI에 맞게 보정 2026-06-12 20:23:47 +09:00
kyy
cc2565ef9b refresh_token 통합 및 login claims 테스트 보정 2026-06-12 20:16:21 +09:00
kyy
e365c97dc0 refresh_token 통합 테스트 실행 경로 정리 2026-06-12 19:52:55 +09:00
4d5b010cbc adminfront: UserCreatePage 및 UserDetailPage에 세부 기능 권한(users / manage_users) 연동 적용하여 접근 제한 버그 해결 2026-06-12 19:50:45 +09:00
aca13c01a7 adminfront: 권한 부여 설정 그리드 상의 'WORKS 연동' 하드코딩 라벨을 실제 메뉴 명칭인 'Worksmobile'로 통일 보완 2026-06-12 19:07:50 +09:00
kyy
ec55d4847e devfront 로그인 claim e2e 검증 추가 2026-06-12 19:07:37 +09:00
af48e09904 adminfront: 권한 부여 매트릭스 상의 Ory SSOT 및 데이터 정합성 토글 바(Select) 비활성화(Lock) 처리 완료 2026-06-12 18:56:26 +09:00
b5ac4e4d3f adminfront: 권한 부여 매트릭스 상의 Ory SSOT 및 데이터 정합성 메뉴 옆에 'Super Admin 전용' 시각적 뱃지 추가 완료 2026-06-12 18:51:25 +09:00
8e9d015443 kratos SSOT 재설계 2026-06-12 18:36:18 +09:00
35f0306456 adminfront: 권한 부여(permissions-direct) 메뉴 및 페이지 접근 권한을 Super Admin 전용으로 일제 제한 완료 2026-06-12 18:03:38 +09:00
09577c3257 adminfront: 비-슈퍼어드민 세부 기능 권한(tenants / manage_tenants) 자동화 검증을 위한 Playwright E2E 테스트 케이스 보강 및 통과 완료 2026-06-12 17:51:09 +09:00
7abd3069ee Merge pull request 'offline_access 스코프 유지 처리 및 refresh_token 발급 경로 수정' (#1120) from feature/custom-claim-ui into dev
Reviewed-on: baron/baron-sso#1120
2026-06-12 16:38:19 +09:00
kyy
bdd86f4d88 offline_access 스코프 유지 처리 및 refresh_token 발급 경로 수정 2026-06-12 16:33:43 +09:00
e4680ec49d Merge pull request 'offline_access 기본 강제 제거 및 refresh_token grant 정책 정리' (#1119) from feature/custom-claim-ui into dev
Reviewed-on: baron/baron-sso#1119
2026-06-12 16:06:36 +09:00
kyy
568dc258e7 offline_access 기본 강제 제거 및 refresh_token grant 정책 정리 2026-06-12 16:01:51 +09:00
2820ca941d adminfront: TenantListPage에 세부 기능 권한(tenants / manage_tenants) 우회 및 제어 전격 적용하여 접근 제한 버그 해결 2026-06-12 15:50:46 +09:00
e41a2162da Merge pull request 'feature/custom-claim-ui' (#1118) from feature/custom-claim-ui into dev
Reviewed-on: baron/baron-sso#1118
2026-06-12 15:05:18 +09:00
kyy
c587f37089 ClientConsentsPage Biome 포맷 정리 2026-06-12 15:02:45 +09:00
kyy
ca15e2a35c offline_access 기본 스코프 추가 및 refresh_token 발급 확인 2026-06-12 14:55:17 +09:00
kyy
fb7a05797c date/timezone 한 줄 정렬 2026-06-12 14:55:17 +09:00
d39838a1c9 adminfront: 권한 부여(Direct Permissions) 페이지에서 테넌트 기능 권한 탭 제거 및 시스템 메뉴 권한 단일 패널 전환 2026-06-12 11:43:40 +09:00
a70755e993 adminfront 및 백엔드: 전 메뉴 및 탭 수준 ReBAC 기반 접근 제어(Admin Control) 기능 추가 구현 완료 2026-06-12 11:40:56 +09:00
d0bdc54286 adminfront 및 백엔드: 세부 권한 변경 시 Keto 동기식 실시간 쓰기 및 프론트 일괄 갱신 적용하여 지연/롤백 버그 해결 완료 2026-06-12 11:39:28 +09:00
b96c8100e0 Merge pull request 'feature/df-rp-settings' (#1104) from feature/df-rp-settings into dev
Reviewed-on: baron/baron-sso#1104
2026-06-11 16:54:40 +09:00
kyy
73cebd993b consent 시간대 검증 반영과 RP claim 입력 검증 안정화 2026-06-11 16:45:13 +09:00
kyy
269a607302 devfront biome 오류 수정 2026-06-11 16:38:16 +09:00
kyy
5ac72be6b1 onsent 시간대 검증과 RP claim 플레이그라이트 기대값 정리 2026-06-11 16:35:25 +09:00
kyy
79845d2b6a devfront e2e test 오류 수정 2026-06-11 16:25:24 +09:00
kyy
01bc6d9b08 code-check lint 오류 수정 2026-06-11 15:37:40 +09:00
kyy
1b9421f3e6 RP Custom Claims 권한 체크박스 정리 2026-06-11 15:17:44 +09:00
kyy
d480a01857 분리된 tenant 스코프 제어 정책 적용 2026-06-11 15:16:45 +09:00
22afe6654e offline_access 제거 확인 추가 및 scope 선택 개선 2026-06-11 15:02:52 +09:00
c495e9119b offline 스코프 제거, rp_claims 값 표준화 2026-06-11 14:50:26 +09:00
f60b15a17b custom claim 타입보정 UI. 대표테넌트 노출 보정 2026-06-11 11:27:11 +09:00
0bb3ccb850 코드 테스트 실패 수정 2026-06-11 08:55:41 +09:00
4d77060b5d custom claim 권한체크 확인 2026-06-11 08:29:25 +09:00
fd6addfffd adminfront: 시스템 권한 설정 패널에 슬랙 스타일의 좌우 분할 화면(Split Screen) 및 그룹 토글 레이아웃 전격 반영 2026-06-10 17:38:52 +09:00
679c1656f4 adminfront: React Query Optimistic Updates 적용하여 세부 권한 매트릭스 UI 0ms 즉각 반응하도록 속도 고도화 완료 2026-06-10 17:30:12 +09:00
b4f80a36b0 adminfront 및 백엔드: 글로벌 사이드바 11개 전 메뉴별 ReBAC 기반 접근 제어(Admin Control) 스키마, REST API, UI 설정 패널 전격 구현 완료 2026-06-10 16:55:34 +09:00
839ca9d407 Merge pull request 'feature/df-permission' (#1073) from feature/df-permission into dev
Reviewed-on: baron/baron-sso#1073
2026-06-10 16:42:46 +09:00
kyy
1b075e049f devfront Playwright 실패 케이스 수정 2026-06-10 16:28:39 +09:00
5b4efae001 adminfront: 테넌트 상세의 5번째 서브 탭 '세부 권한' 다국어 TOML 번역 키(tab_relations) 바인딩 완료 2026-06-10 16:05:01 +09:00
4a88e4fd97 adminfront: 글로벌 사이드바 '권한 부여' 메뉴 한글화 및 ko/en 다국어 번역 키(TOML) 매핑 완료 2026-06-10 16:02:26 +09:00
kyy
01bde0925d adminfront/devfront E2E 및 lint 실패 수정 2026-06-10 15:59:33 +09:00
2fe15efeca adminfront: 글로벌 사이드바에 독립적인 '권한 부여' 메뉴 및 전용 대시보드 페이지 추가 완료 2026-06-10 15:57:07 +09:00
kyy
b591184194 devfront lint 오류 수정 2026-06-10 15:47:46 +09:00
6ebcb43b16 adminfront: 탭별 세부 권한 격리 부여를 위한 독자적인 5번째 탭(세부 권한) 추가 및 연동 완료 2026-06-10 15:44:07 +09:00
kyy
5738469983 adminfront biome 오류 수정 2026-06-10 15:43:58 +09:00
kyy
52046e4a66 adminfront/devfront code-check 수정 2026-06-10 15:42:01 +09:00
kyy
e9af231fb0 adminfront/devfront code-check 오류 수정 2026-06-10 15:19:34 +09:00
kyy
85c2eb1690 code-check 및 사용자 상세 claim 관련 오류 수정 2026-06-10 10:37:51 +09:00
kyy
4c9d219fd4 lint 포맷 불일치 수정 2026-06-10 10:20:40 +09:00
kyy
2234986abd devfront biome 오류 수정 2026-06-10 10:11:52 +09:00
kyy
b919f600e1 누락 locale 키 추가 및 린트 정리 2026-06-10 10:11:52 +09:00
kyy
437a3ad98d 개발자 권한을 페이지별로 선택/부여 가능하도록 개선 2026-06-10 10:11:52 +09:00
kyy
3ed9e912e6 테넌트 비소속 개발자 권한 신청/부여 가능 2026-06-10 10:11:52 +09:00
kyy
0f11173739 개발자 권한 부여 페이지 추가 2026-06-10 10:11:52 +09:00
kyy
41e755b1c7 devfront 테넌트 미소속 개발자 신청 안내 추가 2026-06-10 10:11:52 +09:00
kyy
894feb20f1 devfront rp_admin tenant_admin 제거 2026-06-10 10:11:52 +09:00
85707500ef adminfront 및 백엔드: ReBAC 기반 각 탭별 읽기/쓰기 권한 제어 구현 2026-06-10 10:01:30 +09:00
c880b3c333 orgfront 버그 픽스 2026-06-10 09:36:57 +09:00
28478309fa orgfront 권한 정리 2026-06-10 08:37:27 +09:00
cad1162597 Merge remote-tracking branch 'origin/dev' into dev 2026-06-09 21:08:43 +09:00
1341f07ef9 chore: consolidate local integration changes 2026-06-09 21:03:05 +09:00
107406d113 Merge pull request 'feat(monitor): add blackbox-exporter service to staging compose templates' (#1051) from feature/staging-healthcheck-monitoring into dev
Reviewed-on: baron/baron-sso#1051
2026-06-09 14:39:29 +09:00
67af52d8e2 feat(monitor): add blackbox-exporter service to staging compose templates 2026-06-09 14:38:18 +09:00
48048a24fe Merge pull request 'feature/staging-healthcheck-monitoring' (#1048) from feature/staging-healthcheck-monitoring into dev
Reviewed-on: baron/baron-sso#1048
2026-06-09 14:36:41 +09:00
4eb4c5af34 fix(monitor): update promtail config mount paths in staging compose templates 2026-06-09 14:31:00 +09:00
f61c56cfde Merge branch 'dev' into feature/staging-healthcheck-monitoring 2026-06-09 13:57:37 +09:00
2671ebda27 feat(monitor): commit preserved blackbox exporter config and observability dashboard 2026-06-09 13:53:01 +09:00
2405961375 chore(monitor): remove unused monitoring environment variables from env sample 2026-06-09 13:33:26 +09:00
ae97950108 feat(monitor): precisely exclude Loki, Grafana, and Prometheus while keeping promtail and blackbox-exporter 2026-06-09 10:23:54 +09:00
f726463a6c Merge pull request 'build 검증 워크플로우' (#1030) from feature/df-permission into dev
Reviewed-on: baron/baron-sso#1030
2026-06-08 10:56:58 +09:00
kyy
badcabb644 build 검증 워크플로우 2026-06-08 10:53:25 +09:00
aa2848c3b6 restart policy 정리 2026-06-08 08:30:51 +09:00
9be833d2e0 Merge pull request 'feature/af-tenant-ui' (#1024) from feature/af-tenant-ui into dev
Reviewed-on: baron/baron-sso#1024
2026-06-05 21:26:52 +09:00
4e81e214a3 fix(deploy): remove grafana-sms-webhook from compose templates again 2026-06-05 21:26:01 +09:00
561659f333 프롬테일 오류 수정 2026-06-05 21:22:04 +09:00
kyy
0b48fe22c7 adminfront-tests E2E 기대값을 현재 UI 동작에 맞게 정리 2026-06-05 21:16:11 +09:00
kyy
b8c1b116b1 adminfront-tests 테넌트 트리 선택 selector를 고유 testid 기준으로 수정 2026-06-05 21:07:27 +09:00
kyy
57c05c9241 adminfront 포맷 정리 2026-06-05 21:03:00 +09:00
kyy
9478944197 adminfront-tests 검색 placeholder 기대값을 현재 UI 문구에 맞게 수정 2026-06-05 21:00:40 +09:00
kyy
c9cf7d6c67 adminfront-tests 대량 테넌트 목록 집계 검증을 테스트 환경 동작에 맞게 수정 2026-06-05 20:53:28 +09:00
kyy
06d2b71e25 adminfront-tests 테넌트 타입 라벨 검증을 현재 UI에 맞게 수정 2026-06-05 20:48:39 +09:00
kyy
9803108de2 adminfront-tests Playwright CLI 경로 하드코딩 제거 2026-06-05 20:42:59 +09:00
fe176c6912 fix(deploy): remove unavailable grafana-sms-webhook and fix promtail env expansion 2026-06-05 20:42:22 +09:00
kyy
01cd7a0ad3 code-check 오류 수정 2026-06-05 20:37:23 +09:00
kyy
87a45f0e76 누락 키 추가 2026-06-05 19:30:35 +09:00
5670288616 fix(deploy): add docker login for Gitea registry in staging_code_pull workflow 2026-06-05 19:20:20 +09:00
3ab9d28c9d fix(deploy): use Gitea container registry domain for grafana-sms-webhook image 2026-06-05 19:15:37 +09:00
2dedeb66b6 feat(monitoring): add promtail and grafana-sms-webhook to staging_pull_compose template 2026-06-05 18:59:01 +09:00
1f47abb860 feat(monitoring): integrate prometheus and promtail log aggregation with sms alerts 2026-06-05 18:34:22 +09:00
kyy
a6f9d89477 사이드바 펼침/접기 형식 변환 추가 2026-06-05 17:47:45 +09:00
kyy
729a9890a6 테넌트 레지스트리 테이블 UI 복원 2026-06-05 15:32:00 +09:00
kyy
b4883bc9eb 빌드 오류 및 리다이렉트 수정 2026-06-05 14:32:49 +09:00
kyy
d54d258117 검색 placeholder와 폭 정리 2026-06-05 13:36:36 +09:00
kyy
f3e9ca52be 사용자 목록 로케일 및 링크 스타일 통일 2026-06-05 13:36:36 +09:00
kyy
1596342d03 사이드바 접기 기능 추가 2026-06-05 13:33:40 +09:00
kyy
f6c7cb3b22 tenants 레지스트리 가독성/로케일 적용 2026-06-05 13:32:27 +09:00
kyy
47d2f15283 tenants 목록 툴바 레이아웃 정리 2026-06-05 13:29:55 +09:00
29038254dd 백업/복구로직 변경, 깜빡임 버그 해결 2026-06-05 12:26:51 +09:00
4bae1dd00d fix(deploy): align staging frontend runtime with production images 2026-06-05 09:24:44 +09:00
ded9dfc56b Merge pull request 'fix(deploy): resolve frontend deployment failure by fixing workspace detection and dependency installation' (#1005) from feature/rbac-simplification-and-remove-dev-switcher into dev
Reviewed-on: baron/baron-sso#1005
2026-06-05 08:58:47 +09:00
3f4138e3a0 Merge pull request 'feature/rbac-simplification-and-remove-dev-switcher' (#1003) from feature/rbac-simplification-and-remove-dev-switcher into dev
Reviewed-on: baron/baron-sso#1003
2026-06-04 18:11:48 +09:00
ba3e9103f2 Merge pull request 'feature/rbac-simplification-and-remove-dev-switcher' (#997) from feature/rbac-simplification-and-remove-dev-switcher into dev
Reviewed-on: baron/baron-sso#997
2026-06-04 13:06:11 +09:00
477 changed files with 62548 additions and 9838 deletions

View File

@@ -36,6 +36,34 @@ CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한
WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com
WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token
# --- NAVER WORKS Drive backup upload ---
# Drive API 업로드에는 `file` scope가 필요합니다.
# 운영에서는 Drive 권한이 위임된 사용자/OAuth access token을 우선 사용하세요.
# 서비스 계정 JWT 방식은 WORKS 앱 정책에서 Drive API scope 위임이 허용된 경우에만 사용할 수 있습니다.
WORKS_DRIVE_TARGET=sharedrive
WORKS_DRIVE_SHARED_DRIVE_ID=
WORKS_DRIVE_PARENT_FILE_ID=
WORKS_DRIVE_USER_ID=me
WORKS_DRIVE_GROUP_ID=
WORKS_DRIVE_SHARED_FOLDER_ID=
WORKS_DRIVE_ACCESS_TOKEN=
WORKS_DRIVE_ACCESS_TOKEN_FILE=
WORKS_DRIVE_ACCESS_TOKEN_CMD=
WORKS_DRIVE_OAUTH_SCOPE=file
WORKS_DRIVE_OAUTH_CLIENT_ID=
WORKS_DRIVE_OAUTH_CLIENT_SECRET=
WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT=
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE=./config/worksmobile-driveapp-private-key.pem
WORKS_DRIVE_OAUTH_REFRESH_TOKEN=
WORKS_DRIVE_OAUTH_REDIRECT_URI=
WORKS_DRIVE_SPLIT_SIZE=9000M
WORKS_DRIVE_MAX_SINGLE_FILE_BYTES=0
WORKS_DRIVE_FORCE_SPLIT=false
WORKS_DRIVE_OVERWRITE=false
WORKS_DRIVE_DRY_RUN=false
WORKS_DRIVE_UPLOAD_REPORTS=true
WORKS_DRIVE_REPORT_FOLDER_NAME=reports
# Audit System Configuration # Audit System Configuration
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수 AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
@@ -118,6 +146,8 @@ HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
# HYDRA_LOGIN_URL=https://sso.hmac.kr/login # HYDRA_LOGIN_URL=https://sso.hmac.kr/login
# HYDRA_CONSENT_URL=https://sso.hmac.kr/consent # HYDRA_CONSENT_URL=https://sso.hmac.kr/consent
# HYDRA_ERROR_URL=https://sso.hmac.kr/error # HYDRA_ERROR_URL=https://sso.hmac.kr/error
# Refresh Token 만료시각 source of truth (Hydra + backend ID Token rt_expires_at claim)
HYDRA_REFRESH_TOKEN_TTL=720h
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택) # Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다. # 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
@@ -150,5 +180,9 @@ VITE_OIDC_CLIENT_ID=devfront
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
DEVFRONT_URL=http://localhost:5174 DEVFRONT_URL=http://localhost:5174
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
ORGFRONT_URL=http://localhost:5175
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback
VITE_ORGCHART_URL= VITE_ORGCHART_URL=
# promtail에서 로그를 전송받을 Loki 서버 엔드포인트 URL
LOKI_URL=http://loki:3100/loki/api/v1/push

View File

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

View File

@@ -131,7 +131,8 @@ jobs:
global='^(\.gitea/workflows/code_check\.yml|Makefile|scripts/|tools/|test/code_check_)' global='^(\.gitea/workflows/code_check\.yml|Makefile|scripts/|tools/|test/code_check_)'
front_shared='^(common/|scripts/playwrightPackageVersion\.cjs|scripts/summarize_vitest_coverage\.mjs|scripts/run_adminfront_ci_tests\.sh|\.gitea/workflows/code_check\.yml|Makefile)' front_shared='^(common/|scripts/playwrightPackageVersion\.cjs|scripts/summarize_vitest_coverage\.mjs|scripts/run_adminfront_ci_tests\.sh|\.gitea/workflows/code_check\.yml|Makefile)'
i18n_shared='^(common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)' i18n_shared='^(locales/|common/locales/|userfront/assets/translations/|scripts/sync_userfront_locales\.sh|tools/i18n-scanner/)'
react_i18n='^(adminfront/src/locales/|devfront/src/locales/|orgfront/src/locales/)'
backend=false backend=false
userfront=false userfront=false
@@ -154,7 +155,7 @@ jobs:
if matches "$front_shared|^adminfront/|^devfront/|^orgfront/"; then biome=true; fi if matches "$front_shared|^adminfront/|^devfront/|^orgfront/"; then biome=true; fi
lint=false lint=false
if [ "$backend" = true ] || [ "$userfront" = true ] || [ "$adminfront" = true ] || [ "$devfront" = true ] || [ "$orgfront" = true ] || matches "$i18n_shared"; then if [ "$backend" = true ] || [ "$userfront" = true ] || matches "$global|$i18n_shared|$react_i18n"; then
lint=true lint=true
fi fi
@@ -204,7 +205,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.25" go-version: "1.26.2"
cache-dependency-path: backend/go.sum cache-dependency-path: backend/go.sum
- name: Setup Flutter - name: Setup Flutter
@@ -213,42 +214,6 @@ jobs:
channel: "stable" channel: "stable"
cache: true cache: true
- name: Install adminfront dependencies
run: |
cd adminfront
npx pnpm install -C ../common --no-frozen-lockfile
npx pnpm install --no-frozen-lockfile
- name: Biome check adminfront (lint + format)
run: |
cd adminfront
npx biome check . --formatter-enabled=false --assist-enabled=false
npx biome check . --linter-enabled=false --assist-enabled=false
- name: Install devfront dependencies
run: |
cd devfront
npx pnpm install -C ../common --no-frozen-lockfile
npx pnpm install --no-frozen-lockfile
- name: Biome check devfront (lint + format)
run: |
cd devfront
npx biome check . --formatter-enabled=false --assist-enabled=false
npx biome check . --linter-enabled=false --assist-enabled=false
- name: Install orgfront dependencies
run: |
cd orgfront
npx pnpm install -C ../common --no-frozen-lockfile
npx pnpm install --no-frozen-lockfile
- name: Biome check orgfront (lint + format)
run: |
cd orgfront
npx biome check . --formatter-enabled=false --assist-enabled=false
npx biome check . --linter-enabled=false --assist-enabled=false
- name: Lint Go backend - name: Lint Go backend
run: | run: |
docker run --rm \ docker run --rm \
@@ -353,7 +318,7 @@ jobs:
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: "1.25" go-version: "1.26.2"
cache-dependency-path: backend/go.sum cache-dependency-path: backend/go.sum
- name: Run backend tests - name: Run backend tests
@@ -695,6 +660,7 @@ jobs:
mkdir -p reports mkdir -p reports
set +e set +e
cd userfront cd userfront
rm -rf build/web
flutter build web --wasm --release 2>&1 | tee ../reports/userfront-e2e-build.log flutter build web --wasm --release 2>&1 | tee ../reports/userfront-e2e-build.log
build_exit_code=${PIPESTATUS[0]} build_exit_code=${PIPESTATUS[0]}
cd .. cd ..
@@ -878,7 +844,7 @@ jobs:
adminfront-vitest-coverage: adminfront-vitest-coverage:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -1009,7 +975,7 @@ jobs:
devfront-vitest-coverage: devfront-vitest-coverage:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -1140,7 +1106,7 @@ jobs:
orgfront-vitest-coverage: orgfront-vitest-coverage:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }} if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -1271,7 +1237,7 @@ jobs:
adminfront-tests: adminfront-tests:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }} if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
@@ -1366,7 +1332,7 @@ jobs:
devfront-tests: devfront-tests:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }} if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -1460,7 +1426,7 @@ jobs:
run: | run: |
mkdir -p ../reports mkdir -p ../reports
set +e set +e
pnpm test 2>&1 | tee ../reports/devfront-test.log pnpm run test:ci 2>&1 | tee ../reports/devfront-test.log
test_exit_code=${PIPESTATUS[0]} test_exit_code=${PIPESTATUS[0]}
set -e set -e
@@ -1476,7 +1442,7 @@ jobs:
echo "1. \`cd devfront\`" echo "1. \`cd devfront\`"
echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`" echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`"
echo "3. \`pnpm exec playwright install --with-deps\`" echo "3. \`pnpm exec playwright install --with-deps\`"
echo "4. \`pnpm test\`" echo "4. \`pnpm run test:ci\`"
echo echo
echo "## Log Tail (last 200 lines)" echo "## Log Tail (last 200 lines)"
echo '```text' echo '```text'
@@ -1549,7 +1515,7 @@ jobs:
orgfront-tests: orgfront-tests:
needs: needs:
- changes - changes
- lint - biome-check
if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }} if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@@ -0,0 +1,96 @@
name: Deploy Baron SSO Production Images
on:
workflow_dispatch:
inputs:
image_tag:
description: "배포할 공용 저장소 이미지 태그 (예: v1.2606.ab12)"
required: true
type: string
jobs:
deploy-production-images:
runs-on: ubuntu-latest
steps:
- name: Checkout deployment scripts and templates
uses: actions/checkout@v4
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
- name: Build production deployment bundle
env:
IMAGE_TAG: ${{ github.event.inputs.image_tag }}
IMAGE_DEPLOY_ENV: production
IMAGE_DEPLOY_INSTANCE_NAME: ${{ vars.PROD_INSTANCE_NAME }}
IMAGE_DEPLOY_PORT_PREFIX: ${{ vars.PROD_PORT_PREFIX }}
IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.PROD_FRONTEND_URL }}
IMAGE_DEPLOY_COMPOSE_TEMPLATE: deploy/templates/docker-compose.images.yaml
IMAGE_DEPLOY_BUNDLE_FILE: prod-image-deploy-bundle.tgz
ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }}
DEVFRONT_URL: ${{ vars.DEVFRONT_URL }}
ORGFRONT_URL: ${{ vars.ORGFRONT_URL }}
VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }}
IMAGE_DEPLOY_DB_PORT: ${{ vars.PROD_DB_PORT }}
IMAGE_DEPLOY_REDIS_PORT: ${{ vars.PROD_REDIS_PORT }}
IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP: ${{ vars.PROD_CLICKHOUSE_PORT_HTTP }}
IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE: ${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }}
IMAGE_DEPLOY_BACKEND_PORT: ${{ vars.PROD_BACKEND_PORT }}
IMAGE_DEPLOY_FRONTEND_PORT: ${{ vars.PROD_FRONTEND_PORT }}
ADMINFRONT_PORT: ${{ vars.ADMINFRONT_PORT }}
DEVFRONT_PORT: ${{ vars.DEVFRONT_PORT }}
ORGFRONT_PORT: ${{ vars.ORGFRONT_PORT }}
IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.PROD_OATHKEEPER_PROXY_PORT }}
IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.PROD_DOMAIN_SUFFIX }}
ADMINFRONT_CALLBACK_URLS: ${{ vars.ADMINFRONT_CALLBACK_URLS }}
DEVFRONT_CALLBACK_URLS: ${{ vars.DEVFRONT_CALLBACK_URLS }}
ORGFRONT_CALLBACK_URLS: ${{ vars.ORGFRONT_CALLBACK_URLS }}
HYDRA_REFRESH_TOKEN_TTL: ${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
ORY_POSTGRES_USER: ${{ vars.ORY_POSTGRES_USER }}
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 }}
HYDRA_VERSION: ${{ vars.HYDRA_VERSION }}
KETO_VERSION: ${{ vars.KETO_VERSION }}
OATHKEEPER_VERSION: ${{ vars.OATHKEEPER_VERSION }}
ORY_POSTGRES_TAG: ${{ vars.ORY_POSTGRES_TAG }}
OATHKEEPER_UID: ${{ vars.OATHKEEPER_UID }}
OATHKEEPER_GID: ${{ vars.OATHKEEPER_GID }}
OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
ADMIN_EMAIL: ${{ vars.ADMIN_EMAIL }}
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }}
IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.PROD_ORY_POSTGRES_PASSWORD }}
IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.PROD_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.PROD_CLICKHOUSE_PASSWORD }}
IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.PROD_COOKIE_SECRET }}
IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.PROD_JWT_SECRET }}
IMAGE_DEPLOY_CSRF_COOKIE_SECRET: ${{ secrets.PROD_CSRF_COOKIE_SECRET }}
IMAGE_DEPLOY_ADMIN_PASSWORD: ${{ secrets.PROD_ADMIN_PASSWORD }}
run: |
set -euo pipefail
# Same image tag contract as staging: production must consume the
# immutable image tag that already passed staging verification.
scripts/deploy/build_image_deploy_bundle.sh
- name: Upload bundle and run requested production image tag
env:
IMAGE_DEPLOY_BUNDLE_FILE: prod-image-deploy-bundle.tgz
DEPLOY_HOST: ${{ vars.PROD_HOST }}
DEPLOY_USER: ${{ vars.PROD_USER }}
DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
run: |
set -euo pipefail
scripts/deploy/upload_and_run_image_deploy.sh

View File

@@ -0,0 +1,182 @@
name: Publish Baron SSO Production Images
on:
workflow_dispatch:
inputs:
version_prefix:
description: "공용 저장소 이미지 태그 prefix (예: v1.2606, 최종 태그는 v1.2606.<커밋해시4자리>)"
required: true
type: string
jobs:
publish-images:
runs-on: ubuntu-latest
steps:
- name: Checkout dev branch
uses: actions/checkout@v4
with:
ref: dev
- name: Validate publish inputs
env:
VERSION_PREFIX: ${{ github.event.inputs.version_prefix }}
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }}
DEVFRONT_URL: ${{ vars.DEVFRONT_URL }}
ORGFRONT_URL: ${{ vars.ORGFRONT_URL }}
VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }}
run: |
set -euo pipefail
if ! printf '%s' "${VERSION_PREFIX}" | grep -Eq '^v[0-9]+\.[0-9]{4}$'; then
echo "::error::version_prefix must look like vX.YYMM (got: ${VERSION_PREFIX})"
exit 1
fi
required_values="
HARBOR_ENDPOINT HARBOR_HOSTNAME HARBOR_ROBOT_ACCOUNT HARBOR_ROBOT_KEY
ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY
"
for key in ${required_values}; do
if [ -z "${!key:-}" ]; then
echo "::error::Missing required publish value: ${key}. Check Gitea repo variables/secrets."
exit 1
fi
done
- name: Compute commit-hash image tag
id: version
env:
VERSION_PREFIX: ${{ github.event.inputs.version_prefix }}
run: |
set -euo pipefail
short_sha="$(git rev-parse --short=4 HEAD)"
if ! printf '%s' "${short_sha}" | grep -Eq '^[0-9a-f]{4}$'; then
echo "::error::commit hash suffix must be 4 lowercase hexadecimal characters (got: ${short_sha})"
exit 1
fi
image_tag="${VERSION_PREFIX}.${short_sha}"
echo "image_tag=${image_tag}" >> "${GITHUB_OUTPUT}"
echo "Computed production image tag: ${image_tag}"
- name: Login to shared registry
uses: docker/login-action@v3
with:
registry: ${{ vars.HARBOR_ENDPOINT }}
username: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
password: ${{ secrets.HARBOR_ROBOT_KEY }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push backend production image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend:${{ steps.version.outputs.image_tag }}
provenance: false
sbom: false
- name: Build and push userfront production image
uses: docker/build-push-action@v5
with:
context: .
file: ./userfront/Dockerfile
target: production
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront:${{ steps.version.outputs.image_tag }}
provenance: false
sbom: false
- name: Build and push adminfront production image
uses: docker/build-push-action@v5
with:
context: .
file: ./adminfront/Dockerfile
target: production
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.version.outputs.image_tag }}
build-args: |
VITE_ADMIN_PUBLIC_URL=${{ vars.ADMINFRONT_URL }}
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
VITE_OIDC_CLIENT_ID=adminfront
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
provenance: false
sbom: false
- name: Build and push devfront production image
uses: docker/build-push-action@v5
with:
context: .
file: ./devfront/Dockerfile
target: production
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.version.outputs.image_tag }}
build-args: |
VITE_DEVFRONT_PUBLIC_URL=${{ vars.DEVFRONT_URL }}
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
VITE_OIDC_CLIENT_ID=devfront
ORGFRONT_URL=${{ vars.ORGFRONT_URL }}
provenance: false
sbom: false
- name: Build and push orgfront production image
uses: docker/build-push-action@v5
with:
context: .
file: ./orgfront/Dockerfile
target: production
push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.version.outputs.image_tag }}
build-args: |
VITE_ORGFRONT_PUBLIC_URL=${{ vars.ORGFRONT_URL }}
VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}
VITE_OIDC_CLIENT_ID=orgfront
provenance: false
sbom: false
- name: Upload pushed images to WORKS Drive archive
if: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_ARCHIVE_ENABLED == 'true' }}
env:
IMAGE_TAG: ${{ steps.version.outputs.image_tag }}
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR }}
WORKS_DRIVE_TARGET: sharedrive
WORKS_DRIVE_SHARED_DRIVE_ID: ${{ vars.WORKS_DRIVE_SHARED_DRIVE_ID }}
WORKS_DRIVE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_PARENT_FILE_ID }}
WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }}
WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_SECRET }}
WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT }}
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY }}
WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_OAUTH_REFRESH_TOKEN }}
WORKS_ADMIN_API_BASE_URL: ${{ vars.WORKS_ADMIN_API_BASE_URL }}
WORKS_ADMIN_OAUTH_TOKEN_URL: ${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
run: |
set -euo pipefail
: "${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:=docker-build-image}"
required_values="
IMAGE_TAG HARBOR_HOSTNAME WORKS_DRIVE_SHARED_DRIVE_ID
"
for key in ${required_values}; do
if [ -z "${!key:-}" ]; then
echo "::error::Missing required WORKS image archive value: ${key}."
exit 1
fi
done
for image in backend userfront adminfront devfront orgfront; do
image_ref="${HARBOR_HOSTNAME}/baron_sso/${image}:${IMAGE_TAG}"
docker pull "${image_ref}"
DOCKER_IMAGE_REF="${image_ref}" \
WORKS_DOCKER_IMAGE_ARCHIVE_DIR="${RUNNER_TEMP}/baron-sso-docker-image-upload" \
scripts/docker-image/upload_works_drive.sh
done

View File

@@ -42,19 +42,13 @@ jobs:
sudo apt-get update -y && sudo apt-get install -y skopeo sudo apt-get update -y && sudo apt-get install -y skopeo
fi fi
# Re-tag backend image for image in backend userfront adminfront devfront orgfront; do
echo "Re-tagging backend image..." echo "Re-tagging ${image} image..."
skopeo copy --preserve-digests \ skopeo copy --preserve-digests \
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \ --src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
--src-tls-verify=false --dest-tls-verify=false \ --src-tls-verify=false --dest-tls-verify=false \
"docker://${HARBOR_HOSTNAME}/baron_sso/backend:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/backend:${RE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${RE_TAG}"
done
# Re-tag userfront image
echo "Re-tagging userfront image..."
skopeo copy --preserve-digests \
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
--src-tls-verify=false --dest-tls-verify=false \
"docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${RE_TAG}"
echo "final_image_tag=${RE_TAG}" >> "$GITHUB_OUTPUT" echo "final_image_tag=${RE_TAG}" >> "$GITHUB_OUTPUT"
@@ -68,6 +62,9 @@ jobs:
IMAGE_TAG: ${{ steps.retag.outputs.final_image_tag }} IMAGE_TAG: ${{ steps.retag.outputs.final_image_tag }}
BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }} DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
PROD_HOST: ${{ vars.PROD_HOST }} PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }} PROD_USER: ${{ vars.PROD_USER }}
@@ -101,8 +98,12 @@ jobs:
"CLICKHOUSE_PORT_NATIVE=${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }}" \ "CLICKHOUSE_PORT_NATIVE=${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }}" \
"CLICKHOUSE_USER=${{ vars.PROD_CLICKHOUSE_USER }}" \ "CLICKHOUSE_USER=${{ vars.PROD_CLICKHOUSE_USER }}" \
"CLICKHOUSE_PASSWORD=${{ secrets.PROD_CLICKHOUSE_PASSWORD }}" \ "CLICKHOUSE_PASSWORD=${{ secrets.PROD_CLICKHOUSE_PASSWORD }}" \
"BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \ "PROD_BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
"USERFRONT_PORT=${{ vars.PROD_USERFRONT_PORT }}" \ "BACKEND_PORT=3000" \
"USERFRONT_PORT=${{ vars.PROD_FRONTEND_PORT }}" \
"ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}" \
"DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}" \
"ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}" \
"DB_USER=${{ vars.PROD_DB_USER }}" \ "DB_USER=${{ vars.PROD_DB_USER }}" \
"DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}" \ "DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}" \
"DB_NAME=${{ vars.PROD_DB_NAME }}" \ "DB_NAME=${{ vars.PROD_DB_NAME }}" \
@@ -117,10 +118,34 @@ jobs:
"AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}" \ "AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}" \
"AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}" \ "AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}" \
"AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}" \ "AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}" \
"USERFRONT_URL=${{ vars.PROD_USERFRONT_URL }}" \ "USERFRONT_URL=${{ vars.PROD_FRONTEND_URL }}" \
"ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}" \
"DEVFRONT_URL=${{ vars.DEVFRONT_URL }}" \
"ORGFRONT_URL=${{ vars.ORGFRONT_URL }}" \
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \ "BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
"VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}" \
"HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}" \
"ADMINFRONT_CALLBACK_URLS=${{ vars.ADMINFRONT_CALLBACK_URLS }}" \
"DEVFRONT_CALLBACK_URLS=${{ vars.DEVFRONT_CALLBACK_URLS }}" \
"ORGFRONT_CALLBACK_URLS=${{ vars.ORGFRONT_CALLBACK_URLS }}" \
> .env > .env
required_dotenv_keys="
APP_ENV TZ DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_USER CLICKHOUSE_PASSWORD
PROD_BACKEND_PORT BACKEND_PORT USERFRONT_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER
USERFRONT_URL ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL BACKEND_URL VITE_OIDC_AUTHORITY HYDRA_REFRESH_TOKEN_TTL
ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
"
for key in ${required_dotenv_keys}; do
if ! grep -Eq "^${key}=.+" .env; then
echo "::error::Missing required production .env value: ${key}. Check Gitea repo variables/secrets."
exit 1
fi
done
# Copy compose template and .env file to the remote server # Copy compose template and .env file to the remote server
scp adminfront/seed-tenant.csv "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/adminfront/" 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/docker-compose.template.yaml .env "${PROD_USER}@${PROD_HOST}:${DEPLOY_PATH}/"
@@ -131,6 +156,9 @@ jobs:
"export DEPLOY_PATH='${DEPLOY_PATH}'; \ "export DEPLOY_PATH='${DEPLOY_PATH}'; \
export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \ export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \
export USERFRONT_IMAGE_NAME='${USERFRONT_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 IMAGE_TAG='${IMAGE_TAG}'; \
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \ export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \ export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \

View File

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

View File

@@ -115,6 +115,7 @@ jobs:
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }} KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }} HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }} HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
JWKS_URL=${{ vars.JWKS_URL }} JWKS_URL=${{ vars.JWKS_URL }}
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }} OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }} OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
@@ -135,6 +136,11 @@ jobs:
KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.KRATOS_ALLOWED_RETURN_URLS_EXTRA }} KRATOS_ALLOWED_RETURN_URLS_EXTRA=${{ vars.KRATOS_ALLOWED_RETURN_URLS_EXTRA }}
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }} # OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} # OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
# Monitoring & Alerts
SMS_WEBHOOK_PORT=${{ vars.SMS_WEBHOOK_PORT || '8080' }}
MONITOR_RECIPIENT_PHONES=${{ vars.MONITOR_RECIPIENT_PHONES || '01012345678,01098765432' }}
LOKI_URL=${{ vars.LOKI_URL || 'http://loki:3100/loki/api/v1/push' }}
EOF EOF
# 코드 업데이트 (Git) # 코드 업데이트 (Git)
@@ -190,7 +196,7 @@ jobs:
max="${FRONTEND_HEALTH_MAX_ATTEMPTS:-60}" max="${FRONTEND_HEALTH_MAX_ATTEMPTS:-60}"
i=1 i=1
while [ "${i}" -le "${max}" ]; do 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 if docker exec "${name}" sh -c "if command -v wget >/dev/null 2>&1; then wget -qO- 'http://127.0.0.1:${port}/' >/dev/null; elif command -v node >/dev/null 2>&1; then node -e \"fetch('http://127.0.0.1:${port}/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"; else exit 127; fi" >/dev/null 2>&1; then
echo "Frontend ready: ${name}:${port}" echo "Frontend ready: ${name}:${port}"
return 0 return 0
fi fi
@@ -203,6 +209,28 @@ jobs:
return 1 return 1
} }
check_container_url() {
name="$1"
url="$2"
max="${FRONTEND_HEALTH_MAX_ATTEMPTS:-60}"
i=1
while [ "${i}" -le "${max}" ]; do
if docker exec "${name}" sh -c "if command -v wget >/dev/null 2>&1; then wget -qO- '${url}' >/dev/null; elif command -v node >/dev/null 2>&1; then node -e \"fetch('${url}').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"; else exit 127; fi" >/dev/null 2>&1; then
echo "Container URL ready: ${name} ${url}"
return 0
fi
echo "Waiting for container URL: ${name} ${url} (${i}/${max})"
i=$((i + 1))
sleep 2
done
echo "ERROR: container URL not ready: ${name} ${url}" >&2
docker logs "${name}" --tail 200 >&2 || true
return 1
}
check_container_url baron_backend http://127.0.0.1:3000/health
check_container_http baron_userfront 5000
check_container_http baron_gateway 5000
check_container_http baron_adminfront 5173 check_container_http baron_adminfront 5173
check_container_http baron_devfront 5173 check_container_http baron_devfront 5173
check_container_http baron_orgfront 5175 check_container_http baron_orgfront 5175

View File

@@ -0,0 +1,94 @@
name: Deploy Baron SSO Staging Images
on:
workflow_dispatch:
inputs:
image_tag:
description: "스테이징에 배포할 공용 저장소 이미지 태그 (예: v1.2606.ab12)"
required: true
type: string
jobs:
deploy-staging-images:
runs-on: ubuntu-latest
steps:
- name: Checkout deployment scripts and templates
uses: actions/checkout@v4
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
- name: Build staging deployment bundle
env:
IMAGE_TAG: ${{ github.event.inputs.image_tag }}
IMAGE_DEPLOY_ENV: stage
IMAGE_DEPLOY_INSTANCE_NAME: ${{ vars.STAGE_INSTANCE_NAME }}
IMAGE_DEPLOY_PORT_PREFIX: ${{ vars.STAGE_PORT_PREFIX }}
IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.USERFRONT_URL }}
IMAGE_DEPLOY_COMPOSE_TEMPLATE: deploy/templates/docker-compose.images.yaml
IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz
ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }}
DEVFRONT_URL: ${{ vars.DEVFRONT_URL }}
ORGFRONT_URL: ${{ vars.ORGFRONT_URL }}
VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }}
IMAGE_DEPLOY_DB_PORT: ${{ vars.DB_PORT }}
IMAGE_DEPLOY_REDIS_PORT: ${{ vars.REDIS_PORT }}
IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP: ${{ vars.CLICKHOUSE_PORT_HTTP }}
IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE: ${{ vars.CLICKHOUSE_PORT_NATIVE }}
IMAGE_DEPLOY_BACKEND_PORT: ${{ vars.BACKEND_PORT }}
IMAGE_DEPLOY_FRONTEND_PORT: ${{ vars.USERFRONT_PORT }}
ADMINFRONT_PORT: ${{ vars.ADMINFRONT_PORT }}
DEVFRONT_PORT: ${{ vars.DEVFRONT_PORT }}
ORGFRONT_PORT: ${{ vars.ORGFRONT_PORT }}
IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.OATHKEEPER_PROXY_PORT }}
IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.DOMAIN_SUFFIX }}
ADMINFRONT_CALLBACK_URLS: ${{ vars.ADMINFRONT_CALLBACK_URLS }}
DEVFRONT_CALLBACK_URLS: ${{ vars.DEVFRONT_CALLBACK_URLS }}
ORGFRONT_CALLBACK_URLS: ${{ vars.ORGFRONT_CALLBACK_URLS }}
HYDRA_REFRESH_TOKEN_TTL: ${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
ORY_POSTGRES_USER: ${{ vars.ORY_POSTGRES_USER }}
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 }}
HYDRA_VERSION: ${{ vars.HYDRA_VERSION }}
KETO_VERSION: ${{ vars.KETO_VERSION }}
OATHKEEPER_VERSION: ${{ vars.OATHKEEPER_VERSION }}
ORY_POSTGRES_TAG: ${{ vars.ORY_POSTGRES_TAG }}
OATHKEEPER_UID: ${{ vars.OATHKEEPER_UID }}
OATHKEEPER_GID: ${{ vars.OATHKEEPER_GID }}
OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
ADMIN_EMAIL: ${{ vars.ADMIN_EMAIL }}
HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }}
BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.STG_DB_PASSWORD }}
IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }}
IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.STG_COOKIE_SECRET }}
IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.STG_JWT_SECRET }}
IMAGE_DEPLOY_CSRF_COOKIE_SECRET: ${{ secrets.STG_CSRF_COOKIE_SECRET }}
IMAGE_DEPLOY_ADMIN_PASSWORD: ${{ secrets.STG_ADMIN_PASSWORD }}
run: |
set -euo pipefail
scripts/deploy/build_image_deploy_bundle.sh
- name: Upload bundle and run requested staging image tag
env:
IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz
DEPLOY_HOST: ${{ vars.STAGE_HOST }}
DEPLOY_USER: ${{ vars.STAGE_USER }}
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }}
HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }}
HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }}
run: |
set -euo pipefail
scripts/deploy/upload_and_run_image_deploy.sh

View File

@@ -69,7 +69,7 @@ jobs:
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }} CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }} CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }} CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
CLICKHOUSE_PASSWORD=${{ vars.CLICKHOUSE_PASSWORD }} CLICKHOUSE_PASSWORD=${{ secrets.CLICKHOUSE_PASSWORD }}
BACKEND_PORT=${{ vars.BACKEND_PORT }} BACKEND_PORT=${{ vars.BACKEND_PORT }}
@@ -123,6 +123,7 @@ jobs:
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }} KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }} HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }} HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
JWKS_URL=${{ vars.JWKS_URL }} JWKS_URL=${{ vars.JWKS_URL }}
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }} OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }} OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
@@ -142,9 +143,32 @@ jobs:
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} # OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
EOF EOF
required_dotenv_keys="
APP_ENV BACKEND_LOG_LEVEL CLIENT_LOG_DEBUG WORKS_ADMIN_API_BASE_URL WORKS_ADMIN_OAUTH_TOKEN_URL TZ IDP_PROVIDER
DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_HOST CLICKHOUSE_USER CLICKHOUSE_PASSWORD
BACKEND_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT USERFRONT_PORT OATHKEEPER_API_URL
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR CORS_ALLOWED_ORIGINS PROFILE_CACHE_TTL
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER ADMIN_EMAIL ADMIN_PASSWORD
USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL
ORY_POSTGRES_TAG ORY_POSTGRES_USER ORY_POSTGRES_PASSWORD ORY_POSTGRES_DB KRATOS_DB HYDRA_DB KETO_DB
KRATOS_VERSION KRATOS_UI_NODE_VERSION HYDRA_VERSION KETO_VERSION ORY_SDK_URL KRATOS_PUBLIC_URL
KRATOS_ADMIN_URL KRATOS_BROWSER_URL KRATOS_UI_URL HYDRA_ADMIN_URL HYDRA_PUBLIC_URL HYDRA_REFRESH_TOKEN_TTL JWKS_URL
OATHKEEPER_VERSION OATHKEEPER_UID OATHKEEPER_GID OATHKEEPER_HEALTH_URL OATHKEEPER_HEALTH_INTERVAL_SECONDS
OATHKEEPER_HEALTH_TIMEOUT_SECONDS OATHKEEPER_HEALTH_ENABLED CSRF_COOKIE_NAME CSRF_COOKIE_SECRET
VITE_OIDC_AUTHORITY ADMINFRONT_CALLBACK_URLS DEVFRONT_CALLBACK_URLS ORGFRONT_CALLBACK_URLS
"
for key in ${required_dotenv_keys}; do
if ! grep -Eq "^${key}=.+" .env; then
echo "::error::Missing required staging .env value: ${key}. Check Gitea repo variables/secrets."
exit 1
fi
done
# 파일 복사 # 파일 복사
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker" ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/docker"
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/adminfront" ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/adminfront"
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/scripts"
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함) # [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/" scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
@@ -158,9 +182,10 @@ jobs:
fi fi
scp adminfront/seed-tenant.csv "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/adminfront/" scp adminfront/seed-tenant.csv "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/adminfront/"
scp scripts/render_ory_config.sh "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/scripts/"
scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/" scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml" scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml"
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml" scp compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
# 배포 실행 # 배포 실행
echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \ echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \
@@ -182,6 +207,9 @@ jobs:
docker network inspect \"\$net\" >/dev/null 2>&1 || docker network create \"\$net\" docker network inspect \"\$net\" >/dev/null 2>&1 || docker network create \"\$net\"
done done
bash scripts/render_ory_config.sh; \
chmod -R 777 config/.generated/ory || true; \
envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \ envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨) # [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)

View File

@@ -121,6 +121,7 @@ jobs:
- name: Build userfront WASM - name: Build userfront WASM
run: | run: |
cd userfront cd userfront
rm -rf build/web
flutter build web --wasm --release flutter build web --wasm --release
cd .. cd ..
node userfront/scripts/optimize-web-build.mjs userfront/build/web node userfront/scripts/optimize-web-build.mjs userfront/build/web

2
.gitignore vendored
View File

@@ -17,6 +17,8 @@ config/.generated/
.npm-cache/ .npm-cache/
reports reports
reports/* reports/*
/backups/
/tmp/rp-restore-*/
config/*.pem config/*.pem
common/node_modules common/node_modules
common/.baron-deps-install.lock common/.baron-deps-install.lock

285
Makefile
View File

@@ -29,54 +29,126 @@ ifneq (,$(wildcard ./.env))
COMPOSE_DROP_ENV_ARGS += --env-file .env COMPOSE_DROP_ENV_ARGS += --env-file .env
endif 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 dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app DUMP_SERVICES ?= all
RESTORE_SERVICES ?= all
FILE_PATH ?=
RESTORE_INPUT ?= $(or $(FILE_PATH),$(word 2,$(MAKECMDGOALS)))
CONFIRM_RESTORE ?=
ALLOW_NON_EMPTY_RESTORE ?= false
DUMP_MODE ?= maintenance
BACKUP_USE_DOCKER ?= true
BACKUP_TOOLS_IMAGE ?= baron-sso-backup-tools:local
BACKUP_TOOLS_DOCKERFILE ?= docker/backup-tools/Dockerfile
BACKUP_DOCKER_ENV_ARGS :=
ifneq (,$(wildcard ./.env))
BACKUP_DOCKER_ENV_ARGS += --env-file .env
endif
ifneq (,$(wildcard ./$(AUTH_CONFIG_ENV)))
BACKUP_DOCKER_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
endif
BACKUP_DOCKER_RUN = docker run --rm $(BACKUP_DOCKER_ENV_ARGS) -e BACKUP_REPO_ROOT=/workspace -v /var/run/docker.sock:/var/run/docker.sock -v "$(CURDIR)":/workspace -v /tmp:/tmp -w /workspace $(BACKUP_TOOLS_IMAGE)
DOCKER_IMAGE_REF ?=
WORKS_DOCKER_COMMIT_CONTAINER ?=
WORKS_DOCKER_IMAGE_ARCHIVE_DIR ?= /tmp/baron-sso-docker-image-upload
.PHONY: help 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 ensure-restore-containers up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app backup-tools-build dump restore dump-verify restore-verify dump-list restore-plan upload-cloud works-drive-refresh-token dump-upload-cloud docker-image-upload-works
help: ## 생성된 타깃과 옵션 목록 표시
@printf "Usage:\n make <target> [OPTION=value ...]\n\n"
@printf "Targets:\n"
@awk ' \
BEGIN { current = ""; printed_section = 0 } \
/^# --- .+ ---/ { \
current = $$0; \
gsub(/^# ---[[:space:]]*/, "", current); \
gsub(/[[:space:]]*---$$/, "", current); \
next; \
} \
/^[[:alnum:]_.-]+:([^=]|$$)/ { \
line = $$0; \
target = line; \
sub(/:.*/, "", target); \
if (target ~ /^\.|%/) { next } \
if (seen[target]++) { next } \
desc = ""; \
if (line ~ /##/) { \
desc = line; \
sub(/^.*##[[:space:]]*/, "", desc); \
} \
if (current != "" && current != printed_section) { \
printf "\n %s\n", current; \
printed_section = current; \
} \
if (desc != "") { \
printf " %-36s %s\n", target, desc; \
} else { \
printf " %-36s\n", target; \
} \
} \
' Makefile
@printf "\nOptions:\n"
@awk ' \
/^[A-Z][A-Z0-9_]+[[:space:]]*\?=/ { \
name = $$1; \
value = $$0; \
sub(/[[:space:]]*\?=.*/, "", name); \
sub(/^[^?]+\?=[[:space:]]*/, "", value); \
printf " %-32s default: %s\n", name, value; \
} \
' Makefile
@printf "\nRestore Safety:\n"
@printf " CONFIRM_RESTORE=baron-sso 복구 실행 의도를 명시하는 필수 확인값\n"
@printf " ALLOW_NON_EMPTY_RESTORE=true 비어 있지 않은 복구 대상에 덮어쓰는 승인된 복구에서만 사용\n"
@printf "\nRestore Examples:\n"
@printf " make restore-plan FILE_PATH=stg.today.tar.gz CONFIRM_RESTORE=baron-sso\n"
@printf " make restore FILE_PATH=stg.today.tar.gz CONFIRM_RESTORE=baron-sso ALLOW_NON_EMPTY_RESTORE=true\n"
# --- 인증 설정 빌드/검증 --- # --- 인증 설정 빌드/검증 ---
build-auth-config: build-auth-config: ## 인증 설정 파일 생성
@echo "Building auth config..." @echo "Building auth config..."
@mkdir -p config/.generated @mkdir -p config/.generated
@bash scripts/auth_config.sh build @bash scripts/auth_config.sh build
validate-auth-config: build-auth-config validate-auth-config: build-auth-config ## 인증 설정 값 검증
@echo "Validating auth config..." @echo "Validating auth config..."
@bash scripts/auth_config.sh validate @bash scripts/auth_config.sh validate
verify-auth-config: validate-auth-config verify-auth-config: validate-auth-config ## 인증 설정 연결 상태 확인
@echo "Verifying auth config wiring..." @echo "Verifying auth config wiring..."
@bash scripts/auth_config.sh verify @bash scripts/auth_config.sh verify
render-ory-config: validate-auth-config render-ory-config: validate-auth-config ## Ory 설정 파일 렌더링
@echo "Rendering Ory config..." @echo "Rendering Ory config..."
@bash scripts/render_ory_config.sh @bash scripts/render_ory_config.sh
# --- 기본 실행 --- # --- 기본 실행 ---
# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음) # 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음)
up: up-all up: up-all ## 전체 로컬 스택 실행
up-all: ensure-networks render-ory-config up-all: ensure-networks render-ory-config ## 인프라, Ory, 앱 스택 모두 실행
@echo "Starting ALL stacks (infra + ory + app)..." @echo "Starting ALL stacks (infra + ory + app)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos
# --- 개별 스택 실행 --- # --- 개별 스택 실행 ---
up-infra: ensure-networks up-infra: ensure-networks ## 인프라 스택 실행
@echo "Starting Infra stack (postgres/clickhouse/redis)..." @echo "Starting Infra stack (postgres/clickhouse/redis)..."
docker compose -f $(COMPOSE_INFRA) up -d docker compose -f $(COMPOSE_INFRA) up -d
up-ory: ensure-networks render-ory-config up-ory: ensure-networks render-ory-config ## Ory 스택 실행
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..." @echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos
up-app: ensure-networks render-ory-config up-app: ensure-networks render-ory-config ## 앱 스택 실행
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..." @echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d
up-backend: ensure-networks render-ory-config up-backend: ensure-networks render-ory-config ## 백엔드 컨테이너만 실행
@echo "Starting Backend only..." @echo "Starting Backend only..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d backend docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d backend
ensure-networks: ensure-networks: ## 개발용 Docker 네트워크 보장
@echo "Ensuring Docker networks..." @echo "Ensuring Docker networks..."
@for network in $(DEV_NETWORKS); do \ @for network in $(DEV_NETWORKS); do \
if ! docker network inspect "$$network" >/dev/null 2>&1; then \ if ! docker network inspect "$$network" >/dev/null 2>&1; then \
@@ -87,7 +159,7 @@ ensure-networks:
fi; \ fi; \
done done
ensure-infra: ensure-networks ensure-infra: ensure-networks ## 인프라 스택 실행 상태 보장
@echo "Ensuring Infra stack..." @echo "Ensuring Infra stack..."
@missing=0; \ @missing=0; \
for container in $(INFRA_CONTAINERS); do \ for container in $(INFRA_CONTAINERS); do \
@@ -103,7 +175,7 @@ ensure-infra: ensure-networks
echo "Infra stack is already running."; \ echo "Infra stack is already running."; \
fi fi
ensure-ory: ensure-networks render-ory-config ensure-ory: ensure-networks render-ory-config ## Ory 스택 실행 상태 보장
@echo "Ensuring Ory stack..." @echo "Ensuring Ory stack..."
@missing=0; \ @missing=0; \
for container in $(ORY_CONTAINERS); do \ for container in $(ORY_CONTAINERS); do \
@@ -120,26 +192,74 @@ ensure-ory: ensure-networks render-ory-config
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos; \ docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos; \
fi fi
up-dev: ensure-infra ensure-ory ensure-restore-containers: ## 복구 대상 저장소 컨테이너 실행 상태 보장
@echo "Ensuring restore target containers..."
@if [ "$(CONFIRM_RESTORE)" != "baron-sso" ]; then \
echo "Skipping restore target container startup until CONFIRM_RESTORE=baron-sso is provided."; \
exit 0; \
fi
@$(MAKE) --no-print-directory ensure-networks
@ensure_restore_container() { \
container="$$1"; \
compose_file="$$2"; \
compose_service="$$3"; \
if docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null | grep -qx 'true'; then \
echo "Restore target container $$container is already running."; \
return 0; \
fi; \
if docker inspect "$$container" >/dev/null 2>&1; then \
echo "Starting stopped restore target container $$container..."; \
docker start "$$container"; \
else \
echo "Creating restore target container $$container via $$compose_file service $$compose_service..."; \
docker compose -f "$$compose_file" up -d "$$compose_service"; \
fi; \
for attempt in 1 2 3 4 5 6 7 8 9 10; do \
if docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null | grep -qx 'true'; then \
return 0; \
fi; \
sleep 1; \
done; \
echo "ERROR: restore target container $$container did not reach running state." >&2; \
return 1; \
}; \
services="$(RESTORE_SERVICES)"; \
if [ -z "$$services" ] || [ "$$services" = "all" ]; then \
services="postgres ory-postgres clickhouse ory-clickhouse config"; \
else \
services="$$(printf '%s' "$$services" | tr ',' ' ')"; \
fi; \
for service in $$services; do \
case "$$service" in \
postgres) ensure_restore_container baron_postgres compose.infra.yaml postgres ;; \
ory-postgres) ensure_restore_container ory_postgres compose.ory.yaml postgres ;; \
clickhouse) ensure_restore_container baron_clickhouse compose.infra.yaml clickhouse ;; \
ory-clickhouse) ensure_restore_container ory_clickhouse compose.ory.yaml ory_clickhouse ;; \
config) ;; \
*) echo "ERROR: unknown restore service: $$service" >&2; exit 1 ;; \
esac; \
done
up-dev: ensure-infra ensure-ory ## 개발 기본 스택 준비
@echo "Dev stack is up (infra + ory)." @echo "Dev stack is up (infra + ory)."
up-front-dev: up-infra up-ory up-backend up-front-dev: up-infra up-ory up-backend ## 프론트 개발용 의존 스택 준비
@echo "Dev stack is up (infra + ory + backend)." @echo "Dev stack is up (infra + ory + backend)."
dev: up-dev dev: up-dev ## 개발 앱 컨테이너를 포그라운드로 실행
@echo "Starting development app containers in foreground attach mode..." @echo "Starting development app containers in foreground attach mode..."
BACKEND_LOG_LEVEL=info CLIENT_LOG_DEBUG=false VITE_CLIENT_LOG_DEBUG=false docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES) BACKEND_LOG_LEVEL=info CLIENT_LOG_DEBUG=false VITE_CLIENT_LOG_DEBUG=false docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
dev-debug: up-dev dev-debug: up-dev ## 디버그 로그로 개발 앱 컨테이너 실행
@echo "Starting development app containers in foreground attach debug mode..." @echo "Starting development app containers in foreground attach debug mode..."
BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true VITE_CLIENT_LOG_DEBUG=true USERFRONT_FLUTTER_RUN_FLAGS=--debug docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES) BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true VITE_CLIENT_LOG_DEBUG=true USERFRONT_FLUTTER_RUN_FLAGS=--debug docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
# --- 종료 (Down) --- # --- 종료 (Down) ---
down: down: ## 전체 로컬 스택 중지
@echo "Stopping ALL stacks (infra + ory + app)..." @echo "Stopping ALL stacks (infra + ory + app)..."
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
drop: drop: ## 로컬 스택 컨테이너, 볼륨, 로컬 이미지 제거
@echo "Dropping Baron SSO local Docker stack containers, volumes, and local images..." @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 -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..." @echo "Removing any remaining fixed-name Baron SSO containers..."
@@ -148,25 +268,25 @@ drop:
done done
@echo "Drop complete. External Docker networks are preserved." @echo "Drop complete. External Docker networks are preserved."
down-app: down-app: ## 앱 스택 중지
@echo "Stopping App stack..." @echo "Stopping App stack..."
docker compose -f $(COMPOSE_APP) down docker compose -f $(COMPOSE_APP) down
down-backend: down-backend: ## 백엔드 컨테이너 중지
@echo "Stopping Backend only..." @echo "Stopping Backend only..."
docker compose -f $(COMPOSE_APP) stop backend docker compose -f $(COMPOSE_APP) stop backend
down-infra: down-infra: ## 인프라 스택 중지
@echo "Stopping Infra stack..." @echo "Stopping Infra stack..."
docker compose -f $(COMPOSE_INFRA) down docker compose -f $(COMPOSE_INFRA) down
down-ory: down-ory: ## Ory 스택 중지
@echo "Stopping Ory stack..." @echo "Stopping Ory stack..."
docker compose -f $(COMPOSE_ORY) down docker compose -f $(COMPOSE_ORY) down
# --- 유틸리티 --- # --- 유틸리티 ---
# 인프라 상태 확인 # 인프라 상태 확인
check-infra: check-infra: ## 인프라 헬스 상태 확인
@echo "Checking infra status..." @echo "Checking infra status..."
@if [ "$$(docker inspect -f '{{.State.Health.Status}}' baron_postgres 2>/dev/null)" != "healthy" ]; then \ @if [ "$$(docker inspect -f '{{.State.Health.Status}}' baron_postgres 2>/dev/null)" != "healthy" ]; then \
echo "Error: PostgreSQL is not running or not healthy."; \ echo "Error: PostgreSQL is not running or not healthy."; \
@@ -176,18 +296,77 @@ check-infra:
echo "PostgreSQL is healthy."; \ echo "PostgreSQL is healthy."; \
fi fi
ps: ps: ## 전체 Compose 컨테이너 상태 조회
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) ps docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) ps
logs-infra: logs-infra: ## 인프라 스택 로그 팔로우
docker compose -f $(COMPOSE_INFRA) logs -f docker compose -f $(COMPOSE_INFRA) logs -f
logs-ory: logs-ory: ## Ory 스택 로그 팔로우
docker compose -f $(COMPOSE_ORY) logs -f docker compose -f $(COMPOSE_ORY) logs -f
logs-app: logs-app: ## 앱 스택 로그 팔로우
docker compose -f $(COMPOSE_APP) logs -f docker compose -f $(COMPOSE_APP) logs -f
# --- 백업/복구 ---
backup-tools-build: ## 백업 도구 Docker 이미지 빌드
docker build -f $(BACKUP_TOOLS_DOCKERFILE) -t $(BACKUP_TOOLS_IMAGE) .
ifeq ($(BACKUP_USE_DOCKER),true)
dump: backup-tools-build ## 백업 덤프 생성
$(BACKUP_DOCKER_RUN) bash -lc 'DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh'
restore: backup-tools-build ensure-restore-containers ## 백업 덤프 복구
$(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh'
dump-verify: backup-tools-build ## 백업 덤프 검증
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh'
restore-verify: backup-tools-build ## 복구 결과 검증
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh'
dump-list: backup-tools-build ## 사용 가능한 백업 덤프 목록 조회
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh'
restore-plan: backup-tools-build ## 복구 실행 계획 출력
$(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh'
upload-cloud: backup-tools-build ## 백업 덤프 클라우드 업로드
$(BACKUP_DOCKER_RUN) bash -lc '$(if $(WORKS_DRIVE_DRY_RUN),WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" )$(if $(WORKS_DRIVE_AUTH_MODE),WORKS_DRIVE_AUTH_MODE="$(WORKS_DRIVE_AUTH_MODE)" )BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh'
works-drive-refresh-token: ## WORKS Drive OAuth refresh token 갱신
WORKS_DRIVE_TOKEN_GRANT="$(WORKS_DRIVE_TOKEN_GRANT)" WORKS_DRIVE_AUTH_CODE="$(WORKS_DRIVE_AUTH_CODE)" WORKS_DRIVE_AUTH_CALLBACK_URL="$(WORKS_DRIVE_AUTH_CALLBACK_URL)" scripts/backup/refresh_works_drive_token.sh
else
dump: ## 백업 덤프 생성
DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh
restore: ensure-restore-containers ## 백업 덤프 복구
RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh
dump-verify: ## 백업 덤프 검증
BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh
restore-verify: ## 복구 결과 검증
BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh
dump-list: ## 사용 가능한 백업 덤프 목록 조회
BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh
restore-plan: ## 복구 실행 계획 출력
RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh
upload-cloud: ## 백업 덤프 클라우드 업로드
$(if $(WORKS_DRIVE_DRY_RUN),WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" )$(if $(WORKS_DRIVE_AUTH_MODE),WORKS_DRIVE_AUTH_MODE="$(WORKS_DRIVE_AUTH_MODE)" )BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh
works-drive-refresh-token: ## WORKS Drive OAuth refresh token 갱신
WORKS_DRIVE_TOKEN_GRANT="$(WORKS_DRIVE_TOKEN_GRANT)" WORKS_DRIVE_AUTH_CODE="$(WORKS_DRIVE_AUTH_CODE)" WORKS_DRIVE_AUTH_CALLBACK_URL="$(WORKS_DRIVE_AUTH_CALLBACK_URL)" scripts/backup/refresh_works_drive_token.sh
endif
dump-upload-cloud: dump upload-cloud ## 백업 덤프 생성 후 클라우드 업로드
docker-image-upload-works: ## Docker 이미지를 WORKS Shared Drive archive로 업로드
WORKS_DOCKER_COMMIT_CONTAINER="$(WORKS_DOCKER_COMMIT_CONTAINER)" DOCKER_IMAGE_REF="$(DOCKER_IMAGE_REF)" WORKS_DOCKER_IMAGE_ARCHIVE_DIR="$(WORKS_DOCKER_IMAGE_ARCHIVE_DIR)" scripts/docker-image/upload_works_drive.sh
# --- 로컬 통합 코드 체크 --- # --- 로컬 통합 코드 체크 ---
PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright
PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE
@@ -203,12 +382,12 @@ CODE_CHECK_TEST_JOBS ?= 1
PLAYWRIGHT_WORKERS ?= 1 PLAYWRIGHT_WORKERS ?= 1
FLUTTER_TEST_CONCURRENCY ?= 1 FLUTTER_TEST_CONCURRENCY ?= 1
code-check: code-check-lint code-check-test-jobs code-check: code-check-lint code-check-test-jobs ## 로컬 CI 상당 코드 검사 실행
@echo "code-check complete." @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-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: code-check-test-jobs: ## 코드 검사 테스트 작업 실행
@echo "==> run CI-equivalent test jobs (parallel)" @echo "==> run CI-equivalent test jobs (parallel)"
@$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \ @$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \
code-check-backend-tests \ code-check-backend-tests \
@@ -218,20 +397,20 @@ code-check-test-jobs:
code-check-devfront-tests \ code-check-devfront-tests \
code-check-orgfront-tests code-check-orgfront-tests
code-check-i18n: code-check-i18n: ## i18n 리소스 검사
@echo "==> i18n resource check" @echo "==> i18n resource check"
@mkdir -p reports @mkdir -p reports
node tools/i18n-scanner/index.js node tools/i18n-scanner/index.js
node tools/i18n-scanner/report.js node tools/i18n-scanner/report.js
@cat reports/i18n-report.txt @cat reports/i18n-report.txt
code-check-i18n-values: code-check-i18n-values: ## i18n 번역 값 품질 검사
@echo "==> i18n value quality check" @echo "==> i18n value quality check"
@mkdir -p reports @mkdir -p reports
node tools/i18n-scanner/value-check.js node tools/i18n-scanner/value-check.js
@cat reports/i18n-value-report.txt @cat reports/i18n-value-report.txt
code-check-go-lint: code-check-go-lint: ## Go 포맷과 린트 검사
@echo "==> go lint/format check" @echo "==> go lint/format check"
@if command -v golangci-lint >/dev/null 2>&1; then \ @if command -v golangci-lint >/dev/null 2>&1; then \
cd backend && golangci-lint fmt -E gofmt -E gofumpt -d; \ cd backend && golangci-lint fmt -E gofmt -E gofumpt -d; \
@@ -247,11 +426,11 @@ code-check-go-lint:
exit 1; \ exit 1; \
fi fi
code-check-sync-userfront-locales: code-check-sync-userfront-locales: ## UserFront 로케일 동기화 검사
@echo "==> sync userfront locales" @echo "==> sync userfront locales"
/bin/sh ./scripts/sync_userfront_locales.sh /bin/sh ./scripts/sync_userfront_locales.sh
code-check-userfront-install: code-check-userfront-install: ## UserFront 의존성 설치
@echo "==> install userfront dependencies" @echo "==> install userfront dependencies"
@if command -v flutter >/dev/null 2>&1; then \ @if command -v flutter >/dev/null 2>&1; then \
cd userfront && flutter pub get; \ cd userfront && flutter pub get; \
@@ -259,7 +438,7 @@ code-check-userfront-install:
echo "WARNING: flutter not found, skipping userfront dependencies install."; \ echo "WARNING: flutter not found, skipping userfront dependencies install."; \
fi fi
code-check-userfront-lint: code-check-userfront-lint: ## UserFront 포맷과 analyze 검사
@echo "==> userfront format/analyze" @echo "==> userfront format/analyze"
@if command -v dart >/dev/null 2>&1; then \ @if command -v dart >/dev/null 2>&1; then \
cd userfront && dart format --output=none --set-exit-if-changed lib test; \ cd userfront && dart format --output=none --set-exit-if-changed lib test; \
@@ -272,10 +451,14 @@ code-check-userfront-lint:
echo "WARNING: flutter not found, skipping userfront analyze."; \ echo "WARNING: flutter not found, skipping userfront analyze."; \
fi fi
code-check-front-lint: code-check-front-lint: ## 프론트엔드 Biome 린트와 포맷 검사
@echo "==> adminfront biome lint/format check" @echo "==> adminfront biome lint/format check"
rm -rf adminfront/playwright-report adminfront/test-results rm -rf adminfront/playwright-report adminfront/test-results
cd adminfront && CI=true npx pnpm install --frozen-lockfile --ignore-scripts @if [ -d adminfront/node_modules ]; then \
echo "adminfront/node_modules already present; skipping pnpm install."; \
else \
cd adminfront && CI=true npx pnpm install --frozen-lockfile --ignore-scripts; \
fi
cd adminfront && npx biome lint . cd adminfront && npx biome lint .
cd adminfront && npx biome format . cd adminfront && npx biome format .
@echo "==> devfront biome lint/format check" @echo "==> devfront biome lint/format check"
@@ -289,15 +472,19 @@ code-check-front-lint:
cd devfront && npx biome format . cd devfront && npx biome format .
@echo "==> orgfront biome lint/format check" @echo "==> orgfront biome lint/format check"
rm -rf orgfront/playwright-report orgfront/test-results rm -rf orgfront/playwright-report orgfront/test-results
cd orgfront && npm ci --ignore-scripts @if [ -d orgfront/node_modules ]; then \
cd orgfront && npx biome lint . echo "orgfront/node_modules already present; skipping npm install."; \
cd orgfront && npx biome format . else \
cd orgfront && npm ci --ignore-scripts; \
fi
cd orgfront && ./node_modules/@biomejs/biome/bin/biome lint .
cd orgfront && ./node_modules/@biomejs/biome/bin/biome format .
code-check-backend-tests: code-check-backend-tests: ## 백엔드 Go 테스트 실행
@echo "==> backend tests" @echo "==> backend tests"
cd backend && GOCACHE=/tmp/baron-sso-go-cache go test -v ./... cd backend && GOCACHE=/tmp/baron-sso-go-cache go test -v ./...
code-check-userfront-tests: code-check-userfront-tests: ## UserFront Flutter 테스트 실행
@echo "==> userfront tests (isolated workspace)" @echo "==> userfront tests (isolated workspace)"
@if ! command -v flutter >/dev/null 2>&1; then \ @if ! command -v flutter >/dev/null 2>&1; then \
echo "WARNING: flutter not found, skipping userfront tests."; \ echo "WARNING: flutter not found, skipping userfront tests."; \
@@ -323,11 +510,11 @@ code-check-userfront-tests:
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \ cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY) cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY)
code-check-adminfront-tests: code-check-adminfront-tests: ## AdminFront 테스트 실행
@echo "==> adminfront tests" @echo "==> adminfront tests"
PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests
code-check-devfront-tests: code-check-devfront-tests: ## DevFront 테스트 실행
@echo "==> devfront tests" @echo "==> devfront tests"
@mkdir -p reports/devfront @mkdir -p reports/devfront
@rm -rf reports/devfront/playwright-report reports/devfront/test-results @rm -rf reports/devfront/playwright-report reports/devfront/test-results
@@ -350,7 +537,7 @@ code-check-devfront-tests:
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \ [ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
exit $$status exit $$status
code-check-orgfront-tests: code-check-orgfront-tests: ## OrgFront 테스트 실행
@echo "==> orgfront tests" @echo "==> orgfront tests"
@mkdir -p reports/orgfront @mkdir -p reports/orgfront
@rm -rf reports/orgfront/playwright-report reports/orgfront/test-results @rm -rf reports/orgfront/playwright-report reports/orgfront/test-results
@@ -366,7 +553,7 @@ code-check-orgfront-tests:
[ -d orgfront/test-results ] && cp -R orgfront/test-results reports/orgfront/ || true; \ [ -d orgfront/test-results ] && cp -R orgfront/test-results reports/orgfront/ || true; \
exit $$status exit $$status
code-check-userfront-e2e-tests: code-check-userfront-e2e-tests: ## UserFront WASM E2E 테스트 실행
@echo "==> userfront wasm playwright e2e tests (isolated workspace)" @echo "==> userfront wasm playwright e2e tests (isolated workspace)"
@if ! command -v flutter >/dev/null 2>&1; then \ @if ! command -v flutter >/dev/null 2>&1; then \
echo "WARNING: flutter not found, skipping userfront e2e tests."; \ echo "WARNING: flutter not found, skipping userfront e2e tests."; \

218
README.md
View File

@@ -40,6 +40,20 @@ baron_sso/
* AdminFront: 사용자 관리 등 Admin 기능 * AdminFront: 사용자 관리 등 Admin 기능
* DevFront: RP 관리 등 개발자 기능 * DevFront: RP 관리 등 개발자 기능
## 개발 실행 정책
`make dev`는 로컬 개발용 실행 모드이며, React 기반 `adminfront`, `devfront`, `orgfront`는 모두 Vite HMR 모드로 동작해야 합니다. 이 세 서비스는 Docker Compose에서 Dockerfile `dev` target을 사용하고 `/workspace/<app>` bind mount 위에서 `npm run dev -- --host 0.0.0.0`로 실행합니다. `make dev` 경로에서 production `dist``serve_frontend_prod.mjs`로 정적 서빙하면 안 됩니다.
현재 개발 포트는 다음과 같습니다.
- AdminFront: `http://localhost:5173`
- DevFront: `http://localhost:5174`
- OrgFront: `http://localhost:5175`
자세한 정책과 회귀 테스트는 [make dev Vite HMR Policy](docs/make-dev-vite-hmr-policy.md)를 확인하세요. 정책 회귀는 `test/frontend_dev_bind_mount_policy_test.sh`에서 검사합니다.
로컬 Playwright E2E도 기본적으로 Vite dev server를 봅니다. Gitea Actions 같은 CI에서는 `CI=true`로 production bundle을 `vite preview`로 검증합니다. 로컬에서 production bundle을 명시적으로 검증하려면 `PLAYWRIGHT_USE_PREVIEW=true`를 사용하세요. 이 정책은 `test/playwright_frontend_runtime_policy_test.sh`에서 검사합니다.
## 🏗 아키텍처 (Architecture) ## 🏗 아키텍처 (Architecture)
@@ -378,6 +392,61 @@ flowchart TD
Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비지니스로직은 Backend를 통해서, 기본 인증 로직은 Ory Stack을 통해 진행됩니다. Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비지니스로직은 Backend를 통해서, 기본 인증 로직은 Ory Stack을 통해 진행됩니다.
### SSOT 및 Redis Cache 전략
Baron SSO의 인증, 권한, OAuth/OIDC 원장은 Ory Stack입니다. Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane입니다. 사용자 identity/profile/소속/조직도 노출 데이터에 대해 Backend DB `users`를 원장 또는 read model로 사용하지 않습니다. Redis는 Ory 원장 데이터의 성능 cache/mirror로만 사용합니다.
Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend가 직접 소비하지 않습니다. Backend가 Redis mirror 또는 Ory Admin API fallback을 기준으로 cursor 기반 API를 adminfront, orgfront, userfront, 외부 API에 제공합니다.
#### 데이터별 원본 위치
| 데이터 | SSOT | 보조 저장소/캐시 | 비고 |
| --- | --- | --- | --- |
| Identity subject, credentials, recovery/verification address | Ory Kratos `identities` | Redis identity mirror | Kratos identity ID가 사용자 subject이며 WORKS `externalKey` 기준입니다. |
| 로그인 식별자 | Ory Kratos traits | Redis identity mirror/index | Kratos가 인증 식별자의 원장입니다. |
| 사용자 이름, 이메일, 전화번호, role 기본값 | Ory Kratos traits | Redis identity mirror | 인증/profile 계산에 필요한 identity 값은 Kratos 기준으로 유지합니다. |
| Baron 사용자 운영 상태, soft delete, 운영 메타데이터 | Ory Kratos traits/state 또는 별도 명시 원장 | Redis mirror/cache | Backend DB `users`를 사용자 read model로 사용하지 않습니다. |
| 테넌트 tree, slug, 조직/부서/직무/직책 | Ory Keto relation tuple, Backend read model | Redis/API response cache 가능 | 권한/관계 판단은 Keto가 원장입니다. Ory가 보관하거나 조회할 수 없는 조직 표시/검색 데이터만 Backend read model에 둡니다. |
| 권한/관계 | Ory Keto relation tuple | PostgreSQL outbox/status | Backend를 통해 relation command를 보내고 처리 상태를 추적합니다. |
| OAuth2/OIDC client, consent, token state | Ory Hydra | PostgreSQL `client_consents`, audit/read model | Hydra가 프로토콜 원장이며 로컬 테이블은 운영 조회/감사용입니다. |
| RP별 사용자 custom claim 값 | Backend read model `rp_user_metadata` | ID token/userinfo claim assembly | Ory에 저장되지 않는 RP 범위 데이터입니다. Kratos traits나 claim output을 SSOT로 취급하지 않습니다. |
| 전역 사용자 custom claim 값 | Backend read model `users.metadata.global_custom_claims` | ID token claim assembly | Ory에 저장되지 않는 운영 범위 custom 값입니다. |
| WORKS Mobile mapping/outbox/job 상태 | PostgreSQL `worksmobile_*` | WORKS API 비교 응답 cache 가능 | 외부 SaaS 연동 상태이며 identity 원장이 아닙니다. |
| 감사 로그/사용량 | ClickHouse, Oathkeeper/Ory 로그 | 화면별 summary cache 가능 | command와 보안 이벤트의 감사 원장입니다. |
| Headless JWKS 검증 상태 | Redis `headless:jwks:*` cache | DevFront 상태 카드 | RP public key 문서 자체는 외부 `jwksUri`가 원본입니다. |
| 로그인 코드, pending login, verification token | Redis short-lived key | 없음 | 만료 가능한 휘발성 상태입니다. 백업/복구 대상이 아닙니다. |
#### SSOT 보장 원칙
1. Kratos/Hydra/Keto/WORKS로 향하는 쓰기 command는 Backend를 통과합니다.
2. Backend는 Ory write 성공 후 원장 ID를 기준으로 Ory를 재조회하고, Redis mirror를 갱신하거나 stale로 표시합니다. 사용자 identity/profile/소속 데이터는 Backend DB `users`에 read model로 갱신하지 않습니다.
3. write-through 갱신 실패 시 원장 write를 되돌린 것으로 간주하지 않습니다. 대신 mirror/cache 상태를 `stale` 또는 `failed`로 표시하고 drift report와 refresh 대상으로 둡니다.
4. Kratos Admin API 또는 Kratos DB를 Backend 밖에서 직접 수정하는 경로는 운영 정책상 금지합니다. 정비/DR처럼 예외가 필요한 경우에는 Redis mirror를 stale로 표시하고, full refresh와 drift report를 완료하기 전까지 cache 결과를 신뢰하지 않습니다.
5. Backend DB `users`나 Redis cache는 Kratos partial list를 full snapshot처럼 취급하지 않습니다. Kratos 목록 조회가 partial이면 로컬 사용자 데이터를 근거로 정상 목록을 만들지 않습니다.
6. frontend/API 대량 조회는 Backend가 제공하는 cursor 기반을 원칙으로 합니다. `limit=5000&offset=0` 같은 단일 대량 offset 조회는 사용자 수가 늘면 partial data를 전체처럼 보이게 만들 수 있으므로 신규 구현에서 금지합니다.
7. Redis cache miss가 발생한 단건 조회는 가능한 경우 SSOT로 fallback하고, fallback 성공 시 Redis를 갱신합니다. 목록 조회는 mirror 상태가 `ready`가 아니면 화면/API에 경고 상태를 함께 전달해야 합니다.
#### Redis 사용 원칙
Redis는 원장이 아니라 cache/mirror 계층입니다. Redis 데이터 유실은 장애지만 데이터 유실 사고로 보지 않고, 원장 재조회와 refresh로 재수렴해야 합니다.
| Redis 데이터 | 역할 | TTL/보존 정책 | 장애 시 처리 |
| --- | --- | --- | --- |
| `identity:mirror:{identityID}` | Kratos identity summary 단건 cache | 장기 mirror. refresh 상태와 함께 운영 | Kratos `GetIdentity` fallback 후 write-through |
| `identity:index:*` | Backend cursor API용 identity 목록/검색 index | mirror refresh 주기로 재작성 | `stale` 표시 후 full refresh |
| `identity:mirror:state` | mirror 상태, count, last error | 영구 상태 key | adminfront에서 경고 표시 |
| `headless:jwks:*` | RP headless login JWKS cache | JWKS TTL과 prefetch 정책 | kid miss/검증 실패/TTL 만료 시 재조회 |
| login/verification/pending 계열 key | 인증 흐름의 단기 상태 | 짧은 TTL 필수 | 만료 또는 유실 시 사용자가 흐름 재시작 |
| 일반 API response cache | 선택적 성능 cache | 짧은 TTL, invalidation 우선 | miss 시 Backend DB 또는 Ory 원장 조회 |
운영 Redis 설정은 `maxmemory``maxmemory_policy`가 명시되어야 합니다. identity mirror처럼 재수렴 가능한 데이터와 pending login처럼 사용자 흐름에 영향을 주는 단기 key가 같은 Redis를 공유하므로, eviction 발생 여부와 TTL 없는 key 증가를 운영 화면에서 볼 수 있어야 합니다.
#### Redis 모니터링 계획
Redis 적정 설정 판단에 필요한 운영 지표를 adminfront에 노출하는 후속 작업은 이슈 [#1046](https://gitea.hmac.kr/baron/baron-sso/issues/1046)으로 분리했습니다.
표시 대상은 Redis 연결/버전/uptime, `used_memory`, `maxmemory`, `maxmemory_policy`, keyspace hit/miss, expired/evicted keys, prefix별 key count, TTL 분포, `identity:mirror:state`, headless JWKS cache failure 요약입니다. 이 화면은 `super_admin` 전용으로 두고, Redis key value 자체는 노출하지 않습니다.
--- ---
## 🚀 시작하기 (Getting Started) ## 🚀 시작하기 (Getting Started)
@@ -527,6 +596,155 @@ docker compose --env-file .env --env-file config/.generated/auth-config.env -f d
- **Hydra Public**: http://localhost:4444 - **Hydra Public**: http://localhost:4444
- **Kratos UI (UserFront)**: http://localhost:5000 - **Kratos UI (UserFront)**: http://localhost:5000
### 전체 백업/복구
전체 백업/복구는 CSV export/import가 아니라 Baron SSO와 Ory Stack 저장소를 같은 시점의 재해 복구 단위로 보존하는 절차입니다. 사용자 UUID, Kratos identity ID, Hydra/Keto 원장, WORKS 연동 mapping이 어긋나면 안 되므로 운영 복구는 DB dump와 설정 snapshot을 함께 다룹니다.
#### 백업 실행
```bash
# 전체 백업
make dump
# 출력 위치를 직접 지정
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 일부 서비스만 백업
make dump DUMP_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config
make dump DUMP_SERVICES=ory-postgres,ory-clickhouse
# 생성된 백업 검증
make dump-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# WORKS Drive로 외부 분산 저장
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 지정 경로로 dump 후 바로 WORKS Drive 업로드
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 로컬 백업 목록
make dump-list
```
기본값은 `DUMP_SERVICES=all`, `DUMP_MODE=maintenance`입니다. `DUMP_SERVICES`는 다음 값을 콤마로 조합할 수 있습니다.
| 값 | 대상 |
| --- | --- |
| `postgres` | Baron Postgres (`baron_postgres`, `${DB_NAME:-baron_sso}`) |
| `ory-postgres` | Ory Postgres의 `${KRATOS_DB:-ory_kratos}`, `${HYDRA_DB:-ory_hydra}`, `${KETO_DB:-ory_keto}` |
| `clickhouse` | Baron ClickHouse (`baron_clickhouse`) |
| `ory-clickhouse` | Ory ClickHouse (`ory_clickhouse`) |
| `config` | `.env` redacted copy, generated Ory config, gateway, 주요 compose 파일 |
백업 산출물은 기본적으로 `backups/baron-sso-backup-YYYYMMDD-HHMMSSZ/` 아래에 생성됩니다.
```text
manifest.json
checksums.sha256
postgres/
clickhouse/
config/
reports/
```
#### WORKS Drive 외부 업로드
`make dump`, `make restore`, `make upload-cloud`는 기본적으로 `docker/backup-tools/Dockerfile`에서 빌드한 `baron-sso-backup-tools:local` 컨테이너 안에서 실행됩니다. 호스트에는 Docker와 Docker socket 접근 권한만 필요하고, `zstd`, `jq`, `curl`, `openssl`, `postgresql-client` 같은 백업/복구 도구는 backup-tools image에 포함됩니다.
`make upload-cloud`는 기존 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 묶은 뒤 WORKS Drive에 업로드합니다. 압축 포맷은 `.tar.zst`로 고정되어 있고, 압축/해제는 backup-tools 컨테이너 내부의 `zstd`로 수행합니다.
백업이 완료되면 `reports/backup-report.md`도 생성됩니다. 이 report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록됩니다. `make upload-cloud`는 `reports/*.md`만 WORKS Drive 대상 폴더 아래의 `reports` 하위 폴더로 업로드하며, 업로드 파일명은 `backup-report-YYYYMMDD-HHMMSSZ.md`처럼 업로드 시각을 붙입니다. `reports/cloud-upload.json`은 로컬 업로드 실행 기록으로만 남기고 Drive에는 업로드하지 않습니다.
```bash
# 권장: 백업 경로를 명시해서 dump와 upload를 분리
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 또는 같은 BACKUP 경로로 연속 실행
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 실제 업로드 전 endpoint와 target만 확인
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ WORKS_DRIVE_DRY_RUN=true
# 예외적으로 호스트 도구로 직접 실행
make restore BACKUP_USE_DOCKER=false BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ CONFIRM_RESTORE=baron-sso
```
주요 변수:
| 변수 | 설명 |
| --- | --- |
| `WORKS_DRIVE_TARGET` | `sharedrive`, `mydrive`, `group`, `sharedfolder` 중 하나. 기본값은 `sharedrive`입니다. |
| `WORKS_DRIVE_SHARED_DRIVE_ID` | `WORKS_DRIVE_TARGET=sharedrive`일 때 공용 드라이브 ID입니다. |
| `WORKS_DRIVE_PARENT_FILE_ID` | 업로드할 대상 폴더의 WORKS Drive `fileId`입니다. 폴더 이름이나 경로가 아니며, 비우면 대상 drive/folder root에 업로드합니다. |
| `WORKS_DRIVE_USER_ID` | `mydrive` 또는 `sharedfolder` 대상 사용자 ID입니다. 기본값은 `me`입니다. |
| `WORKS_DRIVE_GROUP_ID` | `WORKS_DRIVE_TARGET=group`일 때 조직/그룹 ID입니다. |
| `WORKS_DRIVE_SHARED_FOLDER_ID` | `WORKS_DRIVE_TARGET=sharedfolder`일 때 공유받은 폴더 ID입니다. |
| `WORKS_DRIVE_ACCESS_TOKEN` | Drive API 호출용 Bearer token입니다. Drive API는 `file` scope가 필요합니다. |
| `WORKS_DRIVE_ACCESS_TOKEN_FILE` | access token을 파일에서 읽을 때 사용합니다. |
| `WORKS_DRIVE_ACCESS_TOKEN_CMD` | access token을 명령 출력으로 주입할 때 사용합니다. |
| `WORKS_DRIVE_OAUTH_SCOPE` | Drive 업로드 앱 OAuth token에 사용할 scope입니다. 기본값은 `file`입니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_ID` | Drive 업로드 앱의 OAuth client ID입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_CLIENT_ID`와 분리합니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_SECRET` | Drive 업로드 앱의 OAuth client secret입니다. |
| `WORKS_DRIVE_OAUTH_REFRESH_TOKEN` | Drive 업로드 앱의 refresh token입니다. 명시 access token이 없으면 이 값으로 access token을 갱신합니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT` | Drive 업로드 앱의 service account입니다. JWT `sub`에 들어갑니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE` | Drive 업로드 앱 private key 파일입니다. 예: `./config/worksmobile-driveapp-private-key.pem` |
| `WORKS_DRIVE_SPLIT_SIZE` | 분할 업로드 시 part 크기입니다. 기본값은 `9000M`입니다. |
| `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` | 이 값보다 archive가 크면 split part로 나눕니다. 기본값 `0`은 자동 분할 비활성입니다. |
| `WORKS_DRIVE_FORCE_SPLIT` | `true`이면 크기와 무관하게 split part로 업로드합니다. |
| `WORKS_DRIVE_OVERWRITE` | WORKS Drive upload URL 생성 요청의 overwrite 플래그입니다. 기본값은 `false`입니다. |
| `WORKS_DRIVE_UPLOAD_REPORTS` | `true`이면 `reports/*.md`를 Drive의 report 폴더로 함께 업로드합니다. 기본값은 `true`입니다. |
| `WORKS_DRIVE_REPORT_FOLDER_NAME` | Markdown report를 업로드할 하위 폴더 이름입니다. 기본값은 `reports`입니다. |
Drive API는 업로드 URL 생성 후 해당 URL에 multipart `Filedata`로 실제 파일을 전송하는 2단계 방식입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 Drive 업로드용 `WORKS_DRIVE_OAUTH_*`는 서로 다른 앱/키로 관리합니다. token 우선순위는 `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 서비스 계정 JWT fallback 순서입니다. 운영에서는 Drive API 권한과 `file` scope 위임 정책을 먼저 확인해야 합니다.
#### 복구 계획과 복구 실행
```bash
# 복구 전 계획 확인
make restore-plan BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
CONFIRM_RESTORE=baron-sso
# 복구 실행
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
CONFIRM_RESTORE=baron-sso
# .tar.zst archive를 직접 복구 입력으로 사용
make restore DUMP_FILE=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ.tar.zst \
RESTORE_SERVICES=all \
CONFIRM_RESTORE=baron-sso
# report 경로를 명시
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
CONFIRM_RESTORE=baron-sso \
RESTORE_REPORT=reports/restore/baron-sso-restore-report.json
# 복구 후 기본 검증
make restore-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
```
복구는 반드시 빈 volume 또는 restore 전용 stack에서 수행하는 것을 기본 정책으로 합니다. `make restore`는 `BACKUP` 또는 `DUMP_FILE` 중 하나와 `CONFIRM_RESTORE=baron-sso`가 없으면 실패하고, 기본적으로 non-empty Postgres 대상에는 복구하지 않습니다. 승인된 restore rehearsal에서만 `ALLOW_NON_EMPTY_RESTORE=true`를 사용하세요. `DUMP_FILE=.tar.zst` 해제도 backup-tools 컨테이너에서 수행하므로 호스트 `zstd` 설치에 의존하지 않습니다.
`make restore`는 복구 report를 JSON과 Markdown으로 남깁니다. `BACKUP` 디렉터리 입력의 기본 JSON report는 `<BACKUP>/reports/restore-report.json`이고, `DUMP_FILE` archive 입력의 기본 JSON report는 `reports/restore/<archive-name>-restore-report.json`입니다. 같은 경로에 `.md` 확장자의 Markdown 요약도 함께 생성됩니다. `RESTORE_REPORT`로 직접 지정할 수 있습니다. report에는 입력 archive, 복구 서비스, checksum 검증 상태, 복구 후 대상 row count 비교 결과가 기록됩니다.
`config` 복구는 운영 파일을 직접 덮어쓰지 않고 `config-restored/`에 풀어 수동 검토하도록 합니다. migration은 자동 실행하지 않으며, Ory Stack과 backend 기동 후 super admin login, 대표 OIDC login, WORKS comparison dry-run을 통과하기 전까지 WORKS relay를 자동 재개하지 않습니다.
#### 백업/복구 범위
필수 백업 대상:
- Baron Postgres: users, tenants, user_login_ids, user_groups, RP metadata, WORKS mapping/outbox 등
- Ory Postgres: Kratos identity/credentials/session, Hydra client/consent/token state, Keto relation tuple
- Baron ClickHouse: 감사 로그와 RP usage event
- Ory ClickHouse: Oathkeeper/Ory 계열 접근 로그
- 설정 snapshot: `.env` redacted copy, generated Ory config, gateway, compose 파일
기본 제외 대상:
- Redis: pending login, short code, cache 등 휘발성 데이터이므로 복구 후 재수렴 대상으로 봅니다.
- 프론트 빌드 산출물: 소스와 이미지 태그로 재생성합니다.
- coverage, reports, test-results 같은 로컬 개발 산출물
상세 설계와 운영 정책은 `docs/backup-restore-design.md`를 기준으로 유지합니다.
### MCP 서버 (Hydra/Kratos/Keto) ### MCP 서버 (Hydra/Kratos/Keto)
MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다. MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다.
프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요. 프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요.

View File

@@ -1,29 +1,51 @@
FROM node:lts FROM node:lts AS deps
WORKDIR /workspace WORKDIR /workspace
# Set CI environment variable to true to avoid TTY issues with pnpm
ENV CI=true ENV CI=true
ENV ADMINFRONT_BUILD_OUT_DIR=/workspace/adminfront/dist
# Install pnpm
RUN corepack enable && corepack prepare pnpm@10.5.2 --activate RUN corepack enable && corepack prepare pnpm@10.5.2 --activate
# Copy workspace configs and common package COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY common ./common COPY common ./common
COPY adminfront ./adminfront COPY adminfront ./adminfront
# Install dependencies for the workspace ARG VITE_ADMIN_PUBLIC_URL
RUN pnpm install --filter adminfront... --filter baron-sso... --no-frozen-lockfile --ignore-scripts ARG VITE_OIDC_AUTHORITY
ARG VITE_OIDC_CLIENT_ID
ARG ORGFRONT_URL
ENV VITE_ADMIN_PUBLIC_URL=$VITE_ADMIN_PUBLIC_URL
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
ENV ORGFRONT_URL=$ORGFRONT_URL
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치 RUN pnpm install --frozen-lockfile --ignore-scripts
RUN npm install -g serve
FROM deps AS dev
WORKDIR /workspace/adminfront WORKDIR /workspace/adminfront
ENV NODE_ENV=development
# Vite 기본 포트
EXPOSE 5173 EXPOSE 5173
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙 CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
RUN chmod +x ./scripts/runtime-mode.sh
CMD ["sh", "./scripts/runtime-mode.sh"] FROM deps AS build
WORKDIR /workspace/adminfront
RUN npm run build
FROM node:24-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
ENV FRONTEND_DIST_DIR=/app/dist
ENV PORT=5173
COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs
COPY --from=build /workspace/adminfront/dist ./dist
EXPOSE 5173
CMD ["node", "./serve_frontend_prod.mjs"]

Binary file not shown.

View File

@@ -0,0 +1,134 @@
{
"metric": "tenant-profile-local-performance",
"tenantId": "56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"actualApiBaseUrl": "http://localhost:5173/api",
"measuredAt": "2026-06-16T23:45:00.441Z",
"browser": "chromium",
"samples": [
{
"sample": 1,
"configFieldsVisibleMs": 424,
"networkIdleMs": 862,
"orgUnitType": "센터",
"visibility": "public",
"worksmobileSync": "enabled",
"apiTimings": [
{
"method": "GET",
"url": "http://playwright-mock/api/v1/user/me",
"status": 200,
"durationMs": 134
},
{
"method": "GET",
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"status": 200,
"durationMs": 184
}
]
},
{
"sample": 2,
"configFieldsVisibleMs": 376,
"networkIdleMs": 751,
"orgUnitType": "센터",
"visibility": "public",
"worksmobileSync": "enabled",
"apiTimings": [
{
"method": "GET",
"url": "http://playwright-mock/api/v1/user/me",
"status": 200,
"durationMs": 20
},
{
"method": "GET",
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"status": 200,
"durationMs": 133
}
]
},
{
"sample": 3,
"configFieldsVisibleMs": 400,
"networkIdleMs": 797,
"orgUnitType": "센터",
"visibility": "public",
"worksmobileSync": "enabled",
"apiTimings": [
{
"method": "GET",
"url": "http://playwright-mock/api/v1/user/me",
"status": 200,
"durationMs": 21
},
{
"method": "GET",
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"status": 200,
"durationMs": 156
}
]
},
{
"sample": 4,
"configFieldsVisibleMs": 431,
"networkIdleMs": 843,
"orgUnitType": "센터",
"visibility": "public",
"worksmobileSync": "enabled",
"apiTimings": [
{
"method": "GET",
"url": "http://playwright-mock/api/v1/user/me",
"status": 200,
"durationMs": 25
},
{
"method": "GET",
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"status": 200,
"durationMs": 178
}
]
},
{
"sample": 5,
"configFieldsVisibleMs": 380,
"networkIdleMs": 758,
"orgUnitType": "센터",
"visibility": "public",
"worksmobileSync": "enabled",
"apiTimings": [
{
"method": "GET",
"url": "http://playwright-mock/api/v1/user/me",
"status": 200,
"durationMs": 24
},
{
"method": "GET",
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
"status": 200,
"durationMs": 129
}
]
}
],
"summary": {
"configFieldsVisibleMs": {
"min": 376,
"max": 431,
"p50": 400,
"p95": 431
},
"networkIdleMs": {
"min": 751,
"max": 862,
"p50": 797,
"p95": 862
}
},
"screenshotPath": "/home/lectom/repos/baron-sso/adminfront/e2e-evidence/tenant-profile-performance-local.png"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -14,6 +14,8 @@ const port = Number.parseInt(process.env.PORT ?? "5173", 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`; const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl; const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI && !process.env.PORT; const reuseExistingServer = !process.env.CI && !process.env.PORT;
const usePreviewServer =
process.env.CI === "true" || process.env.PLAYWRIGHT_USE_PREVIEW === "true";
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
/** /**
@@ -84,7 +86,7 @@ export default defineConfig({
webServer: process.env.BASE_URL webServer: process.env.BASE_URL
? undefined ? undefined
: { : {
command: process.env.CI command: usePreviewServer
? `pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort` ? `pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort`
: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`, : `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`,
url: `http://127.0.0.1:${port}`, url: `http://127.0.0.1:${port}`,

View File

@@ -51,14 +51,17 @@ ensure_frontend_dependencies() {
if [ -n "$WORKSPACE_ROOT" ]; then if [ -n "$WORKSPACE_ROOT" ]; then
WORKSPACE_DIR="$WORKSPACE_ROOT" WORKSPACE_DIR="$WORKSPACE_ROOT"
LOCK_FILE="$WORKSPACE_ROOT/pnpm-lock.yaml" LOCK_FILE="$WORKSPACE_ROOT/pnpm-lock.yaml"
COMMON_PACKAGE_FILE="$WORKSPACE_ROOT/common/package.json"
INSTALL_CMD="cd $WORKSPACE_ROOT && CI=true pnpm install --filter ${APP_PACKAGE_NAME}... --frozen-lockfile --ignore-scripts" INSTALL_CMD="cd $WORKSPACE_ROOT && CI=true pnpm install --filter ${APP_PACKAGE_NAME}... --frozen-lockfile --ignore-scripts"
elif [ -f "pnpm-lock.yaml" ]; then elif [ -f "pnpm-lock.yaml" ]; then
WORKSPACE_DIR="." WORKSPACE_DIR="."
LOCK_FILE="pnpm-lock.yaml" LOCK_FILE="pnpm-lock.yaml"
COMMON_PACKAGE_FILE="/workspace/common/package.json"
INSTALL_CMD="CI=true pnpm install --frozen-lockfile --ignore-scripts" INSTALL_CMD="CI=true pnpm install --frozen-lockfile --ignore-scripts"
else else
WORKSPACE_DIR="." WORKSPACE_DIR="."
LOCK_FILE="package-lock.json" LOCK_FILE="package-lock.json"
COMMON_PACKAGE_FILE="/workspace/common/package.json"
INSTALL_CMD="npm ci" INSTALL_CMD="npm ci"
fi fi
@@ -100,9 +103,9 @@ ensure_frontend_dependencies() {
} }
if command -v sha256sum >/dev/null 2>&1; then if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')" deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
else else
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')" deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
fi fi
deps_stamp="node_modules/.baron-deps-hash" deps_stamp="node_modules/.baron-deps-hash"
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)" installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
@@ -111,9 +114,9 @@ ensure_frontend_dependencies() {
echo "Installing frontend dependencies..." echo "Installing frontend dependencies..."
acquire_install_lock acquire_install_lock
if command -v sha256sum >/dev/null 2>&1; then if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')" deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | sha256sum | awk '{print $1}')"
else else
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')" deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" "$COMMON_PACKAGE_FILE" package.json 2>/dev/null | cksum | awk '{print $1}')"
fi fi
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)" installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
if [ "$installed_hash" = "$deps_hash" ]; then if [ "$installed_hash" = "$deps_hash" ]; then

View File

@@ -9,4 +9,8 @@ c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,COMPANY,baron-group,jangheon,,j
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr,,, b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-sanup,,jangheon.co.kr,,,
e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr,,, e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr,,,
4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,MH_manager,USER_GROUP,hanmac-family,mhd,맨아워 대시보드 권한 보유자그룹,,private,,no 4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,MH_manager,USER_GROUP,hanmac-family,mhd,맨아워 대시보드 권한 보유자그룹,,private,,no
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,, e41adf79-3d15-4807-8303-afbdb0f2bab7,SW_uploader,USER_GROUP,hanmac-family,sw-uploader,소프트웨어 배포 권한 그룹,,private,,no
ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916,일반회사,COMPANY_GROUP,,commercial,외부 기업회원 루트 테넌트,,,,
d19c10f0-0224-4bbb-bf3e-ce579c5338ea,공공기관,COMPANY_GROUP,,public-org,공공기관 기본 루트 테넌트,,,,
78accec5-8eba-4324-b8f1-10ab360011fe,교육/학생,COMPANY_GROUP,,edu,교육기관 및 학생 기본 루트 테넌트,,,,
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,개인사용자,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,,
1 id name type parent_tenant_slug slug memo email_domain visibility org_unit_type worksmobile_sync
9 b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6 장헌산업 COMPANY baron-group jangheon-sanup jangheon.co.kr
10 e57cb22c-383e-4489-8c2f-0c5431917e86 (주)피티씨 COMPANY baron-group ptc pre-cast.co.kr
11 4d0f26b9-702c-4bc6-8996-46e9eedfdeb7 MH_manager USER_GROUP hanmac-family mhd 맨아워 대시보드 권한 보유자그룹 private no
12 9607eb7b-04d2-42ab-80fe-780fe21c7e8f e41adf79-3d15-4807-8303-afbdb0f2bab7 Personal SW_uploader PERSONAL USER_GROUP hanmac-family personal sw-uploader 개인 사용자 기본 루트 테넌트 소프트웨어 배포 권한 그룹 private no
13 ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916 일반회사 COMPANY_GROUP commercial 외부 기업회원 루트 테넌트
14 d19c10f0-0224-4bbb-bf3e-ce579c5338ea 공공기관 COMPANY_GROUP public-org 공공기관 기본 루트 테넌트
15 78accec5-8eba-4324-b8f1-10ab360011fe 교육/학생 COMPANY_GROUP edu 교육기관 및 학생 기본 루트 테넌트
16 9607eb7b-04d2-42ab-80fe-780fe21c7e8f 개인사용자 PERSONAL personal 개인 사용자 기본 루트 테넌트

View File

@@ -16,10 +16,10 @@ describe("admin routes", () => {
expect(matches?.at(-1)?.route.path).toBe("/auth/callback"); expect(matches?.at(-1)?.route.path).toBe("/auth/callback");
}); });
it("registers the super-admin user projection management route", () => { it("registers the super-admin Ory SSOT system route", () => {
const matches = matchRoutes(adminRoutes, "/system/projections/users"); const matches = matchRoutes(adminRoutes, "/system/ory-ssot");
expect(matches?.at(-1)?.route.path).toBe("system/projections/users"); expect(matches?.at(-1)?.route.path).toBe("system/ory-ssot");
}); });
it("registers the super-admin data integrity management route", () => { it("registers the super-admin data integrity management route", () => {
@@ -28,6 +28,33 @@ describe("admin routes", () => {
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity"); expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
}); });
it("routes global custom claim settings before user detail id matching", async () => {
const matches = matchRoutes(adminRoutes, "/users/custom-claims");
const leafRoute = matches?.at(-1)?.route;
expect(leafRoute?.path).toBe("users/custom-claims");
expect(await getRouteComponentName(leafRoute)).toBe(
"GlobalCustomClaimsPage",
);
});
it("code-splits tenant detail profile routes away from the initial admin shell", () => {
const matches = matchRoutes(
adminRoutes,
"/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
);
const detailRoute = matches?.find(
(match) => match.route.path === "tenants/:tenantId",
)?.route;
const profileRoute = matches?.at(-1)?.route;
expect(detailRoute?.element).toBeUndefined();
expect(typeof detailRoute?.lazy).toBe("function");
expect(profileRoute?.index).toBe(true);
expect(profileRoute?.element).toBeUndefined();
expect(typeof profileRoute?.lazy).toBe("function");
});
it("keeps protected admin pages behind an auth guard before mounting the layout", () => { it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
const rootRoute = adminRoutes.find((route) => route.path === "/"); const rootRoute = adminRoutes.find((route) => route.path === "/");
const protectedShellRoute = rootRoute?.children?.[0]; const protectedShellRoute = rootRoute?.children?.[0];
@@ -38,6 +65,29 @@ describe("admin routes", () => {
}); });
}); });
async function getRouteComponentName(route: unknown) {
if (
typeof route === "object" &&
route !== null &&
"lazy" in route &&
typeof route.lazy === "function"
) {
const lazyRoute = await route.lazy();
if ("Component" in lazyRoute && typeof lazyRoute.Component === "function") {
return lazyRoute.Component.name;
}
if ("element" in lazyRoute) {
return getRouteElementName(lazyRoute.element);
}
}
if (typeof route === "object" && route !== null && "element" in route) {
return getRouteElementName(route.element);
}
return undefined;
}
function getRouteElementName(element: unknown) { function getRouteElementName(element: unknown) {
if ( if (
typeof element === "object" && typeof element === "object" &&

View File

@@ -1,29 +1,33 @@
import type { ComponentType } from "react";
import type { RouteObject } from "react-router-dom"; import type { RouteObject } from "react-router-dom";
import { createBrowserRouter } from "react-router-dom"; import { createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout"; 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 AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard"; import AuthGuard from "../features/auth/AuthGuard";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage"; import LoginPage from "../features/auth/LoginPage";
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import UserProjectionPage from "../features/projections/UserProjectionPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
import 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"; import { ADMIN_AUTH_CALLBACK_PATH } from "../lib/authConfig";
type RouteModule = {
default: ComponentType;
};
function lazyDefault(loader: () => Promise<RouteModule>) {
return async () => {
const module = await loader();
return { Component: module.default };
};
}
function lazyNamed<TModule, TKey extends keyof TModule>(
loader: () => Promise<TModule>,
key: TKey,
) {
return async () => {
const module = await loader();
return { Component: module[key] as ComponentType };
};
}
export const adminRoutes: RouteObject[] = [ export const adminRoutes: RouteObject[] = [
{ {
path: "/login", path: "/login",
@@ -40,33 +44,147 @@ export const adminRoutes: RouteObject[] = [
{ {
element: <AppLayout />, element: <AppLayout />,
children: [ children: [
{ index: true, element: <GlobalOverviewPage /> }, {
{ path: "audit-logs", element: <AuditLogsPage /> }, index: true,
{ path: "auth", element: <AuthPage /> }, lazy: lazyDefault(
{ path: "users", element: <UserListPage /> }, () => import("../features/overview/GlobalOverviewPage"),
{ path: "users/new", element: <UserCreatePage /> }, ),
{ path: "users/:id", element: <UserDetailPage /> }, },
{ path: "tenants", element: <TenantListPage /> }, {
{ path: "tenants/new", element: <TenantCreatePage /> }, path: "audit-logs",
{ path: "worksmobile", element: <TenantWorksmobilePage /> }, lazy: lazyDefault(() => import("../features/audit/AuditLogsPage")),
},
{
path: "auth",
lazy: lazyDefault(() => import("../features/auth/AuthPage")),
},
{
path: "users",
lazy: lazyDefault(() => import("../features/users/UserListPage")),
},
{
path: "users/custom-claims",
lazy: lazyDefault(
() => import("../features/users/GlobalCustomClaimsPage"),
),
},
{
path: "users/new",
lazy: lazyDefault(() => import("../features/users/UserCreatePage")),
},
{
path: "users/:id",
lazy: lazyDefault(() => import("../features/users/UserDetailPage")),
},
{
path: "tenants",
lazy: lazyDefault(
() => import("../features/tenants/routes/TenantListPage"),
),
},
{
path: "tenants/new",
lazy: lazyDefault(
() => import("../features/tenants/routes/TenantCreatePage"),
),
},
{
path: "worksmobile",
lazy: lazyNamed(
() => import("../features/tenants/routes/TenantWorksmobilePage"),
"TenantWorksmobilePage",
),
},
{
path: "permissions-direct",
lazy: lazyNamed(
() =>
import(
"../features/tenants/routes/TenantFineGrainedPermissionsPage"
),
"TenantFineGrainedPermissionsPage",
),
},
{ {
path: "tenants/:tenantId", path: "tenants/:tenantId",
element: <TenantDetailPage />, lazy: lazyDefault(
() => import("../features/tenants/routes/TenantDetailPage"),
),
children: [ children: [
{ index: true, element: <TenantProfilePage /> }, {
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> }, index: true,
{ path: "organization", element: <TenantUserGroupsTab /> }, lazy: lazyNamed(
{ path: "schema", element: <TenantSchemaPage /> }, () => import("../features/tenants/routes/TenantProfilePage"),
"TenantProfilePage",
),
},
{
path: "permissions",
lazy: lazyNamed(
() =>
import(
"../features/tenants/routes/TenantAdminsAndOwnersTab"
),
"TenantAdminsAndOwnersTab",
),
},
{
path: "organization",
lazy: lazyDefault(
() =>
import(
"../features/user-groups/routes/TenantUserGroupsTab"
),
),
},
{
path: "schema",
lazy: lazyNamed(
() => import("../features/tenants/routes/TenantSchemaPage"),
"TenantSchemaPage",
),
},
{
path: "relations",
lazy: lazyNamed(
() =>
import(
"../features/tenants/routes/TenantFineGrainedPermissionsTab"
),
"TenantFineGrainedPermissionsTab",
),
},
], ],
}, },
{ {
path: "tenants/:tenantId/organization/:id", path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />, lazy: lazyDefault(
() =>
import("../features/user-groups/routes/TenantUserGroupsTab"),
),
},
{
path: "api-keys",
lazy: lazyDefault(
() => import("../features/api-keys/ApiKeyListPage"),
),
},
{
path: "api-keys/new",
lazy: lazyDefault(
() => import("../features/api-keys/ApiKeyCreatePage"),
),
},
{
path: "system/ory-ssot",
lazy: lazyDefault(() => import("../features/ory-ssot/OrySSOTPage")),
},
{
path: "system/data-integrity",
lazy: lazyDefault(
() => import("../features/integrity/DataIntegrityPage"),
),
}, },
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/projections/users", element: <UserProjectionPage /> },
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
], ],
}, },
], ],

View File

@@ -53,6 +53,8 @@ function LanguageSelector() {
return ( return (
<select <select
id="admin-language-selector"
name="admin-language-selector"
value={locale} value={locale}
onChange={(event) => handleChange(event.target.value as 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" className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"

View File

@@ -31,4 +31,27 @@ describe("LocaleRefreshBoundary", () => {
expect(screen.getByText("2")).toBeInTheDocument(); expect(screen.getByText("2")).toBeInTheDocument();
}); });
it("ignores storage events unrelated to locale changes", async () => {
render(
<LocaleRefreshBoundary>
<RenderCounter />
</LocaleRefreshBoundary>,
);
expect(screen.getByText("1")).toBeInTheDocument();
await act(async () => {
window.dispatchEvent(
new StorageEvent("storage", {
key: "admin_session",
newValue: "token",
oldValue: null,
storageArea: window.localStorage,
}),
);
});
expect(screen.getByText("1")).toBeInTheDocument();
});
}); });

View File

@@ -1,4 +1,5 @@
import { Fragment, type ReactNode, useEffect, useState } from "react"; import { Fragment, type ReactNode, useEffect, useState } from "react";
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
type LocaleRefreshBoundaryProps = { type LocaleRefreshBoundaryProps = {
children: ReactNode; children: ReactNode;
@@ -12,12 +13,19 @@ function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) {
setLocaleVersion((current) => current + 1); setLocaleVersion((current) => current + 1);
}; };
const syncLocaleFromStorage = (event: StorageEvent) => {
if (event.key !== LOCALE_STORAGE_KEY && event.key !== null) {
return;
}
syncLocale();
};
window.addEventListener("localechange", syncLocale); window.addEventListener("localechange", syncLocale);
window.addEventListener("storage", syncLocale); window.addEventListener("storage", syncLocaleFromStorage);
return () => { return () => {
window.removeEventListener("localechange", syncLocale); window.removeEventListener("localechange", syncLocale);
window.removeEventListener("storage", syncLocale); window.removeEventListener("storage", syncLocaleFromStorage);
}; };
}, []); }, []);

View File

@@ -102,7 +102,7 @@ describe("admin AppLayout", () => {
expect(screen.getByText("Tenants")).toBeInTheDocument(); expect(screen.getByText("Tenants")).toBeInTheDocument();
expect(screen.getByText("Org Chart")).toBeInTheDocument(); expect(screen.getByText("Org Chart")).toBeInTheDocument();
expect(screen.getByText("Worksmobile")).toBeInTheDocument(); expect(screen.getByText("Worksmobile")).toBeInTheDocument();
expect(screen.getByText("User Projection")).toBeInTheDocument(); expect(screen.getByText("Ory SSOT System")).toBeInTheDocument();
expect(screen.getByText("Data Integrity")).toBeInTheDocument(); expect(screen.getByText("Data Integrity")).toBeInTheDocument();
const navigation = screen.getByRole("navigation"); const navigation = screen.getByRole("navigation");
const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) => const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) =>
@@ -113,9 +113,10 @@ describe("admin AppLayout", () => {
"Tenants", "Tenants",
"Org Chart", "Org Chart",
"Worksmobile", "Worksmobile",
"User Projection", "Ory SSOT System",
"Data Integrity", "Data Integrity",
"Users", "Users",
"권한 부여",
"Auth Guard", "Auth Guard",
"API Keys", "API Keys",
"Audit Logs", "Audit Logs",
@@ -127,6 +128,22 @@ describe("admin AppLayout", () => {
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull(); expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
}); });
it("toggles the sidebar and persists the collapsed state", async () => {
renderLayout();
const collapseButton = await screen.findByRole("button", {
name: "사이드바 접기",
});
fireEvent.click(collapseButton);
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
"true",
);
expect(
screen.getByRole("button", { name: "사이드바 펼치기" }),
).toBeInTheDocument();
});
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => { it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
renderLayout(); renderLayout();

View File

@@ -26,11 +26,13 @@ import {
buildShellProfileSummary, buildShellProfileSummary,
buildShellSessionStatus, buildShellSessionStatus,
readShellSessionExpiryEnabled, readShellSessionExpiryEnabled,
readShellSidebarCollapsed,
readShellTheme, readShellTheme,
type ShellSidebarNavItem, type ShellSidebarNavItem,
type ShellTranslator, type ShellTranslator,
shellLayoutClasses, shellLayoutClasses,
writeShellSessionExpiryEnabled, writeShellSessionExpiryEnabled,
writeShellSidebarCollapsed,
} from "../../../../common/shell"; } from "../../../../common/shell";
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess"; import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
@@ -60,6 +62,12 @@ const staticNavItems: ShellSidebarNavItem[] = [
to: "/users", to: "/users",
icon: Users, icon: Users,
}, },
{
labelKey: "ui.admin.nav.permissions_direct",
labelFallback: "권한 부여",
to: "/permissions-direct",
icon: ShieldCheck,
},
{ {
labelKey: "ui.admin.nav.auth_guard", labelKey: "ui.admin.nav.auth_guard",
labelFallback: "Auth Guard", labelFallback: "Auth Guard",
@@ -165,6 +173,9 @@ function AppLayout() {
const isDevelopmentRuntime = import.meta.env.MODE === "development"; const isDevelopmentRuntime = import.meta.env.MODE === "development";
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme); const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileOpen, setIsProfileOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
readShellSidebarCollapsed(false),
);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime), readShellSessionExpiryEnabled(!isDevelopmentRuntime),
); );
@@ -201,70 +212,71 @@ function AppLayout() {
...profile, ...profile,
role: effectiveRole ?? profile?.role, role: effectiveRole ?? profile?.role,
}); });
const filteredItems = items.filter((item) => {
if (item.to === "/api-keys") return isSuperAdmin;
return true;
});
const orgfrontUrl = buildAuthenticatedOrgChartUrl( const orgfrontUrl = buildAuthenticatedOrgChartUrl(
import.meta.env.ORGFRONT_URL || "http://localhost:5175", import.meta.env.ORGFRONT_URL || "http://localhost:5175",
{ includeInternal: true }, { includeInternal: false },
); );
if (isSuperAdmin) { // Splice optional menus in a standard order
filteredItems.splice(1, 0, { items.splice(1, 0, {
labelKey: "ui.admin.nav.tenants", labelKey: "ui.admin.nav.tenants",
labelFallback: "Tenants", labelFallback: "Tenants",
to: "/tenants", to: "/tenants",
icon: Building2, icon: Building2,
}); });
filteredItems.splice(2, 0, { items.splice(2, 0, {
labelKey: "ui.admin.nav.org_chart", labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart", labelFallback: "Org Chart",
to: orgfrontUrl, to: orgfrontUrl,
icon: Network, icon: Network,
isExternal: true, isExternal: true,
}); });
if (showWorksmobile) { items.splice(3, 0, {
filteredItems.splice(3, 0, { labelKey: "ui.admin.nav.worksmobile",
labelKey: "ui.admin.nav.worksmobile", labelFallback: "Worksmobile",
labelFallback: "Worksmobile", to: "/worksmobile",
to: "/worksmobile", icon: LineWorksNavIcon,
icon: LineWorksNavIcon, });
}); items.splice(4, 0, {
} labelKey: "ui.admin.nav.ory_ssot",
filteredItems.splice(4, 0, { labelFallback: "Ory SSOT System",
labelKey: "ui.admin.nav.user_projection", to: "/system/ory-ssot",
labelFallback: "User Projection", icon: Database,
to: "/system/projections/users", });
icon: Database, items.splice(5, 0, {
}); labelKey: "ui.admin.nav.data_integrity",
filteredItems.splice(5, 0, { labelFallback: "Data Integrity",
labelKey: "ui.admin.nav.data_integrity", to: "/system/data-integrity",
labelFallback: "Data Integrity", icon: ShieldCheck,
to: "/system/data-integrity", });
icon: ShieldCheck,
});
} else {
// Non-superadmins
filteredItems.splice(1, 0, {
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
if (showWorksmobile) {
filteredItems.splice(2, 0, {
labelKey: "ui.admin.nav.worksmobile",
labelFallback: "Worksmobile",
to: "/worksmobile",
icon: LineWorksNavIcon,
});
}
}
return filteredItems; const permissions = profile?.systemPermissions;
return items.filter((item) => {
// Super Admin ALWAYS bypasses and gets full access to everything
if (isSuperAdmin) {
if (item.to === "/worksmobile") return showWorksmobile;
return true;
}
// For others, check their fine-grained systemPermissions
if (!permissions) return false;
if (item.to === "/") return permissions.overview;
if (item.to === "/users") return permissions.users;
if (item.to === "/auth") return permissions.auth_guard;
if (item.to === "/api-keys") return permissions.api_keys;
if (item.to === "/audit-logs") return permissions.audit_logs;
if (item.to === "/permissions-direct") return false;
if (item.to === "/tenants") return permissions.tenants;
if (item.to === orgfrontUrl) return permissions.org_chart;
if (item.to === "/worksmobile") return permissions.worksmobile;
if (item.to === "/system/ory-ssot") return permissions.ory_ssot;
if (item.to === "/system/data-integrity")
return permissions.data_integrity;
return true;
});
}, [profile]); }, [profile]);
const handleLogout = () => { const handleLogout = () => {
@@ -508,10 +520,18 @@ function AppLayout() {
return next; return next;
}); });
}; };
const handleSidebarToggle = () => {
setIsSidebarCollapsed((prev) => {
const next = !prev;
writeShellSidebarCollapsed(next);
return next;
});
};
const sidebarNavContent = ( const sidebarNavContent = (
<div className={shellLayoutClasses.navList}> <div className={shellLayoutClasses.navList}>
{navItems.map((item) => { {navItems.map((item) => {
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item; const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
const label = t(labelKey, labelFallback);
if (isExternal) { if (isExternal) {
return ( return (
@@ -522,11 +542,18 @@ function AppLayout() {
rel="noopener noreferrer" rel="noopener noreferrer"
className={[ className={[
shellLayoutClasses.navItemBase, shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
shellLayoutClasses.navItemIdle, shellLayoutClasses.navItemIdle,
].join(" ")} ].join(" ")}
title={label}
aria-label={label}
> >
<Icon size={18} /> <Icon size={18} />
<span>{t(labelKey, labelFallback)}</span> <span className={isSidebarCollapsed ? "sr-only" : ""}>
{label}
</span>
</a> </a>
); );
} }
@@ -539,6 +566,9 @@ function AppLayout() {
className={({ isActive }) => className={({ isActive }) =>
[ [
shellLayoutClasses.navItemBase, shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
item.isActive !== undefined item.isActive !== undefined
? item.isActive ? item.isActive
? shellLayoutClasses.navItemActive ? shellLayoutClasses.navItemActive
@@ -548,9 +578,11 @@ function AppLayout() {
: shellLayoutClasses.navItemIdle, : shellLayoutClasses.navItemIdle,
].join(" ") ].join(" ")
} }
title={label}
aria-label={label}
> >
<Icon size={18} /> <Icon size={18} />
<span>{t(labelKey, labelFallback)}</span> <span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
</NavLink> </NavLink>
); );
})} })}
@@ -561,10 +593,17 @@ function AppLayout() {
<button <button
type="button" type="button"
onClick={handleLogout} onClick={handleLogout}
className={shellLayoutClasses.logoutButton} className={
isSidebarCollapsed
? shellLayoutClasses.logoutButtonCollapsed
: shellLayoutClasses.logoutButton
}
title={t("ui.shell.nav.logout", "Logout")}
> >
<LogOut size={18} /> <LogOut size={18} />
<span>{t("ui.shell.nav.logout", "Logout")}</span> <span className={isSidebarCollapsed ? "sr-only" : ""}>
{t("ui.shell.nav.logout", "Logout")}
</span>
</button> </button>
</div> </div>
); );
@@ -578,13 +617,23 @@ function AppLayout() {
} }
return ( return (
<div className={shellLayoutClasses.root}> <div
className={
isSidebarCollapsed
? shellLayoutClasses.rootCollapsed
: shellLayoutClasses.root
}
>
<AppSidebar <AppSidebar
brandLabel={t("ui.admin.brand", "Baron 로그인")} brandLabel={t("ui.admin.brand", "Baron 로그인")}
brandTitle={t("ui.admin.title", "Admin Control")} brandTitle={t("ui.admin.title", "Admin Control")}
brandIcon={<ShieldHalf size={20} />} brandIcon={<ShieldHalf size={20} />}
navContent={sidebarNavContent} navContent={sidebarNavContent}
footerContent={sidebarFooterContent} footerContent={sidebarFooterContent}
collapsed={isSidebarCollapsed}
onToggleCollapsed={handleSidebarToggle}
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
/> />
<div className={shellLayoutClasses.contentWide}> <div className={shellLayoutClasses.contentWide}>
@@ -785,7 +834,7 @@ function AppLayout() {
</div> </div>
</header> </header>
<main className={shellLayoutClasses.mainMinWidth}> <main className={shellLayoutClasses.mainMinWidth}>
<Outlet /> <Outlet context={isSidebarCollapsed} />
</main> </main>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -6,9 +6,14 @@ export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {} extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>( const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, id, name, ...props }, ref) => {
const fallbackId = React.useId();
const fieldId = id ?? (name ? undefined : fallbackId);
return ( return (
<input <input
id={fieldId}
name={name}
type={type} type={type}
className={cn(commonInputClass, className)} className={cn(commonInputClass, className)}
ref={ref} ref={ref}

View File

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

View File

@@ -5,9 +5,14 @@ export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {} extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, id, name, ...props }, ref) => {
const fallbackId = React.useId();
const fieldId = id ?? (name ? undefined : fallbackId);
return ( return (
<textarea <textarea
id={fieldId}
name={name}
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className, className,

View File

@@ -18,6 +18,11 @@ const notify = () => {
}; };
const toastBase = (message: string, type: ToastType = "success") => { const toastBase = (message: string, type: ToastType = "success") => {
if (
toasts.some((toast) => toast.message === message && toast.type === type)
) {
return;
}
const id = Math.random().toString(36).substring(2, 9); const id = Math.random().toString(36).substring(2, 9);
toasts = [...toasts, { id, message, type }]; toasts = [...toasts, { id, message, type }];
notify(); notify();

View File

@@ -1,6 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createApiKey } from "../../lib/adminApi"; import { createApiKey } from "../../lib/adminApi";
@@ -50,15 +49,13 @@ describe("ApiKeyCreatePage", () => {
}); });
it("includes org-context:read in the create request when selected", async () => { it("includes org-context:read in the create request when selected", async () => {
const user = userEvent.setup();
renderPage(); renderPage();
await user.type( fireEvent.change(screen.getByLabelText("서비스 또는 목적 식별 이름"), {
screen.getByLabelText("서비스 또는 목적 식별 이름"), target: { value: "org-context-client" },
"org-context-client", });
); fireEvent.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ })); fireEvent.click(screen.getByRole("button", { name: /API 키 발급하기/ }));
await user.click(screen.getByRole("button", { name: /API 키 발급하기/ }));
await waitFor(() => { await waitFor(() => {
expect(createApiKey).toHaveBeenCalledWith( expect(createApiKey).toHaveBeenCalledWith(

View File

@@ -74,7 +74,7 @@ describe("ApiKeyListPage", () => {
}); });
it("updates scopes without changing client_id", async () => { it("updates scopes without changing client_id", async () => {
const user = userEvent.setup(); const user = userEvent.setup({ delay: null });
renderPage(); renderPage();
expect(await screen.findByText("client-id-stable")).toBeInTheDocument(); expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
@@ -88,7 +88,7 @@ describe("ApiKeyListPage", () => {
scopes: expect.arrayContaining(["audit:read", "org-context:read"]), scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
}); });
}); });
}); }, 15_000);
it("rotates only the secret and shows the one-time secret", async () => { it("rotates only the secret and shows the one-time secret", async () => {
const user = userEvent.setup(); const user = userEvent.setup();

View File

@@ -159,6 +159,8 @@ function AuditLogsPage() {
)} )}
/> />
<select <select
id="audit-filter-status"
name="audit-filter-status"
data-testid="audit-filter-status" data-testid="audit-filter-status"
className="h-10 rounded-md border border-input bg-background px-3 text-sm" className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={statusFilter} value={statusFilter}

View File

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

View File

@@ -1,13 +1,31 @@
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { Navigate, Outlet, useLocation } from "react-router-dom"; import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import { clearStoredAdminAuthSession } from "../../lib/auth";
export default function AuthGuard() { export default function AuthGuard() {
const auth = useAuth(); const auth = useAuth();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const handledAuthErrorRef = useRef(false);
const isTest = const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true; ._IS_TEST_MODE === true;
useEffect(() => {
if (!auth.error || handledAuthErrorRef.current || isTest) {
return;
}
handledAuthErrorRef.current = true;
clearStoredAdminAuthSession();
void Promise.resolve(
auth.removeUser ? auth.removeUser() : undefined,
).finally(() => {
navigate("/login", { replace: true });
});
}, [auth, auth.error, isTest, navigate]);
if (isTest) { if (isTest) {
return <Outlet />; return <Outlet />;
} }

View File

@@ -64,6 +64,8 @@ function PermissionChecker() {
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")} {t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
</Label> </Label>
<select <select
id="permission-checker-namespace"
name="permission-checker-namespace"
value={namespace} value={namespace}
onChange={(e) => setNamespace(e.target.value)} 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" 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"

View File

@@ -143,6 +143,7 @@ vi.mock("../../lib/adminApi", () => ({
login_count: 3, login_count: 3,
}, },
]), ]),
fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({ items: [] })),
fetchPasswordPolicy: vi.fn(async () => ({ fetchPasswordPolicy: vi.fn(async () => ({
minLength: 12, minLength: 12,
lowercase: true, lowercase: true,
@@ -196,6 +197,7 @@ vi.mock("../../lib/adminApi", () => ({
worksmobileId: "works-user-1", worksmobileId: "works-user-1",
worksmobileName: "Engineer User", worksmobileName: "Engineer User",
worksmobileEmail: "engineer@example.com", worksmobileEmail: "engineer@example.com",
worksmobileDomainId: 1001,
worksmobilePrimaryOrgId: "works-org-1", worksmobilePrimaryOrgId: "works-org-1",
worksmobilePrimaryOrgName: "기술연구팀", worksmobilePrimaryOrgName: "기술연구팀",
status: "matched", status: "matched",
@@ -380,17 +382,19 @@ describe("adminfront large page coverage smoke", () => {
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }), screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
); );
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
target: { value: "InitialPassword!1" },
});
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
await waitFor(() => await waitFor(() =>
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith( expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
"tenant-company", "tenant-company",
"user-2", "user-2",
expect.any(String), undefined,
"InitialPassword!1",
), ),
); );
const credentialBatchId = vi.mocked(
adminApi.enqueueWorksmobileUserSync,
).mock.calls[0][2];
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled(); expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
}); });
@@ -416,6 +420,10 @@ describe("adminfront large page coverage smoke", () => {
fireEvent.click( fireEvent.click(
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }), screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
); );
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
target: { value: "InitialPassword!1" },
});
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
await waitFor(() => await waitFor(() =>
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2), expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
@@ -424,21 +432,20 @@ describe("adminfront large page coverage smoke", () => {
1, 1,
"tenant-company", "tenant-company",
"user-2", "user-2",
expect.any(String), undefined,
"InitialPassword!1",
); );
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith( expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
2, 2,
"tenant-company", "tenant-company",
"user-3", "user-3",
expect.any(String), undefined,
"InitialPassword!1",
); );
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled(); expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
}); });
it("downloads or deletes Worksmobile credential batches from history", async () => { it("renders and retries Worksmobile jobs from history", async () => {
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
vi.spyOn(window, "confirm").mockReturnValue(true);
renderWithProviders( renderWithProviders(
<Routes> <Routes>
<Route <Route
@@ -450,45 +457,20 @@ describe("adminfront large page coverage smoke", () => {
); );
fireEvent.click(screen.getByRole("tab", { name: "이력" })); fireEvent.click(screen.getByRole("tab", { name: "이력" }));
await screen.findByText("credential-batch-1"); expect((await screen.findAllByText("user-1")).length).toBeGreaterThan(0);
expect( expect(screen.getByText("failed")).toBeInTheDocument();
screen.getByRole("button", {
name: "credential-batch-pending 비밀번호 CSV 다운로드",
}),
).toBeDisabled();
fireEvent.click(
screen.getByRole("button", {
name: "credential-batch-1 비밀번호 CSV 다운로드",
}),
);
await waitFor(() =>
expect(
adminApi.downloadWorksmobileInitialPasswordsCSV,
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
);
fireEvent.click( fireEvent.click(screen.getAllByRole("button", { name: "" })[0]);
screen.getByRole("button", {
name: "credential-batch-1 비밀번호 값 삭제",
}),
);
await waitFor(() => await waitFor(() =>
expect( expect(adminApi.retryWorksmobileJob).toHaveBeenCalledWith(
adminApi.deleteWorksmobileCredentialBatchPasswords, "tenant-company",
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"), "job-1",
),
); );
fireEvent.click(
screen.getByRole("button", {
name: "credential-batch-1 실패 사유 보기",
}),
);
expect(await screen.findByText("failed-user@samaneng.com")).toBeInTheDocument();
expect(screen.getByText("worksmobile api failed")).toBeInTheDocument();
}); });
it("enqueues Worksmobile password reset as a credential batch", async () => { it("opens Worksmobile password management for matched users", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true); const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
renderWithProviders( renderWithProviders(
<Routes> <Routes>
<Route <Route
@@ -504,17 +486,21 @@ describe("adminfront large page coverage smoke", () => {
await screen.findAllByText("Engineer User"); await screen.findAllByText("Engineer User");
fireEvent.click( fireEvent.click(
screen.getByRole("button", { screen.getByRole("button", {
name: "Engineer User 비밀번호 재설정", name: "Engineer User 비밀번호 관리",
}), }),
); );
await waitFor(() => expect(openSpy).toHaveBeenCalledWith(
expect(adminApi.resetWorksmobileUserPassword).toHaveBeenCalledWith( expect.stringContaining(
"tenant-company", "https://auth.worksmobile.com/integrate/password/manage",
"user-1",
expect.any(String),
), ),
"_blank",
"noopener,noreferrer",
); );
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled(); const [url] = openSpy.mock.calls[0] ?? [];
const parsed = new URL(String(url));
expect(parsed.searchParams.get("targetUserTenantId")).toBe("works-admin");
expect(parsed.searchParams.get("targetUserDomainId")).toBe("1001");
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
}); });
}); });

View File

@@ -0,0 +1,33 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
function listSourceFiles(directory: string): string[] {
const entries = readdirSync(directory);
const files: string[] = [];
for (const entry of entries) {
const path = join(directory, entry);
const stat = statSync(path);
if (stat.isDirectory()) {
files.push(...listSourceFiles(path));
continue;
}
if (path.endsWith(".tsx")) {
files.push(path);
}
}
return files;
}
describe("admin page animation policy", () => {
it("does not use long enter fade animations on stable page containers", () => {
const sourceRoot = join(process.cwd(), "src");
const offenders = listSourceFiles(sourceRoot).filter((file) =>
readFileSync(file, "utf8").includes("animate-in fade-in duration-500"),
);
expect(offenders.map((file) => file.replace(`${sourceRoot}/`, ""))).toEqual(
[],
);
});
});

View File

@@ -29,6 +29,7 @@ const members = [
vi.mock("../../lib/i18n", () => createI18nMock()); vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({ vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ id: "admin-user", role: "super_admin" })),
fetchTenant: vi.fn(async () => tenant), fetchTenant: vi.fn(async () => tenant),
fetchUsers: vi.fn(async () => ({ fetchUsers: vi.fn(async () => ({
items: [ items: [

View File

@@ -1,12 +1,23 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react"; import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock"; import { createI18nMock } from "../../test/i18nMock";
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab"; import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
import { TenantFineGrainedPermissionsTab } from "../tenants/routes/TenantFineGrainedPermissionsTab";
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab"; import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
const exportUsersCSVMock = vi.hoisted(() =>
vi.fn(async () => ({
blob: new Blob(["email,name\nmember@example.com,Member User\n"], {
type: "text/csv",
}),
filename: "users_export_20260609.csv",
})),
);
const bulkUpdateUsersMock = vi.hoisted(() => vi.fn(async () => ({ results: [] })));
const tenants = [ const tenants = [
{ {
id: "tenant-root", id: "tenant-root",
@@ -50,7 +61,7 @@ const users = [
id: "user-owner", id: "user-owner",
name: "Owner User", name: "Owner User",
email: "owner@example.com", email: "owner@example.com",
role: "tenant_admin", role: "super_admin",
status: "active", status: "active",
}, },
{ {
@@ -84,12 +95,29 @@ vi.mock("react-oidc-context", () => ({
})); }));
vi.mock("../../lib/adminApi", () => ({ vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => users[0]),
fetchTenant: vi.fn(async (tenantId) => ({
id: tenantId,
name: "Test Tenant",
slug: "test-tenant",
userPermissions: { view: true, manage: true, manage_admins: true },
})),
fetchTenantOwners: vi.fn(async () => [users[0]]), fetchTenantOwners: vi.fn(async () => [users[0]]),
fetchTenantAdmins: vi.fn(async () => [users[1]]), fetchTenantAdmins: vi.fn(async () => [users[1]]),
addTenantOwner: vi.fn(async () => undefined), addTenantOwner: vi.fn(async () => undefined),
addTenantAdmin: vi.fn(async () => undefined), addTenantAdmin: vi.fn(async () => undefined),
removeTenantOwner: vi.fn(async () => undefined), removeTenantOwner: vi.fn(async () => undefined),
removeTenantAdmin: vi.fn(async () => undefined), removeTenantAdmin: vi.fn(async () => undefined),
fetchTenantRelations: vi.fn(async () => [
{
userId: "user-relation-1",
name: "Relation User",
email: "relation@example.com",
relations: ["profile_managers", "schema_viewers"],
},
]),
addTenantRelation: vi.fn(async () => undefined),
removeTenantRelation: vi.fn(async () => undefined),
fetchUsers: vi.fn(async () => ({ fetchUsers: vi.fn(async () => ({
items: users, items: users,
total: users.length, total: users.length,
@@ -100,10 +128,12 @@ vi.mock("../../lib/adminApi", () => ({
})), })),
updateTenant: vi.fn(async () => tenants[2]), updateTenant: vi.fn(async () => tenants[2]),
updateUser: vi.fn(async () => users[2]), updateUser: vi.fn(async () => users[2]),
bulkUpdateUsers: bulkUpdateUsersMock,
exportTenantsCSV: vi.fn(async () => ({ exportTenantsCSV: vi.fn(async () => ({
blob: new Blob(["name,slug"]), blob: new Blob(["name,slug"]),
filename: "tenants.csv", filename: "tenants.csv",
})), })),
exportUsersCSV: exportUsersCSVMock,
})); }));
function renderWithProviders(ui: React.ReactElement, entry: string) { function renderWithProviders(ui: React.ReactElement, entry: string) {
@@ -125,6 +155,10 @@ describe("admin tenant tab coverage smoke", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true); vi.spyOn(window, "confirm").mockReturnValue(true);
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
"blob:tenant-users-export",
);
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
}); });
it("renders tenant owners and admins lists", async () => { it("renders tenant owners and admins lists", async () => {
@@ -144,6 +178,22 @@ describe("admin tenant tab coverage smoke", () => {
expect(screen.getByText("admin@example.com")).toBeInTheDocument(); expect(screen.getByText("admin@example.com")).toBeInTheDocument();
}); });
it("renders tenant fine-grained relations list", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/relations"
element={<TenantFineGrainedPermissionsTab />}
/>
</Routes>,
"/tenants/tenant-company/relations",
);
expect(await screen.findByText("Relation User")).toBeInTheDocument();
expect(screen.getByText("relation@example.com")).toBeInTheDocument();
expect(screen.getByText("세부 권한 설정 (Fine-grained Permissions)")).toBeInTheDocument();
});
it("renders tenant hierarchy and selected organization members", async () => { it("renders tenant hierarchy and selected organization members", async () => {
renderWithProviders( renderWithProviders(
<Routes> <Routes>
@@ -159,4 +209,68 @@ describe("admin tenant tab coverage smoke", () => {
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0); expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
expect(await screen.findByText("Member User")).toBeInTheDocument(); expect(await screen.findByText("Member User")).toBeInTheDocument();
}); });
it("exports selected organization users by tenant slug", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/organization"
element={<TenantUserGroupsTab />}
/>
</Routes>,
"/tenants/tenant-company/organization",
);
expect(await screen.findByText("Member User")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("tenant-current-users-export-btn"));
await waitFor(() => {
expect(exportUsersCSVMock).toHaveBeenCalledWith("", "gpdtdc", false);
});
});
it("queues searched users and bulk adds them to the selected organization", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/organization"
element={<TenantUserGroupsTab />}
/>
</Routes>,
"/tenants/tenant-company/organization",
);
expect(await screen.findByText("Member User")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /멤버 추가/ }));
fireEvent.change(screen.getByTestId("tenant-org-member-search-input"), {
target: { value: "user" },
});
fireEvent.click(screen.getByTestId("tenant-org-member-search-btn"));
fireEvent.click(
await screen.findByTestId("tenant-org-member-search-result-user-owner"),
);
fireEvent.click(
await screen.findByTestId("tenant-org-member-search-result-user-admin"),
);
expect(screen.getByTestId("tenant-org-member-add-queue")).toHaveTextContent(
"Owner User",
);
expect(screen.getByTestId("tenant-org-member-add-queue")).toHaveTextContent(
"Admin User",
);
fireEvent.click(screen.getByTestId("tenant-org-member-add-submit-btn"));
await waitFor(() => {
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
userIds: ["user-owner", "user-admin"],
tenantSlug: "gpdtdc",
isAddTenant: true,
});
});
});
}); });

View File

@@ -6,10 +6,8 @@ import {
fetchDataIntegrityReport, fetchDataIntegrityReport,
fetchMe, fetchMe,
fetchOrphanUserLoginIDs, fetchOrphanUserLoginIDs,
fetchUserProjectionStatus,
reconcileUserProjection,
resetUserProjection,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
import { createI18nMock } from "../../test/i18nMock"; import { createI18nMock } from "../../test/i18nMock";
import DataIntegrityPage from "./DataIntegrityPage"; import DataIntegrityPage from "./DataIntegrityPage";
@@ -63,24 +61,6 @@ vi.mock("../../lib/adminApi", () => ({
], ],
total: 1, total: 1,
})), })),
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",
})),
deleteOrphanUserLoginIDs: vi.fn(async () => ({ deleteOrphanUserLoginIDs: vi.fn(async () => ({
deletedCount: 1, deletedCount: 1,
deleted: [ deleted: [
@@ -124,12 +104,6 @@ describe("DataIntegrityPage", () => {
renderPage(); renderPage();
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument(); expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "정합성 검사" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "사용자 동기화" }),
).toBeInTheDocument();
expect( expect(
await screen.findByText( await screen.findByText(
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.", "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
@@ -141,35 +115,14 @@ describe("DataIntegrityPage", () => {
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1); expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
}); });
it("renders user projection sync inside data integrity", async () => {
renderPage();
fireEvent.click(await screen.findByRole("tab", { name: "사용자 동기화" }));
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
expect(screen.getByText("준비됨")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
await waitFor(() => {
expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
await waitFor(() => {
expect(resetUserProjection).toHaveBeenCalledTimes(1);
});
expect(fetchUserProjectionStatus).toHaveBeenCalled();
});
it("shows orphan login ID targets and deletes selected rows", async () => { it("shows orphan login ID targets and deletes selected rows", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true); vi.spyOn(window, "confirm").mockReturnValue(true);
renderPage(); const { container } = renderPage();
expect(await screen.findByText("유령 로그인 ID 정리")).toBeInTheDocument(); expect(await screen.findByText("유령 로그인 ID 정리")).toBeInTheDocument();
expect(await screen.findByText("EMP001")).toBeInTheDocument(); expect(await screen.findByText("EMP001")).toBeInTheDocument();
expect(screen.getByText("삭제된 테넌트")).toBeInTheDocument(); expect(screen.getByText("삭제된 테넌트")).toBeInTheDocument();
expectNoAnonymousFormFields(container);
expect(fetchOrphanUserLoginIDs).toHaveBeenCalledTimes(1); expect(fetchOrphanUserLoginIDs).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("checkbox", { name: "EMP001 선택" })); fireEvent.click(screen.getByRole("checkbox", { name: "EMP001 선택" }));

View File

@@ -19,7 +19,6 @@ import {
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale"; import { getAdminDateLocale } from "../../lib/locale";
import { UserProjectionContent } from "../projections/UserProjectionPage";
function statusLabel(status: DataIntegrityStatus) { function statusLabel(status: DataIntegrityStatus) {
switch (status) { switch (status) {
@@ -188,14 +187,6 @@ function recheckStatusText(status: "idle" | "running" | "success" | "error") {
} }
} }
function pageTabClassName(active: boolean) {
return `relative px-6 py-3 text-sm font-medium transition-colors ${
active
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`;
}
function OrphanLoginIDTable({ function OrphanLoginIDTable({
items, items,
selectedIds, selectedIds,
@@ -247,6 +238,7 @@ function OrphanLoginIDTable({
<tr key={item.id}> <tr key={item.id}>
<td className="px-3 py-2"> <td className="px-3 py-2">
<input <input
name={`orphan-login-id-select-${item.id}`}
type="checkbox" type="checkbox"
aria-label={t( aria-label={t(
"ui.admin.integrity.table.select_item", "ui.admin.integrity.table.select_item",
@@ -293,9 +285,6 @@ function OrphanLoginIDTable({
function DataIntegrityContent() { function DataIntegrityContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
"integrity",
);
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]); const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
const [recheckStatus, setRecheckStatus] = useState< const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error" "idle" | "running" | "success" | "error"
@@ -372,243 +361,210 @@ function DataIntegrityContent() {
</p> </p>
</div> </div>
</div> </div>
{activeTab === "integrity" ? ( <div className="flex flex-col items-end gap-1">
<div className="flex flex-col items-end gap-1"> <Button
<Button type="button"
type="button" variant="outline"
variant="outline" onClick={handleRecheck}
onClick={handleRecheck} disabled={isLoading || isFetching || isManualRechecking}
disabled={isLoading || isFetching || isManualRechecking} >
<Database size={16} />
{isManualRechecking
? t("ui.admin.integrity.recheck.running", "검사 중")
: t("ui.admin.integrity.recheck.run", "다시 검사")}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
> >
<Database size={16} /> {recheckMessage}
{isManualRechecking </output>
? t("ui.admin.integrity.recheck.running", "검사 중") ) : null}
: t("ui.admin.integrity.recheck.run", "다시 검사")} </div>
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
{recheckMessage}
</output>
) : null}
</div>
) : null}
</header> </header>
<div <div className="space-y-4 pb-6">
className="flex border-b border-border" {isError ? (
role="tablist" <section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
aria-label="데이터 정합성 탭" {(error as Error)?.message ||
> t(
<button "msg.admin.integrity.report.load_error",
type="button" "정합성 리포트를 불러오지 못했습니다.",
role="tab" )}
aria-selected={activeTab === "integrity"}
className={pageTabClassName(activeTab === "integrity")}
onClick={() => setActiveTab("integrity")}
>
{t("ui.admin.integrity.tab_checks", "정합성 검사")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "projection"}
className={pageTabClassName(activeTab === "projection")}
onClick={() => setActiveTab("projection")}
>
{t("ui.admin.integrity.tab_user_projection", "사용자 동기화")}
</button>
</div>
{activeTab === "integrity" ? (
<div className="space-y-4 pb-6 animate-in fade-in duration-500">
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.integrity.report.load_error",
"정합성 리포트를 불러오지 못했습니다.",
)}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.read_model.description",
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
)}
</p>
</div>
{data ? (
<Badge variant={statusBadgeVariant(data.status)}>
{statusLabel(data.status)}
</Badge>
) : null}
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.integrity.loading", "불러오는 중")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.passed", "정상")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.failures", "실패 건수")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
</section> </section>
) : null}
<div className="space-y-4"> <section className="rounded-lg border border-border bg-card p-5">
{(data?.sections ?? []).map((section) => ( <div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<section <div>
key={section.key} <h3 className="text-lg font-bold flex items-center gap-2">
className="rounded-lg border border-border bg-card p-5" {t(
> "ui.admin.integrity.read_model.title",
<div className="mb-4 flex items-center justify-between gap-3"> "Read model integrity",
<div className="space-y-1"> )}
<h3 className="text-lg font-bold flex items-center gap-2"> </h3>
{integritySectionLabel(section.key, section.label)} <p className="text-sm text-muted-foreground">
</h3> {t(
<p className="text-sm text-muted-foreground"> "msg.admin.integrity.read_model.description",
{integritySectionDescription(section.key)} "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
</p> )}
</div> </p>
<Badge variant={statusBadgeVariant(section.status)}> </div>
{statusLabel(section.status)} {data ? (
</Badge> <Badge variant={statusBadgeVariant(data.status)}>
</div> {statusLabel(data.status)}
<div className="divide-y divide-border"> </Badge>
{section.checks.map((check) => ( ) : null}
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">
{integrityCheckLabel(check.key, check.label)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{integrityCheckDescription(
check.key,
check.description,
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
</div> </div>
<section className="rounded-lg border border-border bg-card p-5"> {isLoading ? (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div className="py-8 text-sm text-muted-foreground">
<div> {t("ui.admin.integrity.loading", "불러오는 중")}
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
</p>
</div>
<Button
type="button"
variant="destructive"
onClick={handleDeleteSelected}
disabled={
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
>
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
</Button>
</div> </div>
{orphanLoginIDsQuery.isError ? ( ) : (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"> <dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
{t( <div>
"msg.admin.integrity.orphan_login_ids.load_error", <dt className="text-sm text-muted-foreground">
"유령 로그인 ID 대상을 불러오지 못했습니다.", {t("ui.admin.integrity.summary.total_checks", "검사 항목")}
)} </dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div> </div>
) : null} <div>
{deleteMutation.data ? ( <dt className="text-sm text-muted-foreground">
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200"> {t("ui.admin.integrity.summary.passed", "정상")}
{t( </dt>
"msg.admin.integrity.orphan_login_ids.delete_success", <dd className="mt-1 text-xl font-semibold tabular-nums">
"{{count}}개의 유령 로그인 ID를 삭제했습니다.", {data?.summary.passed ?? 0}
{ count: deleteMutation.data.deletedCount }, </dd>
)}
</div> </div>
) : null} <div>
<OrphanLoginIDTable <dt className="text-sm text-muted-foreground">
items={orphanItems} {t("ui.admin.integrity.summary.failures", "실패 건수")}
selectedIds={selectedOrphanIds} </dt>
onToggle={toggleOrphanID} <dd className="mt-1 text-xl font-semibold tabular-nums">
/> {data?.summary.failures ?? 0}
</section> </dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
</section>
<div className="space-y-4">
{(data?.sections ?? []).map((section) => (
<section
key={section.key}
className="rounded-lg border border-border bg-card p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{integritySectionLabel(section.key, section.label)}
</h3>
<p className="text-sm text-muted-foreground">
{integritySectionDescription(section.key)}
</p>
</div>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
</Badge>
</div>
<div className="divide-y divide-border">
{section.checks.map((check) => (
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">
{integrityCheckLabel(check.key, check.label)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{integrityCheckDescription(
check.key,
check.description,
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
</div> </div>
) : (
<div className="animate-in fade-in duration-500"> <section className="rounded-lg border border-border bg-card p-5">
<UserProjectionContent embedded /> <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
</div> <div>
)} <h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
</p>
</div>
<Button
type="button"
variant="destructive"
onClick={handleDeleteSelected}
disabled={
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
>
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
</Button>
</div>
{orphanLoginIDsQuery.isError ? (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t(
"msg.admin.integrity.orphan_login_ids.load_error",
"유령 로그인 ID 대상을 불러오지 못했습니다.",
)}
</div>
) : null}
{deleteMutation.data ? (
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{t(
"msg.admin.integrity.orphan_login_ids.delete_success",
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
{ count: deleteMutation.data.deletedCount },
)}
</div>
) : null}
<OrphanLoginIDTable
items={orphanItems}
selectedIds={selectedOrphanIds}
onToggle={toggleOrphanID}
/>
</section>
</div>
</main> </main>
); );
} }

View File

@@ -0,0 +1,85 @@
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 {
fetchMe,
fetchOrySSOTSystemStatus,
flushIdentityCache,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import OrySSOTPage from "./OrySSOTPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchOrySSOTSystemStatus: vi.fn(async () => ({
identityCache: {
status: "ready",
redisReady: true,
observedCount: 151,
keyCount: 153,
lastRefreshedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
},
})),
flushIdentityCache: vi.fn(async () => ({
status: "success",
flushedKeys: 153,
updatedAt: "2026-05-11T03:02:00Z",
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<OrySSOTPage />
</QueryClientProvider>,
);
}
describe("OrySSOTPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
window.localStorage.setItem("locale", "ko");
});
it("renders identity cache status and flushes cache", async () => {
renderPage();
expect(
(await screen.findAllByText("Ory SSOT 시스템")).length,
).toBeGreaterThan(0);
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
expect(screen.getByText("151")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
await waitFor(() => {
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
});
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
it("blocks non-super admins", async () => {
currentRole = "tenant_admin";
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(fetchMe).toHaveBeenCalled();
expect(fetchOrySSOTSystemStatus).not.toHaveBeenCalled();
});
});

View File

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

View File

@@ -506,7 +506,7 @@ function GlobalOverviewPage() {
); );
return ( return (
<div className="space-y-4 animate-in fade-in duration-500"> <div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3"> <div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary"> <div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">

View File

@@ -1,116 +0,0 @@
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 { createI18nMock } from "../../test/i18nMock";
import UserProjectionPage from "./UserProjectionPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
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);
window.localStorage.setItem("locale", "ko");
});
it("renders projection status for super_admin", async () => {
renderPage();
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
expect(
await screen.findByText(
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
),
).toBeInTheDocument();
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
expect(screen.getByText("준비됨")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
expect(fetchUserProjectionStatus).toHaveBeenCalled();
});
it("runs reconcile and reset actions for super_admin", async () => {
renderPage();
await screen.findByText("사용자 동기화 관리");
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("사용자 동기화 관리")).not.toBeInTheDocument();
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
});
it("renders localized labels in English", async () => {
window.localStorage.setItem("locale", "en");
renderPage();
expect(
await screen.findByText("User Projection Management"),
).toBeInTheDocument();
expect(
await screen.findByText("Review and sync the Kratos user read model."),
).toBeInTheDocument();
expect(screen.getByText("Re-sync")).toBeInTheDocument();
expect(await screen.findByText("ready")).toBeInTheDocument();
});
});

View File

@@ -1,298 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, RefreshCw, RotateCcw, Users } 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";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
function formatDateTime(value?: string) {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat(getAdminDateLocale(), {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
}
function ProjectionStatusBadge({
ready,
status,
}: {
ready: boolean;
status: string;
}) {
if (ready) {
return (
<Badge variant="success">
{t("ui.admin.user_projection.status.ready", "ready")}
</Badge>
);
}
if (status === "failed") {
return (
<Badge variant="warning">
{t("ui.admin.user_projection.status.failed", "failed")}
</Badge>
);
}
return (
<Badge variant="secondary">
{status
? status
: t("ui.admin.user_projection.status.not_ready", "not ready")}
</Badge>
);
}
export function UserProjectionContent({
embedded = false,
}: {
embedded?: boolean;
}) {
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(
t(
"msg.admin.user_projection.reset_confirm",
"Rebuild user projection from the Kratos source of truth?",
),
);
if (confirmed) {
resetMutation.mutate();
}
};
const isWorking = reconcileMutation.isPending || resetMutation.isPending;
const actionResult = reconcileMutation.data ?? resetMutation.data;
const actionError = reconcileMutation.error ?? resetMutation.error;
const header = (
<header
className={
embedded
? "flex flex-shrink-0 flex-wrap items-start justify-between gap-4"
: "flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur"
}
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Users size={20} />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.user_projection.title", "User Projection Management")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.subtitle",
"Review and sync the Kratos user read model.",
)}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => reconcileMutation.mutate()}
disabled={isWorking}
>
<RefreshCw size={16} />
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isWorking}
>
<RotateCcw size={16} />
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
</Button>
</div>
</header>
);
const body = (
<>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.user_projection.load_error",
"Failed to load projection status.",
)}
</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">
{t(
"msg.admin.user_projection.action_success",
"Refreshed the projection for {{count}} users.",
{ count: actionResult.syncedUsers },
)}
</section>
) : null}
{actionError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(actionError as Error)?.message ||
t(
"msg.admin.user_projection.action_error",
"Projection operation failed.",
)}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.user_projection.card.title",
"Kratos users projection",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.user_projection.card.description",
"Current user read model state referenced by backend DB statistics.",
)}
</p>
</div>
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.user_projection.loading", "Loading")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.user_projection.summary.status", "Status")}
</dt>
<dd className="mt-1">
<ProjectionStatusBadge
ready={data?.ready ?? false}
status={data?.status ?? "unknown"}
/>
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.user_projection.summary.projected_users",
"Projected users",
)}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.projectedUsers ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.user_projection.summary.last_synced",
"Last synced",
)}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.lastSyncedAt)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.user_projection.summary.updated_at", "Updated at")}
</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>
</>
);
if (embedded) {
return (
<div className="space-y-4 pb-6">
{header}
{body}
</div>
);
}
return (
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
{header}
{body}
</main>
);
}
export default function UserProjectionPage() {
return (
<RoleGuard
roles={["super_admin"]}
fallback={
<main className="p-6 md:p-8">
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="text-lg font-semibold">
{t("ui.admin.user_projection.forbidden.title", "Access denied")}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.forbidden.description",
"This screen is only available to super_admin users.",
)}
</p>
</section>
</main>
}
>
<UserProjectionContent />
</RoleGuard>
);
}

View File

@@ -1,11 +1,9 @@
import { render, screen } from "@testing-library/react"; import { fireEvent, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { DomainTagInput } from "./DomainTagInput"; import { DomainTagInput } from "./DomainTagInput";
describe("DomainTagInput", () => { describe("DomainTagInput", () => {
it("shows a clear duplicate tenant warning and adds the domain after confirmation", async () => { it("shows a clear duplicate tenant warning and adds the domain after confirmation", async () => {
const user = userEvent.setup();
const onChange = vi.fn(); const onChange = vi.fn();
const onConfirmedConflictsChange = vi.fn(); const onConfirmedConflictsChange = vi.fn();
@@ -34,10 +32,9 @@ describe("DomainTagInput", () => {
/>, />,
); );
await user.type( const input = screen.getByPlaceholderText("example.com");
screen.getByPlaceholderText("example.com"), fireEvent.change(input, { target: { value: "samaneng.com" } });
"samaneng.com ", fireEvent.keyDown(input, { key: " " });
);
expect( expect(
await screen.findByText( await screen.findByText(
@@ -45,7 +42,7 @@ describe("DomainTagInput", () => {
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "계속 진행" })); fireEvent.click(screen.getByRole("button", { name: "계속 진행" }));
expect(onChange).toHaveBeenCalledWith(["samaneng.com"]); expect(onChange).toHaveBeenCalledWith(["samaneng.com"]);
expect(onConfirmedConflictsChange).toHaveBeenCalledWith(["samaneng.com"]); expect(onConfirmedConflictsChange).toHaveBeenCalledWith(["samaneng.com"]);

View File

@@ -29,6 +29,7 @@ type DomainTagInputProps = {
confirmedConflicts?: string[]; confirmedConflicts?: string[];
onConfirmedConflictsChange?: (domains: string[]) => void; onConfirmedConflictsChange?: (domains: string[]) => void;
placeholder?: string; placeholder?: string;
disabled?: boolean;
}; };
export function DomainTagInput({ export function DomainTagInput({
@@ -40,6 +41,7 @@ export function DomainTagInput({
confirmedConflicts = [], confirmedConflicts = [],
onConfirmedConflictsChange, onConfirmedConflictsChange,
placeholder, placeholder,
disabled = false,
}: DomainTagInputProps) { }: DomainTagInputProps) {
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>( const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
@@ -107,14 +109,16 @@ export function DomainTagInput({
className="gap-1 rounded-md" className="gap-1 rounded-md"
> >
<span>{domain}</span> <span>{domain}</span>
<button {!disabled && (
type="button" <button
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60" type="button"
onClick={() => removeDomain(domain)} className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
aria-label={t("ui.common.remove", "삭제")} onClick={() => removeDomain(domain)}
> aria-label={t("ui.common.remove", "삭제")}
<X size={12} /> >
</button> <X size={12} />
</button>
)}
</Badge> </Badge>
))} ))}
<Input <Input
@@ -133,6 +137,7 @@ export function DomainTagInput({
tokenizeInput(); tokenizeInput();
} }
}} }}
disabled={disabled}
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0" 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} placeholder={value.length === 0 ? placeholder : undefined}
/> />

View File

@@ -46,8 +46,10 @@ describe("ParentTenantSelector picker", () => {
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ })); fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument(); expect(screen.getByRole("dialog")).toBeInTheDocument();
const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src"); const pickerSrc = screen
expect(pickerSrc).toContain("/login"); .getByTestId("parent-tenant-picker-frame")
.getAttribute("src");
expect(pickerSrc).toContain("http://localhost:5175/login");
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker"); expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
fireEvent( fireEvent(
@@ -71,6 +73,30 @@ describe("ParentTenantSelector picker", () => {
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1")); await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
}); });
it("scopes the org-chart picker to the requested tenant root", () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
orgChartTenantId="group-1"
orgChartPickerLabel="한맥가족에서 선택"
/>,
);
fireEvent.click(screen.getByRole("button", { name: "한맥가족에서 선택" }));
const pickerSrc = screen
.getByTestId("parent-tenant-picker-frame")
.getAttribute("src");
expect(decodeURIComponent(pickerSrc ?? "")).toContain("tenantId=group-1");
});
it("keeps the current tenant out of picker message selections", async () => { it("keeps the current tenant out of picker message selections", async () => {
const onChange = vi.fn(); const onChange = vi.fn();

View File

@@ -31,10 +31,12 @@ type ParentTenantSelectorProps = {
labelAction?: ReactNode; labelAction?: ReactNode;
contextLabel?: string; contextLabel?: string;
orgChartPickerLabel?: string; orgChartPickerLabel?: string;
orgChartTenantId?: string;
localPickerLabel?: string; localPickerLabel?: string;
localTenantFilter?: (tenant: TenantSummary) => boolean; localTenantFilter?: (tenant: TenantSummary) => boolean;
compact?: boolean; compact?: boolean;
controlTestId?: string; controlTestId?: string;
disabled?: boolean;
}; };
export function ParentTenantSelector({ export function ParentTenantSelector({
@@ -49,10 +51,12 @@ export function ParentTenantSelector({
labelAction, labelAction,
contextLabel, contextLabel,
orgChartPickerLabel, orgChartPickerLabel,
orgChartTenantId,
localPickerLabel, localPickerLabel,
localTenantFilter, localTenantFilter,
compact = false, compact = false,
controlTestId, controlTestId,
disabled = false,
}: ParentTenantSelectorProps) { }: ParentTenantSelectorProps) {
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [localPickerOpen, setLocalPickerOpen] = useState(false); const [localPickerOpen, setLocalPickerOpen] = useState(false);
@@ -66,6 +70,9 @@ export function ParentTenantSelector({
); );
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL, import.meta.env.ORGFRONT_URL,
{
tenantId: orgChartTenantId,
},
); );
useEffect(() => { useEffect(() => {
@@ -112,6 +119,7 @@ export function ParentTenantSelector({
variant="outline" variant="outline"
size="sm" size="sm"
className={compact ? "h-8 shrink-0 px-2" : undefined} className={compact ? "h-8 shrink-0 px-2" : undefined}
disabled={disabled}
> >
<Building2 className="h-4 w-4" /> <Building2 className="h-4 w-4" />
{orgChartPickerLabel ?? {orgChartPickerLabel ??
@@ -135,13 +143,19 @@ export function ParentTenantSelector({
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")} title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl} src={pickerUrl}
className="h-[600px] w-full rounded-md border" className="h-[600px] w-full rounded-md border"
data-testid="parent-tenant-picker-frame"
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{localPickerLabel && ( {localPickerLabel && (
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}> <Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button type="button" variant="outline" size="sm"> <Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
>
<Building2 className="h-4 w-4" /> <Building2 className="h-4 w-4" />
{localPickerLabel} {localPickerLabel}
</Button> </Button>
@@ -161,6 +175,8 @@ export function ParentTenantSelector({
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<input <input
id="parent-tenant-local-search"
name="parent-tenant-local-search"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" 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} value={localSearch}
onChange={(event) => setLocalSearch(event.target.value)} onChange={(event) => setLocalSearch(event.target.value)}
@@ -226,6 +242,7 @@ export function ParentTenantSelector({
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"} className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
onClick={() => onChange("")} onClick={() => onChange("")}
aria-label={noneLabel} aria-label={noneLabel}
disabled={disabled}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>

View File

@@ -0,0 +1,29 @@
import type React from "react";
import {
type TenantPermissionKey,
useTenantPermission,
} from "../hooks/useTenantPermission";
interface TenantPermissionGuardProps {
tenantId: string;
relation: TenantPermissionKey;
fallback?: React.ReactNode;
children: React.ReactNode;
}
export function TenantPermissionGuard({
tenantId,
relation,
fallback = null,
children,
}: TenantPermissionGuardProps) {
const { hasPermission, isLoading } = useTenantPermission(tenantId);
if (isLoading) return null;
if (!hasPermission(relation)) {
return <>{fallback}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,184 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, renderHook, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { describe, expect, it, vi } from "vitest";
import {
fetchMe,
fetchTenant,
type TenantSummary,
type UserProfileResponse,
} from "../../../lib/adminApi";
import { TenantPermissionGuard } from "../components/TenantPermissionGuard";
import { useTenantPermission } from "./useTenantPermission";
vi.mock("../../../lib/adminApi", () => ({
fetchMe: vi.fn(),
fetchTenant: vi.fn(),
}));
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
function mockProfile(
overrides: Partial<UserProfileResponse>,
): UserProfileResponse {
return {
id: "user-id",
email: "user@example.com",
name: "Test User",
phone: "",
role: "user",
department: "",
affiliationType: "general",
...overrides,
};
}
function mockTenant(overrides: Partial<TenantSummary>): TenantSummary {
return {
id: "tenant-id",
type: "COMPANY",
name: "Test Tenant",
slug: "test-tenant",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
};
}
describe("useTenantPermission", () => {
it("returns true for all permissions if user is super_admin", async () => {
vi.mocked(fetchMe).mockResolvedValue(
mockProfile({
id: "user-super",
role: "super_admin",
}),
);
vi.mocked(fetchTenant).mockResolvedValue(
mockTenant({
id: "tenant-1",
name: "Super Tenant",
userPermissions: { view: false, manage: false, manage_admins: false },
}),
);
const { result } = renderHook(() => useTenantPermission("tenant-1"), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasPermission("view")).toBe(true);
expect(result.current.hasPermission("manage")).toBe(true);
expect(result.current.hasPermission("manage_admins")).toBe(true);
});
it("returns permissions mapped from userPermissions for normal admins/users", async () => {
vi.mocked(fetchMe).mockResolvedValue(
mockProfile({
id: "user-admin",
role: "tenant_admin",
}),
);
vi.mocked(fetchTenant).mockResolvedValue(
mockTenant({
id: "tenant-2",
name: "Tenant Admin Corp",
userPermissions: { view: true, manage: true, manage_admins: false },
}),
);
const { result } = renderHook(() => useTenantPermission("tenant-2"), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasPermission("view")).toBe(true);
expect(result.current.hasPermission("manage")).toBe(true);
expect(result.current.hasPermission("manage_admins")).toBe(false);
});
});
describe("TenantPermissionGuard", () => {
it("renders children when user has permission", async () => {
vi.mocked(fetchMe).mockResolvedValue(
mockProfile({
id: "user-admin",
role: "tenant_admin",
}),
);
vi.mocked(fetchTenant).mockResolvedValue(
mockTenant({
id: "tenant-3",
userPermissions: { view: true, manage: true, manage_admins: false },
}),
);
render(
<TenantPermissionGuard
tenantId="tenant-3"
relation="manage"
fallback={<div>Access Denied</div>}
>
<div>Access Granted</div>
</TenantPermissionGuard>,
{ wrapper: createWrapper() },
);
await waitFor(() => {
expect(screen.getByText("Access Granted")).toBeInTheDocument();
});
expect(screen.queryByText("Access Denied")).not.toBeInTheDocument();
});
it("renders fallback when user lacks permission", async () => {
vi.mocked(fetchMe).mockResolvedValue(
mockProfile({
id: "user-admin",
role: "tenant_admin",
}),
);
vi.mocked(fetchTenant).mockResolvedValue(
mockTenant({
id: "tenant-4",
userPermissions: { view: true, manage: false, manage_admins: false },
}),
);
render(
<TenantPermissionGuard
tenantId="tenant-4"
relation="manage"
fallback={<div>Access Denied</div>}
>
<div>Access Granted</div>
</TenantPermissionGuard>,
{ wrapper: createWrapper() },
);
await waitFor(() => {
expect(screen.getByText("Access Denied")).toBeInTheDocument();
});
expect(screen.queryByText("Access Granted")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,41 @@
import { useQuery } from "@tanstack/react-query";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { normalizeAdminRole } from "../../../lib/roles";
export type TenantPermissionKey =
| "view"
| "manage"
| "manage_admins"
| "view_profile"
| "manage_profile"
| "view_permissions"
| "manage_permissions"
| "view_organization"
| "manage_organization"
| "view_schema"
| "manage_schema"
| "view_worksmobile"
| "manage_worksmobile";
export function useTenantPermission(tenantId: string) {
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const { data: tenant } = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
enabled: !!tenantId,
});
const hasPermission = (requiredRelation: TenantPermissionKey): boolean => {
// Super Admin always has full bypass access
if (normalizeAdminRole(profile?.role) === "super_admin") {
return true;
}
return !!tenant?.userPermissions?.[requiredRelation];
};
return { hasPermission, isLoading: !tenant };
}

View File

@@ -49,6 +49,7 @@ import {
type TenantAdmin, type TenantAdmin,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
type DialogMode = "owner" | "admin"; type DialogMode = "owner" | "admin";
@@ -69,6 +70,10 @@ export function TenantAdminsAndOwnersTab() {
const _currentUserId = auth.user?.profile.sub; const _currentUserId = auth.user?.profile.sub;
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>(); const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? ""; const tenantId = tenantIdParam ?? "";
const { hasPermission } = useTenantPermission(tenantId);
const isWritable =
hasPermission("manage_permissions") || hasPermission("manage_admins");
const canView = hasPermission("view_permissions") || hasPermission("view");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null); const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
@@ -338,6 +343,16 @@ export function TenantAdminsAndOwnersTab() {
if (!tenantId) return null; if (!tenantId) return null;
if (!canView) {
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>
</div>
);
}
const serverOwners = ownersQuery.data || []; const serverOwners = ownersQuery.data || [];
const serverAdmins = adminsQuery.data || []; const serverAdmins = adminsQuery.data || [];
const currentOwners = mergePendingMembers(serverOwners, pendingOwners); const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
@@ -362,7 +377,7 @@ export function TenantAdminsAndOwnersTab() {
); );
return ( return (
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]"> <div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0"> <div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
{/* Owners Card */} {/* Owners Card */}
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]"> <Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
@@ -382,6 +397,7 @@ export function TenantAdminsAndOwnersTab() {
<Button <Button
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("owner")} onClick={() => setDialogMode("owner")}
disabled={!isWritable}
> >
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.owners.add_button", "소유자 추가")} {t("ui.admin.tenants.owners.add_button", "소유자 추가")}
@@ -471,6 +487,7 @@ export function TenantAdminsAndOwnersTab() {
<Button <Button
className="bg-primary text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("admin")} onClick={() => setDialogMode("admin")}
disabled={!isWritable}
> >
<UserPlus className="mr-2 h-4 w-4" /> <UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")} {t("ui.admin.tenants.admins.add_button", "관리자 추가")}

View File

@@ -61,6 +61,13 @@ function TenantCreatePage() {
}); });
const tenants = parentQuery.data?.items ?? []; const tenants = parentQuery.data?.items ?? [];
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId); const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
const hanmacFamilyTenantId = useMemo(
() =>
tenants.find(
(tenant) => tenant.slug.trim().toLowerCase() === "hanmac-family",
)?.id ?? "",
[tenants],
);
const canConfigureHanmacOrg = useMemo(() => { const canConfigureHanmacOrg = useMemo(() => {
if (!selectedParentTenant) return false; if (!selectedParentTenant) return false;
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") { if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
@@ -206,6 +213,7 @@ function TenantCreatePage() {
"ui.admin.tenants.create.form.pick_hanmac_parent", "ui.admin.tenants.create.form.pick_hanmac_parent",
"한맥가족에서 선택", "한맥가족에서 선택",
)} )}
orgChartTenantId={hanmacFamilyTenantId}
localPickerLabel={t( localPickerLabel={t(
"ui.admin.tenants.create.form.pick_other_parent", "ui.admin.tenants.create.form.pick_other_parent",
"다른 테넌트 선택", "다른 테넌트 선택",

View File

@@ -2,9 +2,9 @@ import { useQuery } from "@tanstack/react-query";
import { Copy } from "lucide-react"; import { Copy } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom"; import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi"; import { fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles"; import { useTenantPermission } from "../hooks/useTenantPermission";
function TenantDetailPage() { function TenantDetailPage() {
const params = useParams<{ tenantId: string }>(); const params = useParams<{ tenantId: string }>();
@@ -17,13 +17,7 @@ function TenantDetailPage() {
enabled: tenantId.length > 0, enabled: tenantId.length > 0,
}); });
const { data: profile } = useQuery({ const { hasPermission } = useTenantPermission(tenantId);
queryKey: ["me"],
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canAccessSchema = profileRole === "super_admin";
const isPermissionsTab = location.pathname.includes("/permissions"); const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization"); const isOrganizationTab = location.pathname.includes("/organization");
@@ -110,7 +104,7 @@ function TenantDetailPage() {
> >
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")} {t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</Link> </Link>
{canAccessSchema && ( {hasPermission("view") && (
<Link <Link
to={`/tenants/${tenantId}/schema`} to={`/tenants/${tenantId}/schema`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${ className={`px-6 py-3 text-sm font-medium transition-colors relative ${
@@ -122,10 +116,22 @@ function TenantDetailPage() {
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")} {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link> </Link>
)} )}
{hasPermission("view") && (
<Link
to={`/tenants/${tenantId}/relations`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
location.pathname.includes("/relations")
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_relations", "세부 권한")}
</Link>
)}
</div> </div>
{/* Outlet for nested routes */} {/* Outlet for nested routes */}
<div className="animate-in fade-in duration-500"> <div>
<Outlet /> <Outlet />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,164 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../../test/i18nMock";
import { TenantFineGrainedPermissionsPage } from "./TenantFineGrainedPermissionsPage";
const fetchUsersMock = vi.hoisted(() => vi.fn());
const bulkUpdateUsersMock = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/i18n", () => createI18nMock());
vi.mock("../../../lib/adminApi", () => ({
addSystemRelation: vi.fn(async () => undefined),
addTenantRelation: vi.fn(async () => undefined),
bulkUpdateUsers: bulkUpdateUsersMock,
fetchAllTenants: vi.fn(async () => ({ items: [], total: 0 })),
fetchMe: vi.fn(async () => ({
id: "current-admin",
name: "Current Admin",
email: "current@example.com",
role: "super_admin",
})),
fetchSystemRelations: vi.fn(async () => []),
fetchTenantRelations: vi.fn(async () => []),
fetchUsers: fetchUsersMock,
removeSystemRelation: vi.fn(async () => undefined),
removeTenantRelation: vi.fn(async () => undefined),
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/permissions-direct"]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantFineGrainedPermissionsPage Super Admin role tab", () => {
beforeEach(() => {
vi.clearAllMocks();
bulkUpdateUsersMock.mockResolvedValue({ results: [] });
fetchUsersMock.mockResolvedValue({
items: [
{
id: "current-admin",
name: "Current Admin",
email: "current@example.com",
role: "super_admin",
status: "active",
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
},
{
id: "bootstrap-admin",
name: "Bootstrap Admin",
email: "env-admin@example.com",
role: "super_admin",
status: "active",
metadata: { bootstrapSuperAdmin: true },
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
},
{
id: "delegated-admin",
name: "Delegated Admin",
email: "delegated@example.com",
role: "super_admin",
status: "active",
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
},
{
id: "regular-user",
name: "Regular User",
email: "regular@example.com",
phone: "010-0000-0001",
role: "user",
status: "active",
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
},
],
total: 4,
limit: 1000,
offset: 0,
});
});
it("shows revocable super admin users even when they have no direct system relations", async () => {
renderWithProviders(
<Routes>
<Route
path="/permissions-direct"
element={<TenantFineGrainedPermissionsPage />}
/>
</Routes>,
);
fireEvent.click(
await screen.findByRole("tab", { name: "Super Admin 역할" }),
);
expect(await screen.findByText("Delegated Admin")).toBeInTheDocument();
expect(screen.getByText("delegated@example.com")).toBeInTheDocument();
expect(screen.queryByText("Current Admin")).not.toBeInTheDocument();
expect(screen.queryByText("Bootstrap Admin")).not.toBeInTheDocument();
expect(screen.queryByText("Regular User")).not.toBeInTheDocument();
fireEvent.click(
screen.getByTestId("super-admin-role-user-delegated-admin"),
);
fireEvent.click(screen.getByRole("button", { name: "Super Admin 회수" }));
await waitFor(() =>
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
userIds: ["delegated-admin"],
role: "user",
}),
);
});
it("searches regular users and grants the Super Admin role from the target queue", async () => {
renderWithProviders(
<Routes>
<Route
path="/permissions-direct"
element={<TenantFineGrainedPermissionsPage />}
/>
</Routes>,
);
fireEvent.click(
await screen.findByRole("tab", { name: "Super Admin 역할" }),
);
fireEvent.change(
await screen.findByPlaceholderText("UUID, 이름, 이메일, 전화번호 검색"),
{ target: { value: "010-0000-0001" } },
);
fireEvent.click(screen.getByRole("button", { name: "부여 대상 추가" }));
expect(
screen.getByTestId("super-admin-grant-target-regular-user"),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Super Admin 부여" }));
await waitFor(() =>
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
userIds: ["regular-user"],
role: "super_admin",
}),
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,782 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react";
import { useEffect, useState } from "react";
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,
} 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 {
addTenantRelation,
fetchMe,
fetchTenantRelations,
fetchUsers,
removeTenantRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
interface TenantFineGrainedPermissionsTabProps {
tenantIdProp?: string;
}
export function TenantFineGrainedPermissionsTab({
tenantIdProp,
}: TenantFineGrainedPermissionsTabProps = {}) {
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdProp || tenantIdParam || "";
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const isWritable = profile?.role === "super_admin";
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false);
// 🌟 테넌트 탭별 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
const [localTenantPermissions, setLocalTenantPermissions] = useState<
Record<string, Record<string, "none" | "read" | "write">>
>({});
const relationsQuery = useQuery({
queryKey: ["tenant-relations", tenantId],
queryFn: () => fetchTenantRelations(tenantId),
enabled: !!tenantId,
});
const _relationsData = relationsQuery.data ?? [];
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
useEffect(() => {
if (relationsQuery.data) {
const initialMap: Record<
string,
Record<string, "none" | "read" | "write">
> = {};
for (const user of relationsQuery.data) {
initialMap[user.userId] = {};
const tabs = [
"profile",
"permissions",
"organization",
"schema",
"worksmobile",
];
for (const tab of tabs) {
const isWrite = user.relations.includes(`${tab}_managers`);
const isRead = user.relations.includes(`${tab}_viewers`);
initialMap[user.userId][tab] = isWrite
? "write"
: isRead
? "read"
: "none";
}
}
setLocalTenantPermissions(initialMap);
}
}, [relationsQuery.data]);
const relations = relationsQuery.data ?? [];
const invalidateAllQueries = () => {
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-relations", tenantId],
});
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["me"] });
}, 500);
};
const addRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
addTenantRelation(tenantId, payload.userId, payload.relation),
onMutate: async (newRelation) => {
await queryClient.cancelQueries({
queryKey: ["tenant-relations", tenantId],
});
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"tenant-relations",
tenantId,
]);
queryClient.setQueryData<TenantRelation[]>(
["tenant-relations", tenantId],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(
["tenant-relations", tenantId],
context.previousRelations,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
},
});
const removeRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
removeTenantRelation(tenantId, payload.userId, payload.relation),
onMutate: async (targetRelation) => {
await queryClient.cancelQueries({
queryKey: ["tenant-relations", tenantId],
});
const previousRelations = queryClient.getQueryData<TenantRelation[]>([
"tenant-relations",
tenantId,
]);
queryClient.setQueryData<TenantRelation[]>(
["tenant-relations", tenantId],
(old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter(
(r) => r !== targetRelation.relation,
),
};
}
return user;
});
},
);
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(
["tenant-relations", tenantId],
context.previousRelations,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
onSuccess: () => {
// Quiet mutate
},
});
const handleRelationChange = async (
userId: string,
tab: "profile" | "permissions" | "organization" | "schema" | "worksmobile",
currentVal: "none" | "read" | "write",
newVal: "none" | "read" | "write",
) => {
const readRel = `${tab}_viewers`;
const writeRel = `${tab}_managers`;
if (currentVal === newVal) return;
try {
if (currentVal === "read") {
await removeRelationMutation.mutateAsync({ userId, relation: readRel });
} else if (currentVal === "write") {
await removeRelationMutation.mutateAsync({
userId,
relation: writeRel,
});
}
if (newVal === "read") {
await addRelationMutation.mutateAsync({ userId, relation: readRel });
} else if (newVal === "write") {
await addRelationMutation.mutateAsync({ userId, relation: writeRel });
}
invalidateAllQueries();
// 🌟 Trigger a single consolidated success toast at the very end
toast.success(
t(
"msg.admin.tenants.relations.update_success",
"세부 권한이 성공적으로 변경되었습니다.",
),
);
} catch {
// Individual mutations handle error toast via onError
}
};
const handleRemoveAllRelations = async (
userId: string,
userRelations: string[],
) => {
if (
!window.confirm(
t(
"msg.admin.tenants.relations.remove_all_confirm",
"이 사용자의 모든 세부 권한을 삭제하시겠습니까?",
),
)
) {
return;
}
for (const rel of userRelations) {
await removeRelationMutation.mutateAsync({ userId, relation: rel });
}
invalidateAllQueries();
};
const usersQuery = useQuery({
queryKey: ["admin-users-search", searchTerm],
queryFn: () => fetchUsers(20, 0, searchTerm),
enabled: isDialogOpen && searchTerm.length >= 2,
});
const handleAddUser = (userId: string) => {
addRelationMutation.mutate(
{ userId, relation: "profile_viewers" },
{
onSettled: () => {
invalidateAllQueries();
},
},
);
setIsDialogOpen(false);
setSearchTerm("");
};
if (!tenantId) return null;
const searchResults = usersQuery.data?.items || [];
return (
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
<Card className="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.relations.title",
"세부 권한 설정 (Fine-grained Permissions)",
)}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.relations.subtitle",
"사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setIsDialogOpen(true)}
disabled={!isWritable}
>
<UserPlus className="mr-2 h-4 w-4" />
{t(
"ui.admin.tenants.relations.add_button",
"세부 권한 사용자 추가",
)}
</Button>
</CardHeader>
<CardContent className="pt-0">
{!isWritable && (
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-950/20 text-amber-800 dark:text-amber-200 border border-amber-200 dark:border-amber-800/30 rounded-lg text-sm font-medium">
{t(
"msg.admin.tenants.relations.super_admin_only_desc",
"이 화면의 권한 설정은 시스템 최고 관리자(super_admin)만 수정할 수 있습니다.",
)}
</div>
)}
<div className="rounded-md border border-border overflow-hidden">
<Table>
<TableHeader className="bg-secondary/40">
<TableRow>
<TableHead className="font-bold">
{t("ui.common.name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_permissions", "권한 관리")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</TableHead>
<TableHead className="font-bold">
{t(
"ui.admin.tenants.detail.tab_worksmobile",
"네이버웍스 연동",
)}
</TableHead>
<TableHead className="font-bold text-center w-20">
{t("ui.common.action", "작업")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{relations.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center py-12 text-muted-foreground"
>
{t(
"msg.admin.tenants.relations.empty",
"세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요.",
)}
</TableCell>
</TableRow>
) : (
relations.map((user) => {
const profileVal = user.relations.includes(
"profile_managers",
)
? "write"
: user.relations.includes("profile_viewers")
? "read"
: "none";
const permissionsVal = user.relations.includes(
"permissions_managers",
)
? "write"
: user.relations.includes("permissions_viewers")
? "read"
: "none";
const organizationVal = user.relations.includes(
"organization_managers",
)
? "write"
: user.relations.includes("organization_viewers")
? "read"
: "none";
const schemaVal = user.relations.includes("schema_managers")
? "write"
: user.relations.includes("schema_viewers")
? "read"
: "none";
const worksmobileVal = user.relations.includes(
"worksmobile_managers",
)
? "write"
: user.relations.includes("worksmobile_viewers")
? "read"
: "none";
const curProfileVal =
localTenantPermissions[user.userId]?.profile ??
profileVal;
const curPermissionsVal =
localTenantPermissions[user.userId]?.permissions ??
permissionsVal;
const curOrganizationVal =
localTenantPermissions[user.userId]?.organization ??
organizationVal;
const curSchemaVal =
localTenantPermissions[user.userId]?.schema ?? schemaVal;
const curWorksmobileVal =
localTenantPermissions[user.userId]?.worksmobile ??
worksmobileVal;
return (
<TableRow
key={user.userId}
className="hover:bg-muted/10 transition-colors"
>
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="font-semibold text-foreground">
{user.name}
</span>
<span className="text-xs text-muted-foreground italic">
{user.email}
</span>
</div>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curProfileVal}
disabled={!isWritable}
name={`tenant-fine-grained-profile-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
profile: nextVal,
},
}));
handleRelationChange(
user.userId,
"profile",
profileVal,
nextVal,
);
}}
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curPermissionsVal}
disabled={!isWritable}
name={`tenant-fine-grained-permissions-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
permissions: nextVal,
},
}));
handleRelationChange(
user.userId,
"permissions",
permissionsVal,
nextVal,
);
}}
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curOrganizationVal}
disabled={!isWritable}
name={`tenant-fine-grained-organization-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
organization: nextVal,
},
}));
handleRelationChange(
user.userId,
"organization",
organizationVal,
nextVal,
);
}}
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curSchemaVal}
disabled={!isWritable}
name={`tenant-fine-grained-schema-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
schema: nextVal,
},
}));
handleRelationChange(
user.userId,
"schema",
schemaVal,
nextVal,
);
}}
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curWorksmobileVal}
disabled={!isWritable}
name={`tenant-fine-grained-worksmobile-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as
| "none"
| "read"
| "write";
setLocalTenantPermissions((prev) => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
worksmobile: nextVal,
},
}));
handleRelationChange(
user.userId,
"worksmobile",
worksmobileVal,
nextVal,
);
}}
>
<option value="none">
{t("ui.common.none", "권한 없음")}
</option>
<option value="read">
{t("ui.common.read", "조회 가능 (Read)")}
</option>
<option value="write">
{t("ui.common.write", "수정 가능 (Write)")}
</option>
</select>
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
disabled={!isWritable}
onClick={() =>
handleRemoveAllRelations(
user.userId,
user.relations,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Common Dialog for adding users */}
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
setIsDialogOpen(false);
setSearchTerm("");
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{t(
"ui.admin.tenants.relations.dialog_title",
"세부 권한 관리 유저 추가",
)}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.tenants.admins.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
)}
</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 isAlreadyInMatrix = relations.some(
(r) => r.userId === user.id,
);
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={isAlreadyInMatrix ? "ghost" : "outline"}
disabled={
isAlreadyInMatrix || addRelationMutation.isPending
}
onClick={() => handleAddUser(user.id)}
>
{isAlreadyInMatrix ? (
<Badge variant="secondary" className="font-normal">
{t(
"ui.admin.tenants.relations.already_added",
"이미 추가됨",
)}
</Badge>
) : (
<>
<Plus className="h-3 w-3 mr-1" />{" "}
{t("ui.common.add", "추가")}
</>
)}
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -62,6 +62,7 @@ import {
removeGroupMember, removeGroupMember,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
type UserGroupNode = GroupSummary & { type UserGroupNode = GroupSummary & {
children: UserGroupNode[]; children: UserGroupNode[];
@@ -126,6 +127,7 @@ interface UserGroupTreeNodeProps {
AxiosError<{ error?: string }>, AxiosError<{ error?: string }>,
{ groupId: string; userId: string } { groupId: string; userId: string }
>; >;
isWritable?: boolean;
} }
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
@@ -137,6 +139,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
onAddSubGroup, onAddSubGroup,
addMemberMutation, addMemberMutation,
removeMemberMutation, removeMemberMutation,
isWritable = true,
}) => { }) => {
const [isExpanded, setIsExpanded] = useState(true); const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
@@ -200,6 +203,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
e.stopPropagation(); e.stopPropagation();
onAddSubGroup(node.id); onAddSubGroup(node.id);
}} }}
disabled={!isWritable}
> >
<Plus size={14} /> <Plus size={14} />
</Button> </Button>
@@ -210,6 +214,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
e.stopPropagation(); e.stopPropagation();
onDelete(node.id); onDelete(node.id);
}} }}
disabled={!isWritable}
> >
<Trash2 size={14} className="text-destructive" /> <Trash2 size={14} className="text-destructive" />
</Button> </Button>
@@ -229,6 +234,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
onAddSubGroup={onAddSubGroup} onAddSubGroup={onAddSubGroup}
addMemberMutation={addMemberMutation} addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation} removeMemberMutation={removeMemberMutation}
isWritable={isWritable}
/> />
))} ))}
</> </>
@@ -240,6 +246,11 @@ function TenantGroupsPage() {
const tenantId = params.tenantId ?? ""; const tenantId = params.tenantId ?? "";
const _queryClient = useQueryClient(); const _queryClient = useQueryClient();
const { hasPermission } = useTenantPermission(tenantId);
const isWritable =
hasPermission("manage_organization") || hasPermission("manage");
const canView = hasPermission("view_organization") || hasPermission("view");
const [newGroupName, setNewGroupName] = useState(""); const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState(""); const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [newGroupUnitType, setNewGroupUnitType] = useState("Team"); const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
@@ -387,6 +398,16 @@ function TenantGroupsPage() {
}, },
}); });
if (!canView) {
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>
</div>
);
}
const groupTree = groupsQuery.data const groupTree = groupsQuery.data
? buildGroupTree(groupsQuery.data, tenantId) ? buildGroupTree(groupsQuery.data, tenantId)
: []; : [];
@@ -423,6 +444,7 @@ function TenantGroupsPage() {
id="name" id="name"
value={newGroupName} value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)} onChange={(e) => setNewGroupName(e.target.value)}
disabled={!isWritable}
placeholder={t( placeholder={t(
"ui.admin.groups.form.name_placeholder", "ui.admin.groups.form.name_placeholder",
"예: 개발팀, 인사팀", "예: 개발팀, 인사팀",
@@ -437,6 +459,7 @@ function TenantGroupsPage() {
id="unitType" id="unitType"
value={newGroupUnitType} value={newGroupUnitType}
onChange={(e) => setNewGroupUnitType(e.target.value)} onChange={(e) => setNewGroupUnitType(e.target.value)}
disabled={!isWritable}
placeholder={t( placeholder={t(
"ui.admin.groups.form.unit_level_placeholder", "ui.admin.groups.form.unit_level_placeholder",
"예: 본부, 팀, 셀", "예: 본부, 팀, 셀",
@@ -449,9 +472,10 @@ function TenantGroupsPage() {
</Label> </Label>
<select <select
id="parentId" id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={newGroupParentId || ""} value={newGroupParentId || ""}
onChange={(e) => setNewGroupParentId(e.target.value || null)} onChange={(e) => setNewGroupParentId(e.target.value || null)}
disabled={!isWritable}
> >
<option value="">{t("ui.common.none", "없음")}</option> <option value="">{t("ui.common.none", "없음")}</option>
{groupsQuery.data?.map((group) => ( {groupsQuery.data?.map((group) => (
@@ -469,6 +493,7 @@ function TenantGroupsPage() {
id="desc" id="desc"
value={newGroupDesc} value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)} onChange={(e) => setNewGroupNameDesc(e.target.value)}
disabled={!isWritable}
placeholder={t( placeholder={t(
"ui.admin.groups.form.desc_placeholder", "ui.admin.groups.form.desc_placeholder",
"그룹 용도 설명", "그룹 용도 설명",
@@ -478,7 +503,9 @@ function TenantGroupsPage() {
<Button <Button
className="w-full" className="w-full"
onClick={() => createMutation.mutate()} onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending} disabled={
!newGroupName || createMutation.isPending || !isWritable
}
> >
{t("ui.admin.groups.form.submit", "생성하기")} {t("ui.admin.groups.form.submit", "생성하기")}
</Button> </Button>
@@ -569,6 +596,7 @@ function TenantGroupsPage() {
onAddSubGroup={handleAddSubGroup} onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation} addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation} removeMemberMutation={removeMemberMutation}
isWritable={isWritable}
/> />
))} ))}
</TableBody> </TableBody>

View File

@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi"; import type { TenantSummary } from "../../../lib/adminApi";
import { import {
filterTenantsByScope, filterTenantsByScope,
filterTenantViewRowsBySearch,
getTenantSearchMatchIds,
getTenantViewRows, getTenantViewRows,
resolveTenantSelectionIds, resolveTenantSelectionIds,
tenantMatchesListSearch, tenantMatchesListSearch,
@@ -69,6 +71,7 @@ describe("TenantListPage tenant list helpers", () => {
expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true); expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true); expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true); expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "삼안")).toBe(false);
}); });
it("can return tree rows or same-level table rows", () => { it("can return tree rows or same-level table rows", () => {
@@ -79,4 +82,33 @@ describe("TenantListPage tenant list helpers", () => {
[0, 0, 0, 0], [0, 0, 0, 0],
); );
}); });
it("marks only direct search matches when tree search includes ancestors", () => {
const treeRows = getTenantViewRows(
tenants.filter((item) => item.id !== "company-2"),
"tree",
"",
true,
);
expect(treeRows.map((row) => row.id)).toEqual([
"company-1",
"dept-1",
"team-1",
]);
expect(getTenantSearchMatchIds(treeRows, "platform")).toEqual(["team-1"]);
});
it("filters displayed tenant rows to direct matches only", () => {
const treeRows = getTenantViewRows(
tenants.filter((item) => item.id !== "company-2"),
"tree",
"",
true,
);
expect(
filterTenantViewRowsBySearch(treeRows, "team-1").map((row) => row.id),
).toEqual(["team-1"]);
});
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../../test/i18nMock";
import { TenantProfilePage } from "./TenantProfilePage";
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
const fetchTenantMock = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/i18n", () => createI18nMock());
vi.mock("../../../lib/adminApi", () => ({
approveTenant: vi.fn(),
deleteTenant: vi.fn(),
fetchAllTenants: fetchAllTenantsMock,
fetchMe: vi.fn(async () => ({
id: "admin-1",
role: "super_admin",
})),
fetchTenant: fetchTenantMock,
updateTenant: vi.fn(),
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/tenant-leaf"]}>
{ui}
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantProfilePage initial profile loading", () => {
beforeEach(() => {
vi.clearAllMocks();
fetchTenantMock.mockResolvedValue({
id: "tenant-leaf",
type: "USER_GROUP",
parentId: "tenant-company",
name: "기술기획",
slug: "tech-planning",
description: "",
status: "active",
domains: [],
memberCount: 0,
config: {
orgUnitType: "팀",
visibility: "internal",
worksmobileExcluded: true,
},
createdAt: "2026-06-17T00:00:00Z",
updatedAt: "2026-06-17T00:00:00Z",
});
});
it("renders tenant config fields from the tenant response before the full tenant list resolves", async () => {
fetchAllTenantsMock.mockReturnValue(new Promise(() => undefined));
renderWithProviders(
<Routes>
<Route path="/tenants/:tenantId" element={<TenantProfilePage />} />
</Routes>,
);
expect(await screen.findByDisplayValue("기술기획")).toBeInTheDocument();
expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀");
expect(screen.getByLabelText("공개 범위")).toHaveValue("internal");
expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded");
expect(fetchAllTenantsMock).not.toHaveBeenCalled();
});
});

View File

@@ -25,6 +25,7 @@ import {
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput"; import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector"; import { ParentTenantSelector } from "../components/ParentTenantSelector";
import { useTenantPermission } from "../hooks/useTenantPermission";
import { import {
formatDomainConflictMessage, formatDomainConflictMessage,
type ServerDomainConflict, type ServerDomainConflict,
@@ -52,10 +53,9 @@ export function TenantProfilePage() {
enabled: tenantId.length > 0, enabled: tenantId.length > 0,
}); });
const parentQuery = useQuery({ const { hasPermission } = useTenantPermission(tenantId);
queryKey: ["tenants", "list-all"], const isWritable = hasPermission("manage_profile") || hasPermission("manage");
queryFn: () => fetchAllTenants(), const canView = hasPermission("view_profile") || hasPermission("view");
});
const [name, setName] = useState(""); const [name, setName] = useState("");
const [type, setType] = useState("COMPANY"); const [type, setType] = useState("COMPANY");
@@ -89,6 +89,16 @@ export function TenantProfilePage() {
} }
}, [tenantQuery.data]); }, [tenantQuery.data]);
const hasPersistedOrgConfig =
tenantQuery.data?.slug?.toLowerCase() !== "hanmac-family" &&
(typeof tenantQuery.data?.config?.orgUnitType === "string" ||
typeof tenantQuery.data?.config?.visibility === "string" ||
typeof tenantQuery.data?.config?.worksmobileExcluded !== "undefined");
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
enabled: !!tenantQuery.data && !hasPersistedOrgConfig,
});
const allTenants = parentQuery.data?.items ?? []; const allTenants = parentQuery.data?.items ?? [];
const orgConfigCandidate = tenantQuery.data const orgConfigCandidate = tenantQuery.data
? { ? {
@@ -98,7 +108,8 @@ export function TenantProfilePage() {
} }
: undefined; : undefined;
const canEditOrgConfig = orgConfigCandidate const canEditOrgConfig = orgConfigCandidate
? shouldAllowHanmacOrgConfig(orgConfigCandidate, [ ? hasPersistedOrgConfig ||
shouldAllowHanmacOrgConfig(orgConfigCandidate, [
...allTenants, ...allTenants,
orgConfigCandidate, orgConfigCandidate,
]) ])
@@ -203,6 +214,16 @@ export function TenantProfilePage() {
); );
} }
if (!canView) {
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>
</div>
);
}
const handleDelete = () => { const handleDelete = () => {
if (isProtectedSeedTenant) { if (isProtectedSeedTenant) {
return; return;
@@ -261,13 +282,21 @@ export function TenantProfilePage() {
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "} {t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</Label> </Label>
<Input value={name} onChange={(e) => setName(e.target.value)} /> <Input
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!isWritable}
/>
</div> </div>
<div data-testid="tenant-slug-slot" className="space-y-1"> <div data-testid="tenant-slug-slot" className="space-y-1">
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")} {t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label> </Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} /> <Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
disabled={!isWritable}
/>
</div> </div>
<div data-testid="tenant-parent-picker-slot" className="min-w-0"> <div data-testid="tenant-parent-picker-slot" className="min-w-0">
<ParentTenantSelector <ParentTenantSelector
@@ -283,6 +312,7 @@ export function TenantProfilePage() {
excludeTenantId={tenantId} excludeTenantId={tenantId}
compact compact
controlTestId="tenant-parent-picker-control" controlTestId="tenant-parent-picker-control"
disabled={!isWritable}
/> />
</div> </div>
</div> </div>
@@ -300,6 +330,7 @@ export function TenantProfilePage() {
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" 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} value={type}
onChange={(e) => setType(e.target.value)} onChange={(e) => setType(e.target.value)}
disabled={!isWritable}
> >
<option value="COMPANY"> <option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")} {t("domain.tenant_type.company", "COMPANY (일반 기업)")}
@@ -336,17 +367,23 @@ export function TenantProfilePage() {
data-testid="tenant-org-unit-type-slot" data-testid="tenant-org-unit-type-slot"
className="space-y-1" className="space-y-1"
> >
<Label className="text-sm font-semibold"> <Label
htmlFor="tenant-org-unit-type"
className="text-sm font-semibold"
>
{t( {t(
"ui.admin.tenants.profile.org_unit_type", "ui.admin.tenants.profile.org_unit_type",
"조직 세부타입", "조직 세부타입",
)} )}
</Label> </Label>
<select <select
id="tenant-org-unit-type"
name="tenant-org-unit-type"
data-testid="tenant-org-unit-type-select" data-testid="tenant-org-unit-type-select"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" 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 disabled:cursor-not-allowed disabled:opacity-50"
value={orgUnitType} value={orgUnitType}
onChange={(event) => setOrgUnitType(event.target.value)} onChange={(event) => setOrgUnitType(event.target.value)}
disabled={!isWritable}
> >
<option value="">{t("ui.common.none", "없음")}</option> <option value="">{t("ui.common.none", "없음")}</option>
{orgUnitTypeOptions.map((option) => ( {orgUnitTypeOptions.map((option) => (
@@ -357,17 +394,23 @@ export function TenantProfilePage() {
</select> </select>
</div> </div>
<div data-testid="tenant-visibility-slot" className="space-y-1"> <div data-testid="tenant-visibility-slot" className="space-y-1">
<Label className="text-sm font-semibold"> <Label
htmlFor="tenant-visibility"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.profile.visibility", "공개 범위")} {t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label> </Label>
<select <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" id="tenant-visibility"
name="tenant-visibility"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={tenantVisibility} value={tenantVisibility}
onChange={(event) => onChange={(event) =>
setTenantVisibility( setTenantVisibility(
event.target.value as TenantVisibility, event.target.value as TenantVisibility,
) )
} }
disabled={!isWritable}
> >
{TENANT_VISIBILITY_OPTIONS.map((option) => ( {TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>
@@ -380,7 +423,10 @@ export function TenantProfilePage() {
data-testid="tenant-worksmobile-excluded-slot" data-testid="tenant-worksmobile-excluded-slot"
className="space-y-1" className="space-y-1"
> >
<Label className="text-sm font-semibold"> <Label
htmlFor="worksmobileExcluded"
className="text-sm font-semibold"
>
{t( {t(
"ui.admin.tenants.profile.worksmobile_sync", "ui.admin.tenants.profile.worksmobile_sync",
"WORKS 연동", "WORKS 연동",
@@ -388,11 +434,12 @@ export function TenantProfilePage() {
</Label> </Label>
<select <select
id="worksmobileExcluded" id="worksmobileExcluded"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" 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 disabled:cursor-not-allowed disabled:opacity-50"
value={worksmobileExcluded ? "excluded" : "enabled"} value={worksmobileExcluded ? "excluded" : "enabled"}
onChange={(event) => onChange={(event) =>
setWorksmobileExcluded(event.target.value === "excluded") setWorksmobileExcluded(event.target.value === "excluded")
} }
disabled={!isWritable}
> >
<option value="enabled"> <option value="enabled">
{t( {t(
@@ -420,6 +467,7 @@ export function TenantProfilePage() {
rows={2} rows={2}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
disabled={!isWritable}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -438,6 +486,7 @@ export function TenantProfilePage() {
confirmedConflicts={forceDomainConflicts} confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts} onConfirmedConflictsChange={setForceDomainConflicts}
placeholder="example.com, example.kr" placeholder="example.com, example.kr"
disabled={!isWritable}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -450,6 +499,7 @@ export function TenantProfilePage() {
size="sm" size="sm"
variant={status === "active" ? "default" : "outline"} variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")} onClick={() => setStatus("active")}
disabled={!isWritable}
> >
{t("ui.common.status.active", "활성")} {t("ui.common.status.active", "활성")}
</Button> </Button>
@@ -458,6 +508,7 @@ export function TenantProfilePage() {
size="sm" size="sm"
variant={status === "inactive" ? "default" : "outline"} variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")} onClick={() => setStatus("inactive")}
disabled={!isWritable}
> >
{t("ui.common.status.inactive", "비활성")} {t("ui.common.status.inactive", "비활성")}
</Button> </Button>
@@ -476,7 +527,9 @@ export function TenantProfilePage() {
<Button <Button
variant="outline" variant="outline"
onClick={handleDelete} onClick={handleDelete}
disabled={deleteMutation.isPending || isProtectedSeedTenant} disabled={
deleteMutation.isPending || isProtectedSeedTenant || !isWritable
}
title={ title={
isProtectedSeedTenant isProtectedSeedTenant
? t( ? t(
@@ -495,7 +548,7 @@ export function TenantProfilePage() {
variant="default" variant="default"
className="bg-green-600 hover:bg-green-700" className="bg-green-600 hover:bg-green-700"
onClick={handleApprove} onClick={handleApprove}
disabled={approveMutation.isPending} disabled={approveMutation.isPending || !isWritable}
> >
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")} {t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
</Button> </Button>
@@ -508,7 +561,8 @@ export function TenantProfilePage() {
disabled={ disabled={
updateMutation.isPending || updateMutation.isPending ||
tenantQuery.isLoading || tenantQuery.isLoading ||
name.trim() === "" name.trim() === "" ||
!isWritable
} }
> >
<Save size={16} /> <Save size={16} />

View File

@@ -14,9 +14,9 @@ import {
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label"; import { Label } from "../../../components/ui/label";
import { toast } from "../../../components/ui/use-toast"; import { toast } from "../../../components/ui/use-toast";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi"; import { fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles"; import { useTenantPermission } from "../hooks/useTenantPermission";
import { import {
createSchemaField, createSchemaField,
isSchemaFieldType, isSchemaFieldType,
@@ -28,13 +28,11 @@ export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>(); const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading } = useQuery({ const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(
queryKey: ["me"], tenantId ?? "",
queryFn: fetchMe, );
}); const canView = hasPermission("view_schema") || hasPermission("view");
const isWritable = hasPermission("manage_schema") || hasPermission("manage");
const profileRole = normalizeAdminRole(profile?.role);
const canAccess = profileRole === "super_admin";
const tenantQuery = useQuery({ const tenantQuery = useQuery({
queryKey: ["tenant", tenantId], queryKey: ["tenant", tenantId],
@@ -42,7 +40,7 @@ export function TenantSchemaPage() {
if (!tenantId) throw new Error("Tenant ID is required"); if (!tenantId) throw new Error("Tenant ID is required");
return fetchTenant(tenantId); return fetchTenant(tenantId);
}, },
enabled: !!tenantId && canAccess, enabled: !!tenantId && canView,
}); });
const [fields, setFields] = useState<SchemaField[]>([]); const [fields, setFields] = useState<SchemaField[]>([]);
@@ -85,7 +83,7 @@ export function TenantSchemaPage() {
}, },
}); });
if (isProfileLoading) { if (isPermissionLoading) {
return ( return (
<div className="p-8 text-center animate-pulse text-muted-foreground"> <div className="p-8 text-center animate-pulse text-muted-foreground">
{t("msg.common.loading", "로딩 중...")} {t("msg.common.loading", "로딩 중...")}
@@ -93,7 +91,7 @@ export function TenantSchemaPage() {
); );
} }
if (!canAccess) { if (!canView) {
return ( return (
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6"> <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"> <h3 className="text-xl font-bold text-destructive">
@@ -147,7 +145,7 @@ export function TenantSchemaPage() {
)} )}
</CardDescription> </CardDescription>
</div> </div>
<Button onClick={addField} size="sm"> <Button onClick={addField} size="sm" disabled={!isWritable}>
<Plus size={16} className="mr-2" /> <Plus size={16} className="mr-2" />
{t("ui.admin.tenants.schema.add_field", "필드 추가")} {t("ui.admin.tenants.schema.add_field", "필드 추가")}
</Button> </Button>
@@ -182,6 +180,7 @@ export function TenantSchemaPage() {
"예: employee_id", "예: employee_id",
)} )}
className="h-10" className="h-10"
disabled={!isWritable}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -198,6 +197,7 @@ export function TenantSchemaPage() {
"예: 사번", "예: 사번",
)} )}
className="h-10" className="h-10"
disabled={!isWritable}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -205,8 +205,11 @@ export function TenantSchemaPage() {
{t("ui.admin.tenants.schema.field.type", "유형")} {t("ui.admin.tenants.schema.field.type", "유형")}
</Label> </Label>
<select <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" id={`tenant-schema-field-type-${field.key || index}`}
name={`tenant-schema-field-type-${field.key || index}`}
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary disabled:opacity-60"
value={field.type} value={field.type}
disabled={!isWritable}
onChange={(e) => { onChange={(e) => {
const nextType = e.target.value; const nextType = e.target.value;
if (isSchemaFieldType(nextType)) { if (isSchemaFieldType(nextType)) {
@@ -266,12 +269,14 @@ export function TenantSchemaPage() {
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
name={`tenant-schema-field-required-${field.key || index}`}
type="checkbox" type="checkbox"
checked={field.required} checked={field.required}
disabled={!isWritable}
onChange={(e) => onChange={(e) =>
updateField(index, { required: e.target.checked }) updateField(index, { required: e.target.checked })
} }
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
/> />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{t("ui.admin.tenants.schema.field.required", "필수 입력")} {t("ui.admin.tenants.schema.field.required", "필수 입력")}
@@ -279,12 +284,14 @@ export function TenantSchemaPage() {
</label> </label>
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
name={`tenant-schema-field-admin-only-${field.key || index}`}
type="checkbox" type="checkbox"
checked={field.adminOnly} checked={field.adminOnly}
disabled={!isWritable}
onChange={(e) => onChange={(e) =>
updateField(index, { adminOnly: e.target.checked }) updateField(index, { adminOnly: e.target.checked })
} }
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
/> />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{t( {t(
@@ -295,8 +302,10 @@ export function TenantSchemaPage() {
</label> </label>
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
name={`tenant-schema-field-login-id-${field.key || index}`}
type="checkbox" type="checkbox"
checked={field.isLoginId || false} checked={field.isLoginId || false}
disabled={!isWritable}
onChange={(e) => onChange={(e) =>
updateField(index, { updateField(index, {
isLoginId: e.target.checked, isLoginId: e.target.checked,
@@ -304,7 +313,7 @@ export function TenantSchemaPage() {
type: e.target.checked ? "text" : field.type, type: e.target.checked ? "text" : field.type,
}) })
} }
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
/> />
<span className="text-sm font-medium text-blue-600"> <span className="text-sm font-medium text-blue-600">
{t( {t(
@@ -315,9 +324,10 @@ export function TenantSchemaPage() {
</label> </label>
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
name={`tenant-schema-field-indexed-${field.key || index}`}
type="checkbox" type="checkbox"
checked={field.indexed || field.isLoginId || false} checked={field.indexed || field.isLoginId || false}
disabled={field.isLoginId} disabled={field.isLoginId || !isWritable}
onChange={(e) => onChange={(e) =>
updateField(index, { indexed: e.target.checked }) updateField(index, { indexed: e.target.checked })
} }
@@ -333,12 +343,14 @@ export function TenantSchemaPage() {
{(field.type === "number" || field.type === "float") && ( {(field.type === "number" || field.type === "float") && (
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
name={`tenant-schema-field-unsigned-${field.key || index}`}
type="checkbox" type="checkbox"
checked={field.unsigned} checked={field.unsigned}
disabled={!isWritable}
onChange={(e) => onChange={(e) =>
updateField(index, { unsigned: e.target.checked }) updateField(index, { unsigned: e.target.checked })
} }
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
/> />
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{t( {t(
@@ -352,6 +364,7 @@ export function TenantSchemaPage() {
<div className="space-y-2"> <div className="space-y-2">
<Input <Input
value={field.validation} value={field.validation}
disabled={!isWritable}
onChange={(e) => onChange={(e) =>
updateField(index, { validation: e.target.value }) updateField(index, { validation: e.target.value })
} }
@@ -368,6 +381,7 @@ export function TenantSchemaPage() {
size="icon" size="icon"
className="text-destructive hover:bg-destructive/10 h-10 w-10" className="text-destructive hover:bg-destructive/10 h-10 w-10"
onClick={() => removeField(index)} onClick={() => removeField(index)}
disabled={!isWritable}
> >
<Trash2 size={18} /> <Trash2 size={18} />
</Button> </Button>
@@ -381,7 +395,9 @@ export function TenantSchemaPage() {
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
<Button <Button
onClick={() => updateMutation.mutate(fields)} onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading} disabled={
updateMutation.isPending || tenantQuery.isLoading || !isWritable
}
className="px-8 h-11" className="px-8 h-11"
> >
<Save size={18} className="mr-2" /> <Save size={18} className="mr-2" />

View File

@@ -0,0 +1,261 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../../test/i18nMock";
import TenantUsersPage from "./TenantUsersPage";
const exportUsersCSVMock = vi.hoisted(() => vi.fn());
const updateUserMock = vi.hoisted(() => vi.fn());
const bulkUpdateUsersMock = vi.hoisted(() => vi.fn());
const fetchUsersMock = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/i18n", () => createI18nMock());
vi.mock("../../../lib/adminApi", () => ({
fetchTenant: vi.fn(async () => ({
id: "tenant-team-id",
name: "기술기획팀",
slug: "tech-planning",
})),
fetchUsers: fetchUsersMock,
bulkUpdateUsers: bulkUpdateUsersMock,
exportUsersCSV: exportUsersCSVMock,
updateUser: updateUserMock,
}));
function renderTenantUsersPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const result = render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/tenant-team-id/users"]}>
<Routes>
<Route
path="/tenants/:tenantId/users"
element={<TenantUsersPage />}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
return { ...result, queryClient };
}
describe("TenantUsersPage export", () => {
beforeEach(() => {
exportUsersCSVMock.mockReset();
updateUserMock.mockReset();
bulkUpdateUsersMock.mockReset();
fetchUsersMock.mockReset();
fetchUsersMock.mockResolvedValue({
items: [
{
id: "user-1",
name: "Alice",
email: "alice@example.com",
role: "user",
status: "active",
},
],
total: 1,
});
exportUsersCSVMock.mockResolvedValue({
blob: new Blob(["email,name\nalice@example.com,Alice\n"], {
type: "text/csv",
}),
filename: "users_export_20260609.csv",
});
updateUserMock.mockResolvedValue({});
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
"blob:tenant-users-export",
);
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
bulkUpdateUsersMock.mockResolvedValue({ results: [] });
});
it("exports only the currently opened tenant users by tenant slug", async () => {
renderTenantUsersPage();
await screen.findByText("Alice");
fireEvent.click(screen.getByTestId("tenant-users-export-menu-item"));
await waitFor(() => {
expect(exportUsersCSVMock).toHaveBeenCalledWith(
"",
"tech-planning",
false,
);
});
});
it("queues searched users and adds all queued users to the tenant at once", async () => {
fetchUsersMock
.mockResolvedValueOnce({ items: [], total: 0 })
.mockResolvedValueOnce({
items: [
{
id: "user-2",
name: "Bob",
email: "bob@example.com",
role: "user",
status: "active",
},
{
id: "user-3",
name: "Carol",
email: "carol@example.com",
role: "user",
status: "active",
},
],
total: 2,
})
.mockResolvedValue({ items: [], total: 0 });
updateUserMock.mockResolvedValue({});
renderTenantUsersPage();
const addButton = await screen.findByTestId(
"tenant-member-add-existing-btn",
);
await waitFor(() => expect(addButton).not.toBeDisabled());
fireEvent.click(addButton);
fireEvent.change(screen.getByTestId("tenant-member-search-input"), {
target: { value: "bo" },
});
fireEvent.click(await screen.findByText("Bob"));
fireEvent.click(await screen.findByText("Carol"));
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Bob",
);
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Carol",
);
fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn"));
await waitFor(() => {
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
userIds: ["user-2", "user-3"],
tenantSlug: "tech-planning",
isAddTenant: true,
});
});
expect(updateUserMock).not.toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ isAddTenant: true }),
);
});
it("queues orgfront multi picker users and adds them with one bulk request", async () => {
fetchUsersMock
.mockResolvedValueOnce({
items: [
{
id: "existing-user",
name: "Existing",
email: "existing@example.com",
role: "user",
status: "active",
},
],
total: 1,
})
.mockResolvedValue({ items: [], total: 0 });
renderTenantUsersPage();
const addButton = await screen.findByTestId(
"tenant-member-add-existing-btn",
);
await waitFor(() => expect(addButton).not.toBeDisabled());
fireEvent.click(addButton);
const picker = await screen.findByTitle("조직도에서 구성원 선택");
expect(decodeURIComponent(picker.getAttribute("src") ?? "")).toContain(
"/embed/picker?mode=multiple&select=user",
);
fireEvent(
window,
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
mode: "multiple",
selections: [
{ type: "tenant", id: "team-1", name: "플랫폼팀" },
{
type: "user",
id: "picked-user-1",
name: "Picked One",
email: "picked1@example.com",
},
{
type: "user",
id: "picked-user-2",
name: "Picked Two",
},
{
type: "user",
id: "existing-user",
name: "Existing",
email: "existing@example.com",
},
],
},
},
}),
);
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Picked One",
);
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Picked Two",
);
expect(screen.getByTestId("tenant-member-add-queue")).not.toHaveTextContent(
"Existing",
);
fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn"));
await waitFor(() => {
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
userIds: ["picked-user-1", "picked-user-2"],
tenantSlug: "tech-planning",
isAddTenant: true,
});
});
});
it("removes a member from the tenant and invalidates the user detail cache", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
const { queryClient } = renderTenantUsersPage();
queryClient.setQueryData(["user", "user-1"], {
id: "user-1",
name: "Alice",
});
await screen.findByText("Alice");
fireEvent.click(screen.getByTestId("tenant-member-remove-user-1"));
await waitFor(() => {
expect(updateUserMock).toHaveBeenCalledWith("user-1", {
tenantSlug: "tech-planning",
isRemoveTenant: true,
});
});
expect(queryClient.getQueryState(["user", "user-1"])?.isInvalidated).toBe(
true,
);
confirmSpy.mockRestore();
});
});

View File

@@ -1,6 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { Loader2, Mail, Plus, User, UserPlus } from "lucide-react"; import {
FileDown,
Loader2,
Mail,
Plus,
Search,
User,
UserPlus,
X,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table"; import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
@@ -11,6 +21,15 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../../components/ui/card"; } from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { import {
Table, Table,
TableBody, TableBody,
@@ -20,14 +39,35 @@ import {
TableRow, TableRow,
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast"; import { toast } from "../../../components/ui/use-toast";
import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi"; import {
bulkUpdateUsers,
exportUsersCSV,
fetchTenant,
fetchUsers,
type UserSummary,
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import {
buildAuthenticatedOrgChartUserMultiPickerUrl,
parseOrgChartUserSelections,
} from "../../users/orgChartPicker";
function TenantUsersPage() { function TenantUsersPage() {
const params = useParams<{ tenantId: string }>(); const params = useParams<{ tenantId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const tenantId = params.tenantId ?? ""; const tenantId = params.tenantId ?? "";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [addMembersOpen, setAddMembersOpen] = React.useState(false);
const [memberSearch, setMemberSearch] = React.useState("");
const [queuedMembers, setQueuedMembers] = React.useState<UserSummary[]>([]);
const orgChartMemberPickerUrl = React.useMemo(
() =>
buildAuthenticatedOrgChartUserMultiPickerUrl(
import.meta.env.ORGFRONT_URL,
),
[],
);
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴 // 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
const tenantQuery = useQuery({ const tenantQuery = useQuery({
@@ -45,10 +85,37 @@ function TenantUsersPage() {
enabled: !!tenantSlug, enabled: !!tenantSlug,
}); });
const memberSearchTerm = memberSearch.trim();
const memberSearchQuery = useQuery({
queryKey: ["tenant-member-search", tenantSlug, memberSearchTerm],
queryFn: () => fetchUsers(20, 0, memberSearchTerm),
enabled: addMembersOpen && memberSearchTerm.length >= 2,
});
const exportMutation = useMutation({
mutationFn: (includeIds: boolean) =>
exportUsersCSV("", tenantSlug ?? "", 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 removeTenantMutation = useMutation({ const removeTenantMutation = useMutation({
mutationFn: ({ userId, slug }: { userId: string; slug: string }) => mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }), updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
onSuccess: () => { onSuccess: (_result, variables) => {
toast.success( toast.success(
t( t(
"msg.admin.tenants.members.remove_success", "msg.admin.tenants.members.remove_success",
@@ -56,6 +123,8 @@ function TenantUsersPage() {
), ),
); );
usersQuery.refetch(); usersQuery.refetch();
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", variables.userId] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
}, },
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>) => {
@@ -66,6 +135,38 @@ function TenantUsersPage() {
}, },
}); });
const addMembersMutation = useMutation({
mutationFn: async (members: UserSummary[]) => {
if (!tenantSlug || members.length === 0) return;
await bulkUpdateUsers({
userIds: members.map((member) => member.id),
tenantSlug,
isAddTenant: true,
});
},
onSuccess: () => {
const count = queuedMembers.length;
toast.success(
t(
"msg.admin.tenants.members.add_success",
"{{count}}명의 구성원이 추가되었습니다.",
{ count },
),
);
setQueuedMembers([]);
setMemberSearch("");
setAddMembersOpen(false);
usersQuery.refetch();
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.admin.tenants.members.add_error", "구성원 추가 실패"),
);
},
});
const _handleRemoveMember = (userId: string, userName: string) => { const _handleRemoveMember = (userId: string, userName: string) => {
if (!tenantSlug) return; if (!tenantSlug) return;
if ( if (
@@ -82,6 +183,68 @@ function TenantUsersPage() {
}; };
const users = usersQuery.data?.items ?? []; const users = usersQuery.data?.items ?? [];
const existingUserIds = React.useMemo(
() => new Set(users.map((user) => user.id)),
[users],
);
const queuedUserIds = React.useMemo(
() => new Set(queuedMembers.map((user) => user.id)),
[queuedMembers],
);
const searchResults = memberSearchQuery.data?.items ?? [];
const queueMembers = React.useCallback(
(members: UserSummary[]) => {
setQueuedMembers((current) => {
const blockedIds = new Set([
...existingUserIds,
...current.map((member) => member.id),
]);
const next = [...current];
for (const member of members) {
if (blockedIds.has(member.id)) continue;
blockedIds.add(member.id);
next.push(member);
}
return next;
});
},
[existingUserIds],
);
const queueMember = (member: UserSummary) => {
queueMembers([member]);
};
const removeQueuedMember = (memberId: string) => {
setQueuedMembers((current) =>
current.filter((member) => member.id !== memberId),
);
};
React.useEffect(() => {
if (!addMembersOpen) return;
const onMessage = (event: MessageEvent) => {
const selections = parseOrgChartUserSelections(event.data);
if (selections.length === 0) return;
queueMembers(
selections.map((selection) => ({
id: selection.id,
name: selection.name,
email: selection.email,
role: "user",
status: "active",
createdAt: "",
updatedAt: "",
})),
);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [addMembersOpen, queueMembers]);
return ( return (
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden"> <Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -92,12 +255,39 @@ function TenantUsersPage() {
count: users.length, count: users.length,
})} })}
</CardTitle> </CardTitle>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" size="sm" asChild className="gap-2"> <Button
<Link to={`/users?addTenant=${tenantSlug}`}> variant="outline"
<UserPlus size={16} /> size="sm"
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")} className="gap-2"
</Link> disabled={!tenantSlug || exportMutation.isPending}
data-testid="tenant-users-export-menu-item"
onClick={() => exportMutation.mutate(false)}
>
<FileDown size={16} />
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={!tenantSlug || exportMutation.isPending}
data-testid="tenant-users-export-with-ids-menu-item"
onClick={() => exportMutation.mutate(true)}
>
<FileDown size={16} />
{t("ui.common.export_with_ids", "UUID 포함 내보내기")}
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={!tenantSlug}
data-testid="tenant-member-add-existing-btn"
onClick={() => setAddMembersOpen(true)}
>
<UserPlus size={16} />
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</Button> </Button>
<Button size="sm" asChild className="gap-2"> <Button size="sm" asChild className="gap-2">
<Link to={`/users/new?tenantSlug=${tenantSlug}`}> <Link to={`/users/new?tenantSlug=${tenantSlug}`}>
@@ -107,6 +297,156 @@ function TenantUsersPage() {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<Dialog open={addMembersOpen} onOpenChange={setAddMembersOpen}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.tenants.members.add_existing_description",
"검색 결과를 선택해 추가 명단에 담은 뒤 한 번에 배정합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,1.2fr)]">
<div className="space-y-3">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={memberSearch}
onChange={(event) => setMemberSearch(event.target.value)}
className="h-9 pl-9"
placeholder={t(
"ui.admin.tenants.members.search_placeholder",
"이름 또는 이메일 검색",
)}
data-testid="tenant-member-search-input"
/>
</div>
<div className="rounded-md border">
<div className="max-h-56 overflow-auto">
{memberSearchTerm.length < 2 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{t(
"ui.admin.tenants.members.search_min_length",
"두 글자 이상 입력하세요.",
)}
</div>
) : memberSearchQuery.isFetching ? (
<div className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
{t("ui.common.searching", "검색 중...")}
</div>
) : searchResults.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{t("ui.common.no_results", "검색 결과가 없습니다.")}
</div>
) : (
<div className="divide-y">
{searchResults.map((user) => {
const disabled =
existingUserIds.has(user.id) ||
queuedUserIds.has(user.id);
return (
<button
key={user.id}
type="button"
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={() => queueMember(user)}
>
<span className="min-w-0">
<span className="block truncate font-medium">
{user.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{user.email}
</span>
</span>
<Plus size={16} className="flex-shrink-0" />
</button>
);
})}
</div>
)}
</div>
</div>
</div>
<div className="min-h-[360px] overflow-hidden rounded-md border">
<iframe
title={t(
"ui.admin.tenants.members.org_picker_title",
"조직도에서 구성원 선택",
)}
src={orgChartMemberPickerUrl}
className="h-[420px] w-full"
data-testid="tenant-member-org-picker-frame"
/>
</div>
<div
className="min-h-20 rounded-md border bg-muted/20 p-3 lg:col-span-2"
data-testid="tenant-member-add-queue"
>
{queuedMembers.length === 0 ? (
<div className="flex h-14 items-center justify-center text-sm text-muted-foreground">
{t(
"ui.admin.tenants.members.queue_empty",
"추가할 구성원을 선택하세요.",
)}
</div>
) : (
<div className="flex flex-wrap gap-2">
{queuedMembers.map((user) => (
<span
key={user.id}
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-2 py-1 text-sm"
>
<span className="max-w-52 truncate">{user.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => removeQueuedMember(user.id)}
aria-label={t(
"ui.admin.tenants.members.queue_remove",
"추가 명단에서 제거",
)}
>
<X size={14} />
</button>
</span>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAddMembersOpen(false)}
disabled={addMembersMutation.isPending}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => addMembersMutation.mutate(queuedMembers)}
disabled={
queuedMembers.length === 0 || addMembersMutation.isPending
}
data-testid="tenant-member-add-submit-btn"
>
{addMembersMutation.isPending && (
<Loader2 size={16} className="animate-spin" />
)}
{t("ui.admin.tenants.members.add_queued", "선택 구성원 추가")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0"> <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 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div className="flex-1 overflow-auto relative custom-scrollbar">
@@ -125,12 +465,15 @@ function TenantUsersPage() {
<TableHead> <TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")} {t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead> </TableHead>
<TableHead className="text-right">
{t("ui.common.actions", "작업")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{usersQuery.isLoading ? ( {usersQuery.isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center py-20"> <TableCell colSpan={5} className="text-center py-20">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Loader2 <Loader2
className="animate-spin text-muted-foreground" className="animate-spin text-muted-foreground"
@@ -145,7 +488,7 @@ function TenantUsersPage() {
) : users.length === 0 ? ( ) : users.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={4} colSpan={5}
className="text-center py-8 text-muted-foreground" className="text-center py-8 text-muted-foreground"
> >
{t( {t(
@@ -187,6 +530,23 @@ function TenantUsersPage() {
{t(`ui.common.status.${user.status}`, user.status)} {t(`ui.common.status.${user.status}`, user.status)}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
aria-label={t(
"ui.admin.tenants.members.remove",
"구성원 제외",
)}
data-testid={`tenant-member-remove-${user.id}`}
onClick={(event) => {
event.stopPropagation();
_handleRemoveMember(user.id, user.name);
}}
>
<X size={16} />
</Button>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}

View File

@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildWorksmobilePasswordManageUrl, buildWorksmobilePasswordManageUrl,
@@ -10,6 +12,7 @@ import {
filterWorksmobileComparisonRowsBySearch, filterWorksmobileComparisonRowsBySearch,
formatWorksmobileOrgDetails, formatWorksmobileOrgDetails,
formatWorksmobilePersonName, formatWorksmobilePersonName,
formatWorksmobileSelectionFailureDescription,
formatWorksmobileUpdateDetails, formatWorksmobileUpdateDetails,
getDefaultGroupComparisonFilters, getDefaultGroupComparisonFilters,
getDefaultUserComparisonFilters, getDefaultUserComparisonFilters,
@@ -17,7 +20,9 @@ import {
getWorksmobileComparisonStatusLabel, getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey, getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds, getWorksmobileSelectedActionIds,
getWorksmobileSelectedCreateUserIds,
getWorksmobileSelectedMissingExternalKeyOrgUnitIds, getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
getWorksmobileSelectedUpdateUserIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds, getWorksmobileSelectedWorksOnlyOrgUnitIds,
isImmutableWorksmobileAccount, isImmutableWorksmobileAccount,
summarizeWorksmobileComparison, summarizeWorksmobileComparison,
@@ -25,6 +30,18 @@ import {
} from "./worksmobileComparison"; } from "./worksmobileComparison";
describe("TenantWorksmobilePage comparison helpers", () => { describe("TenantWorksmobilePage comparison helpers", () => {
it("does not apply page-level enter animations to Worksmobile tab panels", () => {
const source = readFileSync(
join(
process.cwd(),
"src/features/tenants/routes/TenantWorksmobilePage.tsx",
),
"utf8",
);
expect(source).not.toContain("space-y-4 animate-in fade-in duration-500");
});
it("summarizes comparison rows by status", () => { it("summarizes comparison rows by status", () => {
const summary = summarizeWorksmobileComparison([ const summary = summarizeWorksmobileComparison([
{ resourceType: "USER", status: "matched" }, { resourceType: "USER", status: "matched" },
@@ -225,6 +242,41 @@ describe("TenantWorksmobilePage comparison helpers", () => {
]); ]);
}); });
it("separates selected WORKS user creation ids from update-needed user ids", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "baron-only",
},
{
resourceType: "USER",
status: "needs_update",
baronId: "needs-update",
worksmobileId: "works-needs-update",
},
{
resourceType: "USER",
status: "matched",
baronId: "matched",
worksmobileId: "works-matched",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-only",
},
];
const selectedKeys = rows.map(getWorksmobileRowSelectionKey);
expect(getWorksmobileSelectedCreateUserIds(rows, selectedKeys)).toEqual([
"baron-only",
]);
expect(getWorksmobileSelectedUpdateUserIds(rows, selectedKeys)).toEqual([
"needs-update",
]);
});
it("uses compact comparison columns by default", () => { it("uses compact comparison columns by default", () => {
expect(getDefaultWorksmobileComparisonColumns()).toEqual({ expect(getDefaultWorksmobileComparisonColumns()).toEqual({
status: true, status: true,
@@ -472,6 +524,48 @@ describe("TenantWorksmobilePage comparison helpers", () => {
).toEqual([rows[0]]); ).toEqual([rows[0]]);
}); });
it("filters users by WORKS account status", () => {
const rows = [
{
resourceType: "USER",
status: "matched",
baronId: "user-1",
worksmobileAccountStatus: "active",
},
{
resourceType: "USER",
status: "matched",
baronId: "user-2",
worksmobileAccountStatus: "suspended",
},
{
resourceType: "USER",
status: "matched",
baronId: "user-3",
worksmobileAccountStatus: "invited",
},
];
expect(
filterWorksmobileComparisonRows(
rows,
getDefaultUserComparisonFilters(),
false,
"suspended",
),
).toEqual([rows[1]]);
});
it("formats partial Worksmobile selection failures with detailed reasons", () => {
expect(
formatWorksmobileSelectionFailureDescription(1, [
"7e30daf6-f912-4306-befc-478feb7b74cc: target user tenant is excluded from Worksmobile sync",
]),
).toBe(
"성공 1건, 실패 1건\n7e30daf6-f912-4306-befc-478feb7b74cc: target user tenant is excluded from Worksmobile sync",
);
});
it("formats update details for changed organization rows", () => { it("formats update details for changed organization rows", () => {
expect( expect(
formatWorksmobileUpdateDetails({ formatWorksmobileUpdateDetails({
@@ -492,6 +586,107 @@ describe("TenantWorksmobilePage comparison helpers", () => {
]); ]);
}); });
it("formats update details for changed user phone and employee number", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
baronName: "강명진",
worksmobileName: "강명진",
baronEmail: "mjkang4@hanmaceng.co.kr",
worksmobileEmail: "mjkang4@hanmaceng.co.kr",
externalKey: "user-1",
baronPhone: "+821051583696",
worksmobilePhone: "+821099998888",
baronEmployeeNumber: "mjkang4",
worksmobileEmployeeNumber: "M17205",
}),
).toEqual([
"전화번호: +821099998888 -> +821051583696",
"사번: M17205 -> mjkang4",
]);
});
it("formats backend update reasons when value diff details are not directly visible", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
baronName: "신현우",
worksmobileName: "신현우",
baronEmail: "hwshin2@hanmaceng.co.kr",
worksmobileEmail: "hwshin2@hanmaceng.co.kr",
externalKey: "user-1",
updateReasons: ["organization"],
}),
).toEqual(["조직: Baron 소속 정보를 WORKS에 반영해야 합니다."]);
});
it("formats grade update reasons with before and after values", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
externalKey: "user-1",
baronName: "신현우",
worksmobileName: "신현우",
baronGrade: "책임",
worksmobileLevelName: "선임",
updateReasons: ["grade"],
}),
).toEqual(["직급: 선임 -> 책임"]);
});
it("formats grade update reasons with matched WORKS membership", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
externalKey: "user-1",
baronName: "연구원",
worksmobileName: "연구원",
baronGrade: "책임연구원",
worksmobileLevelName: "",
updateReasons: ["grade"],
userMemberships: [
{
baronOrgId: "1d74bebb-c5a1-49d4-bec4-90f0c89ad21f",
baronOrgSlug: "hmeg",
baronOrgName: "HmEG",
baronGrade: "책임연구원",
worksmobileOrgId: "works-hmeg",
worksmobileOrgName: "WORKS HmEG",
worksmobileDomainName: "baroncs.co.kr",
gradeNeedsUpdate: true,
},
],
}),
).toEqual(["직급: 없음 -> 책임연구원 (Baron HmEG / WORKS WORKS HmEG)"]);
});
it("does not format phone update details for spaced Korean country code formatting only", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
baronName: "강명진",
worksmobileName: "강명진",
baronEmail: "mjkang4@hanmaceng.co.kr",
worksmobileEmail: "mjkang4@hanmaceng.co.kr",
externalKey: "user-1",
baronPhone: "+821041585840",
worksmobilePhone: "+82 1041585840",
baronEmployeeNumber: "mjkang4",
worksmobileEmployeeNumber: "M17205",
}),
).toEqual(["사번: M17205 -> mjkang4"]);
});
it("formats WORKS account name with level on one line", () => { it("formats WORKS account name with level on one line", () => {
expect( expect(
formatWorksmobilePersonName({ formatWorksmobilePersonName({

View File

@@ -16,6 +16,24 @@ export function tenantMatchesListSearch(
.some((value) => value.toLowerCase().includes(normalizedSearch)); .some((value) => value.toLowerCase().includes(normalizedSearch));
} }
export function getTenantSearchMatchIds(
rows: Array<Pick<TenantSummary, "id" | "name" | "slug" | "type">>,
search: string,
) {
if (!search.trim()) return [];
return rows
.filter((row) => tenantMatchesListSearch(row, search))
.map((row) => row.id);
}
export function filterTenantViewRowsBySearch<T extends TenantViewRow>(
rows: T[],
search: string,
) {
if (!search.trim()) return rows;
return rows.filter((row) => tenantMatchesListSearch(row, search));
}
function collectTenantTreeRows( function collectTenantTreeRows(
nodes: TenantNode[], nodes: TenantNode[],
depth: number, depth: number,
@@ -91,7 +109,8 @@ export function getTenantViewRows(
...(rowsById.get(tenant.id) ?? { ...(rowsById.get(tenant.id) ?? {
...tenant, ...tenant,
children: [], children: [],
recursiveMemberCount: Number(tenant.memberCount) || 0, recursiveMemberCount:
Number(tenant.totalMemberCount ?? tenant.memberCount) || 0,
}), }),
depth: 0, depth: 0,
})); }));

View File

@@ -6,6 +6,14 @@ export type WorksmobileComparisonFilter =
| "needs_update" | "needs_update"
| "matched"; | "matched";
export type WorksmobileAccountStatusFilter =
| "all"
| "active"
| "invited"
| "suspended"
| "inactive"
| "deleted";
export type WorksmobileComparisonSummary = { export type WorksmobileComparisonSummary = {
total: number; total: number;
matched: number; matched: number;
@@ -172,6 +180,54 @@ export function getWorksmobileSelectedActionIds(
.filter((id): id is string => Boolean(id)); .filter((id): id is string => Boolean(id));
} }
export function getWorksmobileSelectedCreateUserIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter(
(row) =>
row.resourceType === "USER" &&
row.status === "missing_in_worksmobile" &&
selected.has(getWorksmobileRowSelectionKey(row)),
)
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
}
export function getWorksmobileSelectedUpdateUserIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter(
(row) =>
row.resourceType === "USER" &&
row.status === "needs_update" &&
selected.has(getWorksmobileRowSelectionKey(row)),
)
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
}
export function formatWorksmobileSelectionFailureDescription(
successCount: number,
failures: string[],
) {
const summary = `성공 ${successCount}건, 실패 ${failures.length}`;
const visibleFailures = failures.slice(0, 3);
if (failures.length <= visibleFailures.length) {
return [summary, ...visibleFailures].join("\n");
}
return [
summary,
...visibleFailures,
`${failures.length - visibleFailures.length}건 실패`,
].join("\n");
}
export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds( export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds(
rows: WorksmobileComparisonItem[], rows: WorksmobileComparisonItem[],
selectedKeys: string[], selectedKeys: string[],
@@ -219,6 +275,7 @@ const worksmobileComparisonSearchFields: Array<
"externalKey", "externalKey",
"worksmobileName", "worksmobileName",
"worksmobileEmail", "worksmobileEmail",
"worksmobileAccountStatus",
"worksmobileLevelId", "worksmobileLevelId",
"worksmobileLevelName", "worksmobileLevelName",
"worksmobileTask", "worksmobileTask",
@@ -260,6 +317,7 @@ export function filterWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[], rows: WorksmobileComparisonItem[],
filters: WorksmobileComparisonFilter[], filters: WorksmobileComparisonFilter[],
onlyMissingExternalKey = false, onlyMissingExternalKey = false,
accountStatus: WorksmobileAccountStatusFilter = "all",
) { ) {
const allowedStatuses = new Set( const allowedStatuses = new Set(
filters.flatMap((filter) => worksmobileFilterStatuses[filter]), filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
@@ -270,7 +328,15 @@ export function filterWorksmobileComparisonRows(
} }
allowedStatuses.add("missing_external_key"); allowedStatuses.add("missing_external_key");
} }
return rows.filter((row) => allowedStatuses.has(row.status)); return rows.filter((row) => {
if (accountStatus !== "all") {
return row.worksmobileAccountStatus === accountStatus;
}
if (!allowedStatuses.has(row.status)) {
return false;
}
return true;
});
} }
export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) { export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) {
@@ -299,6 +365,32 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
return details; return details;
} }
export function formatWorksmobileUserMembershipDetails(
row: WorksmobileComparisonItem,
) {
return (row.userMemberships ?? []).map((membership) => {
const baronOrg =
membership.baronOrgName?.trim() ||
membership.baronOrgSlug?.trim() ||
membership.baronOrgId?.trim() ||
"Baron 조직";
const worksOrg =
membership.worksmobileOrgName?.trim() ||
membership.worksmobileOrgId?.trim() ||
"WORKS 조직 없음";
const details = [
membership.baronPrimary ? "기본" : "겸직",
`Baron ${baronOrg}`,
`WORKS ${worksOrg}`,
membership.worksmobileLevelName?.trim() ||
membership.worksmobileLevelId?.trim()
? `직급 ${membership.worksmobileLevelName?.trim() || membership.worksmobileLevelId?.trim()}`
: "",
].filter(Boolean);
return details.join(" / ");
});
}
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) { export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) { if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) {
return [`최근 실패: ${row.worksmobileLastError}`]; return [`최근 실패: ${row.worksmobileLastError}`];
@@ -308,24 +400,67 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
} }
const details: string[] = []; const details: string[] = [];
const renderedReasons = new Set<string>();
const addDetail = (reason: string, detail: string) => {
details.push(detail);
renderedReasons.add(reason);
};
const baronName = row.baronName?.trim(); const baronName = row.baronName?.trim();
const worksmobileName = row.worksmobileName?.trim(); const worksmobileName = row.worksmobileName?.trim();
if (baronName && worksmobileName && baronName !== worksmobileName) { if (baronName && worksmobileName && baronName !== worksmobileName) {
details.push(`이름: ${worksmobileName} -> ${baronName}`); addDetail("name", `이름: ${worksmobileName} -> ${baronName}`);
} }
if (row.resourceType === "USER") { if (row.resourceType === "USER") {
const expectedExternalKey = row.baronId?.trim() ?? ""; const expectedExternalKey = row.baronId?.trim() ?? "";
const actualExternalKey = row.externalKey?.trim() ?? ""; const actualExternalKey = row.externalKey?.trim() ?? "";
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) { if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
details.push( addDetail(
"external_key",
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`, `external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
); );
} }
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? ""; const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? ""; const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) { if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
details.push(`이메일: ${actualEmail} -> ${expectedEmail}`); addDetail("email", `이메일: ${actualEmail} -> ${expectedEmail}`);
} }
const expectedPhone = row.baronPhone?.trim() ?? "";
const actualPhone = row.worksmobilePhone?.trim() ?? "";
if (
expectedPhone &&
actualPhone &&
normalizeWorksmobilePhoneForCompare(expectedPhone) !==
normalizeWorksmobilePhoneForCompare(actualPhone)
) {
addDetail("phone", `전화번호: ${actualPhone} -> ${expectedPhone}`);
}
const expectedEmployeeNumber = row.baronEmployeeNumber?.trim() ?? "";
const actualEmployeeNumber = row.worksmobileEmployeeNumber?.trim() ?? "";
if (
expectedEmployeeNumber &&
actualEmployeeNumber &&
expectedEmployeeNumber !== actualEmployeeNumber
) {
addDetail(
"employee_number",
`사번: ${actualEmployeeNumber} -> ${expectedEmployeeNumber}`,
);
}
const expectedGrade = row.baronGrade?.trim() ?? "";
const actualGrade =
row.worksmobileLevelName?.trim() ?? row.worksmobileLevelId?.trim() ?? "";
if (
row.updateReasons?.includes("grade") &&
(expectedGrade || actualGrade) &&
expectedGrade !== actualGrade
) {
const membershipContext = formatWorksmobileGradeMembershipContext(row);
addDetail(
"grade",
`직급: ${actualGrade || "없음"} -> ${expectedGrade || "없음"}${membershipContext}`,
);
}
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
return details; return details;
} }
@@ -345,14 +480,123 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
const actualParentKey = const actualParentKey =
row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? ""; row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? "";
if (expectedParentKey !== actualParentKey) { if (expectedParentKey !== actualParentKey) {
details.push( addDetail(
"organization",
`상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`, `상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`,
); );
} }
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
return details; return details;
} }
function appendWorksmobileUpdateReasonFallbacks(
details: string[],
row: WorksmobileComparisonItem,
renderedReasons: Set<string>,
) {
for (const reason of row.updateReasons ?? []) {
const normalizedReason = reason.trim();
if (!normalizedReason || renderedReasons.has(normalizedReason)) {
continue;
}
const detail = formatWorksmobileUpdateReasonFallback(normalizedReason, row);
if (!detail) {
continue;
}
details.push(detail);
renderedReasons.add(normalizedReason);
}
}
function formatWorksmobileUpdateReasonFallback(
reason: string,
row: WorksmobileComparisonItem,
) {
switch (reason) {
case "name":
return "이름: Baron 사용자명을 WORKS에 반영해야 합니다.";
case "external_key":
return "external_key: Baron 사용자 ID를 WORKS 외부 키로 반영해야 합니다.";
case "email":
return "이메일: Baron 이메일을 WORKS에 반영해야 합니다.";
case "phone":
return "전화번호: Baron 전화번호를 WORKS에 반영해야 합니다.";
case "employee_number":
return "사번: Baron 사번을 WORKS에 반영해야 합니다.";
case "grade": {
const expectedGrade = row.baronGrade?.trim() ?? "";
const actualGrade =
row.worksmobileLevelName?.trim() ??
row.worksmobileLevelId?.trim() ??
"";
if (expectedGrade || actualGrade) {
return `직급: ${actualGrade || "없음"} -> ${expectedGrade || "없음"}${formatWorksmobileGradeMembershipContext(row)}`;
}
return "직급: Baron 직급 정보를 WORKS에 반영해야 합니다.";
}
case "organization":
return row.resourceType === "GROUP"
? "조직: Baron 조직 정보를 WORKS에 반영해야 합니다."
: "조직: Baron 소속 정보를 WORKS에 반영해야 합니다.";
case "manager":
return "조직장: Baron 조직장 설정을 WORKS에 반영해야 합니다.";
default:
return `업데이트 사유: ${reason}`;
}
}
function formatWorksmobileGradeMembershipContext(
row: WorksmobileComparisonItem,
) {
const membership =
row.userMemberships?.find((item) => item.gradeNeedsUpdate) ??
row.userMemberships?.find(
(item) =>
item.baronGrade?.trim() &&
item.baronGrade?.trim() === row.baronGrade?.trim(),
);
if (!membership) {
return "";
}
const baronOrg =
membership.baronOrgName?.trim() ||
membership.baronOrgSlug?.trim() ||
membership.baronOrgId?.trim();
const worksOrg =
membership.worksmobileOrgName?.trim() ||
membership.worksmobileOrgId?.trim();
if (!baronOrg && !worksOrg) {
return "";
}
return ` (Baron ${baronOrg || "없음"} / WORKS ${worksOrg || "없음"})`;
}
function normalizeWorksmobilePhoneForCompare(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const digits = trimmed.replace(/\D/g, "");
if (!digits) {
return "";
}
if (digits.startsWith("010")) {
return `+82${digits.slice(1)}`;
}
if (digits.startsWith("82")) {
let rest = digits.slice(2);
while (rest.startsWith("82")) {
rest = rest.slice(2);
}
if (rest.startsWith("0")) {
rest = rest.slice(1);
}
return `+82${rest}`;
}
return `+${digits}`;
}
export function buildWorksmobilePasswordManageUrl({ export function buildWorksmobilePasswordManageUrl({
tenantId, tenantId,
domainId, domainId,
@@ -413,6 +657,18 @@ export const comparisonFilterOptions: Array<{
export const userFilterOptions = comparisonFilterOptions; export const userFilterOptions = comparisonFilterOptions;
export const worksmobileAccountStatusFilterOptions: Array<{
value: WorksmobileAccountStatusFilter;
label: string;
}> = [
{ value: "all", label: "WORKS 전체" },
{ value: "active", label: "active" },
{ value: "invited", label: "invited" },
{ value: "suspended", label: "suspended" },
{ value: "inactive", label: "inactive" },
{ value: "deleted", label: "deleted" },
];
export function getDefaultUserComparisonFilters(): WorksmobileComparisonFilter[] { export function getDefaultUserComparisonFilters(): WorksmobileComparisonFilter[] {
return ["baron_only", "needs_update", "works_only"]; return ["baron_only", "needs_update", "works_only"];
} }

View File

@@ -1,12 +1,25 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants"; import { getSeedTenantIds, isSeedTenant } from "./protectedTenants";
describe("protectedTenants", () => { describe("protectedTenants", () => {
it("marks tenants from seed-tenant.csv as protected", () => { it("marks tenants from seed-tenant.csv as protected by UUID", () => {
expect(getSeedTenantSlugs()).toEqual( expect(getSeedTenantIds()).toEqual(
expect.arrayContaining(["hanmac-family", "personal"]), expect.arrayContaining([
"038326b6-954a-48a7-a85f-efd83f62b82a",
"5a03efd2-e62f-4243-800d-58334bf48b2f",
"9607eb7b-04d2-42ab-80fe-780fe21c7e8f",
]),
); );
expect(isSeedTenant({ slug: "hanmac-family" })).toBe(true); expect(
expect(isSeedTenant({ slug: "normal-tenant" })).toBe(false); isSeedTenant({
id: "5a03efd2-e62f-4243-800d-58334bf48b2f",
}),
).toBe(true);
expect(
isSeedTenant({
id: "5A03EFD2-E62F-4243-800D-58334BF48B2F",
}),
).toBe(true);
expect(isSeedTenant({ id: "normal-tenant" })).toBe(false);
}); });
}); });

View File

@@ -4,16 +4,15 @@ import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
import type { TenantSummary } from "../../../lib/adminApi"; import type { TenantSummary } from "../../../lib/adminApi";
import { parseTenantCSV } from "./tenantCsvImport"; import { parseTenantCSV } from "./tenantCsvImport";
const seedTenantSlugs = new Set( const seedTenants = parseTenantCSV(seedTenantCSVRaw);
parseTenantCSV(seedTenantCSVRaw) const seedTenantIds = new Set(
.map((row) => row.slug.trim().toLowerCase()) seedTenants.map((row) => row.tenantId.trim().toLowerCase()).filter(Boolean),
.filter(Boolean),
); );
export function isSeedTenant(tenant: Pick<TenantSummary, "slug">): boolean { export function isSeedTenant(tenant: Pick<TenantSummary, "id">): boolean {
return seedTenantSlugs.has(tenant.slug.trim().toLowerCase()); return seedTenantIds.has(tenant.id.trim().toLowerCase());
} }
export function getSeedTenantSlugs(): string[] { export function getSeedTenantIds(): string[] {
return Array.from(seedTenantSlugs); return Array.from(seedTenantIds);
} }

View File

@@ -61,7 +61,9 @@ import {
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast"; import { toast } from "../../../components/ui/use-toast";
import { import {
bulkUpdateUsers,
exportTenantsCSV, exportTenantsCSV,
exportUsersCSV,
fetchAllTenants, fetchAllTenants,
fetchUsers, fetchUsers,
type TenantSummary, type TenantSummary,
@@ -71,6 +73,10 @@ import {
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree"; import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
import {
buildAuthenticatedOrgChartUserMultiPickerUrl,
parseOrgChartUserSelections,
} from "../../users/orgChartPicker";
// --- Icons & Helpers --- // --- Icons & Helpers ---
const getTenantIcon = (type?: string) => { const getTenantIcon = (type?: string) => {
@@ -223,8 +229,10 @@ const MemberTable: React.FC<{
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (userId: string) => mutationFn: (userId: string) =>
updateUser(userId, { tenantSlug, isRemoveTenant: true }), updateUser(userId, { tenantSlug, isRemoveTenant: true }),
onSuccess: () => { onSuccess: (_result, userId) => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", userId] });
toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다.")); toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
refetch(); refetch();
}, },
@@ -296,7 +304,12 @@ const MemberTable: React.FC<{
<TableCell> <TableCell>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"> <Button
variant="ghost"
size="icon"
className="h-8 w-8"
data-testid={`tenant-org-member-actions-${user.id}`}
>
<MoreHorizontal size={14} /> <MoreHorizontal size={14} />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -313,6 +326,7 @@ const MemberTable: React.FC<{
{t("ui.common.move_org", "타 조직으로 이동")} {t("ui.common.move_org", "타 조직으로 이동")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
data-testid={`tenant-org-member-remove-${user.id}`}
onClick={() => { onClick={() => {
if ( if (
window.confirm( window.confirm(
@@ -432,6 +446,24 @@ function TenantUserGroupsTab() {
), ),
}); });
const exportCurrentMembersMutation = useMutation({
mutationFn: (tenantSlug: string) => exportUsersCSV("", tenantSlug, false),
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", "사용자 내보내기에 실패했습니다."),
),
});
// Data Fetching // Data Fetching
const { const {
data: allTenantsData, data: allTenantsData,
@@ -616,13 +648,29 @@ function TenantUserGroupsTab() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsUserAddOpen(true)} onClick={() => setIsUserAddOpen(true)}
data-testid="tenant-org-member-add-open-btn"
> >
<UserPlus size={16} className="mr-2" /> <UserPlus size={16} className="mr-2" />
{t("ui.admin.users.list.add", "멤버 추가")} {t("ui.admin.users.list.add", "멤버 추가")}
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() =>
exportCurrentMembersMutation.mutate(selectedNode.slug)
}
disabled={
!selectedNode.slug || exportCurrentMembersMutation.isPending
}
data-testid="tenant-current-users-export-btn"
>
<Download size={16} className="mr-2" />
{t("ui.admin.tenants.members.export", "선택 조직 사용자 CSV")}
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -836,8 +884,19 @@ const UserAddDialog: React.FC<{
const [userSearch, setUserSearch] = useState(""); const [userSearch, setUserSearch] = useState("");
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<UserSummary[]>([]); const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null); const [queuedUsers, setQueuedUsers] = useState<UserSummary[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const orgChartMemberPickerUrl = React.useMemo(
() =>
buildAuthenticatedOrgChartUserMultiPickerUrl(
import.meta.env.ORGFRONT_URL,
),
[],
);
const queuedUserIds = React.useMemo(
() => new Set(queuedUsers.map((user) => user.id)),
[queuedUsers],
);
const handleSearch = async () => { const handleSearch = async () => {
if (!userSearch) return; if (!userSearch) return;
@@ -853,12 +912,22 @@ const UserAddDialog: React.FC<{
}; };
const handleAssign = async () => { const handleAssign = async () => {
if (!selectedUserId) return; if (queuedUsers.length === 0) return;
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await updateUser(selectedUserId, { tenantSlug }); await bulkUpdateUsers({
userIds: queuedUsers.map((user) => user.id),
tenantSlug,
isAddTenant: true,
});
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다.")); toast.success(
t(
"msg.admin.tenants.members.add_success",
"{{count}}명의 구성원이 추가되었습니다.",
{ count: queuedUsers.length },
),
);
onOpenChange(false); onOpenChange(false);
resetFields(); resetFields();
} catch (err) { } catch (err) {
@@ -875,9 +944,54 @@ const UserAddDialog: React.FC<{
const resetFields = () => { const resetFields = () => {
setUserSearch(""); setUserSearch("");
setSearchResults([]); setSearchResults([]);
setSelectedUserId(null); setQueuedUsers([]);
}; };
const queueUsers = React.useCallback((users: UserSummary[]) => {
setQueuedUsers((current) => {
const blockedIds = new Set(current.map((user) => user.id));
const next = [...current];
for (const user of users) {
if (blockedIds.has(user.id)) continue;
blockedIds.add(user.id);
next.push(user);
}
return next;
});
}, []);
const queueUser = (user: UserSummary) => {
queueUsers([user]);
};
const removeQueuedUser = (userId: string) => {
setQueuedUsers((current) => current.filter((user) => user.id !== userId));
};
React.useEffect(() => {
if (!open) return;
const onMessage = (event: MessageEvent) => {
const selections = parseOrgChartUserSelections(event.data);
if (selections.length === 0) return;
queueUsers(
selections.map((selection) => ({
id: selection.id,
name: selection.name,
email: selection.email,
role: "user",
status: "active",
createdAt: "",
updatedAt: "",
})),
);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [open, queueUsers]);
return ( return (
<Dialog <Dialog
open={open} open={open}
@@ -886,7 +1000,7 @@ const UserAddDialog: React.FC<{
if (!v) resetFields(); if (!v) resetFields();
}} }}
> >
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="max-w-5xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{t("ui.admin.users.create.title", "멤버 추가")} {t("ui.admin.users.create.title", "멤버 추가")}
@@ -896,52 +1010,103 @@ const UserAddDialog: React.FC<{
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="grid gap-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,1.2fr)]">
<div className="flex gap-2"> <div className="space-y-3">
<Input <div className="flex gap-2">
placeholder={t( <Input
"ui.admin.users.list.search_placeholder", placeholder={t(
"이메일 검색...", "ui.admin.users.list.search_placeholder",
)} "이메일 검색...",
value={userSearch} )}
onChange={(e) => setUserSearch(e.target.value)} value={userSearch}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} onChange={(e) => setUserSearch(e.target.value)}
/> onKeyDown={(e) => e.key === "Enter" && handleSearch()}
<Button data-testid="tenant-org-member-search-input"
variant="secondary" />
onClick={handleSearch} <Button
disabled={isSearching} variant="secondary"
> onClick={handleSearch}
<Search size={16} /> disabled={isSearching}
</Button> data-testid="tenant-org-member-search-btn"
</div> >
<ScrollArea className="h-60 border rounded-md"> <Search size={16} />
<Table> </Button>
<TableBody> </div>
{searchResults?.map((user) => ( <ScrollArea className="h-60 rounded-md border">
<TableRow <Table>
key={user.id} <TableBody>
className={`cursor-pointer hover:bg-muted/50 ${selectedUserId === user.id ? "bg-primary/5" : ""}`} {searchResults?.map((user) => (
onClick={() => setSelectedUserId(user.id)} <TableRow
> key={user.id}
<TableCell> data-testid={`tenant-org-member-search-result-${user.id}`}
<div className="flex items-center justify-between"> className={`cursor-pointer hover:bg-muted/50 ${queuedUserIds.has(user.id) ? "bg-primary/5 opacity-60" : ""}`}
<div> onClick={() => queueUser(user)}
<p className="text-sm font-medium">{user.name}</p> >
<p className="text-[10px] text-muted-foreground"> <TableCell>
{user.email} <div className="flex items-center justify-between">
</p> <div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-[10px] text-muted-foreground">
{user.email}
</p>
</div>
{queuedUserIds.has(user.id) && (
<ChevronRight size={16} className="text-primary" />
)}
</div> </div>
{selectedUserId === user.id && ( </TableCell>
<ChevronRight size={16} className="text-primary" /> </TableRow>
)} ))}
</div> </TableBody>
</TableCell> </Table>
</TableRow> </ScrollArea>
</div>
<div className="min-h-[360px] overflow-hidden rounded-md border">
<iframe
title={t(
"ui.admin.tenants.members.org_picker_title",
"조직도에서 구성원 선택",
)}
src={orgChartMemberPickerUrl}
className="h-[420px] w-full"
data-testid="tenant-org-member-picker-frame"
/>
</div>
<div
className="min-h-16 rounded-md border bg-muted/20 p-3 lg:col-span-2"
data-testid="tenant-org-member-add-queue"
>
{queuedUsers.length === 0 ? (
<div className="flex h-10 items-center justify-center text-sm text-muted-foreground">
{t(
"ui.admin.tenants.members.queue_empty",
"추가할 구성원을 선택하세요.",
)}
</div>
) : (
<div className="flex flex-wrap gap-2">
{queuedUsers.map((user) => (
<span
key={user.id}
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-2 py-1 text-sm"
>
<span className="max-w-52 truncate">{user.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => removeQueuedUser(user.id)}
aria-label={t(
"ui.admin.tenants.members.queue_remove",
"추가 명단에서 제거",
)}
>
<Trash2 size={14} />
</button>
</span>
))} ))}
</TableBody> </div>
</Table> )}
</ScrollArea> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -949,7 +1114,8 @@ const UserAddDialog: React.FC<{
</Button> </Button>
<Button <Button
onClick={handleAssign} onClick={handleAssign}
disabled={isSubmitting || !selectedUserId} disabled={isSubmitting || queuedUsers.length === 0}
data-testid="tenant-org-member-add-submit-btn"
> >
{t("ui.common.add", "배정")} {t("ui.common.add", "배정")}
</Button> </Button>

View File

@@ -0,0 +1,96 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import GlobalCustomClaimsPage from "./GlobalCustomClaimsPage";
const fetchGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn());
const updateGlobalCustomClaimDefinitionsMock = vi.hoisted(() => vi.fn());
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchGlobalCustomClaimDefinitions: fetchGlobalCustomClaimDefinitionsMock,
updateGlobalCustomClaimDefinitions: updateGlobalCustomClaimDefinitionsMock,
}));
vi.mock("../../components/ui/use-toast", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
function renderGlobalCustomClaimsPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<GlobalCustomClaimsPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("GlobalCustomClaimsPage", () => {
beforeEach(() => {
fetchGlobalCustomClaimDefinitionsMock.mockReset();
fetchGlobalCustomClaimDefinitionsMock.mockResolvedValue({
items: [
{
key: "locale",
label: "Locale",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
description: "",
},
],
});
updateGlobalCustomClaimDefinitionsMock.mockReset();
updateGlobalCustomClaimDefinitionsMock.mockResolvedValue({ items: [] });
});
it("forces user read permission on when user write permission is enabled", async () => {
renderGlobalCustomClaimsPage();
const readSelect = await screen.findByTestId(
"global-claim-definition-read-permission-locale",
);
const writeSelect = await screen.findByTestId(
"global-claim-definition-write-permission-locale",
);
expect(readSelect).toHaveValue("admin_only");
expect(writeSelect).toHaveValue("admin_only");
fireEvent.change(writeSelect, { target: { value: "user_and_admin" } });
await waitFor(() => {
expect(readSelect).toHaveValue("user_and_admin");
expect(writeSelect).toHaveValue("user_and_admin");
});
fireEvent.click(screen.getByRole("button", { name: /저장|Save/ }));
await waitFor(() => {
expect(updateGlobalCustomClaimDefinitionsMock).toHaveBeenCalled();
});
expect(updateGlobalCustomClaimDefinitionsMock.mock.calls[0][0]).toEqual({
items: [
expect.objectContaining({
key: "locale",
readPermission: "user_and_admin",
writePermission: "user_and_admin",
}),
],
});
});
});

View File

@@ -0,0 +1,336 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Key, Plus, Save, Trash2, Users } from "lucide-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { toast } from "../../components/ui/use-toast";
import {
fetchGlobalCustomClaimDefinitions,
type GlobalCustomClaimDefinition,
type GlobalCustomClaimPermission,
updateGlobalCustomClaimDefinitions,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
type ClaimDraft = GlobalCustomClaimDefinition & { id: string };
const valueTypes: GlobalCustomClaimDefinition["valueType"][] = [
"text",
"number",
"boolean",
"array",
"object",
"date",
"datetime",
];
const permissions: GlobalCustomClaimPermission[] = [
"admin_only",
"user_and_admin",
];
function toDrafts(items: GlobalCustomClaimDefinition[]): ClaimDraft[] {
return items.map((item, index) => ({
id: `${item.key || "claim"}-${index}`,
key: item.key,
label: item.label,
valueType: item.valueType || "text",
readPermission: item.readPermission || "admin_only",
writePermission: item.writePermission || "admin_only",
description: item.description || "",
}));
}
function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
return drafts
.map((draft) => normalizeClaimDraftPermissions(draft))
.map((draft) => ({
key: draft.key.trim(),
label: draft.label.trim(),
valueType: draft.valueType,
readPermission: draft.readPermission,
writePermission: draft.writePermission,
description: draft.description?.trim(),
}))
.filter((draft) => draft.key.length > 0);
}
function normalizeClaimDraftPermissions(draft: ClaimDraft): ClaimDraft {
if (draft.writePermission !== "user_and_admin") {
return draft;
}
return {
...draft,
readPermission: "user_and_admin",
};
}
function permissionLabel(permission: GlobalCustomClaimPermission) {
return permission === "user_and_admin"
? t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)
: t("ui.common.custom_claim_permission.admin_only", "관리자만 가능");
}
export default function GlobalCustomClaimsPage() {
const queryClient = useQueryClient();
const [drafts, setDrafts] = React.useState<ClaimDraft[]>([]);
const query = useQuery({
queryKey: ["global-custom-claim-definitions"],
queryFn: fetchGlobalCustomClaimDefinitions,
});
React.useEffect(() => {
if (query.data) {
setDrafts(toDrafts(query.data.items));
}
}, [query.data]);
const mutation = useMutation({
mutationFn: updateGlobalCustomClaimDefinitions,
onSuccess: (data) => {
queryClient.setQueryData(["global-custom-claim-definitions"], data);
toast.success(t("msg.info.saved_success", "저장되었습니다."));
},
onError: () => {
toast.error(t("err.common.unknown", "오류가 발생했습니다."));
},
});
const addClaim = () => {
setDrafts((current) => [
...current,
{
id: `global-claim-${Date.now()}`,
key: "",
label: "",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
description: "",
},
]);
};
const updateClaim = (id: string, patch: Partial<ClaimDraft>) => {
setDrafts((current) =>
current.map((draft) =>
draft.id === id
? normalizeClaimDraftPermissions({ ...draft, ...patch })
: draft,
),
);
};
const removeClaim = (id: string) => {
setDrafts((current) => current.filter((draft) => draft.id !== id));
};
const saveClaims = () => {
mutation.mutate({ items: toDefinitions(drafts) });
};
return (
<div className="space-y-6">
<PageHeader
titleAs="h2"
icon={<Key size={20} />}
title={t(
"ui.admin.users.global_custom_claims.title",
"전역 Claim 설정",
)}
description={t(
"msg.admin.users.global_custom_claims.description",
"모든 RP에 공통 적용할 사용자 claim 정의와 사용자의 읽기/쓰기 권한 기본값을 관리합니다. 쓰기 허용 시 읽기도 자동으로 허용됩니다.",
)}
actions={
<>
<Button asChild variant="outline" size="sm" className="h-9">
<Link to="/users">
<Users size={16} />
{t("ui.admin.users.list.title", "사용자 관리")}
</Link>
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-2"
onClick={addClaim}
>
<Plus size={16} />
{t("ui.common.add", "추가")}
</Button>
<Button
type="button"
size="sm"
className="h-9 gap-2"
disabled={mutation.isPending}
onClick={saveClaims}
>
<Save size={16} />
{t("ui.common.save", "저장")}
</Button>
</>
}
/>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-lg">
{t(
"ui.admin.users.global_custom_claims.registry",
"Global Claim Registry",
)}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.global_custom_claims.registry",
"정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다. 읽기/쓰기는 관리자 권한이 아니라 사용자가 본인 claim 값을 조회하거나 수정할 수 있는지에 대한 설정입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{query.isLoading ? (
<div className="py-12 text-center text-sm text-muted-foreground">
{t("ui.common.loading", "로딩 중...")}
</div>
) : drafts.length === 0 ? (
<div className="rounded-lg border border-dashed py-12 text-center text-sm text-muted-foreground">
{t(
"msg.admin.users.global_custom_claims.empty",
"정의된 전역 claim이 없습니다.",
)}
</div>
) : (
drafts.map((claim) => (
<div
key={claim.id}
className="grid gap-3 rounded-md border bg-background p-3 lg:grid-cols-[minmax(160px,0.8fr)_minmax(160px,0.8fr)_130px_160px_160px_minmax(220px,1fr)_40px]"
>
<Input
value={claim.key}
name={`global-claim-definition-key-${claim.id}`}
className="font-mono text-xs"
placeholder="claim_key"
data-testid={`global-claim-definition-key-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, { key: event.target.value })
}
/>
<Input
value={claim.label}
name={`global-claim-definition-label-${claim.id}`}
placeholder={t(
"ui.admin.users.global_custom_claims.label_placeholder",
"표시 이름",
)}
data-testid={`global-claim-definition-label-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, { label: event.target.value })
}
/>
<select
aria-label={t(
"ui.admin.users.global_custom_claims.value_type",
"Claim 타입",
)}
value={claim.valueType}
name={`global-claim-definition-value-type-${claim.id}`}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
onChange={(event) =>
updateClaim(claim.id, {
valueType: event.target
.value as GlobalCustomClaimDefinition["valueType"],
})
}
>
{valueTypes.map((valueType) => (
<option key={valueType} value={valueType}>
{valueType}
</option>
))}
</select>
<select
aria-label={t(
"ui.admin.users.global_custom_claims.read_permission",
"읽기 권한",
)}
value={claim.readPermission}
name={`global-claim-definition-read-permission-${claim.id}`}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
data-testid={`global-claim-definition-read-permission-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, {
readPermission: event.target
.value as GlobalCustomClaimPermission,
})
}
>
{permissions.map((permission) => (
<option key={permission} value={permission}>
{permissionLabel(permission)}
</option>
))}
</select>
<select
aria-label={t(
"ui.admin.users.global_custom_claims.write_permission",
"쓰기 권한",
)}
value={claim.writePermission}
name={`global-claim-definition-write-permission-${claim.id}`}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
data-testid={`global-claim-definition-write-permission-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, {
writePermission: event.target
.value as GlobalCustomClaimPermission,
})
}
>
{permissions.map((permission) => (
<option key={permission} value={permission}>
{permissionLabel(permission)}
</option>
))}
</select>
<Input
value={claim.description || ""}
name={`global-claim-definition-description-${claim.id}`}
placeholder={t(
"ui.admin.users.global_custom_claims.description_placeholder",
"설명",
)}
onChange={(event) =>
updateClaim(claim.id, { description: event.target.value })
}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeClaim(claim.id)}
>
<Trash2 size={16} />
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -49,7 +49,11 @@ import {
type UserCreateResponse, type UserCreateResponse,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles"; import {
canManageTenantScopedUsers,
isSuperAdminRole,
normalizeAdminRole,
} from "../../lib/roles";
import { import {
buildAuthenticatedOrgChartTenantPickerUrl, buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants, filterNonHanmacFamilyTenants,
@@ -57,6 +61,7 @@ import {
type OrgChartTenantSelection, type OrgChartTenantSelection,
parseOrgChartTenantSelection, parseOrgChartTenantSelection,
} from "./orgChartPicker"; } from "./orgChartPicker";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import type { UserSchemaField } from "./userSchemaFields"; import type { UserSchemaField } from "./userSchemaFields";
import { resolvePersonalTenant } from "./utils/personalTenant"; import { resolvePersonalTenant } from "./utils/personalTenant";
@@ -154,6 +159,9 @@ function UserCreatePage() {
queryFn: fetchMe, queryFn: fetchMe,
}); });
const profileRole = normalizeAdminRole(profile?.role); const profileRole = normalizeAdminRole(profile?.role);
const canManageUsers =
canManageTenantScopedUsers(profile) ||
!!profile?.systemPermissions?.manage_users;
const { const {
register, register,
@@ -204,8 +212,12 @@ function UserCreatePage() {
// Lock company for non-super_admin // Lock company for non-super_admin
React.useEffect(() => { React.useEffect(() => {
if (profileRole !== "super_admin" && profile?.tenantSlug) { if (profileRole !== "super_admin") {
setValue("tenantSlug", profile.tenantSlug); const delegatedTenantSlug =
profile?.tenantSlug || profile?.manageableTenants?.[0]?.slug;
if (delegatedTenantSlug) {
setValue("tenantSlug", delegatedTenantSlug);
}
} }
}, [profile, profileRole, setValue]); }, [profile, profileRole, setValue]);
@@ -390,7 +402,7 @@ function UserCreatePage() {
}, },
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>) => {
setError( setError(
err.response?.data?.error || formatUserPolicyMessage(err.response?.data?.error) ||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."), t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
); );
}, },
@@ -524,8 +536,7 @@ function UserCreatePage() {
} }
}; };
// Access Control: Only super_admin can create users if (profile && !canManageUsers) {
if (profile && profileRole !== "super_admin") {
return ( return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4"> <div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<ShieldAlert size={48} className="text-destructive" /> <ShieldAlert size={48} className="text-destructive" />
@@ -712,6 +723,8 @@ function UserCreatePage() {
</Label> </Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground"> <label className="flex items-center gap-2 text-xs text-muted-foreground">
<input <input
id="auto-password"
name="auto-password"
type="checkbox" type="checkbox"
checked={autoPassword} checked={autoPassword}
onChange={(event) => setAutoPassword(event.target.checked)} onChange={(event) => setAutoPassword(event.target.checked)}
@@ -933,8 +946,12 @@ function UserCreatePage() {
data-testid={`appointment-tenant-picker-${index}`} data-testid={`appointment-tenant-picker-${index}`}
> >
<Building2 className="mr-2 h-4 w-4 shrink-0" /> <Building2 className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate"> <span className="pointer-events-none truncate">
{appointment.tenantName || "테넌트 선택"} {appointment.tenantName ||
t(
"ui.admin.users.create.form.pick_from_hanmac_family",
"한맥가족에서 선택",
)}
</span> </span>
</Button> </Button>
{appointment.tenantSlug && ( {appointment.tenantSlug && (

View File

@@ -7,68 +7,35 @@ import UserDetailPage from "./UserDetailPage";
const updateUserMock = vi.hoisted(() => vi.fn()); const updateUserMock = vi.hoisted(() => vi.fn());
const profileRoleMock = vi.hoisted(() => ({ role: "super_admin" })); const profileRoleMock = vi.hoisted(() => ({ role: "super_admin" }));
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
const fetchUserMock = vi.hoisted(() => vi.fn());
vi.mock("../../lib/i18n", () => createI18nMock()); vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({ vi.mock("../../lib/adminApi", () => ({
deleteUser: vi.fn(), deleteUser: vi.fn(),
fetchAllTenants: vi.fn(async () => ({ fetchAllTenants: fetchAllTenantsMock,
items: [
{
id: "tenant-hanmac",
type: "COMPANY",
name: "한맥기술",
slug: "hanmac",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
],
total: 1,
})),
fetchMe: vi.fn(async () => ({ fetchMe: vi.fn(async () => ({
id: "admin-user", id: "admin-user",
role: profileRoleMock.role, role: profileRoleMock.role,
name: "Admin", name: "Admin",
email: "admin@example.com", email: "admin@example.com",
})), })),
fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({
items: [
{
key: "contract_date",
label: "계약일",
valueType: "date",
readPermission: "admin_only",
writePermission: "admin_only",
description: "",
},
],
})),
fetchPasswordPolicy: vi.fn(async () => ({ minLength: 12 })), fetchPasswordPolicy: vi.fn(async () => ({ minLength: 12 })),
fetchTenant: vi.fn(), fetchTenant: vi.fn(),
fetchUser: vi.fn(async () => ({ fetchUser: fetchUserMock,
id: "user-1",
email: "user@example.com",
name: "사용자",
phone: "01012345678",
role: "user",
status: "active",
tenantSlug: "hanmac",
tenant: {
id: "tenant-hanmac",
type: "COMPANY",
name: "한맥기술",
slug: "hanmac",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
joinedTenants: [],
metadata: {
employee_id: {
"0": "h",
"1": "j",
"2": "k",
"3": "w",
"4": "o",
"5": "n",
},
},
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
})),
fetchUserRpHistory: vi.fn(async () => []), fetchUserRpHistory: vi.fn(async () => []),
updateUser: updateUserMock, updateUser: updateUserMock,
})); }));
@@ -93,6 +60,60 @@ describe("UserDetailPage Worksmobile employee number", () => {
beforeEach(() => { beforeEach(() => {
updateUserMock.mockReset(); updateUserMock.mockReset();
updateUserMock.mockResolvedValue({}); updateUserMock.mockResolvedValue({});
fetchAllTenantsMock.mockReset();
fetchAllTenantsMock.mockResolvedValue({
items: [
{
id: "tenant-hanmac",
type: "COMPANY",
name: "한맥기술",
slug: "hanmac",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
],
total: 1,
});
fetchUserMock.mockReset();
fetchUserMock.mockResolvedValue({
id: "user-1",
email: "user@example.com",
name: "사용자",
phone: "01012345678",
role: "user",
status: "active",
tenantSlug: "hanmac",
tenant: {
id: "tenant-hanmac",
type: "COMPANY",
name: "한맥기술",
slug: "hanmac",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
joinedTenants: [],
metadata: {
employee_id: {
"0": "h",
"1": "j",
"2": "k",
"3": "w",
"4": "o",
"5": "n",
},
global_custom_claims: {
contract_date: "2026-06-09",
},
},
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
});
profileRoleMock.role = "super_admin"; profileRoleMock.role = "super_admin";
}); });
@@ -152,4 +173,348 @@ describe("UserDetailPage Worksmobile employee number", () => {
const payload = updateUserMock.mock.calls[0][1]; const payload = updateUserMock.mock.calls[0][1];
expect(payload.metadata).not.toHaveProperty("employee_id"); expect(payload.metadata).not.toHaveProperty("employee_id");
}); });
it("shows non-private appointment tenants from metadata and hides private tenants", async () => {
fetchAllTenantsMock.mockResolvedValue({
items: [
{
id: "tenant-hanmac",
type: "COMPANY",
name: "한맥기술",
slug: "hanmac",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
{
id: "tenant-public",
type: "USER_GROUP",
name: "공개 TF",
slug: "public-tf",
description: "",
status: "active",
config: { visibility: "public" },
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
{
id: "tenant-internal",
type: "USER_GROUP",
name: "내부 조직",
slug: "internal-team",
description: "",
status: "active",
config: { visibility: "internal" },
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
{
id: "tenant-private",
type: "USER_GROUP",
name: "비공개 조직",
slug: "private-team",
description: "",
status: "active",
config: { visibility: "private" },
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
],
total: 4,
});
fetchUserMock.mockResolvedValue({
id: "user-1",
email: "user@example.com",
name: "사용자",
phone: "01012345678",
role: "user",
status: "active",
tenantSlug: "hanmac",
tenant: {
id: "tenant-hanmac",
type: "COMPANY",
name: "한맥기술",
slug: "hanmac",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
joinedTenants: [],
metadata: {
additionalAppointments: [
{
tenantId: "tenant-public",
tenantSlug: "public-tf",
tenantName: "공개 TF",
},
{
tenantId: "tenant-internal",
tenantSlug: "internal-team",
tenantName: "내부 조직",
},
{
tenantId: "tenant-private",
tenantSlug: "private-team",
tenantName: "비공개 조직",
},
],
},
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
});
renderUserDetailPage();
fireEvent.click(await screen.findByRole("tab", { name: /테넌트 프로필/ }));
expect(await screen.findByText("공개 TF")).toBeInTheDocument();
expect(screen.getByText("내부 조직")).toBeInTheDocument();
expect(screen.queryByText("비공개 조직")).not.toBeInTheDocument();
});
it("only allows editing per-user values for globally defined custom claims", async () => {
renderUserDetailPage();
const tab = await screen.findByTestId("global-custom-claim-tab");
fireEvent.click(tab);
expect(
screen.queryByRole("button", { name: "추가" }),
).not.toBeInTheDocument();
const valueInput = await screen.findByTestId(
"global-custom-claim-value-contract_date",
);
expect(screen.getByText("contract_date")).toBeInTheDocument();
expect(valueInput).toHaveValue("2026-06-09");
expect(valueInput).toHaveAttribute("type", "date");
fireEvent.change(valueInput, { target: { value: "2026-07-01" } });
fireEvent.click(
screen.getByRole("button", { name: /사용자 Claim 값 저장/ }),
);
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
expect(updateUserMock).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({
metadata: expect.objectContaining({
global_custom_claims: expect.objectContaining({
contract_date: "2026-07-01",
}),
global_custom_claim_permissions: expect.objectContaining({
contract_date: {
readPermission: "admin_only",
writePermission: "admin_only",
},
}),
}),
}),
);
});
it("does not reveal the manually entered password after a successful reset", async () => {
renderUserDetailPage();
fireEvent.click(await screen.findByRole("tab", { name: "보안 & 활동" }));
fireEvent.click(screen.getByRole("button", { name: "초기화 도구" }));
fireEvent.click(screen.getByRole("tab", { name: "직접 입력" }));
const passwordInputs = document.querySelectorAll('input[type="password"]');
expect(passwordInputs).toHaveLength(2);
fireEvent.change(passwordInputs[0], {
target: { value: "ManualPass123!" },
});
fireEvent.change(passwordInputs[1], {
target: { value: "ManualPass123!" },
});
fireEvent.click(screen.getByRole("button", { name: "재설정 완료" }));
await waitFor(() =>
expect(updateUserMock).toHaveBeenCalledWith("user-1", {
password: "ManualPass123!",
}),
);
expect(screen.queryByText("ManualPass123!")).not.toBeInTheDocument();
expect(
document.querySelectorAll('input[value="ManualPass123!"]'),
).toHaveLength(0);
});
it("preserves per-user global custom claim permissions instead of overwriting them from definitions", async () => {
fetchUserMock.mockResolvedValueOnce({
id: "user-1",
email: "user@example.com",
name: "사용자",
phone: "01012345678",
role: "user",
status: "active",
tenantSlug: "hanmac",
tenant: {
id: "tenant-hanmac",
type: "COMPANY",
name: "한맥기술",
slug: "hanmac",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
joinedTenants: [],
metadata: {
global_custom_claims: {
contract_date: "2026-06-09",
},
global_custom_claim_types: {
contract_date: "date",
},
global_custom_claim_permissions: {
contract_date: {
readPermission: "user_and_admin",
writePermission: "user_and_admin",
},
},
},
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
});
renderUserDetailPage();
const tab = await screen.findByTestId("global-custom-claim-tab");
fireEvent.click(tab);
const valueInput = await screen.findByTestId(
"global-custom-claim-value-contract_date",
);
expect(screen.getAllByText("사용자 및 관리자 가능").length).toBeGreaterThan(
0,
);
fireEvent.change(valueInput, { target: { value: "2026-07-01" } });
fireEvent.click(
screen.getByRole("button", { name: /사용자 Claim 값 저장/ }),
);
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
expect(updateUserMock).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({
metadata: expect.objectContaining({
global_custom_claims: expect.objectContaining({
contract_date: "2026-07-01",
}),
global_custom_claim_permissions: expect.objectContaining({
contract_date: {
readPermission: "user_and_admin",
writePermission: "user_and_admin",
},
}),
}),
}),
);
});
it("defaults a Hanmac family member to the Hanmac family tenant tab and does not show the external company tab", async () => {
fetchAllTenantsMock.mockResolvedValue({
items: [
{
id: "hanmac-root-id",
type: "COMPANY_GROUP",
name: "한맥가족",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
{
id: "hanmac-team-id",
type: "USER_GROUP",
name: "한맥팀",
slug: "hanmac-team",
parentId: "hanmac-root-id",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
{
id: "commercial-root-id",
type: "COMPANY_GROUP",
name: "Commercial",
slug: "commercial",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
],
total: 3,
});
fetchUserMock.mockResolvedValue({
id: "user-1",
email: "user@example.com",
name: "사용자",
phone: "01012345678",
role: "user",
status: "active",
tenantSlug: "hanmac-team",
tenant: {
id: "hanmac-team-id",
type: "USER_GROUP",
name: "한맥팀",
slug: "hanmac-team",
parentId: "hanmac-root-id",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
joinedTenants: [],
metadata: {},
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
});
renderUserDetailPage();
await screen.findByRole("tab", { name: "한맥가족" });
const tenantTabs = screen
.getAllByRole("tab")
.filter((tab) =>
["한맥가족", "일반회사", "공공기관", "교육기관", "개인"].includes(
tab.textContent?.trim() ?? "",
),
);
expect(tenantTabs.map((tab) => tab.textContent?.trim())).toEqual([
"한맥가족",
"일반회사",
"공공기관",
"교육기관",
"개인",
]);
expect(screen.getByRole("tab", { name: "한맥가족" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(
screen.queryByRole("tab", { name: /외부 기업 회원/i }),
).not.toBeInTheDocument();
});
}); });

View File

@@ -60,10 +60,14 @@ import {
TabsTrigger, TabsTrigger,
} from "../../components/ui/tabs"; } from "../../components/ui/tabs";
import { toast } from "../../components/ui/use-toast"; import { toast } from "../../components/ui/use-toast";
import type { PasswordPolicyResponse } from "../../lib/adminApi"; import type {
GlobalCustomClaimDefinition,
PasswordPolicyResponse,
} from "../../lib/adminApi";
import { import {
deleteUser, deleteUser,
fetchAllTenants, fetchAllTenants,
fetchGlobalCustomClaimDefinitions,
fetchMe, fetchMe,
fetchPasswordPolicy, fetchPasswordPolicy,
fetchTenant, fetchTenant,
@@ -75,17 +79,23 @@ import {
updateUser, updateUser,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { normalizeAdminRole } from "../../lib/roles"; import {
canManageUserInTenantScope,
normalizeAdminRole,
} from "../../lib/roles";
import { generateSecurePassword } from "../../lib/utils"; import { generateSecurePassword } from "../../lib/utils";
import { import {
buildAuthenticatedOrgChartTenantPickerUrl, buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants, filterTenantsByMembershipRoot,
getTenantGradeOptions, getTenantGradeOptions,
isHanmacFamilyTenant, isHanmacFamilyTenant,
isHanmacFamilyUser,
type OrgChartTenantSelection, type OrgChartTenantSelection,
parseOrgChartTenantSelection, parseOrgChartTenantSelection,
resolveUserMembershipTenantTab,
USER_MEMBERSHIP_TENANT_TABS,
type UserMembershipTenantTabId,
} from "./orgChartPicker"; } from "./orgChartPicker";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import type { UserSchemaField } from "./userSchemaFields"; import type { UserSchemaField } from "./userSchemaFields";
import { import {
normalizeUserStatusValue, normalizeUserStatusValue,
@@ -101,13 +111,32 @@ type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
sub_email?: string | string[]; sub_email?: string | string[];
}; };
}; };
type UserCategory = "hanmac" | "external" | "personal"; type UserCategory = UserMembershipTenantTabId;
type PasswordResetMode = "generated" | "manual"; type PasswordResetMode = "generated" | "manual";
type PickerTarget = { kind: "appointment"; index: number }; type PickerTarget = { kind: "appointment"; index: number };
type AppointmentDraft = UserAppointment & { type AppointmentDraft = UserAppointment & {
draftId: string; draftId: string;
}; };
type GlobalCustomClaimType =
| "text"
| "number"
| "boolean"
| "array"
| "object"
| "date"
| "datetime";
type CustomClaimPermission = "admin_only" | "user_and_admin";
type GlobalCustomClaimRow = {
id: string;
key: string;
label: string;
value: string;
valueType: GlobalCustomClaimType;
readPermission: CustomClaimPermission;
writePermission: CustomClaimPermission;
description?: string;
};
const PASSWORD_RESET_MIN_LENGTH = 12; const PASSWORD_RESET_MIN_LENGTH = 12;
@@ -115,6 +144,15 @@ function isMetadataRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
function normalizeCustomClaimPermission(
value: unknown,
fallback: CustomClaimPermission,
): CustomClaimPermission {
return value === "admin_only" || value === "user_and_admin"
? value
: fallback;
}
function cleanMetadataValue(value: unknown): unknown { function cleanMetadataValue(value: unknown): unknown {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value return value
@@ -176,6 +214,89 @@ function createDraftId() {
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`; return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
} }
function createGlobalCustomClaimRows(
metadata: Record<string, unknown>,
definitions: GlobalCustomClaimDefinition[],
): GlobalCustomClaimRow[] {
const rawClaims = isMetadataRecord(metadata.global_custom_claims)
? metadata.global_custom_claims
: {};
const rawPermissions = isMetadataRecord(
metadata.global_custom_claim_permissions,
)
? metadata.global_custom_claim_permissions
: {};
return definitions.map((definition, index) => {
const value = rawClaims[definition.key];
const rawPermission = rawPermissions[definition.key];
const permission: Record<string, unknown> = isMetadataRecord(rawPermission)
? rawPermission
: {};
return {
id: `${definition.key}-${index}`,
key: definition.key,
label: definition.label,
description: definition.description,
value:
typeof value === "string"
? value
: value == null
? ""
: JSON.stringify(value),
valueType: definition.valueType,
readPermission: normalizeCustomClaimPermission(
permission.readPermission,
definition.readPermission,
),
writePermission: normalizeCustomClaimPermission(
permission.writePermission,
definition.writePermission,
),
};
});
}
function globalCustomClaimInputType(valueType: GlobalCustomClaimType) {
if (valueType === "date") {
return "date";
}
if (valueType === "datetime") {
return "datetime-local";
}
if (valueType === "number") {
return "number";
}
return "text";
}
function globalCustomClaimRowsToMetadata(rows: GlobalCustomClaimRow[]) {
const claims: Record<string, unknown> = {};
const types: Record<string, GlobalCustomClaimType> = {};
const permissions: Record<
string,
{
readPermission: CustomClaimPermission;
writePermission: CustomClaimPermission;
}
> = {};
for (const row of rows) {
const key = row.key.trim();
if (!key) {
continue;
}
claims[key] = row.value.trim();
types[key] = row.valueType;
permissions[key] = {
readPermission: row.readPermission,
writePermission: row.writePermission,
};
}
return { claims, types, permissions };
}
async function resolveTenantSelection( async function resolveTenantSelection(
selection: OrgChartTenantSelection, selection: OrgChartTenantSelection,
tenants: TenantSummary[], tenants: TenantSummary[],
@@ -197,6 +318,48 @@ async function resolveTenantSelection(
}; };
} }
function getTenantVisibility(tenant?: TenantSummary) {
const value = tenant?.config?.visibility;
return typeof value === "string" ? value.trim().toLowerCase() : "public";
}
function isPrivateTenant(tenant?: TenantSummary) {
return getTenantVisibility(tenant) === "private";
}
function appointmentTenantsFromMetadata(
metadata: Record<string, unknown> | undefined,
tenants: TenantSummary[],
) {
const rawAppointments = metadata?.additionalAppointments;
if (!Array.isArray(rawAppointments)) {
return [];
}
return rawAppointments
.map((raw) => {
if (!raw || typeof raw !== "object") {
return null;
}
const appointment = raw as Record<string, unknown>;
const tenantId =
typeof appointment.tenantId === "string" ? appointment.tenantId : "";
const tenantSlug =
typeof appointment.tenantSlug === "string"
? appointment.tenantSlug
: typeof appointment.slug === "string"
? appointment.slug
: "";
return tenants.find(
(tenant) =>
(tenantId && tenant.id === tenantId) ||
(tenantSlug && tenant.slug === tenantSlug),
);
})
.filter((tenant): tenant is TenantSummary => Boolean(tenant))
.filter((tenant) => !isPrivateTenant(tenant));
}
function createEmptyAppointment(): AppointmentDraft { function createEmptyAppointment(): AppointmentDraft {
return { return {
draftId: createDraftId(), draftId: createDraftId(),
@@ -291,8 +454,6 @@ function TenantMetadataFields({
register: UseFormRegister<UserFormValues>; register: UseFormRegister<UserFormValues>;
errors: FieldErrors<UserFormValues>; errors: FieldErrors<UserFormValues>;
}) { }) {
if (schema.length === 0) return null;
return ( return (
<div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm"> <div className="rounded-xl border border-border bg-card overflow-hidden shadow-sm">
<div className="bg-muted/30 px-5 py-3 border-b border-border flex items-center justify-between"> <div className="bg-muted/30 px-5 py-3 border-b border-border flex items-center justify-between">
@@ -307,74 +468,85 @@ function TenantMetadataFields({
</span> </span>
</div> </div>
<div className="p-6 grid gap-6 md:grid-cols-2"> <div className="p-6 grid gap-6 md:grid-cols-2">
{schema.map((field) => ( {schema.length === 0 ? (
<div key={field.key} className="space-y-2"> <p className="text-sm text-muted-foreground md:col-span-2">
<Label {t(
htmlFor={`metadata.${tenant.id}.${field.key}`} "msg.admin.users.detail.tenant_schema_empty",
className="text-xs font-semibold text-muted-foreground flex items-center gap-1" "이 테넌트에 설정된 프로필 필드가 없습니다.",
>
{field.label}
{field.required && <span className="text-destructive">*</span>}
{field.adminOnly && (
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
Admin Only
</span>
)}
{field.isLoginId && (
<span className="ml-2 text-[9px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
{t("ui.admin.users.detail.form.is_login_id", "로그인 ID")}
</span>
)}
</Label>
<Input
id={`metadata.${tenant.id}.${field.key}`}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={field.type === "boolean" ? "w-5 h-5" : "h-10 text-sm"}
{...register(`metadata.${tenant.id}.${field.key}` as const, {
required: field.required
? t(
"msg.admin.users.detail.form.field_required",
"필수입니다.",
)
: false,
pattern: field.validation
? {
value: new RegExp(field.validation),
message: t(
"msg.admin.users.detail.form.invalid_format",
"형식이 올바르지 않습니다.",
),
}
: undefined,
})}
/>
{(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key] && (
<p className="text-[10px] text-destructive font-medium">
{
(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key]?.message
}
</p>
)} )}
</div> </p>
))} ) : (
schema.map((field) => (
<div key={field.key} className="space-y-2">
<Label
htmlFor={`metadata.${tenant.id}.${field.key}`}
className="text-xs font-semibold text-muted-foreground flex items-center gap-1"
>
{field.label}
{field.required && <span className="text-destructive">*</span>}
{field.adminOnly && (
<span className="ml-2 text-[9px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold">
Admin Only
</span>
)}
{field.isLoginId && (
<span className="ml-2 text-[9px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
{t("ui.admin.users.detail.form.is_login_id", "로그인 ID")}
</span>
)}
</Label>
<Input
id={`metadata.${tenant.id}.${field.key}`}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={
field.type === "boolean" ? "w-5 h-5" : "h-10 text-sm"
}
{...register(`metadata.${tenant.id}.${field.key}` as const, {
required: field.required
? t(
"msg.admin.users.detail.form.field_required",
"필수입니다.",
)
: false,
pattern: field.validation
? {
value: new RegExp(field.validation),
message: t(
"msg.admin.users.detail.form.invalid_format",
"형식이 올바르지 않습니다.",
),
}
: undefined,
})}
/>
{(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key] && (
<p className="text-[10px] text-destructive font-medium">
{
(
errors.metadata as unknown as Record<
string,
Record<string, { message?: string }>
>
)?.[tenant.id]?.[field.key]?.message
}
</p>
)}
</div>
))
)}
</div> </div>
</div> </div>
); );
@@ -401,10 +573,13 @@ function UserDetailPage() {
string | null string | null
>(null); >(null);
const [userCategory, setUserCategory] = const [userCategory, setUserCategory] =
React.useState<UserCategory>("external"); React.useState<UserCategory>("hanmac-family");
const [additionalAppointments, setAdditionalAppointments] = React.useState< const [additionalAppointments, setAdditionalAppointments] = React.useState<
AppointmentDraft[] AppointmentDraft[]
>([]); >([]);
const [globalCustomClaimRows, setGlobalCustomClaimRows] = React.useState<
GlobalCustomClaimRow[]
>([]);
const [pickerTarget, setPickerTarget] = React.useState<PickerTarget | null>( const [pickerTarget, setPickerTarget] = React.useState<PickerTarget | null>(
null, null,
); );
@@ -446,6 +621,14 @@ function UserDetailPage() {
queryKey: ["password-policy"], queryKey: ["password-policy"],
queryFn: fetchPasswordPolicy, queryFn: fetchPasswordPolicy,
}); });
const { data: globalCustomClaimDefinitionsData } = useQuery({
queryKey: ["global-custom-claim-definitions"],
queryFn: fetchGlobalCustomClaimDefinitions,
});
const globalCustomClaimDefinitions = React.useMemo(
() => globalCustomClaimDefinitionsData?.items ?? [],
[globalCustomClaimDefinitionsData?.items],
);
const { const {
register, register,
@@ -472,6 +655,18 @@ function UserDetailPage() {
const profileRole = normalizeAdminRole(profile?.role); const profileRole = normalizeAdminRole(profile?.role);
const isAdmin = profileRole === "super_admin"; const isAdmin = profileRole === "super_admin";
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id); const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
const canManageCurrentUser = canManageUserInTenantScope({ profile, user });
const isWritable =
isAdmin ||
isSelf ||
canManageCurrentUser ||
!!profile?.systemPermissions?.manage_users;
const canViewUser =
isAdmin ||
isSelf ||
canManageCurrentUser ||
!!profile?.systemPermissions?.users ||
!!profile?.systemPermissions?.manage_users;
const watchedStatus = watch("status"); const watchedStatus = watch("status");
const [newSubEmail, setNewSubEmail] = React.useState(""); const [newSubEmail, setNewSubEmail] = React.useState("");
@@ -499,9 +694,18 @@ function UserDetailPage() {
}; };
const resetMutation = useMutation({ const resetMutation = useMutation({
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }), mutationFn: ({ password }: { password: string; mode: PasswordResetMode }) =>
onSuccess: (_, newPass) => { updateUser(userId, { password }),
setGeneratedPassword(newPass); onSuccess: (_, { password, mode }) => {
if (mode === "manual") {
setGeneratedPassword(null);
setManualPassword("");
setManualPasswordConfirm("");
setIsManualPasswordVisible(false);
setIsPasswordResetOpen(false);
} else {
setGeneratedPassword(password);
}
setPasswordResetError(null); setPasswordResetError(null);
toast.success( toast.success(
t( t(
@@ -560,7 +764,7 @@ function UserDetailPage() {
newPass = generateSecurePassword(); newPass = generateSecurePassword();
} }
resetMutation.mutate(newPass); resetMutation.mutate({ password: newPass, mode: passwordResetMode });
}; };
const hanmacFamilyTenantId = React.useMemo(() => { const hanmacFamilyTenantId = React.useMemo(() => {
@@ -578,7 +782,8 @@ function UserDetailPage() {
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL, import.meta.env.ORGFRONT_URL,
{ {
tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined, tenantId:
userCategory === "hanmac-family" ? hanmacFamilyTenantId : undefined,
}, },
); );
@@ -669,7 +874,7 @@ function UserDetailPage() {
const handleUserCategoryChange = (value: string) => { const handleUserCategoryChange = (value: string) => {
const nextCategory = value as UserCategory; const nextCategory = value as UserCategory;
setUserCategory(nextCategory); setUserCategory(nextCategory);
if (nextCategory !== "hanmac") { if (nextCategory !== "hanmac-family") {
setAdditionalAppointments([]); setAdditionalAppointments([]);
} }
}; };
@@ -737,22 +942,15 @@ function UserDetailPage() {
: [], : [],
} as UserFormValues["metadata"], } as UserFormValues["metadata"],
}); });
const isUserHanmacFamily = isHanmacFamilyUser( const resolvedUserCategory = resolveUserMembershipTenantTab(
user, user,
tenants, tenants,
hanmacFamilyTenantId, ).id;
); const isUserHanmacFamily = resolvedUserCategory === "hanmac-family";
const isPersonalUser =
user.tenantSlug === personalTenant.slug ||
user.tenant?.id === personalTenant.id ||
user.tenant?.slug === personalTenant.slug ||
metadata.personalTenantId === personalTenant.id;
const resolvedUserCategory = isPersonalUser
? "personal"
: isUserHanmacFamily
? "hanmac"
: "external";
setUserCategory(resolvedUserCategory); setUserCategory(resolvedUserCategory);
setGlobalCustomClaimRows(
createGlobalCustomClaimRows(metadata, globalCustomClaimDefinitions),
);
const familyFallbackTenants = [ const familyFallbackTenants = [
...(user.joinedTenants ?? []), ...(user.joinedTenants ?? []),
...(user.tenant ? [user.tenant] : []), ...(user.tenant ? [user.tenant] : []),
@@ -810,7 +1008,13 @@ function UserDetailPage() {
: [], : [],
); );
} }
}, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]); }, [
globalCustomClaimDefinitions,
hanmacFamilyTenantId,
tenants,
user,
reset,
]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data), mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
@@ -821,7 +1025,7 @@ function UserDetailPage() {
}, },
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>) => {
toast.error( toast.error(
err.response?.data?.error || formatUserPolicyMessage(err.response?.data?.error) ||
t("err.common.unknown", "오류가 발생했습니다."), t("err.common.unknown", "오류가 발생했습니다."),
); );
}, },
@@ -887,6 +1091,7 @@ function UserDetailPage() {
try { try {
const tenant = await ensurePersonalTenant(); const tenant = await ensurePersonalTenant();
payload.tenantSlug = tenant.slug; payload.tenantSlug = tenant.slug;
payload.isPrimaryTenant = true;
payload.department = undefined; payload.department = undefined;
payload.grade = undefined; payload.grade = undefined;
payload.position = undefined; payload.position = undefined;
@@ -901,7 +1106,7 @@ function UserDetailPage() {
} }
} }
if (userCategory === "hanmac") { if (userCategory === "hanmac-family") {
const appointments = additionalAppointments const appointments = additionalAppointments
.filter((appointment) => appointment.tenantId) .filter((appointment) => appointment.tenantId)
.map((appointment) => ({ .map((appointment) => ({
@@ -920,6 +1125,7 @@ function UserDetailPage() {
const primary = appointments.find((a) => a.isPrimary); const primary = appointments.find((a) => a.isPrimary);
if (primary) { if (primary) {
payload.tenantSlug = primary.tenantSlug; payload.tenantSlug = primary.tenantSlug;
payload.isPrimaryTenant = true;
payload.primaryTenantId = primary.tenantId; payload.primaryTenantId = primary.tenantId;
payload.primaryTenantName = primary.tenantName; payload.primaryTenantName = primary.tenantName;
metadata.primaryTenantId = primary.tenantId; metadata.primaryTenantId = primary.tenantId;
@@ -942,6 +1148,7 @@ function UserDetailPage() {
primaryTenantSlug: primary?.tenantSlug, primaryTenantSlug: primary?.tenantSlug,
}; };
payload.tenantSlug = primary?.tenantSlug; payload.tenantSlug = primary?.tenantSlug;
payload.isPrimaryTenant = primary ? true : undefined;
payload.primaryTenantId = primary?.tenantId; payload.primaryTenantId = primary?.tenantId;
payload.primaryTenantName = primary?.tenantName; payload.primaryTenantName = primary?.tenantName;
} }
@@ -959,20 +1166,65 @@ function UserDetailPage() {
} }
}; };
const updateGlobalCustomClaimRow = (
id: string,
patch: Partial<GlobalCustomClaimRow>,
) => {
setGlobalCustomClaimRows((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
};
const saveGlobalCustomClaims = () => {
const { claims, types, permissions } = globalCustomClaimRowsToMetadata(
globalCustomClaimRows,
);
mutation.mutate({
metadata: {
...((user?.metadata as Record<string, unknown> | undefined) ?? {}),
global_custom_claims: claims,
global_custom_claim_types: types,
global_custom_claim_permissions: permissions,
},
});
};
const userAffiliatedTenants = React.useMemo(() => { const userAffiliatedTenants = React.useMemo(() => {
const joined = user?.joinedTenants || []; const joined = user?.joinedTenants || [];
const primary = user?.tenant; const primary = user?.tenant;
const all = [...joined]; const appointmentTenants = appointmentTenantsFromMetadata(
if (primary && !joined.some((t) => t.id === primary.id)) { user?.metadata as Record<string, unknown> | undefined,
tenants,
);
const all = joined.filter((tenant) => {
const fullTenant = tenants.find((item) => item.id === tenant.id);
return !isPrivateTenant(fullTenant ?? tenant);
});
if (
primary &&
!isPrivateTenant(
tenants.find((tenant) => tenant.id === primary.id) ?? primary,
) &&
!all.some((t) => t.id === primary.id)
) {
all.unshift(primary); all.unshift(primary);
} }
for (const tenant of appointmentTenants) {
if (!all.some((item) => item.id === tenant.id)) {
all.push(tenant);
}
}
return all; return all;
}, [user?.joinedTenants, user?.tenant]); }, [tenants, user?.joinedTenants, user?.metadata, user?.tenant]);
const selectableRepresentativeTenants = React.useMemo( const selectableRepresentativeTenants = React.useMemo(
() => () =>
filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId), userCategory === "hanmac-family" || userCategory === "personal"
[userAffiliatedTenants, hanmacFamilyTenantId], ? []
: filterTenantsByMembershipRoot(tenants, userCategory),
[tenants, userCategory],
); );
const isRepresentativeTenantCategory =
userCategory !== "hanmac-family" && userCategory !== "personal";
if (isLoading) { if (isLoading) {
return ( return (
@@ -999,8 +1251,7 @@ function UserDetailPage() {
); );
} }
// Access Control: Only super_admin or self can view details if (profile && !canViewUser) {
if (!isAdmin && !isSelf) {
return ( return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4"> <div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<ShieldAlert size={48} className="text-destructive" /> <ShieldAlert size={48} className="text-destructive" />
@@ -1118,6 +1369,17 @@ function UserDetailPage() {
<Building2 size={16} className="mr-2" /> <Building2 size={16} className="mr-2" />
{t("ui.admin.users.detail.tabs.tenants", "테넌트 프로필")} {t("ui.admin.users.detail.tabs.tenants", "테넌트 프로필")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="customClaims"
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
data-testid="global-custom-claim-tab"
>
<Key size={16} className="mr-2" />
{t(
"ui.admin.users.detail.tabs.custom_claims",
"전역 Custom Claims",
)}
</TabsTrigger>
<TabsTrigger <TabsTrigger
value="security" value="security"
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm" className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
@@ -1349,28 +1611,19 @@ function UserDetailPage() {
className="space-y-4 pt-6 border-t border-dashed" className="space-y-4 pt-6 border-t border-dashed"
> >
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground"> <TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
<TabsTrigger {USER_MEMBERSHIP_TENANT_TABS.map((tab) => (
value="hanmac" <TabsTrigger
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none" key={tab.id}
> value={tab.id}
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
</TabsTrigger> >
<TabsTrigger {tab.label}
value="external" </TabsTrigger>
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none" ))}
>
</TabsTrigger>
<TabsTrigger
value="personal"
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
>
</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
{userCategory === "external" && ( {isRepresentativeTenantCategory && (
<div className="grid gap-8 md:grid-cols-2"> <div className="grid gap-8 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
@@ -1414,7 +1667,7 @@ function UserDetailPage() {
</div> </div>
)} )}
{userCategory === "hanmac" && ( {userCategory === "hanmac-family" && (
<div className="space-y-4 rounded-md border p-4"> <div className="space-y-4 rounded-md border p-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-3"> <div className="space-y-3">
@@ -1636,7 +1889,7 @@ function UserDetailPage() {
</div> </div>
)} )}
{userCategory === "external" && ( {isRepresentativeTenantCategory && (
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t"> <div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
@@ -1698,22 +1951,24 @@ function UserDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
<div className="flex justify-end pt-4"> {isWritable && (
<Button <div className="flex justify-end pt-4">
type="submit" <Button
disabled={mutation.isPending} type="submit"
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105" disabled={mutation.isPending}
> className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
{mutation.isPending ? ( >
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> {mutation.isPending ? (
) : ( <Loader2 className="mr-2 h-5 w-5 animate-spin" />
<Save className="mr-2 h-5 w-5" /> ) : (
)} <Save className="mr-2 h-5 w-5" />
<span className="text-base font-bold"> )}
{t("ui.admin.users.detail.save", "저장하기")} <span className="text-base font-bold">
</span> {t("ui.admin.users.detail.save", "저장하기")}
</Button> </span>
</div> </Button>
</div>
)}
</TabsContent> </TabsContent>
<TabsContent <TabsContent
@@ -1790,6 +2045,135 @@ function UserDetailPage() {
</Button> </Button>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent
value="customClaims"
className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2"
>
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Key size={18} className="text-primary" />
{t(
"ui.admin.users.detail.custom_claims.title",
"사용자별 Custom Claim 값",
)}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.detail.custom_claims.description",
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. 읽기/쓰기 표시는 사용자가 본인 claim 값을 조회하거나 직접 수정할 수 있는지에 대한 권한이며, claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
)}
</CardDescription>
</div>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => navigate("/users/custom-claims")}
>
<Key className="h-4 w-4" />
{t(
"ui.admin.users.global_custom_claims.manage_definitions",
"전역 정의 관리",
)}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 p-8">
{globalCustomClaimRows.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed bg-muted/5 py-12 text-center text-sm text-muted-foreground">
{t(
"msg.admin.users.detail.custom_claims.empty",
"전역으로 정의된 custom claim이 없습니다.",
)}
</div>
) : (
<div className="space-y-3">
{globalCustomClaimRows.map((claim) => (
<div
key={claim.id}
className="grid gap-3 lg:grid-cols-[minmax(180px,0.8fr)_130px_150px_160px_minmax(220px,1fr)]"
>
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
{claim.key}
</div>
<Badge
variant="muted"
className="h-10 justify-center rounded-md px-3 font-mono text-xs"
>
{claim.valueType}
</Badge>
<Badge
variant="muted"
className="h-10 justify-center rounded-md px-3 text-xs"
>
{claim.readPermission === "user_and_admin"
? t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)
: t(
"ui.common.custom_claim_permission.admin_only",
"관리자만 가능",
)}
</Badge>
<Badge
variant="muted"
className="h-10 justify-center rounded-md px-3 text-xs"
>
{claim.writePermission === "user_and_admin"
? t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)
: t(
"ui.common.custom_claim_permission.admin_only",
"관리자만 가능",
)}
</Badge>
<Input
type={globalCustomClaimInputType(claim.valueType)}
value={claim.value}
onChange={(event) =>
updateGlobalCustomClaimRow(claim.id, {
value: event.target.value,
})
}
className="font-mono text-xs"
data-testid={`global-custom-claim-value-${claim.key || claim.id}`}
placeholder="claim value"
/>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end pt-4">
<Button
type="button"
disabled={mutation.isPending}
onClick={saveGlobalCustomClaims}
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
>
{mutation.isPending ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Save className="mr-2 h-5 w-5" />
)}
<span className="text-base font-bold">
{t(
"ui.admin.users.detail.custom_claims.save",
"사용자 Claim 값 저장",
)}
</span>
</Button>
</div>
</TabsContent>
</form> </form>
<TabsContent <TabsContent

View File

@@ -22,8 +22,9 @@ const users = Array.from({ length: 200 }, (_, index) => ({
})); }));
const fetchUsersMock = vi.hoisted(() => vi.fn()); const fetchUsersMock = vi.hoisted(() => vi.fn());
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
const searchRenderBudgetMs = const searchRenderBudgetMs =
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 200; process.env.npm_lifecycle_event === "test:coverage" ? 500 : 300;
vi.mock("../../lib/i18n", () => createI18nMock()); vi.mock("../../lib/i18n", () => createI18nMock());
@@ -34,10 +35,7 @@ vi.mock("../../lib/adminApi", () => ({
name: "Admin", name: "Admin",
email: "admin@example.com", email: "admin@example.com",
})), })),
fetchAllTenants: vi.fn(async () => ({ fetchAllTenants: fetchAllTenantsMock,
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
total: 1,
})),
fetchTenant: vi.fn(async () => ({ fetchTenant: vi.fn(async () => ({
id: "tenant-1", id: "tenant-1",
name: "한맥", name: "한맥",
@@ -108,6 +106,11 @@ describe("UserListPage search rendering", () => {
beforeEach(() => { beforeEach(() => {
selectRenderCounter.count = 0; selectRenderCounter.count = 0;
fetchUsersMock.mockReset(); fetchUsersMock.mockReset();
fetchAllTenantsMock.mockReset();
fetchAllTenantsMock.mockResolvedValue({
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
total: 1,
});
fetchUsersMock.mockImplementation( fetchUsersMock.mockImplementation(
async (_limit: number, _offset: number, search?: string) => { async (_limit: number, _offset: number, search?: string) => {
const normalizedSearch = search?.trim().toLowerCase(); const normalizedSearch = search?.trim().toLowerCase();
@@ -127,7 +130,7 @@ describe("UserListPage search rendering", () => {
renderUserListPage(); renderUserListPage();
await screen.findByText("User 0"); await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색..."); const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
const renderCountBeforeTyping = selectRenderCounter.count; const renderCountBeforeTyping = selectRenderCounter.count;
fireEvent.change(searchInput, { target: { value: "u" } }); fireEvent.change(searchInput, { target: { value: "u" } });
@@ -136,6 +139,19 @@ describe("UserListPage search rendering", () => {
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping); expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
}); });
it("describes the user list as identity mirror backed, not local DB backed", async () => {
renderUserListPage();
await screen.findByText("User 0");
expect(
screen.getByText(
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
),
).toBeInTheDocument();
expect(screen.queryByText(/Local DB/)).not.toBeInTheDocument();
});
it("keeps rendered row controls below the full 200-user result set", async () => { it("keeps rendered row controls below the full 200-user result set", async () => {
renderUserListPage(); renderUserListPage();
@@ -157,6 +173,91 @@ describe("UserListPage search rendering", () => {
expect(content).toHaveClass("flex", "h-full", "items-center"); expect(content).toHaveClass("flex", "h-full", "items-center");
}); });
it("does not render private additional tenant appointments in the tenant column", async () => {
fetchUsersMock.mockResolvedValueOnce({
items: [
{
...users[0],
name: "Additional Tenant User",
metadata: {
additionalAppointments: [
{
tenantId: "tenant-2",
tenantSlug: "private-team",
tenantName: "비공개 팀",
isPrimary: false,
},
],
},
},
],
total: 1,
});
renderUserListPage();
expect(
await screen.findByText("Additional Tenant User"),
).toBeInTheDocument();
expect(screen.getAllByText("한맥").length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument();
});
it("excludes private tenants when choosing the representative tenant for the user list", async () => {
fetchAllTenantsMock.mockResolvedValueOnce({
items: [
{
id: "tenant-private",
name: "비공개 팀",
slug: "private-team",
config: { visibility: "private" },
},
{
id: "tenant-public",
name: "공개 팀",
slug: "public-team",
config: { visibility: "public" },
},
],
total: 2,
});
fetchUsersMock.mockResolvedValueOnce({
items: [
{
...users[0],
name: "Private Primary User",
tenantSlug: "private-team",
tenant: {
id: "tenant-private",
name: "비공개 팀",
slug: "private-team",
config: { visibility: "private" },
},
joinedTenants: [
{
id: "tenant-public",
name: "공개 팀",
slug: "public-team",
config: { visibility: "public" },
},
],
metadata: {
primaryTenantId: "tenant-private",
primaryTenantSlug: "private-team",
primaryTenantName: "비공개 팀",
},
},
],
total: 1,
});
renderUserListPage();
expect(await screen.findByText("Private Primary User")).toBeInTheDocument();
expect(screen.getByText("공개 팀")).toBeInTheDocument();
expect(screen.queryByText("비공개 팀")).not.toBeInTheDocument();
});
it("centers the initial loading message across the user table", async () => { it("centers the initial loading message across the user table", async () => {
const deferred = createDeferred<{ items: typeof users; total: number }>(); const deferred = createDeferred<{ items: typeof users; total: number }>();
fetchUsersMock.mockReturnValueOnce(deferred.promise); fetchUsersMock.mockReturnValueOnce(deferred.promise);
@@ -179,7 +280,7 @@ describe("UserListPage search rendering", () => {
renderUserListPage(); renderUserListPage();
await screen.findByText("User 0"); await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색..."); const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
const startedAt = performance.now(); const startedAt = performance.now();
fireEvent.change(searchInput, { target: { value: "user 19" } }); fireEvent.change(searchInput, { target: { value: "user 19" } });
@@ -189,4 +290,19 @@ describe("UserListPage search rendering", () => {
expect(screen.queryByText("User 0")).not.toBeInTheDocument(); expect(screen.queryByText("User 0")).not.toBeInTheDocument();
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs); expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
}); });
it("keeps rendered form fields identifiable for browser autofill diagnostics", async () => {
const { container } = renderUserListPage();
await screen.findByText("User 0");
const anonymousFields = Array.from(
container.querySelectorAll("input, select, textarea"),
).filter(
(field) =>
!field.getAttribute("id")?.trim() &&
!field.getAttribute("name")?.trim(),
);
expect(anonymousFields).toHaveLength(0);
});
}); });

View File

@@ -13,6 +13,7 @@ import {
ChevronDown, ChevronDown,
FileDown, FileDown,
FileSpreadsheet, FileSpreadsheet,
Key,
LayoutDashboard, LayoutDashboard,
Plus, Plus,
RefreshCw, RefreshCw,
@@ -96,11 +97,12 @@ import {
updateUser, updateUser,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles"; import { normalizeAdminRole } from "../../lib/roles";
import { import {
downloadUserTemplate, downloadUserTemplate,
UserBulkUploadModal, UserBulkUploadModal,
} from "./components/UserBulkUploadModal"; } from "./components/UserBulkUploadModal";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import { import {
normalizeUserStatusValue, normalizeUserStatusValue,
type UserStatusValue, type UserStatusValue,
@@ -117,9 +119,9 @@ type UserSchemaField = {
type UserSortKey = string; type UserSortKey = string;
const USER_ROW_ESTIMATED_HEIGHT = 64; const USER_ROW_ESTIMATED_HEIGHT = 64;
const USER_ROW_OVERSCAN = 20; const USER_ROW_OVERSCAN = 2;
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640; const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const; const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 220] as const;
const userMetadataColumnWidth = 160; const userMetadataColumnWidth = 160;
const userCreatedColumnWidth = 150; const userCreatedColumnWidth = 150;
type UserRowVirtualizer = Virtualizer<HTMLDivElement, HTMLTableRowElement>; type UserRowVirtualizer = Virtualizer<HTMLDivElement, HTMLTableRowElement>;
@@ -133,21 +135,111 @@ const userSortableTableHeadContentClassName = "h-full items-center";
const userTableStateCellClassName = const userTableStateCellClassName =
"flex h-24 items-center justify-center p-0 text-center text-sm text-muted-foreground"; "flex h-24 items-center justify-center p-0 text-center text-sm text-muted-foreground";
const bulkPermissionOptions = [ type RepresentativeTenantCandidate = {
{ id?: string;
value: "super_admin", slug?: string;
labelKey: "ui.admin.role.super_admin", name?: string;
fallback: "시스템 관리자", config?: Record<string, unknown>;
}, };
{
value: "user",
labelKey: "ui.admin.role.user",
fallback: "일반 사용자",
},
] as const;
function assignableSystemRoleValue(role?: string | null) { function stringValue(value: unknown) {
return isSuperAdminRole(role) ? "super_admin" : "user"; return typeof value === "string" ? value.trim() : "";
}
function tenantVisibility(tenant?: RepresentativeTenantCandidate) {
const visibility = tenant?.config?.visibility;
return typeof visibility === "string" ? visibility.trim() : "";
}
function findTenantCandidate(
candidate: RepresentativeTenantCandidate,
tenants: TenantSummary[],
) {
const id = candidate.id?.toLowerCase() ?? "";
const slug = candidate.slug?.toLowerCase() ?? "";
if (!id && !slug) return undefined;
return tenants.find(
(tenant) =>
(id && tenant.id.toLowerCase() === id) ||
(slug && tenant.slug.toLowerCase() === slug),
);
}
function isPrivateTenantCandidate(
candidate: RepresentativeTenantCandidate,
tenants: TenantSummary[],
) {
const tenant = findTenantCandidate(candidate, tenants) ?? candidate;
return tenantVisibility(tenant) === "private";
}
function candidateLabel(candidate: RepresentativeTenantCandidate) {
return candidate.name || candidate.slug || candidate.id || "";
}
function metadataTenantCandidate(
metadata: Record<string, unknown> | undefined,
): RepresentativeTenantCandidate | null {
const id = stringValue(metadata?.primaryTenantId);
const slug = stringValue(metadata?.primaryTenantSlug);
const name = stringValue(metadata?.primaryTenantName);
if (!id && !slug && !name) return null;
return { id, slug, name };
}
function appointmentTenantCandidate(
appointment: unknown,
): RepresentativeTenantCandidate | null {
if (!appointment || typeof appointment !== "object") return null;
const value = appointment as Record<string, unknown>;
const id = stringValue(value.tenantId);
const slug = stringValue(value.tenantSlug ?? value.slug);
const name = stringValue(value.tenantName ?? value.name);
if (!id && !slug && !name) return null;
return { id, slug, name };
}
function resolveRepresentativeTenantLabel(
user: UserSummary,
tenants: TenantSummary[],
) {
const candidates: RepresentativeTenantCandidate[] = [];
const knownTenants = [
...(user.tenant ? [user.tenant] : []),
...(user.joinedTenants ?? []),
...tenants,
];
const primaryFromMetadata = metadataTenantCandidate(user.metadata);
if (primaryFromMetadata) candidates.push(primaryFromMetadata);
if (user.tenant) candidates.push(user.tenant);
for (const tenant of user.joinedTenants ?? []) {
candidates.push(tenant);
}
const appointments = user.metadata?.additionalAppointments;
if (Array.isArray(appointments)) {
for (const appointment of appointments) {
if (
appointment &&
typeof appointment === "object" &&
(appointment as Record<string, unknown>).isPrimary !== true
) {
continue;
}
const candidate = appointmentTenantCandidate(appointment);
if (candidate) candidates.push(candidate);
}
}
if (user.tenantSlug) candidates.push({ slug: user.tenantSlug });
const representative = candidates.find(
(candidate) =>
candidateLabel(candidate) &&
!isPrivateTenantCandidate(candidate, knownTenants),
);
return candidateLabel(representative ?? {});
} }
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect { function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
@@ -204,12 +296,14 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
<SearchFilterBar <SearchFilterBar
primary={ primary={
<> <>
<div className="relative w-48"> <div className="relative w-56">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
id="user-list-search"
name="user-list-search"
placeholder={t( placeholder={t(
"ui.admin.users.list.search_placeholder", "ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...", "이름 또는 이메일 검색",
)} )}
className="h-9 pl-9" className="h-9 pl-9"
value={localSearch} value={localSearch}
@@ -223,6 +317,8 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
</div> </div>
<select <select
id="user-list-tenant-filter"
name="user-list-tenant-filter"
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" 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} value={selectedCompany}
onChange={(event) => onCompanyChange(event.target.value)} onChange={(event) => onCompanyChange(event.target.value)}
@@ -257,8 +353,9 @@ function UserListPage() {
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState< const [selectedBulkStatus, setSelectedBulkStatus] = React.useState<
UserStatusValue | "" UserStatusValue | ""
>(""); >("");
const [selectedBulkPermission, setSelectedBulkPermission] = const [selectedBulkRole, setSelectedBulkRole] = React.useState<
React.useState(""); "super_admin" | "user" | ""
>("");
const [sortConfig, setSortConfig] = const [sortConfig, setSortConfig] =
React.useState<SortConfig<UserSortKey> | null>(null); React.useState<SortConfig<UserSortKey> | null>(null);
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false); const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
@@ -269,6 +366,8 @@ function UserListPage() {
queryFn: fetchMe, queryFn: fetchMe,
}); });
const profileRole = normalizeAdminRole(profile?.role); const profileRole = normalizeAdminRole(profile?.role);
const isWritable =
profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
const { data: tenantsData } = useQuery({ const { data: tenantsData } = useQuery({
queryKey: ["tenants", "all"], queryKey: ["tenants", "all"],
@@ -416,10 +515,10 @@ function UserListPage() {
name_email: (user) => name_email: (user) =>
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`, `${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
tenant_dept: (user) => tenant_dept: (user) =>
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${user.department ?? ""}`, `${resolveRepresentativeTenantLabel(user, tenants)} ${user.department ?? ""}`,
}, },
), ),
[userSchema], [tenants, userSchema],
); );
const items = React.useMemo(() => { const items = React.useMemo(() => {
if (!sortConfig) { if (!sortConfig) {
@@ -489,7 +588,7 @@ function UserListPage() {
]); ]);
const shouldVirtualizeRows = !query.isLoading && items.length > 0; const shouldVirtualizeRows = !query.isLoading && items.length > 0;
const tableColumnCount = 9 + visibleUserSchemaFields.length; const tableColumnCount = 8 + visibleUserSchemaFields.length;
const requestSort = (key: UserSortKey) => { const requestSort = (key: UserSortKey) => {
setSortConfig((current) => toggleSort(current, key)); setSortConfig((current) => toggleSort(current, key));
@@ -507,8 +606,6 @@ function UserListPage() {
}; };
const total = query.data?.pages[0]?.total ?? 0; const total = query.data?.pages[0]?.total ?? 0;
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (selectedUserIds.length === items.length) { if (selectedUserIds.length === items.length) {
setSelectedUserIds([]); setSelectedUserIds([]);
@@ -540,11 +637,25 @@ function UserListPage() {
const bulkUpdateMutation = useMutation({ const bulkUpdateMutation = useMutation({
mutationFn: bulkUpdateUsers, mutationFn: bulkUpdateUsers,
onSuccess: () => { onSuccess: (data) => {
const failed = data.results?.filter((result) => !result.success) ?? [];
if (failed.length > 0) {
toast.error(
t(
"msg.admin.users.bulk.update_partial_error",
"{{count}}명의 사용자 정보 수정에 실패했습니다.",
{ count: failed.length },
),
{
description: formatUserPolicyMessage(failed[0]?.message),
},
);
return;
}
query.refetch(); query.refetch();
setSelectedUserIds([]); setSelectedUserIds([]);
setSelectedBulkStatus(""); setSelectedBulkStatus("");
setSelectedBulkPermission(""); setSelectedBulkRole("");
toast.success( toast.success(
t( t(
"msg.admin.users.bulk.update_success", "msg.admin.users.bulk.update_success",
@@ -562,14 +673,6 @@ function UserListPage() {
}); });
}; };
const _handleApplyBulkPermission = () => {
if (selectedUserIds.length === 0 || !selectedBulkPermission) return;
bulkUpdateMutation.mutate({
userIds: selectedUserIds,
role: selectedBulkPermission,
});
};
const handleBulkDelete = () => { const handleBulkDelete = () => {
if (selectedUserIds.length === 0) return; if (selectedUserIds.length === 0) return;
if ( if (
@@ -613,7 +716,7 @@ function UserListPage() {
} }
description={t( description={t(
"msg.admin.users.list.subtitle", "msg.admin.users.list.subtitle",
"시스템 사용자를 조회하고 관리합니다.", "Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
)} )}
actions={ actions={
<> <>
@@ -636,6 +739,15 @@ function UserListPage() {
<RefreshCw size={16} /> <RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")} {t("ui.common.refresh", "새로고침")}
</Button> </Button>
<Button asChild variant="outline" size="sm" className="h-9 gap-2">
<Link to="/users/custom-claims">
<Key size={16} />
{t(
"ui.admin.users.global_custom_claims.title",
"전역 Claim 설정",
)}
</Link>
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -660,8 +772,9 @@ function UserListPage() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onSelect={() => { onSelect={() => {
setBulkUploadOpen(true); if (isWritable) setBulkUploadOpen(true);
}} }}
disabled={!isWritable}
className="cursor-pointer" className="cursor-pointer"
> >
<Upload size={16} className="mr-2 opacity-50" /> <Upload size={16} className="mr-2 opacity-50" />
@@ -727,6 +840,7 @@ function UserListPage() {
className="flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-muted/50" className="flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-muted/50"
> >
<input <input
name={`user-list-column-${field.key}`}
type="checkbox" type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
checked={visibleColumns[field.key] !== false} checked={visibleColumns[field.key] !== false}
@@ -752,12 +866,19 @@ function UserListPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Button asChild size="sm" className="h-9"> {isWritable ? (
<Link to="/users/new"> <Button asChild size="sm" className="h-9">
<Link to="/users/new">
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
</Link>
</Button>
) : (
<Button size="sm" className="h-9" disabled>
<Plus size={16} /> <Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")} {t("ui.admin.users.list.add", "사용자 추가")}
</Link> </Button>
</Button> )}
</> </>
} }
/> />
@@ -802,6 +923,7 @@ function UserListPage() {
<TableHead className={`${userTableHeadClassName} w-12`}> <TableHead className={`${userTableHeadClassName} w-12`}>
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<input <input
name="user-list-select-all"
type="checkbox" type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer" className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={ checked={
@@ -857,15 +979,6 @@ function UserListPage() {
{getSortIcon("status")} {getSortIcon("status")}
</div> </div>
</TableHead> </TableHead>
<TableHead
className={userTableHeadInteractiveClassName}
onClick={() => requestSort("role")}
>
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.role", "ROLE")}
{getSortIcon("role")}
</div>
</TableHead>
<TableHead <TableHead
className={userTableHeadInteractiveClassName} className={userTableHeadInteractiveClassName}
onClick={() => requestSort("tenant_dept")} onClick={() => requestSort("tenant_dept")}
@@ -957,6 +1070,9 @@ function UserListPage() {
virtualRows.map((virtualRow) => { virtualRows.map((virtualRow) => {
const user = items[virtualRow.index]; const user = items[virtualRow.index];
if (!user) return null; if (!user) return null;
const representativeTenantLabel =
resolveRepresentativeTenantLabel(user, tenants) ||
t("ui.common.unassigned", "미배정");
return ( return (
<TableRow <TableRow
@@ -980,6 +1096,7 @@ function UserListPage() {
> >
<TableCell> <TableCell>
<input <input
name={`user-list-select-${user.id}`}
type="checkbox" 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" 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)} checked={selectedUserIds.includes(user.id)}
@@ -998,7 +1115,7 @@ function UserListPage() {
<TableCell> <TableCell>
<Link <Link
to={`/users/${user.id}`} to={`/users/${user.id}`}
className="font-medium hover:underline text-primary truncate block max-w-[150px]" className="block max-w-[150px] truncate font-medium text-foreground transition-colors hover:text-primary hover:underline"
title={user.name} title={user.name}
> >
{user.name} {user.name}
@@ -1030,7 +1147,8 @@ function UserListPage() {
} }
disabled={ disabled={
statusMutation.isPending || statusMutation.isPending ||
user.id === profile?.id user.id === profile?.id ||
!isWritable
} }
> >
<SelectTrigger <SelectTrigger
@@ -1053,42 +1171,10 @@ function UserListPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</TableCell> </TableCell>
<TableCell>
<Select
value={assignableSystemRoleValue(user.role)}
onValueChange={(value) =>
bulkUpdateMutation.mutate({
userIds: [user.id],
role: value,
})
}
disabled={
bulkUpdateMutation.isPending ||
!isSuperAdminRole(profile?.role) ||
user.id === profile?.id
}
>
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
<SelectValue />
</SelectTrigger>
<SelectContent>
{bulkPermissionOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{t(option.labelKey, option.fallback)}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell> <TableCell>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{user.tenant?.name || {representativeTenantLabel}
user.tenantSlug ||
t("ui.common.unassigned", "미배정")}
</span> </span>
{user.department && ( {user.department && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -1151,31 +1237,6 @@ function UserListPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{canPromoteSuperAdmin && (
<Select
value={selectedBulkPermission}
onValueChange={setSelectedBulkPermission}
>
<SelectTrigger
className="h-8 w-[120px] bg-transparent border-background/20 text-background text-xs"
data-testid="bulk-permission-select"
>
<SelectValue
placeholder={t(
"ui.admin.users.bulk.permission_placeholder",
"권한 선택",
)}
/>
</SelectTrigger>
<SelectContent>
{bulkPermissionOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey, option.fallback)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -1184,15 +1245,15 @@ function UserListPage() {
const payload: { const payload: {
userIds: string[]; userIds: string[];
status?: UserStatusValue; status?: UserStatusValue;
role?: string; role?: "super_admin" | "user";
} = { userIds: selectedUserIds }; } = { userIds: selectedUserIds };
let hasChanges = false; let hasChanges = false;
if (selectedBulkStatus) { if (selectedBulkStatus) {
payload.status = selectedBulkStatus; payload.status = selectedBulkStatus;
hasChanges = true; hasChanges = true;
} }
if (selectedBulkPermission && canPromoteSuperAdmin) { if (selectedBulkRole) {
payload.role = selectedBulkPermission; payload.role = selectedBulkRole;
hasChanges = true; hasChanges = true;
} }
if (hasChanges) { if (hasChanges) {
@@ -1200,20 +1261,51 @@ function UserListPage() {
} }
}} }}
disabled={ disabled={
(!selectedBulkStatus && !selectedBulkPermission) || (!selectedBulkStatus && !selectedBulkRole) ||
bulkUpdateMutation.isPending bulkUpdateMutation.isPending ||
!isWritable
} }
data-testid="bulk-apply-btn" data-testid="bulk-apply-btn"
> >
<ShieldCheck size={14} /> <ShieldCheck size={14} />
{t("ui.common.apply", "적용")} {t("ui.common.apply", "적용")}
</Button> </Button>
<Select
value={selectedBulkRole}
onValueChange={(value) =>
setSelectedBulkRole(value as "super_admin" | "user")
}
>
<SelectTrigger
className="h-8 w-[150px] bg-transparent border-background/20 text-background text-xs"
data-testid="bulk-permission-select"
>
<SelectValue
placeholder={t(
"ui.admin.users.bulk.permission_placeholder",
"권한 선택",
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="super_admin">
{t(
"ui.admin.users.detail.form.role_super_admin",
"시스템 관리자",
)}
</SelectItem>
<SelectItem value="user">
{t("ui.admin.users.detail.form.role_user", "일반 사용자")}
</SelectItem>
</SelectContent>
</Select>
<div className="w-px h-4 bg-background/20 mx-1" /> <div className="w-px h-4 bg-background/20 mx-1" />
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5" className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
onClick={handleBulkDelete} onClick={handleBulkDelete}
disabled={!isWritable}
data-testid="bulk-delete-btn" data-testid="bulk-delete-btn"
> >
<Trash2 size={14} /> <Trash2 size={14} />

View File

@@ -191,6 +191,8 @@ export function UserBulkMoveGroupModal({
{t("ui.admin.users.create.form.tenant", "테넌트 선택")} {t("ui.admin.users.create.form.tenant", "테넌트 선택")}
</label> </label>
<select <select
id="bulk-move-target-tenant"
name="bulk-move-target-tenant"
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" 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} value={selectedTenantSlug}
onChange={(e) => { onChange={(e) => {
@@ -290,6 +292,8 @@ export function UserBulkMoveGroupModal({
</div> </div>
<label className="flex items-center gap-2 cursor-pointer mt-2 pt-2 border-t border-destructive/10"> <label className="flex items-center gap-2 cursor-pointer mt-2 pt-2 border-t border-destructive/10">
<input <input
id="bulk-move-acknowledge-warning"
name="bulk-move-acknowledge-warning"
type="checkbox" type="checkbox"
checked={acknowledgeWarning} checked={acknowledgeWarning}
onChange={(e) => setAcknowledgeWarning(e.target.checked)} onChange={(e) => setAcknowledgeWarning(e.target.checked)}

View File

@@ -35,6 +35,7 @@ import {
type TenantImportPreviewRow, type TenantImportPreviewRow,
} from "../../tenants/utils/tenantCsvImport"; } from "../../tenants/utils/tenantCsvImport";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker"; import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
import { formatUserPolicyMessage } from "../userPolicyMessages";
import { parseUserCSV } from "../utils/csvParser"; import { parseUserCSV } from "../utils/csvParser";
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority"; import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
import { import {
@@ -420,6 +421,7 @@ export function UserBulkUploadModal({
? t("ui.common.change_file", "파일 변경") ? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")} : t("ui.common.select_file", "파일 선택")}
<input <input
name="user-bulk-upload-file"
type="file" type="file"
accept=".csv" accept=".csv"
className="hidden" className="hidden"
@@ -482,6 +484,8 @@ export function UserBulkUploadModal({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<select <select
id={`user-bulk-tenant-match-${preview.row.rowNumber}`}
name={`user-bulk-tenant-match-${preview.row.rowNumber}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={ value={
selectedTenantMatches[preview.row.rowNumber] ?? selectedTenantMatches[preview.row.rowNumber] ??
@@ -512,6 +516,8 @@ export function UserBulkUploadModal({
{(selectedTenantMatches[preview.row.rowNumber] ?? {(selectedTenantMatches[preview.row.rowNumber] ??
"__create__") === "__create__" && ( "__create__") === "__create__" && (
<input <input
id={`user-bulk-tenant-create-slug-${preview.row.rowNumber}`}
name={`user-bulk-tenant-create-slug-${preview.row.rowNumber}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm" className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
value={ value={
selectedTenantCreateSlugs[ selectedTenantCreateSlugs[
@@ -552,6 +558,8 @@ export function UserBulkUploadModal({
> >
<td className="p-2"> <td className="p-2">
<input <input
id={`user-bulk-email-preview-${index}`}
name={`user-bulk-email-preview-${index}`}
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs" className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
value={ value={
hanmacEmailPreviews[index]?.finalEmail ?? hanmacEmailPreviews[index]?.finalEmail ??
@@ -761,7 +769,7 @@ export function UserBulkUploadModal({
)} )}
{!r.success && ( {!r.success && (
<div className="text-xs text-destructive"> <div className="text-xs text-destructive">
{r.message} {formatUserPolicyMessage(r.message)}
</div> </div>
)} )}
</div> </div>

View File

@@ -2,11 +2,15 @@ import { describe, expect, it } from "vitest";
import { import {
buildAuthenticatedOrgChartTenantPickerUrl, buildAuthenticatedOrgChartTenantPickerUrl,
buildAuthenticatedOrgChartUrl, buildAuthenticatedOrgChartUrl,
buildAuthenticatedOrgChartUserMultiPickerUrl,
buildOrgChartTenantPickerUrl, buildOrgChartTenantPickerUrl,
classifyTenantByMembershipRoot,
filterNonHanmacFamilyTenants, filterNonHanmacFamilyTenants,
getTenantGradeOptions, getTenantGradeOptions,
isHanmacFamilyUser, isHanmacFamilyUser,
parseOrgChartTenantSelection, parseOrgChartTenantSelection,
parseOrgChartUserSelections,
USER_MEMBERSHIP_TENANT_TABS,
} from "./orgChartPicker"; } from "./orgChartPicker";
describe("orgChartPicker", () => { describe("orgChartPicker", () => {
@@ -49,18 +53,51 @@ describe("orgChartPicker", () => {
); );
}); });
it("builds the admin chart navigation URL with internal visibility enabled", () => { it("falls back to the orgfront development origin for authenticated picker URLs", () => {
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe( expect(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue", buildAuthenticatedOrgChartTenantPickerUrl(undefined, {
tenantId: "hanmac-family-id",
}),
).toBe(
"http://localhost:5175/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id%26includeInternal%3Dtrue",
); );
}); });
it("can build chart navigation URL without internal visibility", () => { it("builds an authenticated multi picker URL for tenant member selection", () => {
expect(
buildAuthenticatedOrgChartUserMultiPickerUrl(
"https://orgchart.example.com",
),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dmultiple%26select%3Duser%26width%3D720%26height%3D640%26includeInternal%3Dtrue%26includeDescendants%3Dtrue%26showDescendantToggle%3Dtrue%26rootTenantId%3Dall",
);
});
it("builds a scoped authenticated multi picker URL for recursive tenant user selection", () => {
expect(
buildAuthenticatedOrgChartUserMultiPickerUrl(
"https://orgchart.example.com",
{ tenantId: "tenant-a" },
),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dmultiple%26select%3Duser%26width%3D720%26height%3D640%26includeInternal%3Dtrue%26includeDescendants%3Dtrue%26showDescendantToggle%3Dtrue%26tenantId%3Dtenant-a",
);
});
it("builds the admin chart navigation URL without internal visibility by default", () => {
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
);
});
it("can build chart navigation URL with internal visibility when explicitly requested", () => {
expect( expect(
buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", { buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", {
includeInternal: false, includeInternal: true,
}), }),
).toBe("https://orgchart.example.com/login?auto=1&returnTo=%2Fchart"); ).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue",
);
}); });
it("parses the first tenant id and name from orgfront confirm messages", () => { it("parses the first tenant id and name from orgfront confirm messages", () => {
@@ -98,6 +135,50 @@ describe("orgChartPicker", () => {
expect(parseOrgChartTenantSelection({ type: "other" })).toBeNull(); expect(parseOrgChartTenantSelection({ type: "other" })).toBeNull();
}); });
it("parses user selections from orgfront multi picker messages", () => {
expect(
parseOrgChartUserSelections({
type: "orgfront:picker:confirm",
payload: {
mode: "multiple",
selections: [
{ type: "tenant", id: "tenant-1", name: "기술기획" },
{
type: "user",
id: "user-1",
name: "홍길동",
email: "hong@example.com",
rootTenantName: "한맥가족",
leafTenantName: "기술기획",
},
{
type: "user",
id: "user-2",
name: "김영희",
tenantName: "디자인팀",
},
{ type: "user", id: "", name: "잘못된 사용자" },
],
},
}),
).toEqual([
{
id: "user-1",
name: "홍길동",
email: "hong@example.com",
rootTenantName: "한맥가족",
leafTenantName: "기술기획",
},
{
id: "user-2",
name: "김영희",
email: "",
rootTenantName: undefined,
leafTenantName: "디자인팀",
},
]);
});
it("filters Hanmac family subtree and system tenants from non-family tenant choices", () => { it("filters Hanmac family subtree and system tenants from non-family tenant choices", () => {
const visibleTenants = filterNonHanmacFamilyTenants( const visibleTenants = filterNonHanmacFamilyTenants(
[ [
@@ -309,11 +390,48 @@ describe("orgChartPicker", () => {
"차장", "차장",
"부장", "부장",
"이사", "이사",
"상무", "상무이사",
"전무", "전무이사",
"부사장", "부사장",
"사장", "사장",
"회장", "회장",
]); ]);
}); });
it("classifies tenants by the configured top-level tenant root UUIDs", () => {
const tenants = [
{
id: "hanmac-root-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "commercial-root-id",
slug: "commercial",
name: "Commercial",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "commercial-child-id",
slug: "external-company",
name: "외부기업",
type: "COMPANY",
parentId: "commercial-root-id",
},
];
expect(USER_MEMBERSHIP_TENANT_TABS.map((tab) => tab.id)).toEqual([
"hanmac-family",
"commercial",
"public-org",
"edu",
"personal",
]);
expect(classifyTenantByMembershipRoot(tenants[2], tenants)?.id).toBe(
"commercial",
);
});
}); });

View File

@@ -3,6 +3,14 @@ export type OrgChartTenantSelection = {
name: string; name: string;
}; };
export type OrgChartUserSelection = {
id: string;
name: string;
email: string;
rootTenantName?: string;
leafTenantName?: string;
};
export type TenantFilterTarget = { export type TenantFilterTarget = {
id?: string; id?: string;
tenantId?: string; tenantId?: string;
@@ -24,6 +32,20 @@ export type HanmacFamilyUserTarget = {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}; };
export type UserMembershipTenantTabId =
| "hanmac-family"
| "commercial"
| "public-org"
| "edu"
| "personal";
export type UserMembershipTenantTab = {
id: UserMembershipTenantTabId;
label: string;
rootSlug: string;
seedTenantId?: string;
};
type OrgChartPickerMessage = { type OrgChartPickerMessage = {
type?: unknown; type?: unknown;
payload?: { payload?: {
@@ -31,6 +53,10 @@ type OrgChartPickerMessage = {
type?: unknown; type?: unknown;
id?: unknown; id?: unknown;
name?: unknown; name?: unknown;
email?: unknown;
rootTenantName?: unknown;
leafTenantName?: unknown;
tenantName?: unknown;
}>; }>;
}; };
}; };
@@ -40,11 +66,47 @@ type OrgChartTenantPickerOptions = {
tenantId?: string; tenantId?: string;
}; };
type OrgChartUserMultiPickerOptions = {
tenantId?: string;
};
type OrgChartLoginOptions = { type OrgChartLoginOptions = {
includeInternal?: boolean; includeInternal?: boolean;
returnTo?: string; returnTo?: string;
}; };
const DEFAULT_ORGFRONT_BASE_URL = "http://localhost:5175";
export const USER_MEMBERSHIP_TENANT_TABS: UserMembershipTenantTab[] = [
{
id: "hanmac-family",
label: "한맥가족",
rootSlug: "hanmac-family",
seedTenantId: "038326b6-954a-48a7-a85f-efd83f62b82a",
},
{
id: "commercial",
label: "일반회사",
rootSlug: "commercial",
},
{
id: "public-org",
label: "공공기관",
rootSlug: "public-org",
},
{
id: "edu",
label: "교육기관",
rootSlug: "edu",
},
{
id: "personal",
label: "개인",
rootSlug: "personal",
seedTenantId: "9607eb7b-04d2-42ab-80fe-780fe21c7e8f",
},
];
export const GPDTDC_GRADE_OPTIONS = [ export const GPDTDC_GRADE_OPTIONS = [
"연구원", "연구원",
"선임", "선임",
@@ -61,8 +123,8 @@ export const HANMAC_FAMILY_GRADE_OPTIONS = [
"차장", "차장",
"부장", "부장",
"이사", "이사",
"상무", "상무이사",
"전무", "전무이사",
"부사장", "부사장",
"사장", "사장",
"회장", "회장",
@@ -99,6 +161,118 @@ function resolveTenantTarget<T extends TenantFilterTarget>(
); );
} }
function resolveMembershipRoot<T extends TenantFilterTarget>(
tab: UserMembershipTenantTab,
tenants: T[],
) {
const rootSlug = tab.rootSlug.toLowerCase();
return (
tenants.find(
(tenant) => tab.seedTenantId && tenant.id === tab.seedTenantId,
) ??
tenants.find((tenant) => tenant.slug?.trim().toLowerCase() === rootSlug)
);
}
export function classifyTenantByMembershipRoot<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
) {
const tenant = resolveTenantTarget(target, tenants);
if (!tenant?.id) return undefined;
const tenantById = new Map(
tenants
.filter((item) => item.id?.trim())
.map((item) => [item.id as string, item]),
);
return USER_MEMBERSHIP_TENANT_TABS.find((tab) => {
const root = resolveMembershipRoot(tab, tenants);
if (!root?.id) return false;
const resolvedTenant = tenantById.get(tenant.id ?? "") ?? tenant;
return isInTenantSubtree(resolvedTenant, root.id, tenantById);
});
}
export function filterTenantsByMembershipRoot<T extends TenantFilterTarget>(
tenants: T[],
tabId: UserMembershipTenantTabId,
) {
const tab = USER_MEMBERSHIP_TENANT_TABS.find((item) => item.id === tabId);
if (!tab) return [];
const root = resolveMembershipRoot(tab, tenants);
if (!root?.id) return [];
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
.map((tenant) => [tenant.id as string, tenant]),
);
return tenants.filter(
(tenant) =>
!isSystemTenant(tenant) &&
isPublicRepresentativeTenant(tenant) &&
isInTenantSubtree(tenant, root.id as string, tenantById),
);
}
export function resolveUserMembershipTenantTab<T extends TenantFilterTarget>(
user: HanmacFamilyUserTarget,
tenants: T[],
) {
const metadataAppointments = Array.isArray(
user.metadata?.additionalAppointments,
)
? user.metadata.additionalAppointments
.map((appointment) => appointment as TenantFilterTarget)
.filter(
(appointment) =>
typeof appointment.tenantId === "string" ||
typeof appointment.id === "string" ||
typeof appointment.tenantSlug === "string" ||
typeof appointment.slug === "string",
)
.map((appointment) => ({
id: appointment.id ?? appointment.tenantId,
slug: appointment.slug ?? appointment.tenantSlug,
parentId: appointment.parentId,
type: appointment.type,
name: appointment.name ?? appointment.tenantName,
}))
: [];
const tenantBySlug = new Map(
tenants
.filter((tenant) => tenant.slug?.trim())
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
);
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
.map((tenant) => [tenant.id as string, tenant]),
);
const candidates = [
user.tenant,
...(user.joinedTenants ?? []),
...metadataAppointments,
...metadataAppointments.map((appointment) =>
tenantById.get(appointment.id ?? ""),
),
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
];
return (
USER_MEMBERSHIP_TENANT_TABS.find((tab) =>
candidates.some(
(candidate) =>
classifyTenantByMembershipRoot(candidate, tenants)?.id === tab.id,
),
) ?? USER_MEMBERSHIP_TENANT_TABS[0]
);
}
function isGPDTDCTenant<T extends TenantFilterTarget>( function isGPDTDCTenant<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined, target: TenantFilterTarget | undefined,
tenants: T[], tenants: T[],
@@ -317,11 +491,43 @@ export function buildAuthenticatedOrgChartTenantPickerUrl(
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl }); return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
} }
export function buildAuthenticatedOrgChartUrl( export function buildOrgChartUserMultiPickerUrl(
baseUrl?: string, baseUrl?: string,
options: OrgChartLoginOptions = { includeInternal: true }, options: OrgChartUserMultiPickerOptions = {},
) { ) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, ""); const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const params = new URLSearchParams({
mode: "multiple",
select: "user",
width: "720",
height: "640",
});
params.set("includeInternal", "true");
params.set("includeDescendants", "true");
params.set("showDescendantToggle", "true");
if (options.tenantId?.trim()) {
params.set("tenantId", options.tenantId.trim());
} else {
params.set("rootTenantId", "all");
}
return `${normalizedBase}/embed/picker?${params.toString()}`;
}
export function buildAuthenticatedOrgChartUserMultiPickerUrl(
baseUrl?: string,
options: OrgChartUserMultiPickerOptions = {},
) {
const pickerUrl = buildOrgChartUserMultiPickerUrl("", options);
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
}
export function buildAuthenticatedOrgChartUrl(
baseUrl?: string,
options: OrgChartLoginOptions = { includeInternal: false },
) {
const normalizedBase =
baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_ORGFRONT_BASE_URL;
let returnTo = options.returnTo?.trim() || "/chart"; let returnTo = options.returnTo?.trim() || "/chart";
if (options.includeInternal && returnTo.startsWith("/chart")) { if (options.includeInternal && returnTo.startsWith("/chart")) {
const [path, query = ""] = returnTo.split("?", 2); const [path, query = ""] = returnTo.split("?", 2);
@@ -360,3 +566,46 @@ export function parseOrgChartTenantSelection(
name: selection.name, name: selection.name,
}; };
} }
export function parseOrgChartUserSelections(
message: unknown,
): OrgChartUserSelection[] {
const data = message as OrgChartPickerMessage;
if (data?.type !== "orgfront:picker:confirm") {
return [];
}
return (data.payload?.selections ?? [])
.filter(
(
selection,
): selection is {
type: "user";
id: string;
name: string;
email?: string;
rootTenantName?: string;
leafTenantName?: string;
tenantName?: string;
} =>
selection?.type === "user" &&
typeof selection.id === "string" &&
typeof selection.name === "string" &&
selection.id.trim() !== "",
)
.map((selection) => ({
id: selection.id,
name: selection.name,
email: typeof selection.email === "string" ? selection.email : "",
rootTenantName:
typeof selection.rootTenantName === "string"
? selection.rootTenantName
: undefined,
leafTenantName:
typeof selection.leafTenantName === "string"
? selection.leafTenantName
: typeof selection.tenantName === "string"
? selection.tenantName
: undefined,
}));
}

View File

@@ -0,0 +1,20 @@
const INTERNAL_DOMAIN_PERSONAL_POLICY_PATTERNS = [
"internal email domain cannot be assigned to personal tenant",
"내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다",
];
export function formatUserPolicyMessage(message?: string | null) {
const raw = String(message ?? "").trim();
if (!raw) {
return "";
}
const normalized = raw.toLowerCase();
if (
INTERNAL_DOMAIN_PERSONAL_POLICY_PATTERNS.some((pattern) =>
normalized.includes(pattern.toLowerCase()),
)
) {
return "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다. 대표소속을 회사 또는 조직 소속으로 지정해 주세요.";
}
return raw;
}

View File

@@ -54,13 +54,21 @@ describe("adminApi endpoint contracts", () => {
await adminApi.fetchAdminOverviewStats(); await adminApi.fetchAdminOverviewStats();
await adminApi.fetchDataIntegrityReport(); await adminApi.fetchDataIntegrityReport();
await adminApi.fetchOrphanUserLoginIDs(); await adminApi.fetchOrphanUserLoginIDs();
await adminApi.fetchUserProjectionStatus(); await adminApi.fetchOrySSOTSystemStatus();
await adminApi.fetchAdminRPUsageDaily({ await adminApi.fetchAdminRPUsageDaily({
days: 30, days: 30,
period: "week", period: "week",
tenantId: "tenant-1", tenantId: "tenant-1",
}); });
await adminApi.fetchTenants(25, 50, "parent-1", "cursor-b"); await adminApi.fetchTenants(
25,
50,
"parent-1",
"cursor-b",
"saman",
"name",
"asc",
);
await adminApi.fetchAllTenants({ pageSize: 200, parentId: "parent-1" }); await adminApi.fetchAllTenants({ pageSize: 200, parentId: "parent-1" });
await adminApi.fetchTenant("tenant-1"); await adminApi.fetchTenant("tenant-1");
await adminApi.fetchTenantAdmins("tenant-1"); await adminApi.fetchTenantAdmins("tenant-1");
@@ -90,12 +98,16 @@ describe("adminApi endpoint contracts", () => {
expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", { expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", {
params: { limit: 10, cursor: "cursor-a" }, params: { limit: 10, cursor: "cursor-a" },
}); });
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/ory/ssot");
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", { expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", {
params: { params: {
limit: 25, limit: 25,
offset: 50, offset: 50,
parentId: "parent-1", parentId: "parent-1",
cursor: "cursor-b", cursor: "cursor-b",
search: "saman",
sort: "name",
direction: "asc",
}, },
}); });
expect(fetchAllCursorPages).toHaveBeenCalledWith( expect(fetchAllCursorPages).toHaveBeenCalledWith(
@@ -133,8 +145,7 @@ describe("adminApi endpoint contracts", () => {
const adminApi = await import("./adminApi"); const adminApi = await import("./adminApi");
await adminApi.deleteOrphanUserLoginIDs(["orphan-1"]); await adminApi.deleteOrphanUserLoginIDs(["orphan-1"]);
await adminApi.reconcileUserProjection(); await adminApi.flushIdentityCache();
await adminApi.resetUserProjection();
await adminApi.createTenant({ name: "Tenant", slug: "tenant" }); await adminApi.createTenant({ name: "Tenant", slug: "tenant" });
await adminApi.updateTenant("tenant-1", { status: "inactive" }); await adminApi.updateTenant("tenant-1", { status: "inactive" });
await adminApi.deleteTenant("tenant-1"); await adminApi.deleteTenant("tenant-1");
@@ -167,6 +178,7 @@ describe("adminApi endpoint contracts", () => {
"tenant-1", "tenant-1",
"user-2", "user-2",
"credential-batch-1", "credential-batch-1",
"InputPass1!",
); );
await adminApi.resetWorksmobileUserPassword( await adminApi.resetWorksmobileUserPassword(
"tenant-1", "tenant-1",
@@ -199,7 +211,7 @@ describe("adminApi endpoint contracts", () => {
{ data: { ids: ["orphan-1"] } }, { data: { ids: ["orphan-1"] } },
); );
expect(apiClient.post).toHaveBeenCalledWith( expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/projections/users/reconcile", "/v1/admin/ory/ssot/identity-cache/flush",
); );
expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", { expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", {
status: "active", status: "active",
@@ -209,7 +221,10 @@ describe("adminApi endpoint contracts", () => {
); );
expect(apiClient.post).toHaveBeenCalledWith( expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/sync", "/v1/admin/tenants/tenant-1/worksmobile/users/user-2/sync",
{ credentialBatchId: "credential-batch-1" }, {
credentialBatchId: "credential-batch-1",
initialPassword: "InputPass1!",
},
); );
expect(apiClient.post).toHaveBeenCalledWith( expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/password/reset", "/v1/admin/tenants/tenant-1/worksmobile/users/user-2/password/reset",

View File

@@ -36,11 +36,17 @@ describe("adminApi user tenant payloads", () => {
const { updateUser } = await import("./adminApi"); const { updateUser } = await import("./adminApi");
apiClient.put.mockResolvedValue({ data: {} }); apiClient.put.mockResolvedValue({ data: {} });
await updateUser("user-id", { tenantSlug: "new-tenant" }); await updateUser("user-id", {
tenantSlug: "new-tenant",
isPrimaryTenant: true,
});
expect(apiClient.put).toHaveBeenCalledWith( expect(apiClient.put).toHaveBeenCalledWith(
"/v1/admin/users/user-id", "/v1/admin/users/user-id",
expect.objectContaining({ tenantSlug: "new-tenant" }), expect.objectContaining({
tenantSlug: "new-tenant",
isPrimaryTenant: true,
}),
); );
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode"); expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
}); });
@@ -61,6 +67,7 @@ describe("adminApi user tenant payloads", () => {
await bulkUpdateUsers({ await bulkUpdateUsers({
userIds: ["user-id"], userIds: ["user-id"],
tenantSlug: "new-tenant", tenantSlug: "new-tenant",
isPrimaryTenant: true,
}); });
expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({ expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({
@@ -71,6 +78,7 @@ describe("adminApi user tenant payloads", () => {
); );
expect(apiClient.put.mock.calls[0][1]).toMatchObject({ expect(apiClient.put.mock.calls[0][1]).toMatchObject({
tenantSlug: "new-tenant", tenantSlug: "new-tenant",
isPrimaryTenant: true,
}); });
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode"); expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
}); });

View File

@@ -31,7 +31,23 @@ export type TenantSummary = {
domains?: string[]; domains?: string[];
parentId?: string; parentId?: string;
config?: Record<string, unknown>; config?: Record<string, unknown>;
memberCount: number; // Added member count memberCount: number; // 해당 테넌트 직접 소속 인원
totalMemberCount?: number; // 하위 테넌트 포함 전체 인원
userPermissions?: {
view: boolean;
manage: boolean;
manage_admins: boolean;
view_profile?: boolean;
manage_profile?: boolean;
view_permissions?: boolean;
manage_permissions?: boolean;
view_organization?: boolean;
manage_organization?: boolean;
view_schema?: boolean;
manage_schema?: boolean;
view_worksmobile?: boolean;
manage_worksmobile?: boolean;
};
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@@ -145,19 +161,24 @@ export type AdminOverviewStats = {
auditEvents24h: number; auditEvents24h: number;
}; };
export type UserProjectionStatus = { export type IdentityCacheStatus = {
name: string; status: string;
status: "ready" | "failed" | "syncing" | string; redisReady: boolean;
ready: boolean; mirrorVersion?: string;
lastSyncedAt?: string; observedCount: number;
keyCount: number;
lastRefreshedAt?: string;
lastError?: string; lastError?: string;
updatedAt?: string; updatedAt?: string;
projectedUsers: number;
}; };
export type UserProjectionActionResult = { export type OrySSOTSystemStatus = {
identityCache: IdentityCacheStatus;
};
export type IdentityCacheFlushResult = {
status: string; status: string;
syncedUsers: number; flushedKeys: number;
updatedAt: string; updatedAt: string;
}; };
@@ -254,23 +275,15 @@ export async function deleteOrphanUserLoginIDs(ids: string[]) {
return data; return data;
} }
export async function fetchUserProjectionStatus() { export async function fetchOrySSOTSystemStatus() {
const { data } = await apiClient.get<UserProjectionStatus>( const { data } =
"/v1/admin/projections/users", await apiClient.get<OrySSOTSystemStatus>("/v1/admin/ory/ssot");
);
return data; return data;
} }
export async function reconcileUserProjection() { export async function flushIdentityCache() {
const { data } = await apiClient.post<UserProjectionActionResult>( const { data } = await apiClient.post<IdentityCacheFlushResult>(
"/v1/admin/projections/users/reconcile", "/v1/admin/ory/ssot/identity-cache/flush",
);
return data;
}
export async function resetUserProjection() {
const { data } = await apiClient.post<UserProjectionActionResult>(
"/v1/admin/projections/users/reset",
); );
return data; return data;
} }
@@ -299,11 +312,13 @@ export async function fetchTenants(
parentId?: string, parentId?: string,
cursor?: string, cursor?: string,
search?: string, search?: string,
sort?: string,
direction?: "asc" | "desc",
) { ) {
const { data } = await apiClient.get<TenantListResponse>( const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants", "/v1/admin/tenants",
{ {
params: { limit, offset, parentId, cursor, search }, params: { limit, offset, parentId, cursor, search, sort, direction },
}, },
); );
return data; return data;
@@ -471,6 +486,61 @@ export async function removeTenantOwner(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`); await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
} }
export type TenantRelation = {
userId: string;
name: string;
email: string;
relations: string[];
};
export async function fetchTenantRelations(tenantId: string) {
const { data } = await apiClient.get<{ items: TenantRelation[] }>(
`/v1/admin/tenants/${tenantId}/relations`,
);
return data.items;
}
export async function addTenantRelation(
tenantId: string,
userId: string,
relation: string,
) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/relations`, {
userId,
relation,
});
}
export async function removeTenantRelation(
tenantId: string,
userId: string,
relation: string,
) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/relations`, {
data: { userId, relation },
});
}
export async function fetchSystemRelations() {
const { data } = await apiClient.get<{ items: TenantRelation[] }>(
`/v1/admin/system/relations`,
);
return data.items;
}
export async function addSystemRelation(userId: string, relation: string) {
await apiClient.post(`/v1/admin/system/relations`, {
userId,
relation,
});
}
export async function removeSystemRelation(userId: string, relation: string) {
await apiClient.delete(`/v1/admin/system/relations`, {
data: { userId, relation },
});
}
// Group Management // Group Management
export type GroupMember = { export type GroupMember = {
id: string; id: string;
@@ -703,6 +773,7 @@ export type UserUpdateRequest = {
role?: string; role?: string;
status?: string; status?: string;
tenantSlug?: string; tenantSlug?: string;
isPrimaryTenant?: boolean;
isAddTenant?: boolean; isAddTenant?: boolean;
isRemoveTenant?: boolean; isRemoveTenant?: boolean;
department?: string; department?: string;
@@ -716,6 +787,28 @@ export type UserUpdateRequest = {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}; };
export type GlobalCustomClaimPermission = "admin_only" | "user_and_admin";
export type GlobalCustomClaimDefinition = {
key: string;
label: string;
valueType:
| "text"
| "number"
| "boolean"
| "array"
| "object"
| "date"
| "datetime";
readPermission: GlobalCustomClaimPermission;
writePermission: GlobalCustomClaimPermission;
description?: string;
};
export type GlobalCustomClaimDefinitionsResponse = {
items: GlobalCustomClaimDefinition[];
};
export type UserAppointment = { export type UserAppointment = {
tenantId: string; tenantId: string;
tenantSlug?: string; tenantSlug?: string;
@@ -847,6 +940,9 @@ export type WorksmobileComparisonItem = {
baronSlug?: string; baronSlug?: string;
baronName?: string; baronName?: string;
baronEmail?: string; baronEmail?: string;
baronPhone?: string;
baronEmployeeNumber?: string;
baronGrade?: string;
baronPrimaryOrgId?: string; baronPrimaryOrgId?: string;
baronPrimaryOrgSlug?: string; baronPrimaryOrgSlug?: string;
baronPrimaryOrgName?: string; baronPrimaryOrgName?: string;
@@ -857,6 +953,9 @@ export type WorksmobileComparisonItem = {
externalKey?: string; externalKey?: string;
worksmobileName?: string; worksmobileName?: string;
worksmobileEmail?: string; worksmobileEmail?: string;
worksmobilePhone?: string;
worksmobileEmployeeNumber?: string;
worksmobileAccountStatus?: string;
worksmobileLevelId?: string; worksmobileLevelId?: string;
worksmobileLevelName?: string; worksmobileLevelName?: string;
worksmobileTask?: string; worksmobileTask?: string;
@@ -878,9 +977,29 @@ export type WorksmobileComparisonItem = {
worksmobileJobRetryCount?: number; worksmobileJobRetryCount?: number;
worksmobileLastError?: string; worksmobileLastError?: string;
worksmobileLastAttemptAt?: string; worksmobileLastAttemptAt?: string;
userMemberships?: WorksmobileUserMembershipComparison[];
updateReasons?: string[];
status: string; status: string;
}; };
export type WorksmobileUserMembershipComparison = {
baronOrgId?: string;
baronOrgSlug?: string;
baronOrgName?: string;
baronGrade?: string;
baronPrimary?: boolean;
worksmobileDomainId?: number;
worksmobileDomainName?: string;
worksmobileOrgId?: string;
worksmobileOrgName?: string;
worksmobileLevelId?: string;
worksmobileLevelName?: string;
worksmobileOrgPositionId?: string;
worksmobileOrgIsManager?: boolean;
worksmobilePrimary?: boolean;
gradeNeedsUpdate?: boolean;
};
export type WorksmobileComparison = { export type WorksmobileComparison = {
users: WorksmobileComparisonItem[]; users: WorksmobileComparisonItem[];
groups: WorksmobileComparisonItem[]; groups: WorksmobileComparisonItem[];
@@ -906,6 +1025,23 @@ export async function fetchUser(userId: string) {
return data; return data;
} }
export async function fetchGlobalCustomClaimDefinitions() {
const { data } = await apiClient.get<GlobalCustomClaimDefinitionsResponse>(
"/v1/admin/global-custom-claims",
);
return data;
}
export async function updateGlobalCustomClaimDefinitions(
payload: GlobalCustomClaimDefinitionsResponse,
) {
const { data } = await apiClient.put<GlobalCustomClaimDefinitionsResponse>(
"/v1/admin/global-custom-claims",
payload,
);
return data;
}
export async function createUser(payload: UserCreateRequest) { export async function createUser(payload: UserCreateRequest) {
const { data } = await apiClient.post<UserCreateResponse>( const { data } = await apiClient.post<UserCreateResponse>(
"/v1/admin/users", "/v1/admin/users",
@@ -1040,14 +1176,21 @@ export async function enqueueWorksmobileUserSync(
tenantId: string, tenantId: string,
userId: string, userId: string,
credentialBatchId?: string, credentialBatchId?: string,
initialPassword?: string,
) { ) {
const trimmedBatchId = credentialBatchId?.trim(); const trimmedBatchId = credentialBatchId?.trim();
const trimmedInitialPassword = initialPassword?.trim();
const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`; const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`;
const { data } = trimmedBatchId const body = {
? await apiClient.post<WorksmobileOutboxItem>(path, { ...(trimmedBatchId ? { credentialBatchId: trimmedBatchId } : {}),
credentialBatchId: trimmedBatchId, ...(trimmedInitialPassword
}) ? { initialPassword: trimmedInitialPassword }
: await apiClient.post<WorksmobileOutboxItem>(path); : {}),
};
const { data } =
Object.keys(body).length > 0
? await apiClient.post<WorksmobileOutboxItem>(path, body)
: await apiClient.post<WorksmobileOutboxItem>(path);
return data; return data;
} }
@@ -1078,12 +1221,17 @@ export async function bulkUpdateUsers(payload: {
status?: string; status?: string;
role?: string; role?: string;
tenantSlug?: string; tenantSlug?: string;
isPrimaryTenant?: boolean;
isAddTenant?: boolean;
department?: string; department?: string;
position?: string; position?: string;
grade?: string; grade?: string;
jobTitle?: string; jobTitle?: string;
}) { }) {
const { data } = await apiClient.put("/v1/admin/users/bulk", payload); const { data } = await apiClient.put<BulkUserResponse>(
"/v1/admin/users/bulk",
payload,
);
return data; return data;
} }
@@ -1136,6 +1284,32 @@ export async function fetchUserRpHistory(userId: string) {
return data; return data;
} }
export type SystemPermissions = {
overview: boolean;
tenants: boolean;
org_chart: boolean;
worksmobile: boolean;
ory_ssot: boolean;
data_integrity: boolean;
users: boolean;
permissions_direct: boolean;
auth_guard: boolean;
api_keys: boolean;
audit_logs: boolean;
manage_overview?: boolean;
manage_tenants?: boolean;
manage_org_chart?: boolean;
manage_worksmobile?: boolean;
manage_ory_ssot?: boolean;
manage_data_integrity?: boolean;
manage_users?: boolean;
manage_permissions_direct?: boolean;
manage_auth_guard?: boolean;
manage_api_keys?: boolean;
manage_audit_logs?: boolean;
};
export type UserProfileResponse = { export type UserProfileResponse = {
id: string; id: string;
email: string; email: string;
@@ -1149,6 +1323,7 @@ export type UserProfileResponse = {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
tenant?: TenantSummary; tenant?: TenantSummary;
manageableTenants?: TenantSummary[]; manageableTenants?: TenantSummary[];
systemPermissions?: SystemPermissions;
}; };
export async function fetchMe() { export async function fetchMe() {

View File

@@ -1,7 +1,7 @@
import axios from "axios"; import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth"; import { shouldStartLoginRedirect } from "../../../common/core/auth";
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session"; import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
import { userManager } from "./auth"; import { clearAdminAuthSession, userManager } from "./auth";
let isRedirectingToLogin = false; let isRedirectingToLogin = false;
@@ -50,12 +50,7 @@ apiClient.interceptors.response.use(
"[apiClient] 401 Unauthorized detected. Clearing session state.", "[apiClient] 401 Unauthorized detected. Clearing session state.",
); );
// 로컬 스토리지의 세션 키 제거 await clearAdminAuthSession();
window.localStorage.removeItem("admin_session");
// oidc-client의 유저 상태도 제거하여 isAuthenticated를 false로 만듭니다.
// 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
await userManager.removeUser();
if ( if (
shouldStartLoginRedirect({ shouldStartLoginRedirect({

View File

@@ -4,7 +4,10 @@ import {
buildCommonOidcRuntimeConfig, buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings, buildCommonUserManagerSettings,
} from "../../../common/core/auth"; } from "../../../common/core/auth";
import { resolveAdminPublicOrigin } from "./authConfig"; import {
resolveAdminOidcAuthority,
resolveAdminPublicOrigin,
} from "./authConfig";
const adminPublicOrigin = resolveAdminPublicOrigin( const adminPublicOrigin = resolveAdminPublicOrigin(
import.meta.env.VITE_ADMIN_PUBLIC_URL, import.meta.env.VITE_ADMIN_PUBLIC_URL,
@@ -12,7 +15,10 @@ const adminPublicOrigin = resolveAdminPublicOrigin(
); );
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({ export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc", authority: resolveAdminOidcAuthority(
import.meta.env.VITE_OIDC_AUTHORITY,
window.location.origin,
),
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront", clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
origin: adminPublicOrigin, origin: adminPublicOrigin,
userStore: new WebStorageStateStore({ store: window.localStorage }), userStore: new WebStorageStateStore({ store: window.localStorage }),
@@ -21,3 +27,31 @@ export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
export const userManager = new UserManager( export const userManager = new UserManager(
buildCommonUserManagerSettings(oidcConfig), buildCommonUserManagerSettings(oidcConfig),
); );
export function clearStoredAdminAuthSession(
storage: Storage = window.localStorage,
) {
const keysToRemove: string[] = [];
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (
key &&
(key === "admin_session" ||
key.startsWith("oidc.user:") ||
key.startsWith("oidc.state") ||
key.startsWith("oidc.signin"))
) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
storage.removeItem(key);
}
}
export async function clearAdminAuthSession() {
clearStoredAdminAuthSession();
await userManager.removeUser();
}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
buildAdminAuthRedirectUris, buildAdminAuthRedirectUris,
canStartBrowserPkceLogin, canStartBrowserPkceLogin,
resolveAdminOidcAuthority,
resolveAdminPublicOrigin, resolveAdminPublicOrigin,
} from "./authConfig"; } from "./authConfig";
@@ -26,6 +27,12 @@ describe("admin auth config", () => {
); );
}); });
it("uses the local OIDC authority for localhost when no explicit authority is set", () => {
expect(resolveAdminOidcAuthority(undefined, "http://localhost:5173")).toBe(
"http://localhost:5000/oidc",
);
});
it("blocks browser PKCE login when WebCrypto is unavailable", () => { it("blocks browser PKCE login when WebCrypto is unavailable", () => {
expect( expect(
canStartBrowserPkceLogin({ canStartBrowserPkceLogin({

View File

@@ -5,6 +5,8 @@ export interface AdminAuthRedirectUris {
} }
export const ADMIN_AUTH_CALLBACK_PATH = "/auth/callback"; export const ADMIN_AUTH_CALLBACK_PATH = "/auth/callback";
const ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY = "https://sso.hmac.kr/oidc";
const ADMIN_LOCAL_OIDC_PORT = "5000";
export function resolveAdminPublicOrigin( export function resolveAdminPublicOrigin(
configuredOrigin: string | undefined, configuredOrigin: string | undefined,
@@ -71,6 +73,27 @@ function isDevTrustedPkceOrigin(origin: string) {
} }
} }
export function resolveAdminOidcAuthority(
configuredAuthority: string | undefined,
browserOrigin: string,
) {
const trimmed = configuredAuthority?.trim();
if (trimmed) {
return trimmed;
}
try {
const originUrl = new URL(browserOrigin);
if (isDevTrustedPkceOrigin(originUrl.origin)) {
return `${originUrl.protocol}//${originUrl.hostname}:${ADMIN_LOCAL_OIDC_PORT}/oidc`;
}
} catch {
return ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY;
}
return ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY;
}
export function canStartBrowserPkceLogin({ export function canStartBrowserPkceLogin({
isSecureContext = window.isSecureContext, isSecureContext = window.isSecureContext,
origin = window.location.origin, origin = window.location.origin,

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
canManageTenantScopedUsers,
canManageUserInTenantScope,
isSuperAdminRole, isSuperAdminRole,
normalizeAdminRole, normalizeAdminRole,
ROLE_SUPER_ADMIN, ROLE_SUPER_ADMIN,
@@ -32,4 +34,43 @@ describe("admin role helpers", () => {
expect(isSuperAdminRole("admin")).toBe(false); expect(isSuperAdminRole("admin")).toBe(false);
expect(isSuperAdminRole(undefined)).toBe(false); expect(isSuperAdminRole(undefined)).toBe(false);
}); });
it("allows delegated tenant admins with manageable tenants to manage scoped users", () => {
const profile = {
id: "admin-user",
role: "user",
manageableTenants: [{ id: "tenant-1", slug: "tenant-a" }],
};
expect(canManageTenantScopedUsers(profile)).toBe(true);
expect(
canManageUserInTenantScope({
profile,
user: { id: "user-1", tenantSlug: "tenant-a" },
}),
).toBe(true);
expect(
canManageUserInTenantScope({
profile,
user: { id: "user-2", tenantSlug: "tenant-b" },
}),
).toBe(false);
});
it("does not treat ordinary tenant membership as delegated user management", () => {
const profile = {
id: "member-user",
role: "user",
tenantSlug: "tenant-a",
manageableTenants: [],
};
expect(canManageTenantScopedUsers(profile)).toBe(false);
expect(
canManageUserInTenantScope({
profile,
user: { id: "user-1", tenantSlug: "tenant-a" },
}),
).toBe(false);
});
}); });

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