1
0
forked from baron/baron-sso

751 Commits

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

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

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

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
.git
.gitea
.codex
.env
.env.*
**/.dart_tool
**/.packages
**/build
**/node_modules
**/dist
**/.next
**/.cache
**/coverage
**/tmp
**/logs
**/*.log
**/*.swp
**/.DS_Store

View File

@@ -28,6 +28,8 @@ DB_NAME=baron_sso
# Must be 32 bytes. Generate with `openssl rand -hex 32`
COOKIE_SECRET=super-secret-key-must-be-32-bytes!
JWT_SECRET=super-secret-key-must-be-32-bytes!
# Optional backend slog override: debug, info, warn, error
BACKEND_LOG_LEVEL=
REDIS_ADDR=redis:6389 # compose.infra.yaml의 redis 포트(컨테이너 내부 기준)
CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한 Origin 지정 필요
@@ -38,11 +40,6 @@ AUDIT_QUEUE_SIZE=2000 # 감사 로그 대기열(채널) 버퍼 크기
# Redis Cache Configuration
PROFILE_CACHE_TTL=30m # User Profile Redis 캐시 만료 시간
# Descope Project ID (Required for Auth)
DESCOPE_PROJECT_ID=P2t...your_descope_project_id
DESCOPE_MANAGEMENT_KEY=your_descope_management_key_here
DESCOPE_TEST_ACCOUNT=tester@baroncs.co.kr
# --- Naver Cloud Services ---
NAVER_CLOUD_ACCESS_KEY=ncp_iam_...
NAVER_CLOUD_SECRET_KEY=ncp_iam_...
@@ -64,7 +61,8 @@ ADMIN_PASSWORD=adminPasswordIsNotSimple
USERFRONT_URL=https://sso.hmac.kr
# Services proxied via Nginx
BACKEND_URL=${USERFRONT_URL}/api
BACKEND_PUBLIC_URL=${USERFRONT_URL}
BACKEND_URL=${USERFRONT_URL}
OATHKEEPER_PUBLIC_URL=${USERFRONT_URL}
# ory-stack 변수들
@@ -95,6 +93,8 @@ HYDRA_VERSION=v25.4.0-distroless
KETO_VERSION=v25.4.0-distroless
# KETO_READ_PORT=4466 # Internal only
# KETO_WRITE_PORT=4467 # Internal only
KETO_READ_URL=http://keto:4466
KETO_WRITE_URL=http://keto:4467
# Kratos Selfservice UI upstreams (override for deployments)
ORY_SDK_URL=http://kratos:4433
@@ -111,6 +111,10 @@ HYDRA_ADMIN_URL=http://hydra:4445
# Oathkeeper가 /oidc 경로를 Hydra Public API로 라우팅합니다.
HYDRA_PUBLIC_URL=${OATHKEEPER_PUBLIC_URL}/oidc
# Kratos allowed_return_urls 확장 목록 (콤마 구분, 선택)
# 기본값은 KRATOS_UI_URL, USERFRONT_URL, 각 callback URL을 자동 포함합니다.
KRATOS_ALLOWED_RETURN_URLS_EXTRA=[]
# Oathkeeper JWKS (내부 통신용)
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
@@ -127,3 +131,15 @@ OATHKEEPER_HEALTH_ENABLED=true
COOKIE_SECRET=localcookie123
CSRF_COOKIE_NAME=__HOST-baronSSO_csrf
CSRF_COOKIE_SECRET=localcsrf123
# AdminFront OIDC 설정
ADMINFRONT_URL=http://localhost:5173
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback
# DevFront OIDC 설정
VITE_OIDC_CLIENT_ID=devfront
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
DEVFRONT_URL=http://localhost:5174
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback
VITE_ORGCHART_URL=

1886
.env.test2 Normal file

File diff suppressed because it is too large Load Diff

7
.gitea/coverage.json Normal file
View File

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

View File

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

View File

@@ -106,11 +106,6 @@ jobs:
provenance: false
sbom: false
- name: Temporarily update userfront nginx port
run: |
sed -i 's/listen 5000;/listen 80;/g' userfront/nginx.conf
sed -i 's/proxy_pass http:\/\/baron_backend:3000;/proxy_pass http:\/\/baron_backend:3010;/g' userfront/nginx.conf
- name: Build and push userfront RC image
uses: docker/build-push-action@v5
with:

View File

@@ -1,109 +1,811 @@
name: Code Check
on:
workflow_dispatch:
inputs:
run_lint:
description: "Run linters for Go and Flutter"
required: true
type: boolean
default: true
run_backend_tests:
description: "Run backend Go tests"
required: true
type: boolean
default: true
run_userfront_tests:
description: "Run userfront Flutter tests"
required: true
type: boolean
default: true
push:
branches:
- dev
pull_request:
branches:
- dev
workflow_dispatch:
inputs:
run_lint:
description: "Run lint/format checks for Go, Flutter, adminfront, devfront"
required: true
type: boolean
default: true
run_backend_tests:
description: "Run backend Go tests"
required: true
type: boolean
default: true
run_userfront_tests:
description: "Run userfront Flutter tests"
required: true
type: boolean
default: true
run_userfront_e2e_tests:
description: "Run userfront WASM Playwright E2E tests"
required: true
type: boolean
default: true
run_adminfront_tests:
description: "Run adminfront Playwright tests"
required: true
type: boolean
default: true
run_devfront_tests:
description: "Run devfront Playwright tests"
required: true
type: boolean
default: true
jobs:
lint:
if: ${{ inputs.run_lint == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
lint:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.run_lint == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: |
adminfront/package-lock.json
devfront/package-lock.json
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: i18n resource check
run: |
mkdir -p reports
node tools/i18n-scanner/index.js
node tools/i18n-scanner/report.js
cat reports/i18n-report.txt
- name: Lint Go backend
uses: golangci/golangci-lint-action@v6
with:
version: v1.59
working-directory: backend
args: --enable-only=gofmt,gofumpt
- name: i18n value quality check
run: |
mkdir -p reports
node tools/i18n-scanner/value-check.js
cat reports/i18n-value-report.txt
- name: Analyze Flutter userfront
run: |
cd userfront
flutter analyze --no-fatal-warnings --no-fatal-infos
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
backend-tests:
needs: lint
if: ${{ inputs.run_backend_tests == true }}
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
options: >
--health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
clickhouse:
image: clickhouse/clickhouse-server:24.6
options: >
--health-cmd "wget -qO- 'http://localhost:8123/ping'" --health-interval 10s --health-timeout 5s --health-retries 5
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
env:
REDIS_ADDR: redis:6379
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PORT_NATIVE: 9000
- name: Install adminfront dependencies
run: |
cd adminfront
npm ci
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Biome check adminfront (lint + format)
run: |
cd adminfront
npx biome check . --formatter-enabled=false --organize-imports-enabled=false
npx biome check . --linter-enabled=false --organize-imports-enabled=false
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Install devfront dependencies
run: |
cd devfront
npm ci
- name: Run backend tests
run: |
cd backend
go test -v ./...
- name: Biome check devfront (lint + format)
run: |
cd devfront
npx biome check . --formatter-enabled=false --organize-imports-enabled=false
npx biome check . --linter-enabled=false --organize-imports-enabled=false
userfront-tests:
needs: lint
if: ${{ inputs.run_userfront_tests == true }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Lint Go backend
run: |
docker run --rm \
-v "${PWD}/backend:/app" \
-w /app \
golangci/golangci-lint:v2.10.1 \
golangci-lint fmt -E gofmt -E gofumpt -d
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Sync userfront locales
run: |
/bin/sh ./scripts/sync_userfront_locales.sh
- name: Run userfront tests
run: |
cd userfront
if [ -d test ]; then
flutter test
else
echo "No userfront tests: skipping (test/ directory not found)."
fi
- name: Install Userfront dependencies
run: |
cd userfront
flutter pub get
- name: Format Flutter userfront
run: |
cd userfront
dart format --output=none --set-exit-if-changed lib test
- name: Analyze Flutter userfront
run: |
cd userfront
flutter analyze --no-fatal-warnings --no-fatal-infos
backend-tests:
needs: lint
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_backend_tests == true) }}
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
options: >
--health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
clickhouse:
image: clickhouse/clickhouse-server:24.6
options: >
--health-cmd "wget -qO- 'http://localhost:8123/ping'" --health-interval 10s --health-timeout 5s --health-retries 5
env:
REDIS_ADDR: redis:6379
CLICKHOUSE_HOST: clickhouse
CLICKHOUSE_PORT_NATIVE: 9000
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: "1.25"
cache-dependency-path: backend/go.sum
- name: Run backend tests
run: |
mkdir -p reports
set +e
cd backend
go test -v ./... 2>&1 | tee ../reports/backend-test.log
test_exit_code=${PIPESTATUS[0]}
cd ..
if [ "$test_exit_code" -ne 0 ]; then
{
echo "# Backend Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`backend-tests\`"
echo "- Exit Code: \`$test_exit_code\`"
echo
echo "## Command"
echo "\`go test -v ./...\`"
echo
echo "## Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/backend-test.log
echo '```'
} > reports/backend-test-failure-report.md
fi
exit "$test_exit_code"
- name: Publish backend failure summary
if: ${{ failure() }}
run: |
if [ -f reports/backend-test-failure-report.md ]; then
cat reports/backend-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload backend failure report artifact
if: ${{ failure() }}
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: backend-test-failure-report
path: |
reports/backend-test-failure-report.md
reports/backend-test.log
if-no-files-found: ignore
userfront-tests:
needs: lint
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_tests == true) }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Sync userfront locales
run: |
/bin/sh ./scripts/sync_userfront_locales.sh
- name: Run userfront tests
run: |
cd userfront
if [ -d test ]; then
mkdir -p ../reports
set +e
flutter test 2>&1 | tee ../reports/userfront-test.log
test_exit_code=${PIPESTATUS[0]}
set -e
if [ "$test_exit_code" -ne 0 ]; then
{
echo "# Userfront Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`userfront-tests\`"
echo "- Exit Code: \`$test_exit_code\`"
echo
echo "## Command"
echo "\`flutter test\`"
echo
if [ -f ../reports/userfront-test.log ]; then
echo "## Test Log Tail (last 200 lines)"
echo '```text'
tail -n 200 ../reports/userfront-test.log
echo '```'
fi
} > ../reports/userfront-test-failure-report.md
exit 1
fi
else
echo "No userfront tests: skipping (test/ directory not found)."
fi
- name: Ensure userfront failure report exists
if: ${{ failure() }}
run: |
mkdir -p reports
if [ -f reports/userfront-test-failure-report.md ]; then
exit 0
fi
{
echo "# Userfront Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`userfront-tests\`"
echo "- Reason: \`Job failed before detailed report generation\`"
echo
if [ -f reports/userfront-test.log ]; then
echo "## Test Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/userfront-test.log
echo '```'
fi
} > reports/userfront-test-failure-report.md
- name: Publish userfront failure summary
if: ${{ failure() }}
run: |
if [ -f reports/userfront-test-failure-report.md ]; then
cat reports/userfront-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload userfront failure report artifact
if: ${{ failure() }}
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: userfront-test-failure-report
path: |
reports/userfront-test-failure-report.md
reports/userfront-test.log
if-no-files-found: ignore
userfront-e2e-tests:
needs: lint
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_userfront_e2e_tests == true) }}
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: userfront-e2e/package-lock.json
- name: Get Playwright version
id: playwright-version
run: |
cd userfront-e2e
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright Browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
cache: true
- name: Sync userfront locales
run: |
/bin/sh ./scripts/sync_userfront_locales.sh
- name: Install userfront-e2e dependencies
run: |
mkdir -p reports
set +e
cd userfront-e2e
npm ci 2>&1 | tee ../reports/userfront-e2e-install.log
install_exit_code=${PIPESTATUS[0]}
cd ..
set -e
if [ "$install_exit_code" -ne 0 ]; then
{
echo "# Userfront E2E Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`userfront-e2e-tests\`"
echo "- Reason: \`Dependency install failed\`"
echo "- Exit Code: \`$install_exit_code\`"
echo
echo "## Command"
echo "\`cd userfront-e2e && npm ci\`"
echo
echo "## Install Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/userfront-e2e-install.log
echo '```'
} > reports/userfront-e2e-test-failure-report.md
exit 1
fi
- name: Build userfront WASM
run: |
mkdir -p reports
set +e
cd userfront
flutter build web --wasm --release 2>&1 | tee ../reports/userfront-e2e-build.log
build_exit_code=${PIPESTATUS[0]}
cd ..
set -e
if [ "$build_exit_code" -ne 0 ]; then
{
echo "# Userfront E2E Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`userfront-e2e-tests\`"
echo "- Reason: \`WASM build failed\`"
echo "- Exit Code: \`$build_exit_code\`"
echo
echo "## Command"
echo "\`cd userfront && flutter build web --wasm --release\`"
echo
echo "## Build Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/userfront-e2e-build.log
echo '```'
} > reports/userfront-e2e-test-failure-report.md
exit 1
fi
- name: Provision browsers for userfront-e2e tests
run: |
set +e
cd userfront-e2e
npx playwright install --with-deps chromium 2>&1 | tee ../reports/userfront-e2e-provision.log
provision_exit_code=${PIPESTATUS[0]}
cd ..
set -e
if [ "$provision_exit_code" -ne 0 ]; then
{
echo "# Userfront E2E Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`userfront-e2e-tests\`"
echo "- Reason: \`Browser provisioning failed\`"
echo "- Exit Code: \`$provision_exit_code\`"
echo
echo "## Command"
echo "\`cd userfront-e2e && npx playwright install --with-deps chromium\`"
echo
echo "## Provision Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/userfront-e2e-provision.log
echo '```'
} > reports/userfront-e2e-test-failure-report.md
exit 1
fi
- name: Run userfront-e2e tests
run: |
mkdir -p reports
set +e
cd userfront-e2e
npm test 2>&1 | tee ../reports/userfront-e2e-test.log
test_exit_code=${PIPESTATUS[0]}
cd ..
set -e
if [ "$test_exit_code" -ne 0 ]; then
{
echo "# Userfront E2E Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`userfront-e2e-tests\`"
echo "- Exit Code: \`$test_exit_code\`"
echo
echo "## Commands"
echo "1. \`cd userfront-e2e\`"
echo "2. \`npm ci\`"
echo "3. \`cd ../userfront && flutter build web --wasm --release\`"
echo "4. \`cd ../userfront-e2e && npx playwright install --with-deps chromium\`"
echo "5. \`npm test\`"
echo
echo "## Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/userfront-e2e-test.log
echo '```'
} > reports/userfront-e2e-test-failure-report.md
fi
exit "$test_exit_code"
- name: Ensure userfront-e2e failure report exists
if: ${{ failure() }}
run: |
mkdir -p reports
if [ -f reports/userfront-e2e-test-failure-report.md ]; then
exit 0
fi
{
echo "# Userfront E2E Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`userfront-e2e-tests\`"
echo "- Reason: \`Job failed before detailed report generation\`"
echo
if [ -f reports/userfront-e2e-install.log ]; then
echo "## Install Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/userfront-e2e-install.log
echo '```'
echo
fi
if [ -f reports/userfront-e2e-build.log ]; then
echo "## Build Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/userfront-e2e-build.log
echo '```'
echo
fi
if [ -f reports/userfront-e2e-provision.log ]; then
echo "## Provision Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/userfront-e2e-provision.log
echo '```'
echo
fi
if [ -f reports/userfront-e2e-test.log ]; then
echo "## Test Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/userfront-e2e-test.log
echo '```'
fi
} > reports/userfront-e2e-test-failure-report.md
- name: Publish userfront-e2e failure summary
if: ${{ failure() }}
run: |
if [ -f reports/userfront-e2e-test-failure-report.md ]; then
cat reports/userfront-e2e-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload userfront-e2e failure report artifact
if: ${{ failure() }}
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: userfront-e2e-test-failure-report
path: |
reports/userfront-e2e-test-failure-report.md
reports/userfront-e2e-install.log
reports/userfront-e2e-build.log
reports/userfront-e2e-provision.log
reports/userfront-e2e-test.log
userfront-e2e/playwright-report
userfront-e2e/test-results
if-no-files-found: ignore
adminfront-tests:
needs: lint
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_adminfront_tests == true) }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: adminfront/package-lock.json
- name: Get Playwright version
id: playwright-version
run: |
cd adminfront
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright Browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Run adminfront tests
env:
PLAYWRIGHT_WORKERS: 2
run: |
scripts/run_adminfront_ci_tests.sh adminfront-tests
- name: Ensure adminfront failure report exists
if: ${{ failure() }}
run: |
mkdir -p reports
if [ -f reports/adminfront-test-failure-report.md ]; then
exit 0
fi
{
echo "# Adminfront Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`adminfront-tests\`"
echo "- Reason: \`Job failed before detailed report generation\`"
echo
if [ -f reports/adminfront-install.log ]; then
echo "## Install Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/adminfront-install.log
echo '```'
echo
fi
if [ -f reports/adminfront-provision.log ]; then
echo "## Provision Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/adminfront-provision.log
echo '```'
echo
fi
if [ -f reports/adminfront-test.log ]; then
echo "## Test Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/adminfront-test.log
echo '```'
fi
} > reports/adminfront-test-failure-report.md
- name: Publish adminfront failure summary
if: ${{ failure() }}
run: |
if [ -f reports/adminfront-test-failure-report.md ]; then
cat reports/adminfront-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload adminfront failure report artifact
if: ${{ failure() }}
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: adminfront-test-failure-report
path: |
reports/adminfront-test-failure-report.md
reports/adminfront-install.log
reports/adminfront-provision.log
reports/adminfront-test.log
adminfront/playwright-report
adminfront/test-results
if-no-files-found: ignore
devfront-tests:
needs: lint
if: ${{ always() && (github.event_name != 'workflow_dispatch' || inputs.run_devfront_tests == true) }}
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
cache: "npm"
cache-dependency-path: devfront/package-lock.json
- name: Get Playwright version
id: playwright-version
run: |
cd devfront
echo "version=$(npm list @playwright/test | grep @playwright/test | awk -F@ '{print $NF}')" >> "$GITHUB_OUTPUT"
- name: Cache Playwright Browsers
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install devfront dependencies
run: |
mkdir -p reports
set +e
cd devfront
npm ci 2>&1 | tee ../reports/devfront-install.log
install_exit_code=${PIPESTATUS[0]}
cd ..
set -e
if [ "$install_exit_code" -ne 0 ]; then
{
echo "# Devfront Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`devfront-tests\`"
echo "- Reason: \`Dependency install failed\`"
echo "- Exit Code: \`$install_exit_code\`"
echo
echo "## Command"
echo "\`cd devfront && npm ci\`"
echo
echo "## Install Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/devfront-install.log
echo '```'
} > reports/devfront-test-failure-report.md
exit 1
fi
- name: Provision browsers for devfront tests
run: |
set +e
cd devfront
npx playwright install --with-deps 2>&1 | tee ../reports/devfront-provision.log
provision_exit_code=${PIPESTATUS[0]}
cd ..
set -e
if [ "$provision_exit_code" -ne 0 ]; then
{
echo "# Devfront Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`devfront-tests\`"
echo "- Reason: \`Browser provisioning failed\`"
echo "- Exit Code: \`$provision_exit_code\`"
echo
echo "## Command"
echo "\`cd devfront && npx playwright install --with-deps\`"
echo
echo "## Provision Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/devfront-provision.log
echo '```'
} > reports/devfront-test-failure-report.md
exit 1
fi
- name: Run devfront tests
env:
PLAYWRIGHT_WORKERS: 2
run: |
mkdir -p reports
set +e
cd devfront
npm test 2>&1 | tee ../reports/devfront-test.log
test_exit_code=${PIPESTATUS[0]}
cd ..
set -e
if [ "$test_exit_code" -ne 0 ]; then
{
echo "# Devfront Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`devfront-tests\`"
echo "- Exit Code: \`$test_exit_code\`"
echo
echo "## Commands"
echo "1. \`cd devfront\`"
echo "2. \`npm ci\`"
echo "3. \`npx playwright install --with-deps\`"
echo "4. \`npm test\`"
echo
echo "## Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/devfront-test.log
echo '```'
} > reports/devfront-test-failure-report.md
fi
exit "$test_exit_code"
- name: Ensure devfront failure report exists
if: ${{ failure() }}
run: |
mkdir -p reports
if [ -f reports/devfront-test-failure-report.md ]; then
exit 0
fi
{
echo "# Devfront Test Failure Report"
echo
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
echo "- Job: \`devfront-tests\`"
echo "- Reason: \`Job failed before detailed report generation\`"
echo
if [ -f reports/devfront-install.log ]; then
echo "## Install Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/devfront-install.log
echo '```'
echo
fi
if [ -f reports/devfront-provision.log ]; then
echo "## Provision Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/devfront-provision.log
echo '```'
echo
fi
if [ -f reports/devfront-test.log ]; then
echo "## Test Log Tail (last 200 lines)"
echo '```text'
tail -n 200 reports/devfront-test.log
echo '```'
fi
} > reports/devfront-test-failure-report.md
- name: Publish devfront failure summary
if: ${{ failure() }}
run: |
if [ -f reports/devfront-test-failure-report.md ]; then
cat reports/devfront-test-failure-report.md >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload devfront failure report artifact
if: ${{ failure() }}
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: devfront-test-failure-report
path: |
reports/devfront-test-failure-report.md
reports/devfront-install.log
reports/devfront-provision.log
reports/devfront-test.log
devfront/playwright-report
devfront/test-results
if-no-files-found: ignore

