1
0
forked from baron/baron-sso

189 Commits

Author SHA1 Message Date
f19b694c0b fix auth link session conflict policy 2026-05-21 13:50:18 +09:00
8dfe6fed82 adminfront 태넌트 화면 기능 누락 복구 2026-05-21 10:29:15 +09:00
5bb1c5871c fix staging frontend docker build context 2026-05-21 08:51:35 +09:00
2d6ca2f66b 코드 체크 개선 2026-05-21 08:46:08 +09:00
79f99757ee Fix frontend test dependencies after merge 2026-05-20 18:20:39 +09:00
49b78b3786 Merge remote-tracking branch 'origin/dev' into dev 2026-05-20 18:16:03 +09:00
2c3cab78b1 Update dev workflow and org chart settings 2026-05-20 18:15:54 +09:00
8b61c054e7 Merge pull request 'feature/admin-user-data-mgmt-fix' (#870) from feature/admin-user-data-mgmt-fix into dev
Reviewed-on: baron/baron-sso#870
2026-05-20 18:00:52 +09:00
c46c700c60 Merge branch 'dev' of https://gitea.hmac.kr/baron/baron-sso into dev 2026-05-20 17:59:37 +09:00
8c991ec48d Merge pull request 'feature/front-i18n' (#869) from feature/front-i18n into dev
Reviewed-on: baron/baron-sso#869
2026-05-20 17:19:36 +09:00
kyy
0af268021e code-check 오류 수정 2026-05-20 17:12:48 +09:00
kyy
b55ab7bc67 앱 생성 개발자 권한 신청 안내 추가 2026-05-20 13:41:16 +09:00
kyy
dcb442b68d 인증 가드 타이틀 보조 문구 위치 조정 2026-05-20 13:41:16 +09:00
kyy
dd1238a4e4 headless JWKS 워커 backoff 회귀 테스트 추가 2026-05-20 13:41:16 +09:00
kyy
16d43c5973 headless JWKS 워커 실패 backoff 및 timeout 단축 2026-05-20 13:41:16 +09:00
kyy
c21ea29111 인증 가드 박스 타이틀 아이콘 배지 제거 2026-05-20 13:41:15 +09:00
kyy
fc4a2f3536 공용 i18n namespace 정리 2026-05-20 13:41:15 +09:00
kyy
c2dbc8fc88 페이지 헤더 스타일 통일 2026-05-20 13:41:15 +09:00
kyy
528ceea754 공유 의존성 설치 락 수정 2026-05-20 13:41:15 +09:00
kyy
63622dcf28 박스 타이틀 description 추가 및 로고 추가 2026-05-20 13:41:15 +09:00
kyy
598f6ff9d1 공유 node_modules 설치 충돌 방지 2026-05-20 13:41:15 +09:00
kyy
611730f22a Kratos 허용 return URL 생성 로직 보강 2026-05-20 13:41:15 +09:00
kyy
c9664b5844 테넌트 페이지 타이틀/박스 타이틀 보조 문구 로케일 수정 2026-05-20 13:41:15 +09:00
kyy
a1f3604b24 adminfront 카드 타이틀 크기 통일 (text-lg) 및 한국어 적용 2026-05-20 13:41:15 +09:00
kyy
097caf395c 타이틀 크기 및 상단 패딩 밀림 제거 2026-05-20 13:41:15 +09:00
kyy
54fb7b4db6 타이틀 이모티콘 로고 제거 2026-05-20 13:41:15 +09:00
kyy
d1184613d8 서브 타이틀 텍스트 렌더링 오류 수정 2026-05-20 13:41:15 +09:00
kyy
222dc6f4a4 adminfront 상단 화면 i18n 정리 2026-05-20 13:41:15 +09:00
kyy
279bfae9ec adminfront code-check 설치 단계 pnpm으로 전환 2026-05-20 13:41:15 +09:00
kyy
7d99dba890 API 키 페이지 영문 적용 2026-05-20 13:41:15 +09:00
kyy
e7dab0f8fd adminfront /api-keys 새로고침 404 해결 2026-05-20 13:41:15 +09:00
kyy
c7d25f3611 API 키 페이지 locale 전환 시 /api-keys 404 방지 2026-05-20 13:41:15 +09:00
5496735e2f make dev/dev-debug 구분. 2026-05-20 13:34:19 +09:00
f4bfa7c129 fix(adminfront): fix double-click required for file input in bulk upload modal
Refactored the file input button to use a native HTML label with Radix UI's asChild prop. This ensures the file dialog opens reliably on the first click without relying on JS synthetic click events.
2026-05-20 13:30:29 +09:00
11d535f4e3 fix(adminfront): prevent bulk import modal from unmounting when dropdown closes
Lifted the modal state out of the DropdownMenuContent to ensure the dialog does not unmount immediately when the dropdown item is clicked.
2026-05-20 13:28:03 +09:00
53dacda5d5 feat(adminfront): add Data Management menu to User tab
This commit introduces a 'Data Management' dropdown menu to the User list page, consolidating user CSV import, template download, and export actions. It aligns the UI with the existing Tenant list page.
2026-05-20 13:25:21 +09:00
0155ee4ee7 front류 개발모드에서는 세션 갱신 끄기 2026-05-20 11:48:31 +09:00
0031784c07 userfront dev모드 구동 모드 run으로 변경 2026-05-20 11:37:02 +09:00
0f61425bbf Merge remote-tracking branch 'origin/dev' into dev 2026-05-20 11:19:30 +09:00
fd82dd9bdd 조직 연동 오류 해결 2026-05-20 11:17:31 +09:00
58f968b0fe Merge pull request 'feature/userfront-magic-link-ux-fix' (#865) from feature/userfront-magic-link-ux-fix into dev
Reviewed-on: baron/baron-sso#865
2026-05-20 11:03:23 +09:00
8f593cf6c0 Merge branch 'dev' into feature/userfront-magic-link-ux-fix 2026-05-20 10:52:43 +09:00
42b49674cc 사용자 상태 세분화 2026-05-20 10:17:15 +09:00
bb918932f4 feat(userfront): improve magic link approval UX on mobile
- Fixes issue #852 where 'verify_failed' error was shown on remote approval
- Added specialized success view for remote-originated approval requests
- Added 'Close Window' action for mobile browsers
- Improved error handling for already verified/used tokens
- Added necessary i18n strings in Korean and English
2026-05-19 17:59:10 +09:00
9112c4fb36 조직도 줌 레벨 상향 2026-05-18 18:06:13 +09:00
0b54992309 테넌트 설정 샘플 추가 2026-05-18 15:38:21 +09:00
e29d056b9e 네이버 웍스 연동기능 개선 2026-05-18 15:36:30 +09:00
c71ece84b8 Merge pull request 'feature/common-ui' (#838) from feature/common-ui into dev
Reviewed-on: baron/baron-sso#838
2026-05-18 10:03:29 +09:00
kyy
36fc945eaf 런트 CI 안정화를 위한 React 중복 해석 방지 및 설치 재시도 추가 2026-05-18 09:58:56 +09:00
kyy
f22a914586 devfront ci 포트 변경 2026-05-15 20:31:04 +09:00
kyy
b84c52366e devfront 코드 체크 오류 2026-05-15 20:20:57 +09:00
kyy
a4ffb49314 CI 실행 경로와 Playwright 서버 명령 정리 2026-05-15 20:14:33 +09:00
kyy
97c02fdba1 webServer.command를 npm 기준에서 pnpm 기준으로 변경 2026-05-15 20:08:52 +09:00
kyy
f028aeb716 devfront 코드 체크 오류 수정 2026-05-15 19:58:22 +09:00
kyy
e01b3475ec code check 오류 수정 2026-05-15 19:49:16 +09:00
kyy
cd16cb3a4a userfront 런타임 BACKEND_URL fallback 수정 2026-05-15 18:42:05 +09:00
kyy
eddab895e9 CSV 내보내기 버튼 공통 2026-05-15 18:42:05 +09:00
kyy
0f80ee4f4d 페이지 타이틀 하단 설명 문구 수정 2026-05-15 18:42:05 +09:00
kyy
9df69f22e8 감사 로그 테이블 헤더 및 검색창 문구 수정 2026-05-15 18:42:05 +09:00
kyy
974af01d34 타이틀 상단 보조문구 제거 2026-05-15 18:42:05 +09:00
kyy
18eede3a10 감사로그 공통 로케일 적용 2026-05-15 18:42:05 +09:00
kyy
055a804f7f 감사 로그 테이블 공통 컬럼 통일 2026-05-15 18:42:05 +09:00
kyy
94f33a0a64 감사로그 수행자 표시 2026-05-15 18:42:05 +09:00
kyy
0bf8089120 admin/dev 사이드바 프레임 공통화 2026-05-15 18:42:05 +09:00
kyy
0327409631 devfront: 개요 페이지 경로와 레이아웃 정리 2026-05-15 18:42:05 +09:00
kyy
c0894eeb8a 개요 페이지 공통 컴포넌트 및 문구 적용 2026-05-15 18:42:05 +09:00
kyy
c9bf16cf8e 개요 화면 공통 컴포넌트와 로케일 추가 2026-05-15 18:42:05 +09:00
kyy
cb602de049 감사로그 검색창 통일화 2026-05-15 18:42:05 +09:00
kyy
faffb6dc05 검색/필터 바 shell 공통화 2026-05-15 18:42:05 +09:00
kyy
b3c360c54f 검색/필터 바 공토 shell 컴포넌트 추가 2026-05-15 18:42:05 +09:00
kyy
12e37b24b0 사이드바 메뉴 순서 변경 2026-05-15 18:42:05 +09:00
kyy
153ea3bad5 페이지 헤더 레이아웃 공통화 2026-05-15 18:42:05 +09:00
kyy
3a0cd1cfed 페이지 헤더 공통 컴포넌트 통일 2026-05-15 18:42:05 +09:00
kyy
0a5ae51a68 페이지 헤더 공통 컴포넌트 추가 2026-05-15 18:42:05 +09:00
kyy
bdd42be57e 공통 테이블 헤더 배경 토큰 적용 2026-05-15 18:42:05 +09:00
kyy
b387673a8a 하위 테넌트 테이블 쉘을 common/ui/table 기준으로 정리 2026-05-15 18:42:05 +09:00
kyy
5ef8f933cc 공통 테이블 헤더 배경 토큰 적용 2026-05-15 18:42:05 +09:00
kyy
0d84dbcde1 테이블 헤더 배경 및 sticky surface 공통 토큰 추가 2026-05-15 18:42:05 +09:00
kyy
4d0d4f6a63 개발자 권한 신청 테이블 공통 스타일 적용 2026-05-15 18:42:05 +09:00
55c44b1a6c Merge remote-tracking branch 'origin/dev' into dev 2026-05-15 18:21:01 +09:00
d4090b7d8d fix: align local Ory cookie domain rendering 2026-05-15 18:20:49 +09:00
220e87494b Merge pull request 'feature/tenant-user-list-ui-improvement' (#834) from feature/tenant-user-list-ui-improvement into dev
Reviewed-on: baron/baron-sso#834
2026-05-15 17:46:15 +09:00
b7fbbf568d Merge branch 'dev' into feature/tenant-user-list-ui-improvement 2026-05-15 17:45:35 +09:00
41fe1b09c6 fix(ci): fix multiline cache key error by extracting only the first line of playwright version 2026-05-15 17:39:46 +09:00
b1e617ff37 fix(ci): ensure bash trap cleanup does not return a non-zero exit code which fails the overall github action job 2026-05-15 17:17:09 +09:00
cca8aea7a2 fix(ci): use --no-frozen-lockfile for pnpm install to bypass strict lockfile validation errors in github actions 2026-05-15 16:17:39 +09:00
14fb155cd9 fix(userfront): prevent public env asset request 2026-05-15 15:00:23 +09:00
d28a121d6c fix(ci): use pnpm exec playwright install for pnpm workspaces to fix command not found 2026-05-15 14:23:46 +09:00
4346f48bbe perf(userfront): optimize login web loading 2026-05-15 14:16:34 +09:00
16422f4e2e fix(ci): use npx playwright and fix adminfront scripts for reliable binary resolution in CI 2026-05-15 13:59:51 +09:00
eff21aaa82 chore(frontend): sync pnpm-lock.yaml to include @playwright/test in workspace apps for CI 2026-05-15 13:51:43 +09:00
1b483d4cbf fix(ci): add @playwright/test to workspace projects so pnpm exec can locate the binary locally 2026-05-15 13:46:02 +09:00
8f57c6b15f fix(ci): use pnpm exec playwright install to ensure playwright is found in pnpm workspaces 2026-05-15 13:37:14 +09:00
565d03da43 fix(ci): use pnpm instead of npm to run playwright tests 2026-05-15 13:36:55 +09:00
eb697e560a fix(ci): use pnpm instead of npm to extract playwright version for caching 2026-05-15 13:36:37 +09:00
8010d3644d chore(frontend): sync pnpm-lock.yaml after dependency updates to fix CI outdated lockfile errors 2026-05-15 13:28:27 +09:00
9f7b925e73 fix(ci): update github actions to properly support pnpm workspace 2026-05-15 13:20:16 +09:00
55d5e58783 fix(frontend): revert to safe npm install in docker runtime to resolve persistent pnpm ENOENT cross-device store errors 2026-05-15 11:59:34 +09:00
4f952df003 fix(frontend): resolve docker build pnpm workspace errors by using no-frozen-lockfile and ignoring pnpm-store 2026-05-15 11:56:49 +09:00
412695841b fix(frontend): use pnpm with no-frozen-lockfile to bypass docker mount issues 2026-05-15 11:37:39 +09:00
62d765a77b fix(frontend): fallback to standard npm install in docker runtime scripts to bypass pnpm cross-device hardlink limitations 2026-05-15 11:31:10 +09:00
4de7124a3c fix(frontend): force hoisted node-linker in docker runtime script to prevent cross-volume symlink/hardlink ENOENT errors 2026-05-15 11:30:15 +09:00
e71e090eec fix(frontend): use isolated pnpm store inside docker to prevent ENOENT cache corruption errors 2026-05-15 11:24:22 +09:00
1a0dddbd98 fix(frontend): force clean pnpm install in runtime script to prevent ENOENT errors with corrupted node_modules mounts 2026-05-15 11:21:18 +09:00
dcab9205d2 fix(frontend): run pnpm install with CI=true to prevent TTY prompt errors during runtime setup 2026-05-15 11:14:22 +09:00
8951de510e refactor(frontend): centralize configurations and deduplicate dependencies in common workspace
- Centralized biome.json, tailwind.config.ts, and vite.config.ts into common/config.
- Updated sub-apps to inherit from shared base configurations.
- Deduplicated dependencies across apps using common workspace.
- Fixed TypeScript resolution issues by restoring necessary build dependencies.
- Removed obsolete package-lock.json files.
- Applied minor import fixes via Biome.
- Fixed react-router-dom v7 type errors.
2026-05-15 10:28:07 +09:00
4ca562ce0e fix: Dockerfile에서 더 이상 존재하지 않는 루트 package.json 복사 시도 오류 수정 2026-05-14 17:07:42 +09:00
3f957d7a9f chore: 모노레포 구축에 따른 Dockerfile 빌드 컨텍스트 변경 및 pnpm 의존성 설치 지원 2026-05-14 17:01:05 +09:00
c2a9e1044c common/node_modules 2026-05-14 16:36:14 +09:00
254e34dbca chore: 모노레포 구축에 따른 Docker 컨테이너 및 런타임 스크립트 대응 2026-05-14 16:29:27 +09:00
c06a5bf181 feat: common 폴더를 루트로 하는 pnpm workspace 모노레포 구축 2026-05-14 15:56:16 +09:00
57456bd4cd Merge pull request '테넌트 및 사용자 목록 UI 개선: 계층 구조 테이블 도입, 벌크 액션 바 통합, 컬럼 순서 조정 및 데이터 관리 드롭다운 적용' (#818) from feature/tenant-user-list-ui-improvement into dev
Reviewed-on: baron/baron-sso#818
2026-05-14 12:57:24 +09:00
d1b550f6f7 test: 프론트엔드 변경된 UI(트리 및 데이터 관리)에 따른 전체 테스트 케이스 동기화 및 픽스 완료 2026-05-14 12:50:35 +09:00
0b92ad49da test: 프론트엔드 테스트 환경에서 벌크 액션 버튼명 변경에 따른 테스트 케이스 동기화 2026-05-14 11:55:17 +09:00
5cd3f04f69 fix: 프론트엔드 테스트 환경 런타임 오류(getSortIcon 누락) 및 타입스크립트 에러 수정 2026-05-14 11:35:27 +09:00
574238c744 Merge branch 'dev' into feature/tenant-user-list-ui-improvement 2026-05-14 11:23:57 +09:00
1b9687e9e8 fix: 백엔드 테스트 추가 검증사항 수정 및 다국어 템플릿 정리 2026-05-14 11:20:38 +09:00
b4a3cc4318 Merge pull request 'feature/df-dashboard' (#819) from feature/df-dashboard into dev
Reviewed-on: baron/baron-sso#819
2026-05-14 11:14:07 +09:00
kyy
258c91a740 adminfront profile 접근 에러 2026-05-14 11:07:28 +09:00
ece8df50f6 Merge branch 'dev' into feature/tenant-user-list-ui-improvement 2026-05-14 11:06:39 +09:00
024e1cc5bd fix: 기타 문법 오류 수정 및 i18n 언어팩(누락된 키) 업데이트 2026-05-14 11:01:49 +09:00
841e1f8ab2 fix: 백엔드 테스트 오류 수정 (외래 키 제약조건 및 데이터 무결성 검증 로직 반영) 2026-05-14 11:00:15 +09:00
kyy
79f5ace7ef 누락 키 및 린트 적용 2026-05-14 10:56:23 +09:00
kyy
da10b4be15 92e607aee8 기준 code check 오류 수정 2026-05-14 10:24:21 +09:00
kyy
e803a0b150 프로필 메뉴 문구 한국어 적용 2026-05-14 10:23:24 +09:00
kyy
c7ed9186c9 consents 탭 박스 정렬 수정 2026-05-14 10:23:24 +09:00
kyy
76a63264fe devfront consents 및 audit 테이블 공통화 2026-05-14 10:23:24 +09:00
kyy
481ec5fc15 감사 로그 테이블에 공통 table 스타일 적용 2026-05-14 10:23:24 +09:00
kyy
ee8cfb4ba8 common/ui/table 기준 테이블 스타일 공통화 2026-05-14 10:23:24 +09:00
kyy
c8ac953b14 adminfront 사용자/테넌트 테이블 쉘 공통화 2026-05-14 10:23:24 +09:00
kyy
40d64acf15 devfront 연동 앱 목록 테이블 간격 및 문구 정리 2026-05-14 10:23:24 +09:00
kyy
4a0e5641cb dev/admin 테이블 정렬 헤더 UI 공통화 2026-05-14 10:23:24 +09:00
kyy
8a8b5baaf6 테이블 기본 정렬 생성일 통일 2026-05-14 10:23:24 +09:00
kyy
187f0da29b 정렬 헤더 UI 공통화 및 devfront secret 표시 수정 2026-05-14 10:23:24 +09:00
kyy
498fdd802c Server side app 클라이언트 키 표시 2026-05-14 10:23:24 +09:00
kyy
b9a351ca59 비권한 사용자 안내문구 2026-05-14 10:23:24 +09:00
kyy
a26093836f 불필요한 파일 제거 2026-05-14 10:23:24 +09:00
d77199bdbc Fix code-check locale and headless test failures 2026-05-14 10:15:50 +09:00
d3e83332fb 테넌트 및 사용자 목록 UI 개선: 계층 구조 테이블 도입, 벌크 액션 바 통합, 컬럼 순서 조정 및 데이터 관리 드롭다운 적용 2026-05-14 10:01:51 +09:00
8bca127723 orgfront 코드 체크 추가, 백엔드 기준 강화 2026-05-14 09:49:37 +09:00
92e607aee8 정합성 검사 중복실행 방지 2026-05-14 09:23:54 +09:00
df543d6203 정합성 위반사항 확인 및 조치기능 추가 2026-05-14 09:04:33 +09:00
9ca73e8774 권한부여 및 정합성 검사 추가 2026-05-14 08:45:48 +09:00
f6f8e88342 refactoring 2026-05-14 08:11:02 +09:00
e36a973053 사용자 테넌트 소속 데이터 정리 2026-05-13 18:27:55 +09:00
8a6e41d74c 테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거 2026-05-13 18:10:37 +09:00
5e7b7b878c 테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거 2026-05-13 18:05:51 +09:00
f4ed1057a2 Merge pull request 'fix/adminfront-bulk-upload-type-error' (#793) from fix/adminfront-bulk-upload-type-error into dev
Reviewed-on: baron/baron-sso#793
2026-05-13 15:15:36 +09:00
f047c24a38 fix(adminfront): fix ReferenceErrors and null checks in UI components
- Import and initialize 'navigate' in TenantUsersPage and TenantAdminsAndOwnersTab.
- Use optional chaining for 'user.id' in UserDetailPage to prevent runtime errors during initial load.
2026-05-13 15:08:36 +09:00
9681945f5a test(adminfront): update tests to match recent UI changes
- Remove checks for deleted 'Action' column buttons in tenant list.
- Update user list test to expect 'ROLE' column.
- Update user creation test to expect role field and payload.
2026-05-13 15:00:17 +09:00
3a3bfd3c00 Merge branch 'dev' into fix/adminfront-bulk-upload-type-error 2026-05-13 14:52:32 +09:00
a31eceaf16 feat(adminfront): implement user role management and cleanup tenant list UI
- Add user role management (view, edit, bulk) in UserListPage, UserDetailPage, and UserCreatePage.
- Restrict role modification to super_admin only.
- Remove redundant action columns from tenant-related lists (TenantListPage, TenantSubTenantsPage, TenantUsersPage, TenantAdminsAndOwnersTab).
- Improve navigation by making table rows clickable where actions were removed.
2026-05-13 14:50:11 +09:00
a4d707d4d8 기능 재배포 2026-05-13 14:27:00 +09:00
629716f226 액션 러너 캐시 정리 2026-05-13 14:04:59 +09:00
6ed9b2b734 Merge remote-tracking branch 'origin/dev' into dev 2026-05-13 13:46:10 +09:00
8c2b2f71ef 조직도 M2M조회 추가, 자동로그인 보완 2026-05-13 13:44:30 +09:00
ee24842225 Merge pull request 'fix(adminfront): add missing fields to TenantCSVRow in bulk upload modal' (#782) from fix/adminfront-bulk-upload-type-error into dev
Reviewed-on: baron/baron-sso#782
2026-05-13 11:32:45 +09:00
5f48a1c172 fix(adminfront): add missing fields to TenantCSVRow in bulk upload modal 2026-05-13 11:29:29 +09:00
72288f1d39 기능 동기화 2026-05-13 08:50:01 +09:00
def2f924c9 Merge remote-tracking branch 'origin/dev' into dev 2026-05-13 08:42:45 +09:00
ae0a516ee4 Merge pull request 'feature/common-core' (#774) from feature/common-core into dev
Reviewed-on: baron/baron-sso#774
2026-05-12 18:08:59 +09:00
0c706a8936 headless link 테스트 환경 격리 2026-05-12 18:05:24 +09:00
kyy
298b919d1a 누락 키 수정 및 린트 적용 2026-05-12 18:04:41 +09:00
937f2f9820 Merge remote-tracking branch 'origin/dev' into dev 2026-05-12 18:03:08 +09:00
e8a4d7544f 테넌트 CSV 조직 설정 동기화 보완 2026-05-12 18:02:55 +09:00
kyy
878867f6cc 대시보드 기능 추가 2026-05-12 17:17:51 +09:00
kyy
250bc297fa 볼륨 마운트 추가 2026-05-12 17:17:51 +09:00
68eeac90f7 Merge pull request 'feature/common-core' (#768) from feature/common-core into dev
Reviewed-on: baron/baron-sso#768
2026-05-12 15:14:44 +09:00
kyy
ee41083b73 adminfront ci 스크립트 common 디렉터리 추가 2026-05-12 15:10:09 +09:00
kyy
45ce440569 code-check import 오류 수정 2026-05-12 15:01:03 +09:00
kyy
084e8594ff userfront code check 오류 수정 2026-05-12 14:50:19 +09:00
kyy
f810efd420 5e649c279f 기준 code-check 오류 수정 2026-05-12 13:51:13 +09:00
kyy
7259c62251 dev 병합 code-check 오류 수정 2026-05-12 13:51:13 +09:00
kyy
6709bf3029 누락 및 불필요한 키 수정 2026-05-12 13:51:11 +09:00
kyy
3626584046 RP 대시보드 기능 추가 2026-05-12 13:49:51 +09:00
kyy
a2a6938246 common 정렬 헬퍼 공통화 및 devfront 목록 정렬 추가 2026-05-12 13:49:51 +09:00
kyy
a0713df85a tailwind content 경로에 common 레이어 추가 2026-05-12 13:49:51 +09:00
kyy
48853aae99 theme 미적용 오류 수정 2026-05-12 13:49:51 +09:00
kyy
5149fdc246 button badge input card 공용화 2026-05-12 13:49:51 +09:00
kyy
85e1a172dd common shell frame/state helper 공용화 2026-05-12 13:49:51 +09:00
kyy
7d7f17ab69 front 공통 theme token 및 base style 정리 2026-05-12 13:49:51 +09:00
kyy
1c083dd586 common auth/session bootstrap과 renew policy 공용화 2026-05-12 13:49:51 +09:00
kyy
1419c8db27 gitkeep 파일 제거 2026-05-12 13:49:51 +09:00
kyy
0655206f05 common utils 경로로 cn helper 공용화 2026-05-12 13:49:51 +09:00
kyy
d371bd32c8 common query client 기본 옵션 공용화 2026-05-12 13:49:51 +09:00
kyy
c0c5a23dc1 common/locales 기반 i18n 스캐너와 문서 정리 2026-05-12 13:49:51 +09:00
kyy
27f48baadc 컨테이너/실행 환경 보정 2026-05-12 13:49:51 +09:00
kyy
b8a25135fc 각 프런트의 i18n 연결 및 locale 정리 2026-05-12 13:49:51 +09:00
kyy
efbf970a18 공통 i18n 레이어 추가 2026-05-12 13:49:51 +09:00
428 changed files with 52578 additions and 14112 deletions

View File

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

View File

@@ -32,6 +32,11 @@ BACKEND_LOG_LEVEL=
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준) REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요 CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
# --- NAVER WORKS API ---
WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com
WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token
# Audit System Configuration # Audit System Configuration
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수 AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기 AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기

View File

@@ -89,7 +89,7 @@ jobs:
- name: Build and push adminfront RC image - name: Build and push adminfront RC image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ./adminfront context: .
file: ./adminfront/Dockerfile file: ./adminfront/Dockerfile
push: true push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.rc_calculator.outputs.new_rc_tag }} tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
@@ -99,7 +99,7 @@ jobs:
- name: Build and push devfront RC image - name: Build and push devfront RC image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ./devfront context: .
file: ./devfront/Dockerfile file: ./devfront/Dockerfile
push: true push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.rc_calculator.outputs.new_rc_tag }} tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.rc_calculator.outputs.new_rc_tag }}
@@ -109,7 +109,7 @@ jobs:
- name: Build and push orgfront RC image - name: Build and push orgfront RC image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: ./orgfront context: .
file: ./orgfront/Dockerfile file: ./orgfront/Dockerfile
push: true push: true
tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_tag }} tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.rc_calculator.outputs.new_rc_tag }}

View File

