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_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token
# --- NAVER WORKS Drive backup upload ---
# Drive API 업로드에는 `file` scope가 필요합니다.
# 운영에서는 Drive 권한이 위임된 사용자/OAuth access token을 우선 사용하세요.
# 서비스 계정 JWT 방식은 WORKS 앱 정책에서 Drive API scope 위임이 허용된 경우에만 사용할 수 있습니다.
WORKS_DRIVE_TARGET=sharedrive
WORKS_DRIVE_SHARED_DRIVE_ID=
WORKS_DRIVE_PARENT_FILE_ID=
WORKS_DRIVE_USER_ID=me
WORKS_DRIVE_GROUP_ID=
WORKS_DRIVE_SHARED_FOLDER_ID=
WORKS_DRIVE_ACCESS_TOKEN=
WORKS_DRIVE_ACCESS_TOKEN_FILE=
WORKS_DRIVE_ACCESS_TOKEN_CMD=
WORKS_DRIVE_OAUTH_SCOPE=file
WORKS_DRIVE_OAUTH_CLIENT_ID=
WORKS_DRIVE_OAUTH_CLIENT_SECRET=
WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT=
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE=./config/worksmobile-driveapp-private-key.pem
WORKS_DRIVE_OAUTH_REFRESH_TOKEN=
WORKS_DRIVE_OAUTH_REDIRECT_URI=
WORKS_DRIVE_SPLIT_SIZE=9000M
WORKS_DRIVE_MAX_SINGLE_FILE_BYTES=0
WORKS_DRIVE_FORCE_SPLIT=false
WORKS_DRIVE_OVERWRITE=false
WORKS_DRIVE_DRY_RUN=false
WORKS_DRIVE_UPLOAD_REPORTS=true
WORKS_DRIVE_REPORT_FOLDER_NAME=reports
# Audit System Configuration
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
@@ -118,6 +146,8 @@ HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
# HYDRA_LOGIN_URL=https://sso.hmac.kr/login
# HYDRA_CONSENT_URL=https://sso.hmac.kr/consent
# 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_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
@@ -150,5 +180,9 @@ VITE_OIDC_CLIENT_ID=devfront
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
DEVFRONT_URL=http://localhost:5174
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
ORGFRONT_URL=http://localhost:5175
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,https://sso.hmac.kr/orgfront/auth/callback
VITE_ORGCHART_URL=
# promtail에서 로그를 전송받을 Loki 서버 엔드포인트 URL
LOKI_URL=http://loki:3100/loki/api/v1/push

View File

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

View File

@@ -131,7 +131,8 @@ jobs:
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)'
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
userfront=false
@@ -154,7 +155,7 @@ jobs:
if matches "$front_shared|^adminfront/|^devfront/|^orgfront/"; then biome=true; fi
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
fi
@@ -204,7 +205,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.26.2"
cache-dependency-path: backend/go.sum
- name: Setup Flutter
@@ -213,42 +214,6 @@ jobs:
channel: "stable"
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
run: |
docker run --rm \
@@ -353,7 +318,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
go-version: "1.26.2"
cache-dependency-path: backend/go.sum
- name: Run backend tests
@@ -695,6 +660,7 @@ jobs:
mkdir -p reports
set +e
cd userfront
rm -rf build/web
flutter build web --wasm --release 2>&1 | tee ../reports/userfront-e2e-build.log
build_exit_code=${PIPESTATUS[0]}
cd ..
@@ -878,7 +844,7 @@ jobs:
adminfront-vitest-coverage:
needs:
- changes
- lint
- biome-check
if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest
steps:
@@ -1009,7 +975,7 @@ jobs:
devfront-vitest-coverage:
needs:
- changes
- lint
- biome-check
if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest
steps:
@@ -1140,7 +1106,7 @@ jobs:
orgfront-vitest-coverage:
needs:
- changes
- lint
- biome-check
if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_front_coverage == true) }}
runs-on: ubuntu-latest
steps:
@@ -1271,7 +1237,7 @@ jobs:
adminfront-tests:
needs:
- changes
- lint
- biome-check
if: ${{ always() && needs.changes.outputs.adminfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -1366,7 +1332,7 @@ jobs:
devfront-tests:
needs:
- changes
- lint
- biome-check
if: ${{ always() && needs.changes.outputs.devfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
runs-on: ubuntu-latest
steps:
@@ -1460,7 +1426,7 @@ jobs:
run: |
mkdir -p ../reports
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]}
set -e
@@ -1476,7 +1442,7 @@ jobs:
echo "1. \`cd devfront\`"
echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`"
echo "3. \`pnpm exec playwright install --with-deps\`"
echo "4. \`pnpm test\`"
echo "4. \`pnpm run test:ci\`"
echo
echo "## Log Tail (last 200 lines)"
echo '```text'
@@ -1549,7 +1515,7 @@ jobs:
orgfront-tests:
needs:
- changes
- lint
- biome-check
if: ${{ always() && needs.changes.outputs.orgfront == 'true' && (github.event_name != 'workflow_dispatch' || inputs.run_orgfront_tests == true) }}
runs-on: ubuntu-latest
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
fi
# Re-tag backend image
echo "Re-tagging backend image..."
skopeo copy --preserve-digests \
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
--src-tls-verify=false --dest-tls-verify=false \
"docker://${HARBOR_HOSTNAME}/baron_sso/backend:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/backend:${RE_TAG}"
# Re-tag userfront image
echo "Re-tagging userfront image..."
skopeo copy --preserve-digests \
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
--src-tls-verify=false --dest-tls-verify=false \
"docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/userfront:${RE_TAG}"
for image in backend userfront adminfront devfront orgfront; do
echo "Re-tagging ${image} image..."
skopeo copy --preserve-digests \
--src-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" --dest-creds "${HARBOR_USER}:${HARBOR_PASSWORD}" \
--src-tls-verify=false --dest-tls-verify=false \
"docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${BASE_TAG}" "docker://${HARBOR_HOSTNAME}/baron_sso/${image}:${RE_TAG}"
done
echo "final_image_tag=${RE_TAG}" >> "$GITHUB_OUTPUT"
@@ -68,6 +62,9 @@ jobs:
IMAGE_TAG: ${{ steps.retag.outputs.final_image_tag }}
BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend
USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront
ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront
DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront
ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront
DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
@@ -101,8 +98,12 @@ jobs:
"CLICKHOUSE_PORT_NATIVE=${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }}" \
"CLICKHOUSE_USER=${{ vars.PROD_CLICKHOUSE_USER }}" \
"CLICKHOUSE_PASSWORD=${{ secrets.PROD_CLICKHOUSE_PASSWORD }}" \
"BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
"USERFRONT_PORT=${{ vars.PROD_USERFRONT_PORT }}" \
"PROD_BACKEND_PORT=${{ vars.PROD_BACKEND_PORT }}" \
"BACKEND_PORT=3000" \
"USERFRONT_PORT=${{ vars.PROD_FRONTEND_PORT }}" \
"ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}" \
"DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}" \
"ORGFRONT_PORT=${{ vars.ORGFRONT_PORT }}" \
"DB_USER=${{ vars.PROD_DB_USER }}" \
"DB_PASSWORD=${{ secrets.PROD_DB_PASSWORD }}" \
"DB_NAME=${{ vars.PROD_DB_NAME }}" \
@@ -117,10 +118,34 @@ jobs:
"AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}" \
"AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}" \
"AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}" \
"USERFRONT_URL=${{ vars.PROD_USERFRONT_URL }}" \
"USERFRONT_URL=${{ vars.PROD_FRONTEND_URL }}" \
"ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}" \
"DEVFRONT_URL=${{ vars.DEVFRONT_URL }}" \
"ORGFRONT_URL=${{ vars.ORGFRONT_URL }}" \
"BACKEND_URL=${{ vars.PROD_BACKEND_URL }}" \
"VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }}" \
"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
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
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}/"
@@ -131,6 +156,9 @@ jobs:
"export DEPLOY_PATH='${DEPLOY_PATH}'; \
export BACKEND_IMAGE_NAME='${BACKEND_IMAGE_NAME}'; \
export USERFRONT_IMAGE_NAME='${USERFRONT_IMAGE_NAME}'; \
export ADMINFRONT_IMAGE_NAME='${ADMINFRONT_IMAGE_NAME}'; \
export DEVFRONT_IMAGE_NAME='${DEVFRONT_IMAGE_NAME}'; \
export ORGFRONT_IMAGE_NAME='${ORGFRONT_IMAGE_NAME}'; \
export IMAGE_TAG='${IMAGE_TAG}'; \
export HARBOR_ENDPOINT='${HARBOR_ENDPOINT}'; \
export HARBOR_ROBOT_ACCOUNT='${HARBOR_ROBOT_ACCOUNT}'; \

View File

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

View File

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

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_HOST=${{ vars.CLICKHOUSE_HOST }}
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
CLICKHOUSE_PASSWORD=${{ vars.CLICKHOUSE_PASSWORD }}
CLICKHOUSE_PASSWORD=${{ secrets.CLICKHOUSE_PASSWORD }}
BACKEND_PORT=${{ vars.BACKEND_PORT }}
@@ -123,6 +123,7 @@ jobs:
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
HYDRA_REFRESH_TOKEN_TTL=${{ vars.HYDRA_REFRESH_TOKEN_TTL }}
JWKS_URL=${{ vars.JWKS_URL }}
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
@@ -142,9 +143,32 @@ jobs:
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
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}/adminfront"
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p ${DEPLOY_PATH}/scripts"
# [중요] docker/ory 폴더 복사 (여기에 init-db/1-createdb.sql이 있어야 함)
scp -r docker/ory "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/docker/"
@@ -158,9 +182,10 @@ jobs:
fi
scp adminfront/seed-tenant.csv "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/adminfront/"
scp scripts/render_ory_config.sh "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/scripts/"
scp docker/docker-compose.staging.template.yaml .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
scp docker/compose.infra.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.infra.yml"
scp docker/compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
scp compose.ory.yaml "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/compose.ory.yml"
# 배포 실행
echo "${HARBOR_ROBOT_KEY}" | ssh "${STAGE_USER}@${STAGE_HOST}" \
@@ -181,6 +206,9 @@ jobs:
for net in baron_net public_net ory-net hydranet kratosnet; do
docker network inspect \"\$net\" >/dev/null 2>&1 || docker network create \"\$net\"
done
bash scripts/render_ory_config.sh; \
chmod -R 777 config/.generated/ory || true; \
envsubst < docker-compose.staging.template.yaml > docker-compose.yml; \

View File

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

2
.gitignore vendored
View File

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

285
Makefile
View File