View File

@@ -109,8 +109,6 @@ jobs:
"COOKIE_SECRET=${{ secrets.PROD_COOKIE_SECRET }}" \
"JWT_SECRET=${{ secrets.PROD_JWT_SECRET }}" \
"REDIS_ADDR=${{ vars.PROD_REDIS_ADDR }}" \
"DESCOPE_PROJECT_ID=${{ vars.DESCOPE_PROJECT_ID }}" \
"DESCOPE_MANAGEMENT_KEY=${{ secrets.DESCOPE_MANAGEMENT_KEY }}" \
"NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}" \
"NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}" \
"NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}" \

View File

@@ -0,0 +1,194 @@
name: Release Baron SSO to Staging
on:
workflow_dispatch:
inputs:
target_branch:
description: "Branch to deploy"
required: true
default: "dev"
jobs:
deploy-staging:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup SSH
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }}
- name: Deploy to Staging by git pull
env:
DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
STAGE_HOST: ${{ vars.STAGE_HOST }}
STAGE_USER: ${{ vars.STAGE_USER }}
TARGET_BRANCH: ${{ inputs.target_branch }}
run: |
set -euo pipefail
echo "DEBUG: STAGE_USER='${STAGE_USER}'"
echo "DEBUG: STAGE_HOST='${STAGE_HOST}'"
echo "DEBUG: DEPLOY_PATH='${DEPLOY_PATH}'"
echo "DEBUG: TARGET_BRANCH='${TARGET_BRANCH}'"
# Sanity check
if [ -z "${STAGE_USER}" ] || [ -z "${STAGE_HOST}" ] || [ -z "${DEPLOY_PATH}" ] || [ -z "${TARGET_BRANCH}" ]; then
echo "::error::Missing required vars (STAGE_USER/STAGE_HOST/DEPLOY_PATH/TARGET_BRANCH)."
exit 1
fi
ssh-keyscan -H "${STAGE_HOST}" >> ~/.ssh/known_hosts
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}'"
# .env 파일 생성
cat <<'EOF' > .env
APP_ENV=stage
BACKEND_LOG_LEVEL=debug
CLIENT_LOG_DEBUG=true
TZ=Asia/Seoul
IDP_PROVIDER=ory
# DB & Clickhouse
DB_PORT=${{ vars.DB_PORT }}
CLICKHOUSE_PORT_HTTP=${{ vars.CLICKHOUSE_PORT_HTTP }}
CLICKHOUSE_PORT_NATIVE=${{ vars.CLICKHOUSE_PORT_NATIVE }}
CLICKHOUSE_HOST=${{ vars.CLICKHOUSE_HOST }}
CLICKHOUSE_USER=${{ vars.CLICKHOUSE_USER }}
CLICKHOUSE_PASSWORD=${{ secrets.CLICKHOUSE_PASSWORD }}
BACKEND_PORT=${{ vars.BACKEND_PORT }}
ADMINFRONT_PORT=${{ vars.ADMINFRONT_PORT }}
DEVFRONT_PORT=${{ vars.DEVFRONT_PORT }}
USERFRONT_PORT=${{ vars.USERFRONT_PORT }}
OATHKEEPER_API_URL=${{ vars.OATHKEEPER_API_URL }}
DB_USER=${{ vars.DB_USER }}
DB_PASSWORD=${{ secrets.STG_DB_PASSWORD }}
DB_NAME=${{ vars.DB_NAME }}
COOKIE_SECRET=${{ secrets.STG_COOKIE_SECRET }}
JWT_SECRET=${{ secrets.STG_JWT_SECRET }}
REDIS_ADDR=${{ vars.REDIS_ADDR }}
CORS_ALLOWED_ORIGINS=${{ vars.CORS_ALLOWED_ORIGINS }}
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
DESCOPE_TEST_ACCOUNT=${{ vars.DESCOPE_TEST_ACCOUNT }}
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
NAVER_SENDER_PHONE_NUMBER=${{ vars.NAVER_SENDER_PHONE_NUMBER }}
AWS_REGION=${{ vars.AWS_REGION }}
AWS_ACCESS_KEY_ID=${{ vars.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY=${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_SES_SENDER=${{ vars.AWS_SES_SENDER }}
ADMIN_EMAIL=${{ vars.ADMIN_EMAIL }}
ADMIN_PASSWORD=${{ secrets.STG_ADMIN_PASSWORD }}
USERFRONT_URL=${{ vars.USERFRONT_URL }}
ADMINFRONT_URL=${{ vars.ADMINFRONT_URL }}
DEVFRONT_URL=${{ vars.DEVFRONT_URL }}
VITE_ORGCHART_URL=${{ vars.VITE_ORGCHART_URL }}
BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}
BACKEND_URL=${{ vars.BACKEND_URL }}
OATHKEEPER_PUBLIC_URL=${{ vars.OATHKEEPER_PUBLIC_URL }}
ORY_POSTGRES_TAG=${{ vars.ORY_POSTGRES_TAG }}
ORY_POSTGRES_USER=${{ vars.ORY_POSTGRES_USER }}
ORY_POSTGRES_PASSWORD=${{ secrets.STG_ORY_POSTGRES_PASSWORD }}
ORY_POSTGRES_DB=${{ vars.ORY_POSTGRES_DB }}
KRATOS_DB=${{ vars.KRATOS_DB }}
HYDRA_DB=${{ vars.HYDRA_DB }}
KETO_DB=${{ vars.KETO_DB }}
KRATOS_VERSION=${{ vars.KRATOS_VERSION }}
KRATOS_UI_NODE_VERSION=${{ vars.KRATOS_UI_NODE_VERSION }}
HYDRA_VERSION=${{ vars.HYDRA_VERSION }}
KETO_VERSION=${{ vars.KETO_VERSION }}
ORY_SDK_URL=${{ vars.ORY_SDK_URL }}
KRATOS_PUBLIC_URL=${{ vars.KRATOS_PUBLIC_URL }}
KRATOS_ADMIN_URL=${{ vars.KRATOS_ADMIN_URL }}
KRATOS_BROWSER_URL=${{ vars.KRATOS_BROWSER_URL }}
KRATOS_UI_URL=${{ vars.KRATOS_UI_URL }}
HYDRA_ADMIN_URL=${{ vars.HYDRA_ADMIN_URL }}
HYDRA_PUBLIC_URL=${{ vars.HYDRA_PUBLIC_URL }}
JWKS_URL=${{ vars.JWKS_URL }}
OATHKEEPER_VERSION=${{ vars.OATHKEEPER_VERSION }}
OATHKEEPER_UID=${{ vars.OATHKEEPER_UID }}
OATHKEEPER_GID=${{ vars.OATHKEEPER_GID }}
OATHKEEPER_HEALTH_URL=${{ vars.OATHKEEPER_HEALTH_URL }}
OATHKEEPER_HEALTH_INTERVAL_SECONDS=${{ vars.OATHKEEPER_HEALTH_INTERVAL_SECONDS }}
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=${{ vars.OATHKEEPER_HEALTH_TIMEOUT_SECONDS }}
OATHKEEPER_HEALTH_ENABLED=${{ vars.OATHKEEPER_HEALTH_ENABLED }}
CSRF_COOKIE_NAME=${{ vars.CSRF_COOKIE_NAME }}
CSRF_COOKIE_SECRET=${{ secrets.STG_CSRF_COOKIE_SECRET }}
# Frontend OIDC configs for Staging
VITE_OIDC_AUTHORITY=https://sso.hmac.kr/oidc
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback,https://sso.hmac.kr/auth/callback,http://172.16.10.176:5173/auth/callback,https://sadmin.hmac.kr/auth/callback
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback,https://sso.hmac.kr/devfront/auth/callback,http://172.16.10.176:5174/auth/callback,https://sdev.hmac.kr/auth/callback
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback,http://172.16.10.176:5175/auth/callback,https://baron-orgchart.hmac.kr/auth/callback
# OATHKEEPER_INTROSPECT_CLIENT_ID=${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }}
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
EOF
# 코드 업데이트 (Git)
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \
if [ ! -d .git ]; then
git init
git remote add origin ssh://git@172.16.10.175:222/baron/baron-sso.git
else
git remote set-url origin ssh://git@172.16.10.175:222/baron/baron-sso.git
fi
git fetch --depth 1 origin '${TARGET_BRANCH}' && \
git reset --hard FETCH_HEAD && \
git clean -fd && \
git checkout -B '${TARGET_BRANCH}' FETCH_HEAD"
# .env 파일 복사
scp .env "${STAGE_USER}@${STAGE_HOST}:${DEPLOY_PATH}/"
# 배포 실행
ssh "${STAGE_USER}@${STAGE_HOST}" "DEPLOY_PATH='${DEPLOY_PATH}' bash -s" <<'EOSSH'
set -euo pipefail
cd "${DEPLOY_PATH}"
set -a; . ./.env; set +a;
# 네트워크 생성
for net in baron_net public_net ory-net hydranet kratosnet; do
docker network inspect "${net}" >/dev/null 2>&1 || docker network create "${net}"
done
# [중요] 설정 파일 권한 문제 해결 (Ory 이미지는 root가 아닌 사용자로 실행됨)
chmod -R 777 docker/ory || true
cp docker/staging_pull_compose.template.yaml staging_pull_compose.yaml
docker compose -f staging_pull_compose.yaml pull
# 코드 변경 반영을 위해 build 수행 (userfront nginx.conf 등)
docker compose -f staging_pull_compose.yaml build --pull
docker compose -f staging_pull_compose.yaml up -d --remove-orphans
docker compose -f staging_pull_compose.yaml up -d init-rp
# 배포 후 상태 확인 (실패 시 로그 출력을 위함)
sleep 10
echo "===== INIT-RP LOGS ====="
docker compose -f staging_pull_compose.yaml logs init-rp || true
echo "========================"
kratos_migrate_cid="$(docker compose -f staging_pull_compose.yaml ps -q kratos-migrate || true)"
if [ -n "${kratos_migrate_cid}" ]; then
if [ "$(docker inspect -f '{{.State.ExitCode}}' "${kratos_migrate_cid}")" -ne 0 ]; then
echo 'Kratos Migrate Failed. Logs:'
docker logs "${kratos_migrate_cid}"
exit 1
fi
else
echo "WARN: kratos-migrate container not found; skipping exit-code check."
fi
EOSSH

View File

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

12
.gitignore vendored
View File

@@ -6,14 +6,20 @@
.vscode/
.codex
.codex/
.serena/
.generated/
*.swp
*.log
*.out
*.exe
.npm-cache/
reports
reports/*
# Docker Services Data (Volumes)
postgres_data/
clickhouse_data/
docker/ory/oathkeeper/logs/
# Backend (Go)
backend/main
@@ -30,3 +36,9 @@ userfront/.dart_tool/
userfront/.packages
userfront/.pub/
userfront/.env
# Frontend test artifacts
adminfront/test-results/
devfront/test-results/
adminfront/playwright-report/
devfront/playwright-report/

221
Makefile
View File

@@ -10,29 +10,50 @@ endif
COMPOSE_INFRA := compose.infra.yaml
COMPOSE_ORY := compose.ory.yaml
COMPOSE_APP := docker-compose.yaml
AUTH_CONFIG_ENV := .generated/auth-config.env
COMPOSE_CLI_ENV_ARGS :=
ifneq (,$(wildcard ./.env))
COMPOSE_CLI_ENV_ARGS += --env-file .env
endif
COMPOSE_CLI_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
# --- 인증 설정 빌드/검증 ---
build-auth-config:
@echo "Building auth config..."
@mkdir -p .generated
@bash scripts/auth_config.sh build
validate-auth-config: build-auth-config
@echo "Validating auth config..."
@bash scripts/auth_config.sh validate
verify-auth-config: validate-auth-config
@echo "Verifying auth config wiring..."
@bash scripts/auth_config.sh verify
# --- 기본 실행 ---
# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음)
up-all:
up-all: validate-auth-config
@echo "Starting ALL stacks (infra + ory + app)..."
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d
# --- 개별 스택 실행 ---
up-infra:
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
docker compose -f $(COMPOSE_INFRA) up -d
up-ory:
up-ory: validate-auth-config
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
docker compose -f $(COMPOSE_ORY) up -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
up-app:
up-app: validate-auth-config
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
docker compose -f $(COMPOSE_APP) up -d
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d
up-backend:
up-backend: validate-auth-config
@echo "Starting Backend only..."
docker compose -f $(COMPOSE_APP) up -d backend
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d backend
up-dev: up-infra up-ory
@echo "Dev stack is up (infra + ory)."
@@ -41,7 +62,7 @@ up-front-dev: up-infra up-ory up-backend
@echo "Dev stack is up (infra + ory + backend)."
# --- 종료 (Down) ---
down-all:
down:
@echo "Stopping ALL stacks (infra + ory + app)..."
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
@@ -84,3 +105,185 @@ logs-ory:
logs-app:
docker compose -f $(COMPOSE_APP) logs -f
# --- 로컬 통합 코드 체크 ---
PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright
PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE
PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE
PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE
ifeq ($(CI),)
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi'
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
else
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium
endif
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests
CODE_CHECK_TEST_JOBS ?= 1
PLAYWRIGHT_WORKERS ?= 1
FLUTTER_TEST_CONCURRENCY ?= 1
code-check: code-check-lint code-check-test-jobs
@echo "code-check complete."
code-check-lint: code-check-i18n code-check-i18n-values code-check-front-lint code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint
code-check-test-jobs:
@echo "==> run CI-equivalent test jobs (parallel)"
@$(MAKE) --no-print-directory -j$(CODE_CHECK_TEST_JOBS) --output-sync=target \
code-check-backend-tests \
code-check-userfront-tests \
code-check-userfront-e2e-tests \
code-check-adminfront-tests \
code-check-devfront-tests
code-check-i18n:
@echo "==> i18n resource check"
@mkdir -p reports
node tools/i18n-scanner/index.js
node tools/i18n-scanner/report.js
@cat reports/i18n-report.txt
code-check-i18n-values:
@echo "==> i18n value quality check"
@mkdir -p reports
node tools/i18n-scanner/value-check.js
@cat reports/i18n-value-report.txt
code-check-go-lint:
@echo "==> go lint/format check"
@if command -v golangci-lint >/dev/null 2>&1; then \
cd backend && golangci-lint fmt -E gofmt -E gofumpt -d; \
elif command -v docker >/dev/null 2>&1; then \
docker run --rm \
-v "$$(pwd)/backend:/app" \
-w /app \
golangci/golangci-lint:v2.10.1 \
golangci-lint fmt -E gofmt -E gofumpt -d; \
else \
echo "ERROR: golangci-lint not found and docker is unavailable."; \
echo "Install golangci-lint v2.10.1 or Docker to match CI lint step."; \
exit 1; \
fi
code-check-sync-userfront-locales:
@echo "==> sync userfront locales"
/bin/sh ./scripts/sync_userfront_locales.sh
code-check-userfront-install:
@echo "==> install userfront dependencies"
cd userfront && flutter pub get
code-check-userfront-lint:
@echo "==> userfront format/analyze"
cd userfront && dart format --output=none --set-exit-if-changed lib test
cd userfront && flutter analyze --no-fatal-warnings --no-fatal-infos
code-check-front-lint:
@echo "==> adminfront biome lint/format check"
rm -rf adminfront/playwright-report adminfront/test-results
cd adminfront && npm ci
cd adminfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd adminfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
@echo "==> devfront biome lint/format check"
rm -rf devfront/playwright-report devfront/test-results
cd devfront && npm ci
cd devfront && npx biome check . --formatter-enabled=false --organize-imports-enabled=false
cd devfront && npx biome check . --linter-enabled=false --organize-imports-enabled=false
code-check-backend-tests:
@echo "==> backend tests"
cd backend && GOCACHE=/tmp/baron-sso-go-cache go test -v ./...
code-check-userfront-tests:
@echo "==> userfront tests (isolated workspace)"
@tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-tests.XXXXXX)"; \
trap 'rm -rf "$$tmp_dir"' EXIT INT TERM; \
mkdir -p "$$tmp_dir/scripts"; \
cp scripts/sync_userfront_locales.sh "$$tmp_dir/scripts/"; \
cp -R locales "$$tmp_dir/locales"; \
if command -v rsync >/dev/null 2>&1; then \
rsync -a --delete \
--exclude '.dart_tool' \
--exclude 'build' \
--exclude '.pub-cache' \
--exclude '.flutter-plugins' \
--exclude '.flutter-plugins-dependencies' \
userfront/ "$$tmp_dir/userfront/"; \
else \
cp -R userfront "$$tmp_dir/userfront"; \
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
fi; \
cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh; \
cd "$$tmp_dir/userfront" && flutter test --concurrency=$(FLUTTER_TEST_CONCURRENCY)
code-check-adminfront-tests:
@echo "==> adminfront tests"
PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) ./scripts/run_adminfront_ci_tests.sh adminfront-tests
code-check-devfront-tests:
@echo "==> devfront tests"
@mkdir -p reports/devfront
@rm -rf reports/devfront/playwright-report reports/devfront/test-results
@status=0; \
(cd devfront && npm ci) || status=$$?; \
if [ $$status -eq 0 ]; then \
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd devfront && PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \
[ -d devfront/playwright-report ] && cp -R devfront/playwright-report reports/devfront/ || true; \
[ -d devfront/test-results ] && cp -R devfront/test-results reports/devfront/ || true; \
exit $$status
code-check-userfront-e2e-tests:
@echo "==> userfront wasm playwright e2e tests (isolated workspace)"
@mkdir -p reports/userfront-e2e
@rm -rf reports/userfront-e2e/playwright-report reports/userfront-e2e/test-results
@tmp_dir="$$(mktemp -d /tmp/baron-sso-userfront-e2e-tests.XXXXXX)"; \
trap 'rm -rf "$$tmp_dir"' EXIT INT TERM; \
mkdir -p "$$tmp_dir/scripts"; \
cp scripts/sync_userfront_locales.sh "$$tmp_dir/scripts/"; \
cp -R locales "$$tmp_dir/locales"; \
if command -v rsync >/dev/null 2>&1; then \
rsync -a --delete \
--exclude '.dart_tool' \
--exclude 'build' \
--exclude '.pub-cache' \
--exclude '.flutter-plugins' \
--exclude '.flutter-plugins-dependencies' \
userfront/ "$$tmp_dir/userfront/"; \
rsync -a --delete \
--exclude 'node_modules' \
--exclude 'playwright-report' \
--exclude 'test-results' \
userfront-e2e/ "$$tmp_dir/userfront-e2e/"; \
else \
cp -R userfront "$$tmp_dir/userfront"; \
rm -rf "$$tmp_dir/userfront/.dart_tool" "$$tmp_dir/userfront/build"; \
cp -R userfront-e2e "$$tmp_dir/userfront-e2e"; \
rm -rf "$$tmp_dir/userfront-e2e/node_modules" "$$tmp_dir/userfront-e2e/playwright-report" "$$tmp_dir/userfront-e2e/test-results"; \
fi; \
status=0; \
(cd "$$tmp_dir" && /bin/sh ./scripts/sync_userfront_locales.sh) || status=$$?; \
if [ $$status -eq 0 ]; then \
(cd "$$tmp_dir/userfront-e2e" && npm ci) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd "$$tmp_dir/userfront" && flutter build web --wasm --release) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_CHROMIUM)) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \
echo "==> userfront-e2e using PORT=$$port"; \
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port PLAYWRIGHT_WORKERS=$(PLAYWRIGHT_WORKERS) npm test) || status=$$?; \
fi; \
[ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \
[ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \
exit $$status

360
README.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -3,19 +3,27 @@
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"dev": "vite --host 0.0.0.0",
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"lint": "biome check .",
"lint:fix": "biome check . --write",
"format": "biome format . --write",
"preview": "vite preview",
"test": "playwright test",
"test:ui": "playwright test --ui"
"test": "npx playwright test",
"test:unit": "vitest run",
"test:ui": "npx playwright test --ui",
"i18n-scan": "cd .. && node tools/i18n-scanner/index.js && node tools/i18n-scanner/report.js"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-scroll-area": "^1.1.2",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
@@ -24,9 +32,11 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"oidc-client-ts": "^3.4.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.71.1",
"react-oidc-context": "^3.3.0",
"react-router-dom": "^6.28.2",
"tailwind-merge": "^3.4.0",
"zod": "^3.24.1"
@@ -34,18 +44,21 @@
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@playwright/test": "^1.58.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.23",
"jsdom": "^28.1.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.2.5"
},
"overrides": {
"vite": "npm:rolldown-vite@7.2.5"
"vite": "^8.0.3",
"vitest": "^4.0.18"
}
}

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env sh
set -eu
app_env="$(printf '%s' "${APP_ENV:-development}" | tr '[:upper:]' '[:lower:]')"
case "$app_env" in
production|prod|stage|staging)
mode="production"
;;
*)
mode="development"
;;
esac
if [ "${1:-}" = "--print-mode" ]; then
printf '%s\n' "$mode"
exit 0
fi
if [ "$mode" = "production" ]; then
echo "Running in production mode with Vite preview..."
exec sh -c "npm run build && npm run preview -- --host 0.0.0.0"
fi
echo "Running in development mode..."
exec npm run dev -- --host 0.0.0.0

View File

@@ -3,26 +3,37 @@ import AppLayout from "../components/layout/AppLayout";
import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import LoginPage from "../features/auth/LoginPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage";
import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
export const router = createBrowserRouter(
[
{
path: "/login",
element: <LoginPage />,
},
{
path: "/auth/callback",
element: <AuthCallbackPage />,
},
{
path: "/",
element: <AppLayout />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "dashboard", element: <DashboardPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
@@ -35,18 +46,19 @@ export const router = createBrowserRouter(
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
],
},
{
path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />,
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
],
},
],
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
{
future: {
v7_startTransition: true,
},
} as unknown as Parameters<typeof createBrowserRouter>[1],
// React Router v7 플래그는 Provider에서 적용합니다.
);

View File

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

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { t } from "../../lib/i18n";
const LOCALE_STORAGE_KEY = "locale";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
function resolveLocale(): Locale {
if (typeof window === "undefined") {
return "ko";
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored === "ko" || stored === "en") {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale === "ko" || pathLocale === "en") {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
return browserLang.startsWith("ko") ? "ko" : "en";
}
function LanguageSelector() {
const [locale, setLocale] = useState<Locale>(resolveLocale());
const handleChange = (next: Locale) => {
if (next === locale) {
return;
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
setLocale(next);
window.location.reload();
};
return (
<select
value={locale}
onChange={(event) => handleChange(event.target.value as Locale)}
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"
aria-label={t("ui.common.language", "언어")}
>
<option value="ko">{t("ui.common.language_ko", "한국어")}</option>
<option value="en">{t("ui.common.language_en", "English")}</option>
</select>
);
}
export default LanguageSelector;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface CheckboxProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange"> {
onCheckedChange?: (checked: boolean | "indeterminate") => void;
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onCheckedChange?.(e.target.checked);
};
return (
<input
type="checkbox"
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary",
className,
)}
onChange={handleChange}
ref={ref}
{...props}
/>
);
},
);
Checkbox.displayName = "Checkbox";
export { Checkbox };

View File

@@ -0,0 +1,120 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,243 +0,0 @@
import {
Activity,
ArrowRight,
Building2,
CheckCircle2,
LineChart,
Radio,
ShieldCheck,
Sparkles,
} from "lucide-react";
const guardHighlights = [
{
title: "Tenant isolation",
body: "All admin calls expect X-Tenant-ID and are prepared for tenant-aware headers.",
metric: "Header guard",
accent: "active",
},
{
title: "Admin TTL",
body: "Session budget kept short for admins. App session vs admin session split is explicit.",
metric: "15m default",
accent: "ttl",
},
{
title: "Audit-first",
body: "Every management action should log to ClickHouse. Hooks in place for later wiring.",
metric: "per-action",
accent: "audit",
},
];
const stackReadiness = [
"React 19 + Vite 7, strict TS, Router v6 data router.",
"TanStack Query 5 provider ready with sane defaults.",
"Axios client stub with bearer + tenant header injector.",
"Tailwind v4 tokens tuned for admin dark plane.",
"React Hook Form + Zod planned for client forms.",
"IdP-neutral auth hook point reserved for role guard.",
];
const nextSteps = [
"Add IdP-neutral OIDC/OAuth auth layer and enforce admin role in RequireAuth.",
"Persist tenant picklist and feed X-Tenant-ID for every admin call.",
"Add shadcn/ui primitives for forms and tables; lock lint/format.",
];
function DashboardPage() {
return (
<div className="space-y-10">
<section className="relative overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-7 shadow-[var(--shadow-card)]">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_24%_20%,rgba(54,211,153,0.14),transparent_32%)]" />
<div className="relative flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
<div className="space-y-3 max-w-2xl">
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-1 text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
<Sparkles size={14} />
adminfront ready
</div>
<h2 className="text-3xl font-semibold leading-tight">
Build the admin plane with{" "}
<span className="text-[var(--color-accent)]">tenant-aware</span>{" "}
defaults and{" "}
<span className="text-[var(--color-accent-strong)]">
least privilege
</span>{" "}
UX.
</h2>
<p className="text-[var(--color-muted)]">
Route, query, and styling scaffolds are in place. Use this canvas
to ship RP registry, audit exploration, and guarded login aligned
with issue #60 while keeping providers swappable.
</p>
<div className="flex flex-wrap gap-3 text-sm">
<span className="rounded-full bg-[rgba(54,211,153,0.16)] px-3 py-2 text-[var(--color-accent)]">
Router + Query wired
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-[var(--color-muted)]">
Admin namespace only
</span>
<span className="rounded-full bg-[rgba(249,168,38,0.16)] px-3 py-2 font-semibold text-[var(--color-accent-strong)]">
Auth hook pending
</span>
</div>
</div>
<div className="grid gap-3 text-sm">
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<ShieldCheck size={16} />
Admin guard scoped to /admin
</div>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<Building2 size={16} />
Tenant selection placeholder ready
</div>
<div className="flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3 text-[var(--color-muted)]">
<Radio size={16} />
Audit stream hook for ClickHouse
</div>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{guardHighlights.map((item) => (
<div
key={item.title}
className="relative overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5 transition hover:-translate-y-1 hover:shadow-[0_16px_48px_rgba(7,15,26,0.4)]"
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_25%_25%,rgba(54,211,153,0.12),transparent_45%)]" />
<div className="relative flex items-center justify-between gap-2">
<div className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
{item.metric}
</div>
<span className="rounded-full border border-[var(--color-border)] px-3 py-1 text-[11px] text-[var(--color-muted)]">
{item.accent}
</span>
</div>
<div className="relative mt-3 space-y-2">
<h3 className="text-lg font-semibold">{item.title}</h3>
<p className="text-sm text-[var(--color-muted)]">{item.body}</p>
</div>
</div>
))}
</section>
<section className="grid gap-6 md:grid-cols-[1.2fr,0.8fr]">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Stack readiness
</p>
<h3 className="text-xl font-semibold">Matches issue #60</h3>
</div>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)] transition hover:border-[var(--color-accent)] hover:text-[var(--color-accent)]"
>
Setup notes
<ArrowRight size={14} />
</button>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
{stackReadiness.map((item) => (
<div
key={item}
className="flex items-center gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
>
<CheckCircle2
size={16}
className="text-[var(--color-accent)]"
/>
<p className="text-sm">{item}</p>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Next actions
</p>
<h3 className="mt-2 text-xl font-semibold">
Ship the first guarded flows
</h3>
<div className="mt-4 space-y-3">
{nextSteps.map((item, idx) => (
<div
key={item}
className="flex gap-3 rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] px-4 py-3"
>
<div className="grid h-8 w-8 place-items-center rounded-full bg-[rgba(249,168,38,0.12)] text-sm font-semibold text-[var(--color-accent-strong)]">
{idx + 1}
</div>
<p className="text-sm text-[var(--color-text)]">{item}</p>
</div>
))}
</div>
</div>
</section>
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Ops board
</p>
<h3 className="text-xl font-semibold">What to prototype next</h3>
</div>
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
Audit ClickHouse
</span>
<span className="rounded-full border border-[var(--color-border)] px-3 py-2">
RP registry
</span>
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-3">
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<LineChart size={16} />
<span className="text-xs uppercase tracking-[0.16em]">
Metrics
</span>
</div>
<h4 className="mt-2 text-lg font-semibold">
RP registration funnel
</h4>
<p className="text-sm text-[var(--color-muted)]">
Visualize create secret rotate redirect URI edits per tenant.
</p>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Activity size={16} />
<span className="text-xs uppercase tracking-[0.16em]">Audit</span>
</div>
<h4 className="mt-2 text-lg font-semibold">Admin action stream</h4>
<p className="text-sm text-[var(--color-muted)]">
Live feed of admin API calls with per-action tenant, actor, and
rate-limit outcome.
</p>
</div>
<div className="rounded-xl border border-[var(--color-border)] bg-[rgba(255,255,255,0.02)] p-4">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<ShieldCheck size={16} />
<span className="text-xs uppercase tracking-[0.16em]">
Access
</span>
</div>
<h4 className="mt-2 text-lg font-semibold">Admin login journey</h4>
<p className="text-sm text-[var(--color-muted)]">
Outline SMS + app-based MFA choice and emphasize admin session
TTL with logout.
</p>
</div>
</div>
</section>
</div>
);
}
export default DashboardPage;

View File

@@ -1,14 +1,14 @@
import {
Activity,
ArrowUpRight,
Box,
Database,
Key,
PlusCircle,
ShieldCheck,
Users,
} from "lucide-react";
import { Link } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import { RoleGuard } from "../../components/auth/RoleGuard";
import {
Card,
CardContent,
@@ -16,153 +16,186 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
const summaryCards = [
{
label: "Total Tenants",
value: "-",
hint: "Tenant-aware core",
icon: Users,
},
{
label: "OIDC Clients",
value: "-",
hint: "Hydra registry",
icon: ShieldCheck,
},
{
label: "Audit Events (24h)",
value: "-",
hint: "ClickHouse stream",
icon: Activity,
},
{
label: "Policy Gate",
value: "Planned",
hint: "Keto + Admin checks",
icon: Database,
},
];
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
function GlobalOverviewPage() {
return (
<div className="space-y-10">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Global Overview
</p>
<h2 className="text-3xl font-semibold">
Tenant-independent control plane
<div className="space-y-8 animate-in fade-in duration-500">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.overview.title", "Dashboard")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
<p className="text-muted-foreground">
{t(
"msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="muted">IDP: Ory primary</Badge>
<Badge variant="muted">Fallback: Descope</Badge>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map(({ label, value, hint, icon: Icon }) => (
<Card key={label} className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardDescription>{label}</CardDescription>
<div className="rounded-full border border-[var(--color-border)] p-2 text-[var(--color-muted)]">
<Icon size={16} />
<RoleGuard roles={["super_admin"]}>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.total_tenants", "총 테넌트")}
</CardTitle>
<div className="rounded-full bg-primary/10 p-2 text-primary">
<Users size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-semibold">{value}</div>
<p className="mt-1 text-xs text-[var(--color-muted)]">{hint}</p>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
))}
</div>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.oidc_clients", "연동 클라이언트")}
</CardTitle>
<div className="rounded-full bg-blue-500/10 p-2 text-blue-500">
<ShieldCheck size={16} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
OIDC
</p>
</CardContent>
</Card>
</RoleGuard>
<div className="grid gap-6 lg:grid-cols-[1.4fr,1fr]">
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl">Admin playbook</CardTitle>
<CardDescription>
, , .
</CardDescription>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t(
"ui.admin.overview.summary.audit_events_24h",
"최근 감사 로그 (24h)",
)}
</CardTitle>
<div className="rounded-full bg-orange-500/10 p-2 text-orange-500">
<Activity size={16} />
</div>
</CardHeader>
<CardContent className="space-y-3 text-sm text-[var(--color-muted)]">
<div className="flex items-start gap-3">
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
<ShieldCheck size={14} />
</div>
<div>
<p className="font-semibold text-foreground">
Backend-only IDP access
</p>
<p>
IDP backend를 , Hydra/Kratos
admin .
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-1 rounded-full border border-[var(--color-border)] p-2">
<Box size={14} />
</div>
<div>
<p className="font-semibold text-foreground">
Tenant isolation
</p>
<p>
Tenant , Keto
.
</p>
</div>
</div>
<CardContent>
<div className="text-2xl font-bold">-</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-xl"> </CardTitle>
<CardDescription>
.
</CardDescription>
<Card className="transition-all hover:shadow-md">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{t("ui.admin.overview.summary.policy_gate", "정책 상태")}
</CardTitle>
<div className="rounded-full bg-green-500/10 p-2 text-green-500">
<Database size={16} />
</div>
</CardHeader>
<CardContent className="space-y-3">
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/tenants/new">
<ArrowUpRight size={16} />
</Link>
</Button>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/audit-logs">
<ArrowUpRight size={16} />
</Link>
</Button>
<Button
asChild
className="w-full justify-between"
variant="outline"
>
<Link to="/dashboard">
<ArrowUpRight size={16} />
</Link>
</Button>
<CardContent>
<div className="text-2xl font-bold text-green-600 dark:text-green-500">
Active
</div>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</CardContent>
</Card>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold tracking-tight">
{t("ui.admin.overview.quick_links.title", "빠른 작업")}
</h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<RoleGuard roles={["super_admin"]}>
<Link
to="/tenants/new"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary transition-colors group-hover:bg-primary group-hover:text-primary-foreground">
<PlusCircle size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-primary">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</RoleGuard>
<Link
to="/users"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-blue-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-blue-500/10 text-blue-500 transition-colors group-hover:bg-blue-500 group-hover:text-white">
<Users size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-blue-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
<RoleGuard roles={["super_admin"]}>
<Link
to="/api-keys"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-purple-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10 text-purple-500 transition-colors group-hover:bg-purple-500 group-hover:text-white">
<Key size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-purple-500">
API
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</RoleGuard>
<Link
to="/audit-logs"
className="group flex flex-col justify-between rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-orange-500/50 hover:shadow-md"
>
<div className="mb-4 inline-flex h-10 w-10 items-center justify-center rounded-lg bg-orange-500/10 text-orange-500 transition-colors group-hover:bg-orange-500 group-hover:text-white">
<Activity size={20} />
</div>
<div>
<h4 className="font-semibold transition-colors group-hover:text-orange-500">
</h4>
<p className="mt-1 text-xs text-muted-foreground">
.
</p>
</div>
</Link>
</div>
</div>
<RoleGuard roles={["super_admin"]}>
<div className="pt-4">
<PermissionChecker />
</div>
</RoleGuard>
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react";
import { useState } from "react";
@@ -15,21 +15,31 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { createTenant } from "../../../lib/adminApi";
import { createTenant, fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantCreatePage() {
const navigate = useNavigate();
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [parentId, setParentId] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
const parentQuery = useQuery({
queryKey: ["tenants", { limit: 1000 }],
queryFn: () => fetchTenants(1000, 0),
});
const mutation = useMutation({
mutationFn: () =>
createTenant({
name,
type,
slug: slug || undefined,
parentId: parentId || undefined,
description: description || undefined,
status,
domains: domains
@@ -48,19 +58,18 @@ function TenantCreatePage() {
return (
<div className="space-y-8">
<header className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>/</span>
<span className="text-foreground">Create</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-3xl font-semibold"> </h2>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.create.title", "테넌트 생성")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
{t(
"msg.admin.tenants.create.subtitle",
"새로운 테넌트를 시스템에 등록합니다.",
)}
</p>
</div>
<Badge variant="muted">Admin only</Badge>
</div>
</header>
@@ -68,65 +77,159 @@ function TenantCreatePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} />
Tenant Profile
{t("ui.admin.tenants.create.profile.title", "Tenant Profile")}
</CardTitle>
<CardDescription>
. Slug는 .
{t(
"msg.admin.tenants.create.profile.subtitle",
"필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span>
<Label htmlFor="tenant-name" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
<Input
id="tenant-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.name_placeholder",
"테넌트 이름을 입력하세요",
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-type" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="tenant-type"
name="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="parentId" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
</Label>
<select
id="parentId"
name="parentId"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{parentQuery.data?.items?.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label>
<Input
id="tenant-slug"
name="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="tenant-slug"
placeholder={t(
"ui.admin.tenants.create.form.slug_placeholder",
"tenant-slug",
)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Label
htmlFor="tenant-description"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.create.form.description", "설명")}
</Label>
<Textarea
id="tenant-description"
name="description"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)
<Label htmlFor="tenant-domains" className="text-sm font-semibold">
{t(
"ui.admin.tenants.create.form.domains_label",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<Input
id="tenant-domains"
name="domains"
value={domains}
onChange={(e) => setDomains(e.target.value)}
placeholder="example.com, example.kr"
placeholder={t(
"ui.admin.tenants.create.form.domains_placeholder",
"example.com, example.kr",
)}
/>
<p className="text-xs text-muted-foreground">
Users with these email domains will be automatically assigned to
this tenant.
{t(
"msg.admin.tenants.create.form.domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
Active
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
Inactive
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
@@ -143,26 +246,32 @@ function TenantCreatePage() {
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles size={18} />
{t("ui.admin.tenants.create.memo.title", "정책 메모")}
</CardTitle>
<CardDescription>
Tenant Keto .
{t(
"msg.admin.tenants.create.memo.subtitle",
"Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-[var(--color-muted)]">
, .
{t(
"msg.admin.tenants.create.memo.body",
"생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.",
)}
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3">
<Button variant="outline" onClick={() => navigate("/tenants")}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || name.trim() === ""}
>
{t("ui.common.create", "생성")}
</Button>
</div>
</div>

View File

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

View File

@@ -1,8 +1,24 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
type UseMutationResult,
useMutation,
useQuery,
} from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, RefreshCw, Trash2, Users, UserPlus, UserMinus, Shield } from "lucide-react";
import {
ChevronDown,
ChevronRight,
Plus,
RefreshCw,
Shield,
Trash2,
UserMinus,
UserPlus,
Users,
} from "lucide-react";
import type React from "react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -11,6 +27,8 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import {
Table,
TableBody,
@@ -19,198 +37,584 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { fetchGroups, createGroup, deleteGroup, fetchUsers, addGroupMember, removeGroupMember } from "../../../lib/adminApi";
import { Badge } from "../../../components/ui/badge";
import { toast } from "../../../components/ui/use-toast";
import {
type GroupSummary,
addGroupMember,
createGroup,
deleteGroup,
fetchGroups,
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
type UserGroupNode = GroupSummary & {
children: UserGroupNode[];
isExpanded?: boolean;
};
function buildGroupTree(
groups: GroupSummary[],
parentId: string | null = null,
): UserGroupNode[] {
const nodes: UserGroupNode[] = [];
const childrenOf = new Map<string, UserGroupNode[]>();
// First pass: Initialize all groups as nodes and populate childrenOf map
for (const group of groups) {
childrenOf.set(group.id, []);
}
// Second pass: Populate children
for (const group of groups) {
const node: UserGroupNode = {
...group,
children: childrenOf.get(group.id) ?? [],
};
if (group.parentId === parentId) {
nodes.push(node);
} else {
// Check if the parent exists before adding to children
// This handles cases where a parent might not be in the current 'groups' list (e.g., filtered data)
if (group.parentId && childrenOf.has(group.parentId)) {
childrenOf.get(group.parentId)?.push(node);
} else {
// If parentId exists but parent not found, it's a root level group for this tree view
nodes.push(node);
}
}
}
// Sort children for consistent rendering (optional, but good for UI)
nodes.sort((a, b) => a.name.localeCompare(b.name));
for (const node of nodes) {
node.children.sort((a, b) => a.name.localeCompare(b.name));
}
return nodes;
}
interface UserGroupTreeNodeProps {
node: UserGroupNode;
level: number;
onSelect: (groupId: string) => void;
selectedGroupId: string | null;
onDelete: (groupId: string) => void;
onAddSubGroup: (parentId: string) => void;
addMemberMutation: UseMutationResult<
void,
AxiosError<{ error?: string }>,
{ groupId: string; userId: string }
>;
removeMemberMutation: UseMutationResult<
void,
AxiosError<{ error?: string }>,
{ groupId: string; userId: string }
>;
}
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
node,
level,
onSelect,
selectedGroupId,
onDelete,
onAddSubGroup,
addMemberMutation,
removeMemberMutation,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children.length > 0;
const handleToggleExpand = (e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
};
return (
<>
<TableRow
className={`cursor-pointer ${selectedGroupId === node.id ? "bg-primary/5" : ""}`}
onClick={() => onSelect(node.id)}
>
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
<div className="flex items-center gap-2">
{hasChildren ? (
<Button
variant="ghost"
size="sm"
onClick={handleToggleExpand}
className="h-6 w-6 p-0"
>
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</Button>
) : (
level > 0 && (
<span className="inline-block w-6 text-center">
<ChevronRight
size={16}
className="text-muted-foreground inline-block align-middle"
/>
</span>
)
)}
<Users size={14} className="text-muted-foreground" />
<span className="font-semibold">{node.name}</span>
<Badge variant="secondary" className="text-[10px] font-mono">
{node.unitType || "Team"}
</Badge>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">
{t("msg.admin.groups.members.count", "{{count}} 명", {
count: node.members?.length || 0,
})}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddSubGroup(node.id);
}}
>
<Plus size={14} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDelete(node.id);
}}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
{isExpanded &&
hasChildren &&
node.children.map((child) => (
<UserGroupTreeNode
key={child.id}
node={child}
level={level + 1}
onSelect={onSelect}
selectedGroupId={selectedGroupId}
onDelete={onDelete}
onAddSubGroup={onAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
/>
))}
</>
);
};
function TenantGroupsPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
const [newGroupParentId, setNewGroupParentId] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
// 그룹 목록 조회
const groupsQuery = useQuery({
queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId!),
enabled: !!tenantId,
});
// 사용자 목록 조회 (멤버 추가용)
const usersQuery = useQuery({
queryKey: ["users", { limit: 100 }],
queryFn: () => fetchUsers(100, 0),
queryFn: () => fetchGroups(tenantId),
enabled: tenantId.length > 0,
});
// 그룹 생성
const createMutation = useMutation({
mutationFn: () => createGroup(tenantId!, { name: newGroupName, description: newGroupDesc }),
mutationFn: () =>
createGroup(tenantId, {
name: newGroupName,
description: newGroupDesc,
unitType: newGroupUnitType,
parentId: newGroupParentId || undefined,
}),
onSuccess: () => {
toast.success(
t(
"msg.admin.groups.list.create_success",
"그룹이 성공적으로 생성되었습니다.",
),
);
groupsQuery.refetch();
setNewGroupName("");
setNewGroupNameDesc("");
setNewGroupUnitType("Team");
setNewGroupParentId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
// 그룹 삭제
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteGroup(id),
onSuccess: () => groupsQuery.refetch(),
mutationFn: (id: string) => deleteGroup(tenantId, id),
onSuccess: () => {
toast.success(
t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."),
);
groupsQuery.refetch();
setSelectedGroupId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.groups.list.delete_error", "그룹 삭제 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
// 멤버 추가
const addMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => addGroupMember(groupId, userId),
onSuccess: () => groupsQuery.refetch(),
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
addGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(
t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
// 멤버 제거
const removeMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => removeGroupMember(groupId, userId),
onSuccess: () => groupsQuery.refetch(),
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
removeGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(
t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
const groupTree = groupsQuery.data
? buildGroupTree(groupsQuery.data, tenantId)
: [];
const handleAddSubGroup = (parentId: string) => {
setNewGroupParentId(parentId);
// Optionally scroll to the create form or highlight it
};
const handleAddMember = (groupId: string) => {
const userId = window.prompt("추가할 사용자의 UUID를 입력하세요:");
const userId = window.prompt(
t(
"msg.admin.groups.prompt.user_id",
"추가할 사용자의 UUID를 입력하세요:",
),
);
if (userId) {
addMemberMutation.mutate({ groupId, userId });
}
};
const currentGroup = groupsQuery.data?.find(g => g.id === selectedGroupId);
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return (
<div className="space-y-6 mt-6">
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
{/* 그룹 생성 폼 */}
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader>
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader className="flex-shrink-0">
<CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />
<Plus size={16} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.create.description",
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className="space-y-4 flex-1 overflow-auto">
<div className="space-y-1">
<Label htmlFor="name"> </Label>
<Input
id="name"
value={newGroupName}
onChange={e => setNewGroupName(e.target.value)}
placeholder="예: 개발팀, 인사팀"
<Label htmlFor="name">
{t("ui.admin.groups.form.name_label", "그룹 이름")}
</Label>
<Input
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t(
"ui.admin.groups.form.name_placeholder",
"예: 개발팀, 인사팀",
)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="desc"></Label>
<Input
id="desc"
value={newGroupDesc}
onChange={e => setNewGroupNameDesc(e.target.value)}
placeholder="그룹 용도 설명"
<Label htmlFor="unitType">
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
</Label>
<Input
id="unitType"
value={newGroupUnitType}
onChange={(e) => setNewGroupUnitType(e.target.value)}
placeholder={t(
"ui.admin.groups.form.unit_level_placeholder",
"예: 본부, 팀, 셀",
)}
/>
</div>
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
<div className="space-y-1">
<Label htmlFor="parentId">
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
</Label>
<select
id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={newGroupParentId || ""}
onChange={(e) => setNewGroupParentId(e.target.value || null)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{groupsQuery.data?.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="desc">
{t("ui.admin.groups.form.desc_label", "설명")}
</Label>
<Input
id="desc"
value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)}
placeholder={t(
"ui.admin.groups.form.desc_placeholder",
"그룹 용도 설명",
)}
/>
</div>
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
</CardContent>
</Card>
{/* 그룹 목록 */}
<Card className="bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
{/* 그룹 목록 (트리 뷰) */}
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>User Groups</CardTitle>
<CardDescription> .</CardDescription>
<CardTitle>
{t("ui.admin.groups.list.title", "User Groups")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.groups.list.subtitle",
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
)}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<OrgChartUploadModal
tenantId={tenantId}
onSuccess={() => groupsQuery.refetch()}
/>
<Button
variant="ghost"
size="sm"
onClick={() => groupsQuery.refetch()}
>
<RefreshCw size={14} />
</Button>
</div>
<Button variant="ghost" size="sm" onClick={() => groupsQuery.refetch()}>
<RefreshCw size={14} />
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>MEMBERS</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.data?.map((group) => (
<TableRow
key={group.id}
className={`cursor-pointer ${selectedGroupId === group.id ? 'bg-primary/5' : ''}`}
onClick={() => setSelectedGroupId(group.id)}
>
<TableCell>
<div className="font-semibold flex items-center gap-2">
<Users size={14} className="text-muted-foreground" />
{group.name}
</div>
<p className="text-[10px] text-muted-foreground">{group.description}</p>
</TableCell>
<TableCell>
<Badge variant="secondary">{group.members?.length || 0} </Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleAddMember(group.id); }}>
<UserPlus size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); deleteMutation.mutate(group.id); }}>
<Trash2 size={14} className="text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.admin.groups.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.groups.table.members", "MEMBERS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.isLoading && (
<TableRow>
<TableCell colSpan={3}>
{t("msg.admin.groups.list.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!groupsQuery.isLoading && groupTree.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.list.empty",
"아직 등록된 그룹이 없습니다.",
)}
</TableCell>
</TableRow>
)}
{groupTree.map((node) => (
<UserGroupTreeNode
key={node.id}
node={node}
level={0}
onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId}
onDelete={(id) => {
if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
"그룹을 삭제하시겠습니까?",
),
)
) {
deleteMutation.mutate(id);
}
}}
onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
/>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
{currentGroup && (
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader>
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader className="flex-shrink-0">
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" />
[{currentGroup.name}]
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
name: currentGroup.name,
})}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"그룹에 속한 멤버들을 확인하고 관리합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow><TableCell colSpan={3} className="text-center py-4 text-muted-foreground"> .</TableCell></TableRow>
)}
{currentGroup.members?.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">{user.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => removeMemberMutation.mutate({ groupId: currentGroup.id, userId: user.id })}
>
<UserMinus size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex justify-end mb-4 flex-shrink-0">
<Button
size="sm"
onClick={() => handleAddMember(currentGroup.id)}
disabled={addMemberMutation.isPending}
>
<UserPlus size={14} className="mr-1" />
{t("ui.common.add", "멤버 추가")}
</Button>
</div>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.admin.groups.members.table.name", "이름")}
</TableHead>
<TableHead>
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.remove", "제거")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-4 text-muted-foreground"
>
{t(
"msg.admin.groups.members.empty",
"멤버가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{currentGroup.members?.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">
{user.name}
</TableCell>
<TableCell className="text-muted-foreground">
{user.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
removeMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
})
}
disabled={removeMemberMutation.isPending}
>
<UserMinus size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
)}

