diff --git a/README.md b/README.md index 6f8fce9c..457b1a1e 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,20 @@ baron_sso/ * AdminFront: 사용자 관리 등 Admin 기능 * DevFront: RP 관리 등 개발자 기능 +## 개발 실행 정책 + +`make dev`는 로컬 개발용 실행 모드이며, React 기반 `adminfront`, `devfront`, `orgfront`는 모두 Vite HMR 모드로 동작해야 합니다. 이 세 서비스는 Docker Compose에서 Dockerfile `dev` target을 사용하고 `/workspace/` bind mount 위에서 `npm run dev -- --host 0.0.0.0`로 실행합니다. `make dev` 경로에서 production `dist`를 `serve_frontend_prod.mjs`로 정적 서빙하면 안 됩니다. + +현재 개발 포트는 다음과 같습니다. + +- AdminFront: `http://localhost:5173` +- DevFront: `http://localhost:5174` +- OrgFront: `http://localhost:5175` + +자세한 정책과 회귀 테스트는 [make dev Vite HMR Policy](docs/make-dev-vite-hmr-policy.md)를 확인하세요. 정책 회귀는 `test/frontend_dev_bind_mount_policy_test.sh`에서 검사합니다. + +로컬 Playwright E2E도 기본적으로 Vite dev server를 봅니다. Gitea Actions 같은 CI에서는 `CI=true`로 production bundle을 `vite preview`로 검증합니다. 로컬에서 production bundle을 명시적으로 검증하려면 `PLAYWRIGHT_USE_PREVIEW=true`를 사용하세요. 이 정책은 `test/playwright_frontend_runtime_policy_test.sh`에서 검사합니다. + ## 🏗 아키텍처 (Architecture) @@ -380,18 +394,18 @@ Kratos가 사용자 SoT이며 Hydra는 순수 OIDC 토큰 엔진입니다. 비 ### SSOT 및 Redis Cache 전략 -Baron SSO의 인증, 권한, OAuth/OIDC 원장은 Ory Stack입니다. Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane이며, Ory에 저장되지 않거나 Ory API로 필요한 방식의 조회가 불가능한 데이터만 read model로 보관합니다. Redis는 원장 데이터와 허용된 read model의 성능 cache/mirror로만 사용합니다. +Baron SSO의 인증, 권한, OAuth/OIDC 원장은 Ory Stack입니다. Backend는 원장 쓰기 경로와 감사 로그를 중앙화하는 Control Plane입니다. 사용자 identity/profile/소속/조직도 노출 데이터에 대해 Backend DB `users`를 원장 또는 read model로 사용하지 않습니다. Redis는 Ory 원장 데이터의 성능 cache/mirror로만 사용합니다. -Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend가 직접 소비하지 않습니다. Backend가 Redis와 허용된 read model을 조합해 cursor 기반 API로 adminfront, orgfront, userfront, 외부 API에 제공합니다. +Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend가 직접 소비하지 않습니다. Backend가 Redis mirror 또는 Ory Admin API fallback을 기준으로 cursor 기반 API를 adminfront, orgfront, userfront, 외부 API에 제공합니다. #### 데이터별 원본 위치 | 데이터 | SSOT | 보조 저장소/캐시 | 비고 | | --- | --- | --- | --- | -| Identity subject, credentials, recovery/verification address | Ory Kratos `identities` | Redis identity mirror, PostgreSQL `users.id` 참조 | Kratos identity ID가 사용자 subject이며 WORKS `externalKey` 기준입니다. | -| 로그인 식별자 | Ory Kratos traits | Redis identity mirror, `user_login_ids` read model | Kratos가 인증 식별자의 원장이고 Backend DB는 중복/정책 검증용 read model입니다. | -| 사용자 이름, 이메일, 전화번호, role 기본값 | Ory Kratos traits | Redis identity mirror, PostgreSQL `users` read model | 인증/profile 계산에 필요한 최소 identity 값만 Kratos에 유지합니다. | -| Baron 사용자 운영 상태, soft delete, 운영 메타데이터 | Backend read model | Redis 조합 응답 cache | Ory에 같은 의미로 저장되거나 조회되지 않는 Baron 운영 데이터입니다. Kratos identity 삭제와 혼동하지 않습니다. | +| Identity subject, credentials, recovery/verification address | Ory Kratos `identities` | Redis identity mirror | Kratos identity ID가 사용자 subject이며 WORKS `externalKey` 기준입니다. | +| 로그인 식별자 | Ory Kratos traits | Redis identity mirror/index | Kratos가 인증 식별자의 원장입니다. | +| 사용자 이름, 이메일, 전화번호, role 기본값 | Ory Kratos traits | Redis identity mirror | 인증/profile 계산에 필요한 identity 값은 Kratos 기준으로 유지합니다. | +| Baron 사용자 운영 상태, soft delete, 운영 메타데이터 | Ory Kratos traits/state 또는 별도 명시 원장 | Redis mirror/cache | Backend DB `users`를 사용자 read model로 사용하지 않습니다. | | 테넌트 tree, slug, 조직/부서/직무/직책 | Ory Keto relation tuple, Backend read model | Redis/API response cache 가능 | 권한/관계 판단은 Keto가 원장입니다. Ory가 보관하거나 조회할 수 없는 조직 표시/검색 데이터만 Backend read model에 둡니다. | | 권한/관계 | Ory Keto relation tuple | PostgreSQL outbox/status | Backend를 통해 relation command를 보내고 처리 상태를 추적합니다. | | OAuth2/OIDC client, consent, token state | Ory Hydra | PostgreSQL `client_consents`, audit/read model | Hydra가 프로토콜 원장이며 로컬 테이블은 운영 조회/감사용입니다. | @@ -405,10 +419,10 @@ Ory에서 Redis cache로 웜업된 identity/조직 데이터는 frontend가 직 #### SSOT 보장 원칙 1. Kratos/Hydra/Keto/WORKS로 향하는 쓰기 command는 Backend를 통과합니다. -2. Backend는 Ory write 성공 후 원장 ID를 기준으로 Ory를 재조회하고, Redis mirror를 갱신하거나 stale로 표시합니다. Backend DB 갱신은 Ory에 저장되지 않거나 조회가 불가능한 read model에 한정합니다. +2. Backend는 Ory write 성공 후 원장 ID를 기준으로 Ory를 재조회하고, Redis mirror를 갱신하거나 stale로 표시합니다. 사용자 identity/profile/소속 데이터는 Backend DB `users`에 read model로 갱신하지 않습니다. 3. write-through 갱신 실패 시 원장 write를 되돌린 것으로 간주하지 않습니다. 대신 mirror/cache 상태를 `stale` 또는 `failed`로 표시하고 drift report와 refresh 대상으로 둡니다. 4. Kratos Admin API 또는 Kratos DB를 Backend 밖에서 직접 수정하는 경로는 운영 정책상 금지합니다. 정비/DR처럼 예외가 필요한 경우에는 Redis mirror를 stale로 표시하고, full refresh와 drift report를 완료하기 전까지 cache 결과를 신뢰하지 않습니다. -5. Backend read model이나 Redis cache는 Kratos partial list를 full snapshot처럼 취급하지 않습니다. Kratos 목록 조회가 partial이면 로컬 사용자를 삭제/숨김 처리하지 않습니다. +5. Backend DB `users`나 Redis cache는 Kratos partial list를 full snapshot처럼 취급하지 않습니다. Kratos 목록 조회가 partial이면 로컬 사용자 데이터를 근거로 정상 목록을 만들지 않습니다. 6. frontend/API 대량 조회는 Backend가 제공하는 cursor 기반을 원칙으로 합니다. `limit=5000&offset=0` 같은 단일 대량 offset 조회는 사용자 수가 늘면 partial data를 전체처럼 보이게 만들 수 있으므로 신규 구현에서 금지합니다. 7. Redis cache miss가 발생한 단건 조회는 가능한 경우 SSOT로 fallback하고, fallback 성공 시 Redis를 갱신합니다. 목록 조회는 mirror 상태가 `ready`가 아니면 화면/API에 경고 상태를 함께 전달해야 합니다. diff --git a/adminfront/Dockerfile b/adminfront/Dockerfile index 305e3026..1c3e21dc 100644 --- a/adminfront/Dockerfile +++ b/adminfront/Dockerfile @@ -1,4 +1,4 @@ -FROM node:lts AS build +FROM node:lts AS deps WORKDIR /workspace @@ -22,6 +22,17 @@ ENV ORGFRONT_URL=$ORGFRONT_URL RUN pnpm install --frozen-lockfile --ignore-scripts +FROM deps AS dev + +WORKDIR /workspace/adminfront +ENV NODE_ENV=development + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] + +FROM deps AS build + WORKDIR /workspace/adminfront RUN npm run build diff --git a/adminfront/e2e-evidence/tenant-profile-performance-local.json b/adminfront/e2e-evidence/tenant-profile-performance-local.json new file mode 100644 index 00000000..0eae7c94 --- /dev/null +++ b/adminfront/e2e-evidence/tenant-profile-performance-local.json @@ -0,0 +1,134 @@ +{ + "metric": "tenant-profile-local-performance", + "tenantId": "56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "actualApiBaseUrl": "http://localhost:5173/api", + "measuredAt": "2026-06-16T23:45:00.441Z", + "browser": "chromium", + "samples": [ + { + "sample": 1, + "configFieldsVisibleMs": 424, + "networkIdleMs": 862, + "orgUnitType": "센터", + "visibility": "public", + "worksmobileSync": "enabled", + "apiTimings": [ + { + "method": "GET", + "url": "http://playwright-mock/api/v1/user/me", + "status": 200, + "durationMs": 134 + }, + { + "method": "GET", + "url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "status": 200, + "durationMs": 184 + } + ] + }, + { + "sample": 2, + "configFieldsVisibleMs": 376, + "networkIdleMs": 751, + "orgUnitType": "센터", + "visibility": "public", + "worksmobileSync": "enabled", + "apiTimings": [ + { + "method": "GET", + "url": "http://playwright-mock/api/v1/user/me", + "status": 200, + "durationMs": 20 + }, + { + "method": "GET", + "url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "status": 200, + "durationMs": 133 + } + ] + }, + { + "sample": 3, + "configFieldsVisibleMs": 400, + "networkIdleMs": 797, + "orgUnitType": "센터", + "visibility": "public", + "worksmobileSync": "enabled", + "apiTimings": [ + { + "method": "GET", + "url": "http://playwright-mock/api/v1/user/me", + "status": 200, + "durationMs": 21 + }, + { + "method": "GET", + "url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "status": 200, + "durationMs": 156 + } + ] + }, + { + "sample": 4, + "configFieldsVisibleMs": 431, + "networkIdleMs": 843, + "orgUnitType": "센터", + "visibility": "public", + "worksmobileSync": "enabled", + "apiTimings": [ + { + "method": "GET", + "url": "http://playwright-mock/api/v1/user/me", + "status": 200, + "durationMs": 25 + }, + { + "method": "GET", + "url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "status": 200, + "durationMs": 178 + } + ] + }, + { + "sample": 5, + "configFieldsVisibleMs": 380, + "networkIdleMs": 758, + "orgUnitType": "센터", + "visibility": "public", + "worksmobileSync": "enabled", + "apiTimings": [ + { + "method": "GET", + "url": "http://playwright-mock/api/v1/user/me", + "status": 200, + "durationMs": 24 + }, + { + "method": "GET", + "url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + "status": 200, + "durationMs": 129 + } + ] + } + ], + "summary": { + "configFieldsVisibleMs": { + "min": 376, + "max": 431, + "p50": 400, + "p95": 431 + }, + "networkIdleMs": { + "min": 751, + "max": 862, + "p50": 797, + "p95": 862 + } + }, + "screenshotPath": "/home/lectom/repos/baron-sso/adminfront/e2e-evidence/tenant-profile-performance-local.png" +} diff --git a/adminfront/e2e-evidence/tenant-profile-performance-local.png b/adminfront/e2e-evidence/tenant-profile-performance-local.png new file mode 100644 index 00000000..a6352ed4 Binary files /dev/null and b/adminfront/e2e-evidence/tenant-profile-performance-local.png differ diff --git a/adminfront/playwright.config.ts b/adminfront/playwright.config.ts index e2b56df4..5e7f8b81 100644 --- a/adminfront/playwright.config.ts +++ b/adminfront/playwright.config.ts @@ -14,6 +14,8 @@ const port = Number.parseInt(process.env.PORT ?? "5173", 10); const defaultBaseUrl = `http://127.0.0.1:${port}`; const baseURL = process.env.BASE_URL ?? defaultBaseUrl; const reuseExistingServer = !process.env.CI && !process.env.PORT; +const usePreviewServer = + process.env.CI === "true" || process.env.PLAYWRIGHT_USE_PREVIEW === "true"; const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; /** @@ -84,7 +86,7 @@ export default defineConfig({ webServer: process.env.BASE_URL ? undefined : { - command: process.env.CI + command: usePreviewServer ? `pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort` : `pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort`, url: `http://127.0.0.1:${port}`, diff --git a/adminfront/seed-tenant.csv b/adminfront/seed-tenant.csv index 5eabc9bd..f5db24ef 100644 --- a/adminfront/seed-tenant.csv +++ b/adminfront/seed-tenant.csv @@ -10,4 +10,7 @@ b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,COMPANY,baron-group,jangheon-s e57cb22c-383e-4489-8c2f-0c5431917e86,(주)피티씨,COMPANY,baron-group,ptc,,pre-cast.co.kr,,, 4d0f26b9-702c-4bc6-8996-46e9eedfdeb7,MH_manager,USER_GROUP,hanmac-family,mhd,맨아워 대시보드 권한 보유자그룹,,private,,no e41adf79-3d15-4807-8303-afbdb0f2bab7,SW_uploader,USER_GROUP,hanmac-family,sw-uploader,소프트웨어 배포 권한 그룹,,private,,no -9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,, +ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916,일반회사,COMPANY_GROUP,,commercial,외부 기업회원 루트 테넌트,,,, +d19c10f0-0224-4bbb-bf3e-ce579c5338ea,공공기관,COMPANY_GROUP,,public-org,공공기관 기본 루트 테넌트,,,, +78accec5-8eba-4324-b8f1-10ab360011fe,교육/학생,COMPANY_GROUP,,edu,교육기관 및 학생 기본 루트 테넌트,,,, +9607eb7b-04d2-42ab-80fe-780fe21c7e8f,개인사용자,PERSONAL,,personal,개인 사용자 기본 루트 테넌트,,,, diff --git a/adminfront/src/app/routes.test.tsx b/adminfront/src/app/routes.test.tsx index 64ff5323..ba169a42 100644 --- a/adminfront/src/app/routes.test.tsx +++ b/adminfront/src/app/routes.test.tsx @@ -28,16 +28,33 @@ describe("admin routes", () => { expect(matches?.at(-1)?.route.path).toBe("system/data-integrity"); }); - it("routes global custom claim settings before user detail id matching", () => { + it("routes global custom claim settings before user detail id matching", async () => { const matches = matchRoutes(adminRoutes, "/users/custom-claims"); const leafRoute = matches?.at(-1)?.route; expect(leafRoute?.path).toBe("users/custom-claims"); - expect(getRouteElementName(leafRoute?.element)).toBe( + expect(await getRouteComponentName(leafRoute)).toBe( "GlobalCustomClaimsPage", ); }); + it("code-splits tenant detail profile routes away from the initial admin shell", () => { + const matches = matchRoutes( + adminRoutes, + "/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb", + ); + const detailRoute = matches?.find( + (match) => match.route.path === "tenants/:tenantId", + )?.route; + const profileRoute = matches?.at(-1)?.route; + + expect(detailRoute?.element).toBeUndefined(); + expect(typeof detailRoute?.lazy).toBe("function"); + expect(profileRoute?.index).toBe(true); + expect(profileRoute?.element).toBeUndefined(); + expect(typeof profileRoute?.lazy).toBe("function"); + }); + it("keeps protected admin pages behind an auth guard before mounting the layout", () => { const rootRoute = adminRoutes.find((route) => route.path === "/"); const protectedShellRoute = rootRoute?.children?.[0]; @@ -48,6 +65,29 @@ describe("admin routes", () => { }); }); +async function getRouteComponentName(route: unknown) { + if ( + typeof route === "object" && + route !== null && + "lazy" in route && + typeof route.lazy === "function" + ) { + const lazyRoute = await route.lazy(); + if ("Component" in lazyRoute && typeof lazyRoute.Component === "function") { + return lazyRoute.Component.name; + } + if ("element" in lazyRoute) { + return getRouteElementName(lazyRoute.element); + } + } + + if (typeof route === "object" && route !== null && "element" in route) { + return getRouteElementName(route.element); + } + + return undefined; +} + function getRouteElementName(element: unknown) { if ( typeof element === "object" && diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index cc4ac8e6..1b04e751 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -1,32 +1,33 @@ +import type { ComponentType } from "react"; import type { RouteObject } from "react-router-dom"; import { createBrowserRouter } from "react-router-dom"; import AppLayout from "../components/layout/AppLayout"; -import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage"; -import ApiKeyListPage from "../features/api-keys/ApiKeyListPage"; -import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuthCallbackPage from "../features/auth/AuthCallbackPage"; import AuthGuard from "../features/auth/AuthGuard"; -import AuthPage from "../features/auth/AuthPage"; import LoginPage from "../features/auth/LoginPage"; -import DataIntegrityPage from "../features/integrity/DataIntegrityPage"; -import OrySSOTPage from "../features/ory-ssot/OrySSOTPage"; -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 { TenantFineGrainedPermissionsPage } from "../features/tenants/routes/TenantFineGrainedPermissionsPage"; -import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab"; -import TenantListPage from "../features/tenants/routes/TenantListPage"; -import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"; -import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage"; -import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage"; -import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab"; -import GlobalCustomClaimsPage from "../features/users/GlobalCustomClaimsPage"; -import UserCreatePage from "../features/users/UserCreatePage"; -import UserDetailPage from "../features/users/UserDetailPage"; -import UserListPage from "../features/users/UserListPage"; import { ADMIN_AUTH_CALLBACK_PATH } from "../lib/authConfig"; +type RouteModule = { + default: ComponentType; +}; + +function lazyDefault(loader: () => Promise) { + return async () => { + const module = await loader(); + return { Component: module.default }; + }; +} + +function lazyNamed( + loader: () => Promise, + key: TKey, +) { + return async () => { + const module = await loader(); + return { Component: module[key] as ComponentType }; + }; +} + export const adminRoutes: RouteObject[] = [ { path: "/login", @@ -43,42 +44,147 @@ export const adminRoutes: RouteObject[] = [ { element: , children: [ - { index: true, element: }, - { path: "audit-logs", element: }, - { path: "auth", element: }, - { path: "users", element: }, - { path: "users/custom-claims", element: }, - { path: "users/new", element: }, - { path: "users/:id", element: }, - { path: "tenants", element: }, - { path: "tenants/new", element: }, - { path: "worksmobile", element: }, + { + index: true, + lazy: lazyDefault( + () => import("../features/overview/GlobalOverviewPage"), + ), + }, + { + path: "audit-logs", + lazy: lazyDefault(() => import("../features/audit/AuditLogsPage")), + }, + { + path: "auth", + lazy: lazyDefault(() => import("../features/auth/AuthPage")), + }, + { + path: "users", + lazy: lazyDefault(() => import("../features/users/UserListPage")), + }, + { + path: "users/custom-claims", + lazy: lazyDefault( + () => import("../features/users/GlobalCustomClaimsPage"), + ), + }, + { + path: "users/new", + lazy: lazyDefault(() => import("../features/users/UserCreatePage")), + }, + { + path: "users/:id", + lazy: lazyDefault(() => import("../features/users/UserDetailPage")), + }, + { + path: "tenants", + lazy: lazyDefault( + () => import("../features/tenants/routes/TenantListPage"), + ), + }, + { + path: "tenants/new", + lazy: lazyDefault( + () => import("../features/tenants/routes/TenantCreatePage"), + ), + }, + { + path: "worksmobile", + lazy: lazyNamed( + () => import("../features/tenants/routes/TenantWorksmobilePage"), + "TenantWorksmobilePage", + ), + }, { path: "permissions-direct", - element: , + lazy: lazyNamed( + () => + import( + "../features/tenants/routes/TenantFineGrainedPermissionsPage" + ), + "TenantFineGrainedPermissionsPage", + ), }, { path: "tenants/:tenantId", - element: , + lazy: lazyDefault( + () => import("../features/tenants/routes/TenantDetailPage"), + ), children: [ - { index: true, element: }, - { path: "permissions", element: }, - { path: "organization", element: }, - { path: "schema", element: }, + { + index: true, + lazy: lazyNamed( + () => import("../features/tenants/routes/TenantProfilePage"), + "TenantProfilePage", + ), + }, + { + path: "permissions", + lazy: lazyNamed( + () => + import( + "../features/tenants/routes/TenantAdminsAndOwnersTab" + ), + "TenantAdminsAndOwnersTab", + ), + }, + { + path: "organization", + lazy: lazyDefault( + () => + import( + "../features/user-groups/routes/TenantUserGroupsTab" + ), + ), + }, + { + path: "schema", + lazy: lazyNamed( + () => import("../features/tenants/routes/TenantSchemaPage"), + "TenantSchemaPage", + ), + }, { path: "relations", - element: , + lazy: lazyNamed( + () => + import( + "../features/tenants/routes/TenantFineGrainedPermissionsTab" + ), + "TenantFineGrainedPermissionsTab", + ), }, ], }, { path: "tenants/:tenantId/organization/:id", - element: , + lazy: lazyDefault( + () => + import("../features/user-groups/routes/TenantUserGroupsTab"), + ), + }, + { + path: "api-keys", + lazy: lazyDefault( + () => import("../features/api-keys/ApiKeyListPage"), + ), + }, + { + path: "api-keys/new", + lazy: lazyDefault( + () => import("../features/api-keys/ApiKeyCreatePage"), + ), + }, + { + path: "system/ory-ssot", + lazy: lazyDefault(() => import("../features/ory-ssot/OrySSOTPage")), + }, + { + path: "system/data-integrity", + lazy: lazyDefault( + () => import("../features/integrity/DataIntegrityPage"), + ), }, - { path: "api-keys", element: }, - { path: "api-keys/new", element: }, - { path: "system/ory-ssot", element: }, - { path: "system/data-integrity", element: }, ], }, ], diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx index 96a85a5f..9d2ef487 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx @@ -15,8 +15,9 @@ import { ShieldCheck, Trash2, Users, + X, } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Avatar, AvatarFallback } from "../../../components/ui/avatar"; import { Badge } from "../../../components/ui/badge"; @@ -31,27 +32,71 @@ import { } from "../../../components/ui/dialog"; import { Input } from "../../../components/ui/input"; import { ScrollArea } from "../../../components/ui/scroll-area"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; import { toast } from "../../../components/ui/use-toast"; import { addSystemRelation, + addTenantRelation, + bulkUpdateUsers, fetchAllTenants, fetchMe, fetchSystemRelations, - fetchUsers, + fetchTenantRelations, removeSystemRelation, + removeTenantRelation, type TenantRelation, + type UserSummary, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { + buildAuthenticatedOrgChartUserMultiPickerUrl, + parseOrgChartUserSelections, +} from "../../users/orgChartPicker"; + +const protectedSystemMenuRelations = new Set([ + "ory_ssot", + "data_integrity", + "permissions_direct", +]); export function TenantFineGrainedPermissionsPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); const [activeTab, _setActiveTab] = useState<"tenant" | "system">("system"); + const [activePermissionTab, setActivePermissionTab] = useState< + "direct" | "super-admin" + >("direct"); const [_selectedTenantId, _setSelectedTenantId] = useState(""); - const [searchTerm, setSearchTerm] = useState(""); - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [activeUserId, setActiveUserId] = useState(null); - const [userSearchTerm, setUserSearchTerm] = useState(""); + const [targetTenantId, setTargetTenantId] = useState(""); + const [queuedTargetUsers, setQueuedTargetUsers] = useState([]); + const [bulkRelationMode, setBulkRelationMode] = useState< + "page" | "target-action" + >("page"); + const [bulkPageRelation, setBulkPageRelation] = + useState("overview_viewers"); + const [bulkTenantPage, setBulkTenantPage] = useState("profile"); + const [bulkAction, setBulkAction] = useState<"read" | "manage">("read"); + const [tenantPickerOpen, setTenantPickerOpen] = useState(false); + const [tenantPickerSearch, setTenantPickerSearch] = useState(""); + const [selectedSuperAdminUserIds, setSelectedSuperAdminUserIds] = useState< + string[] + >([]); + const [assignmentSearchTerm, setAssignmentSearchTerm] = useState(""); + const [assignmentSort, setAssignmentSort] = useState< + "user" | "relation" | "level" + >("user"); + const orgChartMemberPickerUrl = useMemo( + () => + buildAuthenticatedOrgChartUserMultiPickerUrl(import.meta.env.ORGFRONT_URL), + [], + ); // 🌟 글로벌 시스템 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언 const [localSystemPermissions, setLocalSystemPermissions] = useState< @@ -71,7 +116,7 @@ export function TenantFineGrainedPermissionsPage() { enabled: isSuperAdmin, }); - const _tenants = isSuperAdmin + const tenants = isSuperAdmin ? (tenantsQuery.data?.items ?? []) : (profile?.manageableTenants ?? []); @@ -83,6 +128,17 @@ export function TenantFineGrainedPermissionsPage() { }); const systemRelations = systemRelationsQuery.data ?? []; + const tenantRelationsQuery = useQuery({ + queryKey: ["tenant-relations", targetTenantId], + queryFn: () => fetchTenantRelations(targetTenantId), + enabled: + isSuperAdmin && + activePermissionTab === "direct" && + bulkRelationMode === "target-action" && + targetTenantId.length > 0, + }); + const tenantRelations = tenantRelationsQuery.data ?? []; + // 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화 useEffect(() => { if (systemRelationsQuery.data) { @@ -227,6 +283,67 @@ export function TenantFineGrainedPermissionsPage() { }, }); + const addTenantRelationMutation = useMutation({ + mutationFn: (payload: { + tenantId: string; + userId: string; + relation: string; + }) => addTenantRelation(payload.tenantId, payload.userId, payload.relation), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["tenant-relations", variables.tenantId], + }); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const removeTenantRelationMutation = useMutation({ + mutationFn: (payload: { + tenantId: string; + userId: string; + relation: string; + }) => + removeTenantRelation(payload.tenantId, payload.userId, payload.relation), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["tenant-relations", variables.tenantId], + }); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + + const updateUserRoleMutation = useMutation({ + mutationFn: (payload: { userIds: string[]; role: string }) => + bulkUpdateUsers(payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["admin-users"] }); + queryClient.invalidateQueries({ queryKey: ["me"] }); + toast.success( + t( + "msg.admin.permissions_direct.super_admin_grant_success", + "Super Admin 역할이 부여되었습니다.", + ), + ); + setSelectedSuperAdminUserIds([]); + }, + onError: (err: AxiosError<{ error?: string }>) => { + toast.error( + err.response?.data?.error || + t("msg.common.error", "오류가 발생했습니다."), + ); + }, + }); + const handleSystemRelationChange = async ( userId: string, menuKey: string, @@ -289,25 +406,161 @@ export function TenantFineGrainedPermissionsPage() { for (const rel of userRelations) { await removeSystemRelationMutation.mutateAsync({ userId, relation: rel }); } - if (activeUserId === userId) { - setActiveUserId(null); + }; + + const toggleSuperAdminUser = (userId: string, checked: boolean) => { + setSelectedSuperAdminUserIds((current) => + checked + ? [...new Set([...current, userId])] + : current.filter((id) => id !== userId), + ); + }; + + const resolveBulkRelation = () => { + if (bulkRelationMode === "page") { + return bulkPageRelation; } + return `${bulkTenantPage}_${bulkAction === "manage" ? "managers" : "viewers"}`; }; - const usersQuery = useQuery({ - queryKey: ["admin-users-search", searchTerm], - queryFn: () => fetchUsers(20, 0, searchTerm), - enabled: isDialogOpen && searchTerm.length >= 2, - }); + const handleBulkRelationSubmit = async () => { + if (queuedTargetUsers.length === 0) { + toast.error( + t( + "msg.admin.permissions_direct.bulk_users_required", + "권한을 적용할 사용자를 하나 이상 선택하세요.", + ), + ); + return; + } - const handleAddSystemUser = (userId: string) => { - addSystemRelationMutation.mutate({ userId, relation: "overview_viewers" }); - setActiveUserId(userId); - setIsDialogOpen(false); - setSearchTerm(""); + const relation = resolveBulkRelation(); + if (bulkRelationMode === "page" && relation.startsWith("permissions_direct_")) { + toast.error( + t( + "msg.admin.permissions_direct.protected_relation", + "권한 부여 화면 접근 권한은 Super Admin 전용입니다.", + ), + ); + return; + } + if (bulkRelationMode === "target-action" && !targetTenantId) { + toast.error( + t( + "msg.admin.permissions_direct.target_tenant_required", + "대상 테넌트를 선택하세요.", + ), + ); + return; + } + + for (const user of queuedTargetUsers) { + if (bulkRelationMode === "page") { + await addSystemRelationMutation.mutateAsync({ + userId: user.id, + relation, + }); + } else { + const currentSystemRelations = + systemRelations.find((item) => item.userId === user.id)?.relations ?? + []; + const requiredPageAccess = + bulkAction === "manage" ? "tenants_managers" : "tenants_viewers"; + if (!currentSystemRelations.includes(requiredPageAccess)) { + await addSystemRelationMutation.mutateAsync({ + userId: user.id, + relation: requiredPageAccess, + }); + } + await addTenantRelationMutation.mutateAsync({ + tenantId: targetTenantId, + userId: user.id, + relation, + }); + } + } + + toast.success( + t( + "msg.admin.permissions_direct.bulk_grant_success", + "선택 사용자에게 권한을 부여했습니다.", + ), + ); + setQueuedTargetUsers([]); }; - const searchResults = usersQuery.data?.items || []; + const handleGrantSuperAdminRole = () => { + if (selectedSuperAdminUserIds.length === 0) { + toast.error( + t( + "msg.admin.permissions_direct.super_admin_users_required", + "Super Admin을 부여할 사용자를 하나 이상 선택하세요.", + ), + ); + return; + } + updateUserRoleMutation.mutate({ + userIds: selectedSuperAdminUserIds, + role: "super_admin", + }); + }; + + const queueTargetUsers = useCallback((users: UserSummary[]) => { + setQueuedTargetUsers((current) => { + const next = [...current]; + const ids = new Set(current.map((user) => user.id)); + for (const user of users) { + if (ids.has(user.id)) continue; + ids.add(user.id); + next.push(user); + } + return next; + }); + }, []); + + const removeQueuedTargetUser = (userId: string) => { + setQueuedTargetUsers((current) => + current.filter((user) => user.id !== userId), + ); + }; + + useEffect(() => { + if (activePermissionTab !== "direct") return; + + const onMessage = (event: MessageEvent) => { + const selections = parseOrgChartUserSelections(event.data); + if (selections.length === 0) return; + + queueTargetUsers( + selections.map((selection) => ({ + id: selection.id, + name: selection.name, + email: selection.email, + tenantSlug: selection.leafTenantName, + tenant: selection.leafTenantName + ? { + id: "", + slug: "", + name: selection.leafTenantName, + createdAt: "", + updatedAt: "", + } + : undefined, + metadata: { + rootTenantName: selection.rootTenantName, + leafTenantName: selection.leafTenantName, + }, + role: "user", + status: "active", + createdAt: "", + updatedAt: "", + })), + ); + }; + + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [activePermissionTab, queueTargetUsers]); // Categorized system menus with descriptions and icons const systemMenuCategories = [ @@ -441,13 +694,188 @@ export function TenantFineGrainedPermissionsPage() { }, ]; - const filteredRelations = systemRelations.filter( - (r) => - r.name.toLowerCase().includes(userSearchTerm.toLowerCase()) || - r.email.toLowerCase().includes(userSearchTerm.toLowerCase()), + const filteredRelations = systemRelations; + const selectedUser = undefined; + const grantableSystemMenus = systemMenuCategories.flatMap((category) => + category.menus.filter( + (menu) => !protectedSystemMenuRelations.has(menu.relation), + ), ); + const menuByRelation = new Map( + systemMenuCategories + .flatMap((category) => category.menus) + .map((menu) => [menu.relation, menu]), + ); + const pageRelationOptions = grantableSystemMenus.flatMap((menu) => [ + { + label: `${menu.label} - ${t("ui.common.read", "조회")}`, + value: `${menu.relation}_viewers`, + }, + { + label: `${menu.label} - ${t("ui.common.write", "수정")}`, + value: `${menu.relation}_managers`, + }, + ]); + const tenantPermissionPages = [ + { + value: "profile", + label: t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필"), + }, + { + value: "permissions", + label: t("ui.admin.tenants.detail.tab_permissions", "권한 관리"), + }, + { + value: "organization", + label: t("ui.admin.tenants.detail.tab_organization", "조직 관리"), + }, + { + value: "schema", + label: t("ui.admin.tenants.detail.tab_schema", "사용자 스키마"), + }, + ]; + const selectedTargetTenant = tenants.find( + (tenant) => tenant.id === targetTenantId, + ); + const tenantPickerCandidates = tenants.filter((tenant) => { + const query = tenantPickerSearch.trim().toLowerCase(); + if (!query) return true; + return ( + tenant.name.toLowerCase().includes(query) || + tenant.slug.toLowerCase().includes(query) + ); + }); + const permissionAssignmentRows = systemRelations.flatMap((user) => + user.relations.map((relation) => { + const level = relation.endsWith("_managers") ? "write" : "read"; + const target = relation.replace(/_(viewers|managers)$/, ""); + const menu = menuByRelation.get(target); + return { + scope: "system" as const, + user, + relation, + target, + level, + label: menu?.label ?? target, + tenantId: "", + tenantName: t("ui.admin.permissions_direct.scope_system", "전역"), + protected: protectedSystemMenuRelations.has(target), + }; + }), + ); + const tenantPermissionPageByValue = new Map( + tenantPermissionPages.map((page) => [page.value, page.label]), + ); + const tenantPermissionAssignmentRows = tenantRelations.flatMap((user) => + user.relations.map((relation) => { + const level = relation.endsWith("_managers") ? "write" : "read"; + const target = relation.replace(/_(viewers|managers)$/, ""); + return { + scope: "tenant" as const, + user, + relation, + target, + level, + label: tenantPermissionPageByValue.get(target) ?? target, + tenantId: targetTenantId, + tenantName: + selectedTargetTenant?.name ?? + t("ui.admin.permissions_direct.scope_tenant", "테넌트"), + protected: false, + }; + }), + ); + const allPermissionAssignmentRows = + bulkRelationMode === "target-action" + ? tenantPermissionAssignmentRows + : permissionAssignmentRows; + const filteredPermissionAssignmentRows = allPermissionAssignmentRows + .filter((row) => { + const query = assignmentSearchTerm.trim().toLowerCase(); + if (!query) return true; + return ( + row.user.name.toLowerCase().includes(query) || + row.user.email.toLowerCase().includes(query) || + row.relation.toLowerCase().includes(query) || + row.label.toLowerCase().includes(query) + ); + }) + .sort((a, b) => { + if (assignmentSort === "relation") { + const relationCompare = a.label.localeCompare(b.label); + if (relationCompare !== 0) return relationCompare; + } + if (assignmentSort === "level") { + const levelCompare = a.level.localeCompare(b.level); + if (levelCompare !== 0) return levelCompare; + } + return a.user.name.localeCompare(b.user.name); + }); - const selectedUser = systemRelations.find((r) => r.userId === activeUserId); + const handleAssignmentLevelChange = async ( + scope: "system" | "tenant", + tenantId: string, + userId: string, + relation: string, + nextLevel: "none" | "read" | "write", + ) => { + const target = relation.replace(/_(viewers|managers)$/, ""); + if (scope === "system" && protectedSystemMenuRelations.has(target)) return; + + if (scope === "system") { + await removeSystemRelationMutation.mutateAsync({ userId, relation }); + } else { + await removeTenantRelationMutation.mutateAsync({ + tenantId, + userId, + relation, + }); + } + if (nextLevel === "read") { + if (scope === "system") { + await addSystemRelationMutation.mutateAsync({ + userId, + relation: `${target}_viewers`, + }); + } else { + await addTenantRelationMutation.mutateAsync({ + tenantId, + userId, + relation: `${target}_viewers`, + }); + } + } else if (nextLevel === "write") { + if (scope === "system") { + await addSystemRelationMutation.mutateAsync({ + userId, + relation: `${target}_managers`, + }); + } else { + await addTenantRelationMutation.mutateAsync({ + tenantId, + userId, + relation: `${target}_managers`, + }); + } + } + }; + + const handleAssignmentRemove = async ( + scope: "system" | "tenant", + tenantId: string, + userId: string, + relation: string, + ) => { + if (scope === "system") { + await removeSystemRelationMutation.mutateAsync({ userId, relation }); + } else { + await removeTenantRelationMutation.mutateAsync({ + tenantId, + userId, + relation, + }); + } + }; if (profile && !isSuperAdmin) { return ( @@ -477,6 +905,513 @@ export function TenantFineGrainedPermissionsPage() {

+
+ + {isSuperAdmin && ( + + )} +
+ + {activePermissionTab === "direct" && ( + <> +
+
+

+ {t( + "ui.admin.permissions_direct.bulk_title", + "다중 사용자 권한 부여 및 회수", + )} +

+

+ {t( + "msg.admin.permissions_direct.bulk_description", + "페이지별 접근 권한 또는 대상+액션 권한을 선택한 사용자들에게 동시에 적용합니다.", + )} +

+
+
+
+
+

+ {t("ui.admin.permissions_direct.bulk_users", "적용 대상")} +

+ + {queuedTargetUsers.length} + {t("ui.admin.permissions_direct.bulk_selected", "명 선택")} + +
+
+
+
+ {queuedTargetUsers.length === 0 ? ( +
+ {t( + "ui.admin.permissions_direct.target_queue_empty", + "적용할 사용자를 선택하세요.", + )} +
+ ) : ( +
+ {queuedTargetUsers.map((user) => ( + + + {user.name} + + {(user.metadata?.rootTenantName || + user.metadata?.leafTenantName) && ( + + {[user.metadata?.rootTenantName, user.metadata?.leafTenantName] + .filter(Boolean) + .join(" / ")} + + )} + + + ))} +
+ )} +
+
+ +
+