@@ -29,54 +29,126 @@ ifneq (,$(wildcard ./.env))
COMPOSE_DROP_ENV_ARGS += --env-file .env
endif
.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev 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..."
@mkdir -p config/.generated
@bash scripts/auth_config.sh build
validate-auth-config: build-auth-config
validate-auth-config: build-auth-config ## 인증 설정 값 검증
@echo "Validating auth config..."
@bash scripts/auth_config.sh validate
verify-auth-config: validate-auth-config
verify-auth-config: validate-auth-config ## 인증 설정 연결 상태 확인
@echo "Verifying auth config wiring..."
@bash scripts/auth_config.sh verify
render-ory-config: validate-auth-config
render-ory-config: validate-auth-config ## Ory 설정 파일 렌더링
@echo "Rendering Ory config..."
@bash scripts/render_ory_config.sh
# --- 기본 실행 ---
# 주의: --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)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos
# --- 개별 스택 실행 ---
up-infra: ensure-networks
up-infra: ensure-networks ## 인프라 스택 실행
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
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)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
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)..."
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..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build -d backend
ensure-networks:
ensure-networks: ## 개발용 Docker 네트워크 보장
@echo "Ensuring Docker networks..."
@for network in $(DEV_NETWORKS); do \
if ! docker network inspect "$$network" >/dev/null 2>&1; then \
@@ -87,7 +159,7 @@ ensure-networks:
fi; \
done
ensure-infra: ensure-networks
ensure-infra: ensure-networks ## 인프라 스택 실행 상태 보장
@echo "Ensuring Infra stack..."
@missing=0; \
for container in $(INFRA_CONTAINERS); do \
@@ -103,7 +175,7 @@ ensure-infra: ensure-networks
echo "Infra stack is already running."; \
fi
ensure-ory: ensure-networks render-ory-config
ensure-ory: ensure-networks render-ory-config ## Ory 스택 실행 상태 보장
@echo "Ensuring Ory stack..."
@missing=0; \
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; \
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)."
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)."
dev: up-dev
dev: up-dev ## 개발 앱 컨테이너를 포그라운드로 실행
@echo "Starting development app containers in foreground attach mode..."
BACKEND_LOG_LEVEL=info CLIENT_LOG_DEBUG=false VITE_CLIENT_LOG_DEBUG=false docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
dev-debug: up-dev
dev-debug: up-dev ## 디버그 로그로 개발 앱 컨테이너 실행
@echo "Starting development app containers in foreground attach debug mode..."
BACKEND_LOG_LEVEL=debug CLIENT_LOG_DEBUG=true VITE_CLIENT_LOG_DEBUG=true USERFRONT_FLUTTER_RUN_FLAGS=--debug docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
# --- 종료 (Down) ---
down:
down: ## 전체 로컬 스택 중지
@echo "Stopping ALL stacks (infra + ory + app)..."
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..."
-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..."
@@ -148,25 +268,25 @@ drop:
done
@echo "Drop complete. External Docker networks are preserved."
down-app:
down-app: ## 앱 스택 중지
@echo "Stopping App stack..."
docker compose -f $(COMPOSE_APP) down
down-backend:
down-backend: ## 백엔드 컨테이너 중지
@echo "Stopping Backend only..."
docker compose -f $(COMPOSE_APP) stop backend
down-infra:
down-infra: ## 인프라 스택 중지
@echo "Stopping Infra stack..."
docker compose -f $(COMPOSE_INFRA) down
down-ory:
down-ory: ## Ory 스택 중지
@echo "Stopping Ory stack..."
docker compose -f $(COMPOSE_ORY) down
# --- 유틸리티 ---
# 인프라 상태 확인
check-infra:
check-infra: ## 인프라 헬스 상태 확인
@echo "Checking infra status..."
@if [ "$$(docker inspect -f '{{.State.Health.Status}}' baron_postgres 2>/dev/null)" != "healthy" ]; then \
echo "Error: PostgreSQL is not running or not healthy."; \
@@ -176,18 +296,77 @@ check-infra:
echo "PostgreSQL is healthy."; \
fi
ps:
ps: ## 전체 Compose 컨테이너 상태 조회
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) ps
logs-infra:
logs-infra: ## 인프라 스택 로그 팔로우
docker compose -f $(COMPOSE_INFRA) logs -f
logs-ory:
logs-ory: ## Ory 스택 로그 팔로우
docker compose -f $(COMPOSE_ORY) logs -f
logs-app:
logs-app: ## 앱 스택 로그 팔로우
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_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE
@@ -203,12 +382,12 @@ CODE_CHECK_TEST_JOBS ?= 1
PLAYWRIGHT_WORKERS ?= 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."
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)"
@$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \
code-check-backend-tests \
@@ -218,20 +397,20 @@ code-check-test-jobs:
code-check-devfront-tests \
code-check-orgfront-tests
code-check-i18n:
code-check-i18n: ## i18n 리소스 검사
@echo "==> i18n resource check"
@mkdir -p reports
node tools/i18n-scanner/index.js
node tools/i18n-scanner/report.js
@cat reports/i18n-report.txt
code-check-i18n-values:
code-check-i18n-values: ## i18n 번역 값 품질 검사
@echo "==> i18n value quality check"
@mkdir -p reports
node tools/i18n-scanner/value-check.js
@cat reports/i18n-value-report.txt
code-check-go-lint:
code-check-go-lint: ## Go 포맷과 린트 검사
@echo "==> go lint/format check"
@if command -v golangci-lint >/dev/null 2>&1; then \
cd backend && golangci-lint fmt -E gofmt -E gofumpt -d; \
@@ -247,11 +426,11 @@ code-check-go-lint:
exit 1; \
fi
code-check-sync-userfront-locales:
code-check-sync-userfront-locales: ## UserFront 로케일 동기화 검사
@echo "==> sync userfront locales"
/bin/sh ./scripts/sync_userfront_locales.sh
code-check-userfront-install:
code-check-userfront-install: ## UserFront 의존성 설치
@echo "==> install userfront dependencies"
@if command -v flutter >/dev/null 2>&1; then \
cd userfront && flutter pub get; \
@@ -259,7 +438,7 @@ code-check-userfront-install:
echo "WARNING: flutter not found, skipping userfront dependencies install."; \
fi
code-check-userfront-lint:
code-check-userfront-lint: ## UserFront 포맷과 analyze 검사
@echo "==> userfront format/analyze"
@if command -v dart >/dev/null 2>&1; then \
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."; \
fi
code-check-front-lint:
code-check-front-lint: ## 프론트엔드 Biome 린트와 포맷 검사
@echo "==> adminfront biome lint/format check"
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 format .
@echo "==> devfront biome lint/format check"
@@ -289,15 +472,19 @@ code-check-front-lint:
cd devfront && npx biome format .
@echo "==> orgfront biome lint/format check"
rm -rf orgfront/playwright-report orgfront/test-results
cd orgfront && npm ci --ignore-scripts
cd orgfront && npx biome lint .
cd orgfront && npx biome format .
@if [ -d orgfront/node_modules ]; then \
echo "orgfront/node_modules already present; skipping npm install."; \
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"
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)"
@if ! command -v flutter >/dev/null 2>&1; then \
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/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY)
code-check-adminfront-tests:
code-check-adminfront-tests: ## AdminFront 테스트 실행
@echo "==> 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"
@mkdir -p reports/devfront
@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; \
exit $$status
code-check-orgfront-tests:
code-check-orgfront-tests: ## OrgFront 테스트 실행
@echo "==> orgfront tests"
@mkdir -p reports/orgfront
@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; \
exit $$status
code-check-userfront-e2e-tests:
code-check-userfront-e2e-tests: ## UserFront WASM E2E 테스트 실행
@echo "==> userfront wasm playwright e2e tests (isolated workspace)"
@if ! command -v flutter >/dev/null 2>&1; then \
echo "WARNING: flutter not found, skipping userfront e2e tests."; \

218
README.md
View File

@@ -40,6 +40,20 @@ baron_sso/
* AdminFront: 사용자 관리 등 Admin 기능
* 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)
@@ -378,6 +392,61 @@ flowchart TD
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)
@@ -527,6 +596,155 @@ docker compose --env-file .env --env-file config/.generated/auth-config.env -f d
- **Hydra Public**: http://localhost:4444
- **Kratos UI (UserFront)**: http://localhost:5000
### 전체 백업/복구
전체 백업/복구는 CSV export/import가 아니라 Baron SSO와 Ory Stack 저장소를 같은 시점의 재해 복구 단위로 보존하는 절차입니다. 사용자 UUID, Kratos identity ID, Hydra/Keto 원장, WORKS 연동 mapping이 어긋나면 안 되므로 운영 복구는 DB dump와 설정 snapshot을 함께 다룹니다.
#### 백업 실행
```bash
# 전체 백업
make dump
# 출력 위치를 직접 지정
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 일부 서비스만 백업
make dump DUMP_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config
make dump DUMP_SERVICES=ory-postgres,ory-clickhouse
# 생성된 백업 검증
make dump-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# WORKS Drive로 외부 분산 저장
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 지정 경로로 dump 후 바로 WORKS Drive 업로드
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 로컬 백업 목록
make dump-list
```
기본값은 `DUMP_SERVICES=all`, `DUMP_MODE=maintenance`입니다. `DUMP_SERVICES`는 다음 값을 콤마로 조합할 수 있습니다.
| 값 | 대상 |
| --- | --- |
| `postgres` | Baron Postgres (`baron_postgres`, `${DB_NAME:-baron_sso}`) |
| `ory-postgres` | Ory Postgres의 `${KRATOS_DB:-ory_kratos}`, `${HYDRA_DB:-ory_hydra}`, `${KETO_DB:-ory_keto}` |
| `clickhouse` | Baron ClickHouse (`baron_clickhouse`) |
| `ory-clickhouse` | Ory ClickHouse (`ory_clickhouse`) |
| `config` | `.env` redacted copy, generated Ory config, gateway, 주요 compose 파일 |
백업 산출물은 기본적으로 `backups/baron-sso-backup-YYYYMMDD-HHMMSSZ/` 아래에 생성됩니다.
```text
manifest.json
checksums.sha256
postgres/
clickhouse/
config/
reports/
```
#### WORKS Drive 외부 업로드
`make dump`, `make restore`, `make upload-cloud`는 기본적으로 `docker/backup-tools/Dockerfile`에서 빌드한 `baron-sso-backup-tools:local` 컨테이너 안에서 실행됩니다. 호스트에는 Docker와 Docker socket 접근 권한만 필요하고, `zstd`, `jq`, `curl`, `openssl`, `postgresql-client` 같은 백업/복구 도구는 backup-tools image에 포함됩니다.
`make upload-cloud`는 기존 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 묶은 뒤 WORKS Drive에 업로드합니다. 압축 포맷은 `.tar.zst`로 고정되어 있고, 압축/해제는 backup-tools 컨테이너 내부의 `zstd`로 수행합니다.
백업이 완료되면 `reports/backup-report.md`도 생성됩니다. 이 report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록됩니다. `make upload-cloud`는 `reports/*.md`만 WORKS Drive 대상 폴더 아래의 `reports` 하위 폴더로 업로드하며, 업로드 파일명은 `backup-report-YYYYMMDD-HHMMSSZ.md`처럼 업로드 시각을 붙입니다. `reports/cloud-upload.json`은 로컬 업로드 실행 기록으로만 남기고 Drive에는 업로드하지 않습니다.
```bash
# 권장: 백업 경로를 명시해서 dump와 upload를 분리
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 또는 같은 BACKUP 경로로 연속 실행
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
# 실제 업로드 전 endpoint와 target만 확인
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ WORKS_DRIVE_DRY_RUN=true
# 예외적으로 호스트 도구로 직접 실행
make restore BACKUP_USE_DOCKER=false BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ CONFIRM_RESTORE=baron-sso
```
주요 변수:
| 변수 | 설명 |
| --- | --- |
| `WORKS_DRIVE_TARGET` | `sharedrive`, `mydrive`, `group`, `sharedfolder` 중 하나. 기본값은 `sharedrive`입니다. |
| `WORKS_DRIVE_SHARED_DRIVE_ID` | `WORKS_DRIVE_TARGET=sharedrive`일 때 공용 드라이브 ID입니다. |
| `WORKS_DRIVE_PARENT_FILE_ID` | 업로드할 대상 폴더의 WORKS Drive `fileId`입니다. 폴더 이름이나 경로가 아니며, 비우면 대상 drive/folder root에 업로드합니다. |
| `WORKS_DRIVE_USER_ID` | `mydrive` 또는 `sharedfolder` 대상 사용자 ID입니다. 기본값은 `me`입니다. |
| `WORKS_DRIVE_GROUP_ID` | `WORKS_DRIVE_TARGET=group`일 때 조직/그룹 ID입니다. |
| `WORKS_DRIVE_SHARED_FOLDER_ID` | `WORKS_DRIVE_TARGET=sharedfolder`일 때 공유받은 폴더 ID입니다. |
| `WORKS_DRIVE_ACCESS_TOKEN` | Drive API 호출용 Bearer token입니다. Drive API는 `file` scope가 필요합니다. |
| `WORKS_DRIVE_ACCESS_TOKEN_FILE` | access token을 파일에서 읽을 때 사용합니다. |
| `WORKS_DRIVE_ACCESS_TOKEN_CMD` | access token을 명령 출력으로 주입할 때 사용합니다. |
| `WORKS_DRIVE_OAUTH_SCOPE` | Drive 업로드 앱 OAuth token에 사용할 scope입니다. 기본값은 `file`입니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_ID` | Drive 업로드 앱의 OAuth client ID입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_CLIENT_ID`와 분리합니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_SECRET` | Drive 업로드 앱의 OAuth client secret입니다. |
| `WORKS_DRIVE_OAUTH_REFRESH_TOKEN` | Drive 업로드 앱의 refresh token입니다. 명시 access token이 없으면 이 값으로 access token을 갱신합니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT` | Drive 업로드 앱의 service account입니다. JWT `sub`에 들어갑니다. |
| `WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE` | Drive 업로드 앱 private key 파일입니다. 예: `./config/worksmobile-driveapp-private-key.pem` |
| `WORKS_DRIVE_SPLIT_SIZE` | 분할 업로드 시 part 크기입니다. 기본값은 `9000M`입니다. |
| `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` | 이 값보다 archive가 크면 split part로 나눕니다. 기본값 `0`은 자동 분할 비활성입니다. |
| `WORKS_DRIVE_FORCE_SPLIT` | `true`이면 크기와 무관하게 split part로 업로드합니다. |
| `WORKS_DRIVE_OVERWRITE` | WORKS Drive upload URL 생성 요청의 overwrite 플래그입니다. 기본값은 `false`입니다. |
| `WORKS_DRIVE_UPLOAD_REPORTS` | `true`이면 `reports/*.md`를 Drive의 report 폴더로 함께 업로드합니다. 기본값은 `true`입니다. |
| `WORKS_DRIVE_REPORT_FOLDER_NAME` | Markdown report를 업로드할 하위 폴더 이름입니다. 기본값은 `reports`입니다. |
Drive API는 업로드 URL 생성 후 해당 URL에 multipart `Filedata`로 실제 파일을 전송하는 2단계 방식입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 Drive 업로드용 `WORKS_DRIVE_OAUTH_*`는 서로 다른 앱/키로 관리합니다. token 우선순위는 `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 서비스 계정 JWT fallback 순서입니다. 운영에서는 Drive API 권한과 `file` scope 위임 정책을 먼저 확인해야 합니다.
#### 복구 계획과 복구 실행
```bash
# 복구 전 계획 확인
make restore-plan BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
CONFIRM_RESTORE=baron-sso
# 복구 실행
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
CONFIRM_RESTORE=baron-sso
# .tar.zst archive를 직접 복구 입력으로 사용
make restore DUMP_FILE=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ.tar.zst \
RESTORE_SERVICES=all \
CONFIRM_RESTORE=baron-sso
# report 경로를 명시
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
CONFIRM_RESTORE=baron-sso \
RESTORE_REPORT=reports/restore/baron-sso-restore-report.json
# 복구 후 기본 검증
make restore-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
```
복구는 반드시 빈 volume 또는 restore 전용 stack에서 수행하는 것을 기본 정책으로 합니다. `make restore`는 `BACKUP` 또는 `DUMP_FILE` 중 하나와 `CONFIRM_RESTORE=baron-sso`가 없으면 실패하고, 기본적으로 non-empty Postgres 대상에는 복구하지 않습니다. 승인된 restore rehearsal에서만 `ALLOW_NON_EMPTY_RESTORE=true`를 사용하세요. `DUMP_FILE=.tar.zst` 해제도 backup-tools 컨테이너에서 수행하므로 호스트 `zstd` 설치에 의존하지 않습니다.
`make restore`는 복구 report를 JSON과 Markdown으로 남깁니다. `BACKUP` 디렉터리 입력의 기본 JSON report는 `<BACKUP>/reports/restore-report.json`이고, `DUMP_FILE` archive 입력의 기본 JSON report는 `reports/restore/<archive-name>-restore-report.json`입니다. 같은 경로에 `.md` 확장자의 Markdown 요약도 함께 생성됩니다. `RESTORE_REPORT`로 직접 지정할 수 있습니다. report에는 입력 archive, 복구 서비스, checksum 검증 상태, 복구 후 대상 row count 비교 결과가 기록됩니다.
`config` 복구는 운영 파일을 직접 덮어쓰지 않고 `config-restored/`에 풀어 수동 검토하도록 합니다. migration은 자동 실행하지 않으며, Ory Stack과 backend 기동 후 super admin login, 대표 OIDC login, WORKS comparison dry-run을 통과하기 전까지 WORKS relay를 자동 재개하지 않습니다.
#### 백업/복구 범위
필수 백업 대상:
- Baron Postgres: users, tenants, user_login_ids, user_groups, RP metadata, WORKS mapping/outbox 등
- Ory Postgres: Kratos identity/credentials/session, Hydra client/consent/token state, Keto relation tuple
- Baron ClickHouse: 감사 로그와 RP usage event
- Ory ClickHouse: Oathkeeper/Ory 계열 접근 로그
- 설정 snapshot: `.env` redacted copy, generated Ory config, gateway, compose 파일
기본 제외 대상:
- Redis: pending login, short code, cache 등 휘발성 데이터이므로 복구 후 재수렴 대상으로 봅니다.
- 프론트 빌드 산출물: 소스와 이미지 태그로 재생성합니다.
- coverage, reports, test-results 같은 로컬 개발 산출물
상세 설계와 운영 정책은 `docs/backup-restore-design.md`를 기준으로 유지합니다.
### MCP 서버 (Hydra/Kratos/Keto)
MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다.
프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요.

View File

@@ -1,29 +1,51 @@
FROM node:lts
FROM node:lts AS deps
WORKDIR /workspace
# Set CI environment variable to true to avoid TTY issues with pnpm
ENV CI=true
ENV ADMINFRONT_BUILD_OUT_DIR=/workspace/adminfront/dist
# Install pnpm
RUN corepack enable && corepack prepare pnpm@10.5.2 --activate
# Copy workspace configs and common package
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY common ./common
COPY adminfront ./adminfront
# Install dependencies for the workspace
RUN pnpm install --filter adminfront... --filter baron-sso... --no-frozen-lockfile --ignore-scripts
ARG VITE_ADMIN_PUBLIC_URL
ARG VITE_OIDC_AUTHORITY
ARG VITE_OIDC_CLIENT_ID
ARG ORGFRONT_URL
ENV VITE_ADMIN_PUBLIC_URL=$VITE_ADMIN_PUBLIC_URL
ENV VITE_OIDC_AUTHORITY=$VITE_OIDC_AUTHORITY
ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
ENV ORGFRONT_URL=$ORGFRONT_URL
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
RUN npm install -g serve
RUN pnpm install --frozen-lockfile --ignore-scripts
FROM deps AS dev
WORKDIR /workspace/adminfront
ENV NODE_ENV=development
# Vite 기본 포트
EXPOSE 5173
# 실행 스크립트: APP_ENV에 따라 개발 서버 또는 빌드 후 서빙
RUN chmod +x ./scripts/runtime-mode.sh
CMD ["sh", "./scripts/runtime-mode.sh"]
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
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 baseURL = process.env.BASE_URL ?? defaultBaseUrl;
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;
/**
@@ -84,7 +86,7 @@ export default defineConfig({
webServer: process.env.BASE_URL
? undefined
: {
command: process.env.CI
command: usePreviewServer
? `pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort`
: `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`,
url: `http://127.0.0.1:${port}`,