View File

@@ -1,7 +1,16 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
CornerDownRight,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { RoleGuard } from "../../../components/auth/RoleGuard";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
@@ -11,6 +20,8 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Checkbox } from "../../../components/ui/checkbox";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
@@ -19,13 +30,46 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { deleteTenant, fetchTenants } from "../../../lib/adminApi";
import {
type TenantSummary,
deleteTenant,
deleteTenantsBulk,
fetchMe,
fetchTenants,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { OrgChartUploadModal } from "../components/OrgChartUploadModal";
function TenantListPage() {
const navigate = useNavigate();
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
// Redirect tenant_admin ONLY if they have one or fewer manageable tenants in the list
React.useEffect(() => {
if (profile?.role === "tenant_admin") {
const manageableCount = profile.manageableTenants?.length ?? 0;
if (
(manageableCount === 1 || manageableCount === 0) &&
profile.tenantId
) {
navigate(`/tenants/${profile.tenantId}`, { replace: true });
}
}
}, [profile, navigate]);
const query = useQuery({
queryKey: ["tenants", { limit: 50, offset: 0 }],
queryFn: () => fetchTenants(50, 0),
queryKey: ["tenants", { limit: 1000, offset: 0 }],
queryFn: () => fetchTenants(1000, 0),
enabled:
profile?.role === "super_admin" ||
(profile?.role === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) > 1),
});
const deleteMutation = useMutation({
@@ -35,144 +79,335 @@ function TenantListPage() {
},
});
const deleteBulkMutation = useMutation({
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
onSuccess: () => {
setSelectedIds([]);
query.refetch();
},
});
if (
profile &&
profile.role !== "super_admin" &&
profile.role !== "tenant_admin"
) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-xl font-bold">
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
{t("ui.common.go_home", "홈으로 이동")}
</Button>
</div>
);
}
if (
profile?.role === "tenant_admin" &&
(profile.manageableTenants?.length ?? 0) <= 1
) {
return null;
}
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError ? "테넌트 목록 조회에 실패했습니다." : null;
!errorMsg && query.isError
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null;
const items = query.data?.items ?? [];
const allTenants = query.data?.items ?? [];
const tenants = React.useMemo(() => {
if (!search.trim()) return allTenants;
const term = search.toLowerCase();
return allTenants.filter(
(t) =>
t.name.toLowerCase().includes(term) ||
t.slug.toLowerCase().includes(term),
);
}, [allTenants, search]);
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(tenants.map((t) => t.id));
} else {
setSelectedIds([]);
}
};
const handleSelect = (id: string, checked: boolean) => {
if (checked) {
setSelectedIds((prev) => [...prev, id]);
} else {
setSelectedIds((prev) => prev.filter((i) => i !== id));
}
};
const handleDeleteBulk = () => {
if (selectedIds.length === 0) return;
if (
!window.confirm(
t(
"msg.admin.tenants.delete_bulk_confirm",
"선택한 {{count}}개 테넌트를 삭제할까요?",
{ count: selectedIds.length },
),
)
) {
return;
}
deleteBulkMutation.mutate(selectedIds);
};
const rootTenant =
tenants.find((t) => t.type === "COMPANY_GROUP") || tenants[0];
const handleDelete = (tenantId: string, tenantName: string) => {
if (!window.confirm(`테넌트 "${tenantName}"를 삭제할까요?`)) {
if (
!window.confirm(
t(
"msg.admin.tenants.delete_confirm",
'테넌트 "{{name}}"를 삭제할까요?',
{ name: tenantName },
),
)
) {
return;
}
deleteMutation.mutate(tenantId);
};
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>/</span>
<span className="text-foreground">List</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.title", "테넌트 목록")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
.
{t(
"msg.admin.tenants.subtitle",
"시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
<RoleGuard roles={["super_admin"]}>
{selectedIds.length > 0 && (
<Button
variant="destructive"
onClick={handleDeleteBulk}
disabled={deleteBulkMutation.isPending}
className="gap-2"
>
<Trash2 size={16} />
{t("ui.admin.tenants.delete_selected", "선택 삭제")} (
{selectedIds.length})
</Button>
)}
</RoleGuard>
<RoleGuard roles={["super_admin"]}>
<OrgChartUploadModal
tenantId={rootTenant?.id || "root"}
onSuccess={() => query.refetch()}
/>
</RoleGuard>
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
</Button>
<Button asChild>
<Link to="/tenants/new">
<Plus size={16} />
</Link>
{t("ui.common.refresh", "새로고침")}
</Button>
<RoleGuard roles={["super_admin"]}>
<Button asChild>
<Link to="/tenants/new">
<Plus size={16} />
{t("ui.admin.tenants.add", "테넌트 추가")}
</Link>
</Button>
</RoleGuard>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between">
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>Tenant registry</CardTitle>
<CardTitle>
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
</CardTitle>
<CardDescription>
{query.data?.total ?? 0}
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
count: query.data?.total ?? 0,
})}
</CardDescription>
</div>
<Badge variant="muted">Admin only</Badge>
</CardHeader>
<CardContent>
</CardHeader>{" "}
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-6 flex items-center gap-4 flex-shrink-0">
<div className="relative flex-1 min-w-[240px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.list.search_placeholder",
"테넌트 이름 또는 슬러그 검색...",
)}
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
{errorMsg ?? fallbackError}
</div>
)}
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>SLUG</TableHead>
<TableHead>STATUS</TableHead>
<TableHead>UPDATED</TableHead>
<TableHead className="text-right">ACTIONS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}> ...</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}>
.
</TableCell>
</TableRow>
)}
{items.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="font-semibold">{tenant.name}</TableCell>
<TableCell>{tenant.slug}</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
className={
tenant.status === "pending"
? "bg-yellow-100 text-yellow-800"
: ""
}
>
{tenant.status}
</Badge>
</TableCell>
<TableCell>
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="w-[40px]">
<Checkbox
checked={
tenants.length > 0 &&
selectedIds.length === tenants.length
}
onCheckedChange={(checked) =>
handleSelectAll(!!checked)
}
/>
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.type", "TYPE")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.status", "STATUS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.members", "MEMBERS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.updated", "UPDATED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={8}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && tenants.length === 0 && (
<TableRow>
<TableCell
colSpan={8}
className="text-center py-8 text-muted-foreground"
>
<Pencil size={14} />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(tenant.id, tenant.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{t(
"msg.admin.tenants.empty",
"아직 등록된 테넌트가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{tenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(tenant.id)}
onCheckedChange={(checked) =>
handleSelect(tenant.id, !!checked)
}
/>
</TableCell>
<TableCell className="font-semibold">
{tenant.name}
</TableCell>
<TableCell>
<Badge
variant="outline"
className="text-[10px] font-mono"
>
{t(
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
tenant.type,
)}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{tenant.slug}
</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
>
{t(
`ui.common.status.${tenant.status}`,
tenant.status,
)}
</Badge>
</TableCell>
<TableCell className="font-medium">
{tenant.memberCount}
</TableCell>
<TableCell className="text-xs">
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<Pencil size={14} />
{t("ui.common.edit", "편집")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(tenant.id, tenant.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>

View File

@@ -14,12 +14,15 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { toast } from "../../../components/ui/use-toast";
import {
approveTenant,
deleteTenant,
fetchTenant,
fetchTenants,
updateTenant,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export function TenantProfilePage() {
const { tenantId } = useParams<{ tenantId: string }>();
@@ -27,7 +30,9 @@ export function TenantProfilePage() {
const queryClient = useQueryClient();
if (!tenantId) {
return <div>Tenant ID is missing</div>;
return (
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
);
}
const tenantQuery = useQuery({
@@ -35,19 +40,31 @@ export function TenantProfilePage() {
queryFn: () => fetchTenant(tenantId),
});
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchTenants(1000, 0),
});
const availableParents =
parentQuery.data?.items?.filter((t) => t.id !== tenantId) || [];
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
const [parentId, setParentId] = useState("");
useEffect(() => {
if (tenantQuery.data) {
setName(tenantQuery.data.name);
setType(tenantQuery.data.type || "COMPANY");
setSlug(tenantQuery.data.slug);
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
setParentId(tenantQuery.data.parentId ?? "");
}
}, [tenantQuery.data]);
@@ -55,9 +72,11 @@ export function TenantProfilePage() {
mutationFn: () =>
updateTenant(tenantId, {
name,
type,
slug,
description: description || undefined,
status,
parentId: parentId || undefined,
domains: domains
.split(",")
.map((d) => d.trim())
@@ -66,7 +85,13 @@ export function TenantProfilePage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Tenant updated successfully");
toast.success(t("msg.info.saved_success", "저장되었습니다."));
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
@@ -75,7 +100,15 @@ export function TenantProfilePage() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Tenant approved successfully");
toast.success(
t("msg.admin.tenants.approve_success", "테넌트가 승인되었습니다."),
);
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
@@ -83,6 +116,9 @@ export function TenantProfilePage() {
mutationFn: () => deleteTenant(tenantId),
onSuccess: () => {
navigate("/tenants");
toast.success(
t("msg.admin.tenants.delete_success", "테넌트가 삭제되었습니다."),
);
},
});
@@ -92,13 +128,23 @@ export function TenantProfilePage() {
?.response?.data?.error;
const handleDelete = () => {
if (window.confirm("Are you sure you want to delete this tenant?")) {
if (
window.confirm(
t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", {
name: tenantQuery.data?.name ?? "",
}),
)
) {
deleteMutation.mutate();
}
};
const handleApprove = () => {
if (window.confirm("Approve this tenant?")) {
if (
window.confirm(
t("msg.admin.tenants.approve_confirm", "이 테넌트를 승인하시겠습니까?"),
)
) {
approveMutation.mutate();
}
};
@@ -107,9 +153,14 @@ export function TenantProfilePage() {
<>
<Card className="bg-[var(--color-panel)] mt-6">
<CardHeader>
<CardTitle>Tenant profile</CardTitle>
<CardTitle>
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription>
Changes to slug and status are applied immediately.
{t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -120,16 +171,79 @@ export function TenantProfilePage() {
)}
<div className="space-y-2">
<Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span>
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
</Label>
<select
id="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="parentId" className="text-sm font-semibold">
{t("ui.admin.tenants.profile.form.parent", "상위 테넌트 (선택)")}
</Label>
<select
id="parentId"
name="parentId"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
>
<option value="">{t("ui.common.none", "없음 (최상위)")}</option>
{availableParents.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.slug})
</option>
))}
</select>
<p className="text-xs text-muted-foreground mt-1">
{t(
"ui.admin.tenants.profile.form.parent_help",
"하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.description", "설명")}
</Label>
<Textarea
rows={3}
value={description}
@@ -138,7 +252,10 @@ export function TenantProfilePage() {
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)
{t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<Input
value={domains}
@@ -146,26 +263,30 @@ export function TenantProfilePage() {
placeholder="example.com, example.kr"
/>
<p className="text-xs text-muted-foreground">
Users with these email domains will be automatically assigned to
this tenant.
{t(
"ui.admin.tenants.profile.allowed_domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label>
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
Active
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
Inactive
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
@@ -184,7 +305,7 @@ export function TenantProfilePage() {
disabled={deleteMutation.isPending}
>
<Trash2 size={16} />
Delete
{t("ui.common.delete", "삭제")}
</Button>
<div className="flex items-center gap-2">
{status === "pending" && (
@@ -194,11 +315,11 @@ export function TenantProfilePage() {
onClick={handleApprove}
disabled={approveMutation.isPending}
>
Approve Tenant
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
</Button>
)}
<Button variant="outline" onClick={() => navigate("/tenants")}>
Cancel
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => updateMutation.mutate()}
@@ -209,7 +330,7 @@ export function TenantProfilePage() {
}
>
<Save size={16} />
Save
{t("ui.common.save", "저장")}
</Button>
</div>
</div>

View File

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

View File

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

View File

@@ -1,6 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { User, Mail, Phone, ShieldCheck } from "lucide-react";
import { Mail, User } from "lucide-react";
import { useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
@@ -9,80 +16,105 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "../../../components/ui/card";
import { Badge } from "../../../components/ui/badge";
import { fetchUsers, fetchTenant } from "../../../lib/adminApi";
import { fetchTenant, fetchUsers } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantUsersPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
// 테넌트의 슬러그(companyCode)를 먼저 가져옴
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId!),
enabled: !!tenantId,
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const companyCode = tenantQuery.data?.slug;
const tenantSlug = tenantQuery.data?.slug;
// 해당 슬러그로 사용자 검색
const usersQuery = useQuery({
queryKey: ["users", { companyCode }],
queryFn: () => fetchUsers(100, 0, companyCode),
enabled: !!companyCode,
queryKey: ["users", { tenantSlug }],
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
enabled: !!tenantSlug,
});
const users = usersQuery.data?.items ?? [];
return (
<Card className="mt-6 bg-[var(--color-panel)]">
<CardHeader>
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex-shrink-0">
<CardTitle className="flex items-center gap-2">
<User size={18} className="text-primary" />
Tenant Members ({users.length})
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
count: users.length,
})}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>NAME</TableHead>
<TableHead>EMAIL</TableHead>
<TableHead>ROLE</TableHead>
<TableHead>STATUS</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-semibold">{user.name}</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{user.role.replace("_", " ")}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "default" : "muted"}>
{user.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead>
{t("ui.admin.tenants.members.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.email", "EMAIL")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.members.empty",
"소속된 사용자가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-semibold">{user.name}</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{t(
`ui.common.role.${user.role}`,
user.role.replace("_", " "),
)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={user.status === "active" ? "default" : "muted"}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);

View File

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

View File

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

View File

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

View File

@@ -15,18 +15,34 @@ import {
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
createUser,
fetchTenants,
fetchTenant,
type UserCreateRequest,
type UserCreateResponse,
createUser,
fetchMe,
fetchTenant,
fetchTenants,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
type UserSchemaField = {
key: string;
label?: string;
type?: "text" | "number" | "boolean" | "date";
required?: boolean;
adminOnly?: boolean;
validation?: string;
isLoginId?: boolean;
};
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
function UserCreatePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = React.useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = React.useState<
string | null
>(null);
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
const [autoPassword, setAutoPassword] = React.useState(true);
@@ -36,34 +52,78 @@ function UserCreatePage() {
});
const tenants = tenantsData?.items ?? [];
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors },
} = useForm<UserCreateRequest & { metadata: Record<string, any> }>({
} = useForm<UserFormValues>({
defaultValues: {
email: "",
password: "",
name: "",
phone: "",
role: "user",
companyCode: "",
tenantSlug: "",
department: "",
position: "",
jobTitle: "",
metadata: {},
},
});
const selectedCompanyCode = watch("companyCode");
const selectedTenant = tenants.find((t) => t.slug === selectedCompanyCode);
// Lock company for tenant_admin
React.useEffect(() => {
if (profile?.role === "tenant_admin" && profile.tenantSlug) {
setValue("tenantSlug", profile.tenantSlug);
}
}, [profile, setValue]);
const selectedTenantSlug = watch("tenantSlug");
const selectedTenant = tenants.find((t) => t.slug === selectedTenantSlug);
const selectedTenantId = selectedTenant?.id ?? "";
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenant?.id],
queryFn: () => fetchTenant(selectedTenant!.id),
enabled: !!selectedTenant?.id,
queryKey: ["tenant", selectedTenantId],
queryFn: () => fetchTenant(selectedTenantId),
enabled: selectedTenantId.length > 0,
});
const userSchema = (tenantDetail?.config?.userSchema as any[]) ?? [];
const userSchema: UserSchemaField[] = Array.isArray(
tenantDetail?.config?.userSchema,
)
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
const registerMetadata = (field: UserSchemaField) =>
register(`metadata.${field.key}` as `metadata.${string}`, {
required: field.required
? t(
"msg.admin.users.create.form.field_required",
"{{label}}은(는) 필수입니다.",
{
label: field.label || field.key,
},
)
: false,
pattern: field.validation
? {
value: new RegExp(field.validation),
message: t(
"msg.admin.users.create.form.field_invalid",
"{{label}} 형식이 올바르지 않습니다.",
{ label: field.label || field.key },
),
}
: undefined,
});
const mutation = useMutation({
mutationFn: createUser,
@@ -77,26 +137,33 @@ function UserCreatePage() {
navigate("/users");
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(err.response?.data?.error || "사용자 생성에 실패했습니다.");
setError(
err.response?.data?.error ||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
);
},
});
const onSubmit = (data: UserCreateRequest) => {
const onSubmit = (data: UserFormValues) => {
setError(null);
setGeneratedPassword(null);
setCreatedEmail(null);
const payload = { ...data };
if (autoPassword) {
mutation.mutate({ ...data, password: "" });
payload.password = "";
} else if (!data.password) {
setError(
t(
"msg.admin.users.create.password_required",
"비밀번호를 입력하거나 자동 생성을 사용해 주세요.",
),
);
return;
}
if (!data.password) {
setError("비밀번호를 입력하거나 자동 생성을 사용해 주세요.");
return;
}
mutation.mutate(data);
mutation.mutate(payload);
};
const onCopyPassword = async () => {
@@ -112,19 +179,14 @@ function UserCreatePage() {
<div className="max-w-3xl space-y-8">
<header className="flex flex-wrap items-center justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/users" className="hover:underline">
Users
</Link>
<span>/</span>
<span className="text-foreground">New</span>
</div>
<h2 className="text-3xl font-semibold"> </h2>
<h2 className="text-3xl font-semibold">
{t("ui.admin.users.create.title", "사용자 추가")}
</h2>
</div>
<Button variant="ghost" asChild>
<Link to="/users">
<ArrowLeft size={16} className="mr-2" />
{t("ui.admin.users.create.back", "목록으로 돌아가기")}
</Link>
</Button>
</header>
@@ -132,9 +194,23 @@ function UserCreatePage() {
{generatedPassword && (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardTitle>
{t(
"ui.admin.users.create.password_generated.title",
"초기 비밀번호 생성 완료",
)}
</CardTitle>
<CardDescription>
{createdEmail ? `${createdEmail} 계정의 초기 비밀번호입니다.` : "초기 비밀번호가 생성되었습니다."}
{createdEmail
? t(
"msg.admin.users.create.password_generated.with_email",
"{{email}} 계정의 초기 비밀번호입니다.",
{ email: createdEmail },
)
: t(
"msg.admin.users.create.password_generated.default",
"초기 비밀번호가 생성되었습니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@@ -142,11 +218,13 @@ function UserCreatePage() {
<span className="font-mono text-sm">{generatedPassword}</span>
<Button size="sm" variant="outline" onClick={onCopyPassword}>
<ClipboardCopy className="mr-2 h-4 w-4" />
{t("ui.common.copy", "복사")}
</Button>
</div>
<div className="flex justify-end">
<Button onClick={() => navigate("/users")}> </Button>
<Button onClick={() => navigate("/users")}>
{t("ui.admin.users.create.go_list", "목록으로 이동")}
</Button>
</div>
</CardContent>
</Card>
@@ -154,8 +232,15 @@ function UserCreatePage() {
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> .</CardDescription>
<CardTitle>
{t("ui.admin.users.create.account.title", "계정 정보")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.create.account.subtitle",
"새로운 사용자를 시스템에 등록합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
@@ -166,182 +251,274 @@ function UserCreatePage() {
)}
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Label htmlFor="email">
{t("ui.admin.users.create.form.email", "이메일")}
</Label>
<Input
id="email"
placeholder="user@example.com"
{...register("email", { required: "이메일은 필수입니다." })}
placeholder={t(
"ui.admin.users.create.form.email_placeholder",
"user@example.com",
)}
{...register("email", {
required: t(
"msg.admin.users.create.form.email_required",
"이메일은 필수입니다.",
),
})}
/>
{errors.email && (
<p className="text-xs text-destructive">{errors.email.message}</p>
<p className="text-xs text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password"></Label>
<Label htmlFor="password">
{t("ui.admin.users.create.form.password", "비밀번호")}
</Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={autoPassword}
onChange={(event) => setAutoPassword(event.target.checked)}
/>
{t("ui.admin.users.create.form.auto_password", "자동 생성")}
</label>
</div>
<Input
id="password"
type="password"
placeholder="********"
placeholder={t(
"ui.admin.users.create.form.password_placeholder",
"********",
)}
disabled={autoPassword}
{...register("password")}
/>
<p className="text-xs text-muted-foreground">
{autoPassword
? "비워두면 시스템이 초기 비밀번호를 자동 생성합니다."
: "초기 비밀번호를 직접 설정합니다."}
? t(
"msg.admin.users.create.form.password_auto_help",
"비워두면 시스템이 초기 비밀번호를 자동 생성합니다.",
)
: t(
"msg.admin.users.create.form.password_manual_help",
"초기 비밀번호를 직접 설정합니다.",
)}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name">
{t("ui.admin.users.create.form.name", "이름")}
</Label>
<Input
id="name"
placeholder="홍길동"
{...register("name", { required: "이름은 필수입니다." })}
placeholder={t(
"ui.admin.users.create.form.name_placeholder",
"홍길동",
)}
{...register("name", {
required: t(
"msg.admin.users.create.form.name_required",
"이름은 필수입니다.",
),
})}
/>
{errors.name && (
<p className="text-xs text-destructive">{errors.name.message}</p>
<p className="text-xs text-destructive">
{errors.name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone"></Label>
<Label htmlFor="phone">
{t("ui.admin.users.create.form.phone", "전화번호")}
</Label>
<Input
id="phone"
placeholder="010-1234-5678"
placeholder={t(
"ui.admin.users.create.form.phone_placeholder",
"010-1234-5678",
)}
{...register("phone")}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenantSlug">
{t("ui.admin.users.create.form.tenant", "테넌트 (Tenant)")}
</Label>
<div className="space-y-2">
<div className="relative">
<select
id="tenantSlug"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("tenantSlug")}
disabled={profile?.role === "tenant_admin"}
>
<option value="">
{t(
"ui.admin.users.create.form.tenant_global",
"시스템 전역 (소속 없음)",
)}
</option>
<Label htmlFor="companyCode"> (Tenant)</Label>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="relative">
<div className="space-y-2">
<Label htmlFor="department">
{t("ui.admin.users.create.form.department", "부서")}
</Label>
<select
<Input
id="department"
placeholder={t(
"ui.admin.users.create.form.department_placeholder",
"개발팀",
)}
{...register("department")}
/>
</div>
</div>
id="companyCode"
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="position">
{t("ui.admin.users.create.form.position", "직급")}
</Label>
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
<Input
id="position"
placeholder={t(
"ui.admin.users.create.form.position_placeholder",
"수석/책임/선임",
)}
{...register("position")}
/>
</div>
{...register("companyCode")}
<div className="space-y-2">
<Label htmlFor="jobTitle">
{t("ui.admin.users.create.form.job_title", "직무")}
</Label>
>
<Input
id="jobTitle"
placeholder={t(
"ui.admin.users.create.form.job_title_placeholder",
"프론트엔드 개발",
)}
{...register("jobTitle")}
/>
</div>
</div>
<option value=""> ( )</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="department"></Label>
<Input
id="department"
placeholder="개발팀"
{...register("department")}
/>
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
(Custom Fields)
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
</Label>
<Input
id={`metadata.${field.key}`}
type={field.type === "number" ? "number" : "text"}
{...register(`metadata.${field.key}` as any)}
/>
</div>
))}
</div>
</div>
{userSchema.length > 0 && (
<div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground">
{t(
"ui.admin.users.create.custom_fields.title",
"테넌트 확장 정보 (Custom Fields)",
)}
</h3>
<div className="grid gap-4 md:grid-cols-2">
{userSchema.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`metadata.${field.key}`}>
{field.label}
{field.required && (
<span className="ml-1 text-destructive">*</span>
)}
{field.adminOnly && (
<span className="ml-2 text-[10px] bg-blue-500/10 text-blue-500 px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter">
Admin Only
</span>
)}
{field.isLoginId && (
<span className="ml-2 text-[10px] bg-green-500/10 text-green-600 px-1.5 py-0.5 rounded uppercase font-bold">
{t(
"ui.admin.users.create.form.is_login_id",
"로그인 ID",
)}
</span>
)}
</Label>
<Input
id={`metadata.${field.key}`}
type={
field.type === "number"
? "number"
: field.type === "date"
? "date"
: field.type === "boolean"
? "checkbox"
: "text"
}
className={
field.type === "boolean" ? "w-auto h-auto" : ""
}
{...registerMetadata(field)}
/>
{errors.metadata?.[field.key] && (
<p className="text-xs text-destructive">
{
(errors.metadata[field.key] as { message?: string })
?.message
}
</p>
)}
</div>
))}
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor="role"> (Role)</Label>
<div className="space-y-2">
<Label htmlFor="role">
{t("ui.admin.users.create.form.role", "역할 (Role)")}
</Label>
<div className="relative">
<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 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
{...register("role")}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="user">
{t("ui.admin.role.user", "TENANT MEMBER")}
</option>
<option value="tenant_admin">
{t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
</option>
<option value="rp_admin">
{t("ui.admin.role.rp_admin", "RP ADMIN")}
</option>
<option value="super_admin">
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
</option>
</select>
</div>
<p className="text-xs text-muted-foreground">
.
{t(
"msg.admin.users.create.form.role_help",
"시스템 접근 권한을 결정합니다.",
)}
</p>
</div>
@@ -351,14 +528,14 @@ function UserCreatePage() {
variant="outline"
onClick={() => navigate("/users")}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
<Save className="mr-2 h-4 w-4" />
{t("ui.admin.users.create.submit", "사용자 생성")}
</Button>
</div>
</form>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -0,0 +1,302 @@
import { useMutation } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Download,
FileText,
Loader2,
Upload,
} from "lucide-react";
import * as React from "react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
type BulkUserItem,
type BulkUserResult,
bulkCreateUsers,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { parseUserCSV } from "../utils/csvParser";
interface UserBulkUploadModalProps {
onSuccess?: () => void;
}
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const [open, setOpen] = React.useState(false);
const [file, setFile] = React.useState<File | null>(null);
const [parsing, setParsing] = React.useState(false);
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const mutation = useMutation({
mutationFn: bulkCreateUsers,
onSuccess: (data) => {
setResults(data.results);
onSuccess?.();
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
parseCSV(selectedFile);
}
};
const parseCSV = (file: File) => {
setParsing(true);
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
const data = parseUserCSV(text);
setPreviewData(data);
setParsing(false);
};
reader.readAsText(file);
};
const handleUpload = () => {
if (previewData.length > 0) {
mutation.mutate(previewData);
}
};
const downloadTemplate = () => {
const headers =
"email,name,phone,role,tenant,department,position,jobTitle,employee_id";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "user_bulk_template.csv";
a.click();
URL.revokeObjectURL(url);
};
const reset = () => {
setFile(null);
setPreviewData([]);
setResults(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const successCount = results?.filter((r) => r.success).length ?? 0;
const failCount = results ? results.length - successCount : 0;
return (
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) reset();
}}
>
<DialogTrigger asChild>
<Button
variant="outline"
className="gap-2"
data-testid="bulk-import-btn"
>
<Upload size={16} />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle data-testid="bulk-upload-title">
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.bulk.description",
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
)}
</DialogDescription>
</DialogHeader>
{!results ? (
<div className="space-y-4 py-4">
<div className="flex justify-between items-center">
<Button
variant="ghost"
size="sm"
onClick={downloadTemplate}
className="gap-2"
>
<Download size={14} />
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
</Button>
<input
type="file"
accept=".csv"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
</Button>
</div>
{file && (
<div className="rounded-lg border p-4 bg-muted/20">
<div className="flex items-center gap-3 mb-2">
<FileText className="text-primary" />
<span className="font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground">
({(file.size / 1024).toFixed(1)} KB)
</span>
</div>
{parsing ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
{t("msg.common.parsing", "파싱 중...")}
</div>
) : (
<div className="text-sm text-muted-foreground">
{t(
"msg.admin.users.bulk.parsed_count",
"{{count}}명의 사용자가 감지되었습니다.",
{ count: previewData.length },
)}
</div>
)}
</div>
)}
{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>
</tr>
</thead>
<tbody>
{previewData.slice(0, 10).map((u) => (
<tr key={u.email} className="border-t">
<td className="p-2">{u.email}</td>
<td className="p-2">{u.name}</td>
<td className="p-2">{u.tenantSlug || "-"}</td>
</tr>
))}
{previewData.length > 10 && (
<tr>
<td
colSpan={3}
className="p-2 text-center text-muted-foreground italic"
>
... and {previewData.length - 10} more users
</td>
</tr>
)}
</tbody>
</table>
</ScrollArea>
)}
</div>
) : (
<div className="space-y-4 py-4">
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-green-600">
{successCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.success", "성공")}
</div>
</div>
<div className="w-px h-10 bg-border" />
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-destructive">
{failCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.fail", "실패")}
</div>
</div>
</div>
<ScrollArea className="h-[250px] rounded-md border">
<div className="p-2 space-y-2">
{results.map((r) => (
<div
key={r.email}
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
>
{r.success ? (
<CheckCircle2
size={16}
className="text-green-500 mt-0.5"
/>
) : (
<AlertCircle
size={16}
className="text-destructive mt-0.5"
/>
)}
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{r.email}</div>
{!r.success && (
<div className="text-xs text-destructive">
{r.message}
</div>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
<DialogFooter>
{!results ? (
<Button
onClick={handleUpload}
disabled={previewData.length === 0 || mutation.isPending}
className="w-full sm:w-auto"
data-testid="bulk-start-btn"
>
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
</Button>
) : (
<Button
onClick={() => setOpen(false)}
className="w-full sm:w-auto"
data-testid="bulk-close-dialog-btn"
>
{t("ui.common.close", "닫기")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { parseUserCSV } from "./csvParser";
describe("parseUserCSV", () => {
it("should parse valid CSV correctly", () => {
const csv = `email,name,phone,role,tenant,department,emp_id
user1@test.com,Hong Gil Dong,010-1111-2222,user,baron,HR,E001
user2@test.com,Kim Cheol Su,,admin,baron,IT,E002`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
email: "user1@test.com",
name: "Hong Gil Dong",
phone: "010-1111-2222",
role: "user",
tenantSlug: "baron",
department: "HR",
metadata: {
emp_id: "E001",
},
});
expect(result[1].email).toBe("user2@test.com");
expect(result[1].metadata.emp_id).toBe("E002");
});
it("should return empty array for empty input", () => {
expect(parseUserCSV("")).toEqual([]);
});
it("should skip rows without email or name", () => {
const csv = `email,name
,Only Name
no-name@test.com,`;
expect(parseUserCSV(csv)).toHaveLength(0);
});
it("should handle mixed case headers", () => {
const csv = `EMAIL,Name,Tenant
test@test.com,Test,baron`;
const result = parseUserCSV(csv);
expect(result[0].email).toBe("test@test.com");
expect(result[0].tenantSlug).toBe("baron");
});
});

View File

@@ -0,0 +1,52 @@
import type { BulkUserItem } from "../../../lib/adminApi";
export function parseUserCSV(text: string): BulkUserItem[] {
const lines = text.split(/\r?\n/);
if (lines.length < 2) {
return [];
}
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const data: BulkUserItem[] = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const values = lines[i].split(",").map((v) => v.trim());
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
metadata: {},
};
for (let index = 0; index < headers.length; index++) {
const header = headers[index];
const value = values[index];
if (value === undefined || value === "") continue;
if (header === "email") {
item.email = value;
} else if (header === "name") {
item.name = value;
} else if (header === "phone") {
item.phone = value;
} else if (header === "role") {
item.role = value;
} else if (header === "tenant") {
item.tenantSlug = value;
} else if (header === "department") {
item.department = value;
} else if (header === "position") {
item.position = value;
} else if (header === "jobtitle") {
item.jobTitle = value;
} else {
item.metadata[header] = value;
}
}
if (item.email && item.name) {
data.push(item as BulkUserItem);
}
}
return data;
}

View File

@@ -21,23 +21,28 @@ export type AuditLogListResponse = {
export type TenantSummary = {
id: string;
type: string; // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP
name: string;
slug: string;
description: string;
status: string;
domains?: string[];
config?: Record<string, any>;
parentId?: string;
config?: Record<string, unknown>;
memberCount: number; // Added member count
createdAt: string;
updatedAt: string;
};
export type TenantCreateRequest = {
name: string;
type?: string;
slug?: string;
parentId?: string;
description?: string;
status?: string;
domains?: string[];
config?: Record<string, any>;
config?: Record<string, unknown>;
};
export type TenantListResponse = {
@@ -49,11 +54,13 @@ export type TenantListResponse = {
export type TenantUpdateRequest = {
name?: string;
type?: string;
slug?: string;
parentId?: string;
description?: string;
status?: string;
domains?: string[];
config?: Record<string, any>;
config?: Record<string, unknown>;
};
export type ApiKeySummary = {
@@ -92,11 +99,11 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
return data;
}
export async function fetchTenants(limit = 50, offset = 0) {
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
{
params: { limit, offset },
params: { limit, offset, parentId },
},
);
return data;
@@ -132,6 +139,12 @@ export async function deleteTenant(tenantId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}`);
}
export async function deleteTenantsBulk(ids: string[]) {
await apiClient.delete("/v1/admin/tenants/bulk", {
data: { ids },
});
}
export async function approveTenant(tenantId: string) {
const { data } = await apiClient.post<TenantSummary>(
`/v1/admin/tenants/${tenantId}/approve`,
@@ -139,6 +152,194 @@ export async function approveTenant(tenantId: string) {
return data;
}
export type TenantAdmin = {
id: string;
name: string;
email: string;
};
export async function fetchTenantAdmins(tenantId: string) {
const { data } = await apiClient.get<TenantAdmin[]>(
`/v1/admin/tenants/${tenantId}/admins`,
);
return data;
}
export async function addTenantAdmin(tenantId: string, userId: string) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
export async function removeTenantAdmin(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
}
export async function fetchTenantOwners(tenantId: string) {
const { data } = await apiClient.get<TenantAdmin[]>(
`/v1/admin/tenants/${tenantId}/owners`,
);
return data;
}
export async function addTenantOwner(tenantId: string, userId: string) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
}
export async function removeTenantOwner(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
}
// Group Management
export type GroupMember = {
id: string;
name: string;
email: string;
};
export type GroupSummary = {
id: string;
tenantId: string;
parentId?: string;
name: string;
description?: string;
unitType?: string;
members?: GroupMember[];
createdAt?: string;
updatedAt?: string;
};
export type GroupCreateRequest = {
name: string;
parentId?: string;
description?: string;
unitType?: string;
};
export async function fetchGroups(tenantId: string) {
const { data } = await apiClient.get<GroupSummary[]>(
`/v1/admin/tenants/${tenantId}/organization`,
);
return data;
}
export async function fetchGroup(tenantId: string, groupId: string) {
const { data } = await apiClient.get<GroupSummary>(
`/v1/admin/tenants/${tenantId}/organization/${groupId}`,
);
return data;
}
export async function createGroup(
tenantId: string,
payload: GroupCreateRequest,
) {
const { data } = await apiClient.post<GroupSummary>(
`/v1/admin/tenants/${tenantId}/organization`,
payload,
);
return data;
}
export async function deleteGroup(tenantId: string, groupId: string) {
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/organization/${groupId}`,
);
}
export async function addGroupMember(
tenantId: string,
groupId: string,
userId: string,
) {
await apiClient.post(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/members`,
{ userId },
);
}
export async function removeGroupMember(
tenantId: string,
groupId: string,
userId: string,
) {
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/members/${userId}`,
);
}
export interface ImportResult {
totalRows: number;
processed: number;
userCreated: number;
userUpdated: number;
tenantCreated: number;
errors: string[];
}
export async function fetchImportProgress(
tenantId: string,
progressId: string,
) {
const { data } = await apiClient.get<{ current: number; total: number }>(
`/v1/admin/tenants/${tenantId}/organization/import/progress/${progressId}`,
);
return data;
}
export async function importOrgChart(
tenantId: string,
file: File,
progressId?: string,
) {
const formData = new FormData();
formData.append("file", file);
const url = progressId
? `/v1/admin/tenants/${tenantId}/organization/import?progressId=${progressId}`
: `/v1/admin/tenants/${tenantId}/organization/import`;
const { data } = await apiClient.post<{ data: ImportResult }>(url, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});
return data.data;
}
export type GroupRole = {
tenantId: string;
tenantName: string;
relation: string;
};
export async function fetchGroupRoles(tenantId: string, groupId: string) {
const { data } = await apiClient.get<GroupRole[]>(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
);
return data;
}
export async function assignGroupRole(
tenantId: string,
groupId: string,
targetTenantId: string,
relation: string,
) {
await apiClient.post(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
{ tenantId: targetTenantId, relation },
);
}
export async function removeGroupRole(
tenantId: string,
groupId: string,
targetTenantId: string,
relation: string,
) {
await apiClient.delete(
`/v1/admin/tenants/${tenantId}/organization/${groupId}/roles/${targetTenantId}/${relation}`,
);
}
// API Key Management (M2M)
export type ApiKeyCreateRequest = {
name: string;
@@ -176,14 +377,19 @@ export async function deleteApiKey(apiKeyId: string) {
export type UserSummary = {
id: string;
email: string;
loginId?: string;
name: string;
phone?: string;
role: string;
status: string;
tenantSlug?: string;
companyCode?: string;
tenant?: TenantSummary;
metadata?: Record<string, any>;
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;
department?: string;
position?: string;
jobTitle?: string;
createdAt: string;
updatedAt: string;
};
@@ -197,12 +403,16 @@ export type UserListResponse = {
export type UserCreateRequest = {
email: string;
loginId?: string;
password?: string;
name: string;
phone?: string;
role?: string;
companyCode?: string;
tenantSlug?: string;
department?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
};
export type UserCreateResponse = UserSummary & {
@@ -210,18 +420,51 @@ export type UserCreateResponse = UserSummary & {
};
export type UserUpdateRequest = {
loginId?: string;
password?: string;
name?: string;
phone?: string;
role?: string;
status?: string;
companyCode?: string;
tenantSlug?: string;
department?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
};
export async function fetchUsers(limit = 50, offset = 0, search?: string) {
export type BulkUserItem = {
email: string;
loginId?: string;
name: string;
phone?: string;
role?: string;
tenantSlug?: string;
department?: string;
position?: string;
jobTitle?: string;
metadata: Record<string, string>;
};
export type BulkUserResult = {
email: string;
success: boolean;
message?: string;
userId?: string;
};
export type BulkUserResponse = {
results: BulkUserResult[];
};
export async function fetchUsers(
limit = 50,
offset = 0,
search?: string,
tenantSlug?: string,
) {
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search },
params: { limit, offset, search, tenantSlug },
});
return data;
}
@@ -234,17 +477,102 @@ export async function fetchUser(userId: string) {
}
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>(
"/v1/admin/users",
payload,
requestPayload,
);
return data;
}
export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
const params = new URLSearchParams();
if (search) params.append("search", search);
if (tenantSlug) params.append("tenantSlug", tenantSlug);
// Get mock role from storage if exists for dev environment
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (isMockRoleEnabled && mockRole) params.append("x-test-role", mockRole);
const baseUrl = import.meta.env.VITE_ADMIN_API_BASE ?? "/api/v1";
return `${baseUrl}/admin/users/export?${params.toString()}`;
}
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>(
"/v1/admin/users/bulk",
{ users: mappedUsers },
);
return data;
}
export async function bulkUpdateUsers(payload: {
userIds: string[];
status?: string;
role?: string;
tenantSlug?: string;
department?: string;
}) {
const requestPayload: typeof payload & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
return data;
}
export async function bulkDeleteUsers(userIds: string[]) {
const { data } = await apiClient.delete("/v1/admin/users/bulk", {
data: { userIds },
});
return data;
}
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>(
`/v1/admin/users/${userId}`,
payload,
requestPayload,
);
return data;
}
export type PasswordPolicyResponse = {
minLength?: number;
lowercase?: boolean;
uppercase?: boolean;
number?: boolean;
nonAlphanumeric?: boolean;
minCharacterTypes?: number;
};
export async function fetchPasswordPolicy() {
const { data } = await apiClient.get<PasswordPolicyResponse>(
"/v1/auth/password/policy",
);
return data;
}
@@ -253,6 +581,40 @@ export async function deleteUser(userId: string) {
await apiClient.delete(`/v1/admin/users/${userId}`);
}
export type UserRpHistoryItem = {
client_id: string;
client_name: string;
lastLoginAt: string;
status: string;
};
export async function fetchUserRpHistory(userId: string) {
const { data } = await apiClient.get<UserRpHistoryItem[]>(
`/v1/admin/users/${userId}/rp-history`,
);
return data;
}
export type UserProfileResponse = {
id: string;
email: string;
name: string;
phone: string;
role: string;
department: string;
affiliationType: string;
tenantSlug?: string;
tenantId?: string;
metadata?: Record<string, unknown>;
tenant?: TenantSummary;
manageableTenants?: TenantSummary[];
};
export async function fetchMe() {
const { data } = await apiClient.get<UserProfileResponse>("/v1/user/me");
return data;
}
// Relying Party Management
export type RelyingParty = {
clientId: string;
@@ -272,7 +634,7 @@ export type HydraClientReq = {
token_endpoint_auth_method?: string;
grant_types?: string[];
response_types?: string[];
metadata?: Record<string, any>;
metadata?: Record<string, unknown>;
};
export async function fetchRelyingParties(tenantId: string) {
@@ -319,3 +681,33 @@ export async function updateRelyingParty(id: string, payload: HydraClientReq) {
export async function deleteRelyingParty(id: string) {
await apiClient.delete(`/v1/admin/relying-parties/${id}`);
}
export type RPOwner = {
subject: string;
name?: string;
email?: string;
type: string;
};
export async function fetchRPOwners(clientId: string) {
const { data } = await apiClient.get<RPOwner[]>(
`/v1/admin/relying-parties/${clientId}/owners`,
);
return data;
}
export async function addRPOwner(clientId: string, subject: string) {
await apiClient.post(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
}
export async function removeRPOwner(clientId: string, subject: string) {
await apiClient.delete(
`/v1/admin/relying-parties/${clientId}/owners/${subject}`,
);
}

View File

@@ -1,25 +1,34 @@
import axios from "axios";
import { userManager } from "./auth";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_ADMIN_API_BASE ?? "/api",
baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE
? "http://playwright-mock/api"
: (import.meta.env.VITE_ADMIN_API_BASE ?? "/api"),
});
apiClient.interceptors.request.use((config) => {
// TODO: IdP 중립 Auth 레이어 연동 시 세션 토큰을 주입한다.
const sessionToken = window.localStorage.getItem("admin_session");
apiClient.interceptors.request.use(async (config) => {
// IdP 중립 Auth 레이어 연동: oidc-client의 userManager에서 최신 토큰을 가져옵니다.
const user = await userManager.getUser();
const sessionToken =
user?.access_token || window.localStorage.getItem("admin_session");
if (sessionToken) {
config.headers.Authorization = `Bearer ${sessionToken}`;
}
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
// 테넌트 선택 값을 보관하고 헤더로 전달한다.
const tenantId = window.localStorage.getItem("admin_tenant");
if (tenantId) {
config.headers["X-Tenant-ID"] = tenantId;
}
// [Development Only] Inject Mock Role from RoleSwitcher
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (mockRole) {
if (isMockRoleEnabled && mockRole) {
config.headers["X-Test-Role"] = mockRole;
}
@@ -28,8 +37,30 @@ apiClient.interceptors.request.use((config) => {
apiClient.interceptors.response.use(
(response) => response,
(error) => {
// TODO: 401/403 응답 시 로그인/재인증 플로우로 리다이렉션한다.
async (error) => {
if (error.response?.status === 401) {
console.warn(
"[apiClient] 401 Unauthorized detected. Clearing session state.",
);
// 로컬 스토리지의 세션 키 제거
window.localStorage.removeItem("admin_session");
// oidc-client의 유저 상태도 제거하여 isAuthenticated를 false로 만듭니다.
// 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
await userManager.removeUser();
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
const isLoginPath = window.location.pathname === "/login";
if (!isAuthPath && !isLoginPath) {
console.info(
"[apiClient] Redirecting to /login from",
window.location.pathname,
);
window.location.href = "/login";
}
}
return Promise.reject(error);
},
);

View File

@@ -0,0 +1,22 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
export const oidcConfig: AuthProviderProps = {
authority:
import.meta.env.VITE_OIDC_AUTHORITY || `${window.location.protocol}//${window.location.hostname}:{{USERFRONT_PORT}}/oidc`,
client_id: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: "code",
scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: window.location.origin,
popup_redirect_uri: `${window.location.origin}/auth/callback`,
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: false,
};
export const userManager = new UserManager({
...oidcConfig,
authority: oidcConfig.authority || "",
client_id: oidcConfig.client_id || "",
redirect_uri: oidcConfig.redirect_uri || "",
});

View File

@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { t } from "./i18n";
describe("i18n utility", () => {
beforeEach(() => {
window.localStorage.clear();
vi.clearAllMocks();
});
it("returns fallback if key not found", () => {
expect(t("this.key.truly.does.not.exist", "Fallback")).toBe("Fallback");
});
it("returns key if fallback not provided and key not found", () => {
expect(t("this.key.truly.does.not.exist")).toBe(
"this.key.truly.does.not.exist",
);
});
it("replaces variables in template", () => {
expect(t("this.test.key", "Hello {{ name }}", { name: "World" })).toBe(
"Hello World",
);
});
it("respects locale in localStorage", () => {
window.localStorage.setItem("locale", "en");
// We expect some key that exists in en.toml
// Let's use a common one or a fallback if we don't know the content
expect(t("ui.common.save", "Save")).toBe("Save");
});
it("defaults to ko if no locale set and browser language is ko", () => {
vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");
expect(t("ui.common.save", "저장")).toBe("저장");
});
});

148
adminfront/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,148 @@
const LOCALE_STORAGE_KEY = "locale";
const DEFAULT_LOCALE = "ko";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
type TomlValue = string | TomlObject;
interface TomlObject {
[key: string]: TomlValue;
}
function isSupportedLocale(value: string): value is Locale {
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
}
function parseToml(raw: string): TomlObject {
const lines = raw.split(/\r?\n/);
const root: TomlObject = {};
let currentPath: string[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
continue;
}
if (line.startsWith("[") && line.endsWith("]")) {
const sectionName = line.slice(1, -1).trim();
currentPath = sectionName
? sectionName
.split(".")
.map((part) => part.trim())
.filter(Boolean)
: [];
continue;
}
const eqIndex = line.indexOf("=");
if (eqIndex === -1) {
continue;
}
const key = line.slice(0, eqIndex).trim();
const valueRaw = line.slice(eqIndex + 1).trim();
if (!key) {
continue;
}
let value = valueRaw;
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
let cursor: TomlObject = root;
for (const section of currentPath) {
if (!cursor[section] || typeof cursor[section] === "string") {
cursor[section] = {};
}
cursor = cursor[section] as TomlObject;
}
cursor[key] = value;
}
return root;
}
function getValue(target: TomlObject, key: string): string | undefined {
const parts = key.split(".");
let cursor: TomlValue = target;
for (const part of parts) {
if (typeof cursor !== "object" || cursor === null) {
return undefined;
}
cursor = (cursor as TomlObject)[part];
if (cursor === undefined) {
return undefined;
}
}
return typeof cursor === "string" ? cursor : undefined;
}
function detectLocale(): Locale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isSupportedLocale(stored)) {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale && isSupportedLocale(pathLocale)) {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
if (browserLang.startsWith("ko")) {
return "ko";
}
return DEFAULT_LOCALE;
}
// eslint-disable-next-line import/no-unresolved
import enRaw from "../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// eslint-disable-next-line import/no-unresolved
import koRaw from "../locales/ko.toml?raw";
const translations: Record<Locale, TomlObject> = {
ko: parseToml(koRaw),
en: parseToml(enRaw),
};
function formatTemplate(
template: string,
vars?: Record<string, string | number>,
): string {
if (!vars) {
return template;
}
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
const value = vars[key];
if (value === undefined || value === null) {
return match;
}
return String(value);
});
}
export function t(
key: string,
fallback?: string,
vars?: Record<string, string | number>,
): string {
const locale = detectLocale();
const value = getValue(translations[locale], key);
if (value && value.length > 0) {
return formatTemplate(value, vars);
}
return formatTemplate(fallback ?? key, vars);
}

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import {
SESSION_RENEW_THRESHOLD_MS,
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "./sessionSliding";
describe("shouldAttemptSlidingSessionRenew", () => {
const nowMs = 1_700_000_000_000;
it("returns false when remaining time is above the 5 minute threshold", () => {
expect(
shouldAttemptSlidingSessionRenew({
expiresAtSec: Math.floor(
(nowMs + SESSION_RENEW_THRESHOLD_MS + 1_000) / 1000,
),
nowMs,
isEnabled: true,
isAuthenticated: true,
isLoading: false,
isRenewInFlight: false,
lastAttemptAtMs: 0,
}),
).toBe(false);
});
it("returns true when remaining time is within the 5 minute threshold", () => {
expect(
shouldAttemptSlidingSessionRenew({
expiresAtSec: Math.floor(
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
),
nowMs,
isEnabled: true,
isAuthenticated: true,
isLoading: false,
isRenewInFlight: false,
lastAttemptAtMs: 0,
}),
).toBe(true);
});
it("returns false when automatic renewal is disabled", () => {
expect(
shouldAttemptSlidingSessionRenew({
expiresAtSec: Math.floor(
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
),
nowMs,
isEnabled: false,
isAuthenticated: true,
isLoading: false,
isRenewInFlight: false,
lastAttemptAtMs: 0,
}),
).toBe(false);
});
it("returns false when the last renew attempt is still within the throttle window", () => {
expect(
shouldAttemptSlidingSessionRenew({
expiresAtSec: Math.floor(
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
),
nowMs,
isEnabled: true,
isAuthenticated: true,
isLoading: false,
isRenewInFlight: false,
lastAttemptAtMs: nowMs - 10_000,
}),
).toBe(false);
});
});
describe("shouldAttemptUnlimitedSessionRenew", () => {
const nowMs = 1_700_000_000_000;
it("returns false when unlimited mode is not active", () => {
expect(
shouldAttemptUnlimitedSessionRenew({
expiresAtSec: Math.floor(
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
),
nowMs,
isEnabled: true,
isAuthenticated: true,
isLoading: false,
isRenewInFlight: false,
lastAttemptAtMs: 0,
}),
).toBe(false);
});
it("returns true near expiry when session expiry management is disabled", () => {
expect(
shouldAttemptUnlimitedSessionRenew({
expiresAtSec: Math.floor(
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
),
nowMs,
isEnabled: false,
isAuthenticated: true,
isLoading: false,
isRenewInFlight: false,
lastAttemptAtMs: 0,
}),
).toBe(true);
});
it("returns false when the token still has enough remaining lifetime", () => {
expect(
shouldAttemptUnlimitedSessionRenew({
expiresAtSec: Math.floor(
(nowMs + SESSION_RENEW_THRESHOLD_MS + 1_000) / 1000,
),
nowMs,
isEnabled: false,
isAuthenticated: true,
isLoading: false,
isRenewInFlight: false,
lastAttemptAtMs: 0,
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,106 @@
export const SESSION_RENEW_THRESHOLD_MS = 5 * 60 * 1000;
export const SESSION_RENEW_THROTTLE_MS = 30 * 1000;
type SlidingSessionRenewDecisionParams = {
expiresAtSec?: number | null;
nowMs: number;
isEnabled: boolean;
isAuthenticated: boolean;
isLoading: boolean;
isRenewInFlight: boolean;
lastAttemptAtMs: number;
thresholdMs?: number;
throttleMs?: number;
};
export function shouldAttemptSlidingSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (!isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
console.debug(
"[sessionSliding] expiresAtSec is not a number, skipping renew",
);
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
const remainingMin = Math.floor(remainingMs / 1000 / 60);
if (remainingMs <= 0) {
console.debug("[sessionSliding] Session already expired, skipping renew");
return false;
}
if (remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
console.debug("[sessionSliding] Throttling renewal attempt");
return false;
}
console.info(
`[sessionSliding] Attempting sliding session renewal. Remaining: ${remainingMin}m`,
);
return true;
}
export function shouldAttemptUnlimitedSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
console.debug(
"[sessionSliding] expiresAtSec is not a number, skipping unlimited renew",
);
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
const remainingMin = Math.floor(remainingMs / 1000 / 60);
if (remainingMs <= 0) {
console.debug(
"[sessionSliding] Session already expired, skipping unlimited renew",
);
return false;
}
if (remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
console.debug("[sessionSliding] Throttling unlimited renewal attempt");
return false;
}
console.info(
`[sessionSliding] Attempting unlimited session renewal. Remaining: ${remainingMin}m`,
);
return true;
}

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "./adminApi";
import { buildTenantFullTree } from "./tenantTree";
describe("tenantTree utility", () => {
const mockTenants: TenantSummary[] = [
{
id: "root-1",
name: "Root",
slug: "root",
type: "COMPANY",
memberCount: 10,
parentId: undefined,
description: "",
status: "active",
createdAt: "",
updatedAt: "",
},
{
id: "child-1",
name: "Child 1",
slug: "child-1",
type: "USER_GROUP",
memberCount: 5,
parentId: "root-1",
description: "",
status: "active",
createdAt: "",
updatedAt: "",
},
{
id: "grandchild-1",
name: "Grandchild 1",
slug: "grandchild-1",
type: "USER_GROUP",
memberCount: 2,
parentId: "child-1",
description: "",
status: "active",
createdAt: "",
updatedAt: "",
},
];
it("calculates recursive member counts correctly", () => {
const { currentBase } = buildTenantFullTree(mockTenants, "root-1");
expect(currentBase).not.toBeNull();
if (currentBase) {
// Direct: 10, Child: 5, Grandchild: 2 -> Total: 17
expect(currentBase.recursiveMemberCount).toBe(17);
expect(currentBase.children).toHaveLength(1);
const child = currentBase.children[0];
// Direct: 5, Grandchild: 2 -> Total: 7
expect(child.recursiveMemberCount).toBe(7);
expect(child.children).toHaveLength(1);
const grandchild = child.children[0];
// Direct: 2 -> Total: 2
expect(grandchild.recursiveMemberCount).toBe(2);
expect(grandchild.children).toHaveLength(0);
}
});
it("returns null currentBase if rootId is not found", () => {
const { currentBase } = buildTenantFullTree(mockTenants, "non-existent");
expect(currentBase).toBeNull();
});
it("builds correct structure with multiple roots", () => {
const multiRootTenants: TenantSummary[] = [
...mockTenants,
{
id: "root-2",
name: "Root 2",
slug: "root-2",
type: "COMPANY",
memberCount: 3,
parentId: undefined,
description: "",
status: "active",
createdAt: "",
updatedAt: "",
},
];
const { subTree } = buildTenantFullTree(multiRootTenants);
expect(subTree).toHaveLength(2);
expect(subTree.map((n) => n.id)).toContain("root-1");
expect(subTree.map((n) => n.id)).toContain("root-2");
});
});

View File

@@ -0,0 +1,87 @@
import type { TenantSummary } from "./adminApi";
export type TenantNode = TenantSummary & {
children: TenantNode[];
recursiveMemberCount: number;
};
/**
* Builds a hierarchical tree from a flat list of tenants and calculates
* direct and recursive member counts for each node.
*/
export function buildTenantFullTree(
allTenants: TenantSummary[],
rootId?: string,
): { currentBase: TenantNode | null; subTree: TenantNode[] } {
if (allTenants.length === 0) return { currentBase: null, subTree: [] };
const tenantMap = new Map<string, TenantNode>();
for (const t of allTenants) {
tenantMap.set(t.id, {
...t,
children: [],
recursiveMemberCount: t.memberCount || 0,
});
}
const visitedDuringBuild = new Set<string>();
// Build initial children relations and prevent simple cycles
for (const t of allTenants) {
if (t.parentId && t.parentId !== t.id) {
const parent = tenantMap.get(t.parentId);
const child = tenantMap.get(t.id);
if (parent && child) {
// Simple cycle prevention during build: don't add if it creates an immediate loop
parent.children.push(child);
}
}
}
const visitedForCalc = new Set<string>();
// Function to calculate recursive counts with cycle protection
const calculateRecursive = (node: TenantNode): number => {
if (visitedForCalc.has(node.id)) {
console.warn(
`Circular dependency detected in tenant tree for ID: ${node.id}`,
);
return 0; // Prevent infinite loop
}
visitedForCalc.add(node.id);
let total = node.memberCount || 0;
for (const child of node.children) {
total += calculateRecursive(child);
}
node.recursiveMemberCount = total;
// We don't remove from visitedForCalc here because a tree shouldn't have
// multiple paths to the same node anyway (it's a tree, not a graph).
// If it were a DAG, we'd need different logic, but for a tree with parentIds,
// a node should only be visited once.
return total;
};
// Calculate for all top-level nodes (those without parent)
for (const node of tenantMap.values()) {
if (!node.parentId) {
visitedForCalc.clear();
calculateRecursive(node);
}
}
// If a specific rootId is provided, find and return its subtree
if (rootId) {
const base = tenantMap.get(rootId);
if (base) {
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
visitedForCalc.clear();
calculateRecursive(base);
return { currentBase: base, subTree: base.children };
}
return { currentBase: null, subTree: [] };
}
// If no rootId, return all top-level roots as subTree
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId);
return { currentBase: null, subTree: roots };
}

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { cn } from "./utils";
describe("cn utility", () => {
it("merges class names correctly", () => {
expect(cn("a", "b")).toBe("a b");
expect(cn("a", { b: true, c: false })).toBe("a b");
});
it("handles tailwind class conflicts", () => {
expect(cn("px-2 py-2", "px-4")).toBe("py-2 px-4");
});
});

View File

@@ -4,3 +4,22 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function generateSecurePassword(length = 16): string {
const charset =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()_+~`|}{[]:;?><,./-=";
let password = "";
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
const values = new Uint32Array(length);
crypto.getRandomValues(values);
for (let i = 0; i < length; i++) {
password += charset[values[i] % charset.length];
}
} else {
// Fallback for older environments
for (let i = 0; i < length; i++) {
password += charset[Math.floor(Math.random() * charset.length)];
}
}
return password;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,12 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "react-oidc-context";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/routes";
import { Toaster } from "./components/ui/toaster";
import { oidcConfig } from "./lib/auth";
import "./index.css";
const rootElement = document.getElementById("root");
@@ -14,8 +17,16 @@ if (!rootElement) {
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
<AuthProvider {...oidcConfig}>
<QueryClientProvider client={queryClient}>
<RouterProvider
router={router}
future={{
v7_startTransition: true,
}}
/>
<Toaster />
</QueryClientProvider>
</AuthProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,8 @@
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// 각 테스트가 끝날 때마다 DOM을 정리합니다.
afterEach(() => {
cleanup();
});

View File

@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -0,0 +1,114 @@
import { expect, test } from "@playwright/test";
test.describe("Authentication", () => {
test.beforeEach(async ({ page }) => {
// 1. Force state
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
access_token: "fake-token",
token_type: "Bearer",
profile: {
sub: "admin-user",
name: "Admin",
email: "admin@test.com",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 36000,
};
window.localStorage.setItem(key, JSON.stringify(authData));
});
// 2. High-priority Mocks
await page.route("**/api/v1/user/me", async (route) => {
await route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.route("**/oidc/**", async (route) => {
const url = route.request().url();
if (url.includes(".well-known/openid-configuration")) {
await route.fulfill({
json: {
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
jwks_uri: "http://localhost:5000/oidc/jwks",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
end_session_endpoint: "http://localhost:5000/oidc/session/end",
},
headers: { "Access-Control-Allow-Origin": "*" },
});
} else if (url.includes("/jwks")) {
await route.fulfill({
json: { keys: [] },
headers: { "Access-Control-Allow-Origin": "*" },
});
} else {
await route.fulfill({
status: 200,
body: "ok",
headers: { "Access-Control-Allow-Origin": "*" },
});
}
});
// 3. Catch-all for others
await page.route(/.*\/api\/v1\/.*/, async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({ json: { items: [], total: 0 } });
} else {
await route.fulfill({ status: 200, json: {} });
}
});
});
test("should redirect unauthorized users to login page", async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.clear();
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = false;
});
await page.goto("/");
await expect(page).toHaveURL(/.*\/login.*/, { timeout: 15000 });
});
test("should allow access to dashboard when authenticated", async ({
page,
}) => {
await page.goto("/");
// strict mode violation 피하기 위해 .last() 사용하거나 더 구체적인 셀렉터 사용
await expect(page.locator("h1").last()).toContainText(
/Admin Control|운영 도구/i,
{ timeout: 15000 },
);
});
test("should logout and redirect to login page", async ({ page }) => {
await page.goto("/");
page.on("dialog", (dialog) => dialog.accept());
const logoutBtn = page
.locator("button")
.filter({ hasText: /Logout|로그아웃/i })
.first();
await logoutBtn.waitFor({ state: "visible" });
await logoutBtn.click();
await expect(page).toHaveURL(/.*\/login.*/, { timeout: 15000 });
});
});

View File

@@ -0,0 +1,183 @@
import { expect, test } from "@playwright/test";
test.describe("Bulk Actions and Tree Search", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin", role: "super_admin", name: "Admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
};
window.localStorage.setItem(key, JSON.stringify(authData));
});
// Capture ALL API calls to our mock host
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin",
role: "super_admin",
name: "Admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/users")) {
return route.fulfill({
json: {
items: [
{
id: "u-1",
name: "User One",
email: "u1@test.com",
status: "active",
role: "user",
createdAt: new Date().toISOString(),
},
{
id: "u-2",
name: "User Two",
email: "u2@test.com",
status: "active",
role: "user",
createdAt: new Date().toISOString(),
},
],
total: 2,
},
headers,
});
}
if (url.includes("/organization")) {
return route.fulfill({
json: [
{ id: "g-1", name: "Engineering", slug: "eng", tenantId: "t-1" },
{ id: "g-2", name: "Sales", slug: "sales", tenantId: "t-1" },
],
headers,
});
}
if (url.includes("/admin/tenants/t-1")) {
return route.fulfill({
json: {
id: "t-1",
name: "Main Tenant",
slug: "main",
status: "active",
type: "COMPANY",
},
headers,
});
}
if (url.includes("/admin/tenants")) {
return route.fulfill({
json: {
items: [
{ id: "t-1", name: "Main Tenant", slug: "main", type: "COMPANY" },
{
id: "g-1",
name: "Engineering",
slug: "eng",
parentId: "t-1",
type: "USER_GROUP",
},
{
id: "g-2",
name: "Sales",
slug: "sales",
parentId: "t-1",
type: "USER_GROUP",
},
],
total: 3,
},
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});
test("should show bulk action bar when users are selected", async ({
page,
}) => {
await page.goto("/users");
// 로딩바 대기 대신 실제 데이터 텍스트 대기
const table = page.locator("table");
await expect(table).toContainText("User One", { timeout: 20000 });
// 첫 번째 데이터의 체크박스 선택
const userCheckbox = page.locator('table input[type="checkbox"]').nth(1);
await userCheckbox.click();
// 일괄 작업 바 확인
const selectionBar = page.getByTestId("bulk-action-bar");
await expect(selectionBar).toBeVisible({ timeout: 15000 });
// 활성화 버튼 확인
const activeBtn = page.getByTestId("bulk-active-btn");
await expect(activeBtn).toBeVisible({ timeout: 10000 });
// 전체 선택
await page.locator('table input[type="checkbox"]').first().click();
await expect(selectionBar).toBeVisible();
// 선택 해제 버튼
const closeBtn = page.getByTestId("bulk-close-btn");
await closeBtn.click();
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
});
test("should filter and highlight nodes in organization tree", async ({
page,
}) => {
await page.goto("/tenants/t-1");
// 테넌트 이름이 제목으로 나올 때까지 대기
await expect(page.locator("h2").last()).toContainText(
/Main Tenant|상세|Profile/i,
{ timeout: 20000 },
);
const subTenantLink = page
.locator("a, button")
.filter({ hasText: /조직 관리|Organization|Sub-tenant/i })
.first();
await subTenantLink.click();
// 트리 검색 입력창 대기 (더 유연한 셀렉터)
const searchInput = page
.locator('input[placeholder*="검색"], input[placeholder*="Search"]')
.first();
await expect(searchInput).toBeVisible({ timeout: 15000 });
await searchInput.fill("Eng");
const engNode = page
.locator('button, [role="button"]')
.filter({ hasText: "Engineering" })
.first();
await expect(engNode).toBeVisible();
await expect(engNode).toHaveClass(/bg-primary\/5/);
});
});

View File

@@ -1,8 +0,0 @@
import { expect, test } from "@playwright/test";
test("has title", async ({ page }) => {
await page.goto("/");
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/바론 어드민 서비스/);
});

View File

@@ -0,0 +1,126 @@
import { expect, test } from "@playwright/test";
test.describe("Tenant Owners Management", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
access_token: "fake-token",
token_type: "Bearer",
profile: {
sub: "admin-user",
name: "Admin User",
email: "admin@example.com",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 36000,
};
window.localStorage.setItem(key, JSON.stringify(authData));
window.localStorage.setItem("admin_session", "fake-token");
window.localStorage.setItem("locale", "ko");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
await page.route(/.*\/api\/v1\/.*/, async (route) => {
const url = route.request().url();
if (url.includes("/user/me")) {
console.log("Mocking ME");
return route.fulfill({
json: {
id: "admin-user",
name: "Admin User",
email: "admin@example.com",
role: "super_admin",
manageableTenants: [],
},
});
}
if (url.includes("/owners")) {
return route.fulfill({
json: [
{ id: "owner-1", name: "Owner One", email: "owner1@example.com" },
],
});
}
if (url.includes("/admins")) {
return route.fulfill({ json: [] });
}
if (url.includes("/admin/tenants/tenant-1")) {
return route.fulfill({
json: {
id: "tenant-1",
name: "Test Tenant",
slug: "test-tenant",
status: "active",
type: "COMPANY",
},
});
}
if (url.includes("/admin/users") && route.request().method() === "GET") {
return route.fulfill({
json: {
items: [
{ id: "user-2", name: "User Two", email: "user2@example.com" },
],
total: 1,
},
});
}
if (route.request().method() === "GET") {
return route.fulfill({ json: { items: [], total: 0 } });
}
return route.fulfill({ status: 200, json: {} });
});
});
test("should list tenant owners", async ({ page }) => {
await page.goto("/tenants/tenant-1/permissions");
await expect(page.locator(".animate-spin").first()).not.toBeVisible();
await expect(page.getByText(/테넌트 소유자|Tenant Owners/)).toBeVisible();
await expect(page.locator("table").first()).toContainText("Owner One");
await expect(page.locator("table").first()).toContainText(
"owner1@example.com",
);
});
test("should add a new owner", async ({ page }) => {
// Specific override for this test
await page.route(
"**/api/v1/admin/tenants/tenant-1/owners",
async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({ json: [] });
} else {
await route.fulfill({ status: 200, json: {} });
}
},
);
await page.goto("/tenants/tenant-1/permissions");
await expect(page.locator(".animate-spin").first()).not.toBeVisible();
await page.click(
'button:has-text("소유자 추가"), button:has-text("Add Owner")',
);
await page.fill(
'input[placeholder*="사용자 검색"], input[placeholder*="Search users"]',
"User Two",
);
const addButton = page
.locator("role=dialog")
.getByRole("button", { name: /추가|Add/ });
await addButton.click();
});
});

View File

@@ -0,0 +1,197 @@
import { expect, test } from "@playwright/test";
test.describe("Tenants Management", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
};
window.localStorage.setItem(key, JSON.stringify(authData));
});
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/tenants")) {
if (
route.request().method() === "GET" &&
!url.includes("/parent-1") &&
!url.includes("/organization")
) {
return route.fulfill({
json: { items: [], total: 0, limit: 100, offset: 0 },
headers,
});
}
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});
test("should list tenants", async ({ page }) => {
await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() === "GET") {
await route.fulfill({
json: {
items: [
{
id: "1",
name: "Tenant A",
slug: "tenant-a",
status: "active",
type: "COMPANY",
updatedAt: new Date().toISOString(),
},
],
total: 1,
limit: 1000,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
} else {
await route.continue();
}
});
await page.goto("/tenants");
await expect(page.locator("h2").last()).toContainText(
/테넌트 목록|Tenants/i,
{ timeout: 20000 },
);
await expect(page.locator("table")).toContainText("Tenant A", {
timeout: 10000,
});
});
test("should create a new tenant", async ({ page }) => {
await page.goto("/tenants/new");
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
const nameInput = page.locator('input[name="name"]').first();
await nameInput.fill("New Tenant");
const slugInput = page.locator('input[name="slug"]').first();
await slugInput.fill("new-tenant");
await page.locator("textarea").first().fill("Description");
const submitBtn = page
.locator("button")
.filter({ hasText: /생성|Create/i })
.first();
await submitBtn.click();
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
});
test("should show validation error on empty name", async ({ page }) => {
await page.goto("/tenants/new");
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
timeout: 20000,
});
const submitBtn = page
.locator("button")
.filter({ hasText: /생성|Create/i })
.first();
await expect(submitBtn).toBeDisabled();
await page.locator('input[name="name"]').first().fill("Valid Name");
await expect(submitBtn).not.toBeDisabled();
});
test("should show organization hierarchy and member list distinction", async ({
page,
}) => {
const mockTenants = [
{
id: "parent-1",
name: "Parent Org",
slug: "parent-slug",
status: "active",
type: "COMPANY",
memberCount: 5,
parentId: null,
},
{
id: "child-1",
name: "Child Team",
slug: "child-slug",
status: "active",
type: "USER_GROUP",
memberCount: 3,
parentId: "parent-1",
},
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
const url = route.request().url();
if (url.includes("/organization")) {
await route.fulfill({
json: mockTenants,
headers: { "Access-Control-Allow-Origin": "*" },
});
} else if (url.includes("/parent-1")) {
await route.fulfill({
json: mockTenants[0],
headers: { "Access-Control-Allow-Origin": "*" },
});
} else {
await route.fulfill({
json: { items: mockTenants, total: 2, limit: 1000, offset: 0 },
headers: { "Access-Control-Allow-Origin": "*" },
});
}
});
await page.goto("/tenants/parent-1/organization");
await expect(
page.locator(".font-bold, h2").filter({ hasText: "Parent Org" }).first(),
).toBeVisible({ timeout: 20000 });
await expect(
page
.locator(".grid .font-bold, .grid .text-sm")
.filter({ hasText: "Child Team" })
.first(),
).toBeVisible();
await expect(
page
.locator("h3")
.filter({ hasText: /소속 멤버|Members|구성원 관리|Member Management/i })
.first(),
).toBeVisible();
});
});

View File

@@ -1,139 +0,0 @@
import { expect, test } from "@playwright/test";
type UserSummary = {
id: string;
email: string;
name: string;
phone?: string;
role: string;
status: string;
companyCode?: string;
department?: string;
createdAt: string;
updatedAt: string;
};
type UserCreatePayload = {
email: string;
password?: string;
name: string;
phone?: string;
role?: string;
companyCode?: string;
department?: string;
};
test("user create and delete flow", async ({ page }) => {
const users: UserSummary[] = [];
let idSeq = 1;
await page.route("**/api/v1/admin/users**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const isCollection = path.endsWith("/api/v1/admin/users");
const isItem = path.includes("/api/v1/admin/users/");
if (request.method() === "GET" && isCollection) {
const search = url.searchParams.get("search")?.toLowerCase() ?? "";
const limit = Number(url.searchParams.get("limit") ?? "50");
const offset = Number(url.searchParams.get("offset") ?? "0");
const filtered = search
? users.filter(
(user) =>
user.name.toLowerCase().includes(search) ||
user.email.toLowerCase().includes(search),
)
: users;
const items = filtered.slice(offset, offset + limit);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items,
limit,
offset,
total: filtered.length,
}),
});
return;
}
if (request.method() === "POST" && isCollection) {
const payload = request.postDataJSON() as UserCreatePayload;
const now = new Date().toISOString();
const user: UserSummary = {
id: `user-${idSeq++}`,
email: payload.email,
name: payload.name,
phone: payload.phone,
role: payload.role ?? "user",
status: "active",
companyCode: payload.companyCode,
department: payload.department,
createdAt: now,
updatedAt: now,
};
users.unshift(user);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(user),
});
return;
}
if (request.method() === "DELETE" && isItem) {
const userId = path.split("/").pop();
const index = users.findIndex((user) => user.id === userId);
if (index === -1) {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "User not found" }),
});
return;
}
users.splice(index, 1);
await route.fulfill({ status: 204, body: "" });
return;
}
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "Not found" }),
});
});
await page.goto("/users");
await page.getByRole("link", { name: "사용자 추가" }).click();
await expect(page).toHaveURL(/\/users\/new$/);
const uniqueEmail = `playwright-${Date.now()}@example.com`;
await page.getByRole("checkbox", { name: "자동 생성" }).setChecked(false);
await page.getByLabel("이메일").fill(uniqueEmail);
await page.getByLabel("비밀번호").fill("Test1234!");
await page.getByLabel("이름").fill("Playwright User");
await page.getByLabel("전화번호").fill("010-0000-0000");
await page.getByLabel("회사 코드").fill("E2E");
await page.getByLabel("부서").fill("QA");
await page.getByLabel("역할 (Role)").selectOption("admin");
await page.getByRole("button", { name: "사용자 생성" }).click();
await expect(page).toHaveURL(/\/users$/);
const createdRow = page.locator("tbody tr").filter({ hasText: uniqueEmail });
await expect(createdRow).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await createdRow.getByRole("button", { name: /사용자 삭제/ }).click();
await expect(
page.locator("tbody tr").filter({ hasText: uniqueEmail }),
).toHaveCount(0);
});

View File

@@ -0,0 +1,335 @@
import { expect, test } from "@playwright/test";
test.describe("User Management", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
id_token: "fake-id-token",
access_token: "fake-token",
token_type: "Bearer",
scope: "openid profile email",
profile: {
sub: "admin-user",
name: "Admin",
email: "admin@test.com",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 36000,
};
window.localStorage.setItem(key, JSON.stringify(authData));
window.localStorage.setItem("admin_session", "fake-token");
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("oidc.state", "dummy");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
});
await page.route("**/oidc/**", async (route) => {
if (route.request().url().includes("/.well-known/openid-configuration")) {
return route.fulfill({
json: {
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
jwks_uri: "http://localhost:5000/oidc/jwks",
},
});
}
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
await page.route(/.*\/api\/v1\/.*/, async (route) => {
const url = route.request().url();
const method = route.request().method();
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
email: "admin@test.com",
role: "super_admin",
manageableTenants: [],
},
});
}
if (url.match(/\/admin\/tenants(\?.*)?$/) && method === "GET") {
return route.fulfill({
json: {
items: [
{
id: "t-1",
slug: "test-tenant",
name: "Test Tenant",
config: {
userSchema: [
{
key: "loginId",
label: "Login ID",
type: "text",
isLoginId: true,
},
],
},
},
],
total: 1,
limit: 100,
offset: 0,
},
});
}
if (url.match(/\/admin\/tenants\/t-1$/) && method === "GET") {
return route.fulfill({
json: {
id: "t-1",
slug: "test-tenant",
name: "Test Tenant",
config: {
userSchema: [
{
key: "loginId",
label: "Login ID",
type: "text",
isLoginId: true,
},
],
},
},
});
}
if (url.match(/\/admin\/users\/u-1$/) && method === "GET") {
return route.fulfill({
json: {
id: "u-1",
name: "John Doe",
email: "john@test.com",
loginId: "johndoe",
tenantSlug: "test-tenant",
tenant: { id: "t-1", name: "Test Tenant", slug: "test-tenant" },
role: "user",
status: "active",
metadata: { "t-1": { loginId: "johndoe" } },
},
});
}
if (url.includes("/password/policy")) {
return route.fulfill({
json: {
minLength: 12,
lowercase: true,
uppercase: true,
number: true,
nonAlphanumeric: true,
},
});
}
if (url.includes("/rp-history")) {
return route.fulfill({
json: [],
});
}
if (url.match(/\/admin\/users(\?.*)?$/) && method === "POST") {
// Parse request payload to simulate validation checks
const postData = route.request().postDataJSON();
if (postData && postData.metadata?.loginId === "existing_user") {
// Simulate a backend conflict error (409) for an existing loginId
return route.fulfill({
status: 409,
json: {
error: "이미 존재하는 로그인 ID 입니다.",
},
});
}
// Mock successful user creation
return route.fulfill({
json: {
id: "new-user-id",
name: "New User",
email: "newuser@test.com",
loginId: postData?.metadata?.loginId || "newuser123",
},
});
}
if (url.match(/\/admin\/users\/u-1$/) && method === "PUT") {
const postData = route.request().postData();
console.log("PUT /admin/users/u-1 payload:", postData);
// Force 409 error for this specific conflict string
if (postData?.includes("johndoe_conflict")) {
return route.fulfill({
status: 409,
json: {
error: "이미 존재하는 로그인 ID 입니다.",
},
});
}
// Mock successful user update
return route.fulfill({
json: {
id: "u-1",
name: "John Doe Updated",
email: "john@test.com",
loginId: "johndoe_updated",
},
});
}
if (url.match(/\/admin\/users(\?.*)?$/) && method === "GET") {
return route.fulfill({
json: {
items: [
{
id: "u-1",
name: "John Doe",
email: "john@test.com",
loginId: "johndoe",
role: "user",
status: "active",
},
],
total: 1,
limit: 50,
offset: 0,
},
});
}
return route.fulfill({ json: { items: [], total: 0 } });
});
});
test("should successfully edit a user's Login ID", async ({ page }) => {
await page.goto("/users/u-1");
// "테넌트 프로필" 탭 클릭
await page
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
.click();
// Wait for the form to load with the existing login ID
const loginIdInput = page.locator(
'input[name*="metadata"][name*="loginId"]',
);
await expect(loginIdInput).toBeVisible();
await expect(loginIdInput).toHaveValue("johndoe");
// Change the Login ID
await loginIdInput.fill("johndoe_updated");
// Submit the form using Enter key
await loginIdInput.press("Enter");
// Check for success message
await expect(page.getByText(/저장/i).first()).toBeVisible();
});
test("should show conflict error when updating to an existing Login ID", async ({
page,
}) => {
// Intercept ANY PUT request to this user and return 409
await page.route(/\/admin\/users\/u-1/, async (route) => {
if (route.request().method() === "PUT") {
return route.fulfill({
status: 409,
contentType: "application/json",
body: JSON.stringify({ error: "이미 존재하는 로그인 ID 입니다." }),
});
}
return route.fallback();
});
await page.goto("/users/u-1");
// "테넌트 프로필" 탭 클릭
await page
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
.click();
const loginIdInput = page.locator(
'input[name*="metadata"][name*="loginId"]',
);
await expect(loginIdInput).toBeVisible();
await expect(loginIdInput).toHaveValue("johndoe");
// Use a value similar to the successful edit test
await loginIdInput.fill("johndoe_conflict");
// Submit the form using Enter key
await loginIdInput.press("Enter");
// Check for the specific error
await expect(
page.getByText(/이미 존재하는 로그인 ID 입니다/i).first(),
).toBeVisible();
});
test("should successfully create a new user with a Login ID", async ({
page,
}) => {
await page.goto("/users/new");
// Ensure the page title is loaded
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
// Select Tenant first (important for schema fields to show up)
await page.selectOption("select#tenantSlug", "test-tenant");
// Fill required fields
await page.locator('input[name="name"]').fill("New User");
await page.locator('input[name="email"]').fill("newuser@test.com");
// Fill Login ID
const loginIdInput = page.locator('input[id*="metadata"][id*="loginId"]');
await loginIdInput.fill("newuser123");
// Submit the form
const createButton = page.getByRole("button", { name: /생성/i });
await createButton.click();
// Assuming successful creation redirects back to the user list
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
});
test("should show conflict error when creating with an existing Login ID", async ({
page,
}) => {
await page.goto("/users/new");
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
// Select Tenant first (important for schema fields to show up)
await page.selectOption("select#tenantSlug", "test-tenant");
// Fill required fields
await page.locator('input[name="name"]').fill("New User");
await page.locator('input[name="email"]').fill("newuser@test.com");
// Fill Login ID that triggers the mock conflict error
const loginIdInput = page.locator('input[id*="metadata"][id*="loginId"]');
await loginIdInput.fill("existing_user");
// Submit the form
const createButton = page.getByRole("button", { name: /생성/i });
await createButton.click();
// Check for the specific conflict error message from the backend mock
await expect(
page.getByText(/이미 존재하는 로그인 ID 입니다/i),
).toBeVisible();
});
});

View File

@@ -0,0 +1,115 @@
import { expect, test } from "@playwright/test";
test.describe("Users Bulk Upload", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
};
window.localStorage.setItem(key, JSON.stringify(authData));
});
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/users")) {
if (!url.includes("/bulk")) {
return route.fulfill({
json: { items: [], total: 0, limit: 50, offset: 0 },
headers,
});
}
}
if (url.includes("/admin/tenants")) {
return route.fulfill({
json: { items: [], total: 0, limit: 100, offset: 0 },
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});
test("should open bulk upload modal and show preview", async ({ page }) => {
await page.goto("/users");
// 헤더 타이틀이 뜰 때까지 대기
await expect(page.getByTestId("page-title")).toContainText(
/사용자|Users/i,
{ timeout: 20000 },
);
const bulkBtn = page.getByTestId("bulk-import-btn");
await bulkBtn.click();
await expect(page.getByTestId("bulk-upload-title")).toBeVisible({
timeout: 10000,
});
const downloadBtn = page
.locator("button")
.filter({ hasText: /템플릿 다운로드|템플릿 받기|Download Template/ })
.first();
await expect(downloadBtn).toBeVisible();
});
test("should show success results after mock upload", async ({ page }) => {
await page.route("**/api/v1/admin/users/bulk", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
json: {
results: [
{ email: "success@test.com", success: true, userId: "u-1" },
{
email: "fail@test.com",
success: false,
message: "Invalid format",
},
],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
} else {
await route.continue();
}
});
await page.goto("/users");
await expect(page.getByTestId("page-title")).toContainText(
/사용자|Users/i,
{ timeout: 20000 },
);
const bulkBtn = page.getByTestId("bulk-import-btn");
await bulkBtn.click();
const uploadBtn = page.getByTestId("bulk-start-btn");
await expect(uploadBtn).toBeDisabled();
});
});

View File

@@ -0,0 +1,233 @@
import { expect, test } from "@playwright/test";
test.describe("User Schema Dynamic Form", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
const authData = {
id_token: "fake-id-token",
access_token: "fake-token",
token_type: "Bearer",
scope: "openid profile email",
profile: {
sub: "admin-user",
name: "Admin",
email: "admin@test.com",
role: "super_admin",
},
expires_at: Math.floor(Date.now() / 1000) + 36000,
};
window.localStorage.setItem(key, JSON.stringify(authData));
window.localStorage.setItem("admin_session", "fake-token");
window.localStorage.setItem("locale", "ko");
// Mock oidc state to prevent redirection if the library checks it
window.localStorage.setItem("oidc.state", "dummy");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
});
await page.route("**/oidc/**", async (route) => {
if (route.request().url().includes("/.well-known/openid-configuration")) {
return route.fulfill({
json: {
issuer: "http://localhost:5000/oidc",
authorization_endpoint: "http://localhost:5000/oidc/auth",
token_endpoint: "http://localhost:5000/oidc/token",
userinfo_endpoint: "http://localhost:5000/oidc/userinfo",
jwks_uri: "http://localhost:5000/oidc/jwks",
},
});
}
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
await page.route(/.*\/api\/v1\/.*/, async (route) => {
const url = route.request().url();
if (url.includes("/user/me")) {
console.log("Mocking /user/me");
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
email: "admin@test.com",
role: "super_admin",
manageableTenants: [
{ id: "t-1", name: "Test Tenant", slug: "test-tenant" },
],
},
});
}
if (url.match(/\/admin\/tenants\/t-1$/)) {
console.log("Mocking /admin/tenants/t-1");
return route.fulfill({
json: {
id: "t-1",
name: "Test Tenant",
slug: "test-tenant",
config: {
userSchema: [
{
key: "emp_id",
label: "Employee ID",
required: true,
validation: "^E[0-9]{3}$",
},
{
key: "loginId",
label: "Login ID",
required: true,
isLoginId: true,
},
{
key: "salary",
label: "Salary",
adminOnly: true,
type: "number",
},
],
},
},
});
}
if (url.match(/\/admin\/users\/u-1$/)) {
console.log("Mocking /admin/users/u-1");
return route.fulfill({
json: {
id: "u-1",
name: "John Doe",
email: "john@test.com",
tenantSlug: "test-tenant",
tenant: { id: "t-1", name: "Test Tenant", slug: "test-tenant" },
joinedTenants: [
{ id: "t-1", name: "Test Tenant", slug: "test-tenant" },
],
metadata: {
"t-1": { emp_id: "E123", salary: 1000, loginId: "johndoe" },
},
},
});
}
if (url.includes("/password/policy")) {
console.log("Mocking /password/policy");
return route.fulfill({
json: {
minLength: 12,
lowercase: true,
uppercase: true,
number: true,
nonAlphanumeric: true,
},
});
}
if (url.includes("/rp-history")) {
console.log("Mocking /rp-history");
return route.fulfill({
json: [],
});
}
if (url.match(/\/admin\/tenants(\?.*)?$/)) {
console.log("Mocking /admin/tenants");
return route.fulfill({
json: {
items: [
{
id: "t-1",
slug: "test-tenant",
name: "Test Tenant",
config: {
userSchema: [
{
key: "emp_id",
label: "Employee ID",
required: true,
validation: "^E[0-9]{3}$",
},
{
key: "loginId",
label: "Login ID",
required: true,
isLoginId: true,
},
{
key: "salary",
label: "Salary",
adminOnly: true,
type: "number",
},
],
},
},
],
total: 1,
},
});
}
console.log("Mocking default empty list for:", url);
return route.fulfill({ json: { items: [], total: 0 } });
});
});
test("should render custom fields from schema in user detail", async ({
page,
}) => {
await page.goto("/users/u-1");
// "테넌트 프로필" 탭 클릭
await page
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
.click();
// 섹션 헤더 확인
const header = page
.getByText(/테넌트별 프로필 관리|Per-tenant Profile/i)
.first();
await header.waitFor({ state: "visible" });
// 커스텀 필드 레이블 확인
await expect(page.getByText("Employee ID")).toBeVisible();
// input 값 확인 (id에 t-1.emp_id가 포함됨)
const empIdInput = page.locator('input[id*="emp_id"]');
await expect(empIdInput).toHaveValue("E123");
const salaryInput = page.locator('input[id*="salary"]');
await expect(salaryInput).toHaveValue("1000");
await expect(page.getByText(/Admin Only/i).first()).toBeVisible();
});
test("should show regex validation error for custom field", async ({
page,
}) => {
await page.goto("/users/u-1");
// "테넌트 프로필" 탭 클릭
await page
.getByRole("tab", { name: /테넌트 프로필|Tenants|Tenant Profile/i })
.click();
const empIdInput = page.locator('input[id*="emp_id"]');
await empIdInput.waitFor({ state: "visible" });
await empIdInput.fill("invalid");
// Press Enter to trigger form submission and validation
await empIdInput.press("Enter");
// 에러 메시지 확인
const errorMsg = page
.getByText(/형식이 올바르지 않습니다|Invalid format/i)
.first();
await expect(errorMsg).toBeVisible();
});
});

View File

@@ -18,8 +18,8 @@
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true

View File

@@ -1,14 +1,27 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
envPrefix: ["VITE_", "USERFRONT_"],
server: {
host: "0.0.0.0",
host: "127.0.0.1",
// 인스턴스별 도메인을 자동으로 허용
allowedHosts: ["{{ADMINFRONT_DOMAIN}}", "localhost", "127.0.0.1","adminb.hmac.kr"],
proxy: {
"/api": {
target: process.env.API_PROXY_TARGET || "http://localhost:3000",
target: process.env.API_PROXY_TARGET || "http://backend:{{BACKEND_PORT}}",
changeOrigin: true,
},
},
},
preview: {
host: "127.0.0.1",
port: 5173,
allowedHosts: ["{{ADMINFRONT_DOMAIN}}", "localhost", "127.0.0.1", "adminb.hmac.kr"],
proxy: {
"/api": {
target: process.env.API_PROXY_TARGET || "http://backend:{{BACKEND_PORT}}",
changeOrigin: true,
},
},

View File

@@ -0,0 +1,12 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});

View File

@@ -0,0 +1,52 @@
package main
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"context"
"fmt"
"log"
)
func main() {
kratosAdmin := service.NewKratosAdminService()
ctx := context.Background()
identities, err := kratosAdmin.ListIdentities(ctx)
if err != nil {
log.Fatalf("Failed to list identities: %v", err)
}
count := 0
for _, id := range identities {
traits := id.Traits
changed := false
if r, ok := traits["role"].(string); ok {
norm := domain.NormalizeRole(r)
if norm != r && norm == domain.RoleUser {
traits["role"] = norm
traits["grade"] = norm
changed = true
}
} else if g, ok := traits["grade"].(string); ok {
norm := domain.NormalizeRole(g)
if norm != g && norm == domain.RoleUser {
traits["role"] = norm
traits["grade"] = norm
changed = true
}
}
if changed {
_, err := kratosAdmin.UpdateIdentity(ctx, id.ID, traits, id.State)
if err != nil {
log.Printf("Failed to update %s: %v", id.ID, err)
} else {
count++
fmt.Printf("Updated %s\n", id.ID)
}
}
}
fmt.Printf("Total updated: %d\n", count)
}

View File

@@ -0,0 +1,33 @@
package main
import (
"baron-sso-backend/internal/response"
"errors"
"log/slog"
"github.com/gofiber/fiber/v2"
)
func newErrorHandler(appEnv string) fiber.ErrorHandler {
return func(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
var e *fiber.Error
if errors.As(err, &e) {
code = e.Code
}
if appEnv == "production" || appEnv == "stage" {
if code >= 500 {
slog.Error("Internal Server Error",
"error", err.Error(),
"path", c.Path(),
"method", c.Method(),
)
return response.Error(c, code, response.StatusCode(code), "Internal Server Error")
}
}
return response.Error(c, code, response.StatusCode(code), err.Error())
}
}

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