@@ -57,11 +57,6 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "24" node-version: "24"
cache: "npm"
cache-dependency-path: |
adminfront/package-lock.json
devfront/package-lock.json
orgfront/package-lock.json
- name: i18n resource check - name: i18n resource check
run: | run: |
@@ -91,7 +86,8 @@ jobs:
- name: Install adminfront dependencies - name: Install adminfront dependencies
run: | run: |
cd adminfront cd adminfront
npm ci npm install -g pnpm
pnpm install -C ../common --no-frozen-lockfile
- name: Biome check adminfront (lint + format) - name: Biome check adminfront (lint + format)
run: | run: |
@@ -102,7 +98,8 @@ jobs:
- name: Install devfront dependencies - name: Install devfront dependencies
run: | run: |
cd devfront cd devfront
npm ci npm install -g pnpm
pnpm install -C ../common --no-frozen-lockfile
- name: Biome check devfront (lint + format) - name: Biome check devfront (lint + format)
run: | run: |
@@ -113,7 +110,8 @@ jobs:
- name: Install orgfront dependencies - name: Install orgfront dependencies
run: | run: |
cd orgfront cd orgfront
npm ci npm install -g pnpm
pnpm install -C ../common --no-frozen-lockfile
- name: Biome check orgfront (lint + format) - name: Biome check orgfront (lint + format)
run: | run: |
@@ -336,8 +334,7 @@ jobs:
- name: Get Playwright version - name: Get Playwright version
id: playwright-version id: playwright-version
run: | run: |
cd userfront-e2e node scripts/playwrightPackageVersion.cjs userfront-e2e >> "$GITHUB_OUTPUT"
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright Browsers - name: Cache Playwright Browsers
uses: actions/cache@v4 uses: actions/cache@v4
@@ -561,14 +558,11 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "24" node-version: "24"
cache: "npm"
cache-dependency-path: adminfront/package-lock.json
- name: Get Playwright version - name: Get Playwright version
id: playwright-version id: playwright-version
run: | run: |
cd adminfront node scripts/playwrightPackageVersion.cjs adminfront >> "$GITHUB_OUTPUT"
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright Browsers - name: Cache Playwright Browsers
uses: actions/cache@v4 uses: actions/cache@v4
@@ -656,14 +650,12 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "24" node-version: "24"
cache: "npm"
cache-dependency-path: devfront/package-lock.json
- name: Get Playwright version - name: Get Playwright version
id: playwright-version id: playwright-version
working-directory: devfront
run: | run: |
cd devfront node ../scripts/playwrightPackageVersion.cjs . >> "$GITHUB_OUTPUT"
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright Browsers - name: Cache Playwright Browsers
uses: actions/cache@v4 uses: actions/cache@v4
@@ -675,13 +667,12 @@ jobs:
${{ runner.os }}-playwright- ${{ runner.os }}-playwright-
- name: Install devfront dependencies - name: Install devfront dependencies
working-directory: devfront
run: | run: |
mkdir -p reports mkdir -p ../reports
set +e set +e
cd devfront pnpm install -C ../common --no-frozen-lockfile 2>&1 | tee ../reports/devfront-install.log
npm ci 2>&1 | tee ../reports/devfront-install.log
install_exit_code=${PIPESTATUS[0]} install_exit_code=${PIPESTATUS[0]}
cd ..
set -e set -e
if [ "$install_exit_code" -ne 0 ]; then if [ "$install_exit_code" -ne 0 ]; then
@@ -694,23 +685,22 @@ jobs:
echo "- Exit Code: \`$install_exit_code\`" echo "- Exit Code: \`$install_exit_code\`"
echo echo
echo "## Command" echo "## Command"
echo "\`cd devfront && npm ci\`" echo "\`cd devfront && pnpm install -C ../common --no-frozen-lockfile\`"
echo echo
echo "## Install Log Tail (last 200 lines)" echo "## Install Log Tail (last 200 lines)"
echo '```text' echo '```text'
tail -n 200 reports/devfront-install.log tail -n 200 ../reports/devfront-install.log
echo '```' echo '```'
} > reports/devfront-test-failure-report.md } > ../reports/devfront-test-failure-report.md
exit 1 exit 1
fi fi
- name: Provision browsers for devfront tests - name: Provision browsers for devfront tests
working-directory: devfront
run: | run: |
set +e set +e
cd devfront pnpm exec playwright install --with-deps 2>&1 | tee ../reports/devfront-provision.log
npx playwright install --with-deps 2>&1 | tee ../reports/devfront-provision.log
provision_exit_code=${PIPESTATUS[0]} provision_exit_code=${PIPESTATUS[0]}
cd ..
set -e set -e
if [ "$provision_exit_code" -ne 0 ]; then if [ "$provision_exit_code" -ne 0 ]; then
@@ -723,26 +713,25 @@ jobs:
echo "- Exit Code: \`$provision_exit_code\`" echo "- Exit Code: \`$provision_exit_code\`"
echo echo
echo "## Command" echo "## Command"
echo "\`cd devfront && npx playwright install --with-deps\`" echo "\`cd devfront && pnpm exec playwright install --with-deps\`"
echo echo
echo "## Provision Log Tail (last 200 lines)" echo "## Provision Log Tail (last 200 lines)"
echo '```text' echo '```text'
tail -n 200 reports/devfront-provision.log tail -n 200 ../reports/devfront-provision.log
echo '```' echo '```'
} > reports/devfront-test-failure-report.md } > ../reports/devfront-test-failure-report.md
exit 1 exit 1
fi fi
- name: Run devfront tests - name: Run devfront tests
working-directory: devfront
env: env:
PLAYWRIGHT_WORKERS: 2 PLAYWRIGHT_WORKERS: 2
run: | run: |
mkdir -p reports mkdir -p ../reports
set +e set +e
cd devfront pnpm test 2>&1 | tee ../reports/devfront-test.log
npm test 2>&1 | tee ../reports/devfront-test.log
test_exit_code=${PIPESTATUS[0]} test_exit_code=${PIPESTATUS[0]}
cd ..
set -e set -e
if [ "$test_exit_code" -ne 0 ]; then if [ "$test_exit_code" -ne 0 ]; then
@@ -755,15 +744,15 @@ jobs:
echo echo
echo "## Commands" echo "## Commands"
echo "1. \`cd devfront\`" echo "1. \`cd devfront\`"
echo "2. \`npm ci\`" echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`"
echo "3. \`npx playwright install --with-deps\`" echo "3. \`pnpm exec playwright install --with-deps\`"
echo "4. \`npm test\`" echo "4. \`pnpm test\`"
echo echo
echo "## Log Tail (last 200 lines)" echo "## Log Tail (last 200 lines)"
echo '```text' echo '```text'
tail -n 200 reports/devfront-test.log tail -n 200 ../reports/devfront-test.log
echo '```' echo '```'
} > reports/devfront-test-failure-report.md } > ../reports/devfront-test-failure-report.md
fi fi
exit "$test_exit_code" exit "$test_exit_code"
@@ -839,14 +828,11 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: "24" node-version: "24"
cache: "npm"
cache-dependency-path: orgfront/package-lock.json
- name: Get Playwright version - name: Get Playwright version
id: playwright-version id: playwright-version
run: | run: |
cd orgfront node scripts/playwrightPackageVersion.cjs orgfront >> "$GITHUB_OUTPUT"
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright Browsers - name: Cache Playwright Browsers
uses: actions/cache@v4 uses: actions/cache@v4
@@ -862,7 +848,8 @@ jobs:
mkdir -p reports mkdir -p reports
set +e set +e
cd orgfront cd orgfront
npm ci 2>&1 | tee ../reports/orgfront-install.log npm install -g pnpm
pnpm install -C ../common --no-frozen-lockfile 2>&1 | tee ../reports/orgfront-install.log
install_exit_code=${PIPESTATUS[0]} install_exit_code=${PIPESTATUS[0]}
cd .. cd ..
set -e set -e
@@ -891,7 +878,7 @@ jobs:
run: | run: |
set +e set +e
cd orgfront cd orgfront
npx playwright install --with-deps 2>&1 | tee ../reports/orgfront-provision.log pnpm exec playwright install --with-deps 2>&1 | tee ../reports/orgfront-provision.log
provision_exit_code=${PIPESTATUS[0]} provision_exit_code=${PIPESTATUS[0]}
cd .. cd ..
set -e set -e
@@ -906,7 +893,7 @@ jobs:
echo "- Exit Code: \`$provision_exit_code\`" echo "- Exit Code: \`$provision_exit_code\`"
echo echo
echo "## Command" echo "## Command"
echo "\`cd orgfront && npx playwright install --with-deps\`" echo "\`cd orgfront && pnpm exec playwright install --with-deps\`"
echo echo
echo "## Provision Log Tail (last 200 lines)" echo "## Provision Log Tail (last 200 lines)"
echo '```text' echo '```text'
@@ -923,7 +910,7 @@ jobs:
mkdir -p reports mkdir -p reports
set +e set +e
cd orgfront cd orgfront
npm test 2>&1 | tee ../reports/orgfront-test.log pnpm run test 2>&1 | tee ../reports/orgfront-test.log
test_exit_code=${PIPESTATUS[0]} test_exit_code=${PIPESTATUS[0]}
cd .. cd ..
set -e set -e
@@ -939,8 +926,8 @@ jobs:
echo "## Commands" echo "## Commands"
echo "1. \`cd orgfront\`" echo "1. \`cd orgfront\`"
echo "2. \`npm ci\`" echo "2. \`npm ci\`"
echo "3. \`npx playwright install --with-deps\`" echo "3. \`pnpm exec playwright install --with-deps\`"
echo "4. \`npm test\`" echo "4. \`pnpm run test\`"
echo echo
echo "## Log Tail (last 200 lines)" echo "## Log Tail (last 200 lines)"
echo '```text' echo '```text'

View File

@@ -48,6 +48,8 @@ jobs:
APP_ENV=stage APP_ENV=stage
BACKEND_LOG_LEVEL=debug BACKEND_LOG_LEVEL=debug
CLIENT_LOG_DEBUG=true CLIENT_LOG_DEBUG=true
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
TZ=Asia/Seoul TZ=Asia/Seoul
IDP_PROVIDER=ory IDP_PROVIDER=ory

View File

@@ -58,6 +58,8 @@ jobs:
APP_ENV=stage APP_ENV=stage
BACKEND_LOG_LEVEL=debug BACKEND_LOG_LEVEL=debug
CLIENT_LOG_DEBUG=true CLIENT_LOG_DEBUG=true
WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}
WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}
TZ=Asia/Seoul TZ=Asia/Seoul
IDP_PROVIDER=ory IDP_PROVIDER=ory

4
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# General # General
.env .env
.env_backup
.temp .temp
.DS_Store .DS_Store
.idea/ .idea/
@@ -17,6 +18,8 @@ config/.generated/
reports reports
reports/* reports/*
config/*.pem config/*.pem
common/node_modules
common/.baron-deps-install.lock
# Docker Services Data (Volumes) # Docker Services Data (Volumes)
postgres_data/ postgres_data/
@@ -50,3 +53,4 @@ orgfront/playwright-report/
orgfront/node_modules/ orgfront/node_modules/
orgfront/dist/ orgfront/dist/
orgfront/.vite/ orgfront/.vite/
.pnpm-store

View File

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

View File

@@ -29,7 +29,7 @@ ifneq (,$(wildcard ./.env))
COMPOSE_DROP_ENV_ARGS += --env-file .env COMPOSE_DROP_ENV_ARGS += --env-file .env
endif endif
.PHONY: build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app .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
# --- 인증 설정 빌드/검증 --- # --- 인증 설정 빌드/검증 ---
build-auth-config: build-auth-config:
@@ -56,6 +56,7 @@ up: up-all
up-all: ensure-networks render-ory-config up-all: ensure-networks render-ory-config
@echo "Starting ALL stacks (infra + ory + app)..." @echo "Starting ALL stacks (infra + ory + app)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up --build -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) restart kratos
# --- 개별 스택 실행 --- # --- 개별 스택 실행 ---
up-infra: ensure-networks up-infra: ensure-networks
@@ -65,6 +66,7 @@ up-infra: ensure-networks
up-ory: ensure-networks render-ory-config up-ory: ensure-networks render-ory-config
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..." @echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos
up-app: ensure-networks render-ory-config up-app: ensure-networks render-ory-config
@echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..." @echo "Starting App stack (backend/userfront/adminfront/devfront/orgfront)..."
@@ -114,7 +116,8 @@ ensure-ory: ensure-networks render-ory-config
echo "Starting missing Ory stack containers in daemon mode..."; \ echo "Starting missing Ory stack containers in daemon mode..."; \
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \ docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \
else \ else \
echo "Ory stack is already running."; \ echo "Ory stack is already running. Restarting Kratos to apply rendered dev config..."; \
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) restart kratos; \
fi fi
up-dev: ensure-infra ensure-ory up-dev: ensure-infra ensure-ory
@@ -125,7 +128,11 @@ up-front-dev: up-infra up-ory up-backend
dev: up-dev dev: up-dev
@echo "Starting development app containers in foreground attach mode..." @echo "Starting development app containers in foreground attach mode..."
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES) BACKEND_LOG_LEVEL=info CLIENT_LOG_DEBUG=false VITE_CLIENT_LOG_DEBUG=false docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up --build $(DEV_SERVICES)
dev-debug: up-dev
@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: down:
@@ -256,7 +263,7 @@ code-check-userfront-lint:
code-check-front-lint: code-check-front-lint:
@echo "==> adminfront biome lint/format check" @echo "==> adminfront biome lint/format check"
rm -rf adminfront/playwright-report adminfront/test-results rm -rf adminfront/playwright-report adminfront/test-results
cd adminfront && npm ci --ignore-scripts cd adminfront && pnpm install --frozen-lockfile --ignore-scripts
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
@echo "==> devfront biome lint/format check" @echo "==> devfront biome lint/format check"

View File

@@ -155,6 +155,7 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
### 한맥가족 User Import Email 정책 ### 한맥가족 User Import Email 정책
- 전체 시스템에서 `users.email`은 unique입니다. - 전체 시스템에서 `users.email`은 unique입니다.
- `active`, `temporary_leave`, `suspended`, `preboarding`, `baron_guest`, `extended_leave`, `archived` 등 모든 사용자 상태가 unique 검사 대상입니다. 특히 `preboarding`, `baron_guest`, `archived` 사용자는 email/local-part 선점 대상입니다.
- 한맥가족 테넌트 root(`hanmac-family`)와 그 하위 subtree에서는 이메일 도메인과 무관하게 `@` 앞 local-part도 unique 해야 합니다. - 한맥가족 테넌트 root(`hanmac-family`)와 그 하위 subtree에서는 이메일 도메인과 무관하게 `@` 앞 local-part도 unique 해야 합니다.
- 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다. - 예: `han@hanmaceng.co.kr`가 한맥가족 구성원으로 있으면 `han@samaneng.com`은 한맥가족 구성원으로 생성할 수 없습니다.
- `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다. - `email` 값이 `@hanmaceng.co.kr`처럼 도메인만 있으면 import preview에서 이름 기반 local-part를 제안합니다.
@@ -171,6 +172,22 @@ AdminFront의 테넌트와 사용자 export/import는 운영자가 CSV를 직접
- 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다. - 단건 사용자 생성은 한맥가족 local-part 중복 시 자동 제안하지 않고 `409 Conflict`로 차단합니다.
- bulk import는 preview에서 제안/수정된 최종 email을 사용하되, backend가 생성 직전에 다시 unique 규칙을 검증합니다. - bulk import는 preview에서 제안/수정된 최종 email을 사용하되, backend가 생성 직전에 다시 unique 규칙을 검증합니다.
### User Status 정책
| 상태 | 표시명 | Baron 사용 | Works 처리 | 일반 조직도 |
| --- | --- | --- | --- | --- |
| `active` | 재직 | 가능 | 생성/갱신 | 노출 |
| `temporary_leave` | 단기휴무 | 가능 | 계정 유지 | 노출 |
| `suspended` | 정지 | 불가 | suspend | 노출 |
| `preboarding` | 입사대기 | 불가 | 생성 안 함 | 비노출 |
| `baron_guest` | Baron 게스트 | 가능 | 생성 금지, 기존 계정 delete/deprovision | 비노출 |
| `extended_leave` | 장기휴직 | 불가 | delete/deprovision | 비노출 |
| `archived` | 보관 | 불가 | delete/deprovision | 비노출 |
- 기존 `inactive` 입력은 `preboarding`으로, `leave_of_absence` 입력은 `temporary_leave`로 호환 처리합니다.
- 이슈 #862의 초기 명칭 `baron_only`는 구현 명칭으로 사용하지 않고 `baron_guest`로 정리합니다.
- backend bootstrap은 남아 있는 legacy `users.status` 값을 `inactive -> preboarding`, `leave_of_absence -> temporary_leave`, `baron_only -> baron_guest`로 자동 정규화합니다.
- `archived` 사용자는 과거 이력 보존용 계정이며 AdminFront 같은 관리자 화면에서만 감사/운영/중복 확인 목적으로 조회할 수 있습니다.
### 4. 주요 시나리오 (Core Scenarios) ### 4. 주요 시나리오 (Core Scenarios)
1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인). 1. **Same Browser SSO**: Baron 로그인 서비스에 로그인된 상태에서 런처를 통해 타 앱/서비스로 이동 (자동 로그인).
@@ -541,11 +558,12 @@ KETO_WRITE_URL = "http://keto:4467"
``` ```
## 🌐 i18n 구조 (간략) ## 🌐 i18n 구조 (간략)
- **Source of Truth**: `locales/template.toml`이 전체 키의 기준이며 `locales/ko.toml`, `locales/en.toml`과 항상 동기화합니다. - **Root locales**: `locales/template.toml`, `locales/ko.toml`, `locales/en.toml`은 현재 `userfront`와 전역 i18n 검증 기준 리소스입니다.
- **React(Admin/Dev)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`로 사용하고 TOML을 `?raw`로 로드합니다. - **Common locales**: `common/locales/template.toml`, `common/locales/ko.toml`, `common/locales/en.toml``ui.common.*`, `msg.common.*` 같은 React 공통 문구 레이어입니다.
- **React(Admin/Dev/Org)**: `adminfront/src/lib/i18n.ts`, `devfront/src/lib/i18n.ts`, `orgfront/src/lib/i18n.ts`에서 `t(key, fallback, vars)`를 사용하며 `common locale -> app locale override` 순서로 TOML을 `?raw` 로드합니다.
- **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml``tools/i18n-scanner/gen-flutter-i18n.js``userfront/lib/i18n_data.dart`에 사전 생성합니다. - **Flutter(User)**: `userfront/lib/i18n.dart`에서 `tr(key, fallback, params)` 사용. `locales/*.toml``tools/i18n-scanner/gen-flutter-i18n.js``userfront/lib/i18n_data.dart`에 사전 생성합니다.
- **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다. - **UserFront 동기화 규칙**: `locales/*.toml`을 수정한 뒤에는 반드시 `./scripts/sync_userfront_locales.sh`를 실행해 `userfront/assets/translations/*.toml`과 런타임 번역 리소스를 동기화합니다.
- **검증**: `node tools/i18n-scanner/index.js`로 코드-키-로케일 동기화 상태를 점검합니다. - **검증**: `node tools/i18n-scanner/index.js` `root locales``common/locales` 코드-키-로케일 동기화 상태를 함께 점검합니다.
## 🧪 Code Check CI ## 🧪 Code Check CI
워크플로우 파일: `.gitea/workflows/code_check.yml` 워크플로우 파일: `.gitea/workflows/code_check.yml`

View File

@@ -1,16 +1,21 @@
FROM node:lts FROM node:lts
WORKDIR /app WORKDIR /workspace
# 패키지 정보 복사 및 의존성 설치 # Install pnpm
COPY package*.json ./ RUN npm install -g pnpm
RUN npm ci
# Copy workspace configs and common package
COPY common ./common
COPY adminfront ./adminfront
# Install dependencies for the workspace
RUN cd common && pnpm install --no-frozen-lockfile --ignore-scripts
# 프로덕션 서빙을 위한 serve 패키지 글로벌 설치 # 프로덕션 서빙을 위한 serve 패키지 글로벌 설치
RUN npm install -g serve RUN npm install -g serve
# 소스 코드 복사 WORKDIR /workspace/adminfront
COPY . .
# Vite 기본 포트 # Vite 기본 포트
EXPOSE 5173 EXPOSE 5173

View File

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

View File