View File

@@ -51,14 +51,17 @@ ensure_frontend_dependencies() {
if [ -n "$WORKSPACE_ROOT" ]; then
WORKSPACE_DIR="$WORKSPACE_ROOT"
LOCK_FILE="$WORKSPACE_ROOT/pnpm-lock.yaml"
COMMON_PACKAGE_FILE="$WORKSPACE_ROOT/common/package.json"
INSTALL_CMD="cd $WORKSPACE_ROOT && CI=true pnpm install --filter ${APP_PACKAGE_NAME}... --frozen-lockfile --ignore-scripts"
elif [ -f "pnpm-lock.yaml" ]; then
WORKSPACE_DIR="."
LOCK_FILE="pnpm-lock.yaml"
COMMON_PACKAGE_FILE="/workspace/common/package.json"
INSTALL_CMD="CI=true pnpm install --frozen-lockfile --ignore-scripts"
else
WORKSPACE_DIR="."
LOCK_FILE="package-lock.json"
COMMON_PACKAGE_FILE="/workspace/common/package.json"
INSTALL_CMD="npm ci"
fi
@@ -100,9 +103,9 @@ ensure_frontend_dependencies() {
}
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
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
deps_stamp="node_modules/.baron-deps-hash"
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
@@ -111,9 +114,9 @@ ensure_frontend_dependencies() {
echo "Installing frontend dependencies..."
acquire_install_lock
if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 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
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
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
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,,,
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
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");
});
it("registers the super-admin user projection management route", () => {
const matches = matchRoutes(adminRoutes, "/system/projections/users");
it("registers the super-admin Ory SSOT system route", () => {
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", () => {
@@ -28,6 +28,33 @@ describe("admin routes", () => {
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", () => {
const rootRoute = adminRoutes.find((route) => route.path === "/");
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) {
if (
typeof element === "object" &&

View File

@@ -1,29 +1,33 @@
import type { ComponentType } from "react";
import type { RouteObject } from "react-router-dom";
import { createBrowserRouter } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout";
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import UserProjectionPage from "../features/projections/UserProjectionPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
import 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";
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[] = [
{
path: "/login",
@@ -40,33 +44,147 @@ export const adminRoutes: RouteObject[] = [
{
element: <AppLayout />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
{
index: true,
lazy: lazyDefault(
() => import("../features/overview/GlobalOverviewPage"),
),
},
{
path: "audit-logs",
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",
element: <TenantDetailPage />,
lazy: lazyDefault(
() => import("../features/tenants/routes/TenantDetailPage"),
),
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{
index: true,
lazy: lazyNamed(
() => 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",
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 (
<select
id="admin-language-selector"
name="admin-language-selector"
value={locale}
onChange={(event) => handleChange(event.target.value as Locale)}
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"

View File

@@ -31,4 +31,27 @@ describe("LocaleRefreshBoundary", () => {
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 { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
type LocaleRefreshBoundaryProps = {
children: ReactNode;
@@ -12,12 +13,19 @@ function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) {
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("storage", syncLocale);
window.addEventListener("storage", syncLocaleFromStorage);
return () => {
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("Org Chart")).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();
const navigation = screen.getByRole("navigation");
const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) =>
@@ -113,9 +113,10 @@ describe("admin AppLayout", () => {
"Tenants",
"Org Chart",
"Worksmobile",
"User Projection",
"Ory SSOT System",
"Data Integrity",
"Users",
"권한 부여",
"Auth Guard",
"API Keys",
"Audit Logs",
@@ -127,6 +128,22 @@ describe("admin AppLayout", () => {
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
});
it("toggles the sidebar and persists the collapsed state", async () => {
renderLayout();
const collapseButton = await screen.findByRole("button", {
name: "사이드바 접기",
});
fireEvent.click(collapseButton);
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
"true",
);
expect(
screen.getByRole("button", { name: "사이드바 펼치기" }),
).toBeInTheDocument();
});
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
renderLayout();

View File

@@ -26,11 +26,13 @@ import {
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellSidebarCollapsed,
readShellTheme,
type ShellSidebarNavItem,
type ShellTranslator,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
writeShellSidebarCollapsed,
} from "../../../../common/shell";
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
@@ -60,6 +62,12 @@ const staticNavItems: ShellSidebarNavItem[] = [
to: "/users",
icon: Users,
},
{
labelKey: "ui.admin.nav.permissions_direct",
labelFallback: "권한 부여",
to: "/permissions-direct",
icon: ShieldCheck,
},
{
labelKey: "ui.admin.nav.auth_guard",
labelFallback: "Auth Guard",
@@ -165,6 +173,9 @@ function AppLayout() {
const isDevelopmentRuntime = import.meta.env.MODE === "development";
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileOpen, setIsProfileOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
readShellSidebarCollapsed(false),
);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
);
@@ -201,70 +212,71 @@ function AppLayout() {
...profile,
role: effectiveRole ?? profile?.role,
});
const filteredItems = items.filter((item) => {
if (item.to === "/api-keys") return isSuperAdmin;
return true;
});
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
{ includeInternal: true },
{ includeInternal: false },
);
if (isSuperAdmin) {
filteredItems.splice(1, 0, {
labelKey: "ui.admin.nav.tenants",
labelFallback: "Tenants",
to: "/tenants",
icon: Building2,
});
filteredItems.splice(2, 0, {
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
if (showWorksmobile) {
filteredItems.splice(3, 0, {
labelKey: "ui.admin.nav.worksmobile",
labelFallback: "Worksmobile",
to: "/worksmobile",
icon: LineWorksNavIcon,
});
}
filteredItems.splice(4, 0, {
labelKey: "ui.admin.nav.user_projection",
labelFallback: "User Projection",
to: "/system/projections/users",
icon: Database,
});
filteredItems.splice(5, 0, {
labelKey: "ui.admin.nav.data_integrity",
labelFallback: "Data Integrity",
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,
});
}
}
// Splice optional menus in a standard order
items.splice(1, 0, {
labelKey: "ui.admin.nav.tenants",
labelFallback: "Tenants",
to: "/tenants",
icon: Building2,
});
items.splice(2, 0, {
labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
items.splice(3, 0, {
labelKey: "ui.admin.nav.worksmobile",
labelFallback: "Worksmobile",
to: "/worksmobile",
icon: LineWorksNavIcon,
});
items.splice(4, 0, {
labelKey: "ui.admin.nav.ory_ssot",
labelFallback: "Ory SSOT System",
to: "/system/ory-ssot",
icon: Database,
});
items.splice(5, 0, {
labelKey: "ui.admin.nav.data_integrity",
labelFallback: "Data Integrity",
to: "/system/data-integrity",
icon: ShieldCheck,
});
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]);
const handleLogout = () => {
@@ -508,10 +520,18 @@ function AppLayout() {
return next;
});
};
const handleSidebarToggle = () => {
setIsSidebarCollapsed((prev) => {
const next = !prev;
writeShellSidebarCollapsed(next);
return next;
});
};
const sidebarNavContent = (
<div className={shellLayoutClasses.navList}>
{navItems.map((item) => {
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
const label = t(labelKey, labelFallback);
if (isExternal) {
return (
@@ -522,11 +542,18 @@ function AppLayout() {
rel="noopener noreferrer"
className={[
shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
shellLayoutClasses.navItemIdle,
].join(" ")}
title={label}
aria-label={label}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
<span className={isSidebarCollapsed ? "sr-only" : ""}>
{label}
</span>
</a>
);
}
@@ -539,6 +566,9 @@ function AppLayout() {
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
item.isActive !== undefined
? item.isActive
? shellLayoutClasses.navItemActive
@@ -548,9 +578,11 @@ function AppLayout() {
: shellLayoutClasses.navItemIdle,
].join(" ")
}
title={label}
aria-label={label}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
</NavLink>
);
})}
@@ -561,10 +593,17 @@ function AppLayout() {
<button
type="button"
onClick={handleLogout}
className={shellLayoutClasses.logoutButton}
className={
isSidebarCollapsed
? shellLayoutClasses.logoutButtonCollapsed
: shellLayoutClasses.logoutButton
}
title={t("ui.shell.nav.logout", "Logout")}
>
<LogOut size={18} />
<span>{t("ui.shell.nav.logout", "Logout")}</span>
<span className={isSidebarCollapsed ? "sr-only" : ""}>
{t("ui.shell.nav.logout", "Logout")}
</span>
</button>
</div>
);
@@ -578,13 +617,23 @@ function AppLayout() {
}
return (
<div className={shellLayoutClasses.root}>
<div
className={
isSidebarCollapsed
? shellLayoutClasses.rootCollapsed
: shellLayoutClasses.root
}
>
<AppSidebar
brandLabel={t("ui.admin.brand", "Baron 로그인")}
brandTitle={t("ui.admin.title", "Admin Control")}
brandIcon={<ShieldHalf size={20} />}
navContent={sidebarNavContent}
footerContent={sidebarFooterContent}
collapsed={isSidebarCollapsed}
onToggleCollapsed={handleSidebarToggle}
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
/>
<div className={shellLayoutClasses.contentWide}>
@@ -785,7 +834,7 @@ function AppLayout() {
</div>
</header>
<main className={shellLayoutClasses.mainMinWidth}>
<Outlet />
<Outlet context={isSidebarCollapsed} />
</main>
</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>(
({ className, onCheckedChange, ...props }, ref) => {
({ className, onCheckedChange, id, name, ...props }, ref) => {
const fallbackId = React.useId();
const fieldId = id ?? (name ? undefined : fallbackId);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onCheckedChange?.(e.target.checked);
};
return (
<input
id={fieldId}
name={name}
type="checkbox"
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary",

View File

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

View File

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

View File

@@ -18,6 +18,11 @@ const notify = () => {
};
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);
toasts = [...toasts, { id, message, type }];
notify();

View File

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

View File

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

View File

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

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 { 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() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const handledAuthErrorRef = useRef(false);
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
useEffect(() => {
if (!auth.error || handledAuthErrorRef.current || isTest) {
return;
}
handledAuthErrorRef.current = true;
clearStoredAdminAuthSession();
void Promise.resolve(
auth.removeUser ? auth.removeUser() : undefined,
).finally(() => {
navigate("/login", { replace: true });
});
}, [auth, auth.error, isTest, navigate]);
if (isTest) {
return <Outlet />;
}

View File

@@ -64,6 +64,8 @@ function PermissionChecker() {
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
</Label>
<select
id="permission-checker-namespace"
name="permission-checker-namespace"
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"

View File

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

View File

@@ -1,12 +1,23 @@
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 { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
import { TenantFineGrainedPermissionsTab } from "../tenants/routes/TenantFineGrainedPermissionsTab";
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 = [
{
id: "tenant-root",
@@ -50,7 +61,7 @@ const users = [
id: "user-owner",
name: "Owner User",
email: "owner@example.com",
role: "tenant_admin",
role: "super_admin",
status: "active",
},
{
@@ -84,12 +95,29 @@ vi.mock("react-oidc-context", () => ({
}));
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]]),
fetchTenantAdmins: vi.fn(async () => [users[1]]),
addTenantOwner: vi.fn(async () => undefined),
addTenantAdmin: vi.fn(async () => undefined),
removeTenantOwner: vi.fn(async () => undefined),
removeTenantAdmin: vi.fn(async () => undefined),
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 () => ({
items: users,
total: users.length,
@@ -100,10 +128,12 @@ vi.mock("../../lib/adminApi", () => ({
})),
updateTenant: vi.fn(async () => tenants[2]),
updateUser: vi.fn(async () => users[2]),
bulkUpdateUsers: bulkUpdateUsersMock,
exportTenantsCSV: vi.fn(async () => ({
blob: new Blob(["name,slug"]),
filename: "tenants.csv",
})),
exportUsersCSV: exportUsersCSVMock,
}));
function renderWithProviders(ui: React.ReactElement, entry: string) {
@@ -125,6 +155,10 @@ describe("admin tenant tab coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
"blob:tenant-users-export",
);
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
});
it("renders tenant owners and admins lists", async () => {
@@ -144,6 +178,22 @@ describe("admin tenant tab coverage smoke", () => {
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 () => {
renderWithProviders(
<Routes>
@@ -159,4 +209,68 @@ describe("admin tenant tab coverage smoke", () => {
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
expect(await screen.findByText("Member User")).toBeInTheDocument();
});
it("exports selected organization users by tenant slug", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/organization"
element={<TenantUserGroupsTab />}
/>
</Routes>,
"/tenants/tenant-company/organization",
);
expect(await screen.findByText("Member User")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("tenant-current-users-export-btn"));
await waitFor(() => {
expect(exportUsersCSVMock).toHaveBeenCalledWith("", "gpdtdc", false);
});
});
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,
fetchMe,
fetchOrphanUserLoginIDs,
fetchUserProjectionStatus,
reconcileUserProjection,
resetUserProjection,
} from "../../lib/adminApi";
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
import { createI18nMock } from "../../test/i18nMock";
import DataIntegrityPage from "./DataIntegrityPage";
@@ -63,24 +61,6 @@ vi.mock("../../lib/adminApi", () => ({
],
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 () => ({
deletedCount: 1,
deleted: [
@@ -124,12 +104,6 @@ describe("DataIntegrityPage", () => {
renderPage();
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "정합성 검사" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "사용자 동기화" }),
).toBeInTheDocument();
expect(
await screen.findByText(
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
@@ -141,35 +115,14 @@ describe("DataIntegrityPage", () => {
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 () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
renderPage();
const { container } = renderPage();
expect(await screen.findByText("유령 로그인 ID 정리")).toBeInTheDocument();
expect(await screen.findByText("EMP001")).toBeInTheDocument();
expect(screen.getByText("삭제된 테넌트")).toBeInTheDocument();
expectNoAnonymousFormFields(container);
expect(fetchOrphanUserLoginIDs).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("checkbox", { name: "EMP001 선택" }));

View File

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

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

View File

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

View File

@@ -46,8 +46,10 @@ describe("ParentTenantSelector picker", () => {
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src");
expect(pickerSrc).toContain("/login");
const pickerSrc = screen
.getByTestId("parent-tenant-picker-frame")
.getAttribute("src");
expect(pickerSrc).toContain("http://localhost:5175/login");
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
fireEvent(
@@ -71,6 +73,30 @@ describe("ParentTenantSelector picker", () => {
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 () => {
const onChange = vi.fn();

View File

@@ -31,10 +31,12 @@ type ParentTenantSelectorProps = {
labelAction?: ReactNode;
contextLabel?: string;
orgChartPickerLabel?: string;
orgChartTenantId?: string;
localPickerLabel?: string;
localTenantFilter?: (tenant: TenantSummary) => boolean;
compact?: boolean;
controlTestId?: string;
disabled?: boolean;
};
export function ParentTenantSelector({
@@ -49,10 +51,12 @@ export function ParentTenantSelector({
labelAction,
contextLabel,
orgChartPickerLabel,
orgChartTenantId,
localPickerLabel,
localTenantFilter,
compact = false,
controlTestId,
disabled = false,
}: ParentTenantSelectorProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [localPickerOpen, setLocalPickerOpen] = useState(false);
@@ -66,6 +70,9 @@ export function ParentTenantSelector({
);
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
{
tenantId: orgChartTenantId,
},
);
useEffect(() => {
@@ -112,6 +119,7 @@ export function ParentTenantSelector({
variant="outline"
size="sm"
className={compact ? "h-8 shrink-0 px-2" : undefined}
disabled={disabled}
>
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
@@ -135,13 +143,19 @@ export function ParentTenantSelector({
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
data-testid="parent-tenant-picker-frame"
/>
</DialogContent>
</Dialog>
{localPickerLabel && (
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Button
type="button"
variant="outline"
size="sm"
disabled={disabled}
>
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>
@@ -161,6 +175,8 @@ export function ParentTenantSelector({
</DialogHeader>
<div className="space-y-3">
<input
id="parent-tenant-local-search"
name="parent-tenant-local-search"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={localSearch}
onChange={(event) => setLocalSearch(event.target.value)}
@@ -226,6 +242,7 @@ export function ParentTenantSelector({
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
onClick={() => onChange("")}
aria-label={noneLabel}
disabled={disabled}
>
<X className="h-4 w-4" />
</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,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
type DialogMode = "owner" | "admin";
@@ -69,6 +70,10 @@ export function TenantAdminsAndOwnersTab() {
const _currentUserId = auth.user?.profile.sub;
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
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 [searchTerm, setSearchTerm] = useState("");
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
@@ -338,6 +343,16 @@ export function TenantAdminsAndOwnersTab() {
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 serverAdmins = adminsQuery.data || [];
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
@@ -362,7 +377,7 @@ export function TenantAdminsAndOwnersTab() {
);
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">
{/* Owners Card */}
<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
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("owner")}
disabled={!isWritable}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
@@ -471,6 +487,7 @@ export function TenantAdminsAndOwnersTab() {
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("admin")}
disabled={!isWritable}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}

View File

@@ -61,6 +61,13 @@ function TenantCreatePage() {
});
const tenants = parentQuery.data?.items ?? [];
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(() => {
if (!selectedParentTenant) return false;
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
@@ -206,6 +213,7 @@ function TenantCreatePage() {
"ui.admin.tenants.create.form.pick_hanmac_parent",
"한맥가족에서 선택",
)}
orgChartTenantId={hanmacFamilyTenantId}
localPickerLabel={t(
"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 { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { useTenantPermission } from "../hooks/useTenantPermission";
function TenantDetailPage() {
const params = useParams<{ tenantId: string }>();
@@ -17,13 +17,7 @@ function TenantDetailPage() {
enabled: tenantId.length > 0,
});
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canAccessSchema = profileRole === "super_admin";
const { hasPermission } = useTenantPermission(tenantId);
const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization");
@@ -110,7 +104,7 @@ function TenantDetailPage() {
>
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</Link>
{canAccessSchema && (
{hasPermission("view") && (
<Link
to={`/tenants/${tenantId}/schema`}
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", "사용자 스키마")}
</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>
{/* Outlet for nested routes */}
<div className="animate-in fade-in duration-500">
<div>
<Outlet />
</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,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { useTenantPermission } from "../hooks/useTenantPermission";
type UserGroupNode = GroupSummary & {
children: UserGroupNode[];
@@ -126,6 +127,7 @@ interface UserGroupTreeNodeProps {
AxiosError<{ error?: string }>,
{ groupId: string; userId: string }
>;
isWritable?: boolean;
}
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
@@ -137,6 +139,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
onAddSubGroup,
addMemberMutation,
removeMemberMutation,
isWritable = true,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children.length > 0;
@@ -200,6 +203,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
e.stopPropagation();
onAddSubGroup(node.id);
}}
disabled={!isWritable}
>
<Plus size={14} />
</Button>
@@ -210,6 +214,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
e.stopPropagation();
onDelete(node.id);
}}
disabled={!isWritable}
>
<Trash2 size={14} className="text-destructive" />
</Button>
@@ -229,6 +234,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
onAddSubGroup={onAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
isWritable={isWritable}
/>
))}
</>
@@ -240,6 +246,11 @@ function TenantGroupsPage() {
const tenantId = params.tenantId ?? "";
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 [newGroupDesc, setNewGroupNameDesc] = useState("");
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
? buildGroupTree(groupsQuery.data, tenantId)
: [];
@@ -423,6 +444,7 @@ function TenantGroupsPage() {
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
disabled={!isWritable}
placeholder={t(
"ui.admin.groups.form.name_placeholder",
"예: 개발팀, 인사팀",
@@ -437,6 +459,7 @@ function TenantGroupsPage() {
id="unitType"
value={newGroupUnitType}
onChange={(e) => setNewGroupUnitType(e.target.value)}
disabled={!isWritable}
placeholder={t(
"ui.admin.groups.form.unit_level_placeholder",
"예: 본부, 팀, 셀",
@@ -449,9 +472,10 @@ function TenantGroupsPage() {
</Label>
<select
id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
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 || ""}
onChange={(e) => setNewGroupParentId(e.target.value || null)}
disabled={!isWritable}
>
<option value="">{t("ui.common.none", "없음")}</option>
{groupsQuery.data?.map((group) => (
@@ -469,6 +493,7 @@ function TenantGroupsPage() {
id="desc"
value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)}
disabled={!isWritable}
placeholder={t(
"ui.admin.groups.form.desc_placeholder",
"그룹 용도 설명",
@@ -478,7 +503,9 @@ function TenantGroupsPage() {
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
disabled={
!newGroupName || createMutation.isPending || !isWritable
}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
@@ -569,6 +596,7 @@ function TenantGroupsPage() {
onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
isWritable={isWritable}
/>
))}
</TableBody>

View File

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

View File

@@ -14,9 +14,9 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
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 { normalizeAdminRole } from "../../../lib/roles";
import { useTenantPermission } from "../hooks/useTenantPermission";
import {
createSchemaField,
isSchemaFieldType,
@@ -28,13 +28,11 @@ export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canAccess = profileRole === "super_admin";
const { hasPermission, isLoading: isPermissionLoading } = useTenantPermission(
tenantId ?? "",
);
const canView = hasPermission("view_schema") || hasPermission("view");
const isWritable = hasPermission("manage_schema") || hasPermission("manage");
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
@@ -42,7 +40,7 @@ export function TenantSchemaPage() {
if (!tenantId) throw new Error("Tenant ID is required");
return fetchTenant(tenantId);
},
enabled: !!tenantId && canAccess,
enabled: !!tenantId && canView,
});
const [fields, setFields] = useState<SchemaField[]>([]);
@@ -85,7 +83,7 @@ export function TenantSchemaPage() {
},
});
if (isProfileLoading) {
if (isPermissionLoading) {
return (
<div className="p-8 text-center animate-pulse text-muted-foreground">
{t("msg.common.loading", "로딩 중...")}
@@ -93,7 +91,7 @@ export function TenantSchemaPage() {
);
}
if (!canAccess) {
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">
@@ -147,7 +145,7 @@ export function TenantSchemaPage() {
)}
</CardDescription>
</div>
<Button onClick={addField} size="sm">
<Button onClick={addField} size="sm" disabled={!isWritable}>
<Plus size={16} className="mr-2" />
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
</Button>
@@ -182,6 +180,7 @@ export function TenantSchemaPage() {
"예: employee_id",
)}
className="h-10"
disabled={!isWritable}
/>
</div>
<div className="space-y-2">
@@ -198,6 +197,7 @@ export function TenantSchemaPage() {
"예: 사번",
)}
className="h-10"
disabled={!isWritable}
/>
</div>
<div className="space-y-2">
@@ -205,8 +205,11 @@ export function TenantSchemaPage() {
{t("ui.admin.tenants.schema.field.type", "유형")}
</Label>
<select
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
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}
disabled={!isWritable}
onChange={(e) => {
const nextType = e.target.value;
if (isSchemaFieldType(nextType)) {
@@ -266,12 +269,14 @@ export function TenantSchemaPage() {
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-required-${field.key || index}`}
type="checkbox"
checked={field.required}
disabled={!isWritable}
onChange={(e) =>
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">
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
@@ -279,12 +284,14 @@ export function TenantSchemaPage() {
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-admin-only-${field.key || index}`}
type="checkbox"
checked={field.adminOnly}
disabled={!isWritable}
onChange={(e) =>
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">
{t(
@@ -295,8 +302,10 @@ export function TenantSchemaPage() {
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-login-id-${field.key || index}`}
type="checkbox"
checked={field.isLoginId || false}
disabled={!isWritable}
onChange={(e) =>
updateField(index, {
isLoginId: e.target.checked,
@@ -304,7 +313,7 @@ export function TenantSchemaPage() {
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">
{t(
@@ -315,9 +324,10 @@ export function TenantSchemaPage() {
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-indexed-${field.key || index}`}
type="checkbox"
checked={field.indexed || field.isLoginId || false}
disabled={field.isLoginId}
disabled={field.isLoginId || !isWritable}
onChange={(e) =>
updateField(index, { indexed: e.target.checked })
}
@@ -333,12 +343,14 @@ export function TenantSchemaPage() {
{(field.type === "number" || field.type === "float") && (
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-unsigned-${field.key || index}`}
type="checkbox"
checked={field.unsigned}
disabled={!isWritable}
onChange={(e) =>
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">
{t(
@@ -352,6 +364,7 @@ export function TenantSchemaPage() {
<div className="space-y-2">
<Input
value={field.validation}
disabled={!isWritable}
onChange={(e) =>
updateField(index, { validation: e.target.value })
}
@@ -368,6 +381,7 @@ export function TenantSchemaPage() {
size="icon"
className="text-destructive hover:bg-destructive/10 h-10 w-10"
onClick={() => removeField(index)}
disabled={!isWritable}
>
<Trash2 size={18} />
</Button>
@@ -381,7 +395,9 @@ export function TenantSchemaPage() {
<div className="flex justify-end pt-2">
<Button
onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading}
disabled={
updateMutation.isPending || tenantQuery.isLoading || !isWritable
}
className="px-8 h-11"
>
<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 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 { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
@@ -11,6 +21,15 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
@@ -20,14 +39,35 @@ import {
TableRow,
} from "../../../components/ui/table";
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 {
buildAuthenticatedOrgChartUserMultiPickerUrl,
parseOrgChartUserSelections,
} from "../../users/orgChartPicker";
function TenantUsersPage() {
const params = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const tenantId = params.tenantId ?? "";
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)를 먼저 가져옴
const tenantQuery = useQuery({
@@ -45,10 +85,37 @@ function TenantUsersPage() {
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({
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
onSuccess: () => {
onSuccess: (_result, variables) => {
toast.success(
t(
"msg.admin.tenants.members.remove_success",
@@ -56,6 +123,8 @@ function TenantUsersPage() {
),
);
usersQuery.refetch();
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", variables.userId] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
},
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) => {
if (!tenantSlug) return;
if (
@@ -82,6 +183,68 @@ function TenantUsersPage() {
};
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 (
<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,
})}
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild className="gap-2">
<Link to={`/users?addTenant=${tenantSlug}`}>
<UserPlus size={16} />
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</Link>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
className="gap-2"
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 size="sm" asChild className="gap-2">
<Link to={`/users/new?tenantSlug=${tenantSlug}`}>
@@ -107,6 +297,156 @@ function TenantUsersPage() {
</Button>
</div>
</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">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
@@ -125,12 +465,15 @@ function TenantUsersPage() {
<TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead>
<TableHead className="text-right">
{t("ui.common.actions", "작업")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.isLoading ? (
<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">
<Loader2
className="animate-spin text-muted-foreground"
@@ -145,7 +488,7 @@ function TenantUsersPage() {
) : users.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
colSpan={5}
className="text-center py-8 text-muted-foreground"
>
{t(
@@ -187,6 +530,23 @@ function TenantUsersPage() {
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</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>
))
)}

View File

@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
buildWorksmobilePasswordManageUrl,
@@ -10,6 +12,7 @@ import {
filterWorksmobileComparisonRowsBySearch,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
formatWorksmobileSelectionFailureDescription,
formatWorksmobileUpdateDetails,
getDefaultGroupComparisonFilters,
getDefaultUserComparisonFilters,
@@ -17,7 +20,9 @@ import {
getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileSelectedCreateUserIds,
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
getWorksmobileSelectedUpdateUserIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
@@ -25,6 +30,18 @@ import {
} from "./worksmobileComparison";
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", () => {
const summary = summarizeWorksmobileComparison([
{ 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", () => {
expect(getDefaultWorksmobileComparisonColumns()).toEqual({
status: true,
@@ -472,6 +524,48 @@ describe("TenantWorksmobilePage comparison helpers", () => {
).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", () => {
expect(
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", () => {
expect(
formatWorksmobilePersonName({

View File

@@ -16,6 +16,24 @@ export function tenantMatchesListSearch(
.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(
nodes: TenantNode[],
depth: number,
@@ -91,7 +109,8 @@ export function getTenantViewRows(
...(rowsById.get(tenant.id) ?? {
...tenant,
children: [],
recursiveMemberCount: Number(tenant.memberCount) || 0,
recursiveMemberCount:
Number(tenant.totalMemberCount ?? tenant.memberCount) || 0,
}),
depth: 0,
}));

View File

@@ -6,6 +6,14 @@ export type WorksmobileComparisonFilter =
| "needs_update"
| "matched";
export type WorksmobileAccountStatusFilter =
| "all"
| "active"
| "invited"
| "suspended"
| "inactive"
| "deleted";
export type WorksmobileComparisonSummary = {
total: number;
matched: number;
@@ -172,6 +180,54 @@ export function getWorksmobileSelectedActionIds(
.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(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
@@ -219,6 +275,7 @@ const worksmobileComparisonSearchFields: Array<
"externalKey",
"worksmobileName",
"worksmobileEmail",
"worksmobileAccountStatus",
"worksmobileLevelId",
"worksmobileLevelName",
"worksmobileTask",
@@ -260,6 +317,7 @@ export function filterWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
filters: WorksmobileComparisonFilter[],
onlyMissingExternalKey = false,
accountStatus: WorksmobileAccountStatusFilter = "all",
) {
const allowedStatuses = new Set(
filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
@@ -270,7 +328,15 @@ export function filterWorksmobileComparisonRows(
}
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) {
@@ -299,6 +365,32 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
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) {
if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) {
return [`최근 실패: ${row.worksmobileLastError}`];
@@ -308,24 +400,67 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
}
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 worksmobileName = row.worksmobileName?.trim();
if (baronName && worksmobileName && baronName !== worksmobileName) {
details.push(`이름: ${worksmobileName} -> ${baronName}`);
addDetail("name", `이름: ${worksmobileName} -> ${baronName}`);
}
if (row.resourceType === "USER") {
const expectedExternalKey = row.baronId?.trim() ?? "";
const actualExternalKey = row.externalKey?.trim() ?? "";
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
details.push(
addDetail(
"external_key",
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
);
}
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
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;
}
@@ -345,14 +480,123 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
const actualParentKey =
row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? "";
if (expectedParentKey !== actualParentKey) {
details.push(
addDetail(
"organization",
`상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`,
);
}
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
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({
tenantId,
domainId,
@@ -413,6 +657,18 @@ export const comparisonFilterOptions: Array<{
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[] {
return ["baron_only", "needs_update", "works_only"];
}

View File

@@ -1,12 +1,25 @@
import { describe, expect, it } from "vitest";
import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants";
import { getSeedTenantIds, isSeedTenant } from "./protectedTenants";
describe("protectedTenants", () => {
it("marks tenants from seed-tenant.csv as protected", () => {
expect(getSeedTenantSlugs()).toEqual(
expect.arrayContaining(["hanmac-family", "personal"]),
it("marks tenants from seed-tenant.csv as protected by UUID", () => {
expect(getSeedTenantIds()).toEqual(
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(isSeedTenant({ slug: "normal-tenant" })).toBe(false);
expect(
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 { parseTenantCSV } from "./tenantCsvImport";
const seedTenantSlugs = new Set(
parseTenantCSV(seedTenantCSVRaw)
.map((row) => row.slug.trim().toLowerCase())
.filter(Boolean),
const seedTenants = parseTenantCSV(seedTenantCSVRaw);
const seedTenantIds = new Set(
seedTenants.map((row) => row.tenantId.trim().toLowerCase()).filter(Boolean),
);
export function isSeedTenant(tenant: Pick<TenantSummary, "slug">): boolean {
return seedTenantSlugs.has(tenant.slug.trim().toLowerCase());
export function isSeedTenant(tenant: Pick<TenantSummary, "id">): boolean {
return seedTenantIds.has(tenant.id.trim().toLowerCase());
}
export function getSeedTenantSlugs(): string[] {
return Array.from(seedTenantSlugs);
export function getSeedTenantIds(): string[] {
return Array.from(seedTenantIds);
}

View File

@@ -61,7 +61,9 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
bulkUpdateUsers,
exportTenantsCSV,
exportUsersCSV,
fetchAllTenants,
fetchUsers,
type TenantSummary,
@@ -71,6 +73,10 @@ import {
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
import {
buildAuthenticatedOrgChartUserMultiPickerUrl,
parseOrgChartUserSelections,
} from "../../users/orgChartPicker";
// --- Icons & Helpers ---
const getTenantIcon = (type?: string) => {
@@ -223,8 +229,10 @@ const MemberTable: React.FC<{
const removeMutation = useMutation({
mutationFn: (userId: string) =>
updateUser(userId, { tenantSlug, isRemoveTenant: true }),
onSuccess: () => {
onSuccess: (_result, userId) => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", userId] });
toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
refetch();
},
@@ -296,7 +304,12 @@ const MemberTable: React.FC<{
<TableCell>
<DropdownMenu>
<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} />
</Button>
</DropdownMenuTrigger>
@@ -313,6 +326,7 @@ const MemberTable: React.FC<{
{t("ui.common.move_org", "타 조직으로 이동")}
</DropdownMenuItem>
<DropdownMenuItem
data-testid={`tenant-org-member-remove-${user.id}`}
onClick={() => {
if (
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
const {
data: allTenantsData,
@@ -616,13 +648,29 @@ function TenantUserGroupsTab() {
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsUserAddOpen(true)}
data-testid="tenant-org-member-add-open-btn"
>
<UserPlus size={16} className="mr-2" />
{t("ui.admin.users.list.add", "멤버 추가")}
</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
variant="outline"
size="sm"
@@ -836,8 +884,19 @@ const UserAddDialog: React.FC<{
const [userSearch, setUserSearch] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [queuedUsers, setQueuedUsers] = useState<UserSummary[]>([]);
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 () => {
if (!userSearch) return;
@@ -853,12 +912,22 @@ const UserAddDialog: React.FC<{
};
const handleAssign = async () => {
if (!selectedUserId) return;
if (queuedUsers.length === 0) return;
setIsSubmitting(true);
try {
await updateUser(selectedUserId, { tenantSlug });
await bulkUpdateUsers({
userIds: queuedUsers.map((user) => user.id),
tenantSlug,
isAddTenant: true,
});
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);
resetFields();
} catch (err) {
@@ -875,9 +944,54 @@ const UserAddDialog: React.FC<{
const resetFields = () => {
setUserSearch("");
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 (
<Dialog
open={open}
@@ -886,7 +1000,7 @@ const UserAddDialog: React.FC<{
if (!v) resetFields();
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.create.title", "멤버 추가")}
@@ -896,52 +1010,103 @@ const UserAddDialog: React.FC<{
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 검색...",
)}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button
variant="secondary"
onClick={handleSearch}
disabled={isSearching}
>
<Search size={16} />
</Button>
</div>
<ScrollArea className="h-60 border rounded-md">
<Table>
<TableBody>
{searchResults?.map((user) => (
<TableRow
key={user.id}
className={`cursor-pointer hover:bg-muted/50 ${selectedUserId === user.id ? "bg-primary/5" : ""}`}
onClick={() => setSelectedUserId(user.id)}
>
<TableCell>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-[10px] text-muted-foreground">
{user.email}
</p>
<div className="grid gap-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,1.2fr)]">
<div className="space-y-3">
<div className="flex gap-2">
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 검색...",
)}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
data-testid="tenant-org-member-search-input"
/>
<Button
variant="secondary"
onClick={handleSearch}
disabled={isSearching}
data-testid="tenant-org-member-search-btn"
>
<Search size={16} />
</Button>
</div>
<ScrollArea className="h-60 rounded-md border">
<Table>
<TableBody>
{searchResults?.map((user) => (
<TableRow
key={user.id}
data-testid={`tenant-org-member-search-result-${user.id}`}
className={`cursor-pointer hover:bg-muted/50 ${queuedUserIds.has(user.id) ? "bg-primary/5 opacity-60" : ""}`}
onClick={() => queueUser(user)}
>
<TableCell>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-[10px] text-muted-foreground">
{user.email}
</p>
</div>
{queuedUserIds.has(user.id) && (
<ChevronRight size={16} className="text-primary" />
)}
</div>
{selectedUserId === user.id && (
<ChevronRight size={16} className="text-primary" />
)}
</div>
</TableCell>
</TableRow>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</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>
</Table>
</ScrollArea>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -949,7 +1114,8 @@ const UserAddDialog: React.FC<{
</Button>
<Button
onClick={handleAssign}
disabled={isSubmitting || !selectedUserId}
disabled={isSubmitting || queuedUsers.length === 0}
data-testid="tenant-org-member-add-submit-btn"
>
{t("ui.common.add", "배정")}
</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,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
import {
canManageTenantScopedUsers,
isSuperAdminRole,
normalizeAdminRole,
} from "../../lib/roles";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
@@ -57,6 +61,7 @@ import {
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import type { UserSchemaField } from "./userSchemaFields";
import { resolvePersonalTenant } from "./utils/personalTenant";
@@ -154,6 +159,9 @@ function UserCreatePage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canManageUsers =
canManageTenantScopedUsers(profile) ||
!!profile?.systemPermissions?.manage_users;
const {
register,
@@ -204,8 +212,12 @@ function UserCreatePage() {
// Lock company for non-super_admin
React.useEffect(() => {
if (profileRole !== "super_admin" && profile?.tenantSlug) {
setValue("tenantSlug", profile.tenantSlug);
if (profileRole !== "super_admin") {
const delegatedTenantSlug =
profile?.tenantSlug || profile?.manageableTenants?.[0]?.slug;
if (delegatedTenantSlug) {
setValue("tenantSlug", delegatedTenantSlug);
}
}
}, [profile, profileRole, setValue]);
@@ -390,7 +402,7 @@ function UserCreatePage() {
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(
err.response?.data?.error ||
formatUserPolicyMessage(err.response?.data?.error) ||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
);
},
@@ -524,8 +536,7 @@ function UserCreatePage() {
}
};
// Access Control: Only super_admin can create users
if (profile && profileRole !== "super_admin") {
if (profile && !canManageUsers) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<ShieldAlert size={48} className="text-destructive" />
@@ -712,6 +723,8 @@ function UserCreatePage() {
</Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
id="auto-password"
name="auto-password"
type="checkbox"
checked={autoPassword}
onChange={(event) => setAutoPassword(event.target.checked)}
@@ -933,8 +946,12 @@ function UserCreatePage() {
data-testid={`appointment-tenant-picker-${index}`}
>
<Building2 className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{appointment.tenantName || "테넌트 선택"}
<span className="pointer-events-none truncate">
{appointment.tenantName ||
t(
"ui.admin.users.create.form.pick_from_hanmac_family",
"한맥가족에서 선택",
)}
</span>
</Button>
{appointment.tenantSlug && (

View File

@@ -7,68 +7,35 @@ import UserDetailPage from "./UserDetailPage";
const updateUserMock = vi.hoisted(() => vi.fn());
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/adminApi", () => ({
deleteUser: vi.fn(),
fetchAllTenants: vi.fn(async () => ({
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,
})),
fetchAllTenants: fetchAllTenantsMock,
fetchMe: vi.fn(async () => ({
id: "admin-user",
role: profileRoleMock.role,
name: "Admin",
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 })),
fetchTenant: vi.fn(),
fetchUser: vi.fn(async () => ({
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",
})),
fetchUser: fetchUserMock,
fetchUserRpHistory: vi.fn(async () => []),
updateUser: updateUserMock,
}));
@@ -93,6 +60,60 @@ describe("UserDetailPage Worksmobile employee number", () => {
beforeEach(() => {
updateUserMock.mockReset();
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";
});
@@ -152,4 +173,348 @@ describe("UserDetailPage Worksmobile employee number", () => {
const payload = updateUserMock.mock.calls[0][1];
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,
} from "../../components/ui/tabs";
import { toast } from "../../components/ui/use-toast";
import type { PasswordPolicyResponse } from "../../lib/adminApi";
import type {
GlobalCustomClaimDefinition,
PasswordPolicyResponse,
} from "../../lib/adminApi";
import {
deleteUser,
fetchAllTenants,
fetchGlobalCustomClaimDefinitions,
fetchMe,
fetchPasswordPolicy,
fetchTenant,
@@ -75,17 +79,23 @@ import {
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { normalizeAdminRole } from "../../lib/roles";
import {
canManageUserInTenantScope,
normalizeAdminRole,
} from "../../lib/roles";
import { generateSecurePassword } from "../../lib/utils";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
filterTenantsByMembershipRoot,
getTenantGradeOptions,
isHanmacFamilyTenant,
isHanmacFamilyUser,
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
resolveUserMembershipTenantTab,
USER_MEMBERSHIP_TENANT_TABS,
type UserMembershipTenantTabId,
} from "./orgChartPicker";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import type { UserSchemaField } from "./userSchemaFields";
import {
normalizeUserStatusValue,
@@ -101,13 +111,32 @@ type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
sub_email?: string | string[];
};
};
type UserCategory = "hanmac" | "external" | "personal";
type UserCategory = UserMembershipTenantTabId;
type PasswordResetMode = "generated" | "manual";
type PickerTarget = { kind: "appointment"; index: number };
type AppointmentDraft = UserAppointment & {
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;
@@ -115,6 +144,15 @@ function isMetadataRecord(value: unknown): value is Record<string, unknown> {
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 {
if (Array.isArray(value)) {
return value
@@ -176,6 +214,89 @@ function createDraftId() {
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(
selection: OrgChartTenantSelection,
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 {
return {
draftId: createDraftId(),
@@ -291,8 +454,6 @@ function TenantMetadataFields({
register: UseFormRegister<UserFormValues>;
errors: FieldErrors<UserFormValues>;
}) {
if (schema.length === 0) return null;
return (
<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">
@@ -307,74 +468,85 @@ function TenantMetadataFields({
</span>
</div>
<div className="p-6 grid gap-6 md:grid-cols-2">
{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>
{schema.length === 0 ? (
<p className="text-sm text-muted-foreground md:col-span-2">
{t(
"msg.admin.users.detail.tenant_schema_empty",
"이 테넌트에 설정된 프로필 필드가 없습니다.",
)}
</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>
);
@@ -401,10 +573,13 @@ function UserDetailPage() {
string | null
>(null);
const [userCategory, setUserCategory] =
React.useState<UserCategory>("external");
React.useState<UserCategory>("hanmac-family");
const [additionalAppointments, setAdditionalAppointments] = React.useState<
AppointmentDraft[]
>([]);
const [globalCustomClaimRows, setGlobalCustomClaimRows] = React.useState<
GlobalCustomClaimRow[]
>([]);
const [pickerTarget, setPickerTarget] = React.useState<PickerTarget | null>(
null,
);
@@ -446,6 +621,14 @@ function UserDetailPage() {
queryKey: ["password-policy"],
queryFn: fetchPasswordPolicy,
});
const { data: globalCustomClaimDefinitionsData } = useQuery({
queryKey: ["global-custom-claim-definitions"],
queryFn: fetchGlobalCustomClaimDefinitions,
});
const globalCustomClaimDefinitions = React.useMemo(
() => globalCustomClaimDefinitionsData?.items ?? [],
[globalCustomClaimDefinitionsData?.items],
);
const {
register,
@@ -472,6 +655,18 @@ function UserDetailPage() {
const profileRole = normalizeAdminRole(profile?.role);
const isAdmin = profileRole === "super_admin";
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 [newSubEmail, setNewSubEmail] = React.useState("");
@@ -499,9 +694,18 @@ function UserDetailPage() {
};
const resetMutation = useMutation({
mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
onSuccess: (_, newPass) => {
setGeneratedPassword(newPass);
mutationFn: ({ password }: { password: string; mode: PasswordResetMode }) =>
updateUser(userId, { password }),
onSuccess: (_, { password, mode }) => {
if (mode === "manual") {
setGeneratedPassword(null);
setManualPassword("");
setManualPasswordConfirm("");
setIsManualPasswordVisible(false);
setIsPasswordResetOpen(false);
} else {
setGeneratedPassword(password);
}
setPasswordResetError(null);
toast.success(
t(
@@ -560,7 +764,7 @@ function UserDetailPage() {
newPass = generateSecurePassword();
}
resetMutation.mutate(newPass);
resetMutation.mutate({ password: newPass, mode: passwordResetMode });
};
const hanmacFamilyTenantId = React.useMemo(() => {
@@ -578,7 +782,8 @@ function UserDetailPage() {
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
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 nextCategory = value as UserCategory;
setUserCategory(nextCategory);
if (nextCategory !== "hanmac") {
if (nextCategory !== "hanmac-family") {
setAdditionalAppointments([]);
}
};
@@ -737,22 +942,15 @@ function UserDetailPage() {
: [],
} as UserFormValues["metadata"],
});
const isUserHanmacFamily = isHanmacFamilyUser(
const resolvedUserCategory = resolveUserMembershipTenantTab(
user,
tenants,
hanmacFamilyTenantId,
);
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";
).id;
const isUserHanmacFamily = resolvedUserCategory === "hanmac-family";
setUserCategory(resolvedUserCategory);
setGlobalCustomClaimRows(
createGlobalCustomClaimRows(metadata, globalCustomClaimDefinitions),
);
const familyFallbackTenants = [
...(user.joinedTenants ?? []),
...(user.tenant ? [user.tenant] : []),
@@ -810,7 +1008,13 @@ function UserDetailPage() {
: [],
);
}
}, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]);
}, [
globalCustomClaimDefinitions,
hanmacFamilyTenantId,
tenants,
user,
reset,
]);
const mutation = useMutation({
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
@@ -821,7 +1025,7 @@ function UserDetailPage() {
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
formatUserPolicyMessage(err.response?.data?.error) ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
@@ -887,6 +1091,7 @@ function UserDetailPage() {
try {
const tenant = await ensurePersonalTenant();
payload.tenantSlug = tenant.slug;
payload.isPrimaryTenant = true;
payload.department = undefined;
payload.grade = undefined;
payload.position = undefined;
@@ -901,7 +1106,7 @@ function UserDetailPage() {
}
}
if (userCategory === "hanmac") {
if (userCategory === "hanmac-family") {
const appointments = additionalAppointments
.filter((appointment) => appointment.tenantId)
.map((appointment) => ({
@@ -920,6 +1125,7 @@ function UserDetailPage() {
const primary = appointments.find((a) => a.isPrimary);
if (primary) {
payload.tenantSlug = primary.tenantSlug;
payload.isPrimaryTenant = true;
payload.primaryTenantId = primary.tenantId;
payload.primaryTenantName = primary.tenantName;
metadata.primaryTenantId = primary.tenantId;
@@ -942,6 +1148,7 @@ function UserDetailPage() {
primaryTenantSlug: primary?.tenantSlug,
};
payload.tenantSlug = primary?.tenantSlug;
payload.isPrimaryTenant = primary ? true : undefined;
payload.primaryTenantId = primary?.tenantId;
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 joined = user?.joinedTenants || [];
const primary = user?.tenant;
const all = [...joined];
if (primary && !joined.some((t) => t.id === primary.id)) {
const appointmentTenants = appointmentTenantsFromMetadata(
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);
}
for (const tenant of appointmentTenants) {
if (!all.some((item) => item.id === tenant.id)) {
all.push(tenant);
}
}
return all;
}, [user?.joinedTenants, user?.tenant]);
}, [tenants, user?.joinedTenants, user?.metadata, user?.tenant]);
const selectableRepresentativeTenants = React.useMemo(
() =>
filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
[userAffiliatedTenants, hanmacFamilyTenantId],
userCategory === "hanmac-family" || userCategory === "personal"
? []
: filterTenantsByMembershipRoot(tenants, userCategory),
[tenants, userCategory],
);
const isRepresentativeTenantCategory =
userCategory !== "hanmac-family" && userCategory !== "personal";
if (isLoading) {
return (
@@ -999,8 +1251,7 @@ function UserDetailPage() {
);
}
// Access Control: Only super_admin or self can view details
if (!isAdmin && !isSelf) {
if (profile && !canViewUser) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<ShieldAlert size={48} className="text-destructive" />
@@ -1118,6 +1369,17 @@ function UserDetailPage() {
<Building2 size={16} className="mr-2" />
{t("ui.admin.users.detail.tabs.tenants", "테넌트 프로필")}
</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
value="security"
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"
>
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
<TabsTrigger
value="hanmac"
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="external"
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>
{USER_MEMBERSHIP_TENANT_TABS.map((tab) => (
<TabsTrigger
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"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{userCategory === "external" && (
{isRepresentativeTenantCategory && (
<div className="grid gap-8 md:grid-cols-2">
<div className="space-y-2">
<Label
@@ -1414,7 +1667,7 @@ function UserDetailPage() {
</div>
)}
{userCategory === "hanmac" && (
{userCategory === "hanmac-family" && (
<div className="space-y-4 rounded-md border p-4">
<div className="space-y-4">
<div className="space-y-3">
@@ -1636,7 +1889,7 @@ function UserDetailPage() {
</div>
)}
{userCategory === "external" && (
{isRepresentativeTenantCategory && (
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
<div className="space-y-2">
<Label
@@ -1698,22 +1951,24 @@ function UserDetailPage() {
</CardContent>
</Card>
<div className="flex justify-end pt-4">
<Button
type="submit"
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" />
) : (
<Save className="mr-2 h-5 w-5" />
)}
<span className="text-base font-bold">
{t("ui.admin.users.detail.save", "저장하기")}
</span>
</Button>
</div>
{isWritable && (
<div className="flex justify-end pt-4">
<Button
type="submit"
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" />
) : (
<Save className="mr-2 h-5 w-5" />
)}
<span className="text-base font-bold">
{t("ui.admin.users.detail.save", "저장하기")}
</span>
</Button>
</div>
)}
</TabsContent>
<TabsContent
@@ -1790,6 +2045,135 @@ function UserDetailPage() {
</Button>
</div>
</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>
<TabsContent

View File

@@ -22,8 +22,9 @@ const users = Array.from({ length: 200 }, (_, index) => ({
}));
const fetchUsersMock = vi.hoisted(() => vi.fn());
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
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());
@@ -34,10 +35,7 @@ vi.mock("../../lib/adminApi", () => ({
name: "Admin",
email: "admin@example.com",
})),
fetchAllTenants: vi.fn(async () => ({
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
total: 1,
})),
fetchAllTenants: fetchAllTenantsMock,
fetchTenant: vi.fn(async () => ({
id: "tenant-1",
name: "한맥",
@@ -108,6 +106,11 @@ describe("UserListPage search rendering", () => {
beforeEach(() => {
selectRenderCounter.count = 0;
fetchUsersMock.mockReset();
fetchAllTenantsMock.mockReset();
fetchAllTenantsMock.mockResolvedValue({
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
total: 1,
});
fetchUsersMock.mockImplementation(
async (_limit: number, _offset: number, search?: string) => {
const normalizedSearch = search?.trim().toLowerCase();
@@ -127,7 +130,7 @@ describe("UserListPage search rendering", () => {
renderUserListPage();
await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
const renderCountBeforeTyping = selectRenderCounter.count;
fireEvent.change(searchInput, { target: { value: "u" } });
@@ -136,6 +139,19 @@ describe("UserListPage search rendering", () => {
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 () => {
renderUserListPage();
@@ -157,6 +173,91 @@ describe("UserListPage search rendering", () => {
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 () => {
const deferred = createDeferred<{ items: typeof users; total: number }>();
fetchUsersMock.mockReturnValueOnce(deferred.promise);
@@ -179,7 +280,7 @@ describe("UserListPage search rendering", () => {
renderUserListPage();
await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색...");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
const startedAt = performance.now();
fireEvent.change(searchInput, { target: { value: "user 19" } });
@@ -189,4 +290,19 @@ describe("UserListPage search rendering", () => {
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
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,
FileDown,
FileSpreadsheet,
Key,
LayoutDashboard,
Plus,
RefreshCw,
@@ -96,11 +97,12 @@ import {
updateUser,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
import { normalizeAdminRole } from "../../lib/roles";
import {
downloadUserTemplate,
UserBulkUploadModal,
} from "./components/UserBulkUploadModal";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import {
normalizeUserStatusValue,
type UserStatusValue,
@@ -117,9 +119,9 @@ type UserSchemaField = {
type UserSortKey = string;
const USER_ROW_ESTIMATED_HEIGHT = 64;
const USER_ROW_OVERSCAN = 20;
const USER_ROW_OVERSCAN = 2;
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 userCreatedColumnWidth = 150;
type UserRowVirtualizer = Virtualizer<HTMLDivElement, HTMLTableRowElement>;
@@ -133,21 +135,111 @@ const userSortableTableHeadContentClassName = "h-full items-center";
const userTableStateCellClassName =
"flex h-24 items-center justify-center p-0 text-center text-sm text-muted-foreground";
const bulkPermissionOptions = [
{
value: "super_admin",
labelKey: "ui.admin.role.super_admin",
fallback: "시스템 관리자",
},
{
value: "user",
labelKey: "ui.admin.role.user",
fallback: "일반 사용자",
},
] as const;
type RepresentativeTenantCandidate = {
id?: string;
slug?: string;
name?: string;
config?: Record<string, unknown>;
};
function assignableSystemRoleValue(role?: string | null) {
return isSuperAdminRole(role) ? "super_admin" : "user";
function stringValue(value: unknown) {
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 {
@@ -204,12 +296,14 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
<SearchFilterBar
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" />
<Input
id="user-list-search"
name="user-list-search"
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
"이름 또는 이메일 검색",
)}
className="h-9 pl-9"
value={localSearch}
@@ -223,6 +317,8 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
</div>
<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"
value={selectedCompany}
onChange={(event) => onCompanyChange(event.target.value)}
@@ -257,8 +353,9 @@ function UserListPage() {
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState<
UserStatusValue | ""
>("");
const [selectedBulkPermission, setSelectedBulkPermission] =
React.useState("");
const [selectedBulkRole, setSelectedBulkRole] = React.useState<
"super_admin" | "user" | ""
>("");
const [sortConfig, setSortConfig] =
React.useState<SortConfig<UserSortKey> | null>(null);
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
@@ -269,6 +366,8 @@ function UserListPage() {
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const isWritable =
profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
const { data: tenantsData } = useQuery({
queryKey: ["tenants", "all"],
@@ -416,10 +515,10 @@ function UserListPage() {
name_email: (user) =>
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
tenant_dept: (user) =>
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${user.department ?? ""}`,
`${resolveRepresentativeTenantLabel(user, tenants)} ${user.department ?? ""}`,
},
),
[userSchema],
[tenants, userSchema],
);
const items = React.useMemo(() => {
if (!sortConfig) {
@@ -489,7 +588,7 @@ function UserListPage() {
]);
const shouldVirtualizeRows = !query.isLoading && items.length > 0;
const tableColumnCount = 9 + visibleUserSchemaFields.length;
const tableColumnCount = 8 + visibleUserSchemaFields.length;
const requestSort = (key: UserSortKey) => {
setSortConfig((current) => toggleSort(current, key));
@@ -507,8 +606,6 @@ function UserListPage() {
};
const total = query.data?.pages[0]?.total ?? 0;
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
const toggleSelectAll = () => {
if (selectedUserIds.length === items.length) {
setSelectedUserIds([]);
@@ -540,11 +637,25 @@ function UserListPage() {
const bulkUpdateMutation = useMutation({
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();
setSelectedUserIds([]);
setSelectedBulkStatus("");
setSelectedBulkPermission("");
setSelectedBulkRole("");
toast.success(
t(
"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 = () => {
if (selectedUserIds.length === 0) return;
if (
@@ -613,7 +716,7 @@ function UserListPage() {
}
description={t(
"msg.admin.users.list.subtitle",
"시스템 사용자를 조회하고 관리합니다.",
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
)}
actions={
<>
@@ -636,6 +739,15 @@ function UserListPage() {
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</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>
<DropdownMenuTrigger asChild>
<Button
@@ -660,8 +772,9 @@ function UserListPage() {
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
setBulkUploadOpen(true);
if (isWritable) setBulkUploadOpen(true);
}}
disabled={!isWritable}
className="cursor-pointer"
>
<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"
>
<input
name={`user-list-column-${field.key}`}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
checked={visibleColumns[field.key] !== false}
@@ -752,12 +866,19 @@ function UserListPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<Button asChild size="sm" className="h-9">
<Link to="/users/new">
{isWritable ? (
<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} />
{t("ui.admin.users.list.add", "사용자 추가")}
</Link>
</Button>
</Button>
)}
</>
}
/>
@@ -802,6 +923,7 @@ function UserListPage() {
<TableHead className={`${userTableHeadClassName} w-12`}>
<div className="flex h-full items-center justify-center">
<input
name="user-list-select-all"
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
checked={
@@ -857,15 +979,6 @@ function UserListPage() {
{getSortIcon("status")}
</div>
</TableHead>
<TableHead
className={userTableHeadInteractiveClassName}
onClick={() => requestSort("role")}
>
<div className={userTableHeadContentClassName}>
{t("ui.admin.users.list.table.role", "ROLE")}
{getSortIcon("role")}
</div>
</TableHead>
<TableHead
className={userTableHeadInteractiveClassName}
onClick={() => requestSort("tenant_dept")}
@@ -957,6 +1070,9 @@ function UserListPage() {
virtualRows.map((virtualRow) => {
const user = items[virtualRow.index];
if (!user) return null;
const representativeTenantLabel =
resolveRepresentativeTenantLabel(user, tenants) ||
t("ui.common.unassigned", "미배정");
return (
<TableRow
@@ -980,6 +1096,7 @@ function UserListPage() {
>
<TableCell>
<input
name={`user-list-select-${user.id}`}
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
checked={selectedUserIds.includes(user.id)}
@@ -998,7 +1115,7 @@ function UserListPage() {
<TableCell>
<Link
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}
>
{user.name}
@@ -1030,7 +1147,8 @@ function UserListPage() {
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
user.id === profile?.id ||
!isWritable
}
>
<SelectTrigger
@@ -1053,42 +1171,10 @@ function UserListPage() {
</SelectContent>
</Select>
</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>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{user.tenant?.name ||
user.tenantSlug ||
t("ui.common.unassigned", "미배정")}
{representativeTenantLabel}
</span>
{user.department && (
<span className="text-xs text-muted-foreground">
@@ -1151,31 +1237,6 @@ function UserListPage() {
))}
</SelectContent>
</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
variant="ghost"
size="sm"
@@ -1184,15 +1245,15 @@ function UserListPage() {
const payload: {
userIds: string[];
status?: UserStatusValue;
role?: string;
role?: "super_admin" | "user";
} = { userIds: selectedUserIds };
let hasChanges = false;
if (selectedBulkStatus) {
payload.status = selectedBulkStatus;
hasChanges = true;
}
if (selectedBulkPermission && canPromoteSuperAdmin) {
payload.role = selectedBulkPermission;
if (selectedBulkRole) {
payload.role = selectedBulkRole;
hasChanges = true;
}
if (hasChanges) {
@@ -1200,20 +1261,51 @@ function UserListPage() {
}
}}
disabled={
(!selectedBulkStatus && !selectedBulkPermission) ||
bulkUpdateMutation.isPending
(!selectedBulkStatus && !selectedBulkRole) ||
bulkUpdateMutation.isPending ||
!isWritable
}
data-testid="bulk-apply-btn"
>
<ShieldCheck size={14} />
{t("ui.common.apply", "적용")}
</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" />
<Button
variant="ghost"
size="sm"
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
onClick={handleBulkDelete}
disabled={!isWritable}
data-testid="bulk-delete-btn"
>
<Trash2 size={14} />

View File

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

View File

@@ -35,6 +35,7 @@ import {
type TenantImportPreviewRow,
} from "../../tenants/utils/tenantCsvImport";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
import { formatUserPolicyMessage } from "../userPolicyMessages";
import { parseUserCSV } from "../utils/csvParser";
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
import {
@@ -420,6 +421,7 @@ export function UserBulkUploadModal({
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
<input
name="user-bulk-upload-file"
type="file"
accept=".csv"
className="hidden"
@@ -482,6 +484,8 @@ export function UserBulkUploadModal({
</div>
<div className="space-y-2">
<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"
value={
selectedTenantMatches[preview.row.rowNumber] ??
@@ -512,6 +516,8 @@ export function UserBulkUploadModal({
{(selectedTenantMatches[preview.row.rowNumber] ??
"__create__") === "__create__" && (
<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"
value={
selectedTenantCreateSlugs[
@@ -552,6 +558,8 @@ export function UserBulkUploadModal({
>
<td className="p-2">
<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"
value={
hanmacEmailPreviews[index]?.finalEmail ??
@@ -761,7 +769,7 @@ export function UserBulkUploadModal({
)}
{!r.success && (
<div className="text-xs text-destructive">
{r.message}
{formatUserPolicyMessage(r.message)}
</div>
)}
</div>

View File

@@ -2,11 +2,15 @@ import { describe, expect, it } from "vitest";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
buildAuthenticatedOrgChartUrl,
buildAuthenticatedOrgChartUserMultiPickerUrl,
buildOrgChartTenantPickerUrl,
classifyTenantByMembershipRoot,
filterNonHanmacFamilyTenants,
getTenantGradeOptions,
isHanmacFamilyUser,
parseOrgChartTenantSelection,
parseOrgChartUserSelections,
USER_MEMBERSHIP_TENANT_TABS,
} from "./orgChartPicker";
describe("orgChartPicker", () => {
@@ -49,18 +53,51 @@ describe("orgChartPicker", () => {
);
});
it("builds the admin chart navigation URL with internal visibility enabled", () => {
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue",
it("falls back to the orgfront development origin for authenticated picker URLs", () => {
expect(
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(
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", () => {
@@ -98,6 +135,50 @@ describe("orgChartPicker", () => {
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", () => {
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;
};
export type OrgChartUserSelection = {
id: string;
name: string;
email: string;
rootTenantName?: string;
leafTenantName?: string;
};
export type TenantFilterTarget = {
id?: string;
tenantId?: string;
@@ -24,6 +32,20 @@ export type HanmacFamilyUserTarget = {
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?: unknown;
payload?: {
@@ -31,6 +53,10 @@ type OrgChartPickerMessage = {
type?: unknown;
id?: unknown;
name?: unknown;
email?: unknown;
rootTenantName?: unknown;
leafTenantName?: unknown;
tenantName?: unknown;
}>;
};
};
@@ -40,11 +66,47 @@ type OrgChartTenantPickerOptions = {
tenantId?: string;
};
type OrgChartUserMultiPickerOptions = {
tenantId?: string;
};
type OrgChartLoginOptions = {
includeInternal?: boolean;
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 = [
"연구원",
"선임",
@@ -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>(
target: TenantFilterTarget | undefined,
tenants: T[],
@@ -317,11 +491,43 @@ export function buildAuthenticatedOrgChartTenantPickerUrl(
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
}
export function buildAuthenticatedOrgChartUrl(
export function buildOrgChartUserMultiPickerUrl(
baseUrl?: string,
options: OrgChartLoginOptions = { includeInternal: true },
options: OrgChartUserMultiPickerOptions = {},
) {
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";
if (options.includeInternal && returnTo.startsWith("/chart")) {
const [path, query = ""] = returnTo.split("?", 2);
@@ -360,3 +566,46 @@ export function parseOrgChartTenantSelection(
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.fetchDataIntegrityReport();
await adminApi.fetchOrphanUserLoginIDs();
await adminApi.fetchUserProjectionStatus();
await adminApi.fetchOrySSOTSystemStatus();
await adminApi.fetchAdminRPUsageDaily({
days: 30,
period: "week",
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.fetchTenant("tenant-1");
await adminApi.fetchTenantAdmins("tenant-1");
@@ -90,12 +98,16 @@ describe("adminApi endpoint contracts", () => {
expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", {
params: { limit: 10, cursor: "cursor-a" },
});
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/ory/ssot");
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", {
params: {
limit: 25,
offset: 50,
parentId: "parent-1",
cursor: "cursor-b",
search: "saman",
sort: "name",
direction: "asc",
},
});
expect(fetchAllCursorPages).toHaveBeenCalledWith(
@@ -133,8 +145,7 @@ describe("adminApi endpoint contracts", () => {
const adminApi = await import("./adminApi");
await adminApi.deleteOrphanUserLoginIDs(["orphan-1"]);
await adminApi.reconcileUserProjection();
await adminApi.resetUserProjection();
await adminApi.flushIdentityCache();
await adminApi.createTenant({ name: "Tenant", slug: "tenant" });
await adminApi.updateTenant("tenant-1", { status: "inactive" });
await adminApi.deleteTenant("tenant-1");
@@ -167,6 +178,7 @@ describe("adminApi endpoint contracts", () => {
"tenant-1",
"user-2",
"credential-batch-1",
"InputPass1!",
);
await adminApi.resetWorksmobileUserPassword(
"tenant-1",
@@ -199,7 +211,7 @@ describe("adminApi endpoint contracts", () => {
{ data: { ids: ["orphan-1"] } },
);
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", {
status: "active",
@@ -209,7 +221,10 @@ describe("adminApi endpoint contracts", () => {
);
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/sync",
{ credentialBatchId: "credential-batch-1" },
{
credentialBatchId: "credential-batch-1",
initialPassword: "InputPass1!",
},
);
expect(apiClient.post).toHaveBeenCalledWith(
"/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");
apiClient.put.mockResolvedValue({ data: {} });
await updateUser("user-id", { tenantSlug: "new-tenant" });
await updateUser("user-id", {
tenantSlug: "new-tenant",
isPrimaryTenant: true,
});
expect(apiClient.put).toHaveBeenCalledWith(
"/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");
});
@@ -61,6 +67,7 @@ describe("adminApi user tenant payloads", () => {
await bulkUpdateUsers({
userIds: ["user-id"],
tenantSlug: "new-tenant",
isPrimaryTenant: true,
});
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({
tenantSlug: "new-tenant",
isPrimaryTenant: true,
});
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});

View File

@@ -31,7 +31,23 @@ export type TenantSummary = {
domains?: string[];
parentId?: string;
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;
updatedAt: string;
};
@@ -145,19 +161,24 @@ export type AdminOverviewStats = {
auditEvents24h: number;
};
export type UserProjectionStatus = {
name: string;
status: "ready" | "failed" | "syncing" | string;
ready: boolean;
lastSyncedAt?: string;
export type IdentityCacheStatus = {
status: string;
redisReady: boolean;
mirrorVersion?: string;
observedCount: number;
keyCount: number;
lastRefreshedAt?: string;
lastError?: string;
updatedAt?: string;
projectedUsers: number;
};
export type UserProjectionActionResult = {
export type OrySSOTSystemStatus = {
identityCache: IdentityCacheStatus;
};
export type IdentityCacheFlushResult = {
status: string;
syncedUsers: number;
flushedKeys: number;
updatedAt: string;
};
@@ -254,23 +275,15 @@ export async function deleteOrphanUserLoginIDs(ids: string[]) {
return data;
}
export async function fetchUserProjectionStatus() {
const { data } = await apiClient.get<UserProjectionStatus>(
"/v1/admin/projections/users",
);
export async function fetchOrySSOTSystemStatus() {
const { data } =
await apiClient.get<OrySSOTSystemStatus>("/v1/admin/ory/ssot");
return data;
}
export async function reconcileUserProjection() {
const { data } = await apiClient.post<UserProjectionActionResult>(
"/v1/admin/projections/users/reconcile",
);
return data;
}
export async function resetUserProjection() {
const { data } = await apiClient.post<UserProjectionActionResult>(
"/v1/admin/projections/users/reset",
export async function flushIdentityCache() {
const { data } = await apiClient.post<IdentityCacheFlushResult>(
"/v1/admin/ory/ssot/identity-cache/flush",
);
return data;
}
@@ -299,11 +312,13 @@ export async function fetchTenants(
parentId?: string,
cursor?: string,
search?: string,
sort?: string,
direction?: "asc" | "desc",
) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
{
params: { limit, offset, parentId, cursor, search },
params: { limit, offset, parentId, cursor, search, sort, direction },
},
);
return data;
@@ -471,6 +486,61 @@ export async function removeTenantOwner(tenantId: string, userId: string) {
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
export type GroupMember = {
id: string;
@@ -703,6 +773,7 @@ export type UserUpdateRequest = {
role?: string;
status?: string;
tenantSlug?: string;
isPrimaryTenant?: boolean;
isAddTenant?: boolean;
isRemoveTenant?: boolean;
department?: string;
@@ -716,6 +787,28 @@ export type UserUpdateRequest = {
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 = {
tenantId: string;
tenantSlug?: string;
@@ -847,6 +940,9 @@ export type WorksmobileComparisonItem = {
baronSlug?: string;
baronName?: string;
baronEmail?: string;
baronPhone?: string;
baronEmployeeNumber?: string;
baronGrade?: string;
baronPrimaryOrgId?: string;
baronPrimaryOrgSlug?: string;
baronPrimaryOrgName?: string;
@@ -857,6 +953,9 @@ export type WorksmobileComparisonItem = {
externalKey?: string;
worksmobileName?: string;
worksmobileEmail?: string;
worksmobilePhone?: string;
worksmobileEmployeeNumber?: string;
worksmobileAccountStatus?: string;
worksmobileLevelId?: string;
worksmobileLevelName?: string;
worksmobileTask?: string;
@@ -878,9 +977,29 @@ export type WorksmobileComparisonItem = {
worksmobileJobRetryCount?: number;
worksmobileLastError?: string;
worksmobileLastAttemptAt?: string;
userMemberships?: WorksmobileUserMembershipComparison[];
updateReasons?: 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 = {
users: WorksmobileComparisonItem[];
groups: WorksmobileComparisonItem[];
@@ -906,6 +1025,23 @@ export async function fetchUser(userId: string) {
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) {
const { data } = await apiClient.post<UserCreateResponse>(
"/v1/admin/users",
@@ -1040,14 +1176,21 @@ export async function enqueueWorksmobileUserSync(
tenantId: string,
userId: string,
credentialBatchId?: string,
initialPassword?: string,
) {
const trimmedBatchId = credentialBatchId?.trim();
const trimmedInitialPassword = initialPassword?.trim();
const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`;
const { data } = trimmedBatchId
? await apiClient.post<WorksmobileOutboxItem>(path, {
credentialBatchId: trimmedBatchId,
})
: await apiClient.post<WorksmobileOutboxItem>(path);
const body = {
...(trimmedBatchId ? { credentialBatchId: trimmedBatchId } : {}),
...(trimmedInitialPassword
? { initialPassword: trimmedInitialPassword }
: {}),
};
const { data } =
Object.keys(body).length > 0
? await apiClient.post<WorksmobileOutboxItem>(path, body)
: await apiClient.post<WorksmobileOutboxItem>(path);
return data;
}
@@ -1078,12 +1221,17 @@ export async function bulkUpdateUsers(payload: {
status?: string;
role?: string;
tenantSlug?: string;
isPrimaryTenant?: boolean;
isAddTenant?: boolean;
department?: string;
position?: string;
grade?: 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;
}
@@ -1136,6 +1284,32 @@ export async function fetchUserRpHistory(userId: string) {
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 = {
id: string;
email: string;
@@ -1149,6 +1323,7 @@ export type UserProfileResponse = {
metadata?: Record<string, unknown>;
tenant?: TenantSummary;
manageableTenants?: TenantSummary[];
systemPermissions?: SystemPermissions;
};
export async function fetchMe() {

View File

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

View File

@@ -4,7 +4,10 @@ import {
buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings,
} from "../../../common/core/auth";
import { resolveAdminPublicOrigin } from "./authConfig";
import {
resolveAdminOidcAuthority,
resolveAdminPublicOrigin,
} from "./authConfig";
const adminPublicOrigin = resolveAdminPublicOrigin(
import.meta.env.VITE_ADMIN_PUBLIC_URL,
@@ -12,7 +15,10 @@ const adminPublicOrigin = resolveAdminPublicOrigin(
);
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",
origin: adminPublicOrigin,
userStore: new WebStorageStateStore({ store: window.localStorage }),
@@ -21,3 +27,31 @@ export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
export const userManager = new UserManager(
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 {
buildAdminAuthRedirectUris,
canStartBrowserPkceLogin,
resolveAdminOidcAuthority,
resolveAdminPublicOrigin,
} 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", () => {
expect(
canStartBrowserPkceLogin({

View File

@@ -5,6 +5,8 @@ export interface AdminAuthRedirectUris {
}
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(
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({
isSecureContext = window.isSecureContext,
origin = window.location.origin,

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
canManageTenantScopedUsers,
canManageUserInTenantScope,
isSuperAdminRole,
normalizeAdminRole,
ROLE_SUPER_ADMIN,
@@ -32,4 +34,43 @@ describe("admin role helpers", () => {
expect(isSuperAdminRole("admin")).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