@@ -1,32 +1,6 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", "extends": ["../common/config/biome.base.json"],
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"rules": {
"style": {
"useEnumInitializers": "off"
},
"a11y": {
"noLabelWithoutControl": "off"
}
}
},
"organizeImports": {
"enabled": true
},
"files": { "files": {
"ignore": [ "ignore": [".vite"]
"dist",
".vite",
"node_modules",
"tsconfig*.json",
"test-results",
"test-results.nobody-backup",
"playwright-report"
]
} }
} }

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -14,6 +14,7 @@ const port = Number.parseInt(process.env.PORT ?? "5173", 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`; const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl; const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI && !process.env.PORT; const reuseExistingServer = !process.env.CI && !process.env.PORT;
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
/** /**
* Read environment variables from file. * Read environment variables from file.
@@ -56,7 +57,12 @@ export default defineConfig({
projects: [ projects: [
{ {
name: "chromium", name: "chromium",
use: { ...devices["Desktop Chrome"] }, use: {
...devices["Desktop Chrome"],
launchOptions: chromiumExecutablePath
? { executablePath: chromiumExecutablePath }
: undefined,
},
}, },
{ {

3469
adminfront/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -36,31 +36,91 @@ if [ "${1:-}" = "--print-mode" ]; then
fi fi
ensure_frontend_dependencies() { ensure_frontend_dependencies() {
if [ ! -f package.json ] || [ ! -f package-lock.json ]; then APP_WORKSPACE_FILTER="../adminfront"
# If common workspace exists, manage dependencies from the real workspace tree.
if [ -d /workspace/common ] && [ -f /workspace/common/package.json ]; then
WORKSPACE_DIR="/workspace/common"
LOCK_FILE="/workspace/common/pnpm-lock.yaml"
else
WORKSPACE_DIR="."
LOCK_FILE="package-lock.json"
fi
if [ ! -f "$WORKSPACE_DIR/package.json" ]; then
return 0 return 0
fi fi
lock_mode=""
lock_file="$WORKSPACE_DIR/.baron-deps-install.lock"
acquire_install_lock() {
if command -v flock >/dev/null 2>&1; then
lock_mode="flock"
exec 9>"$lock_file"
flock 9
trap 'release_install_lock' EXIT INT TERM
return 0
fi
lock_mode="mkdir"
while ! mkdir "$lock_file" 2>/dev/null; do
sleep 1
done
trap 'release_install_lock' EXIT INT TERM
}
release_install_lock() {
trap - EXIT INT TERM
if [ "$lock_mode" = "flock" ]; then
flock -u 9 || true
exec 9>&-
return 0
fi
if [ "$lock_mode" = "mkdir" ]; then
rmdir "$lock_file" >/dev/null 2>&1 || true
fi
}
if command -v sha256sum >/dev/null 2>&1; then if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum package.json package-lock.json | sha256sum | awk '{print $1}')" deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | sha256sum | awk '{print $1}')"
else else
deps_hash="$(cksum package.json package-lock.json | cksum | awk '{print $1}')" deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | cksum | awk '{print $1}')"
fi fi
deps_stamp="node_modules/.baron-deps-hash" deps_stamp="node_modules/.baron-deps-hash"
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)" installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
if [ "$installed_hash" != "$deps_hash" ]; then if [ "$installed_hash" != "$deps_hash" ]; then
echo "Installing frontend dependencies from package-lock.json..." echo "Installing frontend dependencies..."
npm ci acquire_install_lock
if command -v sha256sum >/dev/null 2>&1; then
deps_hash="$(sha256sum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | sha256sum | awk '{print $1}')"
else
deps_hash="$(cksum "$WORKSPACE_DIR/package.json" "$LOCK_FILE" 2>/dev/null | cksum | awk '{print $1}')"
fi
installed_hash="$(cat "$deps_stamp" 2>/dev/null || true)"
if [ "$installed_hash" = "$deps_hash" ]; then
release_install_lock
return 0
fi
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
(cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
else
npm ci
fi
mkdir -p node_modules mkdir -p node_modules
printf '%s\n' "$deps_hash" > "$deps_stamp" printf '%s\n' "$deps_hash" > "$deps_stamp"
release_install_lock
fi fi
} }
ensure_frontend_dependencies ensure_frontend_dependencies
if [ "$mode" = "production" ]; then if [ "$mode" = "production" ]; then
echo "Running in production mode with Vite preview..." echo "Running in production mode with custom static server..."
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0" exec sh -c "npm run build && node ./scripts/serve-prod.mjs"
fi fi
echo "Running in development mode..." echo "Running in development mode..."

View File

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

View File

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

View File

@@ -21,4 +21,32 @@ describe("admin routes", () => {
expect(matches?.at(-1)?.route.path).toBe("system/projections/users"); expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
}); });
it("registers the super-admin data integrity management route", () => {
const matches = matchRoutes(adminRoutes, "/system/data-integrity");
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
});
it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
const rootRoute = adminRoutes.find((route) => route.path === "/");
const protectedShellRoute = rootRoute?.children?.[0];
expect(getRouteElementName(rootRoute?.element)).toBe("AuthGuard");
expect(getRouteElementName(protectedShellRoute?.element)).toBe("AppLayout");
expect(protectedShellRoute?.children?.at(0)?.index).toBe(true);
});
}); });
function getRouteElementName(element: unknown) {
if (
typeof element === "object" &&
element !== null &&
"type" in element &&
typeof element.type === "function"
) {
return element.type.name;
}
return undefined;
}

View File

@@ -5,8 +5,10 @@ import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage"; import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage"; import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard";
import AuthPage from "../features/auth/AuthPage"; import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage"; import LoginPage from "../features/auth/LoginPage";
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import UserProjectionPage from "../features/projections/UserProjectionPage"; import UserProjectionPage from "../features/projections/UserProjectionPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab"; import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
@@ -34,34 +36,40 @@ export const adminRoutes: RouteObject[] = [
}, },
{ {
path: "/", path: "/",
element: <AppLayout />, element: <AuthGuard />,
children: [ children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{ {
path: "tenants/:tenantId", element: <AppLayout />,
element: <TenantDetailPage />,
children: [ children: [
{ index: true, element: <TenantProfilePage /> }, { index: true, element: <GlobalOverviewPage /> },
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> }, { path: "audit-logs", element: <AuditLogsPage /> },
{ path: "organization", element: <TenantUserGroupsTab /> }, { path: "auth", element: <AuthPage /> },
{ path: "schema", element: <TenantSchemaPage /> }, { path: "users", element: <UserListPage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> }, { path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
],
},
{
path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />,
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/projections/users", element: <UserProjectionPage /> },
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
], ],
}, },
{
path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />,
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/projections/users", element: <UserProjectionPage /> },
], ],
}, },
]; ];

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
const LOCALE_STORAGE_KEY = "locale";
const SUPPORTED_LOCALES = ["ko", "en"] as const; const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number]; type Locale = (typeof SUPPORTED_LOCALES)[number];
@@ -28,13 +27,27 @@ function resolveLocale(): Locale {
function LanguageSelector() { function LanguageSelector() {
const [locale, setLocale] = useState<Locale>(resolveLocale()); const [locale, setLocale] = useState<Locale>(resolveLocale());
useEffect(() => {
const syncLocale = () => {
setLocale(resolveLocale());
};
window.addEventListener("localechange", syncLocale);
window.addEventListener("storage", syncLocale);
return () => {
window.removeEventListener("localechange", syncLocale);
window.removeEventListener("storage", syncLocale);
};
}, []);
const handleChange = (next: Locale) => { const handleChange = (next: Locale) => {
if (next === locale) { if (next === locale) {
return; return;
} }
window.localStorage.setItem(LOCALE_STORAGE_KEY, next); window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
setLocale(next); setLocale(next);
window.location.reload(); window.dispatchEvent(new Event("localechange"));
}; };
return ( return (

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import {
Moon, Moon,
Network, Network,
NotebookTabs, NotebookTabs,
ShieldCheck,
ShieldHalf, ShieldHalf,
Sun, Sun,
User as UserIcon, User as UserIcon,
@@ -19,9 +20,23 @@ import * as React from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
AppSidebar,
type ShellSidebarNavItem,
type ShellTranslator,
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellTheme,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker"; import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
import { fetchMe } from "../../lib/adminApi"; import { fetchMe } from "../../lib/adminApi";
import { debugLog } from "../../lib/debugLog";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import { import {
shouldAttemptSlidingSessionRenew, shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew, shouldAttemptUnlimitedSessionRenew,
@@ -29,20 +44,84 @@ import {
import LanguageSelector from "../common/LanguageSelector"; import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher"; import RoleSwitcher from "./RoleSwitcher";
interface NavItem { const LOCALE_CHANGED_EVENT = "baron_locale_changed";
label: string; const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
to: string;
icon: React.ComponentType<{ size?: number | string }>; const staticNavItems: ShellSidebarNavItem[] = [
isExternal?: boolean; {
labelKey: "ui.admin.nav.overview",
labelFallback: "Overview",
to: "/",
icon: LayoutDashboard,
end: true,
},
{
labelKey: "ui.admin.nav.users",
labelFallback: "Users",
to: "/users",
icon: Users,
},
{
labelKey: "ui.admin.nav.api_keys",
labelFallback: "API Keys",
to: "/api-keys",
icon: Key,
},
{
labelKey: "ui.admin.nav.audit_logs",
labelFallback: "Audit Logs",
to: "/audit-logs",
icon: NotebookTabs,
},
{
labelKey: "ui.admin.nav.auth_guard",
labelFallback: "Auth Guard",
to: "/auth",
icon: KeyRound,
},
];
type SessionStatusProps = {
expiresAtSec?: number | null;
t: ShellTranslator;
};
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
} }
const staticNavItems: NavItem[] = [ function SessionStatusBadge(props: SessionStatusProps) {
{ label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard }, const sessionStatus = useSessionStatus(props);
{ label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, return (
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, <span
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, className={[
]; shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionStatus.text}
</span>
);
}
function SessionStatusText(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return <>{sessionStatus.text}</>;
}
function AppLayout() { function AppLayout() {
const auth = useAuth(); const auth = useAuth();
@@ -52,6 +131,7 @@ function AppLayout() {
const isRenewInFlightRef = useRef(false); const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0); const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null); const lastVisitedRouteRef = useRef<string | null>(null);
const isDevelopmentRuntime = import.meta.env.MODE === "development";
const isDevRoleOverrideEnabled = const isDevRoleOverrideEnabled =
import.meta.env.MODE === "development" || import.meta.env.MODE === "development" ||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
@@ -62,26 +142,12 @@ function AppLayout() {
const mockRoleOverride = isMockRoleEnabled const mockRoleOverride = isMockRoleEnabled
? window.localStorage.getItem("X-Mock-Role") ? window.localStorage.getItem("X-Mock-Role")
: null; : null;
const [theme, setTheme] = useState<"light" | "dark">(() => { const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light";
});
const [isProfileOpen, setIsProfileOpen] = useState(false); const [isProfileOpen, setIsProfileOpen] = useState(false);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() => { const [, setDevelopmentRenderRevision] = useState(0);
const stored = window.localStorage.getItem("baron_session_expiry_enabled"); const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
return stored !== "false"; readShellSessionExpiryEnabled(!isDevelopmentRuntime),
}); );
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
const { const {
data: profile, data: profile,
isLoading: isProfileLoading, isLoading: isProfileLoading,
@@ -89,10 +155,10 @@ function AppLayout() {
} = useQuery({ } = useQuery({
queryKey: ["me"], queryKey: ["me"],
queryFn: async () => { queryFn: async () => {
console.debug("[AppLayout] Fetching profile..."); debugLog("[AppLayout] Fetching profile...");
try { try {
const data = await fetchMe(); const data = await fetchMe();
console.debug("[AppLayout] Profile fetched successfully:", data.email); debugLog("[AppLayout] Profile fetched successfully:", data.email);
return data; return data;
} catch (err) { } catch (err) {
console.error("[AppLayout] Failed to fetch profile:", err); console.error("[AppLayout] Failed to fetch profile:", err);
@@ -106,18 +172,19 @@ function AppLayout() {
._IS_TEST_MODE === true, ._IS_TEST_MODE === true,
}); });
const navItems = React.useMemo(() => { const navItems = React.useMemo<ShellSidebarNavItem[]>(() => {
const items = [...staticNavItems]; const items = [...staticNavItems];
const isTest = const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true; ._IS_TEST_MODE === true;
const effectiveRole = mockRoleOverride || profile?.role; const effectiveRole = mockRoleOverride || profile?.role;
const isSuperAdmin = isTest || effectiveRole === "super_admin"; const isSuperAdmin = isTest || isSuperAdminRole(effectiveRole);
const isTenantAdmin = effectiveRole === "tenant_admin"; const isTenantAdmin = effectiveRole === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0; const manageableCount = profile?.manageableTenants?.length ?? 0;
const orgfrontUrl = buildAuthenticatedOrgChartUrl( const orgfrontUrl = buildAuthenticatedOrgChartUrl(
import.meta.env.ORGFRONT_URL || "http://localhost:5175", import.meta.env.ORGFRONT_URL || "http://localhost:5175",
{ includeInternal: true },
); );
const filteredItems = items.filter((item) => { const filteredItems = items.filter((item) => {
@@ -128,31 +195,42 @@ function AppLayout() {
if (isSuperAdmin) { if (isSuperAdmin) {
filteredItems.splice(1, 0, { filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants", labelKey: "ui.admin.nav.tenants",
labelFallback: "Tenants",
to: "/tenants", to: "/tenants",
icon: Building2, icon: Building2,
}); });
filteredItems.splice(2, 0, { filteredItems.splice(2, 0, {
label: "ui.admin.nav.org_chart", labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl, to: orgfrontUrl,
icon: Network, icon: Network,
isExternal: true, isExternal: true,
}); });
filteredItems.splice(4, 0, { filteredItems.splice(4, 0, {
label: "ui.admin.nav.user_projection", labelKey: "ui.admin.nav.user_projection",
labelFallback: "User Projection",
to: "/system/projections/users", to: "/system/projections/users",
icon: Database, icon: Database,
}); });
filteredItems.splice(5, 0, {
labelKey: "ui.admin.nav.data_integrity",
labelFallback: "Data Integrity",
to: "/system/data-integrity",
icon: ShieldCheck,
});
} else if (isTenantAdmin || manageableCount > 0) { } else if (isTenantAdmin || manageableCount > 0) {
if (manageableCount <= 1 && profile?.tenantId) { if (manageableCount <= 1 && profile?.tenantId) {
filteredItems.splice(1, 0, { filteredItems.splice(1, 0, {
label: "ui.admin.nav.my_tenant", labelKey: "ui.admin.nav.my_tenant",
labelFallback: "My Tenant",
to: `/tenants/${profile.tenantId}`, to: `/tenants/${profile.tenantId}`,
icon: Building2, icon: Building2,
}); });
} else if (manageableCount > 1) { } else if (manageableCount > 1) {
filteredItems.splice(1, 0, { filteredItems.splice(1, 0, {
label: "ui.admin.nav.tenants", labelKey: "ui.admin.nav.tenants",
labelFallback: "Tenants",
to: "/tenants", to: "/tenants",
icon: Building2, icon: Building2,
}); });
@@ -161,7 +239,8 @@ function AppLayout() {
manageableCount <= 1 && profile?.tenantId ? 2 : 2, manageableCount <= 1 && profile?.tenantId ? 2 : 2,
0, 0,
{ {
label: "ui.admin.nav.org_chart", labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl, to: orgfrontUrl,
icon: Network, icon: Network,
isExternal: true, isExternal: true,
@@ -170,7 +249,8 @@ function AppLayout() {
} else { } else {
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다. // 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
filteredItems.splice(1, 0, { filteredItems.splice(1, 0, {
label: "ui.admin.nav.org_chart", labelKey: "ui.admin.nav.org_chart",
labelFallback: "Org Chart",
to: orgfrontUrl, to: orgfrontUrl,
icon: Network, icon: Network,
isExternal: true, isExternal: true,
@@ -195,7 +275,7 @@ function AppLayout() {
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true; ._IS_TEST_MODE === true;
console.debug("[AppLayout] Auth state check:", { debugLog("[AppLayout] Auth state check:", {
isLoading: auth.isLoading, isLoading: auth.isLoading,
isAuthenticated: auth.isAuthenticated, isAuthenticated: auth.isAuthenticated,
isTest, isTest,
@@ -214,16 +294,30 @@ function AppLayout() {
}, [auth.user]); }, [auth.user]);
useEffect(() => { useEffect(() => {
const root = document.documentElement; applyShellTheme(theme);
root.classList.remove("light", "dark");
if (theme === "light") {
root.classList.add("light");
} else {
root.classList.add("dark");
}
window.localStorage.setItem("admin_theme", theme);
}, [theme]); }, [theme]);
useEffect(() => {
if (!isDevelopmentRuntime) {
return;
}
const rerenderDevelopmentShell = () => {
setDevelopmentRenderRevision((value) => value + 1);
};
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
window.addEventListener(DEV_ROLE_CHANGED_EVENT, rerenderDevelopmentShell);
return () => {
window.removeEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
window.removeEventListener(
DEV_ROLE_CHANGED_EVENT,
rerenderDevelopmentShell,
);
};
}, [isDevelopmentRuntime]);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if ( if (
@@ -289,6 +383,10 @@ function AppLayout() {
]); ]);
useEffect(() => { useEffect(() => {
if (isDevelopmentRuntime) {
return;
}
const maybeKeepSessionAlive = async () => { const maybeKeepSessionAlive = async () => {
const now = Date.now(); const now = Date.now();
if ( if (
@@ -331,6 +429,7 @@ function AppLayout() {
auth.isAuthenticated, auth.isAuthenticated,
auth.isLoading, auth.isLoading,
auth.user?.expires_at, auth.user?.expires_at,
isDevelopmentRuntime,
isSessionExpiryEnabled, isSessionExpiryEnabled,
]); ]);
@@ -388,71 +487,83 @@ function AppLayout() {
setTheme((prev) => (prev === "light" ? "dark" : "light")); setTheme((prev) => (prev === "light" ? "dark" : "light"));
}; };
const profileName = const profileSummary = buildShellProfileSummary({
profile?.name?.trim() || profileName:
auth.user?.profile.name?.toString().trim() || profile?.name ||
auth.user?.profile.preferred_username?.toString().trim() || auth.user?.profile.name?.toString() ||
t("ui.dev.profile.unknown_name", "Unknown User"); auth.user?.profile.preferred_username?.toString(),
const profileEmail = profileEmail: profile?.email || auth.user?.profile.email?.toString(),
profile?.email?.trim() || fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
auth.user?.profile.email?.toString().trim() || fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
t("ui.dev.profile.unknown_email", "unknown@example.com"); });
const profileInitial = profileName.charAt(0).toUpperCase();
const profileRoleKey = mockRoleOverride || profile?.role || "user"; const profileRoleKey = mockRoleOverride || profile?.role || "user";
const expiresAtSec = auth.user?.expires_at;
const remainingMs =
typeof expiresAtSec === "number" ? expiresAtSec * 1000 - nowMs : null;
const remainingTotalSec =
remainingMs !== null ? Math.max(0, Math.floor(remainingMs / 1000)) : null;
const remainingMinutes =
remainingTotalSec !== null ? Math.floor(remainingTotalSec / 60) : null;
const remainingSeconds =
remainingTotalSec !== null ? remainingTotalSec % 60 : null;
let sessionToneClass =
"border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300";
let sessionText = t("ui.dev.session.active", "세션 활성");
if (remainingMs === null) {
sessionToneClass = "border-border bg-card text-muted-foreground";
sessionText = t("ui.dev.session.unknown", "알 수 없음");
} else if (remainingMs <= 0) {
sessionToneClass =
"border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300";
sessionText = t("ui.dev.session.expired", "세션 만료");
} else if (
remainingMinutes !== null &&
remainingSeconds !== null &&
remainingMinutes <= 5
) {
sessionToneClass =
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300";
sessionText = t(
"ui.dev.session.expiring",
"만료 임박: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes,
seconds: remainingSeconds,
},
);
} else {
sessionText = t(
"ui.dev.session.remaining",
"만료 예정: {{minutes}}분 {{seconds}}초 남음",
{
minutes: remainingMinutes ?? 0,
seconds: remainingSeconds ?? 0,
},
);
}
const handleSessionExpiryToggle = () => { const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => { setIsSessionExpiryEnabled((prev) => {
const next = !prev; const next = !prev;
window.localStorage.setItem("baron_session_expiry_enabled", String(next)); writeShellSessionExpiryEnabled(next);
return next; return next;
}); });
}; };
const sidebarNavContent = (
<div className={shellLayoutClasses.navList}>
{navItems.map((item) => {
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
if (isExternal) {
return (
<a
key={to}
href={to}
target="_blank"
rel="noopener noreferrer"
className={[
shellLayoutClasses.navItemBase,
shellLayoutClasses.navItemIdle,
].join(" ")}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</a>
);
}
return (
<NavLink
key={to}
to={to}
end={item.end ?? to === "/"}
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
item.isActive !== undefined
? item.isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle
: isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
);
})}
</div>
);
const sidebarFooterContent = (
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
onClick={handleLogout}
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.shell.nav.logout", "Logout")}</span>
</button>
</div>
);
if (auth.isLoading) { if (auth.isLoading) {
return ( return (
@@ -463,87 +574,19 @@ function AppLayout() {
} }
return ( return (
<div className="grid min-h-screen bg-background text-foreground md:grid-cols-[240px,1fr]"> <div className={shellLayoutClasses.root}>
<aside className="border-b border-border bg-card md:sticky md:top-0 md:h-screen md:border-b-0 md:border-r md:bg-card md:backdrop-blur"> <AppSidebar
<div className="flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6"> brandLabel={t("ui.admin.brand", "Baron 로그인")}
<div className="flex items-center gap-3 md:flex-col md:items-start"> brandTitle={t("ui.admin.title", "Admin Control")}
<div className="grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]"> brandIcon={<ShieldHalf size={20} />}
<ShieldHalf size={20} /> navContent={sidebarNavContent}
</div> footerContent={sidebarFooterContent}
<div> />
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t("ui.admin.brand", "Baron 로그인")}
</p>
<h1 className="text-lg font-semibold">
{t("ui.admin.title", "Admin Control")}
</h1>
</div>
</div>
</div>
<nav className="px-2 pb-4 md:px-3 md:pb-8">
<div className="flex flex-col gap-1">
{navItems.map((item: NavItem) => {
const { label, to, icon: Icon, isExternal } = item;
const isOrgChart = location.pathname === "/tenants/org-chart";
const isTenantsRoot = to === "/tenants";
const isCustomActive = isTenantsRoot
? location.pathname.startsWith("/tenants") && !isOrgChart
: to === "/"
? location.pathname === "/"
: location.pathname.startsWith(to);
if (isExternal) { <div className={shellLayoutClasses.contentWide}>
return ( <header className={shellLayoutClasses.headerElevated}>
<a <div className={shellLayoutClasses.headerInner}>
key={to} <div className={shellLayoutClasses.headerTitleWrap}>
href={to}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-muted/10 hover:text-foreground"
>
<Icon size={18} />
<span>{t(label, label)}</span>
</a>
);
}
return (
<NavLink
key={to}
to={to}
className={() =>
[
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
isCustomActive
? "bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]"
: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
].join(" ")
}
>
<Icon size={18} />
<span>{t(label, label)}</span>
</NavLink>
);
})}
</div>
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
>
<LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
</button>
</div>
</nav>
</aside>
<div className="relative min-w-0">
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
<div className="flex items-center justify-between px-5 py-4 md:px-8">
<div className="flex flex-col gap-1">
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.admin.header.plane", "ADMIN PLANE")} {t("ui.admin.header.plane", "ADMIN PLANE")}
</p> </p>
@@ -552,12 +595,12 @@ function AppLayout() {
</span> </span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <div className={shellLayoutClasses.headerActions}>
<LanguageSelector /> <LanguageSelector />
<button <button
type="button" type="button"
onClick={toggleTheme} onClick={toggleTheme}
className="inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20" className={shellLayoutClasses.actionButton}
aria-label={t("ui.common.theme_toggle", "테마 전환")} aria-label={t("ui.common.theme_toggle", "테마 전환")}
> >
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />} {theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
@@ -566,14 +609,10 @@ function AppLayout() {
: t("ui.common.theme_dark", "Dark")} : t("ui.common.theme_dark", "Dark")}
</button> </button>
{isSessionExpiryEnabled ? ( {isSessionExpiryEnabled ? (
<span <SessionStatusBadge
className={[ expiresAtSec={auth.user?.expires_at}
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex", t={t}
sessionToneClass, />
].join(" ")}
>
{sessionText}
</span>
) : null} ) : null}
<div className="relative" ref={profileMenuRef}> <div className="relative" ref={profileMenuRef}>
<button <button
@@ -582,17 +621,17 @@ function AppLayout() {
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20" className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={isProfileOpen} aria-expanded={isProfileOpen}
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")} aria-label={t("ui.shell.profile.menu_aria", "계정 메뉴 열기")}
> >
<div className="grid h-8 w-8 place-items-center rounded-full bg-primary/15 text-xs font-semibold text-primary"> <div className={shellLayoutClasses.profileInitial}>
{profileInitial} {profileSummary.initial}
</div> </div>
<div className="hidden min-w-0 text-left md:block"> <div className="hidden min-w-0 text-left md:block">
<p className="truncate text-xs font-medium text-foreground"> <p className="truncate text-xs font-medium text-foreground">
{profileName} {profileSummary.name}
</p> </p>
<p className="truncate text-[11px] text-muted-foreground"> <p className="truncate text-[11px] text-muted-foreground">
{profileEmail} {profileSummary.email}
</p> </p>
</div> </div>
<ChevronDown <ChevronDown
@@ -602,45 +641,44 @@ function AppLayout() {
</button> </button>
{isProfileOpen ? ( {isProfileOpen ? (
<div <div role="menu" className={shellLayoutClasses.profileMenu}>
role="menu"
className="absolute right-0 z-30 mt-2 w-72 rounded-xl border border-border bg-card p-3 shadow-xl"
>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground"> <p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")} {t("ui.shell.profile.menu_title", "Account")}
</p> </p>
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-border px-3 py-3"> <div className={shellLayoutClasses.profileCard}>
<div> <div>
<p className="truncate text-sm font-semibold text-foreground"> <p className="truncate text-sm font-semibold text-foreground">
{profileName} {profileSummary.name}
</p> </p>
<p className="truncate text-xs text-muted-foreground"> <p className="truncate text-xs text-muted-foreground">
{profileEmail} {profileSummary.email}
</p> </p>
</div> </div>
<div className="flex items-center pt-1"> <div className="flex items-center pt-1">
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300"> <span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
{t( {t(
`ui.admin.role.${profileRoleKey}`, `ui.shell.role.${profileRoleKey}`,
profileRoleKey.toUpperCase(), profileRoleKey.toUpperCase(),
)} )}
</span> </span>
</div> </div>
</div> </div>
<div className="mt-2 rounded-lg border border-border px-3 py-3"> <div className={shellLayoutClasses.settingsCard}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div> <div>
<p className="text-sm font-medium text-foreground"> <p className="text-sm font-medium text-foreground">
{t("ui.dev.session.auto_extend", "세션 만료 관리")} {t("ui.shell.session.auto_extend", "세션 만료 관리")}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled {isSessionExpiryEnabled ? (
? sessionText <SessionStatusText
: t( expiresAtSec={auth.user?.expires_at}
"ui.dev.session.disabled", t={t}
"세션 만료 비활성화", />
)} ) : (
t("ui.shell.session.disabled", "세션 만료 비활성화")
)}
</p> </p>
</div> </div>
<button <button
@@ -717,7 +755,7 @@ function AppLayout() {
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20" className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
> >
<UserIcon size={16} className="text-muted-foreground" /> <UserIcon size={16} className="text-muted-foreground" />
<span>{t("ui.userfront.nav.profile", "내 정보")}</span> <span>{t("ui.shell.nav.profile", "내 정보")}</span>
</button> </button>
<button <button
type="button" type="button"
@@ -728,7 +766,7 @@ function AppLayout() {
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive" className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
> >
<LogOut size={16} /> <LogOut size={16} />
<span>{t("ui.admin.nav.logout", "Logout")}</span> <span>{t("ui.shell.nav.logout", "Logout")}</span>
</button> </button>
</div> </div>
) : null} ) : null}
@@ -736,7 +774,7 @@ function AppLayout() {
</div> </div>
</div> </div>
</header> </header>
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10"> <main className={shellLayoutClasses.mainMinWidth}>
<Outlet /> <Outlet />
</main> </main>
<RoleSwitcher /> <RoleSwitcher />

View File

@@ -3,6 +3,8 @@ import type { FC } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
const DEV_ROLE_CHANGED_EVENT = "baron_dev_role_changed";
const RoleSwitcher: FC = () => { const RoleSwitcher: FC = () => {
const [currentRole, setCurrentRole] = useState<string>(""); const [currentRole, setCurrentRole] = useState<string>("");
const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false); const [isOverrideEnabled, setIsOverrideEnabled] = useState<boolean>(false);
@@ -31,13 +33,13 @@ const RoleSwitcher: FC = () => {
window.localStorage.setItem("X-Mock-Role-Enabled", "true"); window.localStorage.setItem("X-Mock-Role-Enabled", "true");
setCurrentRole(role); setCurrentRole(role);
setIsOverrideEnabled(true); setIsOverrideEnabled(true);
window.location.reload(); window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
}; };
const clearRoleOverride = () => { const clearRoleOverride = () => {
window.localStorage.removeItem("X-Mock-Role-Enabled"); window.localStorage.removeItem("X-Mock-Role-Enabled");
setIsOverrideEnabled(false); setIsOverrideEnabled(false);
window.location.reload(); window.dispatchEvent(new Event(DEV_ROLE_CHANGED_EVENT));
}; };
if (import.meta.env.MODE === "production") return null; if (import.meta.env.MODE === "production") return null;

View File

@@ -1,38 +1,21 @@
import { type VariantProps, cva } from "class-variance-authority";
import type * as React from "react"; import type * as React from "react";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
const badgeVariants = cva( export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", variant?: CommonBadgeVariant;
{ }
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "text-foreground",
muted: "border-border bg-secondary/60 text-muted-foreground",
success:
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) { function Badge({ className, variant, ...props }: BadgeProps) {
return ( return (
<div className={cn(badgeVariants({ variant }), className)} {...props} /> <div
className={cn(getCommonBadgeClasses({ variant }), className)}
{...props}
/>
); );
} }
export { Badge, badgeVariants }; export { Badge };

View File

@@ -1,41 +1,16 @@
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react"; import * as React from "react";
import {
type CommonButtonSize,
type CommonButtonVariant,
getCommonButtonClasses,
} from "../../../../common/ui/button";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
ghost: "hover:bg-accent hover:text-accent-foreground",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
muted: "bg-muted text-muted-foreground hover:bg-muted/80",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-6 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement> {
VariantProps<typeof buttonVariants> { variant?: CommonButtonVariant;
size?: CommonButtonSize;
asChild?: boolean; asChild?: boolean;
} }
@@ -44,7 +19,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
return ( return (
<Comp <Comp
className={cn(buttonVariants({ variant, size, className }))} className={cn(getCommonButtonClasses({ variant, size }), className)}
ref={ref} ref={ref}
{...props} {...props}
/> />
@@ -53,4 +28,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
); );
Button.displayName = "Button"; Button.displayName = "Button";
export { Button, buttonVariants }; export { Button };

View File

@@ -1,65 +1,51 @@
import type * as React from "react"; import type * as React from "react";
import {
commonCardClass,
commonCardContentClass,
commonCardDescriptionClass,
commonCardFooterClass,
commonCardHeaderClass,
commonCardTitleClass,
} from "../../../../common/ui/card";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return ( return <div className={cn(commonCardClass, className)} {...props} />;
<div
className={cn(
"rounded-2xl border border-border bg-card/90 text-card-foreground shadow-card",
className,
)}
{...props}
/>
);
} }
function CardHeader({ function CardHeader({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) { }: React.HTMLAttributes<HTMLDivElement>) {
return ( return <div className={cn(commonCardHeaderClass, className)} {...props} />;
<div
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
);
} }
function CardTitle({ function CardTitle({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLHeadingElement>) { }: React.HTMLAttributes<HTMLHeadingElement>) {
return ( return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
<h3
className={cn("text-lg font-semibold leading-none", className)}
{...props}
/>
);
} }
function CardDescription({ function CardDescription({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLParagraphElement>) { }: React.HTMLAttributes<HTMLParagraphElement>) {
return ( return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
<p className={cn("text-sm text-muted-foreground", className)} {...props} />
);
} }
function CardContent({ function CardContent({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) { }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />; return <div className={cn(commonCardContentClass, className)} {...props} />;
} }
function CardFooter({ function CardFooter({
className, className,
...props ...props
}: React.HTMLAttributes<HTMLDivElement>) { }: React.HTMLAttributes<HTMLDivElement>) {
return ( return <div className={cn(commonCardFooterClass, className)} {...props} />;
<div className={cn("flex items-center p-6 pt-0", className)} {...props} />
);
} }
export { export {

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import * as React from "react"; import * as React from "react";
import { commonInputClass } from "../../../../common/ui/input";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
export interface InputProps export interface InputProps
@@ -9,10 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
return ( return (
<input <input
type={type} type={type}
className={cn( className={cn(commonInputClass, className)}
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref} ref={ref}
{...props} {...props}
/> />

View File

@@ -1,26 +1,68 @@
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react"; import * as React from "react";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
const Switch = React.forwardRef< interface SwitchProps
React.ElementRef<typeof SwitchPrimitives.Root>, extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> checked?: boolean;
>(({ className, ...props }, ref) => ( defaultChecked?: boolean;
<SwitchPrimitives.Root onCheckedChange?: (checked: boolean) => void;
className={cn( }
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
(
{
className, className,
)} checked,
{...props} defaultChecked = false,
ref={ref} disabled,
> onCheckedChange,
<SwitchPrimitives.Thumb onClick,
className={cn( ...props
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0", },
)} ref,
/> ) => {
</SwitchPrimitives.Root> const isControlled = checked !== undefined;
)); const [internalChecked, setInternalChecked] =
Switch.displayName = SwitchPrimitives.Root.displayName; React.useState(defaultChecked);
const currentChecked = isControlled ? checked : internalChecked;
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented || disabled) {
return;
}
const nextChecked = !currentChecked;
if (!isControlled) {
setInternalChecked(nextChecked);
}
onCheckedChange?.(nextChecked);
};
return (
<button
type="button"
role="switch"
aria-checked={currentChecked}
data-state={currentChecked ? "checked" : "unchecked"}
className={cn(
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
className,
)}
disabled={disabled}
onClick={handleClick}
ref={ref}
{...props}
>
<span
data-state={currentChecked ? "checked" : "unchecked"}
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</button>
);
},
);
Switch.displayName = "Switch";
export { Switch }; export { Switch };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,16 @@
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
ChevronDown,
ChevronUp,
Copy,
ListChecks,
RefreshCw,
Search,
Terminal,
} from "lucide-react";
import * as React from "react"; import * as React from "react";
import {
formatAuditValue,
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
} from "../../../../common/core/audit";
import { AuditLogTable } from "../../../../common/core/components/audit";
import { PageHeader } from "../../../../common/core/components/page";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {
@@ -19,92 +20,17 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../components/ui/card"; } from "../../components/ui/card";
import { import { Input } from "../../components/ui/input";
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import type { AuditLog } from "../../lib/adminApi"; import type { AuditLog } from "../../lib/adminApi";
import { fetchAuditLogs } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
const defaultAuditFilters = [
"method:POST path:/api/v1/*",
"status:failure",
"latency_ms:>1000",
];
type AuditDetails = {
request_id?: string;
method?: string;
path?: string;
status?: number;
latency_ms?: number;
error?: string;
tenant_id?: string;
actor_id?: string;
action?: string;
target?: string;
before?: unknown;
after?: unknown;
};
function parseDetails(details?: string): AuditDetails {
if (!details) {
return {};
}
try {
const parsed = JSON.parse(details);
if (parsed && typeof parsed === "object") {
return parsed as AuditDetails;
}
} catch {}
return {};
}
function formatCellValue(value: unknown) {
if (value === null || value === undefined || value === "") {
return "-";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function formatIsoDateTime(value: string) {
if (!value) {
return { date: "-", time: "-" };
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return { date: value, time: "-" };
}
const date = parsed.toISOString().slice(0, 10);
const time = parsed.toLocaleTimeString("ko-KR", { hour12: false });
return { date, time };
}
function AuditLogsPage() { function AuditLogsPage() {
const [filters, setFilters] = React.useState(defaultAuditFilters); const [searchActorId, setSearchActorId] = React.useState("");
const [filterDraft, setFilterDraft] = React.useState(""); const [searchAction, setSearchAction] = React.useState("");
const [expandedRows, setExpandedRows] = React.useState< const [statusFilter, setStatusFilter] = React.useState("all");
Record<string, boolean> const deferredSearchActorId = React.useDeferredValue(searchActorId.trim());
>({}); const deferredSearchAction = React.useDeferredValue(searchAction.trim());
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
const { const {
data, data,
isLoading, isLoading,
@@ -126,20 +52,29 @@ function AuditLogsPage() {
(page) => (page) =>
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [], page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
) ?? []; ) ?? [];
const filteredLogs = React.useMemo(
const handleAddFilter = () => { () =>
const trimmed = filterDraft.trim(); logs.filter((row) => {
if (!trimmed) { const details = parseAuditDetails(row.details);
return; const actorLabel = resolveAuditActor(row, details).toLowerCase();
} const actionLabel = resolveAuditAction(row, details).toLowerCase();
setFilters((prev) => (prev.includes(trimmed) ? prev : [...prev, trimmed])); const matchesActor =
setFilterDraft(""); deferredSearchActorId === "" ||
}; actorLabel.includes(deferredSearchActorId.toLowerCase());
const matchesAction =
deferredSearchAction === "" ||
actionLabel.includes(deferredSearchAction.toLowerCase());
const matchesStatus =
statusFilter === "all" || row.status === statusFilter;
return matchesActor && matchesAction && matchesStatus;
}),
[logs, deferredSearchActorId, deferredSearchAction, statusFilter],
);
if (isLoading) { if (isLoading) {
return ( return (
<div className="p-8 text-center"> <div className="p-8 text-center">
{t("msg.admin.audit.loading", "Loading audit logs...")} {t("msg.common.audit.loading", "Loading audit logs...")}
</div> </div>
); );
} }
@@ -150,7 +85,7 @@ function AuditLogsPage() {
(error as Error).message; (error as Error).message;
return ( return (
<div className="p-8 text-center text-red-500"> <div className="p-8 text-center text-red-500">
{t("msg.admin.audit.load_error", "Error loading logs: {{error}}", { {t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
error: errMsg, error: errMsg,
})} })}
</div> </div>
@@ -158,445 +93,109 @@ function AuditLogsPage() {
} }
return ( return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]"> <div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4"> <PageHeader
<div> title={t("ui.common.audit.title", "감사 로그")}
<h2 className="text-3xl font-semibold"> description={t(
{t("ui.admin.audit.title", "감사 로그")} "msg.admin.audit.subtitle",
</h2> "관리자 작업 이력을 조회합니다.",
<p className="text-sm text-[var(--color-muted)]"> )}
{t( icon={<NotebookTabs size={20} />}
"msg.admin.audit.subtitle", actions={
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.", <>
)} <Badge variant="muted">
</p> {t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
</div> count: filteredLogs.length,
<div className="flex items-center gap-2"> })}
<Button </Badge>
variant="outline" <Button
onClick={() => refetch()} variant="outline"
disabled={isFetching} onClick={() => refetch()}
> disabled={isFetching}
<RefreshCw size={16} /> >
{t("ui.common.refresh", "새로고침")} <RefreshCw size={16} />
</Button> {t("ui.common.refresh", "새로고침")}
<Button> </Button>
<ListChecks size={16} /> <Button>
{t("ui.admin.audit.export_csv", "Export CSV")} <Download size={16} />
</Button> {t("ui.common.export_csv", "CSV 내보내기")}
</div> </Button>
</header> </>
}
/>
<Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden"> <Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle> <CardTitle className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.audit.registry.title", "Log Registry")} {t("ui.common.audit.registry.title", "Audit registry")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t("msg.admin.audit.registry.count", "총 {{count}}개 로그", { {t(
count: logs.length, "msg.admin.audit.registry.description",
})} "최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
)}
</CardDescription> </CardDescription>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0"> <CardContent className="space-y-4 pt-0">
<div className="mb-4 flex flex-wrap items-center gap-2 flex-shrink-0"> <SearchFilterBar
<div className="flex flex-1 items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-2 text-[var(--color-muted)]"> primary={
<Search size={14} /> <form
<input onSubmit={(e) => {
value={filterDraft} e.preventDefault();
onChange={(event) => setFilterDraft(event.target.value)} refetch();
onKeyDown={(event) => {
if (event.key === "Enter") {
handleAddFilter();
}
}} }}
placeholder={t( className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
"ui.admin.audit.filters.placeholder",
"필터 추가 (예: status:failure)",
)}
className="w-full bg-transparent text-sm text-foreground outline-none"
/>
<Button size="sm" variant="outline" onClick={handleAddFilter}>
{t("ui.common.add", "추가")}
</Button>
</div>
{filters.length === 0 ? (
<span className="text-xs text-[var(--color-muted)]">
{t("msg.admin.audit.filters.empty", "필터 없음")}
</span>
) : (
filters.map((filter) => (
<span
key={filter}
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.04)] px-3 py-1 text-xs text-[var(--color-muted)]"
>
<Terminal size={12} />
{filter}
<button
type="button"
onClick={() =>
setFilters((prev) =>
prev.filter((item) => item !== filter),
)
}
className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-[var(--color-border)] text-[10px] text-[var(--color-muted)]"
aria-label={t(
"ui.admin.audit.filters.remove",
"{{filter}} 필터 제거",
{ filter },
)}
>
×
</button>
</span>
))
)}
</div>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table className="table-fixed">
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[140px]">
{t("ui.admin.audit.table.time", "TIME")}
</TableHead>
<TableHead className="w-[160px]">
{t("ui.admin.audit.table.actor", "ACTOR (ID)")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.request", "REQUEST")}
</TableHead>
<TableHead>
{t("ui.admin.audit.table.path", "PATH")}
</TableHead>
<TableHead className="w-[120px]">
{t("ui.admin.audit.table.status", "STATUS")}
</TableHead>
<TableHead>
{t(
"ui.admin.audit.table.action_target",
"Action / Target",
)}
</TableHead>
<TableHead className="w-[80px]" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={7}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!isLoading && logs.length === 0 && (
<TableRow>
<TableCell colSpan={7}>
{t(
"msg.admin.audit.empty",
"아직 수집된 감사 로그가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{logs.map((row, index) => {
const details = parseDetails(row.details);
const actionLabel =
details.action ||
(details.method && details.path
? `${details.method} ${details.path}`
: row.event_type);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const isExpanded = Boolean(expandedRows[rowKey]);
return (
<React.Fragment key={rowKey}>
<TableRow className="bg-card/40">
<TableCell className="text-xs text-[var(--color-muted)]">
{(() => {
const { date, time } = formatIsoDateTime(
row.timestamp,
);
return (
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
);
})()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{row.user_id || details.actor_id || "-"}
</code>
{(row.user_id || details.actor_id) && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.actor_id",
"Copy actor id",
)}
onClick={() =>
handleCopy(
row.user_id || details.actor_id || "",
)
}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="flex items-start gap-2">
<span className="break-all">
{formatCellValue(details.request_id)}
</span>
{details.request_id && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.request_id",
"Copy request id",
)}
onClick={() =>
handleCopy(details.request_id || "")
}
>
<Copy className="h-3 w-3" />
</Button>
)}
</div>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="font-semibold text-foreground">
{formatCellValue(details.method)}
</div>
<div className="break-all">
{formatCellValue(details.path)}
</div>
</TableCell>
<TableCell>
<Badge
variant={
row.status === "success" || row.status === "ok"
? "success"
: "warning"
}
>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-xs text-[var(--color-muted)]">
<div className="font-semibold text-foreground">
{actionLabel}
</div>
{details.target && (
<div className="flex items-center gap-2">
<span className="break-all">
{t(
"ui.admin.audit.target",
"Target · {{target}}",
{
target: details.target,
},
)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-primary"
aria-label={t(
"ui.admin.audit.copy.target",
"Copy target",
)}
onClick={() =>
handleCopy(details.target || "")
}
>
<Copy className="h-3 w-3" />
</Button>
</div>
)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
setExpandedRows((prev) => ({
...prev,
[rowKey]: !isExpanded,
}))
}
>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</TableCell>
</TableRow>
{isExpanded && (
<TableRow className="bg-card/20">
<TableCell colSpan={7} className="text-xs">
<div className="grid gap-4 text-[var(--color-muted)] md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t(
"ui.admin.audit.details.request",
"Request",
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.request_id",
"Request ID · {{value}}",
{
value: formatCellValue(
details.request_id,
),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.event_id",
"Event ID · {{value}}",
{
value: formatCellValue(row.event_id),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.ip",
"IP · {{value}}",
{
value: formatCellValue(row.ip_address),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.admin.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.admin.audit.details.actor_id",
"Actor ID · {{value}}",
{
value:
row.user_id ||
details.actor_id ||
"-",
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.tenant",
"Tenant · {{value}}",
{
value: formatCellValue(
details.tenant_id,
),
},
)}
</div>
<div>
{t(
"ui.admin.audit.details.device",
"Device · {{value}}",
{
value: formatCellValue(row.device_id),
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t(
"ui.admin.audit.details.result",
"Result",
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.error",
"Error · {{value}}",
{
value: formatCellValue(details.error),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.before",
"Before · {{value}}",
{
value: formatCellValue(details.before),
},
)}
</div>
<div className="break-all">
{t(
"ui.admin.audit.details.after",
"After · {{value}}",
{
value: formatCellValue(details.after),
},
)}
</div>
</div>
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
</div>
<div className="pt-4 text-center flex-shrink-0">
{hasNextPage ? (
<Button
variant="outline"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
> >
{isFetchingNextPage <div className="relative">
? t("msg.common.loading", "Loading...") <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
: t("ui.admin.audit.load_more", "Load more")} <Input
</Button> className="pl-10"
) : ( value={searchActorId}
<span className="text-xs text-[var(--color-muted)]"> onChange={(event) => setSearchActorId(event.target.value)}
{t("msg.admin.audit.end", "End of audit feed")} placeholder={t(
</span> "ui.common.audit.filters.user_id",
)} "Filter by User ID",
</div> )}
/>
</div>
<Input
value={searchAction}
onChange={(event) =>
setSearchAction(event.target.value.toUpperCase())
}
placeholder={t(
"ui.common.audit.filters.action",
"Filter by Action (e.g. ROTATE_SECRET)",
)}
/>
<select
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
<option value="all">
{t("ui.common.audit.filters.status_all", "All Status")}
</option>
<option value="success">
{t("ui.common.status.success", "Success")}
</option>
<option value="failure">
{t("ui.common.status.failure", "Failure")}
</option>
</select>
</form>
}
/>
<AuditLogTable
logs={filteredLogs}
t={t}
loading={isLoading}
hasNextPage={Boolean(hasNextPage)}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
/>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,20 @@
import { KeyRound } from "lucide-react"; import { ShieldHalf } from "lucide-react";
import { PageHeader } from "../../../../common/core/components/page";
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker"; import PermissionChecker from "./components/PermissionChecker";
function AuthPage() { function AuthPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4"> <PageHeader
<div className="space-y-1"> titleAs="h2"
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground"> icon={<ShieldHalf size={20} />}
Admin auth title={t("ui.admin.auth_guard.title", "Auth Guard")}
</p> description={t(
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight"> "ui.admin.auth_guard.subtitle",
<KeyRound size={22} className="text-primary" /> "Verify admin privileges and ReBAC relationships against the policy engine.",
)}
</h2> />
<p className="text-sm text-muted-foreground">
ReBAC .
</p>
</div>
</div>
<PermissionChecker /> <PermissionChecker />
</div> </div>

View File

@@ -10,6 +10,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../components/ui/card"; } from "../../components/ui/card";
import { debugLog } from "../../lib/debugLog";
function LoginPage() { function LoginPage() {
const auth = useAuth(); const auth = useAuth();
@@ -20,7 +21,7 @@ function LoginPage() {
const shouldAutoLogin = searchParams.get("auto") === "1"; const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => { useEffect(() => {
console.debug("[LoginPage] Auth state check:", { debugLog("[LoginPage] Auth state check:", {
isAuthenticated: auth.isAuthenticated, isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading, isLoading: auth.isLoading,
returnTo, returnTo,
@@ -84,8 +85,11 @@ function LoginPage() {
variant="ghost" variant="ghost"
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent" className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
onClick={() => { onClick={() => {
window.location.href = void auth.signinRedirect({
window.location.origin + window.location.pathname; state: {
returnTo,
},
});
}} }}
> >

View File

@@ -1,5 +1,5 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, ShieldAlert, XCircle } from "lucide-react"; import { CheckCircle2, XCircle } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -12,6 +12,7 @@ import {
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label"; import { Label } from "../../../components/ui/label";
import apiClient from "../../../lib/apiClient"; import apiClient from "../../../lib/apiClient";
import { t } from "../../../lib/i18n";
type CheckPermissionResponse = { type CheckPermissionResponse = {
allowed: boolean; allowed: boolean;
@@ -46,50 +47,84 @@ function PermissionChecker() {
return ( return (
<Card className="border-primary/20 bg-[var(--color-panel)]"> <Card className="border-primary/20 bg-[var(--color-panel)]">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="text-lg font-bold">
<ShieldAlert size={20} className="text-primary" /> {t(
ReBAC "ui.admin.auth_guard.checker.title",
"ReBAC permission checker",
)}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
(Subject) (Object) Ory {t(
Keto를 . "ui.admin.auth_guard.checker.description",
"Check in real time whether a subject has access to a resource through Ory Keto.",
)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Namespace</Label> <Label>
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
</Label>
<select <select
value={namespace} value={namespace}
onChange={(e) => setNamespace(e.target.value)} onChange={(e) => setNamespace(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
> >
<option value="Tenant">Tenant</option> <option value="Tenant">
<option value="TenantGroup">TenantGroup</option> {t("ui.admin.auth_guard.checker.namespace.tenant", "Tenant")}
<option value="RelyingParty">RelyingParty</option> </option>
<option value="System">System</option> <option value="TenantGroup">
{t(
"ui.admin.auth_guard.checker.namespace.tenant_group",
"TenantGroup",
)}
</option>
<option value="RelyingParty">
{t(
"ui.admin.auth_guard.checker.namespace.relying_party",
"RelyingParty",
)}
</option>
<option value="System">
{t("ui.admin.auth_guard.checker.namespace.system", "System")}
</option>
</select> </select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Relation</Label> <Label>{t("ui.admin.auth_guard.checker.relation", "Relation")}</Label>
<Input <Input
placeholder="view, manage, admins..." placeholder={t(
"ui.admin.auth_guard.checker.relation_placeholder",
"view, manage, admins...",
)}
value={relation} value={relation}
onChange={(e) => setRelation(e.target.value)} onChange={(e) => setRelation(e.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Object ID</Label> <Label>{t("ui.admin.auth_guard.checker.object_id", "Object ID")}</Label>
<Input <Input
placeholder="Tenant UUID 등" placeholder={t(
"ui.admin.auth_guard.checker.object_id_placeholder",
"Tenant UUID, etc.",
)}
value={object} value={object}
onChange={(e) => setObject(e.target.value)} onChange={(e) => setObject(e.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Subject (User:ID)</Label> <Label>
{t(
"ui.admin.auth_guard.checker.subject",
"Subject (User:ID)",
)}
</Label>
<Input <Input
placeholder="User:uuid 또는 Namespace:ID#Relation" placeholder={t(
"ui.admin.auth_guard.checker.subject_placeholder",
"User:uuid or Namespace:ID#Relation",
)}
value={subject} value={subject}
onChange={(e) => setSubject(e.target.value)} onChange={(e) => setSubject(e.target.value)}
/> />
@@ -102,7 +137,9 @@ function PermissionChecker() {
disabled={!object || !subject || checkMutation.isPending} disabled={!object || !subject || checkMutation.isPending}
className="w-full px-12 md:w-auto" className="w-full px-12 md:w-auto"
> >
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"} {checkMutation.isPending
? t("ui.admin.auth_guard.checker.checking", "Checking...")
: t("ui.admin.auth_guard.checker.check", "Check permission")}
</Button> </Button>
</div> </div>
@@ -117,18 +154,33 @@ function PermissionChecker() {
{result.allowed ? ( {result.allowed ? (
<> <>
<CheckCircle2 size={48} /> <CheckCircle2 size={48} />
<div className="text-xl font-bold">Access ALLOWED</div> <div className="text-lg font-bold">
{t(
"ui.admin.auth_guard.checker.allowed",
"Access ALLOWED",
)}
</div>
<p className="text-center text-sm opacity-80"> <p className="text-center text-sm opacity-80">
. ( {t(
) "ui.admin.auth_guard.checker.allowed_description",
"The subject has access to the requested resource, including inherited permissions.",
)}
</p> </p>
</> </>
) : ( ) : (
<> <>
<XCircle size={48} /> <XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div> <div className="text-lg font-bold">
{t(
"ui.admin.auth_guard.checker.denied",
"Access DENIED",
)}
</div>
<p className="text-center text-sm opacity-80"> <p className="text-center text-sm opacity-80">
. {t(
"ui.admin.auth_guard.checker.denied_description",
"The subject does not have access to the requested resource.",
)}
</p> </p>
</> </>
)} )}

View File

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

View File

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

View File

@@ -3,19 +3,39 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react"; import type React from "react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAdminRPUsageDaily } from "../../lib/adminApi"; import {
fetchAdminRPUsageDaily,
fetchDataIntegrityReport,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import AuthPage from "../auth/AuthPage"; import AuthPage from "../auth/AuthPage";
import GlobalOverviewPage from "./GlobalOverviewPage"; import GlobalOverviewPage from "./GlobalOverviewPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({ vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: "super_admin" })), fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchAdminOverviewStats: vi.fn(async () => ({ fetchAdminOverviewStats: vi.fn(async () => ({
totalTenants: 10, totalTenants: 10,
totalUsers: 152,
oidcClients: 3, oidcClients: 3,
auditEvents24h: 18, auditEvents24h: 18,
})), })),
fetchTenants: vi.fn(async () => ({ fetchAllTenants: vi.fn(async () => ({
items: [ items: [
{
id: "group-1",
type: "COMPANY_GROUP",
name: "한맥그룹",
slug: "hanmac-group",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{ {
id: "company-1", id: "company-1",
type: "COMPANY", type: "COMPANY",
@@ -52,7 +72,7 @@ vi.mock("../../lib/adminApi", () => ({
], ],
limit: 1000, limit: 1000,
offset: 0, offset: 0,
total: 3, total: 4,
})), })),
fetchAdminRPUsageDaily: vi.fn(async () => ({ fetchAdminRPUsageDaily: vi.fn(async () => ({
days: 14, days: 14,
@@ -93,6 +113,30 @@ vi.mock("../../lib/adminApi", () => ({
}, },
], ],
})), })),
fetchDataIntegrityReport: vi.fn(async () => ({
status: "fail",
checkedAt: "2026-05-14T00:00:00Z",
summary: {
totalChecks: 5,
passed: 4,
warnings: 0,
failures: 1,
},
sections: [
{
key: "tenant_integrity",
label: "테넌트 정합성",
status: "pass",
checks: [],
},
{
key: "user_integrity",
label: "사용자 정합성",
status: "fail",
checks: [],
},
],
})),
})); }));
function renderWithProviders(ui: React.ReactElement) { function renderWithProviders(ui: React.ReactElement) {
@@ -112,6 +156,7 @@ function renderWithProviders(ui: React.ReactElement) {
describe("admin overview and auth guard pages", () => { describe("admin overview and auth guard pages", () => {
beforeEach(() => { beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -119,7 +164,7 @@ describe("admin overview and auth guard pages", () => {
renderWithProviders(<GlobalOverviewPage />); renderWithProviders(<GlobalOverviewPage />);
expect( expect(
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"), await screen.findByText("회사별 앱별 로그인 요청 현황"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
await screen.findByLabelText("일 단위 RP 요청 현황"), await screen.findByLabelText("일 단위 RP 요청 현황"),
@@ -132,50 +177,85 @@ describe("admin overview and auth guard pages", () => {
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument(); expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
}); });
it("renders overview summary metrics from the admin stats API", async () => { it("renders overview tenant count from the fully fetched tenant list", async () => {
renderWithProviders(<GlobalOverviewPage />); renderWithProviders(<GlobalOverviewPage />);
expect( expect(
(await screen.findByText("전체 테넌트 수")).parentElement, (await screen.findByText("전체 테넌트 수")).parentElement,
).toHaveTextContent("10"); ).toHaveTextContent("4");
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent( expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
"3", "3",
); );
expect(screen.getByText("전체 사용자 수").parentElement).toHaveTextContent(
"152",
);
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent( expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
"18", "18",
); );
}); });
it("changes the RP usage perspective and targets a permitted organization", async () => { it("limits the overview graph choices to company tenants", async () => {
renderWithProviders(<GlobalOverviewPage />); renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"); await screen.findByText("회사별 앱별 로그인 요청 현황");
expect(
await screen.findByRole("checkbox", { name: "한맥 (hanmac)" }),
).toBeInTheDocument();
expect(
screen.queryByText("한맥그룹 (hanmac-group)"),
).not.toBeInTheDocument();
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
});
it("changes the RP usage perspective and targets a permitted company", async () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인 요청 현황");
fireEvent.click(screen.getByRole("button", { name: "주" })); fireEvent.click(screen.getByRole("button", { name: "주" }));
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0); expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0); expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "월" })); fireEvent.click(screen.getByRole("button", { name: "월" }));
fireEvent.change(screen.getByLabelText("조직 검색"), { fireEvent.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" }));
target: { value: "개발" },
});
fireEvent.change(screen.getByLabelText("대상 조직"), {
target: { value: "org-1" },
});
await waitFor(() => { await waitFor(() => {
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({ expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
days: 90, days: 90,
period: "month", period: "month",
tenantId: "org-1",
}); });
}); });
expect(
screen.queryByText("한맥그룹 (hanmac-group)"),
).not.toBeInTheDocument();
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument(); expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
expect(await screen.findAllByText("05월")).not.toHaveLength(0); });
it("shows the latest integrity summary at the bottom for super admins only", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(await screen.findByText("정합성 최종 검증")).toBeInTheDocument();
expect(screen.getByText("실패 1건")).toBeInTheDocument();
expect(screen.getByText("테넌트 정합성")).toBeInTheDocument();
expect(screen.getByText("사용자 정합성")).toBeInTheDocument();
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("does not fetch or show the integrity summary for non-super admins", async () => {
currentRole = "tenant_admin";
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인 요청 현황");
expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument();
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
}); });
it("moves the permission checker to the auth guard page and removes mock guardrails", () => { it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
renderWithProviders(<AuthPage />); renderWithProviders(<AuthPage />);
expect(screen.getByText("인증가드")).toBeInTheDocument(); expect(screen.getByText("인증 가드")).toBeInTheDocument();
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument(); expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument(); expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
expect( expect(

View File

@@ -1,20 +1,29 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Activity, Activity,
BarChart3, AlertTriangle,
CheckCircle2,
Database, Database,
LayoutDashboard,
ShieldCheck, ShieldCheck,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { type ReactNode, useMemo, useState } from "react"; import { type ReactNode, useMemo, useState } from "react";
import {
OverviewAxisNotes,
OverviewMetric,
OverviewSelectionChips,
} from "../../../../common/core/components/overview";
import { RoleGuard } from "../../components/auth/RoleGuard"; import { RoleGuard } from "../../components/auth/RoleGuard";
import { import {
type DataIntegrityStatus,
type RPUsageDailyMetric, type RPUsageDailyMetric,
type RPUsagePeriod, type RPUsagePeriod,
type TenantSummary, type TenantSummary,
fetchAdminOverviewStats, fetchAdminOverviewStats,
fetchAdminRPUsageDaily, fetchAdminRPUsageDaily,
fetchTenants, fetchAllTenants,
fetchDataIntegrityReport,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
@@ -26,10 +35,8 @@ type DailyPoint = {
type SeriesSummary = { type SeriesSummary = {
key: string; key: string;
tenantLabel: string;
clientLabel: string; clientLabel: string;
loginRequests: number; loginRequests: number;
otherRequests: number;
uniqueSubjects: number; uniqueSubjects: number;
}; };
@@ -55,19 +62,16 @@ function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] { function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
const bySeries = new Map<string, SeriesSummary>(); const bySeries = new Map<string, SeriesSummary>();
for (const row of rows) { for (const row of rows) {
const key = `${row.tenantId}:${row.clientId}`; const key = row.clientId;
const current = const current =
bySeries.get(key) ?? bySeries.get(key) ??
({ ({
key, key,
tenantLabel: row.tenantName || row.tenantId || "-",
clientLabel: row.clientName || row.clientId, clientLabel: row.clientName || row.clientId,
loginRequests: 0, loginRequests: 0,
otherRequests: 0,
uniqueSubjects: 0, uniqueSubjects: 0,
} satisfies SeriesSummary); } satisfies SeriesSummary);
current.loginRequests += row.loginRequests; current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
current.uniqueSubjects = Math.max( current.uniqueSubjects = Math.max(
current.uniqueSubjects, current.uniqueSubjects,
row.uniqueSubjects, row.uniqueSubjects,
@@ -75,10 +79,7 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
bySeries.set(key, current); bySeries.set(key, current);
} }
return Array.from(bySeries.values()) return Array.from(bySeries.values())
.sort( .sort((a, b) => b.loginRequests - a.loginRequests)
(a, b) =>
b.loginRequests + b.otherRequests - (a.loginRequests + a.otherRequests),
)
.slice(0, 5); .slice(0, 5);
} }
@@ -133,30 +134,135 @@ function formatPeriodLabel(date: string, period: RPUsagePeriod) {
return `${parts.monthText}.${parts.dayText}`; return `${parts.monthText}.${parts.dayText}`;
} }
function OverviewMetric({ function formatOverviewDateTime(value?: string) {
icon, if (!value) return "-";
label, const date = new Date(value);
value, if (Number.isNaN(date.getTime())) return value;
}: { return new Intl.DateTimeFormat("ko-KR", {
icon: ReactNode; dateStyle: "medium",
label: string; timeStyle: "short",
value: string; }).format(date);
}) { }
function integrityStatusText(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return t("ui.admin.integrity.status.pass", "정상");
case "warning":
return t("ui.admin.integrity.status.warning", "주의");
default:
return t("ui.admin.integrity.status.fail", "실패");
}
}
function integrityStatusClass(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "text-emerald-700 dark:text-emerald-300";
case "warning":
return "text-amber-700 dark:text-amber-300";
default:
return "text-destructive";
}
}
function IntegrityOverviewSummary() {
const { data, isError } = useQuery({
queryKey: ["admin-overview-integrity"],
queryFn: fetchDataIntegrityReport,
retry: false,
});
if (isError) {
return (
<section className="border-t border-border/60 pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertTriangle size={16} />
<span>
{t(
"ui.admin.integrity.fetch_error",
"정합성 최종 검증 결과를 불러오지 못했습니다.",
)}
</span>
</div>
</section>
);
}
if (!data) {
return null;
}
return ( return (
<span className="inline-flex items-center gap-2 whitespace-nowrap text-sm"> <section className="border-t border-border/60 pt-4">
<span className="text-muted-foreground">{icon}</span> <div className="flex flex-wrap items-start justify-between gap-3">
<span className="text-muted-foreground">{label}</span> <div className="flex items-center gap-2">
<span className="font-semibold tabular-nums">{value}</span> {data.status === "pass" ? (
</span> <CheckCircle2 size={18} className="text-emerald-600" />
) : (
<AlertTriangle size={18} className="text-amber-600" />
)}
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.summary.title",
"정합성 최종 검증",
)}
</h3>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm">
<span
className={`font-semibold ${integrityStatusClass(data.status)}`}
>
{integrityStatusText(data.status)}
</span>
<span className="tabular-nums">
{t("ui.admin.integrity.summary.failures_text", "실패 {{count}}건", {
count: data.summary.failures,
})}
</span>
<span className="text-muted-foreground">
{formatOverviewDateTime(data.checkedAt)}
</span>
</div>
</div>
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
{data.sections.map((section) => (
<div
key={section.key}
className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2"
>
<span>{integritySectionLabel(section.key, section.label)}</span>
<span
className={`font-medium ${integrityStatusClass(section.status)}`}
>
{integrityStatusText(section.status)}
</span>
</div>
))}
</div>
</section>
); );
} }
function integritySectionLabel(key: string, fallback: string) {
switch (key) {
case "tenant_integrity":
return t("ui.admin.integrity.section.tenant_integrity", fallback);
case "user_integrity":
return t("ui.admin.integrity.section.user_integrity", fallback);
default:
return fallback;
}
}
function RPUsageMixedChart({ function RPUsageMixedChart({
rows, rows,
periodControls,
filters, filters,
period, period,
}: { }: {
rows: RPUsageDailyMetric[]; rows: RPUsageDailyMetric[];
periodControls: ReactNode;
filters: ReactNode; filters: ReactNode;
period: RPUsagePeriod; period: RPUsagePeriod;
}) { }) {
@@ -185,142 +291,131 @@ function RPUsageMixedChart({
return ( return (
<section className="space-y-3"> <section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2"> <div className="space-y-1">
<BarChart3 size={18} className="text-primary" /> <h3 className="text-lg font-bold flex items-center gap-2">
<h3 className="text-base font-semibold"> {t("ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황")}
/
</h3> </h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
)}
</p>
</div> </div>
{filters} {periodControls}
</div> </div>
{filters}
{daily.length === 0 ? ( {daily.length === 0 ? (
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground"> <div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
RP . RP .
</div> </div>
) : ( ) : (
<div className="overflow-x-auto"> <div className="space-y-3">
<svg <div className="overflow-x-auto">
role="img" <svg
aria-label="일 단위 RP 요청 현황" role="img"
viewBox={`0 0 ${chartWidth} ${chartHeight}`} aria-label="일 단위 RP 요청 현황"
className="h-[235px] min-w-[720px] w-full" viewBox={`0 0 ${chartWidth} ${chartHeight}`}
> className="h-[235px] min-w-[720px] w-full"
<title> RP </title> >
<g transform="translate(510 10)"> <title> RP </title>
<rect {[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
x="0" const gridY = padTop + innerHeight * ratio;
y="3" const label = Math.round(maxValue * (1 - ratio));
width="10" return (
height="10" <g key={ratio}>
rx="2" <line
className="fill-sky-500/70" x1={padX}
/> x2={chartWidth - padX}
<text x="16" y="12" className="fill-muted-foreground text-[11px]"> y1={gridY}
y2={gridY}
</text> stroke="currentColor"
<line className="text-border"
x1="78" strokeWidth="1"
x2="98" />
y1="8" <text
y2="8" x={padX - 12}
y={gridY + 4}
textAnchor="end"
className="fill-muted-foreground text-[11px]"
>
{label}
</text>
</g>
);
})}
{daily.map((point, index) => {
const center = x(index);
const otherHeight =
(point.otherRequests / maxValue) * innerHeight;
return (
<g key={point.date}>
<rect
x={center - barWidth / 2}
y={padTop + innerHeight - otherHeight}
width={barWidth}
height={otherHeight}
rx="3"
className="fill-sky-500/70"
/>
<text
x={center}
y={chartHeight - 12}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
{formatPeriodLabel(point.date, period)}
</text>
</g>
);
})}
<polyline
points={linePoints}
fill="none"
className="stroke-emerald-500" className="stroke-emerald-500"
strokeWidth="3" strokeWidth="3"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round"
/> />
<text {daily.map((point, index) => (
x="104" <circle
y="12" key={`${point.date}-login`}
className="fill-muted-foreground text-[11px]" cx={x(index)}
> cy={y(point.loginRequests)}
r="4"
</text> className="fill-emerald-500 stroke-background"
</g> strokeWidth="2"
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => { />
const gridY = padTop + innerHeight * ratio; ))}
const label = Math.round(maxValue * (1 - ratio)); </svg>
return ( </div>
<g key={ratio}> <OverviewAxisNotes
<line xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
x1={padX} yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
x2={chartWidth - padX} />
y1={gridY}
y2={gridY}
stroke="currentColor"
className="text-border"
strokeWidth="1"
/>
<text
x={padX - 12}
y={gridY + 4}
textAnchor="end"
className="fill-muted-foreground text-[11px]"
>
{label}
</text>
</g>
);
})}
{daily.map((point, index) => {
const center = x(index);
const otherHeight =
(point.otherRequests / maxValue) * innerHeight;
return (
<g key={point.date}>
<rect
x={center - barWidth / 2}
y={padTop + innerHeight - otherHeight}
width={barWidth}
height={otherHeight}
rx="3"
className="fill-sky-500/70"
/>
<text
x={center}
y={chartHeight - 12}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
{formatPeriodLabel(point.date, period)}
</text>
</g>
);
})}
<polyline
points={linePoints}
fill="none"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{daily.map((point, index) => (
<circle
key={`${point.date}-login`}
cx={x(index)}
cy={y(point.loginRequests)}
r="4"
className="fill-emerald-500 stroke-background"
strokeWidth="2"
/>
))}
</svg>
</div> </div>
)} )}
{series.length > 0 && ( {series.length > 0 && (
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
{series.map((item) => ( {series.map((item) => (
<div key={item.key} className="flex min-w-0 items-center gap-2"> <div
<span className="truncate font-medium">{item.clientLabel}</span> key={item.key}
<span className="truncate text-muted-foreground"> className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
{item.tenantLabel} >
</span> <span className="font-medium">{item.clientLabel}</span>
<span className="ml-auto whitespace-nowrap tabular-nums"> <span className="whitespace-nowrap tabular-nums text-muted-foreground">
{item.loginRequests.toLocaleString()} / {" "} {t(
{item.otherRequests.toLocaleString()} / {" "} "ui.common.chart.series_summary.login_users",
{item.uniqueSubjects.toLocaleString()} "로그인 {{login}} / 사용자 {{subjects}}",
{
login: item.loginRequests.toLocaleString(),
subjects: item.uniqueSubjects.toLocaleString(),
},
)}
</span> </span>
</div> </div>
))} ))}
@@ -332,8 +427,7 @@ function RPUsageMixedChart({
function GlobalOverviewPage() { function GlobalOverviewPage() {
const [period, setPeriod] = useState<RPUsagePeriod>("day"); const [period, setPeriod] = useState<RPUsagePeriod>("day");
const [tenantSearch, setTenantSearch] = useState(""); const [selectedTenantIds, setSelectedTenantIds] = useState<string[]>([]);
const [selectedTenantId, setSelectedTenantId] = useState("");
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
const statsQuery = useQuery({ const statsQuery = useQuery({
queryKey: ["admin-overview-stats"], queryKey: ["admin-overview-stats"],
@@ -342,97 +436,97 @@ function GlobalOverviewPage() {
}); });
const tenantsQuery = useQuery({ const tenantsQuery = useQuery({
queryKey: ["admin-overview-tenant-options"], queryKey: ["admin-overview-tenant-options"],
queryFn: () => fetchTenants(1000, 0), queryFn: () => fetchAllTenants(),
retry: false, retry: false,
}); });
const tenantOptions = useMemo(() => { const tenantOptions = useMemo(() => {
const term = tenantSearch.trim().toLowerCase(); return (tenantsQuery.data?.items ?? []).filter(
return (tenantsQuery.data?.items ?? []) (tenant) => tenant.type === "COMPANY",
.filter( );
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION", }, [tenantsQuery.data?.items]);
)
.filter((tenant) => {
if (!term) return true;
return (
tenant.name.toLowerCase().includes(term) ||
tenant.slug.toLowerCase().includes(term) ||
tenant.id.toLowerCase().includes(term)
);
});
}, [tenantSearch, tenantsQuery.data?.items]);
const usageQuery = useQuery({ const usageQuery = useQuery({
queryKey: ["admin-rp-usage-daily", usageDays, period, selectedTenantId], queryKey: ["admin-rp-usage-daily", usageDays, period],
queryFn: () => queryFn: () =>
fetchAdminRPUsageDaily({ fetchAdminRPUsageDaily({
days: usageDays, days: usageDays,
period, period,
tenantId: selectedTenantId || undefined,
}), }),
retry: false, retry: false,
}); });
const stats = statsQuery.data; const stats = statsQuery.data;
const visibleTenantCount = tenantsQuery.data?.items.length;
const usageRows = usageQuery.data?.items ?? []; const usageRows = usageQuery.data?.items ?? [];
const filteredUsageRows = useMemo(() => {
if (selectedTenantIds.length === 0) {
return usageRows;
}
const selectedSet = new Set(selectedTenantIds);
return usageRows.filter((row) => selectedSet.has(row.tenantId));
}, [selectedTenantIds, usageRows]);
const metric = (value: number | undefined) => const metric = (value: number | undefined) =>
value === undefined ? "-" : value.toLocaleString(); value === undefined ? "-" : value.toLocaleString();
const periodControls = (
<div className="flex h-8 items-center gap-1" aria-label="집계 단위">
{[
["day", t("ui.common.chart.period.day", "일")],
["week", t("ui.common.chart.period.week", "주")],
["month", t("ui.common.chart.period.month", "월")],
].map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={period === value}
onClick={() => setPeriod(value as RPUsagePeriod)}
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
period === value
? "bg-primary text-primary-foreground"
: "bg-muted/60 hover:bg-muted"
}`}
>
{label}
</button>
))}
</div>
);
const chartFilters = ( const chartFilters = (
<div className="flex flex-wrap items-center gap-2"> <div>
<div className="flex h-8 items-center gap-1" aria-label="집계 단위"> <OverviewSelectionChips
{[ allLabel="전체"
["day", "일"], options={tenantOptions.map((tenant) => ({
["week", "주"], id: tenant.id,
["month", "월"], label: `${tenant.name} (${tenant.slug})`,
].map(([value, label]) => ( }))}
<button selectedIds={selectedTenantIds}
key={value} onSelectAll={() => setSelectedTenantIds([])}
type="button" onToggle={(tenantId) => {
aria-pressed={period === value} setSelectedTenantIds((current) =>
onClick={() => setPeriod(value as RPUsagePeriod)} current.includes(tenantId)
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${ ? current.filter((item) => item !== tenantId)
period === value : [...current, tenantId],
? "bg-primary text-primary-foreground" );
: "bg-muted/60 hover:bg-muted" }}
}`}
>
{label}
</button>
))}
</div>
<input
aria-label="조직 검색"
value={tenantSearch}
onChange={(event) => setTenantSearch(event.target.value)}
placeholder="조직 검색"
className="h-8 w-36 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-44"
/> />
<select
aria-label="대상 조직"
value={selectedTenantId}
onChange={(event) => setSelectedTenantId(event.target.value)}
className="h-8 w-40 rounded border border-input bg-background px-2 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring sm:w-52"
>
<option value=""> </option>
{tenantOptions.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name} ({tenant.slug})
</option>
))}
</select>
</div> </div>
); );
return ( return (
<div className="space-y-4 animate-in fade-in duration-500"> <div className="space-y-4 animate-in fade-in duration-500">
<div className="flex flex-wrap items-end justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1"> <div className="flex min-w-0 items-start gap-3">
<h2 className="text-2xl font-semibold tracking-tight"> <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">
{t("ui.admin.overview.title", "Dashboard")} <LayoutDashboard size={20} />
</h2> </div>
<p className="text-sm text-muted-foreground"> <div className="space-y-1">
{t( <h2 className="text-3xl font-semibold">
"msg.admin.overview.description", {t("ui.common.overview.title", "운영 현황")}
"시스템 전반의 주요 현황을 확인하고 관리합니다.", </h2>
)} <p className="text-sm text-muted-foreground">
</p> {t(
"msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
)}
</p>
</div>
</div> </div>
</div> </div>
@@ -444,7 +538,7 @@ function GlobalOverviewPage() {
"ui.admin.overview.summary.total_tenants", "ui.admin.overview.summary.total_tenants",
"전체 테넌트 수", "전체 테넌트 수",
)} )}
value={metric(stats?.totalTenants)} value={metric(visibleTenantCount ?? stats?.totalTenants)}
/> />
<OverviewMetric <OverviewMetric
icon={<ShieldCheck size={14} />} icon={<ShieldCheck size={14} />}
@@ -454,6 +548,11 @@ function GlobalOverviewPage() {
)} )}
value={metric(stats?.oidcClients)} value={metric(stats?.oidcClients)}
/> />
<OverviewMetric
icon={<Users size={14} />}
label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")}
value={metric(stats?.totalUsers)}
/>
</RoleGuard> </RoleGuard>
<OverviewMetric <OverviewMetric
icon={<Activity size={14} />} icon={<Activity size={14} />}
@@ -472,12 +571,24 @@ function GlobalOverviewPage() {
{usageQuery.isError ? ( {usageQuery.isError ? (
<section className="space-y-2"> <section className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<h3 className="text-base font-semibold"> <div className="space-y-1">
/ <h3 className="text-lg font-bold flex items-center gap-2">
</h3> {t(
{chartFilters} "ui.admin.overview.chart.title",
"회사별 앱별 로그인 요청 현황",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
{periodControls}
</div> </div>
{chartFilters}
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
RP Query API . backend RP Query API . backend
`rp_usage_daily_aggregate` `rp_usage_daily_aggregate`
@@ -486,11 +597,16 @@ function GlobalOverviewPage() {
</section> </section>
) : ( ) : (
<RPUsageMixedChart <RPUsageMixedChart
rows={usageRows} rows={filteredUsageRows}
periodControls={periodControls}
filters={chartFilters} filters={chartFilters}
period={period} period={period}
/> />
)} )}
<RoleGuard roles={["super_admin"]}>
<IntegrityOverviewSummary />
</RoleGuard>
</div> </div>
); );
} }

View File

@@ -6,8 +6,11 @@ import {
reconcileUserProjection, reconcileUserProjection,
resetUserProjection, resetUserProjection,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import UserProjectionPage from "./UserProjectionPage"; import UserProjectionPage from "./UserProjectionPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin"; let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({ vi.mock("../../lib/adminApi", () => ({
@@ -52,18 +55,24 @@ describe("UserProjectionPage", () => {
currentRole = "super_admin"; currentRole = "super_admin";
vi.clearAllMocks(); vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true); vi.spyOn(window, "confirm").mockReturnValue(true);
window.localStorage.setItem("locale", "ko");
}); });
it("renders projection status for super_admin", async () => { it("renders projection status for super_admin", async () => {
renderPage(); renderPage();
expect( expect(
await screen.findByText("사용자 Projection 관리"), await screen.findByText("사용자 동기화 관리"),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
await screen.findByText("Kratos users projection"), await screen.findByText(
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText("ready")).toBeInTheDocument(); expect(
await screen.findByText("Kratos 사용자 동기화"),
).toBeInTheDocument();
expect(screen.getByText("준비됨")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument(); expect(screen.getByText("152")).toBeInTheDocument();
expect(fetchUserProjectionStatus).toHaveBeenCalled(); expect(fetchUserProjectionStatus).toHaveBeenCalled();
}); });
@@ -71,7 +80,7 @@ describe("UserProjectionPage", () => {
it("runs reconcile and reset actions for super_admin", async () => { it("runs reconcile and reset actions for super_admin", async () => {
renderPage(); renderPage();
await screen.findByText("사용자 Projection 관리"); await screen.findByText("사용자 동기화 관리");
fireEvent.click(screen.getByRole("button", { name: /재동기화/ })); fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
await waitFor(() => { await waitFor(() => {
@@ -92,8 +101,22 @@ describe("UserProjectionPage", () => {
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument(); expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect( expect(
screen.queryByText("사용자 Projection 관리"), screen.queryByText("사용자 동기화 관리"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(fetchUserProjectionStatus).not.toHaveBeenCalled(); 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,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Database, RefreshCw, RotateCcw } from "lucide-react"; import { AlertTriangle, RefreshCw, RotateCcw, Users } from "lucide-react";
import { RoleGuard } from "../../components/auth/RoleGuard"; import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge"; import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
@@ -8,6 +8,8 @@ import {
reconcileUserProjection, reconcileUserProjection,
resetUserProjection, resetUserProjection,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
function formatDateTime(value?: string) { function formatDateTime(value?: string) {
if (!value) { if (!value) {
@@ -17,7 +19,7 @@ function formatDateTime(value?: string) {
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return value; return value;
} }
return new Intl.DateTimeFormat("ko-KR", { return new Intl.DateTimeFormat(getAdminDateLocale(), {
dateStyle: "medium", dateStyle: "medium",
timeStyle: "medium", timeStyle: "medium",
}).format(date); }).format(date);
@@ -31,12 +33,26 @@ function ProjectionStatusBadge({
status: string; status: string;
}) { }) {
if (ready) { if (ready) {
return <Badge variant="success">ready</Badge>; return (
<Badge variant="success">
{t("ui.admin.user_projection.status.ready", "ready")}
</Badge>
);
} }
if (status === "failed") { if (status === "failed") {
return <Badge variant="warning">failed</Badge>; return (
<Badge variant="warning">
{t("ui.admin.user_projection.status.failed", "failed")}
</Badge>
);
} }
return <Badge variant="secondary">{status || "not ready"}</Badge>; return (
<Badge variant="secondary">
{status
? status
: t("ui.admin.user_projection.status.not_ready", "not ready")}
</Badge>
);
} }
function UserProjectionContent() { function UserProjectionContent() {
@@ -64,7 +80,10 @@ function UserProjectionContent() {
const handleReset = () => { const handleReset = () => {
const confirmed = window.confirm( const confirmed = window.confirm(
"사용자 projection을 Kratos 기준으로 다시 구축하시겠습니까?", t(
"msg.admin.user_projection.reset_confirm",
"Rebuild user projection from the Kratos source of truth?",
),
); );
if (confirmed) { if (confirmed) {
resetMutation.mutate(); resetMutation.mutate();
@@ -76,13 +95,26 @@ function UserProjectionContent() {
const actionError = reconcileMutation.error ?? resetMutation.error; const actionError = reconcileMutation.error ?? resetMutation.error;
return ( return (
<main className="space-y-6 p-6 md:p-8"> <main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="flex flex-wrap items-center justify-between gap-3"> <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> <div className="flex min-w-0 items-start gap-3">
<p className="text-sm text-muted-foreground">System</p> <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">
<h2 className="text-2xl font-semibold tracking-tight"> <Users size={20} />
Projection </div>
</h2> <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>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
@@ -92,7 +124,7 @@ function UserProjectionContent() {
disabled={isWorking} disabled={isWorking}
> >
<RefreshCw size={16} /> <RefreshCw size={16} />
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
</Button> </Button>
<Button <Button
type="button" type="button"
@@ -101,49 +133,72 @@ function UserProjectionContent() {
disabled={isWorking} disabled={isWorking}
> >
<RotateCcw size={16} /> <RotateCcw size={16} />
{t(
"ui.admin.user_projection.actions.reset",
"Reset and rebuild",
)}
</Button> </Button>
</div> </div>
</div> </header>
{isError ? ( {isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"> <section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message || {(error as Error)?.message ||
"projection 상태를 불러오지 못했습니다."} t(
"msg.admin.user_projection.load_error",
"Failed to load projection status.",
)}
</section> </section>
) : null} ) : null}
{actionResult ? ( {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"> <section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{actionResult.syncedUsers} projection을 . {t(
"msg.admin.user_projection.action_success",
"Refreshed the projection for {{count}} users.",
{ count: actionResult.syncedUsers },
)}
</section> </section>
) : null} ) : null}
{actionError ? ( {actionError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"> <section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(actionError as Error)?.message || "projection 작업에 실패했습니다."} {(actionError as Error)?.message ||
t(
"msg.admin.user_projection.action_error",
"Projection operation failed.",
)}
</section> </section>
) : null} ) : null}
<section className="rounded-lg border border-border bg-card p-5"> <section className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 border-b border-border pb-4"> <div className="flex items-center gap-3 border-b border-border pb-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
<Database size={18} />
</div>
<div> <div>
<h3 className="text-base font-semibold">Kratos users projection</h3> <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"> <p className="text-sm text-muted-foreground">
Backend DB read model . {t(
"ui.admin.user_projection.card.description",
"Current user read model state referenced by backend DB statistics.",
)}
</p> </p>
</div> </div>
</div> </div>
{isLoading ? ( {isLoading ? (
<div className="py-8 text-sm text-muted-foreground"> </div> <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"> <dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div> <div>
<dt className="text-sm text-muted-foreground"></dt> <dt className="text-sm text-muted-foreground">
{t("ui.admin.user_projection.summary.status", "Status")}
</dt>
<dd className="mt-1"> <dd className="mt-1">
<ProjectionStatusBadge <ProjectionStatusBadge
ready={data?.ready ?? false} ready={data?.ready ?? false}
@@ -153,20 +208,33 @@ function UserProjectionContent() {
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground"> <dt className="text-sm text-muted-foreground">
Projection {t(
"ui.admin.user_projection.summary.projected_users",
"Projected users",
)}
</dt> </dt>
<dd className="mt-1 text-xl font-semibold tabular-nums"> <dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.projectedUsers ?? 0} {data?.projectedUsers ?? 0}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground"> </dt> <dt className="text-sm text-muted-foreground">
{t(
"ui.admin.user_projection.summary.last_synced",
"Last synced",
)}
</dt>
<dd className="mt-1 text-sm"> <dd className="mt-1 text-sm">
{formatDateTime(data?.lastSyncedAt)} {formatDateTime(data?.lastSyncedAt)}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground"> </dt> <dt className="text-sm text-muted-foreground">
{t(
"ui.admin.user_projection.summary.updated_at",
"Updated at",
)}
</dt>
<dd className="mt-1 text-sm"> <dd className="mt-1 text-sm">
{formatDateTime(data?.updatedAt)} {formatDateTime(data?.updatedAt)}
</dd> </dd>
@@ -190,14 +258,22 @@ export default function UserProjectionPage() {
<RoleGuard <RoleGuard
roles={["super_admin"]} roles={["super_admin"]}
fallback={ fallback={
<main className="p-6 md:p-8"> <main className="p-6 md:p-8">
<section className="rounded-lg border border-border bg-card p-5"> <section className="rounded-lg border border-border bg-card p-5">
<h2 className="text-lg font-semibold"> </h2> <h2 className="text-lg font-semibold">
<p className="mt-2 text-sm text-muted-foreground"> {t(
super_admin . "ui.admin.user_projection.forbidden.title",
</p> "Access denied",
</section> )}
</main> </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 /> <UserProjectionContent />

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi"; import type { TenantSummary } from "../../../lib/adminApi";
import { filterParentTenants } from "./ParentTenantSelector"; import { filterParentTenants } from "./ParentTenantSelector.helpers";
const tenants: TenantSummary[] = [ const tenants: TenantSummary[] = [
{ {

View File

@@ -7,6 +7,7 @@ import {
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTrigger,
DialogTitle, DialogTitle,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { Label } from "../../../components/ui/label"; import { Label } from "../../../components/ui/label";
@@ -16,6 +17,7 @@ import {
buildAuthenticatedOrgChartTenantPickerUrl, buildAuthenticatedOrgChartTenantPickerUrl,
parseOrgChartTenantSelection, parseOrgChartTenantSelection,
} from "../../users/orgChartPicker"; } from "../../users/orgChartPicker";
import { filterParentTenants } from "./ParentTenantSelector.helpers";
type ParentTenantSelectorProps = { type ParentTenantSelectorProps = {
id: string; id: string;
@@ -33,26 +35,6 @@ type ParentTenantSelectorProps = {
localTenantFilter?: (tenant: TenantSummary) => boolean; localTenantFilter?: (tenant: TenantSummary) => boolean;
}; };
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
export function filterParentTenants(
tenants: TenantSummary[],
search: string,
companyOnly: boolean,
excludeTenantId = "",
) {
const normalizedSearch = search.trim().toLowerCase();
return tenants.filter((tenant) => {
if (excludeTenantId && tenant.id === excludeTenantId) return false;
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
if (!normalizedSearch) return true;
return [tenant.name, tenant.slug, tenant.type]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedSearch));
});
}
export function ParentTenantSelector({ export function ParentTenantSelector({
id, id,
label, label,
@@ -106,27 +88,100 @@ export function ParentTenantSelector({
</div> </div>
<input id={id} name={id} type="hidden" value={value} readOnly /> <input id={id} name={id} type="hidden" value={value} readOnly />
<div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2"> <div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
<Button <Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
type="button" <DialogTrigger asChild>
variant="outline" <Button type="button" variant="outline" size="sm">
size="sm" <Building2 className="h-4 w-4" />
onClick={() => setPickerOpen(true)} {orgChartPickerLabel ??
> selectedTenant?.name ??
<Building2 className="h-4 w-4" /> t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
{orgChartPickerLabel ?? </Button>
selectedTenant?.name ?? </DialogTrigger>
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")} <DialogContent className="max-w-[460px] p-4">
</Button> <DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.picker_description",
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
)}
</DialogDescription>
</DialogHeader>
<iframe
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
/>
</DialogContent>
</Dialog>
{localPickerLabel && ( {localPickerLabel && (
<Button <Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
type="button" <DialogTrigger asChild>
variant="outline" <Button type="button" variant="outline" size="sm">
size="sm" <Building2 className="h-4 w-4" />
onClick={() => setLocalPickerOpen(true)} {localPickerLabel}
> </Button>
<Building2 className="h-4 w-4" /> </DialogTrigger>
{localPickerLabel} <DialogContent className="max-w-[460px] p-4">
</Button> <DialogHeader>
<DialogTitle>
{localPickerLabel ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.local_picker_description",
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={localSearch}
onChange={(event) => setLocalSearch(event.target.value)}
placeholder={t(
"ui.admin.tenants.parent.local_search_placeholder",
"테넌트 이름 또는 슬러그 검색",
)}
/>
<div className="max-h-[360px] space-y-2 overflow-y-auto">
{localCandidates.map((tenant) => (
<Button
key={tenant.id}
type="button"
variant="outline"
className="h-auto w-full justify-start px-3 py-2 text-left"
onClick={() => {
onChange(tenant.id);
setLocalPickerOpen(false);
setLocalSearch("");
}}
>
<span>
<span className="block text-sm font-medium">
{tenant.name}
</span>
<span className="block text-xs text-muted-foreground">
{tenant.slug} · {tenant.type}
</span>
</span>
</Button>
))}
{localCandidates.length === 0 && (
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.parent.local_picker_empty",
"선택할 수 있는 테넌트가 없습니다.",
)}
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
)} )}
{selectedTenant ? ( {selectedTenant ? (
<> <>
@@ -156,85 +211,6 @@ export function ParentTenantSelector({
{helpText && ( {helpText && (
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p> <p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
)} )}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.picker_description",
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
)}
</DialogDescription>
</DialogHeader>
<iframe
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
/>
</DialogContent>
</Dialog>
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{localPickerLabel ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.local_picker_description",
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={localSearch}
onChange={(event) => setLocalSearch(event.target.value)}
placeholder={t(
"ui.admin.tenants.parent.local_search_placeholder",
"테넌트 이름 또는 슬러그 검색",
)}
/>
<div className="max-h-[360px] space-y-2 overflow-y-auto">
{localCandidates.map((tenant) => (
<Button
key={tenant.id}
type="button"
variant="outline"
className="h-auto w-full justify-start px-3 py-2 text-left"
onClick={() => {
onChange(tenant.id);
setLocalPickerOpen(false);
setLocalSearch("");
}}
>
<span>
<span className="block text-sm font-medium">
{tenant.name}
</span>
<span className="block text-xs text-muted-foreground">
{tenant.slug} · {tenant.type}
</span>
</span>
</Button>
))}
{localCandidates.length === 0 && (
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.parent.local_picker_empty",
"선택할 수 있는 테넌트가 없습니다.",
)}
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -11,7 +11,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -53,13 +54,27 @@ import { t } from "../../../lib/i18n";
type DialogMode = "owner" | "admin"; type DialogMode = "owner" | "admin";
function mergePendingMembers(
members: TenantAdmin[],
pendingMembers: TenantAdmin[],
) {
const existingIds = new Set(members.map((member) => member.id));
return [
...members,
...pendingMembers.filter((member) => !existingIds.has(member.id)),
];
}
export function TenantAdminsAndOwnersTab() { export function TenantAdminsAndOwnersTab() {
const auth = useAuth(); const auth = useAuth();
const navigate = useNavigate();
const currentUserId = auth.user?.profile.sub; const currentUserId = auth.user?.profile.sub;
const { tenantId } = useParams<{ tenantId: string }>(); const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null); const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
if (!tenantId) return null; if (!tenantId) return null;
@@ -95,18 +110,22 @@ export function TenantAdminsAndOwnersTab() {
// Optimistically add to the list to prevent immediate double clicks // Optimistically add to the list to prevent immediate double clicks
const addedUser = searchResults.find((u) => u.id === userId); const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) { if (addedUser) {
const optimisticOwner = {
id: userId,
name: addedUser.name,
email: addedUser.email,
};
setPendingOwners((old) =>
old.some((owner) => owner.id === userId)
? old
: [...old, optimisticOwner],
);
queryClient.setQueryData<TenantAdmin[]>( queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId], ["tenant-owners", tenantId],
(old) => { (old) => {
if (!old) if (!old) return [optimisticOwner];
return [
{ id: userId, name: addedUser.name, email: addedUser.email },
];
if (old.some((o) => o.id === userId)) return old; if (old.some((o) => o.id === userId)) return old;
return [ return [...old, optimisticOwner];
...old,
{ id: userId, name: addedUser.name, email: addedUser.email },
];
}, },
); );
} }
@@ -125,6 +144,7 @@ export function TenantAdminsAndOwnersTab() {
setSearchTerm(""); setSearchTerm("");
}, },
onError: (err: AxiosError<{ error?: string }>, userId, context) => { onError: (err: AxiosError<{ error?: string }>, userId, context) => {
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
if (context?.previousOwners) { if (context?.previousOwners) {
queryClient.setQueryData( queryClient.setQueryData(
["tenant-owners", tenantId], ["tenant-owners", tenantId],
@@ -148,6 +168,7 @@ export function TenantAdminsAndOwnersTab() {
"tenant-owners", "tenant-owners",
tenantId, tenantId,
]); ]);
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
queryClient.setQueryData<TenantAdmin[]>( queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId], ["tenant-owners", tenantId],
(old) => (old ? old.filter((o) => o.id !== userId) : []), (old) => (old ? old.filter((o) => o.id !== userId) : []),
@@ -194,18 +215,22 @@ export function TenantAdminsAndOwnersTab() {
const addedUser = searchResults.find((u) => u.id === userId); const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) { if (addedUser) {
const optimisticAdmin = {
id: userId,
name: addedUser.name,
email: addedUser.email,
};
setPendingAdmins((old) =>
old.some((admin) => admin.id === userId)
? old
: [...old, optimisticAdmin],
);
queryClient.setQueryData<TenantAdmin[]>( queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId], ["tenant-admins", tenantId],
(old) => { (old) => {
if (!old) if (!old) return [optimisticAdmin];
return [
{ id: userId, name: addedUser.name, email: addedUser.email },
];
if (old.some((a) => a.id === userId)) return old; if (old.some((a) => a.id === userId)) return old;
return [ return [...old, optimisticAdmin];
...old,
{ id: userId, name: addedUser.name, email: addedUser.email },
];
}, },
); );
} }
@@ -223,6 +248,7 @@ export function TenantAdminsAndOwnersTab() {
setSearchTerm(""); setSearchTerm("");
}, },
onError: (err: AxiosError<{ error?: string }>, userId, context) => { onError: (err: AxiosError<{ error?: string }>, userId, context) => {
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
if (context?.previousAdmins) { if (context?.previousAdmins) {
queryClient.setQueryData( queryClient.setQueryData(
["tenant-admins", tenantId], ["tenant-admins", tenantId],
@@ -246,6 +272,7 @@ export function TenantAdminsAndOwnersTab() {
"tenant-admins", "tenant-admins",
tenantId, tenantId,
]); ]);
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
queryClient.setQueryData<TenantAdmin[]>( queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId], ["tenant-admins", tenantId],
(old) => (old ? old.filter((a) => a.id !== userId) : []), (old) => (old ? old.filter((a) => a.id !== userId) : []),
@@ -312,8 +339,10 @@ export function TenantAdminsAndOwnersTab() {
} }
}; };
const currentOwners = ownersQuery.data || []; const serverOwners = ownersQuery.data || [];
const currentAdmins = adminsQuery.data || []; const serverAdmins = adminsQuery.data || [];
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
const currentAdmins = mergePendingMembers(serverAdmins, pendingAdmins);
const searchResults = usersQuery.data?.items || []; const searchResults = usersQuery.data?.items || [];
const isDialogOpen = dialogMode !== null; const isDialogOpen = dialogMode !== null;
@@ -363,7 +392,7 @@ export function TenantAdminsAndOwnersTab() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div className="flex-1 overflow-auto relative custom-scrollbar">
<Table> <Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm"> <TableHeader className={commonStickyTableHeaderClass}>
<TableRow> <TableRow>
<TableHead className="w-[250px] font-bold"> <TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.owners.table_name", "이름")} {t("ui.admin.tenants.owners.table_name", "이름")}
@@ -371,22 +400,19 @@ export function TenantAdminsAndOwnersTab() {
<TableHead className="font-bold"> <TableHead className="font-bold">
{t("ui.admin.tenants.owners.table_email", "이메일")} {t("ui.admin.tenants.owners.table_email", "이메일")}
</TableHead> </TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.owners.table_actions", "액션")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{ownersQuery.isLoading ? ( {ownersQuery.isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="h-32 text-center"> <TableCell colSpan={2} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" /> <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : currentOwners.length === 0 ? ( ) : currentOwners.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={3} colSpan={2}
className="h-32 text-center text-muted-foreground" className="h-32 text-center text-muted-foreground"
> >
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
@@ -404,7 +430,8 @@ export function TenantAdminsAndOwnersTab() {
currentOwners.map((owner) => ( currentOwners.map((owner) => (
<TableRow <TableRow
key={owner.id} key={owner.id}
className="hover:bg-muted/30 transition-colors group" className="hover:bg-muted/30 transition-colors group cursor-pointer"
onClick={() => navigate(`/users/${owner.id}`)}
> >
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -417,46 +444,6 @@ export function TenantAdminsAndOwnersTab() {
<TableCell className="text-muted-foreground italic"> <TableCell className="text-muted-foreground italic">
{owner.email} {owner.email}
</TableCell> </TableCell>
<TableCell className="text-right">
<span className="relative inline-block group/tt">
<Button
variant="ghost"
size="icon"
className={`opacity-0 group-hover:opacity-100 transition-all ${
owner.id === currentUserId ||
currentOwners.length <= 1
? "opacity-50 cursor-not-allowed pointer-events-none"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}`}
onClick={() =>
handleRemoveOwner(owner.id, owner.name)
}
disabled={
removeOwnerMutation.isPending ||
owner.id === currentUserId ||
currentOwners.length <= 1
}
>
<Trash2 className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
{owner.id === currentUserId
? t(
"msg.admin.tenants.owners.remove_self",
"본인의 권한은 회수할 수 없습니다.",
)
: currentOwners.length <= 1
? t(
"msg.admin.tenants.owners.remove_last",
"마지막 소유자는 회수할 수 없습니다.",
)
: t(
"ui.admin.tenants.owners.remove_title",
"소유자 권한 회수",
)}
</span>
</span>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
@@ -494,7 +481,7 @@ export function TenantAdminsAndOwnersTab() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div className="flex-1 overflow-auto relative custom-scrollbar">
<Table> <Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm"> <TableHeader className={commonStickyTableHeaderClass}>
<TableRow> <TableRow>
<TableHead className="w-[250px] font-bold"> <TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.admins.table_name", "이름")} {t("ui.admin.tenants.admins.table_name", "이름")}
@@ -502,22 +489,19 @@ export function TenantAdminsAndOwnersTab() {
<TableHead className="font-bold"> <TableHead className="font-bold">
{t("ui.admin.tenants.admins.table_email", "이메일")} {t("ui.admin.tenants.admins.table_email", "이메일")}
</TableHead> </TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.admins.table_actions", "액션")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{adminsQuery.isLoading ? ( {adminsQuery.isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="h-32 text-center"> <TableCell colSpan={2} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" /> <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : currentAdmins.length === 0 ? ( ) : currentAdmins.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={3} colSpan={2}
className="h-32 text-center text-muted-foreground" className="h-32 text-center text-muted-foreground"
> >
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
@@ -535,7 +519,8 @@ export function TenantAdminsAndOwnersTab() {
currentAdmins.map((admin) => ( currentAdmins.map((admin) => (
<TableRow <TableRow
key={admin.id} key={admin.id}
className="hover:bg-muted/30 transition-colors group" className="hover:bg-muted/30 transition-colors group cursor-pointer"
onClick={() => navigate(`/users/${admin.id}`)}
> >
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -548,46 +533,6 @@ export function TenantAdminsAndOwnersTab() {
<TableCell className="text-muted-foreground italic"> <TableCell className="text-muted-foreground italic">
{admin.email} {admin.email}
</TableCell> </TableCell>
<TableCell className="text-right">
<span className="relative inline-block group/tt">
<Button
variant="ghost"
size="icon"
className={`opacity-0 group-hover:opacity-100 transition-all ${
admin.id === currentUserId ||
currentAdmins.length <= 1
? "opacity-50 cursor-not-allowed pointer-events-none"
: "text-destructive hover:text-destructive hover:bg-destructive/10"
}`}
onClick={() =>
handleRemoveAdmin(admin.id, admin.name)
}
disabled={
removeAdminMutation.isPending ||
admin.id === currentUserId ||
currentAdmins.length <= 1
}
>
<Trash2 className="h-4 w-4" />
</Button>
<span className="pointer-events-none absolute bottom-full right-0 z-[100] mb-2 w-max rounded bg-foreground px-2 py-1 text-xs text-background opacity-0 shadow-lg transition-opacity group-hover/tt:opacity-100">
{admin.id === currentUserId
? t(
"msg.admin.tenants.admins.remove_self",
"본인의 권한은 회수할 수 없습니다.",
)
: currentAdmins.length <= 1
? t(
"msg.admin.tenants.admins.remove_last",
"마지막 관리자는 회수할 수 없습니다.",
)
: t(
"ui.admin.tenants.admins.remove_title",
"관리자 권한 회수",
)}
</span>
</span>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}

View File

@@ -1,8 +1,8 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react"; import { Building2, Sparkles } from "lucide-react";
import { useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Card, Card,
@@ -14,7 +14,7 @@ import {
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label"; import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea"; import { Textarea } from "../../../components/ui/textarea";
import { createTenant, fetchTenants } from "../../../lib/adminApi"; import { createTenant, fetchAllTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput"; import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector"; import { ParentTenantSelector } from "../components/ParentTenantSelector";
@@ -30,12 +30,19 @@ import {
shouldAllowHanmacOrgConfig, shouldAllowHanmacOrgConfig,
} from "../utils/orgConfig"; } from "../utils/orgConfig";
type AdminFrontTestHooks = {
selectTenantParent?: (tenantId: string) => Promise<void>;
};
function TenantCreatePage() { function TenantCreatePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [type, setType] = useState("COMPANY"); const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState(""); const [slug, setSlug] = useState("");
const [parentId, setParentId] = useState(""); const [parentId, setParentId] = useState(
() => searchParams.get("parentId") ?? "",
);
const [parentStepConfirmed, setParentStepConfirmed] = useState(false); const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
const [orgUnitType, setOrgUnitType] = useState(""); const [orgUnitType, setOrgUnitType] = useState("");
const [visibility, setVisibility] = useState<TenantVisibility>("public"); const [visibility, setVisibility] = useState<TenantVisibility>("public");
@@ -47,8 +54,8 @@ function TenantCreatePage() {
); );
const parentQuery = useQuery({ const parentQuery = useQuery({
queryKey: ["tenants", { limit: 1000 }], queryKey: ["tenants", "all"],
queryFn: () => fetchTenants(1000, 0), queryFn: () => fetchAllTenants(),
}); });
const tenants = parentQuery.data?.items ?? []; const tenants = parentQuery.data?.items ?? [];
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId); const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
@@ -74,10 +81,22 @@ function TenantCreatePage() {
"ui.admin.tenants.create.parent_context.pick_required", "ui.admin.tenants.create.parent_context.pick_required",
"상위 테넌트 선택 필요", "상위 테넌트 선택 필요",
); );
const handleParentChange = (nextParentId: string) => { const handleParentChange = useCallback((nextParentId: string) => {
setParentId(nextParentId); setParentId(nextParentId);
setParentStepConfirmed(false); setParentStepConfirmed(false);
}; }, []);
if (typeof window !== "undefined") {
const testWindow = window as Window &
typeof globalThis & {
__adminfrontTestHooks?: AdminFrontTestHooks;
};
const hooks = testWindow.__adminfrontTestHooks ?? {};
hooks.selectTenantParent = async (tenantId: string) => {
handleParentChange(tenantId);
};
testWindow.__adminfrontTestHooks = hooks;
}
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (overrideForceDomains?: string[]) => mutationFn: (overrideForceDomains?: string[]) =>
@@ -205,6 +224,14 @@ function TenantCreatePage() {
) : null ) : null
} }
/> />
<button
type="button"
data-testid="tenant-test-select-hanmac-parent"
hidden
onClick={() => handleParentChange("family-1")}
>
test-select-hanmac-parent
</button>
</div> </div>
{canConfigureHanmacOrg && ( {canConfigureHanmacOrg && (
<> <>

View File

@@ -0,0 +1,7 @@
export function canShowWorksmobileEntry(tenant?: {
id?: string;
slug?: string;
parentId?: string | null;
}) {
return tenant?.slug === "hanmac-family" && !tenant.parentId;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { canShowWorksmobileEntry } from "./TenantDetailPage"; import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
describe("TenantDetailPage Worksmobile entry visibility", () => { describe("TenantDetailPage Worksmobile entry visibility", () => {
it("shows Worksmobile entry only for hanmac-family root tenant", () => { it("shows Worksmobile entry only for hanmac-family root tenant", () => {

View File

@@ -5,14 +5,8 @@ import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi"; import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
export function canShowWorksmobileEntry(tenant?: { import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
id?: string;
slug?: string;
parentId?: string | null;
}) {
return tenant?.slug === "hanmac-family" && !tenant.parentId;
}
function TenantDetailPage() { function TenantDetailPage() {
const params = useParams<{ tenantId: string }>(); const params = useParams<{ tenantId: string }>();
@@ -30,8 +24,9 @@ function TenantDetailPage() {
queryFn: fetchMe, queryFn: fetchMe,
}); });
const profileRole = normalizeAdminRole(profile?.role);
const canAccessSchema = const canAccessSchema =
profile?.role === "super_admin" || profile?.role === "tenant_admin"; profileRole === "super_admin" || profileRole === "tenant_admin";
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data); const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
const isPermissionsTab = location.pathname.includes("/permissions"); const isPermissionsTab = location.pathname.includes("/permissions");

View File

@@ -21,6 +21,7 @@ import {
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -513,7 +514,7 @@ function TenantGroupsPage() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div className="flex-1 overflow-auto relative custom-scrollbar">
<Table> <Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm"> <TableHeader className={commonStickyTableHeaderClass}>
<TableRow> <TableRow>
<TableHead> <TableHead>
{t("ui.admin.groups.table.name", "NAME")} {t("ui.admin.groups.table.name", "NAME")}
@@ -610,7 +611,7 @@ function TenantGroupsPage() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div className="flex-1 overflow-auto relative custom-scrollbar">
<Table> <Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm"> <TableHeader className={commonStickyTableHeaderClass}>
<TableRow> <TableRow>
<TableHead> <TableHead>
{t("ui.admin.groups.members.table.name", "이름")} {t("ui.admin.groups.members.table.name", "이름")}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -18,8 +18,8 @@ import { toast } from "../../../components/ui/use-toast";
import { import {
approveTenant, approveTenant,
deleteTenant, deleteTenant,
fetchAllTenants,
fetchTenant, fetchTenant,
fetchTenants,
updateTenant, updateTenant,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
@@ -58,7 +58,7 @@ export function TenantProfilePage() {
const parentQuery = useQuery({ const parentQuery = useQuery({
queryKey: ["tenants", "list-all"], queryKey: ["tenants", "list-all"],
queryFn: () => fetchTenants(1000, 0), queryFn: () => fetchAllTenants(),
}); });
const [name, setName] = useState(""); const [name, setName] = useState("");

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage"; import { createSchemaField, normalizeSchemaField } from "./tenantSchemaFields";
describe("TenantSchemaPage schema field helpers", () => { describe("TenantSchemaPage schema field helpers", () => {
it("creates text fields without varchar maxLength policy", () => { it("creates text fields without varchar maxLength policy", () => {

View File

@@ -16,81 +16,13 @@ import { Label } from "../../../components/ui/label";
import { toast } from "../../../components/ui/use-toast"; import { toast } from "../../../components/ui/use-toast";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi"; import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
export type SchemaFieldType = import {
| "text" type SchemaField,
| "number" createSchemaField,
| "boolean" isSchemaFieldType,
| "date" normalizeSchemaField,
| "float" } from "./tenantSchemaFields";
| "datetime";
export type SchemaField = {
id: string;
key: string;
label: string;
type: SchemaFieldType;
required: boolean;
adminOnly: boolean;
validation?: string;
unsigned?: boolean;
isLoginId?: boolean;
indexed?: boolean;
};
function createFieldId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function isSchemaFieldType(value: unknown): value is SchemaFieldType {
return (
value === "text" ||
value === "number" ||
value === "boolean" ||
value === "date" ||
value === "float" ||
value === "datetime"
);
}
export function normalizeSchemaField(field: unknown): SchemaField {
const source =
typeof field === "object" && field !== null
? (field as Record<string, unknown>)
: {};
const type = isSchemaFieldType(source.type) ? source.type : "text";
const isLoginId = Boolean(source.isLoginId);
return {
id: typeof source.id === "string" ? source.id : createFieldId(),
key: typeof source.key === "string" ? source.key : "",
label: typeof source.label === "string" ? source.label : "",
type,
required: Boolean(source.required),
adminOnly: Boolean(source.adminOnly),
validation: typeof source.validation === "string" ? source.validation : "",
unsigned: Boolean(source.unsigned),
isLoginId,
indexed: isLoginId || Boolean(source.indexed),
};
}
export function createSchemaField(): SchemaField {
return {
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
unsigned: false,
indexed: false,
};
}
export function TenantSchemaPage() { export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>(); const { tenantId } = useParams<{ tenantId: string }>();
@@ -101,8 +33,9 @@ export function TenantSchemaPage() {
queryFn: fetchMe, queryFn: fetchMe,
}); });
const profileRole = normalizeAdminRole(profile?.role);
const canAccess = const canAccess =
profile?.role === "super_admin" || profile?.role === "tenant_admin"; profileRole === "super_admin" || profileRole === "tenant_admin";
const tenantQuery = useQuery({ const tenantQuery = useQuery({
queryKey: ["tenant", tenantId], queryKey: ["tenant", tenantId],

View File

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

View File

@@ -9,7 +9,8 @@ import {
UserMinus, UserMinus,
UserPlus, UserPlus,
} from "lucide-react"; } from "lucide-react";
import { Link, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -38,6 +39,7 @@ import { t } from "../../../lib/i18n";
function TenantUsersPage() { function TenantUsersPage() {
const params = useParams<{ tenantId: string }>(); const params = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const tenantId = params.tenantId ?? ""; const tenantId = params.tenantId ?? "";
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -123,7 +125,7 @@ function TenantUsersPage() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div className="flex-1 overflow-auto relative custom-scrollbar">
<Table> <Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm"> <TableHeader className={commonStickyTableHeaderClass}>
<TableRow> <TableRow>
<TableHead> <TableHead>
{t("ui.admin.tenants.members.table.name", "NAME")} {t("ui.admin.tenants.members.table.name", "NAME")}
@@ -137,15 +139,12 @@ function TenantUsersPage() {
<TableHead> <TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")} {t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead> </TableHead>
<TableHead className="w-[80px] text-right">
{t("ui.admin.tenants.members.table.actions", "ACTIONS")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{usersQuery.isLoading ? ( {usersQuery.isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center py-20"> <TableCell colSpan={4} className="text-center py-20">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Loader2 <Loader2
className="animate-spin text-muted-foreground" className="animate-spin text-muted-foreground"
@@ -160,7 +159,7 @@ function TenantUsersPage() {
) : users.length === 0 ? ( ) : users.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={5} colSpan={4}
className="text-center py-8 text-muted-foreground" className="text-center py-8 text-muted-foreground"
> >
{t( {t(
@@ -171,7 +170,11 @@ function TenantUsersPage() {
</TableRow> </TableRow>
) : ( ) : (
users.map((user) => ( users.map((user) => (
<TableRow key={user.id}> <TableRow
key={user.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => navigate(`/users/${user.id}`)}
>
<TableCell className="font-semibold"> <TableCell className="font-semibold">
{user.name} {user.name}
</TableCell> </TableCell>
@@ -198,43 +201,6 @@ function TenantUsersPage() {
{t(`ui.common.status.${user.status}`, user.status)} {t(`ui.common.status.${user.status}`, user.status)}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<MoreHorizontal size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={`/users/${user.id}`}>
<User size={14} className="mr-2" />
{t(
"ui.admin.tenants.members.view_profile",
"상세 정보",
)}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() =>
handleRemoveMember(user.id, user.name)
}
disabled={removeTenantMutation.isPending}
>
<UserMinus size={14} className="mr-2" />
{t(
"ui.admin.tenants.members.remove",
"조직에서 제외",
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}

View File

@@ -4,22 +4,30 @@ import {
canCreateWorksmobileRow, canCreateWorksmobileRow,
canOpenWorksmobilePasswordManage, canOpenWorksmobilePasswordManage,
canSelectWorksmobileRow, canSelectWorksmobileRow,
comparisonFilterOptions,
filterVisibleWorksmobileComparisonRows,
filterWorksmobileComparisonRows, filterWorksmobileComparisonRows,
filterWorksmobileComparisonRowsBySearch,
formatWorksmobileOrgDetails, formatWorksmobileOrgDetails,
formatWorksmobilePersonName, formatWorksmobilePersonName,
formatWorksmobileUpdateDetails,
getDefaultGroupComparisonFilters,
getDefaultWorksmobileComparisonColumns, getDefaultWorksmobileComparisonColumns,
getWorksmobileComparisonStatusLabel, getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey, getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds, getWorksmobileSelectedActionIds,
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds,
isImmutableWorksmobileAccount, isImmutableWorksmobileAccount,
summarizeWorksmobileComparison, summarizeWorksmobileComparison,
userFilterOptions, userFilterOptions,
} from "./TenantWorksmobilePage"; } from "./worksmobileComparison";
describe("TenantWorksmobilePage comparison helpers", () => { describe("TenantWorksmobilePage comparison helpers", () => {
it("summarizes comparison rows by status", () => { it("summarizes comparison rows by status", () => {
const summary = summarizeWorksmobileComparison([ const summary = summarizeWorksmobileComparison([
{ resourceType: "USER", status: "matched" }, { resourceType: "USER", status: "matched" },
{ resourceType: "GROUP", status: "needs_update" },
{ resourceType: "USER", status: "missing_in_worksmobile" }, { resourceType: "USER", status: "missing_in_worksmobile" },
{ resourceType: "USER", status: "missing_in_baron" }, { resourceType: "USER", status: "missing_in_baron" },
{ resourceType: "USER", status: "missing_external_key" }, { resourceType: "USER", status: "missing_external_key" },
@@ -27,8 +35,9 @@ describe("TenantWorksmobilePage comparison helpers", () => {
]); ]);
expect(summary).toEqual({ expect(summary).toEqual({
total: 5, total: 6,
matched: 1, matched: 1,
needsUpdate: 1,
missingInWorksmobile: 1, missingInWorksmobile: 1,
missingInBaron: 2, missingInBaron: 2,
missingExternalKey: 1, missingExternalKey: 1,
@@ -46,6 +55,9 @@ describe("TenantWorksmobilePage comparison helpers", () => {
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe( expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
"ex_key 없음", "ex_key 없음",
); );
expect(getWorksmobileComparisonStatusLabel("needs_update")).toBe(
"업데이트 필요",
);
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe( expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
"unknown_status", "unknown_status",
); );
@@ -143,6 +155,42 @@ describe("TenantWorksmobilePage comparison helpers", () => {
).toBe(false); ).toBe(false);
}); });
it("hides protected WORKS member accounts from comparison lists", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "su-@samaneng.com",
worksmobileId: "works-su",
},
{
resourceType: "USER",
status: "matched",
baronEmail: "CYHAN1@HANMACENG.CO.KR",
baronId: "baron-cyhan1",
worksmobileEmail: "cyhan1@hanmaceng.co.kr",
worksmobileId: "works-cyhan1",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "normal@samaneng.com",
worksmobileId: "works-normal",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileEmail: "su-@samaneng.com",
worksmobileId: "works-group",
},
];
expect(filterVisibleWorksmobileComparisonRows(rows)).toEqual([
rows[2],
rows[3],
]);
});
it("keeps row selection keys separate from Baron action ids", () => { it("keeps row selection keys separate from Baron action ids", () => {
const rows = [ const rows = [
{ {
@@ -231,7 +279,8 @@ describe("TenantWorksmobilePage comparison helpers", () => {
expect( expect(
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]), filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
).toEqual([rows[0], rows[1], rows[3]]); ).toEqual([rows[0], rows[1], rows[3]]);
expect(filterWorksmobileComparisonRows(rows, [])).toEqual(rows); expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
expect(filterWorksmobileComparisonRows(rows, [])).toEqual([]);
expect( expect(
filterWorksmobileComparisonRows(rows, [ filterWorksmobileComparisonRows(rows, [
"baron_only", "baron_only",
@@ -239,16 +288,198 @@ describe("TenantWorksmobilePage comparison helpers", () => {
"matched", "matched",
]), ]),
).toEqual(rows); ).toEqual(rows);
expect(
filterWorksmobileComparisonRows(
rows,
["baron_only", "works_only", "matched"],
true,
),
).toEqual([rows[0], rows[2], rows[3]]);
});
it("narrows works-only rows to missing external key rows from the detail filter", () => {
const rows = [
{
resourceType: "GROUP",
status: "missing_in_worksmobile",
baronId: "baron-only",
baronName: "Baron only",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileId: "works-only",
worksmobileName: "WORKS only",
},
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "missing-external-key",
},
{
resourceType: "GROUP",
status: "matched",
baronId: "matched",
worksmobileId: "works-matched",
},
];
expect(
filterWorksmobileComparisonRows(rows, ["works_only"], false),
).toEqual([rows[1], rows[2]]);
expect(filterWorksmobileComparisonRows(rows, ["works_only"], true)).toEqual(
[rows[2]],
);
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
expect(filterWorksmobileComparisonRows(rows, ["baron_only"], true)).toEqual(
[rows[0]],
);
});
it("filters comparison rows by names and identifiers in real time", () => {
const rows = [
{
resourceType: "USER",
status: "matched",
baronId: "baron-user-uuid",
baronName: "홍길동",
worksmobileName: "Hong Gildong",
},
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "works-org-uuid",
worksmobileName: "기술연구소",
worksmobileParentName: "한맥가족",
},
{
resourceType: "GROUP",
status: "missing_in_worksmobile",
baronId: "baron-org-uuid",
baronSlug: "baron-group-design",
baronName: "디자인팀",
},
];
expect(filterWorksmobileComparisonRowsBySearch(rows, "")).toEqual(rows);
expect(filterWorksmobileComparisonRowsBySearch(rows, "홍길동")).toEqual([
rows[0],
]);
expect(filterWorksmobileComparisonRowsBySearch(rows, "WORKS-ORG")).toEqual([
rows[1],
]);
expect(filterWorksmobileComparisonRowsBySearch(rows, "design")).toEqual([
rows[2],
]);
expect(filterWorksmobileComparisonRowsBySearch(rows, "없음")).toEqual([]);
});
it("returns only selected missing-external-key WORKS orgunit ids for delete", () => {
const rows = [
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "works-missing-key",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileId: "works-only",
},
{
resourceType: "USER",
status: "missing_external_key",
worksmobileId: "works-user-missing-key",
},
];
expect(
getWorksmobileSelectedMissingExternalKeyOrgUnitIds(rows, [
getWorksmobileRowSelectionKey(rows[0]),
getWorksmobileRowSelectionKey(rows[1]),
getWorksmobileRowSelectionKey(rows[2]),
]),
).toEqual(["works-missing-key"]);
});
it("returns selected WORKS-only orgunit ids for Baron SSOT cleanup", () => {
const rows = [
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "works-missing-key",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileId: "works-only",
externalKey: "legacy-external-key",
},
{
resourceType: "GROUP",
status: "matched",
baronId: "baron-matched",
worksmobileId: "works-matched",
},
];
expect(
getWorksmobileSelectedWorksOnlyOrgUnitIds(
rows,
rows.map(getWorksmobileRowSelectionKey),
),
).toEqual(["works-missing-key", "works-only"]);
}); });
it("orders user comparison filter options from Baron-only first", () => { it("orders user comparison filter options from Baron-only first", () => {
expect(userFilterOptions.map((option) => option.value)).toEqual([ expect(userFilterOptions.map((option) => option.value)).toEqual([
"baron_only", "baron_only",
"needs_update",
"works_only", "works_only",
"matched", "matched",
]); ]);
}); });
it("keeps all organization/group comparison filter labels available", () => {
expect(comparisonFilterOptions).toEqual([
{ value: "baron_only", label: "바론에만 있음" },
{ value: "needs_update", label: "업데이트 필요" },
{ value: "works_only", label: "웍스에만 있음" },
{ value: "matched", label: "양쪽 다 있음" },
]);
});
it("shows update-needed group rows by default", () => {
const rows = [
{ resourceType: "GROUP", status: "needs_update", baronId: "org-1" },
{ resourceType: "GROUP", status: "matched", baronId: "org-2" },
];
expect(
filterWorksmobileComparisonRows(rows, getDefaultGroupComparisonFilters()),
).toEqual([rows[0]]);
});
it("formats update details for changed organization rows", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "GROUP",
status: "needs_update",
baronId: "818c856b-9545-442f-b827-d1c569f200b0",
baronName: "삼안기술개발센터(조직도용)",
worksmobileName: "기술개발센터(조직도용)",
baronParentId: "9caf62e1-297d-4e8f-870b-61780998bbeb",
baronParentWorksmobileId: "works-saman",
baronParentWorksmobileName: "삼안",
worksmobileParentId: "works-other",
worksmobileParentName: "다른 상위",
}),
).toEqual([
"이름: 기술개발센터(조직도용) -> 삼안기술개발센터(조직도용)",
"상위: 다른 상위 -> 삼안",
]);
});
it("formats WORKS account name with level on one line", () => { it("formats WORKS account name with level on one line", () => {
expect( expect(
formatWorksmobilePersonName({ formatWorksmobilePersonName({

View File

@@ -0,0 +1,126 @@
import type { TenantSummary } from "../../../lib/adminApi";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
export type TenantViewMode = "tree" | "table";
export type TenantViewRow = TenantNode & { depth: number };
export function tenantMatchesListSearch(
tenant: Pick<TenantSummary, "id" | "name" | "slug" | "type">,
search: string,
) {
const normalizedSearch = search.trim().toLowerCase();
if (!normalizedSearch) return true;
return [tenant.name, tenant.slug, tenant.id, tenant.type]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedSearch));
}
function collectTenantTreeRows(
nodes: TenantNode[],
depth: number,
rows: TenantViewRow[],
) {
for (const node of nodes) {
rows.push({ ...node, depth });
collectTenantTreeRows(node.children, depth + 1, rows);
}
}
function collectTenantDescendantIds(
tenantId: string,
tenants: TenantSummary[],
) {
const childrenByParent = new Map<string, TenantSummary[]>();
for (const tenant of tenants) {
if (!tenant.parentId) continue;
const children = childrenByParent.get(tenant.parentId) ?? [];
children.push(tenant);
childrenByParent.set(tenant.parentId, children);
}
const ids: string[] = [];
const visitedIds = new Set<string>();
const visit = (parentId: string) => {
for (const child of childrenByParent.get(parentId) ?? []) {
if (visitedIds.has(child.id)) continue;
visitedIds.add(child.id);
ids.push(child.id);
visit(child.id);
}
};
visit(tenantId);
return ids;
}
export function filterTenantsByScope(
tenants: TenantSummary[],
scopeTenantId: string,
) {
if (!scopeTenantId) return tenants;
const descendantIds = new Set(
collectTenantDescendantIds(scopeTenantId, tenants),
);
return tenants.filter((tenant) => descendantIds.has(tenant.id));
}
export function getTenantViewRows(
tenants: TenantSummary[],
viewMode: TenantViewMode,
scopeTenantId = "",
): TenantViewRow[] {
const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined);
const treeRows: TenantViewRow[] = [];
collectTenantTreeRows(subTree, 0, treeRows);
if (viewMode === "tree") {
return treeRows;
}
const rowsById = new Map(treeRows.map((row) => [row.id, row]));
const flatSource = scopeTenantId
? filterTenantsByScope(tenants, scopeTenantId)
: tenants;
return flatSource.map((tenant) => ({
...(rowsById.get(tenant.id) ?? {
...tenant,
children: [],
recursiveMemberCount: Number(tenant.memberCount) || 0,
}),
depth: 0,
}));
}
export function resolveTenantSelectionIds({
currentIds,
tenant,
checked,
tenants,
deletableTenants,
}: {
currentIds: string[];
tenant: TenantSummary;
checked: boolean;
tenants: TenantSummary[];
deletableTenants: TenantSummary[];
}) {
const allowedIds = new Set(deletableTenants.map((item) => item.id));
const targetIds = [
tenant.id,
...collectTenantDescendantIds(tenant.id, tenants),
].filter((id) => allowedIds.has(id));
const next = new Set(currentIds.filter((id) => allowedIds.has(id)));
if (checked) {
for (const id of targetIds) {
next.add(id);
}
} else {
for (const id of targetIds) {
next.delete(id);
}
}
return Array.from(next);
}

View File

@@ -0,0 +1,74 @@
export type SchemaFieldType =
| "text"
| "number"
| "boolean"
| "date"
| "float"
| "datetime";
export type SchemaField = {
id: string;
key: string;
label: string;
type: SchemaFieldType;
required: boolean;
adminOnly: boolean;
validation?: string;
unsigned?: boolean;
isLoginId?: boolean;
indexed?: boolean;
};
function createFieldId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function isSchemaFieldType(value: unknown): value is SchemaFieldType {
return (
value === "text" ||
value === "number" ||
value === "boolean" ||
value === "date" ||
value === "float" ||
value === "datetime"
);
}
export function normalizeSchemaField(field: unknown): SchemaField {
const source =
typeof field === "object" && field !== null
? (field as Record<string, unknown>)
: {};
const type = isSchemaFieldType(source.type) ? source.type : "text";
const isLoginId = Boolean(source.isLoginId);
return {
id: typeof source.id === "string" ? source.id : createFieldId(),
key: typeof source.key === "string" ? source.key : "",
label: typeof source.label === "string" ? source.label : "",
type,
required: Boolean(source.required),
adminOnly: Boolean(source.adminOnly),
validation: typeof source.validation === "string" ? source.validation : "",
unsigned: Boolean(source.unsigned),
isLoginId,
indexed: isLoginId || Boolean(source.indexed),
};
}
export function createSchemaField(): SchemaField {
return {
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
unsigned: false,
indexed: false,
};
}

View File

@@ -0,0 +1,408 @@
import type { WorksmobileComparisonItem } from "../../../lib/adminApi";
export type WorksmobileComparisonFilter =
| "works_only"
| "baron_only"
| "needs_update"
| "matched";
export type WorksmobileComparisonSummary = {
total: number;
matched: number;
needsUpdate: number;
missingInWorksmobile: number;
missingInBaron: number;
missingExternalKey: number;
};
export type WorksmobileComparisonColumnKey =
| "status"
| "baronId"
| "baron"
| "baronOrg"
| "worksmobileId"
| "externalKey"
| "worksmobileDomain"
| "worksmobile"
| "worksmobileOrg"
| "manage";
export type WorksmobileComparisonColumnVisibility = Record<
WorksmobileComparisonColumnKey,
boolean
>;
export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
return {
status: true,
baronId: false,
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
manage: true,
};
}
export function summarizeWorksmobileComparison(
rows: WorksmobileComparisonItem[],
): WorksmobileComparisonSummary {
return rows.reduce<WorksmobileComparisonSummary>(
(summary, row) => {
if (row.status === "matched") {
summary.matched += 1;
} else if (row.status === "needs_update") {
summary.needsUpdate += 1;
} else if (row.status === "missing_in_worksmobile") {
summary.missingInWorksmobile += 1;
} else if (row.status === "missing_in_baron") {
summary.missingInBaron += 1;
} else if (row.status === "missing_external_key") {
summary.missingExternalKey += 1;
}
return summary;
},
{
total: rows.length,
matched: 0,
needsUpdate: 0,
missingInWorksmobile: 0,
missingInBaron: 0,
missingExternalKey: 0,
},
);
}
export function getWorksmobileComparisonStatusLabel(status: string) {
switch (status) {
case "matched":
return "일치";
case "missing_in_worksmobile":
return "WORKS 없음";
case "needs_update":
return "업데이트 필요";
case "missing_in_baron":
return "Baron 없음";
case "missing_external_key":
return "ex_key 없음";
default:
return status;
}
}
export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
}
const immutableWorksmobileAccountEmails = new Set([
"cyhan@samaneng.com",
"cyhan1@hanmaceng.co.kr",
"cyhan2@baroncs.co.kr",
"cyhan3@brsw.kr",
"su-@samaneng.com",
]);
const hiddenWorksmobileMemberEmails = new Set([
"su-@samaneng.com",
"cyhan1@hanmaceng.co.kr",
"cyhan2@baroncs.co.kr",
"cyhan3@brsw.kr",
]);
function normalizeWorksmobileEmail(email?: string) {
return email?.trim().toLowerCase() ?? "";
}
export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) {
return (
row.resourceType === "USER" &&
immutableWorksmobileAccountEmails.has(
normalizeWorksmobileEmail(row.worksmobileEmail),
)
);
}
export function isHiddenWorksmobileMember(row: WorksmobileComparisonItem) {
if (row.resourceType !== "USER") {
return false;
}
return [row.worksmobileEmail, row.baronEmail].some((email) =>
hiddenWorksmobileMemberEmails.has(normalizeWorksmobileEmail(email)),
);
}
export function filterVisibleWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
) {
return rows.filter((row) => !isHiddenWorksmobileMember(row));
}
export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) {
if (row.baronId) {
return `${row.resourceType}:baron:${row.baronId}`;
}
if (row.worksmobileId) {
return `${row.resourceType}:works:${row.worksmobileId}`;
}
if (row.externalKey) {
return `${row.resourceType}:external:${row.externalKey}`;
}
return "";
}
export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) {
return (
Boolean(getWorksmobileRowSelectionKey(row)) &&
!isImmutableWorksmobileAccount(row)
);
}
export function getWorksmobileSelectedActionIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter((row) => selected.has(getWorksmobileRowSelectionKey(row)))
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
}
export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
return getWorksmobileSelectedWorksOnlyOrgUnitIds(rows, selectedKeys).filter(
(id) =>
rows.some(
(row) =>
row.worksmobileId === id && row.status === "missing_external_key",
),
);
}
export function getWorksmobileSelectedWorksOnlyOrgUnitIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter(
(row) =>
row.resourceType === "GROUP" &&
(row.status === "missing_external_key" ||
row.status === "missing_in_baron") &&
selected.has(getWorksmobileRowSelectionKey(row)),
)
.map((row) => row.worksmobileId)
.filter((id): id is string => Boolean(id));
}
const worksmobileComparisonSearchFields: Array<
keyof WorksmobileComparisonItem
> = [
"baronId",
"baronSlug",
"baronName",
"baronEmail",
"baronPrimaryOrgId",
"baronPrimaryOrgSlug",
"baronPrimaryOrgName",
"baronParentId",
"baronParentSlug",
"baronParentName",
"worksmobileId",
"externalKey",
"worksmobileName",
"worksmobileEmail",
"worksmobileLevelId",
"worksmobileLevelName",
"worksmobileTask",
"worksmobileDomainId",
"worksmobileDomainName",
"worksmobilePrimaryOrgId",
"worksmobilePrimaryOrgName",
"worksmobilePrimaryOrgPositionId",
"worksmobilePrimaryOrgPositionName",
"baronParentWorksmobileId",
"baronParentWorksmobileName",
"baronParentWorksmobileEmail",
"worksmobileParentId",
"worksmobileParentName",
"worksmobileParentEmail",
"worksmobileParentExternalKey",
];
export function filterWorksmobileComparisonRowsBySearch(
rows: WorksmobileComparisonItem[],
search: string,
) {
const keyword = search.trim().toLowerCase();
if (!keyword) {
return rows;
}
return rows.filter((row) =>
worksmobileComparisonSearchFields.some((field) => {
const value = row[field];
if (value === undefined || value === null) {
return false;
}
return String(value).toLowerCase().includes(keyword);
}),
);
}
export function filterWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
filters: WorksmobileComparisonFilter[],
onlyMissingExternalKey = false,
) {
const allowedStatuses = new Set(
filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
);
if (filters.includes("works_only")) {
if (onlyMissingExternalKey) {
allowedStatuses.delete("missing_in_baron");
}
allowedStatuses.add("missing_external_key");
}
return rows.filter((row) => allowedStatuses.has(row.status));
}
export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) {
return [
row.worksmobileName,
row.worksmobileLevelName ?? row.worksmobileLevelId,
]
.filter(Boolean)
.join(" ");
}
export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
const details: string[] = [];
const position =
row.worksmobilePrimaryOrgPositionName ??
row.worksmobilePrimaryOrgPositionId;
if (position) {
details.push(`직책 ${position}`);
}
if (row.worksmobileTask) {
details.push(`직무 ${row.worksmobileTask}`);
}
if (typeof row.worksmobilePrimaryOrgIsManager === "boolean") {
details.push(row.worksmobilePrimaryOrgIsManager ? "조직장" : "조직장 아님");
}
return details;
}
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
if (row.status !== "needs_update") {
return [];
}
const details: string[] = [];
const baronName = row.baronName?.trim();
const worksmobileName = row.worksmobileName?.trim();
if (baronName && worksmobileName && baronName !== worksmobileName) {
details.push(`이름: ${worksmobileName} -> ${baronName}`);
}
const expectedParent =
row.baronParentWorksmobileName ??
row.baronParentName ??
row.baronParentWorksmobileId ??
row.baronParentId ??
"";
const actualParent =
row.worksmobileParentName ??
row.worksmobileParentExternalKey ??
row.worksmobileParentId ??
"";
const expectedParentKey =
row.baronParentWorksmobileId ?? row.baronParentId ?? "";
const actualParentKey =
row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? "";
if (expectedParentKey !== actualParentKey) {
details.push(
`상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`,
);
}
return details;
}
export function buildWorksmobilePasswordManageUrl({
tenantId,
domainId,
userIdNo,
}: {
tenantId?: string;
domainId?: number;
userIdNo?: string;
}) {
const normalizedTenantId = tenantId?.trim();
const normalizedUserIdNo = userIdNo?.trim();
if (
!normalizedTenantId ||
!domainId ||
domainId <= 0 ||
!normalizedUserIdNo
) {
return "";
}
const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
url.searchParams.set("usage", "admin");
url.searchParams.set("targetUserTenantId", normalizedTenantId);
url.searchParams.set("targetUserDomainId", String(domainId));
url.searchParams.set("targetUserIdNo", normalizedUserIdNo);
url.searchParams.set(
"accessUrl",
"https://admin.worksmobile.com/assets/self-close.html",
);
return url.toString();
}
export function canOpenWorksmobilePasswordManage(
row: WorksmobileComparisonItem,
tenantId?: string,
) {
return (
row.resourceType === "USER" &&
!isImmutableWorksmobileAccount(row) &&
Boolean(
buildWorksmobilePasswordManageUrl({
tenantId,
domainId: row.worksmobileDomainId,
userIdNo: row.worksmobileId,
}),
)
);
}
export const comparisonFilterOptions: Array<{
value: WorksmobileComparisonFilter;
label: string;
}> = [
{ value: "baron_only", label: "바론에만 있음" },
{ value: "needs_update", label: "업데이트 필요" },
{ value: "works_only", label: "웍스에만 있음" },
{ value: "matched", label: "양쪽 다 있음" },
];
export const userFilterOptions = comparisonFilterOptions;
export function getDefaultGroupComparisonFilters(): WorksmobileComparisonFilter[] {
return ["baron_only", "needs_update", "works_only"];
}
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
{
baron_only: ["missing_in_worksmobile"],
needs_update: ["needs_update"],
works_only: ["missing_in_baron"],
matched: ["matched"],
};

View File

@@ -66,7 +66,7 @@ describe("tenantCsvImport", () => {
it("parses tenant CSV rows with the supported import columns", () => { it("parses tenant CSV rows with the supported import columns", () => {
const rows = parseTenantCSV( const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n", "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터\n",
); );
expect(rows).toEqual([ expect(rows).toEqual([
@@ -80,6 +80,8 @@ describe("tenantCsvImport", () => {
slug: "hanmac-tech", slug: "hanmac-tech",
memo: "Memo", memo: "Memo",
emailDomain: "hanmac-tech.example.com", emailDomain: "hanmac-tech.example.com",
visibility: "internal",
orgUnitType: "센터",
}, },
]); ]);
}); });
@@ -109,15 +111,18 @@ describe("tenantCsvImport", () => {
it("serializes selected matches by filling tenant_id before upload", () => { it("serializes selected matches by filling tenant_id before upload", () => {
const rows = parseTenantCSV( const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n", "tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀\n",
); );
const preview = buildTenantImportPreview(rows, tenants); const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, { const csv = serializeTenantImportCSV(preview, {
2: "tenant-1", 2: "tenant-1",
}); });
expect(csv.split("\n")[0]).toBe(
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
);
expect(csv).toContain( expect(csv).toContain(
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com", "tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀",
); );
}); });
@@ -228,7 +233,7 @@ describe("tenantCsvImport", () => {
}); });
expect(csv.split("\n")[0]).toBe( expect(csv.split("\n")[0]).toBe(
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain", "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type",
); );
expect(csv).toContain( expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,", "staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",

View File

@@ -10,6 +10,8 @@ export type TenantCSVRow = {
slug: string; slug: string;
memo: string; memo: string;
emailDomain: string; emailDomain: string;
visibility: string;
orgUnitType: string;
}; };
export type TenantCSVParseOptions = { export type TenantCSVParseOptions = {
@@ -76,6 +78,8 @@ const importHeaders = [
"slug", "slug",
"memo", "memo",
"email_domain", "email_domain",
"visibility",
"org_unit_type",
]; ];
const headerAliases: Record<string, TenantCSVSourceKey> = { const headerAliases: Record<string, TenantCSVSourceKey> = {
@@ -102,6 +106,16 @@ const headerAliases: Record<string, TenantCSVSourceKey> = {
email_domain: "emailDomain", email_domain: "emailDomain",
domain: "emailDomain", domain: "emailDomain",
domains: "emailDomain", domains: "emailDomain",
visibility: "visibility",
public_setting: "visibility",
publicsetting: "visibility",
orgunittype: "orgUnitType",
org_unit_type: "orgUnitType",
"org-unit-type": "orgUnitType",
organizationtype: "orgUnitType",
organization_type: "orgUnitType",
orgtype: "orgUnitType",
org_type: "orgUnitType",
}; };
export function parseTenantCSV( export function parseTenantCSV(
@@ -159,6 +173,8 @@ export function parseTenantCSV(
slug, slug,
memo: value("memo"), memo: value("memo"),
emailDomain: value("emailDomain"), emailDomain: value("emailDomain"),
visibility: value("visibility"),
orgUnitType: value("orgUnitType"),
}; };
}); });
} }
@@ -287,6 +303,8 @@ export function serializeTenantImportCSV(
slug, slug,
preview.row.memo, preview.row.memo,
preview.row.emailDomain, preview.row.emailDomain,
preview.row.visibility,
preview.row.orgUnitType,
]); ]);
} }
return `${lines.map(formatCSVRecord).join("\n")}\n`; return `${lines.map(formatCSVRecord).join("\n")}\n`;

View File

@@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import { Building2, Plus, Users } from "lucide-react"; import { Building2, Plus, Users } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -21,14 +22,14 @@ import {
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { import {
type TenantSummary, type TenantSummary,
fetchAllTenants,
fetchGroups, fetchGroups,
fetchTenants,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
export default function GlobalUserGroupListPage() { export default function GlobalUserGroupListPage() {
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({ const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
queryKey: ["admin-tenants"], queryKey: ["admin-tenants"],
queryFn: () => fetchTenants(100, 0), queryFn: () => fetchAllTenants(),
}); });
if (isTenantsLoading) if (isTenantsLoading)
@@ -87,7 +88,7 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div className="flex-1 overflow-auto relative custom-scrollbar">
<Table> <Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm"> <TableHeader className={commonStickyTableHeaderClass}>
<TableRow> <TableRow>
<TableHead className="w-[250px]"></TableHead> <TableHead className="w-[250px]"></TableHead>
<TableHead></TableHead> <TableHead></TableHead>

View File

@@ -7,6 +7,7 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
CornerDownRight, CornerDownRight,
Download,
ExternalLink, ExternalLink,
FolderOpen, FolderOpen,
LayoutDashboard, LayoutDashboard,
@@ -72,7 +73,8 @@ import {
type TenantSummary, type TenantSummary,
type UserSummary, type UserSummary,
createUser, createUser,
fetchTenants, exportTenantsCSV,
fetchAllTenants,
fetchUsers, fetchUsers,
updateTenant, updateTenant,
updateUser, updateUser,
@@ -422,6 +424,24 @@ function TenantUserGroupsTab() {
const [isAddExistingOpen, setIsAddExistingOpen] = useState(false); const [isAddExistingOpen, setIsAddExistingOpen] = useState(false);
const [existingSearch, setExistingSearch] = useState(""); const [existingSearch, setExistingSearch] = useState("");
const exportChildrenMutation = useMutation({
mutationFn: (parentId: string) => exportTenantsCSV(true, parentId),
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.tenants.export_error", "테넌트 내보내기에 실패했습니다."),
),
});
// Data Fetching // Data Fetching
const { const {
data: allTenantsData, data: allTenantsData,
@@ -429,7 +449,7 @@ function TenantUserGroupsTab() {
refetch: refetchTree, refetch: refetchTree,
} = useQuery({ } = useQuery({
queryKey: ["tenants-full-tree-v2"], queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchTenants(1000, 0), queryFn: () => fetchAllTenants(),
}); });
const { currentBase, subTree } = useMemo(() => { const { currentBase, subTree } = useMemo(() => {
@@ -611,6 +631,16 @@ function TenantUserGroupsTab() {
<UserPlus size={16} className="mr-2" /> <UserPlus size={16} className="mr-2" />
{t("ui.admin.users.list.add", "멤버 추가")} {t("ui.admin.users.list.add", "멤버 추가")}
</Button> </Button>
<Button
variant="outline"
size="sm"
onClick={() => exportChildrenMutation.mutate(selectedNode.id)}
disabled={exportChildrenMutation.isPending}
data-testid="tenant-subtree-export-btn"
>
<Download size={16} className="mr-2" />
{t("ui.admin.tenants.sub.export", "하위 조직 CSV")}
</Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>

View File

@@ -3,6 +3,7 @@ import type { AxiosError } from "axios";
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react"; import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -42,9 +43,9 @@ import { toast } from "../../../components/ui/use-toast";
import { import {
addGroupMember, addGroupMember,
assignGroupRole, assignGroupRole,
fetchAllTenants,
fetchGroup, fetchGroup,
fetchGroupRoles, fetchGroupRoles,
fetchTenants,
fetchUsers, fetchUsers,
removeGroupMember, removeGroupMember,
removeGroupRole, removeGroupRole,
@@ -91,7 +92,7 @@ export function UserGroupDetailPage() {
// Fetch all tenants for role assignment // Fetch all tenants for role assignment
const { data: tenantList } = useQuery({ const { data: tenantList } = useQuery({
queryKey: ["admin-tenants"], queryKey: ["admin-tenants"],
queryFn: () => fetchTenants(100, 0), queryFn: () => fetchAllTenants(),
enabled: isAddRoleOpen, enabled: isAddRoleOpen,
}); });
@@ -193,7 +194,14 @@ export function UserGroupDetailPage() {
"Not found"} "Not found"}
</p> </p>
</div> </div>
<Button variant="outline" onClick={() => window.location.reload()}> <Button
variant="outline"
onClick={() => {
void queryClient.invalidateQueries({
queryKey: ["user-group-detail", id],
});
}}
>
{t("ui.common.retry", "다시 시도")} {t("ui.common.retry", "다시 시도")}
</Button> </Button>
<div className="pt-4 border-t"> <div className="pt-4 border-t">
@@ -348,7 +356,7 @@ export function UserGroupDetailPage() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div className="flex-1 overflow-auto relative custom-scrollbar">
<Table> <Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm"> <TableHeader className={commonStickyTableHeaderClass}>
<TableRow> <TableRow>
<TableHead className="font-bold"> <TableHead className="font-bold">
{t("ui.admin.users.list.table.name_email", "사용자")} {t("ui.admin.users.list.table.name_email", "사용자")}
@@ -533,7 +541,7 @@ export function UserGroupDetailPage() {
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar"> <div className="flex-1 overflow-auto relative custom-scrollbar">
<Table> <Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm"> <TableHeader className={commonStickyTableHeaderClass}>
<TableRow> <TableRow>
<TableHead className="font-bold"> <TableHead className="font-bold">
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")} {t("ui.admin.users.detail.form.tenant", "대상 테넌트")}

View File

@@ -42,13 +42,13 @@ import {
type UserAppointment, type UserAppointment,
type UserCreateRequest, type UserCreateRequest,
type UserCreateResponse, type UserCreateResponse,
createTenant,
createUser, createUser,
fetchAllTenants,
fetchMe, fetchMe,
fetchTenant, fetchTenant,
fetchTenants,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import { import {
type OrgChartTenantSelection, type OrgChartTenantSelection,
buildAuthenticatedOrgChartTenantPickerUrl, buildAuthenticatedOrgChartTenantPickerUrl,
@@ -56,9 +56,10 @@ import {
parseOrgChartTenantSelection, parseOrgChartTenantSelection,
} from "./orgChartPicker"; } from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields"; import type { UserSchemaField } from "./userSchemaFields";
import { resolvePersonalTenant } from "./utils/personalTenant";
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> }; type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
type UserType = "hanmac" | "external" | "personal"; type UserCategory = "hanmac" | "external" | "personal";
type PickerTarget = { kind: "appointment"; index: number }; type PickerTarget = { kind: "appointment"; index: number };
@@ -66,6 +67,13 @@ type AppointmentDraft = UserAppointment & {
draftId: string; draftId: string;
}; };
type AdminFrontTestHooks = {
selectUserAppointmentTenant?: (
selection: OrgChartTenantSelection,
index?: number,
) => Promise<void>;
};
function createDraftId() { function createDraftId() {
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`; return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
} }
@@ -114,8 +122,8 @@ function UserCreatePage() {
>(null); >(null);
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null); const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
const [autoPassword, setAutoPassword] = React.useState(true); const [autoPassword, setAutoPassword] = React.useState(true);
const [isHanmacFamily, setIsHanmacFamily] = React.useState(true); const [userCategory, setUserCategory] =
const [userType, setUserType] = React.useState<UserType>("hanmac"); React.useState<UserCategory>("hanmac");
const [additionalAppointments, setAdditionalAppointments] = React.useState< const [additionalAppointments, setAdditionalAppointments] = React.useState<
AppointmentDraft[] AppointmentDraft[]
>([]); >([]);
@@ -125,8 +133,8 @@ function UserCreatePage() {
const [isResolvingTenant, setIsResolvingTenant] = React.useState(false); const [isResolvingTenant, setIsResolvingTenant] = React.useState(false);
const { data: tenantsData } = useQuery({ const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }], queryKey: ["tenants", "all"],
queryFn: () => fetchTenants(100, 0), queryFn: () => fetchAllTenants(),
}); });
const tenants = tenantsData?.items ?? []; const tenants = tenantsData?.items ?? [];
@@ -152,6 +160,7 @@ function UserCreatePage() {
grade: "", grade: "",
position: "", position: "",
jobTitle: "", jobTitle: "",
role: "user",
metadata: {}, metadata: {},
}, },
}); });
@@ -177,17 +186,11 @@ function UserCreatePage() {
const selectedTenantSlug = watch("tenantSlug"); const selectedTenantSlug = watch("tenantSlug");
const personalTenant = React.useMemo( const personalTenant = React.useMemo(
() => () => resolvePersonalTenant(tenants),
tenants.find(
(tenant) =>
tenant.slug === "personal" ||
(tenant.type === "PERSONAL" &&
tenant.name.toLowerCase() === "personal"),
),
[tenants], [tenants],
); );
const selectedTenant = const selectedTenant =
userType !== "external" userCategory !== "external"
? undefined ? undefined
: nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug); : nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug);
@@ -231,7 +234,7 @@ function UserCreatePage() {
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL, import.meta.env.ORGFRONT_URL,
{ {
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined, tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
}, },
); );
@@ -280,6 +283,21 @@ function UserCreatePage() {
return () => window.removeEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage);
}, [applyTenantSelection, pickerTarget]); }, [applyTenantSelection, pickerTarget]);
if (typeof window !== "undefined") {
const testWindow = window as Window &
typeof globalThis & {
__adminfrontTestHooks?: AdminFrontTestHooks;
};
const hooks = testWindow.__adminfrontTestHooks ?? {};
hooks.selectUserAppointmentTenant = async (selection, index = 0) => {
await applyTenantSelection(selection, {
kind: "appointment",
index,
});
};
testWindow.__adminfrontTestHooks = hooks;
}
const addAppointment = () => { const addAppointment = () => {
setAdditionalAppointments((current) => [ setAdditionalAppointments((current) => [
...current, ...current,
@@ -310,25 +328,16 @@ function UserCreatePage() {
); );
}; };
const handleUserTypeChange = (value: string) => { const handleUserCategoryChange = (value: string) => {
const nextType = value as UserType; const nextCategory = value as UserCategory;
setUserType(nextType); setUserCategory(nextCategory);
setIsHanmacFamily(nextType === "hanmac"); if (nextCategory !== "hanmac") {
if (nextType !== "hanmac") {
setAdditionalAppointments([]); setAdditionalAppointments([]);
} }
}; };
const ensurePersonalTenant = async () => { const ensurePersonalTenant = async () => {
if (personalTenant) return personalTenant; return personalTenant;
const tenant = await createTenant({
name: "Personal",
slug: "personal",
type: "PERSONAL",
status: "active",
});
queryClient.invalidateQueries({ queryKey: ["tenants"] });
return tenant;
}; };
const mutation = useMutation({ const mutation = useMutation({
@@ -355,10 +364,13 @@ function UserCreatePage() {
setGeneratedPassword(null); setGeneratedPassword(null);
setCreatedEmail(null); setCreatedEmail(null);
const {
hanmacFamily: _hanmacFamily,
userType: _userType,
...formMetadata
} = data.metadata ?? {};
const metadata: Record<string, unknown> = { const metadata: Record<string, unknown> = {
...(data.metadata ?? {}), ...formMetadata,
hanmacFamily: userType === "hanmac" && isHanmacFamily,
userType,
}; };
const payload: UserCreateRequest = { const payload: UserCreateRequest = {
@@ -366,10 +378,11 @@ function UserCreatePage() {
password: data.password, password: data.password,
name: data.name, name: data.name,
phone: data.phone, phone: data.phone,
role: data.role,
metadata, metadata,
}; };
if (userType === "external") { if (userCategory === "external") {
if (!data.tenantSlug) { if (!data.tenantSlug) {
setError( setError(
t( t(
@@ -386,7 +399,7 @@ function UserCreatePage() {
payload.jobTitle = data.jobTitle; payload.jobTitle = data.jobTitle;
} }
if (userType === "personal") { if (userCategory === "personal") {
try { try {
const tenant = await ensurePersonalTenant(); const tenant = await ensurePersonalTenant();
payload.tenantSlug = tenant.slug; payload.tenantSlug = tenant.slug;
@@ -405,7 +418,7 @@ function UserCreatePage() {
} }
} }
if (userType === "hanmac") { if (userCategory === "hanmac") {
const appointments = additionalAppointments const appointments = additionalAppointments
.filter((appointment) => appointment.tenantId) .filter((appointment) => appointment.tenantId)
.map((appointment) => ({ .map((appointment) => ({
@@ -644,7 +657,32 @@ function UserCreatePage() {
</div> </div>
</div> </div>
<Tabs value={userType} onValueChange={handleUserTypeChange}> <div className="space-y-2">
<Label htmlFor="role">
{t("ui.admin.users.create.form.role", "역할")}
</Label>
<select
id="role"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
disabled={!isSuperAdminRole(profile?.role)}
>
<option value="super_admin">
{t("ui.admin.role.super_admin", "시스템 관리자")}
</option>
<option value="user">
{t("ui.admin.role.user", "일반 사용자")}
</option>
</select>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.users.create.form.role_help",
"시스템 접근 권한을 결정합니다.",
)}
</p>
</div>
<Tabs value={userCategory} onValueChange={handleUserCategoryChange}>
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground"> <TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
<TabsTrigger <TabsTrigger
value="hanmac" value="hanmac"
@@ -761,6 +799,7 @@ function UserCreatePage() {
}) })
} }
disabled={isResolvingTenant} disabled={isResolvingTenant}
data-testid={`appointment-tenant-picker-${index}`}
> >
<Building2 className="mr-2 h-4 w-4" /> <Building2 className="mr-2 h-4 w-4" />
{appointment.tenantName || "테넌트 선택"} {appointment.tenantName || "테넌트 선택"}
@@ -972,6 +1011,7 @@ function UserCreatePage() {
title={t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")} title={t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
src={pickerUrl} src={pickerUrl}
className="h-[600px] w-full rounded-md border" className="h-[600px] w-full rounded-md border"
data-testid="appointment-tenant-picker-frame"
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -44,6 +44,13 @@ import {
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label"; import { Label } from "../../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import { Switch } from "../../components/ui/switch"; import { Switch } from "../../components/ui/switch";
import { import {
Tabs, Tabs,
@@ -56,18 +63,18 @@ import {
type TenantSummary, type TenantSummary,
type UserAppointment, type UserAppointment,
type UserUpdateRequest, type UserUpdateRequest,
createTenant,
deleteUser, deleteUser,
fetchAllTenants,
fetchMe, fetchMe,
fetchPasswordPolicy, fetchPasswordPolicy,
fetchTenant, fetchTenant,
fetchTenants,
fetchUser, fetchUser,
fetchUserRpHistory, fetchUserRpHistory,
updateUser, updateUser,
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import type { PasswordPolicyResponse } from "../../lib/adminApi"; import type { PasswordPolicyResponse } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { normalizeAdminRole } from "../../lib/roles";
import { generateSecurePassword } from "../../lib/utils"; import { generateSecurePassword } from "../../lib/utils";
import { import {
type OrgChartTenantSelection, type OrgChartTenantSelection,
@@ -78,11 +85,17 @@ import {
parseOrgChartTenantSelection, parseOrgChartTenantSelection,
} from "./orgChartPicker"; } from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields"; import type { UserSchemaField } from "./userSchemaFields";
import {
normalizeUserStatusValue,
userStatusLabel,
userStatusValues,
} from "./userStatus";
import { resolvePersonalTenant } from "./utils/personalTenant";
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & { type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
metadata: Record<string, Record<string, string | number | boolean>>; metadata: Record<string, Record<string, string | number | boolean>>;
}; };
type UserType = "hanmac" | "external" | "personal"; type UserCategory = "hanmac" | "external" | "personal";
type PasswordResetMode = "generated" | "manual"; type PasswordResetMode = "generated" | "manual";
type PickerTarget = { kind: "appointment"; index: number }; type PickerTarget = { kind: "appointment"; index: number };
@@ -318,8 +331,8 @@ function UserDetailPage() {
const [passwordResetError, setPasswordResetError] = React.useState< const [passwordResetError, setPasswordResetError] = React.useState<
string | null string | null
>(null); >(null);
const [isHanmacFamily, setIsHanmacFamily] = React.useState(false); const [userCategory, setUserCategory] =
const [userType, setUserType] = React.useState<UserType>("external"); React.useState<UserCategory>("external");
const [additionalAppointments, setAdditionalAppointments] = React.useState< const [additionalAppointments, setAdditionalAppointments] = React.useState<
AppointmentDraft[] AppointmentDraft[]
>([]); >([]);
@@ -346,8 +359,8 @@ function UserDetailPage() {
}); });
const { data: tenantsData } = useQuery({ const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }], queryKey: ["tenants", "all"],
queryFn: () => fetchTenants(100, 0), queryFn: () => fetchAllTenants(),
}); });
const tenants = React.useMemo( const tenants = React.useMemo(
() => tenantsData?.items ?? [], () => tenantsData?.items ?? [],
@@ -387,8 +400,9 @@ function UserDetailPage() {
}, },
}); });
const profileRole = normalizeAdminRole(profile?.role);
const isAdmin = const isAdmin =
profile?.role === "super_admin" || profile?.role === "tenant_admin"; profileRole === "super_admin" || profileRole === "tenant_admin";
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id); const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
const watchedStatus = watch("status"); const watchedStatus = watch("status");
@@ -465,20 +479,14 @@ function UserDetailPage() {
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? ""; return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
}, [tenants]); }, [tenants]);
const personalTenant = React.useMemo( const personalTenant = React.useMemo(
() => () => resolvePersonalTenant(tenants),
tenants.find(
(tenant) =>
tenant.slug === "personal" ||
(tenant.type === "PERSONAL" &&
tenant.name.toLowerCase() === "personal"),
),
[tenants], [tenants],
); );
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL, import.meta.env.ORGFRONT_URL,
{ {
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined, tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
}, },
); );
@@ -566,25 +574,16 @@ function UserDetailPage() {
); );
}; };
const handleUserTypeChange = (value: string) => { const handleUserCategoryChange = (value: string) => {
const nextType = value as UserType; const nextCategory = value as UserCategory;
setUserType(nextType); setUserCategory(nextCategory);
setIsHanmacFamily(nextType === "hanmac"); if (nextCategory !== "hanmac") {
if (nextType !== "hanmac") {
setAdditionalAppointments([]); setAdditionalAppointments([]);
} }
}; };
const ensurePersonalTenant = async () => { const ensurePersonalTenant = async () => {
if (personalTenant) return personalTenant; return personalTenant;
const tenant = await createTenant({
name: "Personal",
slug: "personal",
type: "PERSONAL",
status: "active",
});
queryClient.invalidateQueries({ queryKey: ["tenants"] });
return tenant;
}; };
React.useEffect(() => { React.useEffect(() => {
@@ -598,8 +597,8 @@ function UserDetailPage() {
name: name:
typeof metadata.primaryTenantName === "string" typeof metadata.primaryTenantName === "string"
? metadata.primaryTenantName ? metadata.primaryTenantName
: user.tenant?.name || user.companyCode || "", : user.tenant?.name || user.tenantSlug || "",
slug: user.companyCode, slug: user.tenantSlug,
} }
: null; : null;
const fallbackAppointment = const fallbackAppointment =
@@ -616,9 +615,9 @@ function UserDetailPage() {
name: user.name, name: user.name,
phone: user.phone || "", phone: user.phone || "",
role: user.role, role: user.role,
status: user.status, status: normalizeUserStatusValue(user.status),
tenantSlug: tenantSlug:
user.companyCode || user.tenantSlug ||
user.joinedTenants?.find( user.joinedTenants?.find(
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP", (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
)?.slug || )?.slug ||
@@ -638,14 +637,17 @@ function UserDetailPage() {
tenants, tenants,
hanmacFamilyTenantId, hanmacFamilyTenantId,
); );
const resolvedUserType = const isPersonalUser =
metadata.userType === "personal" || user.companyCode === "personal" user.tenantSlug === personalTenant.slug ||
? "personal" user.tenant?.id === personalTenant.id ||
: isUserHanmacFamily user.tenant?.slug === personalTenant.slug ||
? "hanmac" metadata.personalTenantId === personalTenant.id;
: "external"; const resolvedUserCategory = isPersonalUser
setUserType(resolvedUserType); ? "personal"
setIsHanmacFamily(resolvedUserType === "hanmac"); : isUserHanmacFamily
? "hanmac"
: "external";
setUserCategory(resolvedUserCategory);
const familyFallbackTenants = [ const familyFallbackTenants = [
...(user.joinedTenants ?? []), ...(user.joinedTenants ?? []),
...(user.tenant ? [user.tenant] : []), ...(user.tenant ? [user.tenant] : []),
@@ -696,7 +698,7 @@ function UserDetailPage() {
: [], : [],
); );
} }
}, [hanmacFamilyTenantId, tenants, user, reset]); }, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data), mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
@@ -737,20 +739,22 @@ function UserDetailPage() {
}), }),
); );
const {
hanmacFamily: _hanmacFamily,
userType: _userType,
...safeMetadata
} = cleanMetadata;
const metadata: Record<string, unknown> = { const metadata: Record<string, unknown> = {
...cleanMetadata, ...safeMetadata,
hanmacFamily: userType === "hanmac" && isHanmacFamily,
userType,
}; };
const profileData = { ...data };
profileData.role = undefined;
const payload: UserUpdateRequest = { const payload: UserUpdateRequest = {
...profileData, ...data,
metadata, metadata,
}; };
payload.role = undefined;
if (userType === "personal") { if (userCategory === "personal") {
try { try {
const tenant = await ensurePersonalTenant(); const tenant = await ensurePersonalTenant();
payload.tenantSlug = tenant.slug; payload.tenantSlug = tenant.slug;
@@ -768,7 +772,7 @@ function UserDetailPage() {
} }
} }
if (userType === "hanmac") { if (userCategory === "hanmac") {
const appointments = additionalAppointments const appointments = additionalAppointments
.filter((appointment) => appointment.tenantId) .filter((appointment) => appointment.tenantId)
.map((appointment) => ({ .map((appointment) => ({
@@ -906,7 +910,7 @@ function UserDetailPage() {
> >
<Building2 size={12} className="mr-1.5" /> <Building2 size={12} className="mr-1.5" />
{user.tenant?.name || {user.tenant?.name ||
user.companyCode || user.tenantSlug ||
user.joinedTenants?.find( user.joinedTenants?.find(
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP", (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
)?.name || )?.name ||
@@ -1052,27 +1056,31 @@ function UserDetailPage() {
> >
{t("ui.admin.users.detail.form.status", "상태")} {t("ui.admin.users.detail.form.status", "상태")}
</Label> </Label>
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3"> <Select
<Switch value={normalizeUserStatusValue(watchedStatus || "")}
id="status" onValueChange={(value) =>
checked={watchedStatus === "active"} setValue("status", normalizeUserStatusValue(value), {
onCheckedChange={(checked) => shouldDirty: true,
setValue("status", checked ? "active" : "inactive") })
} }
/> >
<span className="text-sm text-muted-foreground"> <SelectTrigger id="status" className="h-11 shadow-sm">
{t( <SelectValue />
`ui.common.status.${watchedStatus}`, </SelectTrigger>
watchedStatus || "inactive", <SelectContent>
)} {userStatusValues.map((status) => (
</span> <SelectItem key={status} value={status}>
</div> {userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
</div> </div>
<Tabs <Tabs
value={userType} value={userCategory}
onValueChange={handleUserTypeChange} onValueChange={handleUserCategoryChange}
className="space-y-4 pt-6 border-t border-dashed" className="space-y-4 pt-6 border-t border-dashed"
> >
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground"> <TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
@@ -1097,7 +1105,7 @@ function UserDetailPage() {
</TabsList> </TabsList>
</Tabs> </Tabs>
{userType === "external" && ( {userCategory === "external" && (
<div className="grid gap-8 md:grid-cols-2"> <div className="grid gap-8 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label <Label
@@ -1141,7 +1149,7 @@ function UserDetailPage() {
</div> </div>
)} )}
{userType === "hanmac" && ( {userCategory === "hanmac" && (
<div className="space-y-4 rounded-md border p-4"> <div className="space-y-4 rounded-md border p-4">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-3"> <div className="space-y-3">
@@ -1314,7 +1322,7 @@ function UserDetailPage() {
</div> </div>
)} )}
{userType === "personal" && ( {userCategory === "personal" && (
<div className="rounded-md border bg-muted/30 p-4 text-sm"> <div className="rounded-md border bg-muted/30 p-4 text-sm">
{personalTenant {personalTenant
? `Personal (${personalTenant.slug})` ? `Personal (${personalTenant.slug})`
@@ -1322,7 +1330,7 @@ function UserDetailPage() {
</div> </div>
)} )}
{userType === "external" && ( {userCategory === "external" && (
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t"> <div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
<div className="space-y-2"> <div className="space-y-2">
<Label <Label

File diff suppressed because it is too large Load Diff

View File

@@ -20,8 +20,8 @@ import {
type TenantSummary, type TenantSummary,
type UserSummary, type UserSummary,
bulkUpdateUsers, bulkUpdateUsers,
fetchAllTenants,
fetchGroups, fetchGroups,
fetchTenants,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
@@ -52,8 +52,8 @@ export function UserBulkMoveGroupModal({
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: tenantsData } = useQuery({ const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }], queryKey: ["tenants", "all"],
queryFn: () => fetchTenants(100, 0), queryFn: () => fetchAllTenants(),
enabled: open, enabled: open,
}); });
const tenants = tenantsData?.items ?? []; const tenants = tenantsData?.items ?? [];

View File

@@ -18,13 +18,14 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "../../../components/ui/dialog"; } from "../../../components/ui/dialog";
import { DropdownMenuItem } from "../../../components/ui/dropdown-menu";
import { ScrollArea } from "../../../components/ui/scroll-area"; import { ScrollArea } from "../../../components/ui/scroll-area";
import { import {
type BulkUserItem, type BulkUserItem,
type BulkUserResult, type BulkUserResult,
bulkCreateUsers, bulkCreateUsers,
createTenant, createTenant,
fetchTenants, fetchAllTenants,
fetchUsers, fetchUsers,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
@@ -42,6 +43,9 @@ import {
interface UserBulkUploadModalProps { interface UserBulkUploadModalProps {
onSuccess?: () => void; onSuccess?: () => void;
variant?: "button" | "dropdown" | "custom";
open?: boolean;
onOpenChange?: (open: boolean) => void;
} }
function buildUserTenantPreviewRows( function buildUserTenantPreviewRows(
@@ -66,6 +70,8 @@ function buildUserTenantPreviewRows(
slug: user.tenantImport?.slug || user.tenantSlug || key, slug: user.tenantImport?.slug || user.tenantSlug || key,
memo: user.tenantImport?.memo ?? "", memo: user.tenantImport?.memo ?? "",
emailDomain: user.tenantImport?.emailDomain ?? "", emailDomain: user.tenantImport?.emailDomain ?? "",
visibility: "public",
orgUnitType: "node",
}); });
}); });
@@ -118,8 +124,34 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
return "text-muted-foreground"; return "text-muted-foreground";
} }
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) { export const downloadUserTemplate = () => {
const [open, setOpen] = React.useState(false); const headers =
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "user_bulk_template.csv";
a.click();
URL.revokeObjectURL(url);
};
export function UserBulkUploadModal({
onSuccess,
variant = "button",
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: UserBulkUploadModalProps) {
const [localOpen, setLocalOpen] = React.useState(false);
const open = controlledOpen !== undefined ? controlledOpen : localOpen;
const setOpen = (val: boolean) => {
setLocalOpen(val);
controlledOnOpenChange?.(val);
};
const [file, setFile] = React.useState<File | null>(null); const [file, setFile] = React.useState<File | null>(null);
const [parsing, setParsing] = React.useState(false); const [parsing, setParsing] = React.useState(false);
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]); const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
@@ -137,7 +169,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const tenantQuery = useQuery({ const tenantQuery = useQuery({
queryKey: ["tenants", "user-bulk-import"], queryKey: ["tenants", "user-bulk-import"],
queryFn: () => fetchTenants(1000, 0), queryFn: () => fetchAllTenants(),
}); });
const usersQuery = useQuery({ const usersQuery = useQuery({
@@ -321,309 +353,334 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
(preview) => preview?.status === "blockingError", (preview) => preview?.status === "blockingError",
); );
return ( const triggerProps = {
<Dialog disabled: mutation.isPending,
open={open} "data-testid": "bulk-import-btn",
onOpenChange={(val) => { };
setOpen(val);
if (!val) reset(); const triggerNode =
}} variant === "dropdown" ? (
> <DropdownMenuItem
onClick={() => setOpen(true)}
className="cursor-pointer"
{...triggerProps}
>
<Upload size={16} className="mr-2 opacity-50" />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</DropdownMenuItem>
) : variant === "custom" ? null : (
<DialogTrigger asChild> <DialogTrigger asChild>
<Button <Button variant="outline" className="gap-2" {...triggerProps}>
variant="outline"
className="gap-2"
data-testid="bulk-import-btn"
>
<Upload size={16} /> <Upload size={16} />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")} {t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> );
<DialogHeader>
<DialogTitle data-testid="bulk-upload-title">
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.bulk.description",
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
)}
</DialogDescription>
</DialogHeader>
{!results ? ( return (
<div className="space-y-4 py-4"> <>
<div className="flex justify-between items-center"> {variant === "dropdown" ? triggerNode : null}
<Button <Dialog
variant="ghost" open={open}
size="sm" onOpenChange={(val) => {
onClick={downloadTemplate} setOpen(val);
className="gap-2" if (!val) reset();
> }}
<Download size={14} /> >
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")} {variant !== "dropdown" && variant !== "custom" && triggerNode}
</Button> <DialogContent className="max-w-2xl">
<input <DialogHeader>
type="file" <DialogTitle data-testid="bulk-upload-title">
accept=".csv" {t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
className="hidden" </DialogTitle>
ref={fileInputRef} <DialogDescription>
onChange={handleFileChange} {t(
/> "msg.admin.users.bulk.description",
<Button "CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
onClick={() => fileInputRef.current?.click()} )}
variant="secondary" </DialogDescription>
> </DialogHeader>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button>
</div>
{file && ( {!results ? (
<div className="rounded-lg border p-4 bg-muted/20"> <div className="space-y-4 py-4">
<div className="flex items-center gap-3 mb-2"> <div className="flex justify-between items-center">
<FileText className="text-primary" /> <Button
<span className="font-medium">{file.name}</span> variant="ghost"
<span className="text-xs text-muted-foreground"> size="sm"
({(file.size / 1024).toFixed(1)} KB) onClick={downloadTemplate}
</span> className="gap-2"
</div> >
{parsing ? ( <Download size={14} />
<div className="flex items-center gap-2 text-sm text-muted-foreground"> {t(
<Loader2 size={14} className="animate-spin" /> "ui.admin.users.bulk.download_template",
{t("msg.common.parsing", "파싱 중...")} "템플릿 다운로드",
</div> )}
) : ( </Button>
<div className="text-sm text-muted-foreground"> <Button asChild variant="secondary" className="cursor-pointer">
{t( <label>
"msg.admin.users.bulk.parsed_count", {file
"{{count}}명의 사용자가 감지되었습니다.", ? t("ui.common.change_file", "파일 변경")
{ count: previewData.length }, : t("ui.common.select_file", "파일 선택")}
)} <input
</div> type="file"
)} accept=".csv"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
onClick={(e) => {
// Allow picking the same file again if it was cleared
(e.target as HTMLInputElement).value = "";
}}
/>
</label>
</Button>
</div> </div>
)}
{tenantPreviewRows.length > 0 && ( {file && (
<div <div className="rounded-lg border p-4 bg-muted/20">
className="rounded-md border p-3 text-sm" <div className="flex items-center gap-3 mb-2">
data-testid="user-import-tenant-resolution" <FileText className="text-primary" />
> <span className="font-medium">{file.name}</span>
<div className="mb-2 font-medium"> <span className="text-xs text-muted-foreground">
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")} ({(file.size / 1024).toFixed(1)} KB)
</span>
</div>
{parsing ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
{t("msg.common.parsing", "파싱 중...")}
</div>
) : (
<div className="text-sm text-muted-foreground">
{t(
"msg.admin.users.bulk.parsed_count",
"{{count}}명의 사용자가 감지되었습니다.",
{ count: previewData.length },
)}
</div>
)}
</div> </div>
<div className="space-y-2"> )}
{tenantPreviewRows.map((preview) => (
<div {tenantPreviewRows.length > 0 && (
key={preview.row.rowNumber} <div
className="grid gap-2 sm:grid-cols-[1fr_1fr]" className="rounded-md border p-3 text-sm"
> data-testid="user-import-tenant-resolution"
<div> >
<div className="font-medium">{preview.row.name}</div> <div className="mb-2 font-medium">
<div className="font-mono text-xs text-muted-foreground"> {t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
{preview.row.slug} </div>
<div className="space-y-2">
{tenantPreviewRows.map((preview) => (
<div
key={preview.row.rowNumber}
className="grid gap-2 sm:grid-cols-[1fr_1fr]"
>
<div>
<div className="font-medium">{preview.row.name}</div>
<div className="font-mono text-xs text-muted-foreground">
{preview.row.slug}
</div>
</div> </div>
</div> <div className="space-y-2">
<div className="space-y-2"> <select
<select className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={
selectedTenantMatches[preview.row.rowNumber] ??
"__create__"
}
onChange={(event) =>
setSelectedTenantMatches((prev) => ({
...prev,
[preview.row.rowNumber]: event.target.value,
}))
}
>
<option value="__create__">
{t(
"ui.admin.users.bulk.create_missing_tenant",
"신규 생성",
)}
</option>
{preview.candidates.map((candidate) => (
<option
key={candidate.tenantId}
value={candidate.tenantId}
>
{candidate.name} ({candidate.slug})
</option>
))}
</select>
{(selectedTenantMatches[preview.row.rowNumber] ??
"__create__") === "__create__" && (
<input
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
value={ value={
selectedTenantCreateSlugs[ selectedTenantMatches[preview.row.rowNumber] ??
preview.row.rowNumber "__create__"
] ?? ""
} }
onChange={(event) => onChange={(event) =>
setSelectedTenantCreateSlugs((prev) => ({ setSelectedTenantMatches((prev) => ({
...prev, ...prev,
[preview.row.rowNumber]: event.target.value, [preview.row.rowNumber]: event.target.value,
})) }))
} }
/> >
<option value="__create__">
{t(
"ui.admin.users.bulk.create_missing_tenant",
"신규 생성",
)}
</option>
{preview.candidates.map((candidate) => (
<option
key={candidate.tenantId}
value={candidate.tenantId}
>
{candidate.name} ({candidate.slug})
</option>
))}
</select>
{(selectedTenantMatches[preview.row.rowNumber] ??
"__create__") === "__create__" && (
<input
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
value={
selectedTenantCreateSlugs[
preview.row.rowNumber
] ?? ""
}
onChange={(event) =>
setSelectedTenantCreateSlugs((prev) => ({
...prev,
[preview.row.rowNumber]: event.target.value,
}))
}
/>
)}
</div>
</div>
))}
</div>
</div>
)}
{previewData.length > 0 && (
<ScrollArea className="h-[200px] rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="p-2 text-left">Email</th>
<th className="p-2 text-left">Name</th>
<th className="p-2 text-left">Tenant</th>
<th className="p-2 text-left">Status</th>
</tr>
</thead>
<tbody>
{previewData.slice(0, 10).map((u, index) => (
<tr key={`${u.email}-${index}`} className="border-t">
<td className="p-2">
<input
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
value={
hanmacEmailPreviews[index]?.finalEmail ??
u.email
}
onChange={(event) =>
setPreviewData((prev) =>
prev.map((item, itemIndex) =>
itemIndex === index
? { ...item, email: event.target.value }
: item,
),
)
}
/>
</td>
<td className="p-2">{u.name}</td>
<td className="p-2">{u.tenantSlug || "-"}</td>
<td
className={`p-2 text-xs ${hanmacEmailStatusClass(
hanmacEmailPreviews[index],
)}`}
>
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
{hanmacEmailPreviews[index]?.reason && (
<div>{hanmacEmailPreviews[index]?.reason}</div>
)}
</td>
</tr>
))}
{previewData.length > 10 && (
<tr>
<td
colSpan={4}
className="p-2 text-center text-muted-foreground italic"
>
... and {previewData.length - 10} more users
</td>
</tr>
)}
</tbody>
</table>
</ScrollArea>
)}
</div>
) : (
<div className="space-y-4 py-4">
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-green-600">
{successCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.success", "성공")}
</div>
</div>
<div className="w-px h-10 bg-border" />
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-destructive">
{failCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.fail", "실패")}
</div>
</div>
</div>
<ScrollArea className="h-[250px] rounded-md border">
<div className="p-2 space-y-2">
{results.map((r) => (
<div
key={r.email}
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
>
{r.success ? (
<CheckCircle2
size={16}
className="text-green-500 mt-0.5"
/>
) : (
<AlertCircle
size={16}
className="text-destructive mt-0.5"
/>
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{r.email}</div>
{!r.success && (
<div className="text-xs text-destructive">
{r.message}
</div>
)} )}
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div>
)}
{previewData.length > 0 && (
<ScrollArea className="h-[200px] rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="p-2 text-left">Email</th>
<th className="p-2 text-left">Name</th>
<th className="p-2 text-left">Tenant</th>
<th className="p-2 text-left">Status</th>
</tr>
</thead>
<tbody>
{previewData.slice(0, 10).map((u, index) => (
<tr key={`${u.email}-${index}`} className="border-t">
<td className="p-2">
<input
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
value={
hanmacEmailPreviews[index]?.finalEmail ?? u.email
}
onChange={(event) =>
setPreviewData((prev) =>
prev.map((item, itemIndex) =>
itemIndex === index
? { ...item, email: event.target.value }
: item,
),
)
}
/>
</td>
<td className="p-2">{u.name}</td>
<td className="p-2">{u.tenantSlug || "-"}</td>
<td
className={`p-2 text-xs ${hanmacEmailStatusClass(
hanmacEmailPreviews[index],
)}`}
>
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
{hanmacEmailPreviews[index]?.reason && (
<div>{hanmacEmailPreviews[index]?.reason}</div>
)}
</td>
</tr>
))}
{previewData.length > 10 && (
<tr>
<td
colSpan={4}
className="p-2 text-center text-muted-foreground italic"
>
... and {previewData.length - 10} more users
</td>
</tr>
)}
</tbody>
</table>
</ScrollArea> </ScrollArea>
)}
</div>
) : (
<div className="space-y-4 py-4">
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-green-600">
{successCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.success", "성공")}
</div>
</div>
<div className="w-px h-10 bg-border" />
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-destructive">
{failCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.fail", "실패")}
</div>
</div>
</div> </div>
<ScrollArea className="h-[250px] rounded-md border">
<div className="p-2 space-y-2">
{results.map((r) => (
<div
key={r.email}
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
>
{r.success ? (
<CheckCircle2
size={16}
className="text-green-500 mt-0.5"
/>
) : (
<AlertCircle
size={16}
className="text-destructive mt-0.5"
/>
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{r.email}</div>
{!r.success && (
<div className="text-xs text-destructive">
{r.message}
</div>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
<DialogFooter>
{!results ? (
<Button
onClick={handleUpload}
disabled={
previewData.length === 0 ||
mutation.isPending ||
preparing ||
hasBlockingHanmacEmailRows
}
className="w-full sm:w-auto"
data-testid="bulk-start-btn"
>
{(mutation.isPending || preparing) && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
</Button>
) : (
<Button
onClick={() => setOpen(false)}
className="w-full sm:w-auto"
data-testid="bulk-close-dialog-btn"
>
{t("ui.common.close", "닫기")}
</Button>
)} )}
</DialogFooter>
</DialogContent> <DialogFooter>
</Dialog> {!results ? (
<Button
onClick={handleUpload}
disabled={
previewData.length === 0 ||
mutation.isPending ||
preparing ||
hasBlockingHanmacEmailRows
}
className="w-full sm:w-auto"
data-testid="bulk-start-btn"
>
{(mutation.isPending || preparing) && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
</Button>
) : (
<Button
onClick={() => setOpen(false)}
className="w-full sm:w-auto"
data-testid="bulk-close-dialog-btn"
>
{t("ui.common.close", "닫기")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
); );
} }

View File

@@ -15,6 +15,16 @@ describe("orgChartPicker", () => {
); );
}); });
it("adds internal visibility to tenant picker URLs only when requested", () => {
expect(
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
includeInternal: true,
}),
).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600&includeInternal=true",
);
});
it("adds tenantId to the tenant picker URL when Hanmac family scope is known", () => { it("adds tenantId to the tenant picker URL when Hanmac family scope is known", () => {
expect( expect(
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", { buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
@@ -34,12 +44,22 @@ describe("orgChartPicker", () => {
}, },
), ),
).toBe( ).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id", "https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id%26includeInternal%3Dtrue",
); );
}); });
it("builds the chart navigation URL through the org-chart auto login entry", () => { it("builds the admin chart navigation URL with internal visibility enabled", () => {
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe( expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue",
);
});
it("can build chart navigation URL without internal visibility", () => {
expect(
buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", {
includeInternal: false,
}),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart", "https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
); );
}); });
@@ -169,4 +189,66 @@ describe("orgChartPicker", () => {
), ),
).toBe(true); ).toBe(true);
}); });
it("does not treat legacy hanmacFamily metadata as Hanmac family without tenant evidence", () => {
const tenants = [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
];
expect(
isHanmacFamilyUser(
{
companyCode: "external",
tenant: tenants[1],
metadata: { hanmacFamily: true },
},
tenants,
"hanmac-family-id",
),
).toBe(false);
});
it("does not treat userType metadata as Hanmac family without tenant evidence", () => {
const tenants = [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
];
expect(
isHanmacFamilyUser(
{
companyCode: "external",
tenant: tenants[1],
metadata: { userType: "hanmac" },
},
tenants,
"hanmac-family-id",
),
).toBe(false);
});
}); });

View File

@@ -5,10 +5,13 @@ export type OrgChartTenantSelection = {
export type TenantFilterTarget = { export type TenantFilterTarget = {
id?: string; id?: string;
tenantId?: string;
slug?: string; slug?: string;
tenantSlug?: string;
type?: string; type?: string;
parentId?: string | null; parentId?: string | null;
name?: string; name?: string;
tenantName?: string;
}; };
export type HanmacFamilyUserTarget = { export type HanmacFamilyUserTarget = {
@@ -31,10 +34,12 @@ type OrgChartPickerMessage = {
}; };
type OrgChartTenantPickerOptions = { type OrgChartTenantPickerOptions = {
includeInternal?: boolean;
tenantId?: string; tenantId?: string;
}; };
type OrgChartLoginOptions = { type OrgChartLoginOptions = {
includeInternal?: boolean;
returnTo?: string; returnTo?: string;
}; };
@@ -120,20 +125,43 @@ export function isHanmacFamilyUser<T extends TenantFilterTarget>(
tenants: T[], tenants: T[],
hanmacFamilyTenantId?: string, hanmacFamilyTenantId?: string,
) { ) {
const metadata = user.metadata ?? {}; const metadataAppointments = Array.isArray(
if (metadata.hanmacFamily === true || metadata.userType === "hanmac") { user.metadata?.additionalAppointments,
return true; )
} ? 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( const tenantBySlug = new Map(
tenants tenants
.filter((tenant) => tenant.slug?.trim()) .filter((tenant) => tenant.slug?.trim())
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]), .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 tenantCandidates = [ const tenantCandidates = [
user.tenant, user.tenant,
...(user.joinedTenants ?? []), ...(user.joinedTenants ?? []),
tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""), ...metadataAppointments,
...metadataAppointments.map((appointment) =>
tenantById.get(appointment.id ?? ""),
),
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""), tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
]; ];
@@ -178,6 +206,9 @@ export function buildOrgChartTenantPickerUrl(
if (tenantId) { if (tenantId) {
params.set("tenantId", tenantId); params.set("tenantId", tenantId);
} }
if (options.includeInternal) {
params.set("includeInternal", "true");
}
return `${normalizedBase}/embed/picker?${params.toString()}`; return `${normalizedBase}/embed/picker?${params.toString()}`;
} }
@@ -186,16 +217,25 @@ export function buildAuthenticatedOrgChartTenantPickerUrl(
baseUrl?: string, baseUrl?: string,
options: OrgChartTenantPickerOptions = {}, options: OrgChartTenantPickerOptions = {},
) { ) {
const pickerUrl = buildOrgChartTenantPickerUrl("", options); const pickerUrl = buildOrgChartTenantPickerUrl("", {
includeInternal: true,
...options,
});
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl }); return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
} }
export function buildAuthenticatedOrgChartUrl( export function buildAuthenticatedOrgChartUrl(
baseUrl?: string, baseUrl?: string,
options: OrgChartLoginOptions = {}, options: OrgChartLoginOptions = { includeInternal: true },
) { ) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, ""); const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const returnTo = options.returnTo?.trim() || "/chart"; let returnTo = options.returnTo?.trim() || "/chart";
if (options.includeInternal && returnTo.startsWith("/chart")) {
const [path, query = ""] = returnTo.split("?", 2);
const params = new URLSearchParams(query);
params.set("includeInternal", "true");
returnTo = `${path}?${params.toString()}`;
}
const params = new URLSearchParams({ const params = new URLSearchParams({
auto: "1", auto: "1",
returnTo, returnTo,

View File

@@ -0,0 +1,43 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import {
normalizeUserStatusValue,
userStatusLabel,
userStatusValues,
} from "./userStatus";
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}));
describe("userStatus", () => {
it("exposes canonical user status values", () => {
expect(userStatusValues).toEqual([
"active",
"temporary_leave",
"suspended",
"preboarding",
"baron_guest",
"extended_leave",
"archived",
]);
});
it("normalizes legacy status values", () => {
expect(normalizeUserStatusValue("inactive")).toBe("preboarding");
expect(normalizeUserStatusValue("leave_of_absence")).toBe(
"temporary_leave",
);
expect(normalizeUserStatusValue("baron_only")).toBe("baron_guest");
});
it("falls back to preboarding when status is missing", () => {
expect(normalizeUserStatusValue(undefined)).toBe("preboarding");
expect(normalizeUserStatusValue(null)).toBe("preboarding");
});
it("uses canonical labels for legacy status values", () => {
expect(userStatusLabel("baron_only")).toBe("baron_guest");
});
});

View File

@@ -2,13 +2,42 @@ import { t } from "../../lib/i18n";
export const userStatusValues = [ export const userStatusValues = [
"active", "active",
"inactive", "temporary_leave",
"suspended", "suspended",
"leave_of_absence", "preboarding",
"baron_guest",
"extended_leave",
"archived",
] as const; ] as const;
export type UserStatusValue = (typeof userStatusValues)[number]; export type UserStatusValue = (typeof userStatusValues)[number];
export function userStatusLabel(status: string) { export function normalizeUserStatusValue(status?: string | null): UserStatusValue {
return t(`ui.common.status.${status}`, status); switch ((status ?? "").trim().toLowerCase()) {
case "active":
return "active";
case "temporary_leave":
case "leave_of_absence":
return "temporary_leave";
case "suspended":
case "blocked":
return "suspended";
case "preboarding":
case "inactive":
return "preboarding";
case "baron_guest":
case "baron_only":
return "baron_guest";
case "extended_leave":
return "extended_leave";
case "archived":
return "archived";
default:
return "preboarding";
}
}
export function userStatusLabel(status: string) {
const normalized = normalizeUserStatusValue(status);
return t(`ui.common.status.${normalized}`, normalized);
} }

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
GLOBAL_PERSONAL_TENANT_ID,
resolvePersonalTenant,
} from "./personalTenant";
describe("resolvePersonalTenant", () => {
it("uses the fixed global Personal tenant when it is not included in the paged tenant list", () => {
expect(resolvePersonalTenant([])).toMatchObject({
id: GLOBAL_PERSONAL_TENANT_ID,
slug: "personal",
name: "Personal",
type: "PERSONAL",
status: "active",
});
});
it("prefers the tenant returned by the API when available", () => {
expect(
resolvePersonalTenant([
{
id: "api-personal-id",
slug: "personal",
name: "Personal",
type: "PERSONAL",
status: "active",
memberCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
).toMatchObject({
id: "api-personal-id",
slug: "personal",
});
});
});

View File

@@ -0,0 +1,34 @@
import type { TenantSummary } from "../../../lib/adminApi";
export const GLOBAL_PERSONAL_TENANT_ID =
import.meta.env.VITE_PERSONAL_TENANT_ID ||
"9607eb7b-04d2-42ab-80fe-780fe21c7e8f";
export const GLOBAL_PERSONAL_TENANT_SLUG =
import.meta.env.VITE_PERSONAL_TENANT_SLUG || "personal";
export function isPersonalTenant(
tenant: Pick<TenantSummary, "name" | "slug" | "type">,
) {
return (
tenant.slug === GLOBAL_PERSONAL_TENANT_SLUG ||
(tenant.type === "PERSONAL" && tenant.name.toLowerCase() === "personal")
);
}
export function resolvePersonalTenant(tenants: TenantSummary[]): TenantSummary {
const tenant = tenants.find(isPersonalTenant);
if (tenant) return tenant;
return {
id: GLOBAL_PERSONAL_TENANT_ID,
slug: GLOBAL_PERSONAL_TENANT_SLUG,
name: "Personal",
type: "PERSONAL",
description: "개인 사용자 기본 루트 테넌트",
status: "active",
memberCount: 0,
createdAt: "2026-05-04T06:52:59.187802Z",
updatedAt: "2026-05-04T06:52:59.191145Z",
};
}

View File

@@ -1,3 +1,5 @@
@import "../../common/theme/base.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -24,37 +26,7 @@
--input: 215 25% 24%; --input: 215 25% 24%;
--ring: 209 79% 52%; --ring: 209 79% 52%;
--radius: 0.75rem; --radius: 0.75rem;
} --app-background-image: radial-gradient(
.light {
--background: 0 0% 98%;
--foreground: 223 25% 12%;
--card: 0 0% 100%;
--card-foreground: 223 25% 12%;
--popover: 0 0% 100%;
--popover-foreground: 223 25% 12%;
--primary: 209 79% 52%;
--primary-foreground: 0 0% 100%;
--secondary: 220 17% 94%;
--secondary-foreground: 223 25% 20%;
--muted: 223 15% 45%;
--muted-foreground: 223 15% 45%;
--accent: 40 96% 62%;
--accent-foreground: 223 25% 12%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 100%;
--border: 220 17% 90%;
--input: 220 17% 90%;
--ring: 209 79% 52%;
}
* {
@apply border-border;
}
body {
@apply min-h-screen bg-background font-sans text-foreground antialiased;
background-image: radial-gradient(
circle at 10% 18%, circle at 10% 18%,
rgba(54, 211, 153, 0.16), rgba(54, 211, 153, 0.16),
transparent 28% transparent 28%
@@ -70,14 +42,4 @@
transparent 30% transparent 30%
); );
} }
a {
@apply text-inherit no-underline;
}
}
@layer components {
.glass-panel {
@apply rounded-2xl border border-border bg-card/85 shadow-card backdrop-blur;
}
} }

View File

@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiClient = {
post: vi.fn(),
put: vi.fn(),
};
vi.mock("./apiClient", () => ({
default: apiClient,
}));
describe("adminApi user tenant payloads", () => {
beforeEach(() => {
apiClient.post.mockReset();
apiClient.put.mockReset();
});
it("sends tenantSlug without remapping it to companyCode when creating a user", async () => {
const { createUser } = await import("./adminApi");
apiClient.post.mockResolvedValue({ data: {} });
await createUser({
email: "user@test.com",
name: "Test User",
tenantSlug: "test-tenant",
});
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/users",
expect.objectContaining({ tenantSlug: "test-tenant" }),
);
expect(apiClient.post.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
it("sends tenantSlug without remapping it to companyCode when updating a user", async () => {
const { updateUser } = await import("./adminApi");
apiClient.put.mockResolvedValue({ data: {} });
await updateUser("user-id", { tenantSlug: "new-tenant" });
expect(apiClient.put).toHaveBeenCalledWith(
"/v1/admin/users/user-id",
expect.objectContaining({ tenantSlug: "new-tenant" }),
);
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
it("keeps tenantSlug payloads unchanged for bulk user APIs", async () => {
const { bulkCreateUsers, bulkUpdateUsers } = await import("./adminApi");
apiClient.post.mockResolvedValue({ data: {} });
apiClient.put.mockResolvedValue({ data: {} });
await bulkCreateUsers([
{
email: "user@test.com",
name: "Test User",
tenantSlug: "test-tenant",
metadata: {},
},
]);
await bulkUpdateUsers({
userIds: ["user-id"],
tenantSlug: "new-tenant",
});
expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({
tenantSlug: "test-tenant",
});
expect(apiClient.post.mock.calls[0][1].users[0]).not.toHaveProperty(
"companyCode",
);
expect(apiClient.put.mock.calls[0][1]).toMatchObject({
tenantSlug: "new-tenant",
});
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
});

View File

@@ -1,4 +1,6 @@
import { fetchAllCursorPages } from "../../../common/core/pagination";
import apiClient from "./apiClient"; import apiClient from "./apiClient";
import { userManager } from "./auth";
export type AuditLog = { export type AuditLog = {
event_id: string; event_id: string;
@@ -51,6 +53,9 @@ export type TenantListResponse = {
limit: number; limit: number;
offset: number; offset: number;
total: number; total: number;
cursor?: string;
nextCursor?: string;
next_cursor?: string;
}; };
export type TenantUpdateRequest = { export type TenantUpdateRequest = {
@@ -124,6 +129,7 @@ export type RPUsageDailyResponse = {
export type AdminOverviewStats = { export type AdminOverviewStats = {
totalTenants: number; totalTenants: number;
totalUsers: number;
oidcClients: number; oidcClients: number;
auditEvents24h: number; auditEvents24h: number;
}; };
@@ -144,6 +150,60 @@ export type UserProjectionActionResult = {
updatedAt: string; updatedAt: string;
}; };
export type DataIntegrityStatus = "pass" | "warning" | "fail";
export type DataIntegrityCheck = {
key: string;
label: string;
description: string;
status: DataIntegrityStatus;
severity: "info" | "warning" | "error" | string;
count: number;
};
export type DataIntegritySection = {
key: string;
label: string;
status: DataIntegrityStatus;
checks: DataIntegrityCheck[];
};
export type DataIntegrityReport = {
status: DataIntegrityStatus;
checkedAt: string;
summary: {
totalChecks: number;
passed: number;
warnings: number;
failures: number;
};
sections: DataIntegritySection[];
};
export type OrphanUserLoginID = {
id: string;
userId: string;
userEmail?: string;
userDeletedAt?: string;
tenantId: string;
tenantSlug?: string;
tenantDeletedAt?: string;
fieldKey: string;
loginId: string;
reasons: string[];
};
export type OrphanUserLoginIDListResponse = {
items: OrphanUserLoginID[];
total: number;
};
export type DeleteOrphanUserLoginIDsResult = {
deletedCount: number;
deleted: OrphanUserLoginID[];
skippedIds: string[];
};
export async function fetchAuditLogs(limit = 50, cursor?: string) { export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", { const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor }, params: { limit, cursor },
@@ -156,6 +216,28 @@ export async function fetchAdminOverviewStats() {
return data; return data;
} }
export async function fetchDataIntegrityReport() {
const { data } = await apiClient.get<DataIntegrityReport>(
"/v1/admin/integrity",
);
return data;
}
export async function fetchOrphanUserLoginIDs() {
const { data } = await apiClient.get<OrphanUserLoginIDListResponse>(
"/v1/admin/integrity/orphan-user-login-ids",
);
return data;
}
export async function deleteOrphanUserLoginIDs(ids: string[]) {
const { data } = await apiClient.delete<DeleteOrphanUserLoginIDsResult>(
"/v1/admin/integrity/orphan-user-login-ids",
{ data: { ids } },
);
return data;
}
export async function fetchUserProjectionStatus() { export async function fetchUserProjectionStatus() {
const { data } = await apiClient.get<UserProjectionStatus>( const { data } = await apiClient.get<UserProjectionStatus>(
"/v1/admin/projections/users", "/v1/admin/projections/users",
@@ -195,16 +277,73 @@ export async function fetchAdminRPUsageDaily({
return data; return data;
} }
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) { export async function fetchTenants(
limit = 50,
offset = 0,
parentId?: string,
cursor?: string,
) {
const { data } = await apiClient.get<TenantListResponse>( const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants", "/v1/admin/tenants",
{ {
params: { limit, offset, parentId }, params: { limit, offset, parentId, cursor },
}, },
); );
return data; return data;
} }
function getAdminApiBaseUrl() {
if (
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE
) {
return "http://playwright-mock/api";
}
return import.meta.env.VITE_ADMIN_API_BASE ?? "/api";
}
async function buildAdminRequestHeaders() {
const headers: Record<string, string> = {};
const user = await userManager.getUser();
const sessionToken =
user?.access_token || window.localStorage.getItem("admin_session");
if (sessionToken) {
headers.Authorization = `Bearer ${sessionToken}`;
}
const tenantId = window.localStorage.getItem("admin_tenant");
if (tenantId) {
headers["X-Tenant-ID"] = tenantId;
}
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (isMockRoleEnabled && mockRole) {
headers["X-Test-Role"] = mockRole;
}
return headers;
}
export async function fetchAllTenants({
pageSize = 100,
parentId,
}: {
pageSize?: number;
parentId?: string;
} = {}) {
return fetchAllCursorPages<TenantSummary>({
baseUrl: getAdminApiBaseUrl(),
path: "/v1/admin/tenants",
pageSize,
params: { parentId },
headers: await buildAdminRequestHeaders(),
}) as Promise<TenantListResponse>;
}
export async function fetchTenant(tenantId: string) { export async function fetchTenant(tenantId: string) {
const { data } = await apiClient.get<TenantSummary>( const { data } = await apiClient.get<TenantSummary>(
`/v1/admin/tenants/${tenantId}`, `/v1/admin/tenants/${tenantId}`,
@@ -241,9 +380,9 @@ export async function deleteTenantsBulk(ids: string[]) {
}); });
} }
export async function exportTenantsCSV(includeIds = false) { export async function exportTenantsCSV(includeIds = false, parentId?: string) {
const response = await apiClient.get<Blob>("/v1/admin/tenants/export", { const response = await apiClient.get<Blob>("/v1/admin/tenants/export", {
params: { includeIds }, params: { includeIds, parentId: parentId || undefined },
responseType: "blob", responseType: "blob",
}); });
const dispositionHeader = response.headers["content-disposition"]; const dispositionHeader = response.headers["content-disposition"];
@@ -440,6 +579,10 @@ export type ApiKeyCreateResponse = {
clientSecret: string; clientSecret: string;
}; };
export type ApiKeyUpdateScopesRequest = {
scopes: string[];
};
export async function fetchApiKeys(limit = 50, offset = 0) { export async function fetchApiKeys(limit = 50, offset = 0) {
const { data } = await apiClient.get<ApiKeyListResponse>( const { data } = await apiClient.get<ApiKeyListResponse>(
"/v1/admin/api-keys", "/v1/admin/api-keys",
@@ -458,6 +601,24 @@ export async function createApiKey(payload: ApiKeyCreateRequest) {
return data; return data;
} }
export async function updateApiKeyScopes(
apiKeyId: string,
payload: ApiKeyUpdateScopesRequest,
) {
const { data } = await apiClient.patch<ApiKeySummary>(
`/v1/admin/api-keys/${apiKeyId}`,
payload,
);
return data;
}
export async function rotateApiKeySecret(apiKeyId: string) {
const { data } = await apiClient.post<ApiKeyCreateResponse>(
`/v1/admin/api-keys/${apiKeyId}/secret/rotate`,
);
return data;
}
export async function deleteApiKey(apiKeyId: string) { export async function deleteApiKey(apiKeyId: string) {
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`); await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
} }
@@ -628,11 +789,14 @@ export type WorksmobileOverview = {
export type WorksmobileComparisonItem = { export type WorksmobileComparisonItem = {
resourceType: string; resourceType: string;
baronId?: string; baronId?: string;
baronSlug?: string;
baronName?: string; baronName?: string;
baronEmail?: string; baronEmail?: string;
baronPrimaryOrgId?: string; baronPrimaryOrgId?: string;
baronPrimaryOrgSlug?: string;
baronPrimaryOrgName?: string; baronPrimaryOrgName?: string;
baronParentId?: string; baronParentId?: string;
baronParentSlug?: string;
baronParentName?: string; baronParentName?: string;
worksmobileId?: string; worksmobileId?: string;
externalKey?: string; externalKey?: string;
@@ -648,8 +812,13 @@ export type WorksmobileComparisonItem = {
worksmobilePrimaryOrgPositionId?: string; worksmobilePrimaryOrgPositionId?: string;
worksmobilePrimaryOrgPositionName?: string; worksmobilePrimaryOrgPositionName?: string;
worksmobilePrimaryOrgIsManager?: boolean; worksmobilePrimaryOrgIsManager?: boolean;
baronParentWorksmobileId?: string;
baronParentWorksmobileName?: string;
baronParentWorksmobileEmail?: string;
worksmobileParentId?: string; worksmobileParentId?: string;
worksmobileParentName?: string; worksmobileParentName?: string;
worksmobileParentEmail?: string;
worksmobileParentExternalKey?: string;
status: string; status: string;
}; };
@@ -678,17 +847,9 @@ export async function fetchUser(userId: string) {
} }
export async function createUser(payload: UserCreateRequest) { export async function createUser(payload: UserCreateRequest) {
// Map tenantSlug to companyCode for backend compatibility
const requestPayload: UserCreateRequest & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.post<UserCreateResponse>( const { data } = await apiClient.post<UserCreateResponse>(
"/v1/admin/users", "/v1/admin/users",
requestPayload, payload,
); );
return data; return data;
} }
@@ -714,16 +875,9 @@ export async function exportUsersCSV(
} }
export async function bulkCreateUsers(users: BulkUserItem[]) { export async function bulkCreateUsers(users: BulkUserItem[]) {
const mappedUsers = users.map((u) => {
const mapped: BulkUserItem & { companyCode?: string } = { ...u };
if (u.tenantSlug !== undefined) {
mapped.companyCode = u.tenantSlug;
}
return mapped;
});
const { data } = await apiClient.post<BulkUserResponse>( const { data } = await apiClient.post<BulkUserResponse>(
"/v1/admin/users/bulk", "/v1/admin/users/bulk",
{ users: mappedUsers }, { users },
); );
return data; return data;
} }
@@ -778,7 +932,17 @@ export async function enqueueWorksmobileOrgUnitSync(
orgUnitId: string, orgUnitId: string,
) { ) {
const { data } = await apiClient.post<WorksmobileOutboxItem>( const { data } = await apiClient.post<WorksmobileOutboxItem>(
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${orgUnitId}/sync`, `/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/sync`,
);
return data;
}
export async function enqueueWorksmobileOrgUnitDelete(
tenantId: string,
orgUnitId: string,
) {
const { data } = await apiClient.post<WorksmobileOutboxItem>(
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/delete`,
); );
return data; return data;
} }
@@ -810,13 +974,7 @@ export async function bulkUpdateUsers(payload: {
grade?: string; grade?: string;
jobTitle?: string; jobTitle?: string;
}) { }) {
const requestPayload: typeof payload & { companyCode?: string } = { const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
return data; return data;
} }
@@ -828,16 +986,9 @@ export async function bulkDeleteUsers(userIds: string[]) {
} }
export async function updateUser(userId: string, payload: UserUpdateRequest) { export async function updateUser(userId: string, payload: UserUpdateRequest) {
const requestPayload: UserUpdateRequest & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.put<UserSummary>( const { data } = await apiClient.put<UserSummary>(
`/v1/admin/users/${userId}`, `/v1/admin/users/${userId}`,
requestPayload, payload,
); );
return data; return data;
} }

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