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() {
+
+ setActivePermissionTab("direct")}
+ >
+ {t("ui.admin.permissions_direct.tab_direct", "상세 권한")}
+
+ {isSuperAdmin && (
+ setActivePermissionTab("super-admin")}
+ >
+ {t(
+ "ui.admin.permissions_direct.tab_super_admin",
+ "Super Admin 역할",
+ )}
+
+ )}
+
+
+ {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(" / ")}
+
+ )}
+ removeQueuedTargetUser(user.id)}
+ aria-label={t(
+ "ui.admin.permissions_direct.target_queue_remove",
+ "적용 대상에서 제거",
+ )}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {t("ui.admin.permissions_direct.bulk_mode", "권한 방식")}
+
+ setBulkRelationMode(
+ event.target.value as "page" | "target-action",
+ )
+ }
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
+ >
+
+ {t(
+ "ui.admin.permissions_direct.bulk_mode_page",
+ "페이지 접근 권한",
+ )}
+
+
+ {t(
+ "ui.admin.permissions_direct.bulk_mode_target_action",
+ "대상+액션 권한",
+ )}
+
+
+
+
+ {bulkRelationMode === "page" ? (
+
+ {t(
+ "ui.admin.permissions_direct.bulk_page_relation",
+ "페이지 접근 권한",
+ )}
+
+ setBulkPageRelation(event.target.value)
+ }
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
+ >
+ {pageRelationOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ ) : (
+ <>
+
+ {t("ui.admin.permissions_direct.bulk_target", "대상")}
+
+
+ setTenantPickerOpen(true)}
+ >
+ {selectedTargetTenant?.name ??
+ t(
+ "ui.admin.permissions_direct.target_tenant_required_option",
+ "테넌트를 선택하세요",
+ )}
+
+
+
+
+ {t(
+ "ui.admin.permissions_direct.target_tenant_picker_title",
+ "대상 테넌트 선택",
+ )}
+
+
+ {t(
+ "msg.admin.permissions_direct.target_tenant_picker_desc",
+ "권한을 부여할 테넌트를 검색해 선택합니다.",
+ )}
+
+
+
+
+
+
+ setTenantPickerSearch(event.target.value)
+ }
+ className="h-9 pl-8"
+ data-testid="permission-action-tenant-search"
+ placeholder={t(
+ "ui.common.search",
+ "이름 또는 이메일 검색...",
+ )}
+ />
+
+
+ {tenantPickerCandidates.length === 0 ? (
+
+ {t("ui.common.no_results", "검색 결과가 없습니다.")}
+
+ ) : (
+ tenantPickerCandidates.map((tenant) => (
+
{
+ setTargetTenantId(tenant.id);
+ setTenantPickerOpen(false);
+ setTenantPickerSearch("");
+ }}
+ >
+
+ {tenant.name}
+
+
+ {tenant.slug}
+
+
+ ))
+ )}
+
+
+
+
+
+
+ {t(
+ "ui.admin.permissions_direct.bulk_tenant_page",
+ "페이지",
+ )}
+
+ setBulkTenantPage(event.target.value)
+ }
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
+ >
+ {tenantPermissionPages.map((page) => (
+
+ {page.label}
+
+ ))}
+
+
+
+ {t("ui.admin.permissions_direct.bulk_action", "액션")}
+
+ setBulkAction(event.target.value as "read" | "manage")
+ }
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
+ >
+
+ {t("ui.common.read", "조회")}
+
+
+ {t("ui.common.write", "수정")}
+
+
+
+ >
+ )}
+
+
+
+ {t(
+ "ui.admin.permissions_direct.bulk_submit_grant",
+ "선택 사용자에게 권한 부여",
+ )}
+
+
+
+
+
+
+
+
+
+
+ {t(
+ "ui.admin.permissions_direct.assignment_table_title",
+ "부여된 권한",
+ )}
+
+
+ {t(
+ "msg.admin.permissions_direct.assignment_table_desc",
+ "사용자별로 부여된 권한을 검색, 정렬, 수정, 회수합니다.",
+ )}
+
+
+
+
+
+
+ setAssignmentSearchTerm(event.target.value)
+ }
+ className="h-9 pl-8"
+ placeholder={t(
+ "ui.admin.permissions_direct.assignment_search",
+ "사용자, 이메일, 권한 검색",
+ )}
+ />
+
+
+ setAssignmentSort(
+ event.target.value as "user" | "relation" | "level",
+ )
+ }
+ className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
+ >
+
+ {t("ui.admin.permissions_direct.sort_user", "사용자순")}
+
+
+ {t("ui.admin.permissions_direct.sort_relation", "권한순")}
+
+
+ {t("ui.admin.permissions_direct.sort_level", "수준순")}
+
+
+
+
+
+
+
+
+
+
+ {t("ui.admin.permissions_direct.table_user", "사용자")}
+
+
+ {t("ui.admin.permissions_direct.table_target", "대상")}
+
+
+ {t("ui.admin.permissions_direct.table_relation", "Relation")}
+
+
+ {t("ui.admin.permissions_direct.table_level", "권한")}
+
+
+ {t("ui.common.action", "작업")}
+
+
+
+
+ {filteredPermissionAssignmentRows.length === 0 ? (
+
+
+ {t(
+ "msg.admin.permissions_direct.assignment_empty",
+ "부여된 권한이 없습니다.",
+ )}
+
+
+ ) : (
+ filteredPermissionAssignmentRows.map((row) => (
+
+
+
+
+ {row.user.name}
+
+
+ {row.user.email}
+
+
+
+
+
+
+ {row.label}
+ {row.protected && (
+
+ {t(
+ "ui.admin.permissions_direct.super_admin_only",
+ "Super Admin 전용",
+ )}
+
+ )}
+
+
+ {row.tenantName}
+
+
+
+
+ {row.relation}
+
+
+
+ handleAssignmentLevelChange(
+ row.scope,
+ row.tenantId,
+ row.user.userId,
+ row.relation,
+ event.target.value as "none" | "read" | "write",
+ )
+ }
+ className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm disabled:cursor-not-allowed disabled:opacity-50"
+ >
+
+ {t("ui.common.read", "조회")}
+
+
+ {t("ui.common.write", "수정")}
+
+
+ {t("ui.common.none", "권한 없음")}
+
+
+
+
+
+ handleAssignmentRemove(
+ row.scope,
+ row.tenantId,
+ row.user.userId,
+ row.relation,
+ )
+ }
+ >
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+ {false && (
+ <>
{/* 시스템 메뉴 권한 (Admin Control) Split Screen Panel */}
{/* Left Panel: User List */}
@@ -487,14 +1422,6 @@ export function TenantFineGrainedPermissionsPage() {
{t("ui.admin.permissions_direct.user_list", "대상 사용자")} (
{filteredRelations.length})
-
setIsDialogOpen(true)}
- >
-
-
@@ -644,8 +1571,9 @@ export function TenantFineGrainedPermissionsPage() {
{menu.label}
- {(menu.relation === "ory_ssot" ||
- menu.relation === "data_integrity") && (
+ {protectedSystemMenuRelations.has(
+ menu.relation,
+ ) && (
{
const nextVal = e.target.value as
| "none"
@@ -733,128 +1660,88 @@ export function TenantFineGrainedPermissionsPage() {
)}
+ >
+ )}
+ >
+ )}
- {/* User Search Dialog for System relations */}
- {
- if (!open) {
- setIsDialogOpen(false);
- setSearchTerm("");
- }
- }}
- >
-
-
-
+ {activePermissionTab === "super-admin" && isSuperAdmin && (
+
+
+
{t(
- "ui.admin.permissions_direct.dialog_title_system",
- "시스템 권한 관리 유저 추가",
+ "ui.admin.permissions_direct.super_admin_title",
+ "Super Admin 역할 부여",
)}
-
-
+
+
{t(
- "ui.admin.tenants.admins.dialog_description",
- "이름 또는 이메일로 사용자를 검색하세요.",
+ "msg.admin.permissions_direct.super_admin_description",
+ "전역 시스템 관리자 역할은 상세 relation과 분리해서 부여합니다.",
)}
-
-
+
+
-
-
-
-
setSearchTerm(e.target.value)}
- />
+
+
+
+ {t("ui.admin.permissions_direct.super_admin_users", "대상 사용자")}
+
+
+ {selectedSuperAdminUserIds.length}
+ {t("ui.admin.permissions_direct.bulk_selected", "명 선택")}
+
-
-
- {searchTerm.length < 2 ? (
-
-
-
- {t(
- "ui.admin.tenants.admins.dialog_search_hint",
- "검색어를 입력해 주세요.",
- )}
-
-
- ) : usersQuery.isLoading ? (
-
- ) : searchResults.length === 0 ? (
-
+
+ {systemRelations.length === 0 ? (
+
{t(
- "ui.admin.tenants.admins.dialog_no_results",
- "검색 결과가 없습니다.",
+ "msg.admin.permissions_direct.no_users_found",
+ "등록된 사용자가 없습니다.",
)}
) : (
-
- {searchResults.map((user) => {
- const isAlreadyInMatrix = systemRelations.some(
- (r) => r.userId === user.id,
- );
-
- return (
-
-
-
- {user.name.charAt(0)}
-
-
-
- {user.name}
-
-
- {user.email}
-
-
-
-
handleAddSystemUser(user.id)}
- >
- {isAlreadyInMatrix ? (
-
- {t(
- "ui.admin.tenants.relations.already_added",
- "이미 추가됨",
- )}
-
- ) : (
- <>
- {" "}
- {t("ui.common.add", "추가")}
- >
- )}
-
-
- );
- })}
-
+ systemRelations.map((user) => (
+
+
+ toggleSuperAdminUser(user.userId, event.target.checked)
+ }
+ />
+
+
+ {user.name}
+
+
+ {user.email}
+
+
+
+ ))
)}
-
-
+
+
+
+ {t(
+ "ui.admin.permissions_direct.super_admin_grant",
+ "Super Admin 부여",
+ )}
+
+
+
+ )}
+
);
}
diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx
index b85c64f4..8f7819e0 100644
--- a/adminfront/src/features/tenants/routes/TenantListPage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx
@@ -104,6 +104,10 @@ import {
type TenantImportPreviewRow,
type TenantImportResolution,
} from "../utils/tenantCsvImport";
+import {
+ TENANT_VISIBILITY_OPTIONS,
+ type TenantVisibility,
+} from "../utils/orgConfig";
import {
filterTenantsByScope,
filterTenantViewRowsBySearch,
@@ -119,14 +123,30 @@ const tenantCSVTemplate =
const tenantPageSize = 500;
const _tenantVirtualizationThreshold = 250;
const _tenantEstimatedRowHeight = 73;
+type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
+
const tenantTableHeadClassName =
"h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap";
const tenantTableHeadInteractiveClassName = `${tenantTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`;
const tenantTableHeadContentClassName = "flex h-full items-center gap-1";
const _tenantLoadAheadPx = 360;
const _tenantLoadAheadRows = 30;
-
-type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
+const backendTenantSortKeys = new Set
([
+ "createdAt",
+ "id",
+ "name",
+ "slug",
+ "status",
+ "type",
+ "updatedAt",
+]);
+const bulkTenantTypeOptions = [
+ { value: "COMPANY", label: "COMPANY (일반 기업)" },
+ { value: "COMPANY_GROUP", label: "COMPANY_GROUP (그룹사/지주사)" },
+ { value: "ORGANIZATION", label: "ORGANIZATION (정규 조직)" },
+ { value: "USER_GROUP", label: "USER_GROUP (내부 부서/팀)" },
+ { value: "PERSONAL", label: "PERSONAL (개인 워크스페이스)" },
+] as const;
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
@@ -370,6 +390,10 @@ function TenantListPage() {
const [search, setSearch] = React.useState("");
const debouncedSearch = React.useDeferredValue(search.trim());
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState("");
+ const [selectedBulkType, setSelectedBulkType] = React.useState("");
+ const [selectedBulkVisibility, setSelectedBulkVisibility] = React.useState<
+ TenantVisibility | ""
+ >("");
const _tenantTableScrollRef = React.useRef(null);
const { data: profile } = useQuery({
@@ -380,9 +404,20 @@ function TenantListPage() {
const isWritable =
profileRole === "super_admin" ||
!!profile?.systemPermissions?.manage_tenants;
+ const backendSortKey =
+ sortConfig && backendTenantSortKeys.has(sortConfig.key)
+ ? sortConfig.key
+ : undefined;
const query = useInfiniteQuery({
- queryKey: ["tenants", "lazy", debouncedSearch, scopeTenantId],
+ queryKey: [
+ "tenants",
+ "lazy",
+ debouncedSearch,
+ scopeTenantId,
+ backendSortKey,
+ sortConfig?.direction,
+ ],
queryFn: ({ pageParam }) =>
fetchTenants(
tenantPageSize,
@@ -390,12 +425,19 @@ function TenantListPage() {
scopeTenantId || undefined,
pageParam ? (pageParam as string) : undefined,
debouncedSearch,
+ backendSortKey,
+ sortConfig?.direction,
),
initialPageParam: "",
getNextPageParam: (lastPage) =>
lastPage.nextCursor || lastPage.next_cursor || undefined,
});
+ const rawTenants = React.useMemo(
+ () => query.data?.pages.flatMap((page) => page.items) ?? [],
+ [query.data?.pages],
+ );
+
const deleteBulkMutation = useMutation({
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
onSuccess: () => {
@@ -404,21 +446,37 @@ function TenantListPage() {
},
});
- const bulkUpdateStatusMutation = useMutation({
+ const bulkUpdateTenantsMutation = useMutation({
mutationFn: async ({
tenantIds,
status,
+ type,
+ visibility,
}: {
tenantIds: string[];
- status: string;
+ status?: string;
+ type?: string;
+ visibility?: TenantVisibility;
}) => {
- // Execute sequential updates to avoid rate limits or partial failures
- await Promise.all(tenantIds.map((id) => updateTenant(id, { status })));
+ await Promise.all(
+ tenantIds.map((id) => {
+ const source = rawTenants.find((tenant) => tenant.id === id);
+ return updateTenant(id, {
+ ...(status ? { status } : {}),
+ ...(type ? { type } : {}),
+ ...(visibility
+ ? { config: { ...(source?.config ?? {}), visibility } }
+ : {}),
+ });
+ }),
+ );
},
onSuccess: () => {
query.refetch();
setSelectedIds([]);
setSelectedBulkStatus("");
+ setSelectedBulkType("");
+ setSelectedBulkVisibility("");
toast.success(
t(
"msg.admin.tenants.bulk.update_success",
@@ -437,10 +495,19 @@ function TenantListPage() {
});
const handleApplyBulkStatus = () => {
- if (selectedIds.length === 0 || !selectedBulkStatus) return;
- bulkUpdateStatusMutation.mutate({
+ if (
+ selectedIds.length === 0 ||
+ (!selectedBulkStatus && !selectedBulkType && !selectedBulkVisibility)
+ ) {
+ return;
+ }
+ bulkUpdateTenantsMutation.mutate({
tenantIds: selectedIds,
- status: selectedBulkStatus,
+ ...(selectedBulkStatus ? { status: selectedBulkStatus } : {}),
+ ...(selectedBulkType ? { type: selectedBulkType } : {}),
+ ...(selectedBulkVisibility
+ ? { visibility: selectedBulkVisibility }
+ : {}),
});
};
@@ -491,11 +558,6 @@ function TenantListPage() {
},
});
- const rawTenants = React.useMemo(
- () => query.data?.pages.flatMap((page) => page.items) ?? [],
- [query.data?.pages],
- );
-
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
@@ -1067,15 +1129,66 @@ function TenantListPage() {
+
+
+
+
+
+ {bulkTenantTypeOptions.map((option) => (
+
+ {t(
+ `domain.tenant_type.${option.value.toLowerCase()}`,
+ option.label,
+ )}
+
+ ))}
+
+
+
+ setSelectedBulkVisibility(value as TenantVisibility)
+ }
+ >
+
+
+
+
+ {TENANT_VISIBILITY_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
{t("ui.common.apply", "적용")}
diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.performance.test.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.performance.test.tsx
new file mode 100644
index 00000000..1260639a
--- /dev/null
+++ b/adminfront/src/features/tenants/routes/TenantProfilePage.performance.test.tsx
@@ -0,0 +1,81 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from "@testing-library/react";
+import type React from "react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createI18nMock } from "../../../test/i18nMock";
+import { TenantProfilePage } from "./TenantProfilePage";
+
+const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
+const fetchTenantMock = vi.hoisted(() => vi.fn());
+
+vi.mock("../../../lib/i18n", () => createI18nMock());
+
+vi.mock("../../../lib/adminApi", () => ({
+ approveTenant: vi.fn(),
+ deleteTenant: vi.fn(),
+ fetchAllTenants: fetchAllTenantsMock,
+ fetchMe: vi.fn(async () => ({
+ id: "admin-1",
+ role: "super_admin",
+ })),
+ fetchTenant: fetchTenantMock,
+ updateTenant: vi.fn(),
+}));
+
+function renderWithProviders(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ return render(
+
+
+ {ui}
+
+ ,
+ );
+}
+
+describe("TenantProfilePage initial profile loading", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ fetchTenantMock.mockResolvedValue({
+ id: "tenant-leaf",
+ type: "USER_GROUP",
+ parentId: "tenant-company",
+ name: "기술기획",
+ slug: "tech-planning",
+ description: "",
+ status: "active",
+ domains: [],
+ memberCount: 0,
+ config: {
+ orgUnitType: "팀",
+ visibility: "internal",
+ worksmobileExcluded: true,
+ },
+ createdAt: "2026-06-17T00:00:00Z",
+ updatedAt: "2026-06-17T00:00:00Z",
+ });
+ });
+
+ it("renders tenant config fields from the tenant response before the full tenant list resolves", async () => {
+ fetchAllTenantsMock.mockReturnValue(new Promise(() => undefined));
+
+ renderWithProviders(
+
+ } />
+ ,
+ );
+
+ expect(await screen.findByDisplayValue("기술기획")).toBeInTheDocument();
+ expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀");
+ expect(screen.getByLabelText("공개 범위")).toHaveValue("internal");
+ expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded");
+ expect(fetchAllTenantsMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx
index 3bb5c705..fb111098 100644
--- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx
@@ -57,11 +57,6 @@ export function TenantProfilePage() {
const isWritable = hasPermission("manage_profile") || hasPermission("manage");
const canView = hasPermission("view_profile") || hasPermission("view");
- const parentQuery = useQuery({
- queryKey: ["tenants", "list-all"],
- queryFn: () => fetchAllTenants(),
- });
-
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
@@ -94,6 +89,16 @@ export function TenantProfilePage() {
}
}, [tenantQuery.data]);
+ const hasPersistedOrgConfig =
+ tenantQuery.data?.slug?.toLowerCase() !== "hanmac-family" &&
+ (typeof tenantQuery.data?.config?.orgUnitType === "string" ||
+ typeof tenantQuery.data?.config?.visibility === "string" ||
+ typeof tenantQuery.data?.config?.worksmobileExcluded !== "undefined");
+ const parentQuery = useQuery({
+ queryKey: ["tenants", "list-all"],
+ queryFn: () => fetchAllTenants(),
+ enabled: !!tenantQuery.data && !hasPersistedOrgConfig,
+ });
const allTenants = parentQuery.data?.items ?? [];
const orgConfigCandidate = tenantQuery.data
? {
@@ -103,7 +108,8 @@ export function TenantProfilePage() {
}
: undefined;
const canEditOrgConfig = orgConfigCandidate
- ? shouldAllowHanmacOrgConfig(orgConfigCandidate, [
+ ? hasPersistedOrgConfig ||
+ shouldAllowHanmacOrgConfig(orgConfigCandidate, [
...allTenants,
orgConfigCandidate,
])
@@ -361,7 +367,10 @@ export function TenantProfilePage() {
data-testid="tenant-org-unit-type-slot"
className="space-y-1"
>
-
+
{t(
"ui.admin.tenants.profile.org_unit_type",
"조직 세부타입",
@@ -385,7 +394,10 @@ export function TenantProfilePage() {
-
+
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
-
+
{t(
"ui.admin.tenants.profile.worksmobile_sync",
"WORKS 연동",
diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts
index 80bf9377..46c939b9 100644
--- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts
+++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts
@@ -624,6 +624,52 @@ describe("TenantWorksmobilePage comparison helpers", () => {
).toEqual(["조직: Baron 소속 정보를 WORKS에 반영해야 합니다."]);
});
+ it("formats grade update reasons with before and after values", () => {
+ expect(
+ formatWorksmobileUpdateDetails({
+ resourceType: "USER",
+ status: "needs_update",
+ baronId: "user-1",
+ externalKey: "user-1",
+ baronName: "신현우",
+ worksmobileName: "신현우",
+ baronGrade: "책임",
+ worksmobileLevelName: "선임",
+ updateReasons: ["grade"],
+ }),
+ ).toEqual(["직급: 선임 -> 책임"]);
+ });
+
+ it("formats grade update reasons with matched WORKS membership", () => {
+ expect(
+ formatWorksmobileUpdateDetails({
+ resourceType: "USER",
+ status: "needs_update",
+ baronId: "user-1",
+ externalKey: "user-1",
+ baronName: "연구원",
+ worksmobileName: "연구원",
+ baronGrade: "책임연구원",
+ worksmobileLevelName: "",
+ updateReasons: ["grade"],
+ userMemberships: [
+ {
+ baronOrgId: "1d74bebb-c5a1-49d4-bec4-90f0c89ad21f",
+ baronOrgSlug: "hmeg",
+ baronOrgName: "HmEG",
+ baronGrade: "책임연구원",
+ worksmobileOrgId: "works-hmeg",
+ worksmobileOrgName: "WORKS HmEG",
+ worksmobileDomainName: "baroncs.co.kr",
+ gradeNeedsUpdate: true,
+ },
+ ],
+ }),
+ ).toEqual([
+ "직급: 없음 -> 책임연구원 (Baron HmEG / WORKS WORKS HmEG)",
+ ]);
+ });
+
it("does not format phone update details for spaced Korean country code formatting only", () => {
expect(
formatWorksmobileUpdateDetails({
diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
index adac36b0..36d16ab1 100644
--- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx
@@ -68,6 +68,7 @@ import {
formatWorksmobilePersonName,
formatWorksmobileSelectionFailureDescription,
formatWorksmobileUpdateDetails,
+ formatWorksmobileUserMembershipDetails,
getDefaultGroupComparisonFilters,
getDefaultUserComparisonFilters,
getDefaultWorksmobileComparisonColumns,
@@ -813,7 +814,7 @@ const worksmobileComparisonColumnOptions: Array<{
{ key: "externalKey", label: "external_key" },
{ key: "worksmobileDomain", label: "WORKS 도메인" },
{ key: "worksmobile", label: "WORKS" },
- { key: "worksmobileOrg", label: "상위 Works 조직" },
+ { key: "worksmobileOrg", label: "WORKS 조직 매칭" },
{ key: "manage", label: "관리" },
];
@@ -832,7 +833,7 @@ const worksmobileComparisonColumnWidths: Record<
worksmobileDomain: 160,
worksmobileId: 176,
worksmobile: 220,
- worksmobileOrg: 260,
+ worksmobileOrg: 320,
manage: 112,
};
const worksmobileComparisonTableHeadClassName =
@@ -1539,7 +1540,7 @@ function ComparisonTable({
- 상위 Works 조직
+ WORKS 조직 매칭
)}
@@ -1724,33 +1725,17 @@ function ComparisonTable({
)}
{isColumnVisible("worksmobileOrg") && (
-
+ {row.resourceType === "USER" ? (
+
+ ) : (
+
+ )}
)}
{showManageColumn && isColumnVisible("manage") && (
@@ -1893,6 +1878,33 @@ function formatWorksmobileParentOrgDetails(row: WorksmobileComparisonItem) {
return details;
}
+function ComparisonUserMembershipCell({
+ row,
+}: {
+ row: WorksmobileComparisonItem;
+}) {
+ const membershipDetails = formatWorksmobileUserMembershipDetails(row);
+ if (membershipDetails.length > 0) {
+ return (
+
+ {membershipDetails.map((detail) => (
+
+ {detail}
+
+ ))}
+
+ );
+ }
+
+ return (
+
+ );
+}
+
function ComparisonOrgCell({
name,
email,
diff --git a/adminfront/src/features/tenants/routes/worksmobileComparison.ts b/adminfront/src/features/tenants/routes/worksmobileComparison.ts
index 7e930494..d0c15d0b 100644
--- a/adminfront/src/features/tenants/routes/worksmobileComparison.ts
+++ b/adminfront/src/features/tenants/routes/worksmobileComparison.ts
@@ -365,6 +365,32 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
return details;
}
+export function formatWorksmobileUserMembershipDetails(
+ row: WorksmobileComparisonItem,
+) {
+ return (row.userMemberships ?? []).map((membership) => {
+ const baronOrg =
+ membership.baronOrgName?.trim() ||
+ membership.baronOrgSlug?.trim() ||
+ membership.baronOrgId?.trim() ||
+ "Baron 조직";
+ const worksOrg =
+ membership.worksmobileOrgName?.trim() ||
+ membership.worksmobileOrgId?.trim() ||
+ "WORKS 조직 없음";
+ const details = [
+ membership.baronPrimary ? "기본" : "겸직",
+ `Baron ${baronOrg}`,
+ `WORKS ${worksOrg}`,
+ membership.worksmobileLevelName?.trim() ||
+ membership.worksmobileLevelId?.trim()
+ ? `직급 ${membership.worksmobileLevelName?.trim() || membership.worksmobileLevelId?.trim()}`
+ : "",
+ ].filter(Boolean);
+ return details.join(" / ");
+ });
+}
+
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) {
return [`최근 실패: ${row.worksmobileLastError}`];
@@ -420,6 +446,20 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
`사번: ${actualEmployeeNumber} -> ${expectedEmployeeNumber}`,
);
}
+ const expectedGrade = row.baronGrade?.trim() ?? "";
+ const actualGrade =
+ row.worksmobileLevelName?.trim() ?? row.worksmobileLevelId?.trim() ?? "";
+ if (
+ row.updateReasons?.includes("grade") &&
+ (expectedGrade || actualGrade) &&
+ expectedGrade !== actualGrade
+ ) {
+ const membershipContext = formatWorksmobileGradeMembershipContext(row);
+ addDetail(
+ "grade",
+ `직급: ${actualGrade || "없음"} -> ${expectedGrade || "없음"}${membershipContext}`,
+ );
+ }
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
return details;
}
@@ -484,6 +524,17 @@ function formatWorksmobileUpdateReasonFallback(
return "전화번호: Baron 전화번호를 WORKS에 반영해야 합니다.";
case "employee_number":
return "사번: Baron 사번을 WORKS에 반영해야 합니다.";
+ case "grade": {
+ const expectedGrade = row.baronGrade?.trim() ?? "";
+ const actualGrade =
+ row.worksmobileLevelName?.trim() ??
+ row.worksmobileLevelId?.trim() ??
+ "";
+ if (expectedGrade || actualGrade) {
+ return `직급: ${actualGrade || "없음"} -> ${expectedGrade || "없음"}${formatWorksmobileGradeMembershipContext(row)}`;
+ }
+ return "직급: Baron 직급 정보를 WORKS에 반영해야 합니다.";
+ }
case "organization":
return row.resourceType === "GROUP"
? "조직: Baron 조직 정보를 WORKS에 반영해야 합니다."
@@ -495,6 +546,32 @@ function formatWorksmobileUpdateReasonFallback(
}
}
+function formatWorksmobileGradeMembershipContext(
+ row: WorksmobileComparisonItem,
+) {
+ const membership =
+ row.userMemberships?.find((item) => item.gradeNeedsUpdate) ??
+ row.userMemberships?.find(
+ (item) =>
+ item.baronGrade?.trim() &&
+ item.baronGrade?.trim() === row.baronGrade?.trim(),
+ );
+ if (!membership) {
+ return "";
+ }
+ const baronOrg =
+ membership.baronOrgName?.trim() ||
+ membership.baronOrgSlug?.trim() ||
+ membership.baronOrgId?.trim();
+ const worksOrg =
+ membership.worksmobileOrgName?.trim() ||
+ membership.worksmobileOrgId?.trim();
+ if (!baronOrg && !worksOrg) {
+ return "";
+ }
+ return ` (Baron ${baronOrg || "없음"} / WORKS ${worksOrg || "없음"})`;
+}
+
function normalizeWorksmobilePhoneForCompare(value: string) {
const trimmed = value.trim();
if (!trimmed) {
diff --git a/adminfront/src/features/users/UserDetailPage.employeeNumber.test.tsx b/adminfront/src/features/users/UserDetailPage.employeeNumber.test.tsx
index 4c12446d..feb39cc9 100644
--- a/adminfront/src/features/users/UserDetailPage.employeeNumber.test.tsx
+++ b/adminfront/src/features/users/UserDetailPage.employeeNumber.test.tsx
@@ -320,6 +320,36 @@ describe("UserDetailPage Worksmobile employee number", () => {
);
});
+ it("does not reveal the manually entered password after a successful reset", async () => {
+ renderUserDetailPage();
+
+ fireEvent.click(await screen.findByRole("tab", { name: "보안 & 활동" }));
+ fireEvent.click(screen.getByRole("button", { name: "초기화 도구" }));
+ fireEvent.click(screen.getByRole("tab", { name: "직접 입력" }));
+
+ const passwordInputs = document.querySelectorAll('input[type="password"]');
+ expect(passwordInputs).toHaveLength(2);
+
+ fireEvent.change(passwordInputs[0], {
+ target: { value: "ManualPass123!" },
+ });
+ fireEvent.change(passwordInputs[1], {
+ target: { value: "ManualPass123!" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "재설정 완료" }));
+
+ await waitFor(() =>
+ expect(updateUserMock).toHaveBeenCalledWith("user-1", {
+ password: "ManualPass123!",
+ }),
+ );
+
+ expect(screen.queryByText("ManualPass123!")).not.toBeInTheDocument();
+ expect(
+ document.querySelectorAll('input[value="ManualPass123!"]'),
+ ).toHaveLength(0);
+ });
+
it("preserves per-user global custom claim permissions instead of overwriting them from definitions", async () => {
fetchUserMock.mockResolvedValueOnce({
id: "user-1",
@@ -394,4 +424,97 @@ describe("UserDetailPage Worksmobile employee number", () => {
}),
);
});
+
+ it("defaults a Hanmac family member to the Hanmac family tenant tab and does not show the external company tab", async () => {
+ fetchAllTenantsMock.mockResolvedValue({
+ items: [
+ {
+ id: "hanmac-root-id",
+ type: "COMPANY_GROUP",
+ name: "한맥가족",
+ slug: "hanmac-family",
+ description: "",
+ status: "active",
+ memberCount: 1,
+ createdAt: "2026-06-01T00:00:00Z",
+ updatedAt: "2026-06-01T00:00:00Z",
+ },
+ {
+ id: "hanmac-team-id",
+ type: "USER_GROUP",
+ name: "한맥팀",
+ slug: "hanmac-team",
+ parentId: "hanmac-root-id",
+ description: "",
+ status: "active",
+ memberCount: 1,
+ createdAt: "2026-06-01T00:00:00Z",
+ updatedAt: "2026-06-01T00:00:00Z",
+ },
+ {
+ id: "commercial-root-id",
+ type: "COMPANY_GROUP",
+ name: "Commercial",
+ slug: "commercial",
+ description: "",
+ status: "active",
+ memberCount: 0,
+ createdAt: "2026-06-01T00:00:00Z",
+ updatedAt: "2026-06-01T00:00:00Z",
+ },
+ ],
+ total: 3,
+ });
+ fetchUserMock.mockResolvedValue({
+ id: "user-1",
+ email: "user@example.com",
+ name: "사용자",
+ phone: "01012345678",
+ role: "user",
+ status: "active",
+ tenantSlug: "hanmac-team",
+ tenant: {
+ id: "hanmac-team-id",
+ type: "USER_GROUP",
+ name: "한맥팀",
+ slug: "hanmac-team",
+ parentId: "hanmac-root-id",
+ description: "",
+ status: "active",
+ memberCount: 1,
+ createdAt: "2026-06-01T00:00:00Z",
+ updatedAt: "2026-06-01T00:00:00Z",
+ },
+ joinedTenants: [],
+ metadata: {},
+ createdAt: "2026-06-01T00:00:00Z",
+ updatedAt: "2026-06-01T00:00:00Z",
+ });
+
+ renderUserDetailPage();
+
+ await screen.findByRole("tab", { name: "한맥가족" });
+
+ const tenantTabs = screen
+ .getAllByRole("tab")
+ .filter((tab) =>
+ ["한맥가족", "일반회사", "공공기관", "교육기관", "개인"].includes(
+ tab.textContent?.trim() ?? "",
+ ),
+ );
+ expect(tenantTabs.map((tab) => tab.textContent?.trim())).toEqual([
+ "한맥가족",
+ "일반회사",
+ "공공기관",
+ "교육기관",
+ "개인",
+ ]);
+ expect(screen.getByRole("tab", { name: "한맥가족" })).toHaveAttribute(
+ "aria-selected",
+ "true",
+ );
+ expect(
+ screen.queryByRole("tab", { name: /외부 기업 회원/i }),
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx
index a46a329c..2465dcca 100644
--- a/adminfront/src/features/users/UserDetailPage.tsx
+++ b/adminfront/src/features/users/UserDetailPage.tsx
@@ -86,12 +86,14 @@ import {
import { generateSecurePassword } from "../../lib/utils";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
- filterNonHanmacFamilyTenants,
+ filterTenantsByMembershipRoot,
getTenantGradeOptions,
isHanmacFamilyTenant,
- isHanmacFamilyUser,
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
+ resolveUserMembershipTenantTab,
+ USER_MEMBERSHIP_TENANT_TABS,
+ type UserMembershipTenantTabId,
} from "./orgChartPicker";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import type { UserSchemaField } from "./userSchemaFields";
@@ -109,7 +111,7 @@ type UserFormValues = Omit & {
sub_email?: string | string[];
};
};
-type UserCategory = "hanmac" | "external" | "personal";
+type UserCategory = UserMembershipTenantTabId;
type PasswordResetMode = "generated" | "manual";
type PickerTarget = { kind: "appointment"; index: number };
@@ -571,7 +573,7 @@ function UserDetailPage() {
string | null
>(null);
const [userCategory, setUserCategory] =
- React.useState("external");
+ React.useState("hanmac-family");
const [additionalAppointments, setAdditionalAppointments] = React.useState<
AppointmentDraft[]
>([]);
@@ -692,9 +694,18 @@ function UserDetailPage() {
};
const resetMutation = useMutation({
- mutationFn: (newPass: string) => updateUser(userId, { password: newPass }),
- onSuccess: (_, newPass) => {
- setGeneratedPassword(newPass);
+ mutationFn: ({ password }: { password: string; mode: PasswordResetMode }) =>
+ updateUser(userId, { password }),
+ onSuccess: (_, { password, mode }) => {
+ if (mode === "manual") {
+ setGeneratedPassword(null);
+ setManualPassword("");
+ setManualPasswordConfirm("");
+ setIsManualPasswordVisible(false);
+ setIsPasswordResetOpen(false);
+ } else {
+ setGeneratedPassword(password);
+ }
setPasswordResetError(null);
toast.success(
t(
@@ -753,7 +764,7 @@ function UserDetailPage() {
newPass = generateSecurePassword();
}
- resetMutation.mutate(newPass);
+ resetMutation.mutate({ password: newPass, mode: passwordResetMode });
};
const hanmacFamilyTenantId = React.useMemo(() => {
@@ -771,7 +782,8 @@ function UserDetailPage() {
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
{
- tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
+ tenantId:
+ userCategory === "hanmac-family" ? hanmacFamilyTenantId : undefined,
},
);
@@ -862,7 +874,7 @@ function UserDetailPage() {
const handleUserCategoryChange = (value: string) => {
const nextCategory = value as UserCategory;
setUserCategory(nextCategory);
- if (nextCategory !== "hanmac") {
+ if (nextCategory !== "hanmac-family") {
setAdditionalAppointments([]);
}
};
@@ -930,21 +942,11 @@ function UserDetailPage() {
: [],
} as UserFormValues["metadata"],
});
- const isUserHanmacFamily = isHanmacFamilyUser(
+ const resolvedUserCategory = resolveUserMembershipTenantTab(
user,
tenants,
- hanmacFamilyTenantId,
- );
- const isPersonalUser =
- user.tenantSlug === personalTenant.slug ||
- user.tenant?.id === personalTenant.id ||
- user.tenant?.slug === personalTenant.slug ||
- metadata.personalTenantId === personalTenant.id;
- const resolvedUserCategory = isPersonalUser
- ? "personal"
- : isUserHanmacFamily
- ? "hanmac"
- : "external";
+ ).id;
+ const isUserHanmacFamily = resolvedUserCategory === "hanmac-family";
setUserCategory(resolvedUserCategory);
setGlobalCustomClaimRows(
createGlobalCustomClaimRows(metadata, globalCustomClaimDefinitions),
@@ -1009,7 +1011,6 @@ function UserDetailPage() {
}, [
globalCustomClaimDefinitions,
hanmacFamilyTenantId,
- personalTenant,
tenants,
user,
reset,
@@ -1105,7 +1106,7 @@ function UserDetailPage() {
}
}
- if (userCategory === "hanmac") {
+ if (userCategory === "hanmac-family") {
const appointments = additionalAppointments
.filter((appointment) => appointment.tenantId)
.map((appointment) => ({
@@ -1217,9 +1218,13 @@ function UserDetailPage() {
}, [tenants, user?.joinedTenants, user?.metadata, user?.tenant]);
const selectableRepresentativeTenants = React.useMemo(
() =>
- filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
- [userAffiliatedTenants, hanmacFamilyTenantId],
+ userCategory === "hanmac-family" || userCategory === "personal"
+ ? []
+ : filterTenantsByMembershipRoot(tenants, userCategory),
+ [tenants, userCategory],
);
+ const isRepresentativeTenantCategory =
+ userCategory !== "hanmac-family" && userCategory !== "personal";
if (isLoading) {
return (
@@ -1606,28 +1611,19 @@ function UserDetailPage() {
className="space-y-4 pt-6 border-t border-dashed"
>
-
- 한맥가족 구성원
-
-
- 외부 기업 회원
-
-
- 개인 회원
-
+ {USER_MEMBERSHIP_TENANT_TABS.map((tab) => (
+
+ {tab.label}
+
+ ))}
- {userCategory === "external" && (
+ {isRepresentativeTenantCategory && (
)}
- {userCategory === "hanmac" && (
+ {userCategory === "hanmac-family" && (
@@ -1893,7 +1889,7 @@ function UserDetailPage() {
)}
- {userCategory === "external" && (
+ {isRepresentativeTenantCategory && (
;
@@ -135,23 +135,6 @@ const userSortableTableHeadContentClassName = "h-full items-center";
const userTableStateCellClassName =
"flex h-24 items-center justify-center p-0 text-center text-sm text-muted-foreground";
-const bulkPermissionOptions = [
- {
- value: "super_admin",
- labelKey: "ui.admin.role.super_admin",
- fallback: "시스템 관리자",
- },
- {
- value: "user",
- labelKey: "ui.admin.role.user",
- fallback: "일반 사용자",
- },
-] as const;
-
-function assignableSystemRoleValue(role?: string | null) {
- return isSuperAdminRole(role) ? "super_admin" : "user";
-}
-
type RepresentativeTenantCandidate = {
id?: string;
slug?: string;
@@ -370,8 +353,6 @@ function UserListPage() {
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState<
UserStatusValue | ""
>("");
- const [selectedBulkPermission, setSelectedBulkPermission] =
- React.useState("");
const [sortConfig, setSortConfig] =
React.useState | null>(null);
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
@@ -604,7 +585,7 @@ function UserListPage() {
]);
const shouldVirtualizeRows = !query.isLoading && items.length > 0;
- const tableColumnCount = 9 + visibleUserSchemaFields.length;
+ const tableColumnCount = 8 + visibleUserSchemaFields.length;
const requestSort = (key: UserSortKey) => {
setSortConfig((current) => toggleSort(current, key));
@@ -622,8 +603,6 @@ function UserListPage() {
};
const total = query.data?.pages[0]?.total ?? 0;
- const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
-
const toggleSelectAll = () => {
if (selectedUserIds.length === items.length) {
setSelectedUserIds([]);
@@ -673,7 +652,6 @@ function UserListPage() {
query.refetch();
setSelectedUserIds([]);
setSelectedBulkStatus("");
- setSelectedBulkPermission("");
toast.success(
t(
"msg.admin.users.bulk.update_success",
@@ -691,14 +669,6 @@ function UserListPage() {
});
};
- const _handleApplyBulkPermission = () => {
- if (selectedUserIds.length === 0 || !selectedBulkPermission) return;
- bulkUpdateMutation.mutate({
- userIds: selectedUserIds,
- role: selectedBulkPermission,
- });
- };
-
const handleBulkDelete = () => {
if (selectedUserIds.length === 0) return;
if (
@@ -1005,15 +975,6 @@ function UserListPage() {
{getSortIcon("status")}
-
requestSort("role")}
- >
-
- {t("ui.admin.users.list.table.role", "ROLE")}
- {getSortIcon("role")}
-
-
requestSort("tenant_dept")}
@@ -1206,36 +1167,6 @@ function UserListPage() {
-
-
- bulkUpdateMutation.mutate({
- userIds: [user.id],
- role: value,
- })
- }
- disabled={
- bulkUpdateMutation.isPending ||
- !isSuperAdminRole(profile?.role) ||
- user.id === profile?.id
- }
- >
-
-
-
-
- {bulkPermissionOptions.map((option) => (
-
- {t(option.labelKey, option.fallback)}
-
- ))}
-
-
-
@@ -1302,31 +1233,6 @@ function UserListPage() {
))}
- {canPromoteSuperAdmin && (
-
-
-
-
-
- {bulkPermissionOptions.map((option) => (
-
- {t(option.labelKey, option.fallback)}
-
- ))}
-
-
- )}
diff --git a/adminfront/src/features/users/orgChartPicker.test.ts b/adminfront/src/features/users/orgChartPicker.test.ts
index 67462986..4f863516 100644
--- a/adminfront/src/features/users/orgChartPicker.test.ts
+++ b/adminfront/src/features/users/orgChartPicker.test.ts
@@ -4,11 +4,13 @@ import {
buildAuthenticatedOrgChartUrl,
buildAuthenticatedOrgChartUserMultiPickerUrl,
buildOrgChartTenantPickerUrl,
+ classifyTenantByMembershipRoot,
filterNonHanmacFamilyTenants,
getTenantGradeOptions,
isHanmacFamilyUser,
parseOrgChartTenantSelection,
parseOrgChartUserSelections,
+ USER_MEMBERSHIP_TENANT_TABS,
} from "./orgChartPicker";
describe("orgChartPicker", () => {
@@ -67,7 +69,18 @@ describe("orgChartPicker", () => {
"https://orgchart.example.com",
),
).toBe(
- "https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dmultiple%26select%3Duser%26width%3D720%26height%3D640%26includeInternal%3Dtrue%26includeDescendants%3Dtrue%26showDescendantToggle%3Dtrue",
+ "https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dmultiple%26select%3Duser%26width%3D720%26height%3D640%26includeInternal%3Dtrue%26includeDescendants%3Dtrue%26showDescendantToggle%3Dtrue%26rootTenantId%3Dall",
+ );
+ });
+
+ it("builds a scoped authenticated multi picker URL for recursive tenant user selection", () => {
+ expect(
+ buildAuthenticatedOrgChartUserMultiPickerUrl(
+ "https://orgchart.example.com",
+ { tenantId: "tenant-a" },
+ ),
+ ).toBe(
+ "https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dmultiple%26select%3Duser%26width%3D720%26height%3D640%26includeInternal%3Dtrue%26includeDescendants%3Dtrue%26showDescendantToggle%3Dtrue%26tenantId%3Dtenant-a",
);
});
@@ -135,8 +148,15 @@ describe("orgChartPicker", () => {
id: "user-1",
name: "홍길동",
email: "hong@example.com",
+ rootTenantName: "한맥가족",
+ leafTenantName: "기술기획",
+ },
+ {
+ type: "user",
+ id: "user-2",
+ name: "김영희",
+ tenantName: "디자인팀",
},
- { type: "user", id: "user-2", name: "김영희" },
{ type: "user", id: "", name: "잘못된 사용자" },
],
},
@@ -146,11 +166,15 @@ describe("orgChartPicker", () => {
id: "user-1",
name: "홍길동",
email: "hong@example.com",
+ rootTenantName: "한맥가족",
+ leafTenantName: "기술기획",
},
{
id: "user-2",
name: "김영희",
email: "",
+ rootTenantName: undefined,
+ leafTenantName: "디자인팀",
},
]);
});
@@ -366,11 +390,48 @@ describe("orgChartPicker", () => {
"차장",
"부장",
"이사",
- "상무",
- "전무",
+ "상무이사",
+ "전무이사",
"부사장",
"사장",
"회장",
]);
});
+
+ it("classifies tenants by the configured top-level tenant root UUIDs", () => {
+ const tenants = [
+ {
+ id: "hanmac-root-id",
+ slug: "hanmac-family",
+ name: "한맥가족",
+ type: "COMPANY_GROUP",
+ parentId: undefined,
+ },
+ {
+ id: "commercial-root-id",
+ slug: "commercial",
+ name: "Commercial",
+ type: "COMPANY_GROUP",
+ parentId: undefined,
+ },
+ {
+ id: "commercial-child-id",
+ slug: "external-company",
+ name: "외부기업",
+ type: "COMPANY",
+ parentId: "commercial-root-id",
+ },
+ ];
+
+ expect(USER_MEMBERSHIP_TENANT_TABS.map((tab) => tab.id)).toEqual([
+ "hanmac-family",
+ "commercial",
+ "public-org",
+ "edu",
+ "personal",
+ ]);
+ expect(classifyTenantByMembershipRoot(tenants[2], tenants)?.id).toBe(
+ "commercial",
+ );
+ });
});
diff --git a/adminfront/src/features/users/orgChartPicker.ts b/adminfront/src/features/users/orgChartPicker.ts
index d92e3482..ef75ae61 100644
--- a/adminfront/src/features/users/orgChartPicker.ts
+++ b/adminfront/src/features/users/orgChartPicker.ts
@@ -7,6 +7,8 @@ export type OrgChartUserSelection = {
id: string;
name: string;
email: string;
+ rootTenantName?: string;
+ leafTenantName?: string;
};
export type TenantFilterTarget = {
@@ -30,6 +32,20 @@ export type HanmacFamilyUserTarget = {
metadata?: Record;
};
+export type UserMembershipTenantTabId =
+ | "hanmac-family"
+ | "commercial"
+ | "public-org"
+ | "edu"
+ | "personal";
+
+export type UserMembershipTenantTab = {
+ id: UserMembershipTenantTabId;
+ label: string;
+ rootSlug: string;
+ seedTenantId?: string;
+};
+
type OrgChartPickerMessage = {
type?: unknown;
payload?: {
@@ -38,6 +54,9 @@ type OrgChartPickerMessage = {
id?: unknown;
name?: unknown;
email?: unknown;
+ rootTenantName?: unknown;
+ leafTenantName?: unknown;
+ tenantName?: unknown;
}>;
};
};
@@ -47,6 +66,10 @@ type OrgChartTenantPickerOptions = {
tenantId?: string;
};
+type OrgChartUserMultiPickerOptions = {
+ tenantId?: string;
+};
+
type OrgChartLoginOptions = {
includeInternal?: boolean;
returnTo?: string;
@@ -54,6 +77,36 @@ type OrgChartLoginOptions = {
const DEFAULT_ORGFRONT_BASE_URL = "http://localhost:5175";
+export const USER_MEMBERSHIP_TENANT_TABS: UserMembershipTenantTab[] = [
+ {
+ id: "hanmac-family",
+ label: "한맥가족",
+ rootSlug: "hanmac-family",
+ seedTenantId: "038326b6-954a-48a7-a85f-efd83f62b82a",
+ },
+ {
+ id: "commercial",
+ label: "일반회사",
+ rootSlug: "commercial",
+ },
+ {
+ id: "public-org",
+ label: "공공기관",
+ rootSlug: "public-org",
+ },
+ {
+ id: "edu",
+ label: "교육기관",
+ rootSlug: "edu",
+ },
+ {
+ id: "personal",
+ label: "개인",
+ rootSlug: "personal",
+ seedTenantId: "9607eb7b-04d2-42ab-80fe-780fe21c7e8f",
+ },
+];
+
export const GPDTDC_GRADE_OPTIONS = [
"연구원",
"선임",
@@ -70,8 +123,8 @@ export const HANMAC_FAMILY_GRADE_OPTIONS = [
"차장",
"부장",
"이사",
- "상무",
- "전무",
+ "상무이사",
+ "전무이사",
"부사장",
"사장",
"회장",
@@ -108,6 +161,118 @@ function resolveTenantTarget(
);
}
+function resolveMembershipRoot(
+ tab: UserMembershipTenantTab,
+ tenants: T[],
+) {
+ const rootSlug = tab.rootSlug.toLowerCase();
+ return (
+ tenants.find(
+ (tenant) => tab.seedTenantId && tenant.id === tab.seedTenantId,
+ ) ??
+ tenants.find((tenant) => tenant.slug?.trim().toLowerCase() === rootSlug)
+ );
+}
+
+export function classifyTenantByMembershipRoot(
+ target: TenantFilterTarget | undefined,
+ tenants: T[],
+) {
+ const tenant = resolveTenantTarget(target, tenants);
+ if (!tenant?.id) return undefined;
+
+ const tenantById = new Map(
+ tenants
+ .filter((item) => item.id?.trim())
+ .map((item) => [item.id as string, item]),
+ );
+
+ return USER_MEMBERSHIP_TENANT_TABS.find((tab) => {
+ const root = resolveMembershipRoot(tab, tenants);
+ if (!root?.id) return false;
+ const resolvedTenant = tenantById.get(tenant.id ?? "") ?? tenant;
+ return isInTenantSubtree(resolvedTenant, root.id, tenantById);
+ });
+}
+
+export function filterTenantsByMembershipRoot(
+ tenants: T[],
+ tabId: UserMembershipTenantTabId,
+) {
+ const tab = USER_MEMBERSHIP_TENANT_TABS.find((item) => item.id === tabId);
+ if (!tab) return [];
+
+ const root = resolveMembershipRoot(tab, tenants);
+ if (!root?.id) return [];
+
+ const tenantById = new Map(
+ tenants
+ .filter((tenant) => tenant.id?.trim())
+ .map((tenant) => [tenant.id as string, tenant]),
+ );
+
+ return tenants.filter(
+ (tenant) =>
+ !isSystemTenant(tenant) &&
+ isPublicRepresentativeTenant(tenant) &&
+ isInTenantSubtree(tenant, root.id as string, tenantById),
+ );
+}
+
+export function resolveUserMembershipTenantTab(
+ user: HanmacFamilyUserTarget,
+ tenants: T[],
+) {
+ const metadataAppointments = Array.isArray(
+ user.metadata?.additionalAppointments,
+ )
+ ? user.metadata.additionalAppointments
+ .map((appointment) => appointment as TenantFilterTarget)
+ .filter(
+ (appointment) =>
+ typeof appointment.tenantId === "string" ||
+ typeof appointment.id === "string" ||
+ typeof appointment.tenantSlug === "string" ||
+ typeof appointment.slug === "string",
+ )
+ .map((appointment) => ({
+ id: appointment.id ?? appointment.tenantId,
+ slug: appointment.slug ?? appointment.tenantSlug,
+ parentId: appointment.parentId,
+ type: appointment.type,
+ name: appointment.name ?? appointment.tenantName,
+ }))
+ : [];
+ const tenantBySlug = new Map(
+ tenants
+ .filter((tenant) => tenant.slug?.trim())
+ .map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
+ );
+ const tenantById = new Map(
+ tenants
+ .filter((tenant) => tenant.id?.trim())
+ .map((tenant) => [tenant.id as string, tenant]),
+ );
+ const candidates = [
+ user.tenant,
+ ...(user.joinedTenants ?? []),
+ ...metadataAppointments,
+ ...metadataAppointments.map((appointment) =>
+ tenantById.get(appointment.id ?? ""),
+ ),
+ tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
+ ];
+
+ return (
+ USER_MEMBERSHIP_TENANT_TABS.find((tab) =>
+ candidates.some(
+ (candidate) =>
+ classifyTenantByMembershipRoot(candidate, tenants)?.id === tab.id,
+ ),
+ ) ?? USER_MEMBERSHIP_TENANT_TABS[0]
+ );
+}
+
function isGPDTDCTenant(
target: TenantFilterTarget | undefined,
tenants: T[],
@@ -326,7 +491,10 @@ export function buildAuthenticatedOrgChartTenantPickerUrl(
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
}
-export function buildOrgChartUserMultiPickerUrl(baseUrl?: string) {
+export function buildOrgChartUserMultiPickerUrl(
+ baseUrl?: string,
+ options: OrgChartUserMultiPickerOptions = {},
+) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const params = new URLSearchParams({
mode: "multiple",
@@ -337,12 +505,18 @@ export function buildOrgChartUserMultiPickerUrl(baseUrl?: string) {
params.set("includeInternal", "true");
params.set("includeDescendants", "true");
params.set("showDescendantToggle", "true");
+ if (options.tenantId?.trim()) {
+ params.set("tenantId", options.tenantId.trim());
+ }
return `${normalizedBase}/embed/picker?${params.toString()}`;
}
-export function buildAuthenticatedOrgChartUserMultiPickerUrl(baseUrl?: string) {
- const pickerUrl = buildOrgChartUserMultiPickerUrl("");
+export function buildAuthenticatedOrgChartUserMultiPickerUrl(
+ baseUrl?: string,
+ options: OrgChartUserMultiPickerOptions = {},
+) {
+ const pickerUrl = buildOrgChartUserMultiPickerUrl("", options);
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
}
@@ -418,5 +592,15 @@ export function parseOrgChartUserSelections(
id: selection.id,
name: selection.name,
email: typeof selection.email === "string" ? selection.email : "",
+ rootTenantName:
+ typeof selection.rootTenantName === "string"
+ ? selection.rootTenantName
+ : undefined,
+ leafTenantName:
+ typeof selection.leafTenantName === "string"
+ ? selection.leafTenantName
+ : typeof selection.tenantName === "string"
+ ? selection.tenantName
+ : undefined,
}));
}
diff --git a/adminfront/src/lib/adminApi.contract.test.ts b/adminfront/src/lib/adminApi.contract.test.ts
index 1cec516e..18a32787 100644
--- a/adminfront/src/lib/adminApi.contract.test.ts
+++ b/adminfront/src/lib/adminApi.contract.test.ts
@@ -60,7 +60,15 @@ describe("adminApi endpoint contracts", () => {
period: "week",
tenantId: "tenant-1",
});
- await adminApi.fetchTenants(25, 50, "parent-1", "cursor-b");
+ await adminApi.fetchTenants(
+ 25,
+ 50,
+ "parent-1",
+ "cursor-b",
+ "saman",
+ "name",
+ "asc",
+ );
await adminApi.fetchAllTenants({ pageSize: 200, parentId: "parent-1" });
await adminApi.fetchTenant("tenant-1");
await adminApi.fetchTenantAdmins("tenant-1");
@@ -97,6 +105,9 @@ describe("adminApi endpoint contracts", () => {
offset: 50,
parentId: "parent-1",
cursor: "cursor-b",
+ search: "saman",
+ sort: "name",
+ direction: "asc",
},
});
expect(fetchAllCursorPages).toHaveBeenCalledWith(
diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts
index c5c42997..11818d8f 100644
--- a/adminfront/src/lib/adminApi.ts
+++ b/adminfront/src/lib/adminApi.ts
@@ -310,11 +310,13 @@ export async function fetchTenants(
parentId?: string,
cursor?: string,
search?: string,
+ sort?: string,
+ direction?: "asc" | "desc",
) {
const { data } = await apiClient.get(
"/v1/admin/tenants",
{
- params: { limit, offset, parentId, cursor, search },
+ params: { limit, offset, parentId, cursor, search, sort, direction },
},
);
return data;
@@ -938,6 +940,7 @@ export type WorksmobileComparisonItem = {
baronEmail?: string;
baronPhone?: string;
baronEmployeeNumber?: string;
+ baronGrade?: string;
baronPrimaryOrgId?: string;
baronPrimaryOrgSlug?: string;
baronPrimaryOrgName?: string;
@@ -972,10 +975,29 @@ export type WorksmobileComparisonItem = {
worksmobileJobRetryCount?: number;
worksmobileLastError?: string;
worksmobileLastAttemptAt?: string;
+ userMemberships?: WorksmobileUserMembershipComparison[];
updateReasons?: string[];
status: string;
};
+export type WorksmobileUserMembershipComparison = {
+ baronOrgId?: string;
+ baronOrgSlug?: string;
+ baronOrgName?: string;
+ baronGrade?: string;
+ baronPrimary?: boolean;
+ worksmobileDomainId?: number;
+ worksmobileDomainName?: string;
+ worksmobileOrgId?: string;
+ worksmobileOrgName?: string;
+ worksmobileLevelId?: string;
+ worksmobileLevelName?: string;
+ worksmobileOrgPositionId?: string;
+ worksmobileOrgIsManager?: boolean;
+ worksmobilePrimary?: boolean;
+ gradeNeedsUpdate?: boolean;
+};
+
export type WorksmobileComparison = {
users: WorksmobileComparisonItem[];
groups: WorksmobileComparisonItem[];
diff --git a/adminfront/tests/tenant-performance.spec.ts b/adminfront/tests/tenant-performance.spec.ts
new file mode 100644
index 00000000..72c6c0f3
--- /dev/null
+++ b/adminfront/tests/tenant-performance.spec.ts
@@ -0,0 +1,402 @@
+import { performance } from "node:perf_hooks";
+import { expect, test } from "@playwright/test";
+
+const tenantCount = 3500;
+const userCount = 3500;
+
+type TenantFixture = {
+ id: string;
+ name: string;
+ slug: string;
+ status: string;
+ type: string;
+ memberCount: number;
+ recursiveMemberCount: number;
+ createdAt: string;
+ updatedAt: string;
+};
+
+type UserFixture = {
+ id: string;
+ name: string;
+ email: string;
+ phone: string;
+ loginId: string;
+ role: string;
+ status: string;
+ tenantId: string;
+ tenantSlug: string;
+ tenantName: string;
+ department: string;
+ createdAt: string;
+};
+
+function buildTenants(): TenantFixture[] {
+ const baseTime = Date.UTC(2026, 0, 1, 0, 0, 0);
+
+ return Array.from({ length: tenantCount }, (_, index) => {
+ const sequence = index + 1;
+ const padded = String(sequence).padStart(4, "0");
+ const timestamp = new Date(baseTime + sequence * 1000).toISOString();
+
+ return {
+ id: `tenant-${padded}`,
+ name: `Tenant ${padded}`,
+ slug: sequence === 100 ? "full-dataset-needle-0100" : `tenant-${padded}`,
+ status: sequence % 17 === 0 ? "inactive" : "active",
+ type: sequence % 5 === 0 ? "ORGANIZATION" : "COMPANY",
+ memberCount: sequence % 13,
+ recursiveMemberCount: sequence % 29,
+ createdAt: timestamp,
+ updatedAt: timestamp,
+ };
+ });
+}
+
+function buildUsers(): UserFixture[] {
+ const baseTime = Date.UTC(2026, 0, 1, 0, 0, 0);
+
+ return Array.from({ length: userCount }, (_, index) => {
+ const sequence = index + 1;
+ const padded = String(sequence).padStart(4, "0");
+ const timestamp = new Date(baseTime + sequence * 1000).toISOString();
+ const email =
+ sequence === 100
+ ? "full-dataset-user-needle-0100@example.com"
+ : `user-${padded}@example.com`;
+
+ return {
+ id: `user-${padded}`,
+ name: `User ${padded}`,
+ email,
+ phone: "010-1111-2222",
+ loginId: `user-${padded}`,
+ role: "user",
+ status: sequence % 19 === 0 ? "inactive" : "active",
+ tenantId: "tenant-main",
+ tenantSlug: "tenant-main",
+ tenantName: "Main Tenant",
+ department: "Platform",
+ createdAt: timestamp,
+ };
+ });
+}
+
+function compareTenantValues(
+ left: TenantFixture,
+ right: TenantFixture,
+ sortKey: string,
+) {
+ const key = sortKey as keyof TenantFixture;
+ const leftValue = left[key] ?? "";
+ const rightValue = right[key] ?? "";
+
+ return String(leftValue).localeCompare(String(rightValue));
+}
+
+test.describe("Tenant list performance", () => {
+ 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}`;
+ window.localStorage.setItem(
+ key,
+ JSON.stringify({
+ access_token: "fake-token",
+ token_type: "Bearer",
+ profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
+ expires_at: Math.floor(Date.now() / 1000) + 36000,
+ }),
+ );
+ });
+
+ await page.route("**/oidc/**", async (route) => {
+ await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
+ });
+ });
+
+ test("loads and searches the tenant list within the performance budget", async ({
+ page,
+ }, testInfo) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+
+ const tenants = buildTenants();
+
+ await page.route("**/api/v1/**", async (route) => {
+ const url = new URL(route.request().url());
+ const headers = { "Access-Control-Allow-Origin": "*" };
+
+ if (url.pathname.endsWith("/user/me")) {
+ return route.fulfill({
+ json: {
+ id: "admin-user",
+ name: "Admin",
+ role: "super_admin",
+ manageableTenants: [],
+ },
+ headers,
+ });
+ }
+
+ if (
+ url.pathname.endsWith("/admin/tenants") &&
+ route.request().method() === "GET"
+ ) {
+ const limit = Number(url.searchParams.get("limit") ?? "500");
+ const cursor = Number(url.searchParams.get("cursor") ?? "0");
+ const search = url.searchParams.get("search")?.trim().toLowerCase();
+ const sort = url.searchParams.get("sort") ?? "createdAt";
+ const direction = url.searchParams.get("direction") ?? "desc";
+
+ let filtered = tenants;
+ if (search) {
+ filtered = tenants.filter((tenant) =>
+ [tenant.id, tenant.name, tenant.slug, tenant.type].some((value) =>
+ value.toLowerCase().includes(search),
+ ),
+ );
+ }
+
+ const sorted = [...filtered].sort((left, right) => {
+ const result = compareTenantValues(left, right, sort);
+ return direction === "asc" ? result : -result;
+ });
+ const pageItems = sorted.slice(cursor, cursor + limit);
+ const nextOffset = cursor + limit;
+
+ return route.fulfill({
+ json: {
+ items: pageItems,
+ total: sorted.length,
+ limit,
+ offset: 0,
+ nextCursor:
+ nextOffset < sorted.length ? String(nextOffset) : undefined,
+ },
+ headers,
+ });
+ }
+
+ return route.fulfill({ json: { items: [], total: 0 }, headers });
+ });
+
+ const loadStarted = performance.now();
+ await page.goto("/tenants");
+ await expect(page.getByTestId("tenant-internal-id-tenant-3500")).toBeVisible(
+ { timeout: 15000 },
+ );
+ const loadMs = performance.now() - loadStarted;
+ const loadSnapshot = testInfo.outputPath("tenant-list-load.png");
+ await page.screenshot({ path: loadSnapshot, fullPage: true });
+
+ await expect(page.locator("tbody tr").first()).toContainText("Tenant 3500");
+
+ const searchInput = page.getByPlaceholder("이름 또는 슬러그, ID 검색");
+ const searchStarted = performance.now();
+ await searchInput.fill("full-dataset-needle-0100");
+ await expect(page.getByTestId("tenant-internal-id-tenant-0100")).toBeVisible(
+ { timeout: 15000 },
+ );
+ const searchMs = performance.now() - searchStarted;
+ const searchSnapshot = testInfo.outputPath("tenant-list-search.png");
+ await page.screenshot({ path: searchSnapshot, fullPage: true });
+
+ await expect(page.locator("tbody")).toContainText(
+ "full-dataset-needle-0100",
+ );
+ await expect(page.getByTestId("tenant-internal-id-tenant-3500")).toHaveCount(
+ 0,
+ );
+
+ console.log(
+ JSON.stringify({
+ metric: "tenant-list-performance",
+ loadMs: Math.round(loadMs),
+ searchMs: Math.round(searchMs),
+ loadSnapshot,
+ searchSnapshot,
+ }),
+ );
+
+ expect(loadMs).toBeLessThanOrEqual(1500);
+ expect(searchMs).toBeLessThanOrEqual(500);
+ });
+});
+
+test.describe("User list performance", () => {
+ 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}`;
+ window.localStorage.setItem(
+ key,
+ JSON.stringify({
+ 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,
+ }),
+ );
+ });
+
+ 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" } });
+ });
+ });
+
+ test("loads and searches the user list within the performance budget", async ({
+ page,
+ }, testInfo) => {
+ await page.setViewportSize({ width: 1440, height: 900 });
+
+ const users = buildUsers();
+
+ await page.route("**/api/v1/**", async (route) => {
+ const url = new URL(route.request().url());
+ const headers = { "Access-Control-Allow-Origin": "*" };
+
+ if (url.pathname.endsWith("/user/me")) {
+ return route.fulfill({
+ json: {
+ id: "admin-user",
+ name: "Admin",
+ email: "admin@test.com",
+ role: "super_admin",
+ manageableTenants: [],
+ },
+ headers,
+ });
+ }
+
+ if (
+ url.pathname.endsWith("/admin/tenants") &&
+ route.request().method() === "GET"
+ ) {
+ return route.fulfill({
+ json: {
+ items: [
+ {
+ id: "tenant-main",
+ slug: "tenant-main",
+ name: "Main Tenant",
+ type: "COMPANY",
+ config: { userSchema: [] },
+ },
+ ],
+ total: 1,
+ limit: 500,
+ offset: 0,
+ },
+ headers,
+ });
+ }
+
+ if (
+ url.pathname.endsWith("/admin/users") &&
+ route.request().method() === "GET"
+ ) {
+ const limit = Number(url.searchParams.get("limit") ?? "50");
+ const cursor = Number(url.searchParams.get("cursor") ?? "0");
+ const search = url.searchParams.get("search")?.trim().toLowerCase();
+
+ const filtered = search
+ ? users.filter((user) =>
+ [user.id, user.name, user.email, user.loginId].some((value) =>
+ value.toLowerCase().includes(search),
+ ),
+ )
+ : users;
+ const sorted = [...filtered].sort((left, right) =>
+ right.createdAt.localeCompare(left.createdAt),
+ );
+ const pageItems = sorted.slice(cursor, cursor + limit);
+ const nextOffset = cursor + limit;
+
+ return route.fulfill({
+ json: {
+ items: pageItems,
+ total: sorted.length,
+ limit,
+ offset: 0,
+ nextCursor:
+ nextOffset < sorted.length ? String(nextOffset) : undefined,
+ },
+ headers,
+ });
+ }
+
+ return route.fulfill({ json: { items: [], total: 0 }, headers });
+ });
+
+ const loadStarted = performance.now();
+ await page.goto("/users");
+ await expect(page.getByTestId("user-internal-id-user-3500")).toBeVisible({
+ timeout: 15000,
+ });
+ const loadMs = performance.now() - loadStarted;
+ const loadSnapshot = testInfo.outputPath("user-list-load.png");
+ await page.screenshot({ path: loadSnapshot, fullPage: true });
+
+ await expect(page.getByText("User 3500")).toBeVisible();
+
+ const searchInput = page.getByPlaceholder("이름 또는 이메일 검색");
+ const searchStarted = performance.now();
+ await searchInput.fill("full-dataset-user-needle-0100");
+ await expect(page.getByTestId("user-internal-id-user-0100")).toBeVisible({
+ timeout: 15000,
+ });
+ const searchMs = performance.now() - searchStarted;
+ const searchSnapshot = testInfo.outputPath("user-list-search.png");
+ await page.screenshot({ path: searchSnapshot, fullPage: true });
+
+ await expect(
+ page.getByText("full-dataset-user-needle-0100@example.com"),
+ ).toBeVisible();
+ await expect(page.getByTestId("user-internal-id-user-3500")).toHaveCount(0);
+
+ console.log(
+ JSON.stringify({
+ metric: "user-list-performance",
+ loadMs: Math.round(loadMs),
+ searchMs: Math.round(searchMs),
+ loadSnapshot,
+ searchSnapshot,
+ }),
+ );
+
+ expect(loadMs).toBeLessThanOrEqual(1500);
+ expect(searchMs).toBeLessThanOrEqual(500);
+ });
+});
diff --git a/adminfront/tests/tenant-profile-performance-local.spec.ts b/adminfront/tests/tenant-profile-performance-local.spec.ts
new file mode 100644
index 00000000..5096aede
--- /dev/null
+++ b/adminfront/tests/tenant-profile-performance-local.spec.ts
@@ -0,0 +1,219 @@
+import fs from "node:fs";
+import path from "node:path";
+import { performance } from "node:perf_hooks";
+import { expect, test, type Route } from "@playwright/test";
+
+const targetTenantId =
+ process.env.TENANT_PROFILE_PERF_TENANT_ID ??
+ "56cd0fd7-b62a-43c0-8db9-74a30468d7cb";
+const actualApiBaseUrl =
+ process.env.TENANT_PROFILE_PERF_API_BASE_URL ?? "http://localhost:5173/api";
+const normalizedActualApiBaseUrl = actualApiBaseUrl.replace(/\/$/, "");
+const evidenceDir = path.resolve("e2e-evidence");
+
+type ApiTiming = {
+ method: string;
+ url: string;
+ status: number;
+ durationMs: number;
+};
+
+type Measurement = {
+ sample: number;
+ configFieldsVisibleMs: number;
+ networkIdleMs: number;
+ orgUnitType: string | null;
+ visibility: string | null;
+ worksmobileSync: string | null;
+ apiTimings: ApiTiming[];
+};
+
+async function fulfillFromLocalApi(route: Route, targetUrl?: string) {
+ const request = route.request();
+ const corsHeaders = {
+ "access-control-allow-headers": "authorization,content-type,x-test-role",
+ "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS",
+ "access-control-allow-origin": "*",
+ };
+
+ if (request.method() === "OPTIONS") {
+ await route.fulfill({ status: 204, headers: corsHeaders });
+ return;
+ }
+
+ const headers = { ...request.headers(), "x-test-role": "super_admin" };
+ delete headers.authorization;
+ delete headers.host;
+
+ const response = await route.fetch({ url: targetUrl, headers });
+ await route.fulfill({
+ response,
+ headers: { ...response.headers(), ...corsHeaders },
+ });
+}
+
+function percentile(values: number[], ratio: number) {
+ const sorted = [...values].sort((left, right) => left - right);
+ const index = Math.min(
+ sorted.length - 1,
+ Math.ceil(sorted.length * ratio) - 1,
+ );
+ return sorted[index] ?? 0;
+}
+
+test.describe("Tenant profile local performance evidence", () => {
+ test("loads org config fields through the local API within 500ms", async ({
+ page,
+ }, testInfo) => {
+ fs.mkdirSync(evidenceDir, { recursive: true });
+ await page.setViewportSize({ width: 1440, height: 900 });
+
+ await page.addInitScript(() => {
+ window.localStorage.setItem("locale", "ko");
+ window.localStorage.setItem("X-Mock-Role-Enabled", "true");
+ window.localStorage.setItem("X-Mock-Role", "super_admin");
+ window.localStorage.removeItem("admin_session");
+ for (const key of Object.keys(window.localStorage)) {
+ if (key.startsWith("oidc.user:")) {
+ window.localStorage.removeItem(key);
+ }
+ }
+ (
+ 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/**", async (route) => {
+ await fulfillFromLocalApi(route);
+ });
+
+ await page.route("http://playwright-mock/api/**", async (route) => {
+ const request = route.request();
+ const source = new URL(request.url());
+ const target = `${normalizedActualApiBaseUrl}${source.pathname.replace(
+ /^\/api/,
+ "",
+ )}${source.search}`;
+ await fulfillFromLocalApi(route, target);
+ });
+
+ const requestStartedAt = new Map();
+ const apiTimings: ApiTiming[] = [];
+
+ page.on("request", (request) => {
+ const url = request.url();
+ if (url.includes("/api/v1/") || url.includes("playwright-mock/api")) {
+ requestStartedAt.set(request.url(), performance.now());
+ }
+ });
+ page.on("response", (response) => {
+ const request = response.request();
+ const startedAt = requestStartedAt.get(request.url());
+ if (startedAt === undefined) {
+ return;
+ }
+ const timing = {
+ method: request.method(),
+ url: response.url(),
+ status: response.status(),
+ durationMs: Math.round(performance.now() - startedAt),
+ };
+ apiTimings.push(timing);
+ });
+ page.on("requestfailed", (request) => {
+ const url = request.url();
+ if (url.includes("/api/v1/") || url.includes("playwright-mock/api")) {
+ console.log(
+ "api-request-failed",
+ JSON.stringify({
+ method: request.method(),
+ url,
+ failure: request.failure()?.errorText,
+ }),
+ );
+ }
+ });
+
+ const measurements: Measurement[] = [];
+ const sampleCount = 5;
+
+ for (let sample = 1; sample <= sampleCount; sample += 1) {
+ apiTimings.length = 0;
+ const startedAt = performance.now();
+
+ await page.goto(`/tenants/${targetTenantId}`, {
+ waitUntil: "domcontentloaded",
+ });
+
+ const orgUnitTypeSelect = page.getByTestId("tenant-org-unit-type-select");
+ await expect(orgUnitTypeSelect).toBeVisible({ timeout: 15000 });
+ await expect(page.locator("#tenant-visibility")).toBeVisible();
+ await expect(page.locator("#worksmobileExcluded")).toBeVisible();
+
+ const configFieldsVisibleMs = Math.round(performance.now() - startedAt);
+ await page.waitForLoadState("networkidle", { timeout: 15000 });
+ const networkIdleMs = Math.round(performance.now() - startedAt);
+
+ measurements.push({
+ sample,
+ configFieldsVisibleMs,
+ networkIdleMs,
+ orgUnitType: await orgUnitTypeSelect.inputValue(),
+ visibility: await page.locator("#tenant-visibility").inputValue(),
+ worksmobileSync: await page
+ .locator("#worksmobileExcluded")
+ .inputValue(),
+ apiTimings: [...apiTimings],
+ });
+ }
+
+ const screenshotPath = path.join(
+ evidenceDir,
+ "tenant-profile-performance-local.png",
+ );
+ await page.screenshot({ path: screenshotPath, fullPage: true });
+
+ const configTimes = measurements.map(
+ (measurement) => measurement.configFieldsVisibleMs,
+ );
+ const networkIdleTimes = measurements.map(
+ (measurement) => measurement.networkIdleMs,
+ );
+ const evidence = {
+ metric: "tenant-profile-local-performance",
+ tenantId: targetTenantId,
+ actualApiBaseUrl,
+ measuredAt: new Date().toISOString(),
+ browser: testInfo.project.name,
+ samples: measurements,
+ summary: {
+ configFieldsVisibleMs: {
+ min: Math.min(...configTimes),
+ max: Math.max(...configTimes),
+ p50: percentile(configTimes, 0.5),
+ p95: percentile(configTimes, 0.95),
+ },
+ networkIdleMs: {
+ min: Math.min(...networkIdleTimes),
+ max: Math.max(...networkIdleTimes),
+ p50: percentile(networkIdleTimes, 0.5),
+ p95: percentile(networkIdleTimes, 0.95),
+ },
+ },
+ screenshotPath,
+ };
+ const evidencePath = path.join(
+ evidenceDir,
+ "tenant-profile-performance-local.json",
+ );
+ fs.writeFileSync(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`);
+
+ console.log(JSON.stringify(evidence, null, 2));
+
+ expect(evidence.summary.configFieldsVisibleMs.p95).toBeLessThanOrEqual(500);
+ });
+});
diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts
index d03ec80b..680503c3 100644
--- a/adminfront/tests/tenants.spec.ts
+++ b/adminfront/tests/tenants.spec.ts
@@ -527,6 +527,93 @@ test.describe("Tenants Management", () => {
);
});
+ test("should bulk update selected tenant status type and visibility", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 1100, height: 760 });
+ const updatePayloads: Record[] = [];
+
+ await page.route("**/api/v1/admin/tenants**", async (route) => {
+ const request = route.request();
+ const url = new URL(request.url());
+
+ if (request.method() === "PUT") {
+ updatePayloads.push(request.postDataJSON());
+ return route.fulfill({
+ json: {
+ id: url.pathname.split("/").at(-1),
+ name: "Updated Tenant",
+ slug: "updated-tenant",
+ status: "inactive",
+ type: "ORGANIZATION",
+ config: { visibility: "public" },
+ },
+ headers: { "Access-Control-Allow-Origin": "*" },
+ });
+ }
+
+ if (request.method() !== "GET") {
+ return route.continue();
+ }
+
+ return route.fulfill({
+ json: {
+ items: [
+ {
+ id: "tenant-a",
+ name: "Tenant A",
+ slug: "tenant-a",
+ status: "active",
+ type: "COMPANY",
+ config: { visibility: "internal" },
+ updatedAt: new Date().toISOString(),
+ },
+ {
+ id: "tenant-b",
+ name: "Tenant B",
+ slug: "tenant-b",
+ status: "active",
+ type: "COMPANY",
+ config: { visibility: "internal" },
+ updatedAt: new Date().toISOString(),
+ },
+ ],
+ total: 2,
+ limit: 500,
+ offset: 0,
+ },
+ headers: { "Access-Control-Allow-Origin": "*" },
+ });
+ });
+
+ await page.goto("/tenants");
+
+ for (const tenantId of ["tenant-a", "tenant-b"]) {
+ await page
+ .getByTestId(`tenant-internal-id-${tenantId}`)
+ .locator("xpath=ancestor::tr")
+ .getByRole("checkbox")
+ .click();
+ }
+
+ await page.getByTestId("tenant-bulk-status-select").click();
+ await page.getByRole("option", { name: /비활성|inactive/i }).click();
+ await page.getByTestId("tenant-bulk-type-select").click();
+ await page.getByRole("option", { name: /Organization|정규 조직/i }).click();
+ await page.getByTestId("tenant-bulk-visibility-select").click();
+ await page.getByRole("option", { name: "공개", exact: true }).click();
+ await page.getByTestId("tenant-bulk-apply-btn").click();
+
+ await expect.poll(() => updatePayloads).toHaveLength(2);
+ for (const payload of updatePayloads) {
+ expect(payload).toMatchObject({
+ status: "inactive",
+ type: "ORGANIZATION",
+ config: { visibility: "public" },
+ });
+ }
+ });
+
test("switches tree and flat views, searches UUID, and selects descendants", async ({
page,
}) => {
diff --git a/adminfront/tests/users.spec.ts b/adminfront/tests/users.spec.ts
index c0f59917..c763dbe4 100644
--- a/adminfront/tests/users.spec.ts
+++ b/adminfront/tests/users.spec.ts
@@ -754,7 +754,7 @@ test.describe("User Management", () => {
expect(exportUrl).toContain("includeIds=false");
});
- test("should show contact info in one row, hide roles, and change user status", async ({
+ test("should hide role controls from the users table and change user status", async ({
page,
}) => {
let updatePayload: Record | undefined;
@@ -781,13 +781,311 @@ test.describe("User Management", () => {
const table = page.locator("table");
await expect(
table.getByRole("columnheader", { name: /ROLE|역할/i }),
- ).toBeVisible();
+ ).toHaveCount(0);
await page.getByTestId("user-status-select-u-1").click();
await page.getByRole("option", { name: /입사대기|Preboarding/ }).click();
await expect
.poll(() => updatePayload)
.toMatchObject({ status: "preboarding" });
+
+ await table.locator('input[name="user-list-select-u-1"]').check();
+ await expect(page.getByTestId("bulk-permission-select")).toHaveCount(0);
+ });
+
+ test("should keep system role assignment out of the permissions screen", async ({
+ page,
+ }) => {
+ let bulkPayload: Record | undefined;
+
+ await page.route(/\/admin\/system\/relations$/, async (route) => {
+ if (route.request().method() !== "GET") {
+ return route.fallback();
+ }
+ return route.fulfill({
+ json: {
+ items: [
+ {
+ userId: "u-1",
+ name: "John Doe",
+ email: "john@test.com",
+ relations: ["overview_viewers"],
+ },
+ ],
+ },
+ });
+ });
+
+ await page.route(/\/admin\/users\/bulk$/, async (route) => {
+ if (route.request().method() !== "PUT") {
+ return route.fallback();
+ }
+ bulkPayload = route.request().postDataJSON();
+ return route.fulfill({
+ json: { results: [{ userId: "u-1", success: true }] },
+ });
+ });
+
+ await page.goto("/permissions-direct");
+ await expect(
+ page.getByTestId("permission-assignment-row-u-1-overview_viewers"),
+ ).toBeVisible();
+ await expect(
+ page.getByTestId("permissions-direct-super-admin-select"),
+ ).toHaveCount(0);
+ expect(bulkPayload).toBeUndefined();
+ });
+
+ test("should support bulk page and target action grants while keeping permissions direct protected", async ({
+ page,
+ }) => {
+ const relationWrites: Array> = [];
+ const relationDeletes: Array> = [];
+
+ await page.route(/\/admin\/system\/relations$/, async (route) => {
+ const method = route.request().method();
+ if (method === "GET") {
+ return route.fulfill({
+ json: {
+ items: [
+ {
+ userId: "u-1",
+ name: "John Doe",
+ email: "john@test.com",
+ relations: ["overview_viewers"],
+ },
+ {
+ userId: "u-2",
+ name: "Jane Manager",
+ email: "jane@test.com",
+ relations: [],
+ },
+ ],
+ },
+ });
+ }
+ if (method === "POST") {
+ relationWrites.push(route.request().postDataJSON());
+ return route.fulfill({ json: { success: true } });
+ }
+ if (method === "DELETE") {
+ relationDeletes.push(route.request().postDataJSON());
+ return route.fulfill({ json: { success: true } });
+ }
+ return route.fallback();
+ });
+ await page.route(/\/admin\/tenants\/t-1\/relations$/, async (route) => {
+ const method = route.request().method();
+ if (method === "GET") {
+ return route.fulfill({
+ json: {
+ items: [
+ {
+ userId: "u-1",
+ name: "John Doe",
+ email: "john@test.com",
+ relations: ["profile_viewers"],
+ },
+ ],
+ },
+ });
+ }
+ if (method === "POST") {
+ relationWrites.push(route.request().postDataJSON());
+ return route.fulfill({ json: { success: true } });
+ }
+ if (method === "DELETE") {
+ relationDeletes.push(route.request().postDataJSON());
+ return route.fulfill({ json: { success: true } });
+ }
+ return route.fallback();
+ });
+
+ await page.goto("/permissions-direct");
+
+ await expect(page.getByRole("tab", { name: /상세 권한/ })).toBeVisible();
+ await expect(
+ page.getByRole("option", { name: /권한 부여.*수정/ }),
+ ).toHaveCount(0);
+ await expect(page.getByTestId("permission-target-org-picker-frame")).toBeVisible();
+ await expect(page.getByTestId("permission-target-org-picker-frame")).toHaveAttribute(
+ "src",
+ /rootTenantId%3Dall|rootTenantId=all/,
+ );
+ const pickerBox = await page
+ .getByTestId("permission-target-org-picker-frame")
+ .boundingBox();
+ const queueBox = await page.getByTestId("permission-target-queue").boundingBox();
+ expect(pickerBox?.x ?? Number.POSITIVE_INFINITY).toBeLessThan(
+ queueBox?.x ?? Number.NEGATIVE_INFINITY,
+ );
+
+ await page.getByTestId("bulk-relation-mode").selectOption("target-action");
+ await expect(
+ page.getByTestId("bulk-relation-operation"),
+ ).toHaveCount(0);
+ await page.getByTestId("permission-action-tenant-picker-open").click();
+ await page.getByTestId("permission-action-tenant-search").fill("Test");
+ await page.getByTestId("permission-action-tenant-result-t-1").click();
+ await expect(page.getByTestId("bulk-relation-target-tenant")).toHaveValue(
+ "t-1",
+ );
+ await expect(
+ page.getByTestId("permission-target-tenant-scope"),
+ ).toHaveCount(0);
+ await expect(
+ page.getByTestId("permission-target-org-picker-frame"),
+ ).not.toHaveAttribute("src", /tenantId%3Dt-1|tenantId=t-1/);
+ await page.evaluate(() => {
+ window.postMessage(
+ {
+ type: "orgfront:picker:confirm",
+ payload: {
+ selections: [
+ {
+ type: "user",
+ id: "u-2",
+ name: "Jane Manager",
+ email: "jane@test.com",
+ rootTenantName: "한맥가족",
+ leafTenantName: "기술기획",
+ },
+ {
+ type: "user",
+ id: "u-3",
+ name: "Org Picked User",
+ email: "picked@test.com",
+ rootTenantName: "Commercial",
+ leafTenantName: "디자인팀",
+ },
+ ],
+ },
+ },
+ "*",
+ );
+ });
+ await expect(page.getByTestId("permission-target-queue")).toContainText(
+ "Jane Manager",
+ );
+ await expect(page.getByTestId("permission-target-queue")).toContainText(
+ "Org Picked User",
+ );
+ await expect(page.getByTestId("permission-target-queue")).toContainText(
+ "한맥가족 / 기술기획",
+ );
+
+ await page.getByTestId("bulk-relation-target").selectOption("profile");
+ await page.getByTestId("bulk-relation-action").selectOption("manage");
+ await page
+ .getByRole("button", { name: /선택 사용자에게 권한 부여/ })
+ .click();
+
+ await expect.poll(() => relationWrites).toContainEqual(
+ { userId: "u-2", relation: "tenants_managers" },
+ );
+ await expect.poll(() => relationWrites).toContainEqual(
+ { userId: "u-2", relation: "profile_managers" },
+ );
+ await expect.poll(() => relationWrites).toContainEqual(
+ { userId: "u-3", relation: "profile_managers" },
+ );
+
+ await page.getByTestId("permission-assignment-search").fill("John");
+ await expect(page.getByTestId("permission-assignment-row-u-1-profile_viewers")).toBeVisible();
+ await expect(
+ page.getByTestId("permission-assignment-row-u-2-profile_managers"),
+ ).toHaveCount(0);
+ await page.getByTestId("permission-assignment-search").fill("");
+ await page.getByTestId("permission-assignment-sort").selectOption("relation");
+ await page
+ .getByTestId("permission-assignment-level-u-1-profile_viewers")
+ .selectOption("write");
+ await expect.poll(() => relationWrites).toContainEqual({
+ userId: "u-1",
+ relation: "profile_managers",
+ });
+ await page
+ .getByTestId("permission-assignment-remove-u-1-profile_viewers")
+ .click();
+ await expect.poll(() => relationDeletes).toContainEqual({
+ userId: "u-1",
+ relation: "profile_viewers",
+ });
+ });
+
+ test("should grant super admin role from the last tab only for super admins", async ({
+ page,
+ }) => {
+ let bulkPayload: Record | undefined;
+
+ await page.route(/\/admin\/system\/relations$/, async (route) => {
+ if (route.request().method() !== "GET") {
+ return route.fallback();
+ }
+ return route.fulfill({
+ json: {
+ items: [
+ {
+ userId: "u-1",
+ name: "John Doe",
+ email: "john@test.com",
+ relations: ["overview_viewers"],
+ },
+ ],
+ },
+ });
+ });
+
+ await page.route(/\/admin\/users\/bulk$/, async (route) => {
+ if (route.request().method() !== "PUT") {
+ return route.fallback();
+ }
+ bulkPayload = route.request().postDataJSON();
+ return route.fulfill({
+ json: { results: [{ userId: "u-1", success: true }] },
+ });
+ });
+
+ await page.goto("/permissions-direct");
+ const tabs = page.getByRole("tab");
+ await expect(tabs.last()).toHaveText(/Super Admin 역할/);
+ await tabs.last().click();
+
+ await page.getByTestId("super-admin-role-user-u-1").check();
+ await page.getByRole("button", { name: /Super Admin 부여/ }).click();
+
+ await expect.poll(() => bulkPayload).toEqual({
+ userIds: ["u-1"],
+ role: "super_admin",
+ });
+ });
+
+ test("should hide the super admin role tab from non super admins", async ({
+ page,
+ }) => {
+ await page.route(/\/user\/me$/, async (route) => {
+ if (route.request().method() !== "GET") {
+ return route.fallback();
+ }
+ return route.fulfill({
+ json: {
+ id: "operator-user",
+ name: "Operator",
+ email: "operator@test.com",
+ role: "user",
+ manageableTenants: [],
+ },
+ });
+ });
+
+ await page.goto("/permissions-direct");
+
+ await expect(
+ page.getByRole("tab", { name: /Super Admin 역할/ }),
+ ).toHaveCount(0);
+ await expect(
+ page.getByText(/이 작업을 수행할 권한이 없습니다/),
+ ).toBeVisible();
});
test("should center users table loading state and use compact headers", async ({
@@ -1222,8 +1520,17 @@ test.describe("User Management", () => {
await page.goto("/users/u-1");
await expect(
- page.getByRole("tab", { name: /한맥가족 구성원/i }),
+ page.getByRole("tab", { name: /^한맥가족$/i }),
).toHaveAttribute("data-state", "active");
+ await expect(
+ page.getByRole("tab", { name: /외부 기업 회원/i }),
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("tab", { name: /^Commercial$/i }),
+ ).toBeVisible();
+ await expect(page.getByRole("tab", { name: /^공공기관$/i })).toBeVisible();
+ await expect(page.getByRole("tab", { name: /^교육기관$/i })).toBeVisible();
+ await expect(page.getByRole("tab", { name: /^개인$/i })).toBeVisible();
await expect(page.getByLabel(/한맥 가족 구성원으로 등록/i)).toHaveCount(0);
await expect(page.getByTestId("detail-appointment-row-0")).toBeVisible();
await expect(
diff --git a/adminfront/vite.config.ts b/adminfront/vite.config.ts
index 9f93253c..c630599d 100644
--- a/adminfront/vite.config.ts
+++ b/adminfront/vite.config.ts
@@ -4,6 +4,7 @@ import { defineConfig } from "vite";
const buildOutDir =
process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist";
+const usePolling = process.env.DEV_SERVER_WATCH_POLLING === "true";
export default defineConfig({
plugins: [react()],
@@ -24,6 +25,7 @@ export default defineConfig({
},
server: {
host: "127.0.0.1",
+ watch: usePolling ? { interval: 300, usePolling: true } : undefined,
proxy: {
"/api": {
target: process.env.API_PROXY_TARGET || "http://localhost:3000",
diff --git a/backend/cmd/adminctl/worksmobile_sync.go b/backend/cmd/adminctl/worksmobile_sync.go
index 9cbe8106..b5d62565 100644
--- a/backend/cmd/adminctl/worksmobile_sync.go
+++ b/backend/cmd/adminctl/worksmobile_sync.go
@@ -43,6 +43,9 @@ type worksmobileSyncConfig struct {
CreateUsersResultOutput string
CreateUsersLimit int
CreateUsersForcePasswordChange bool
+ UpdateUserLevelsCSV string
+ UpdateUserLevelsResultOutput string
+ UpdateUserLevelsLimit int
ImportHanmacUsersCSV string
ImportHanmacUsersResultOutput string
ImportHanmacUsersPassword string
@@ -168,6 +171,11 @@ func runWorksmobileSync(args []string) error {
return err
}
}
+ if config.UpdateUserLevelsCSV != "" {
+ if err := updateWorksmobileUserLevelsFromCSV(ctx, db, tenantRepo, userRepo, *root, config.UpdateUserLevelsCSV, config.UpdateUserLevelsResultOutput, config.UpdateUserLevelsLimit, newWorksmobileAdminClient()); err != nil {
+ return err
+ }
+ }
if config.ImportHanmacUsersCSV != "" {
if err := importHanmacUsersAndCreateWorksmobileAccounts(ctx, db, tenantRepo, userRepo, *root, config.ImportHanmacUsersCSV, config.ImportHanmacUsersResultOutput, config.ImportHanmacUsersPassword, config.ImportHanmacUsersLimit, config.ImportHanmacUsersForcePasswordChange, newWorksmobileAdminClient()); err != nil {
return err
@@ -232,6 +240,9 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
fs.StringVar(&config.CreateUsersResultOutput, "create-users-result-output", "", "output CSV path for Worksmobile create results")
fs.IntVar(&config.CreateUsersLimit, "create-users-limit", 0, "maximum users to create; 0 means all")
fs.BoolVar(&config.CreateUsersForcePasswordChange, "create-users-force-password-change", true, "request password change at next login for --create-users-csv")
+ fs.StringVar(&config.UpdateUserLevelsCSV, "update-user-levels-csv", "", "CSV containing user_id column to patch Worksmobile user levels directly")
+ fs.StringVar(&config.UpdateUserLevelsResultOutput, "update-user-levels-result-output", "", "output CSV path for Worksmobile user level patch results")
+ fs.IntVar(&config.UpdateUserLevelsLimit, "update-user-levels-limit", 0, "maximum users to patch levels; 0 means all")
fs.StringVar(&config.ImportHanmacUsersCSV, "import-hanmac-users-csv", "", "CSV containing Hanmac internal users to upsert into Baron and create in Worksmobile")
fs.StringVar(&config.ImportHanmacUsersResultOutput, "import-hanmac-users-result-output", "", "output CSV path for Hanmac user import and Worksmobile create results")
fs.StringVar(&config.ImportHanmacUsersPassword, "import-hanmac-users-password", "", "initial password for --import-hanmac-users-csv")
@@ -256,8 +267,8 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
if err := fs.Parse(args); err != nil {
return config, err
}
- if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && !config.Process {
- return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, or --process")
+ if !config.SyncOrgUnits && config.UsersCSV == "" && config.InspectUsersCSV == "" && config.InspectOrgUnitsCSV == "" && config.UpsertOrgUnitID == "" && config.UndeleteUsersCSV == "" && config.RemoveAliasesCSV == "" && config.FindNumberStrippedAliasesOutput == "" && config.DuplicatePhoneCountryCodeOutput == "" && !config.FixDuplicatePhoneCountryCode && config.PendingUsersOutput == "" && config.ResetPendingUsersPassword == "" && config.DeletePendingUsersResultOutput == "" && config.ForceDeleteUsersCSV == "" && config.CreateUsersCSV == "" && config.UpdateUserLevelsCSV == "" && config.ImportHanmacUsersCSV == "" && config.RecreatePendingUsersPassword == "" && config.ActivateAllUsersOutput == "" && config.ComparisonOutput == "" && config.AlignBaronFromWorksOutput == "" && !config.Process {
+ return config, fmt.Errorf("nothing to do; pass --orgunits, --users-csv, --inspect-users-csv, --inspect-orgunits-csv, --upsert-orgunit-id, --undelete-users-csv, --remove-aliases-csv, --find-number-stripped-aliases-output, --duplicate-phone-country-code-output, --fix-duplicate-phone-country-code, --pending-users-output, --reset-pending-users-password, --delete-pending-users-result-output, --force-delete-users-csv, --create-users-csv, --update-user-levels-csv, --import-hanmac-users-csv, --recreate-pending-users-password, --activate-all-users-output, --comparison-output, --align-baron-from-works-output, or --process")
}
if config.ResetPendingUsersPassword != "" && config.ResetPendingUsersResultOutput == "" {
return config, fmt.Errorf("--reset-pending-users-result-output is required with --reset-pending-users-password")
@@ -277,6 +288,9 @@ func resolveWorksmobileSyncConfig(args []string) (worksmobileSyncConfig, error)
if config.CreateUsersCSV != "" && config.CreateUsersResultOutput == "" {
return config, fmt.Errorf("--create-users-result-output is required with --create-users-csv")
}
+ if config.UpdateUserLevelsCSV != "" && config.UpdateUserLevelsResultOutput == "" {
+ return config, fmt.Errorf("--update-user-levels-result-output is required with --update-user-levels-csv")
+ }
if config.ImportHanmacUsersCSV != "" && config.ImportHanmacUsersPassword == "" {
return config, fmt.Errorf("--import-hanmac-users-password is required with --import-hanmac-users-csv")
}
@@ -1346,6 +1360,134 @@ func createWorksmobileUsersFromCSV(ctx context.Context, db *gorm.DB, tenantRepo
return nil
}
+func updateWorksmobileUserLevelsFromCSV(ctx context.Context, db *gorm.DB, tenantRepo repository.TenantRepository, userRepo repository.UserRepository, root domain.Tenant, usersCSV string, outputPath string, limit int, client *service.WorksmobileHTTPClient) error {
+ if limit < 0 {
+ return fmt.Errorf("--update-user-levels-limit cannot be negative")
+ }
+ userIDs, err := readWorksmobileUserIDsCSV(usersCSV)
+ if err != nil {
+ return err
+ }
+ if limit > 0 && len(userIDs) > limit {
+ userIDs = userIDs[:limit]
+ }
+ tenantIDs, err := activeTenantSubtreeIDs(ctx, db, root.ID)
+ if err != nil {
+ return err
+ }
+ tenants, err := tenantRepo.FindByIDs(ctx, tenantIDs)
+ if err != nil {
+ return err
+ }
+ tenantByID := map[string]domain.Tenant{root.ID: root}
+ for _, tenant := range tenants {
+ tenantByID[tenant.ID] = tenant
+ }
+ users, err := userRepo.FindByIDs(ctx, userIDs)
+ if err != nil {
+ return err
+ }
+ userByID := map[string]domain.User{}
+ for _, user := range users {
+ userByID[user.ID] = user
+ }
+
+ file, err := os.Create(outputPath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+ writer := csv.NewWriter(file)
+ defer writer.Flush()
+ header := []string{"user_id", "email", "name", "domain_id", "baron_level", "status", "error"}
+ if err := writer.Write(header); err != nil {
+ return err
+ }
+ okCount := 0
+ skippedCount := 0
+ errorCount := 0
+ for _, userID := range userIDs {
+ status := "ok"
+ errorMessage := ""
+ email := ""
+ name := ""
+ domainID := ""
+ levelName := ""
+ user, ok := userByID[userID]
+ if !ok {
+ status = "skipped"
+ errorMessage = "baron user not found"
+ skippedCount++
+ } else {
+ email = strings.TrimSpace(user.Email)
+ name = strings.TrimSpace(user.Name)
+ if user.TenantID == nil {
+ status = "skipped"
+ errorMessage = "baron user has no tenant"
+ skippedCount++
+ } else if !domain.IsWorksProvisionedUserStatus(user.Status) {
+ status = "skipped"
+ errorMessage = "baron user status is excluded from Worksmobile sync"
+ skippedCount++
+ } else {
+ tenant, ok := tenantByID[*user.TenantID]
+ if !ok {
+ status = "skipped"
+ errorMessage = "baron user tenant is outside Worksmobile sync scope"
+ skippedCount++
+ } else {
+ payload, err := service.BuildWorksmobileUserPayloadForDomainTenants(user, tenant, tenantByID, root.Config)
+ if err != nil {
+ status = "skipped"
+ errorMessage = err.Error()
+ skippedCount++
+ } else {
+ levelDomainID := worksmobileUserLevelPatchDomainID(payload)
+ domainID = fmt.Sprint(levelDomainID)
+ levelName = strings.TrimSpace(payload.LevelID)
+ expectedLevelName := service.WorksmobileLevelDisplayNameForIdentifier(levelName)
+ if levelName == "" {
+ status = "skipped"
+ errorMessage = "baron user has no level"
+ skippedCount++
+ } else if err := client.PatchUserOrganizationLevelByName(ctx, payload.Email, levelDomainID, levelName); err != nil {
+ status = "error"
+ errorMessage = err.Error()
+ errorCount++
+ } else if remote, err := client.GetUser(ctx, payload.Email); err != nil {
+ status = "error"
+ errorMessage = err.Error()
+ errorCount++
+ } else if !service.WorksmobileLevelIdentifierMatchesRemote(levelName, remote.LevelID, remote.LevelName) {
+ status = "error"
+ errorMessage = fmt.Sprintf("worksmobile level verification failed: expected_level=%s expected_level_name=%s remote_level_id=%s remote_level_name=%s", levelName, expectedLevelName, strings.TrimSpace(remote.LevelID), strings.TrimSpace(remote.LevelName))
+ errorCount++
+ } else {
+ okCount++
+ }
+ }
+ }
+ }
+ }
+ if err := writer.Write([]string{userID, email, name, domainID, levelName, status, errorMessage}); err != nil {
+ return err
+ }
+ writer.Flush()
+ if err := writer.Error(); err != nil {
+ return err
+ }
+ }
+ fmt.Printf("worksmobile user levels update result written: %s targets=%d ok=%d skipped=%d errors=%d\n", outputPath, len(userIDs), okCount, skippedCount, errorCount)
+ return nil
+}
+
+func worksmobileUserLevelPatchDomainID(payload service.WorksmobileUserPayload) int64 {
+ if payload.LevelDomainID > 0 {
+ return payload.LevelDomainID
+ }
+ return payload.DomainID
+}
+
type hanmacWorksmobileImportRow struct {
Email string
Name string
@@ -1698,6 +1840,7 @@ func applyHanmacWorksmobileImportRowToUser(user *domain.User, row hanmacWorksmob
"tenantId": tenant.ID,
"tenantSlug": tenant.Slug,
"tenantName": tenant.Name,
+ "grade": strings.TrimSpace(row.Grade),
"isPrimary": true,
}}
}
@@ -2300,6 +2443,7 @@ func exportWorksmobileNeedsUpdateComparison(ctx context.Context, syncService ser
"baron_id",
"baron_name",
"baron_email",
+ "baron_grade",
"baron_primary_org_id",
"baron_primary_org_slug",
"baron_primary_org_name",
@@ -2336,6 +2480,7 @@ func exportWorksmobileNeedsUpdateComparison(ctx context.Context, syncService ser
item.BaronID,
item.BaronName,
item.BaronEmail,
+ item.BaronGrade,
item.BaronPrimaryOrgID,
item.BaronPrimaryOrgSlug,
item.BaronPrimaryOrgName,
@@ -3061,7 +3206,7 @@ func newWorksmobileAdminClient() *service.WorksmobileHTTPClient {
},
)
client.BaseURL = strings.TrimSpace(getenv("WORKS_ADMIN_API_BASE_URL", ""))
- client.RateLimiter = service.NewWorksmobileAPIRateLimiter(180, time.Minute)
+ client.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
return client
}
@@ -3071,7 +3216,7 @@ func newWorksmobileSCIMClient() *service.WorksmobileHTTPClient {
getenv("WORKS_ADMIN_SCIM_TOKEN", getenv("SAMAN_SCIM_LONGLIVE_TOKEN", "")),
)
client.BaseURL = strings.TrimSpace(getenv("WORKS_ADMIN_API_BASE_URL", ""))
- client.RateLimiter = service.NewWorksmobileAPIRateLimiter(180, time.Minute)
+ client.RateLimiter = service.NewWorksmobileAPIRateLimiter(240, time.Minute)
return client
}
diff --git a/backend/cmd/adminctl/worksmobile_sync_test.go b/backend/cmd/adminctl/worksmobile_sync_test.go
index 2f907a0a..b1bec31e 100644
--- a/backend/cmd/adminctl/worksmobile_sync_test.go
+++ b/backend/cmd/adminctl/worksmobile_sync_test.go
@@ -36,3 +36,14 @@ func TestClassifyWorksmobileAlignFromWorksSkipsLocalPartChange(t *testing.T) {
t.Fatalf("expected skipped_email_local_part_changed status, got %s", status)
}
}
+
+func TestWorksmobileUserLevelPatchDomainIDPrefersLevelDomain(t *testing.T) {
+ payload := service.WorksmobileUserPayload{
+ DomainID: 300285955,
+ LevelDomainID: 300286337,
+ }
+
+ if got := worksmobileUserLevelPatchDomainID(payload); got != 300286337 {
+ t.Fatalf("expected level domain id, got %d", got)
+ }
+}
diff --git a/backend/internal/bootstrap/tenant_seed_test.go b/backend/internal/bootstrap/tenant_seed_test.go
index e6adb34d..66d66aa9 100644
--- a/backend/internal/bootstrap/tenant_seed_test.go
+++ b/backend/internal/bootstrap/tenant_seed_test.go
@@ -91,11 +91,6 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
parentSlug: "baron-group",
domains: []string{"pre-cast.co.kr"},
},
- {
- name: "Personal",
- slug: "personal",
- tenantType: domain.TenantTypePersonal,
- },
}
if len(configs) < len(expected) {
@@ -161,6 +156,42 @@ func TestSeedTenantCSVDefinesWorksmobileDomainClassTenants(t *testing.T) {
}
}
+func TestSeedTenantCSVDefinesTopLevelSeedTenantStructure(t *testing.T) {
+ configs, err := loadSeedTenantConfigs()
+ if err != nil {
+ t.Fatalf("loadSeedTenantConfigs returned error: %v", err)
+ }
+
+ configBySlug := make(map[string]InitialTenantConfig, len(configs))
+ for _, config := range configs {
+ configBySlug[config.Slug] = config
+ }
+
+ expectedRoots := []struct {
+ slug string
+ tenantType string
+ }{
+ {slug: "hanmac-family", tenantType: domain.TenantTypeCompanyGroup},
+ {slug: "commercial", tenantType: domain.TenantTypeCompanyGroup},
+ {slug: "public-org", tenantType: domain.TenantTypeCompanyGroup},
+ {slug: "edu", tenantType: domain.TenantTypeCompanyGroup},
+ {slug: "personal", tenantType: domain.TenantTypePersonal},
+ }
+
+ for _, want := range expectedRoots {
+ got, ok := configBySlug[want.slug]
+ if !ok {
+ t.Fatalf("top-level seed tenant slug %q not found", want.slug)
+ }
+ if got.Type != want.tenantType {
+ t.Fatalf("top-level seed tenant[%s] type = %q, want %q", want.slug, got.Type, want.tenantType)
+ }
+ if got.ParentSlug != "" {
+ t.Fatalf("top-level seed tenant[%s] parent slug = %q, want empty", want.slug, got.ParentSlug)
+ }
+ }
+}
+
func TestNormalizeSeedTenantTypeAllowsOrganization(t *testing.T) {
if got := normalizeSeedTenantType("organization"); got != domain.TenantTypeOrganization {
t.Fatalf("normalizeSeedTenantType(organization) = %q, want %q", got, domain.TenantTypeOrganization)
diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go
index a1666bbe..8560eaba 100644
--- a/backend/internal/handler/auth_handler.go
+++ b/backend/internal/handler/auth_handler.go
@@ -8038,11 +8038,6 @@ func (h *AuthHandler) mapKratosTraitsToLocalUser(identityID string, traits map[s
if department := extractTraitString(traits, "department"); department != "" {
localUser.Department = department
}
- if grade := extractTraitString(traits, "grade"); grade != "" {
- if _, isRole := domain.NormalizeRoleAlias(grade); !isRole {
- localUser.Grade = grade
- }
- }
if position := extractTraitString(traits, "position"); position != "" {
localUser.Position = position
}
diff --git a/backend/internal/handler/tenant_handler.go b/backend/internal/handler/tenant_handler.go
index e611dfc6..1c965a51 100644
--- a/backend/internal/handler/tenant_handler.go
+++ b/backend/internal/handler/tenant_handler.go
@@ -9,6 +9,7 @@ import (
"baron-sso-backend/internal/utils"
"bytes"
"context"
+ "encoding/base64"
"encoding/csv"
"encoding/json"
"errors"
@@ -66,6 +67,16 @@ func seedTenantSlugsForDeleteGuard() []string {
return result
}
+func tenantCreatorIDForMembership(profile *domain.UserProfileResponse) string {
+ if profile == nil {
+ return ""
+ }
+ if domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin {
+ return ""
+ }
+ return strings.TrimSpace(profile.ID)
+}
+
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService, sharedLink service.SharedLinkService, hydra *service.HydraAdminService, consentRepo repository.ClientConsentRepository) *TenantHandler {
return &TenantHandler{
DB: db,
@@ -125,6 +136,20 @@ type tenantListResponse struct {
NextCursor string `json:"nextCursor,omitempty"`
}
+type tenantSortDirection string
+
+const (
+ tenantSortAsc tenantSortDirection = "asc"
+ tenantSortDesc tenantSortDirection = "desc"
+)
+
+type tenantQueryCursor struct {
+ Sort string `json:"sort"`
+ Direction string `json:"direction"`
+ Value string `json:"value"`
+ ID string `json:"id"`
+}
+
type orgChartSnapshotCacheInfo struct {
Source string `json:"source"`
Hit bool `json:"hit"`
@@ -132,9 +157,10 @@ type orgChartSnapshotCacheInfo struct {
}
type orgChartSnapshotResponse struct {
- Tenants []tenantSummary `json:"tenants"`
- Users []userSummary `json:"users"`
- Cache orgChartSnapshotCacheInfo `json:"cache"`
+ Tenants []tenantSummary `json:"tenants"`
+ Users []userSummary `json:"users"`
+ GeneratedAt string `json:"generatedAt"`
+ Cache orgChartSnapshotCacheInfo `json:"cache"`
}
func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) ([]domain.Tenant, string, error) {
@@ -147,6 +173,189 @@ func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) (
})
}
+func normalizeTenantSortDirection(raw string) tenantSortDirection {
+ if strings.EqualFold(strings.TrimSpace(raw), string(tenantSortAsc)) {
+ return tenantSortAsc
+ }
+ return tenantSortDesc
+}
+
+func normalizeTenantSortKey(raw string) string {
+ switch strings.TrimSpace(raw) {
+ case "id", "name", "slug", "type", "status", "createdAt", "updatedAt":
+ return strings.TrimSpace(raw)
+ default:
+ return "createdAt"
+ }
+}
+
+func encodeTenantQueryCursor(sortKey string, direction tenantSortDirection, value string, id string) string {
+ if strings.TrimSpace(value) == "" || strings.TrimSpace(id) == "" {
+ return ""
+ }
+ payload, err := json.Marshal(tenantQueryCursor{
+ Sort: sortKey,
+ Direction: string(direction),
+ Value: value,
+ ID: id,
+ })
+ if err != nil {
+ return ""
+ }
+ return base64.RawURLEncoding.EncodeToString(payload)
+}
+
+func decodeTenantQueryCursor(raw string, sortKey string, direction tenantSortDirection) (*tenantQueryCursor, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return nil, nil
+ }
+ decoded, err := base64.RawURLEncoding.DecodeString(raw)
+ if err != nil {
+ return nil, err
+ }
+ var cursor tenantQueryCursor
+ if err := json.Unmarshal(decoded, &cursor); err != nil {
+ return nil, err
+ }
+ if strings.TrimSpace(cursor.ID) == "" || strings.TrimSpace(cursor.Value) == "" {
+ return nil, errors.New("invalid tenant cursor")
+ }
+ if cursor.Sort != sortKey || cursor.Direction != string(direction) {
+ return nil, errors.New("tenant cursor does not match sort")
+ }
+ return &cursor, nil
+}
+
+func tenantCursorValue(tenant domain.Tenant, sortKey string) string {
+ switch sortKey {
+ case "id":
+ return tenant.ID
+ case "name":
+ return strings.ToLower(tenant.Name)
+ case "slug":
+ return strings.ToLower(tenant.Slug)
+ case "type":
+ return strings.ToLower(tenant.Type)
+ case "status":
+ return strings.ToLower(tenant.Status)
+ case "updatedAt":
+ return tenant.UpdatedAt.UTC().Format(time.RFC3339Nano)
+ default:
+ return tenant.CreatedAt.UTC().Format(time.RFC3339Nano)
+ }
+}
+
+func filterTenantsByListSearch(tenants []domain.Tenant, search string) []domain.Tenant {
+ search = strings.ToLower(strings.TrimSpace(search))
+ if search == "" {
+ return tenants
+ }
+ filtered := make([]domain.Tenant, 0, len(tenants))
+ for _, tenant := range tenants {
+ if strings.Contains(strings.ToLower(tenant.ID), search) ||
+ strings.Contains(strings.ToLower(tenant.Name), search) ||
+ strings.Contains(strings.ToLower(tenant.Slug), search) ||
+ strings.Contains(strings.ToLower(tenant.Type), search) ||
+ strings.Contains(strings.ToLower(tenant.Description), search) {
+ filtered = append(filtered, tenant)
+ }
+ }
+ return filtered
+}
+
+func sortTenantsForList(tenants []domain.Tenant, sortKey string, direction tenantSortDirection) {
+ sort.SliceStable(tenants, func(i, j int) bool {
+ left := tenantCursorValue(tenants[i], sortKey)
+ right := tenantCursorValue(tenants[j], sortKey)
+ if left == right {
+ if direction == tenantSortAsc {
+ return tenants[i].ID < tenants[j].ID
+ }
+ return tenants[i].ID > tenants[j].ID
+ }
+ if direction == tenantSortAsc {
+ return left < right
+ }
+ return left > right
+ })
+}
+
+func pageSortedTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string, sortKey string, direction tenantSortDirection) ([]domain.Tenant, string, error) {
+ ordered := append([]domain.Tenant(nil), tenants...)
+ sortTenantsForList(ordered, sortKey, direction)
+
+ cursor, err := decodeTenantQueryCursor(cursorRaw, sortKey, direction)
+ if err != nil {
+ return nil, "", err
+ }
+ if cursor != nil {
+ filtered := ordered[:0]
+ for _, tenant := range ordered {
+ value := tenantCursorValue(tenant, sortKey)
+ after := value > cursor.Value || (value == cursor.Value && tenant.ID > cursor.ID)
+ if direction == tenantSortDesc {
+ after = value < cursor.Value || (value == cursor.Value && tenant.ID < cursor.ID)
+ }
+ if after {
+ filtered = append(filtered, tenant)
+ }
+ }
+ ordered = filtered
+ }
+
+ if len(ordered) <= limit {
+ return ordered, "", nil
+ }
+ page := ordered[:limit]
+ last := page[len(page)-1]
+ return page, encodeTenantQueryCursor(sortKey, direction, tenantCursorValue(last, sortKey), last.ID), nil
+}
+
+func tenantSortExpression(sortKey string) string {
+ switch sortKey {
+ case "id":
+ return "id::text"
+ case "name":
+ return "LOWER(name)"
+ case "slug":
+ return "LOWER(slug)"
+ case "type":
+ return "LOWER(type)"
+ case "status":
+ return "LOWER(status)"
+ case "updatedAt":
+ return "updated_at"
+ default:
+ return "created_at"
+ }
+}
+
+func tenantOrderClause(sortExpression string, direction tenantSortDirection) string {
+ if direction == tenantSortAsc {
+ return sortExpression + " asc, id asc"
+ }
+ return sortExpression + " desc, id desc"
+}
+
+func applyTenantQueryCursor(db *gorm.DB, sortExpression string, cursor *tenantQueryCursor, direction tenantSortDirection) *gorm.DB {
+ if cursor == nil {
+ return db
+ }
+ operator := "<"
+ idOperator := "<"
+ if direction == tenantSortAsc {
+ operator = ">"
+ idOperator = ">"
+ }
+ return db.Where(
+ fmt.Sprintf("%s %s ? OR (%s = ? AND id::text %s ?)", sortExpression, operator, sortExpression, idOperator),
+ cursor.Value,
+ cursor.Value,
+ cursor.ID,
+ )
+}
+
type tenantImportDetail struct {
Row int `json:"row"`
Slug string `json:"slug"`
@@ -289,6 +498,9 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
offset := c.QueryInt("offset", 0)
parentId := c.Query("parentId")
cursorRaw := strings.TrimSpace(c.Query("cursor"))
+ search := strings.TrimSpace(c.Query("search"))
+ sortKey := normalizeTenantSortKey(c.Query("sort"))
+ sortDirection := normalizeTenantSortDirection(c.Query("direction"))
if limit <= 0 {
limit = 50
@@ -298,6 +510,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
var tenants []domain.Tenant
+ var countSourceTenants []domain.Tenant
var total int64
var err error
nextCursor := ""
@@ -365,44 +578,55 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
+ tenants = filterTenantsByListSearch(tenants, search)
+ countSourceTenants = append([]domain.Tenant(nil), tenants...)
total = int64(len(tenants))
if cursorRaw != "" {
- tenants, nextCursor, err = pageTenantsByCursor(tenants, limit, cursorRaw)
+ tenants, nextCursor, err = pageSortedTenantsByCursor(tenants, limit, cursorRaw, sortKey, sortDirection)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else if offset < len(tenants) {
+ sortTenantsForList(tenants, sortKey, sortDirection)
end := min(offset+limit, len(tenants))
tenants = tenants[offset:end]
if total > int64(end) && len(tenants) > 0 {
last := tenants[len(tenants)-1]
- nextCursor = pagination.Encode(last.CreatedAt, last.ID)
+ nextCursor = encodeTenantQueryCursor(sortKey, sortDirection, tenantCursorValue(last, sortKey), last.ID)
}
} else {
tenants = []domain.Tenant{}
}
} else {
// Super Admin case
- if cursorRaw != "" && h.DB != nil {
- tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw, "")
+ if h.DB != nil {
+ tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, offset, parentId, cursorRaw, search, sortKey, sortDirection)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
- offset = 0
+ if cursorRaw != "" {
+ offset = 0
+ }
} else {
- tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, "")
+ tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId, search)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if total > int64(offset+len(tenants)) && len(tenants) > 0 {
last := tenants[len(tenants)-1]
- nextCursor = pagination.Encode(last.CreatedAt, last.ID)
+ nextCursor = encodeTenantQueryCursor(sortKey, sortDirection, tenantCursorValue(last, sortKey), last.ID)
}
}
}
- memberCounts, totalMemberCounts, err := h.countTenantMembers(c.Context(), tenants)
+ var memberCounts map[string]int64
+ var totalMemberCounts map[string]int64
+ if countSourceTenants != nil {
+ memberCounts, totalMemberCounts, err = h.countTenantMembersFromTenantSet(c.Context(), tenants, countSourceTenants)
+ } else {
+ memberCounts, totalMemberCounts, err = h.countTenantMembers(c.Context(), tenants)
+ }
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
@@ -425,8 +649,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
})
}
-func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string, search string) ([]domain.Tenant, int64, string, error) {
- cursor, err := pagination.Decode(cursorRaw)
+func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, offset int, parentID string, cursorRaw string, search string, sortKey string, direction tenantSortDirection) ([]domain.Tenant, int64, string, error) {
+ cursor, err := decodeTenantQueryCursor(cursorRaw, sortKey, direction)
if err != nil {
return nil, 0, "", err
}
@@ -440,8 +664,9 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
if search != "" {
searchTerm := "%" + strings.ToLower(search) + "%"
- countQuery = countQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
- pageQuery = pageQuery.Where("LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(description) LIKE ?", searchTerm, searchTerm, searchTerm)
+ searchClause := "LOWER(id::text) LIKE ? OR LOWER(name) LIKE ? OR LOWER(slug) LIKE ? OR LOWER(type) LIKE ? OR LOWER(description) LIKE ?"
+ countQuery = countQuery.Where(searchClause, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
+ pageQuery = pageQuery.Where(searchClause, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm)
}
var total int64
@@ -449,11 +674,16 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
return nil, 0, "", err
}
- pageQuery = pagination.ApplyCreatedAtIDCursor(pageQuery, cursor, "created_at", "id")
+ sortExpression := tenantSortExpression(sortKey)
+ if cursor != nil {
+ pageQuery = applyTenantQueryCursor(pageQuery, sortExpression, cursor, direction)
+ } else if offset > 0 {
+ pageQuery = pageQuery.Offset(offset)
+ }
var tenants []domain.Tenant
if err := pageQuery.
- Order("created_at desc, id desc").
+ Order(tenantOrderClause(sortExpression, direction)).
Limit(limit + 1).
Preload("Domains").
Find(&tenants).Error; err != nil {
@@ -464,7 +694,7 @@ func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, pare
if len(tenants) > limit {
tenants = tenants[:limit]
last := tenants[len(tenants)-1]
- nextCursor = pagination.Encode(last.CreatedAt, last.ID)
+ nextCursor = encodeTenantQueryCursor(sortKey, direction, tenantCursorValue(last, sortKey), last.ID)
}
return tenants, total, nextCursor, nil
}
@@ -609,8 +839,8 @@ func (h *TenantHandler) ImportTenantsCSV(c *fiber.Ctx) error {
records = orderTenantCSVRecordsByParentSlug(records)
creatorID := ""
- if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok && profile != nil {
- creatorID = profile.ID
+ if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
+ creatorID = tenantCreatorIDForMembership(profile)
}
tenantIDBySlug := make(map[string]string)
@@ -1861,10 +2091,9 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
parentID = &pid
}
- // Extract creator ID if present
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
- creatorID = profile.ID
+ creatorID = tenantCreatorIDForMembership(profile)
}
normalizedDomains := normalizeTenantDomainInputs(req.Domains)
@@ -2887,9 +3116,6 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
includeUserIDs := strings.EqualFold(strings.TrimSpace(c.Query("includeUserIds")), "true")
membersByTenantID := make(map[string][]orgContextMember)
if includeUsers {
- if h.UserRepo == nil {
- return errorJSON(c, fiber.StatusServiceUnavailable, "user repository is not configured")
- }
membersByTenantID, err = h.loadOrgContextMembers(c.Context(), tenantIDs, tenantSlugs, tenantByID, tenantBySlug, includeUserIDs)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
@@ -2919,32 +3145,22 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
}
func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, tenantSlugs []string, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) (map[string][]orgContextMember, error) {
- usersByID, err := h.UserRepo.FindByTenantIDs(ctx, tenantIDs)
- if err != nil {
- return nil, err
- }
- usersBySlug, err := h.UserRepo.FindByCompanyCodes(ctx, tenantSlugs)
- if err != nil {
- return nil, err
- }
- usersByAppointment, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", []string{}, "")
+ identities, err := h.listOrgContextIdentities(ctx, tenantIDs, tenantSlugs)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
membersByTenantID := make(map[string][]orgContextMember)
- users := append(usersByID, usersBySlug...)
- users = append(users, usersByAppointment...)
- for _, user := range users {
- if seen[user.ID] || !domain.IsOrgVisibleUserStatus(user.Status) {
+ for _, identity := range identities {
+ if seen[identity.ID] || !domain.IsOrgVisibleUserStatus(normalizeStatus(identity.State)) {
continue
}
- assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)
+ assignments := mapOrgContextIdentityAssignments(identity, tenantByID, tenantBySlug, includeUserIDs)
if len(assignments) == 0 {
continue
}
- seen[user.ID] = true
+ seen[identity.ID] = true
for _, assignment := range assignments {
membersByTenantID[assignment.TenantID] = append(membersByTenantID[assignment.TenantID], assignment.Member)
}
@@ -2952,6 +3168,55 @@ func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, te
return membersByTenantID, nil
}
+func (h *TenantHandler) listOrgContextIdentities(ctx context.Context, tenantIDs, tenantSlugs []string) ([]service.KratosIdentity, error) {
+ allowedTenantKeys := make(map[string]bool, len(tenantIDs)+len(tenantSlugs))
+ for _, value := range append(tenantIDs, tenantSlugs...) {
+ key := strings.ToLower(strings.TrimSpace(value))
+ if key != "" {
+ allowedTenantKeys[key] = true
+ }
+ }
+ query := service.IdentityMirrorPageQuery{
+ Limit: 10000,
+ AllowedTenantKeys: allowedTenantKeys,
+ }
+ if h != nil && h.IdentityCache != nil {
+ if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.orgContextIdentityMirrorReady(ctx) {
+ result, err := lister.ListIdentityMirrorPage(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ return result.Items, nil
+ }
+ }
+ if h == nil || h.KratosAdmin == nil {
+ return nil, errors.New("identity mirror is unavailable")
+ }
+ identities, err := h.KratosAdmin.ListIdentities(ctx)
+ if err != nil {
+ return nil, err
+ }
+ result, err := pageIdentityMirrorSlice(identities, query)
+ if err != nil {
+ return nil, err
+ }
+ return result.Items, nil
+}
+
+func (h *TenantHandler) orgContextIdentityMirrorReady(ctx context.Context) bool {
+ reader, ok := h.IdentityCache.(identityMirrorStatusReader)
+ if !ok {
+ return false
+ }
+ status, err := reader.GetIdentityCacheStatus(ctx)
+ if err != nil {
+ return false
+ }
+ return status.RedisReady &&
+ status.Status == "ready" &&
+ status.MirrorVersion == identityMirrorVersion
+}
+
func findOrgContextTenantBySlug(tenants []domain.Tenant, slug string) (domain.Tenant, bool) {
normalized := strings.ToLower(strings.TrimSpace(slug))
for _, tenant := range tenants {
@@ -3086,8 +3351,55 @@ func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug m
return assignments
}
+func mapOrgContextIdentityAssignments(identity service.KratosIdentity, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) []orgContextMemberAssignment {
+ assignments := make([]orgContextMemberAssignment, 0, 2)
+ seenTenants := map[string]bool{}
+ traits := identity.Traits
+ appointments := tenantClaimAppointmentsFromTraits(traits)
+
+ addTenant := func(tenant orgContextTenant, ok bool, appointment map[string]any) {
+ if !ok || seenTenants[tenant.ID] {
+ return
+ }
+ seenTenants[tenant.ID] = true
+ if appointment == nil {
+ appointment = lookupTenantClaimAppointment(appointments, tenant.ID, &domain.Tenant{
+ ID: tenant.ID,
+ Slug: tenant.Slug,
+ })
+ }
+ assignments = append(assignments, orgContextMemberAssignment{
+ TenantID: tenant.ID,
+ Member: mapOrgContextIdentityMember(identity, appointment, includeUserIDs),
+ })
+ }
+
+ for _, appointment := range appointments {
+ for _, key := range []string{"tenantId", "tenant_id"} {
+ if tenantID := tenantClaimString(appointment, key); tenantID != "" {
+ addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", appointment)
+ }
+ }
+ for _, key := range []string{"tenantSlug", "tenant_slug", "slug"} {
+ if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
+ tenant := tenantBySlug[strings.ToLower(tenantSlug)]
+ addTenant(tenant, tenant.ID != "", appointment)
+ }
+ }
+ }
+
+ if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
+ addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", nil)
+ }
+ if tenantSlug := extractTraitString(traits, "tenantSlug"); tenantSlug != "" {
+ tenant := tenantBySlug[strings.ToLower(tenantSlug)]
+ addTenant(tenant, tenant.ID != "", nil)
+ }
+ return assignments
+}
+
func mapOrgContextMember(user domain.User, appointment map[string]any, includeUserIDs bool) orgContextMember {
- grade := user.Grade
+ grade := ""
position := user.Position
jobTitle := user.JobTitle
department := user.Department
@@ -3139,6 +3451,60 @@ func mapOrgContextMember(user domain.User, appointment map[string]any, includeUs
}
}
+func mapOrgContextIdentityMember(identity service.KratosIdentity, appointment map[string]any, includeUserIDs bool) orgContextMember {
+ traits := identity.Traits
+ grade := ""
+ position := extractTraitString(traits, "position")
+ jobTitle := extractTraitString(traits, "jobTitle")
+ department := extractTraitString(traits, "department")
+ if value := tenantClaimString(appointment, "grade"); value != "" {
+ grade = value
+ }
+ if value := tenantClaimString(appointment, "position"); value != "" {
+ position = value
+ }
+ if value := tenantClaimString(appointment, "jobTitle"); value != "" {
+ jobTitle = value
+ }
+ if value := tenantClaimString(appointment, "job_title"); value != "" {
+ jobTitle = value
+ }
+ if value := tenantClaimString(appointment, "department"); value != "" {
+ department = value
+ }
+ isOwner := false
+ if value, ok := metadataBoolFromMap(appointment, "isOwner"); ok {
+ isOwner = value
+ }
+ isManager := false
+ if value, ok := metadataBoolFromMap(appointment, "isManager", "lead", "isLead"); ok {
+ isManager = value
+ }
+ isPrimary := false
+ if value, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok {
+ isPrimary = value
+ }
+ id := ""
+ phone := ""
+ if includeUserIDs {
+ id = identity.ID
+ phone = extractTraitString(traits, "phone_number")
+ }
+ return orgContextMember{
+ ID: id,
+ Email: extractTraitString(traits, "email"),
+ Name: extractTraitString(traits, "name"),
+ Phone: phone,
+ Department: department,
+ Grade: grade,
+ Position: position,
+ JobTitle: jobTitle,
+ IsOwner: isOwner,
+ IsManager: isManager,
+ IsPrimary: isPrimary,
+ }
+}
+
func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[string]orgContextTenant) *orgContextTreeNode {
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range tenants {
@@ -3170,6 +3536,24 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
}
func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
+ allTenants := tenants
+ if h.DB != nil {
+ var edges []domain.Tenant
+ if err := h.DB.WithContext(ctx).
+ Model(&domain.Tenant{}).
+ Select("id", "parent_id").
+ Find(&edges).Error; err == nil && len(edges) > 0 {
+ allTenants = edges
+ }
+ } else if h.Service != nil {
+ if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
+ allTenants = listed
+ }
+ }
+ return h.countTenantMembersFromTenantSet(ctx, tenants, allTenants)
+}
+
+func (h *TenantHandler) countTenantMembersFromTenantSet(ctx context.Context, tenants []domain.Tenant, allTenants []domain.Tenant) (map[string]int64, map[string]int64, error) {
counts := make(map[string]int64, len(tenants))
for _, tenant := range tenants {
counts[tenant.ID] = 0
@@ -3181,25 +3565,22 @@ func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain
return counts, counts, nil
}
- tenantIDs := make([]string, 0, len(tenants))
- for _, tenant := range tenants {
+ totalCounts := make(map[string]int64, len(tenants))
+ allTenantIDs := make([]string, 0, len(allTenants))
+ for _, tenant := range allTenants {
if strings.TrimSpace(tenant.ID) != "" {
- tenantIDs = append(tenantIDs, tenant.ID)
+ allTenantIDs = append(allTenantIDs, tenant.ID)
}
}
- directCounts, err := h.UserRepo.CountByTenantIDs(ctx, tenantIDs)
+ directCounts, err := h.UserRepo.CountByTenantIDs(ctx, allTenantIDs)
if err != nil {
return nil, nil, err
}
-
- totalCounts := make(map[string]int64, len(tenants))
- allTenants := tenants
- if h.Service != nil {
- if listed, _, listErr := h.Service.ListTenants(ctx, 10000, 0, "", ""); listErr == nil && len(listed) > 0 {
- allTenants = listed
- }
+ for _, tenant := range tenants {
+ counts[tenant.ID] = directCounts[tenant.ID]
}
+
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range allTenants {
if tenant.ParentID == nil || strings.TrimSpace(*tenant.ParentID) == "" {
@@ -3209,13 +3590,9 @@ func (h *TenantHandler) countTenantMembers(ctx context.Context, tenants []domain
}
for _, tenant := range tenants {
descendantIDs := collectTenantSubtreeIDs(tenant.ID, childrenByParentID)
- if len(descendantIDs) == 0 {
- totalCounts[tenant.ID] = directCounts[tenant.ID]
- continue
- }
- _, total, _, countErr := h.UserRepo.List(ctx, 0, 1, "", descendantIDs, "")
- if countErr != nil {
- return nil, nil, countErr
+ var total int64
+ for _, descendantID := range descendantIDs {
+ total += directCounts[descendantID]
}
totalCounts[tenant.ID] = total
}
@@ -3309,6 +3686,7 @@ func (h *TenantHandler) DeleteShareLink(c *fiber.Ctx) error {
func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
cacheMode := strings.ToLower(strings.TrimSpace(c.Query("cache")))
+ refreshRequested := parseBoolQuery(c.Query("refresh"))
cacheKey := orgChartSnapshotCacheKey(profile, c.Get("X-Tenant-ID"))
role, userID, profileTenantID := orgChartProfileLogValues(profile)
slog.Info("orgchart snapshot request started",
@@ -3317,12 +3695,16 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
"profile_tenant_id", profileTenantID,
"tenant_header", c.Get("X-Tenant-ID"),
"cache_mode", cacheMode,
+ "refresh", refreshRequested,
)
- if cacheMode == "redis" && h.OrgChartCache != nil {
+ if cacheMode == "redis" && h.OrgChartCache != nil && !refreshRequested {
if raw, err := h.OrgChartCache.Get(cacheKey); err == nil && strings.TrimSpace(raw) != "" {
var cached orgChartSnapshotResponse
if err := json.Unmarshal([]byte(raw), &cached); err == nil {
+ if strings.TrimSpace(cached.GeneratedAt) == "" {
+ cached.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
+ }
cached.Cache = orgChartSnapshotCacheInfo{
Source: "redis",
Hit: true,
@@ -3384,7 +3766,11 @@ func (h *TenantHandler) GetOrgChartSnapshot(c *fiber.Ctx) error {
)
}
}
- c.Set("X-Orgfront-Cache", "MISS")
+ if refreshRequested {
+ c.Set("X-Orgfront-Cache", "REFRESH")
+ } else {
+ c.Set("X-Orgfront-Cache", "MISS")
+ }
} else {
c.Set("X-Orgfront-Cache", "BYPASS")
}
@@ -3482,8 +3868,9 @@ func (h *TenantHandler) buildOrgChartSnapshot(ctx context.Context, profile *doma
}
return orgChartSnapshotResponse{
- Tenants: tenantSummaries,
- Users: users,
+ Tenants: tenantSummaries,
+ Users: users,
+ GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}, nil
}
@@ -3543,57 +3930,133 @@ func (h *TenantHandler) listOrgChartTenantsForProfile(ctx context.Context, profi
}
func (h *TenantHandler) listOrgChartUsers(ctx context.Context, profile *domain.UserProfileResponse, tenants []domain.Tenant) ([]userSummary, error) {
- if h.UserRepo == nil {
- return nil, errors.New("user repository is not configured")
- }
role := ""
if profile != nil {
role = domain.NormalizeRole(profile.Role)
}
tenantIDs := []string{}
+ tenantSlugs := []string{}
if role != domain.RoleSuperAdmin {
tenantIDs = make([]string, 0, len(tenants))
+ tenantSlugs = make([]string, 0, len(tenants))
for _, tenant := range tenants {
tenantIDs = append(tenantIDs, tenant.ID)
+ tenantSlugs = append(tenantSlugs, tenant.Slug)
}
}
- users, _, _, err := h.UserRepo.List(ctx, 0, 10000, "", tenantIDs, "")
+ identities, err := h.listOrgContextIdentities(ctx, tenantIDs, tenantSlugs)
if err != nil {
return nil, err
}
- summaries := make([]userSummary, 0, len(users))
- for _, user := range users {
- summary := userSummary{
- ID: user.ID,
- Email: user.Email,
- LoginID: user.Email,
- Name: user.Name,
- Phone: user.Phone,
- Role: domain.NormalizeRole(user.Role),
- Status: normalizeStatus(user.Status),
- TenantSlug: userTenantSlug(user),
- CompanyCode: userTenantSlug(user),
- Metadata: user.Metadata,
- Tenant: user.Tenant,
- Department: user.Department,
- Grade: user.Grade,
- Position: user.Position,
- JobTitle: user.JobTitle,
- CreatedAt: formatTime(user.CreatedAt),
- UpdatedAt: formatTime(user.UpdatedAt),
- }
- if h.Service != nil {
- if joined, err := h.Service.ListJoinedTenants(ctx, user.ID); err == nil {
- summary.JoinedTenants = joined
- }
- }
- summaries = append(summaries, summary)
+ tenantByID := make(map[string]domain.Tenant, len(tenants))
+ tenantBySlug := make(map[string]domain.Tenant, len(tenants))
+ for _, tenant := range tenants {
+ tenantByID[tenant.ID] = tenant
+ tenantBySlug[strings.ToLower(tenant.Slug)] = tenant
+ }
+
+ summaries := make([]userSummary, 0, len(identities))
+ for _, identity := range identities {
+ summaries = append(summaries, mapOrgChartIdentitySummary(identity, tenantByID, tenantBySlug))
}
return summaries, nil
}
+func mapOrgChartIdentitySummary(identity service.KratosIdentity, tenantByID, tenantBySlug map[string]domain.Tenant) userSummary {
+ traits := identity.Traits
+ tenantID := extractTraitString(traits, "tenant_id")
+ tenantSlug := extractTraitString(traits, "tenantSlug")
+ var tenantSummary *domain.Tenant
+ if tenantID != "" {
+ if tenant, ok := tenantByID[tenantID]; ok {
+ tenantCopy := tenant
+ tenantSummary = &tenantCopy
+ if tenantSlug == "" {
+ tenantSlug = tenant.Slug
+ }
+ }
+ }
+ if tenantSummary == nil && tenantSlug != "" {
+ if tenant, ok := tenantBySlug[strings.ToLower(tenantSlug)]; ok {
+ tenantCopy := tenant
+ tenantSummary = &tenantCopy
+ if tenantID == "" {
+ tenantID = tenant.ID
+ }
+ }
+ }
+
+ metadata := make(domain.JSONMap)
+ coreTraits := map[string]bool{
+ "email": true, "name": true, "phone_number": true,
+ "grade": true, "companyCode": true, "company_code": true, "companyCodes": true, "department": true,
+ "position": true, "jobTitle": true,
+ "affiliationType": true, "role": true, "tenant_id": true, "tenantSlug": true,
+ "custom_login_ids": true, "id": true,
+ }
+ for key, value := range traits {
+ if !coreTraits[key] {
+ metadata[key] = value
+ }
+ }
+
+ return userSummary{
+ ID: identity.ID,
+ Email: extractTraitString(traits, "email"),
+ LoginID: resolvePasswordLoginID(traits),
+ Name: extractTraitString(traits, "name"),
+ Phone: extractTraitString(traits, "phone_number"),
+ Role: roleFromTraits(traits),
+ Status: normalizeStatus(identity.State),
+ TenantSlug: tenantSlug,
+ CompanyCode: tenantSlug,
+ Metadata: metadata,
+ Tenant: tenantSummary,
+ JoinedTenants: orgChartIdentityJoinedTenants(traits, tenantByID, tenantBySlug),
+ Department: extractTraitString(traits, "department"),
+ Grade: gradeFromTraits(traits),
+ Position: extractTraitString(traits, "position"),
+ JobTitle: extractTraitString(traits, "jobTitle"),
+ CreatedAt: formatTime(identity.CreatedAt),
+ UpdatedAt: formatTime(identity.UpdatedAt),
+ }
+}
+
+func orgChartIdentityJoinedTenants(traits map[string]any, tenantByID, tenantBySlug map[string]domain.Tenant) []domain.Tenant {
+ seen := make(map[string]bool)
+ joined := make([]domain.Tenant, 0, 2)
+ addTenant := func(tenant domain.Tenant, ok bool) {
+ if !ok || strings.TrimSpace(tenant.ID) == "" || seen[tenant.ID] {
+ return
+ }
+ seen[tenant.ID] = true
+ joined = append(joined, tenant)
+ }
+ if tenantID := extractTraitString(traits, "tenant_id"); tenantID != "" {
+ addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "")
+ }
+ if tenantSlug := extractTraitString(traits, "tenantSlug"); tenantSlug != "" {
+ tenant := tenantBySlug[strings.ToLower(tenantSlug)]
+ addTenant(tenant, tenant.ID != "")
+ }
+ for _, appointment := range tenantClaimAppointmentsFromTraits(traits) {
+ for _, key := range []string{"tenantId", "tenant_id"} {
+ if tenantID := tenantClaimString(appointment, key); tenantID != "" {
+ addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "")
+ }
+ }
+ for _, key := range []string{"tenantSlug", "tenant_slug", "slug"} {
+ if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
+ tenant := tenantBySlug[strings.ToLower(tenantSlug)]
+ addTenant(tenant, tenant.ID != "")
+ }
+ }
+ }
+ return joined
+}
+
func orgChartSnapshotCacheKey(profile *domain.UserProfileResponse, tenantHeader string) string {
role := "anonymous"
userID := "anonymous"
@@ -3651,6 +4114,15 @@ func orgChartSnapshotCacheExpiration() time.Duration {
return 0
}
+func parseBoolQuery(value string) bool {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "1", "true", "yes", "on":
+ return true
+ default:
+ return false
+ }
+}
+
func (h *TenantHandler) GetPublicOrgChart(c *fiber.Ctx) error {
token := c.Query("token")
if token == "" {
diff --git a/backend/internal/handler/tenant_handler_test.go b/backend/internal/handler/tenant_handler_test.go
index 9bdd5413..88d84ff5 100644
--- a/backend/internal/handler/tenant_handler_test.go
+++ b/backend/internal/handler/tenant_handler_test.go
@@ -106,6 +106,7 @@ func (m *MockTenantService) ProvisionTenantByDomain(ctx context.Context, domainN
type MockUserRepoForHandler struct {
mock.Mock
deletedIDs []string
+ listCalls int
}
func (m *MockUserRepoForHandler) DB() *gorm.DB {
@@ -145,6 +146,7 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
}
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantIDs []string, cursor string) ([]domain.User, int64, string, error) {
+ m.listCalls += 1
for _, call := range m.ExpectedCalls {
if call.Method == "List" {
args := m.Called(ctx, offset, limit, search, tenantIDs, cursor)
@@ -240,6 +242,53 @@ func toJSONString(t *testing.T, value any) string {
return string(raw)
}
+func newReadyIdentityMirror(t *testing.T, now time.Time, identities ...service.KratosIdentity) *identityMirrorRedisStub {
+ t.Helper()
+ data := make(map[string]string, len(identities)+1)
+ for _, identity := range identities {
+ raw, err := json.Marshal(identity)
+ require.NoError(t, err)
+ data[identityMirrorKey(identity.ID)] = string(raw)
+ }
+ rawStatus, err := json.Marshal(domain.IdentityCacheStatus{
+ RedisReady: true,
+ Status: "ready",
+ ObservedCount: int64(len(identities)),
+ MirrorVersion: identityMirrorVersion,
+ LastRefreshedAt: &now,
+ })
+ require.NoError(t, err)
+ data["identity:mirror:state"] = string(rawStatus)
+ return &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: data}}
+}
+
+func orgContextIdentityFromUserFixture(user domain.User) service.KratosIdentity {
+ traits := map[string]any{
+ "email": user.Email,
+ "name": user.Name,
+ "phone_number": user.Phone,
+ "department": user.Department,
+ "position": user.Position,
+ "jobTitle": user.JobTitle,
+ }
+ if user.TenantID != nil {
+ traits["tenant_id"] = *user.TenantID
+ }
+ if strings.TrimSpace(user.CompanyCode) != "" {
+ traits["tenantSlug"] = strings.TrimSpace(user.CompanyCode)
+ }
+ for key, value := range user.Metadata {
+ traits[key] = value
+ }
+ return service.KratosIdentity{
+ ID: user.ID,
+ State: user.Status,
+ Traits: traits,
+ CreatedAt: user.CreatedAt,
+ UpdatedAt: user.UpdatedAt,
+ }
+}
+
func TestTenantHandler_CreateTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -268,6 +317,43 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
assert.Equal(t, "t1", got["id"])
}
+func TestTenantHandler_CreateTenantDoesNotAssignSuperAdminAsCreatorMember(t *testing.T) {
+ app := fiber.New()
+ mockSvc := new(MockTenantService)
+ h := &TenantHandler{Service: mockSvc, DB: &gorm.DB{}}
+
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id", Role: domain.RoleSuperAdmin})
+ return c.Next()
+ })
+ app.Post("/tenants", h.CreateTenant)
+
+ input := map[string]any{
+ "name": "System Created Tenant",
+ "slug": "system-created-tenant",
+ }
+ body, _ := json.Marshal(input)
+
+ mockSvc.On(
+ "RegisterTenant",
+ mock.Anything,
+ "System Created Tenant",
+ "system-created-tenant",
+ domain.TenantTypeCompany,
+ "",
+ []string(nil),
+ (*string)(nil),
+ "",
+ ).Return(&domain.Tenant{ID: "system-created-id", Name: "System Created Tenant", Slug: "system-created-tenant"}, nil).Once()
+
+ req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ resp, _ := app.Test(req)
+
+ assert.Equal(t, http.StatusCreated, resp.StatusCode)
+ mockSvc.AssertExpectations(t)
+}
+
func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -293,8 +379,6 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 2}, nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
- Return([]domain.User{}, int64(7), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -306,7 +390,60 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCounts(t *testing.T) {
require.Len(t, res.Items, 1)
assert.Equal(t, int64(2), res.Items[0].MemberCount)
- assert.Equal(t, int64(7), res.Items[0].TotalMemberCount)
+ assert.Equal(t, int64(2), res.Items[0].TotalMemberCount)
+ mockUsers.AssertExpectations(t)
+}
+
+func TestTenantHandler_ListTenantsPassesSearchToBackendQuery(t *testing.T) {
+ app := fiber.New()
+ mockSvc := new(MockTenantService)
+ mockUsers := new(MockUserRepoForHandler)
+ h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
+
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{Role: "super_admin"})
+ return c.Next()
+ })
+ app.Get("/tenants", h.ListTenants)
+
+ tenants := []domain.Tenant{{ID: "tenant-1", Name: "Saman", Slug: "saman"}}
+ mockSvc.On("ListTenants", mock.Anything, 25, 0, "", "saman").Return(tenants, int64(1), nil).Once()
+ mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
+ mockUsers.On("CountByTenantIDs", mock.Anything, []string{"tenant-1"}).
+ Return(map[string]int64{"tenant-1": 1}, nil).Once()
+
+ req := httptest.NewRequest(http.MethodGet, "/tenants?limit=25&search=saman", nil)
+ resp, err := app.Test(req)
+
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ mockSvc.AssertExpectations(t)
+}
+
+func TestTenantHandler_CountTenantMembersDoesNotListUsersPerTenant(t *testing.T) {
+ mockSvc := new(MockTenantService)
+ mockUsers := new(MockUserRepoForHandler)
+ h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
+
+ parentID := "parent-tenant"
+ childID := "child-tenant"
+ tenants := []domain.Tenant{
+ {ID: parentID, Name: "Parent", Slug: "parent"},
+ {ID: childID, Name: "Child", Slug: "child", ParentID: &parentID},
+ }
+ mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Once()
+ mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
+ Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
+
+ memberCounts, totalMemberCounts, err := h.countTenantMembers(context.Background(), tenants)
+
+ require.NoError(t, err)
+ require.Equal(t, int64(1), memberCounts[parentID])
+ require.Equal(t, int64(3), totalMemberCounts[parentID])
+ require.Equal(t, int64(2), memberCounts[childID])
+ require.Equal(t, int64(2), totalMemberCounts[childID])
+ require.Zero(t, mockUsers.listCalls)
+ mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
@@ -358,13 +495,9 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
return strings.HasPrefix(key, "orgchart:snapshot:")
}), mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Twice()
- mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1]}, nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID}).Return(map[string]int64{familyID: 0, samanID: 1}, nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return(users, int64(1), "", nil).Once()
- h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
+ h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
return c.Next()
@@ -378,18 +511,68 @@ func TestTenantHandler_GetOrgChartSnapshotCachesMissResult(t *testing.T) {
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "MISS", resp.Header.Get("X-Orgfront-Cache"))
var body struct {
- Tenants []tenantSummary `json:"tenants"`
- Users []userSummary `json:"users"`
+ Tenants []tenantSummary `json:"tenants"`
+ Users []userSummary `json:"users"`
+ GeneratedAt string `json:"generatedAt"`
}
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
require.Len(t, body.Tenants, 2)
require.Len(t, body.Users, 1)
+ require.NotEmpty(t, body.GeneratedAt)
require.Equal(t, int64(1), body.Tenants[0].TotalMemberCount)
cache.AssertExpectations(t)
mockSvc.AssertExpectations(t)
mockUsers.AssertExpectations(t)
}
+func TestTenantHandler_GetOrgChartSnapshotRefreshBypassesRedisHitAndUpdatesCache(t *testing.T) {
+ app := fiber.New()
+ mockSvc := new(MockTenantService)
+ mockUsers := new(MockUserRepoForHandler)
+ cache := &mockOrgChartCache{}
+ now := time.Date(2026, 6, 17, 0, 0, 0, 0, time.UTC)
+ familyID := "family"
+ tenants := []domain.Tenant{
+ {ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
+ }
+ users := []domain.User{
+ {ID: "fresh-user", Email: "fresh@example.com", Name: "Fresh User", Role: domain.RoleUser, Status: "active", TenantID: &familyID, Tenant: &tenants[0], CreatedAt: now, UpdatedAt: now},
+ }
+
+ cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.MatchedBy(func(raw string) bool {
+ return strings.Contains(raw, "fresh-user")
+ }), time.Duration(0)).Return(nil).Once()
+ mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
+ mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 1}, nil).Once()
+
+ h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))}
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
+ return c.Next()
+ })
+ app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
+
+ req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot?cache=redis&refresh=true", nil)
+ resp, err := app.Test(req)
+
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.Equal(t, "REFRESH", resp.Header.Get("X-Orgfront-Cache"))
+ var body struct {
+ Users []userSummary `json:"users"`
+ Cache orgChartSnapshotCacheInfo `json:"cache"`
+ }
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
+ require.Len(t, body.Users, 1)
+ require.Equal(t, "fresh-user", body.Users[0].ID)
+ require.Equal(t, "database", body.Cache.Source)
+ require.False(t, body.Cache.Hit)
+ cache.AssertNotCalled(t, "Get", mock.Anything)
+ cache.AssertExpectations(t)
+ mockSvc.AssertExpectations(t)
+ mockUsers.AssertExpectations(t)
+}
+
func TestOrgChartSnapshotCacheKeySharesSuperAdminGlobalSnapshot(t *testing.T) {
first := orgChartSnapshotCacheKey(&domain.UserProfileResponse{
ID: "super-admin-1",
@@ -435,10 +618,8 @@ func TestTenantHandler_WarmOrgChartSnapshotCacheStoresSuperAdminGlobalSnapshot(t
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
- h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
+ h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now)}
require.NoError(t, h.WarmOrgChartSnapshotCache(context.Background()))
raw := cache.values["orgchart:snapshot:v1:super_admin:all:none"]
@@ -469,10 +650,8 @@ func TestTenantHandler_RefreshOrgChartSnapshotCacheAfterTenantChangeInvalidatesA
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID}).Return(map[string]int64{familyID: 0}, nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
- h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache}
+ h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, OrgChartCache: cache, IdentityCache: newReadyIdentityMirror(t, now)}
h.refreshOrgChartSnapshotCacheAfterTenantChange(context.Background(), "tenant_created")
@@ -515,11 +694,7 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, samanID, teamID}).Return(map[string]int64{familyID: 0, samanID: 1, teamID: 0}, nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{samanID, teamID}, "").Return([]domain.User{}, int64(1), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{teamID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 10000, "", []string{familyID, samanID, teamID}, "").Return(users, int64(1), "", nil).Once()
- mockSvc.On("ListJoinedTenants", mock.Anything, "user-1").Return([]domain.Tenant{tenants[1], tenants[2]}, nil).Once()
+ h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot", nil)
resp, err := app.Test(req, 1000)
@@ -538,6 +713,66 @@ func TestTenantHandler_GetOrgChartSnapshotHandlesSelfParentHanmacFamily(t *testi
mockUsers.AssertExpectations(t)
}
+func TestTenantHandler_GetOrgChartSnapshotUsesIdentityMirrorWithoutLocalUsersDB(t *testing.T) {
+ app := fiber.New()
+ mockSvc := new(MockTenantService)
+ mockUsers := new(MockUserRepoForHandler)
+ now := time.Date(2026, 6, 17, 13, 0, 0, 0, time.UTC)
+ parent := func(id string) *string { return &id }
+ familyID := "hanmac-family-id"
+ companyID := "hanmac-company-id"
+ tenants := []domain.Tenant{
+ {ID: familyID, Type: domain.TenantTypeCompanyGroup, ParentID: parent(familyID), Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
+ {ID: companyID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
+ }
+ identity := service.KratosIdentity{
+ ID: "identity-orgchart-user",
+ State: domain.UserStatusActive,
+ Traits: map[string]any{
+ "email": "orgchart-mirror@example.com",
+ "name": "OrgChart Mirror",
+ "phone_number": "010-2222-3333",
+ "tenant_id": companyID,
+ "tenantSlug": "hanmac",
+ "position": "팀장",
+ "jobTitle": "Mirror Source",
+ },
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ identityCache := newReadyIdentityMirror(t, now, identity)
+ h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, IdentityCache: identityCache}
+
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "super", Role: domain.RoleSuperAdmin})
+ return c.Next()
+ })
+ app.Get("/admin/orgchart/snapshot", h.GetOrgChartSnapshot)
+
+ mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
+ mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, companyID}).Return(map[string]int64{familyID: 0, companyID: 1}, nil).Once()
+
+ req := httptest.NewRequest(http.MethodGet, "/admin/orgchart/snapshot", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var body struct {
+ Users []userSummary `json:"users"`
+ }
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
+ require.Len(t, body.Users, 1)
+ require.Equal(t, "identity-orgchart-user", body.Users[0].ID)
+ require.Equal(t, "orgchart-mirror@example.com", body.Users[0].Email)
+ require.Equal(t, "hanmac", body.Users[0].TenantSlug)
+ require.Equal(t, "Mirror Source", body.Users[0].JobTitle)
+ require.Equal(t, 1, identityCache.pageCalls)
+ require.Equal(t, 0, identityCache.fullCalls)
+ mockUsers.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
+ mockSvc.AssertExpectations(t)
+ mockUsers.AssertExpectations(t)
+}
+
func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -566,10 +801,6 @@ func TestTenantHandler_ListTenantsReturnsTotalMemberCountForDescendants(t *testi
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(2), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{parentID, childID}).
Return(map[string]int64{parentID: 1, childID: 2}, nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{parentID, childID}, "").
- Return([]domain.User{}, int64(3), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{childID}, "").
- Return([]domain.User{}, int64(2), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -612,8 +843,6 @@ func TestTenantHandler_ListTenants(t *testing.T) {
mockSvc.On("ListTenants", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(tenants, int64(2), nil).Maybe()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"t1", "t2"}).
Return(map[string]int64{"t1": 5, "t2": 10}, nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t1"}, "").Return([]domain.User{}, int64(5), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{"t2"}, "").Return([]domain.User{}, int64(10), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -663,8 +892,6 @@ func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.
mockSvc.On("ListTenants", mock.Anything, 2, 0, "", "").Return(tenants, int64(3), nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(3), nil).Once()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000002", "00000000-0000-0000-0000-000000000001"}).Return(map[string]int64{}, nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000002"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").Return([]domain.User{}, int64(0), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
resp, _ := app.Test(req)
@@ -1058,6 +1285,14 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
UpdatedAt: now,
},
}
+ identityFixtures := make([]service.KratosIdentity, 0, len(usersByTenantID)+len(usersBySlug)+len(usersByList))
+ for _, user := range usersByTenantID {
+ identityFixtures = append(identityFixtures, orgContextIdentityFromUserFixture(user))
+ }
+ for _, user := range usersByList {
+ identityFixtures = append(identityFixtures, orgContextIdentityFromUserFixture(user))
+ }
+ h.IdentityCache = newReadyIdentityMirror(t, now, identityFixtures...)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil)
@@ -1088,7 +1323,15 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
deptPlatform := tenantsPayload[2].(map[string]any)
platformMembers := deptPlatform["members"].([]any)
require.Len(t, platformMembers, 3)
- firstUser := platformMembers[0].(map[string]any)
+ var firstUser map[string]any
+ for _, item := range platformMembers {
+ member := item.(map[string]any)
+ if member["email"] == "lead@example.com" {
+ firstUser = member
+ break
+ }
+ }
+ require.NotNil(t, firstUser)
require.NotContains(t, firstUser, "id")
require.NotContains(t, firstUser, "phone")
require.NotContains(t, firstUser, "tenantIds")
@@ -1132,6 +1375,83 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
require.NotContains(t, toJSONString(t, got), "extended-leave@example.com")
}
+func TestTenantHandler_GetOrgContextJSONUsesIdentityMirrorWithoutLocalUsersDB(t *testing.T) {
+ app := fiber.New()
+ mockSvc := new(MockTenantService)
+ mockUsers := new(MockUserRepoForHandler)
+ now := time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC)
+ familyID := "group-hanmac-family"
+ companyID := "company-hanmac"
+ deptID := "dept-platform"
+ parent := func(id string) *string { return &id }
+ tenants := []domain.Tenant{
+ {ID: familyID, Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
+ {ID: companyID, Type: domain.TenantTypeCompany, ParentID: parent(familyID), Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
+ {ID: deptID, Type: domain.TenantTypeUserGroup, ParentID: parent(companyID), Name: "플랫폼실", Slug: "platform", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
+ }
+ identity := service.KratosIdentity{
+ ID: "identity-platform-lead",
+ State: domain.UserStatusActive,
+ Traits: map[string]any{
+ "email": "mirror-lead@example.com",
+ "name": "Mirror Lead",
+ "phone_number": "010-0000-0000",
+ "tenant_id": companyID,
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": deptID,
+ "tenantSlug": "platform",
+ "isPrimary": true,
+ "isOwner": true,
+ "position": "실장",
+ "jobTitle": "SSOT Lead",
+ },
+ },
+ },
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ rawIdentity, err := json.Marshal(identity)
+ require.NoError(t, err)
+ rawStatus, err := json.Marshal(domain.IdentityCacheStatus{
+ RedisReady: true,
+ Status: "ready",
+ ObservedCount: 1,
+ MirrorVersion: identityMirrorVersion,
+ LastRefreshedAt: &now,
+ })
+ require.NoError(t, err)
+ identityCache := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
+ identityMirrorKey(identity.ID): string(rawIdentity),
+ "identity:mirror:state": string(rawStatus),
+ }}}
+ h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers, IdentityCache: identityCache}
+
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("apiKeyName", "orgfront-ssot-client")
+ return c.Next()
+ })
+ app.Get("/org-context", h.GetOrgContext)
+
+ mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var got map[string]any
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
+ require.Contains(t, toJSONString(t, got), "mirror-lead@example.com")
+ require.Contains(t, toJSONString(t, got), "SSOT Lead")
+ require.Equal(t, 1, identityCache.pageCalls)
+ require.Equal(t, 0, identityCache.fullCalls)
+ mockUsers.AssertNotCalled(t, "FindByTenantIDs", mock.Anything, mock.Anything)
+ mockUsers.AssertNotCalled(t, "FindByCompanyCodes", mock.Anything, mock.Anything)
+ mockUsers.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
+ mockSvc.AssertExpectations(t)
+}
+
func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -1152,6 +1472,7 @@ func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *test
users := []domain.User{
{ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now},
}
+ h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil)
@@ -1182,6 +1503,63 @@ func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *test
require.NotContains(t, tree, "directUserIds")
}
+func TestTenantHandler_GetOrgContextJSONDoesNotFallbackToUserGradeForTenantMember(t *testing.T) {
+ app := fiber.New()
+ mockSvc := new(MockTenantService)
+ mockUsers := new(MockUserRepoForHandler)
+ h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
+
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("apiKeyName", "orgfront-ssot-client")
+ return c.Next()
+ })
+ app.Get("/org-context", h.GetOrgContext)
+
+ now := time.Date(2026, 6, 17, 9, 0, 0, 0, time.UTC)
+ tenantID := "company-hanmac"
+ tenants := []domain.Tenant{
+ {ID: tenantID, Type: domain.TenantTypeCompany, Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
+ }
+ users := []domain.User{
+ {
+ ID: "user-grade-only",
+ Email: "grade-only@example.com",
+ Name: "직급 단독",
+ Status: domain.UserStatusActive,
+ TenantID: &tenantID,
+ Grade: "책임",
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": tenantID,
+ "tenantSlug": "hanmac",
+ },
+ },
+ },
+ CreatedAt: now,
+ UpdatedAt: now,
+ },
+ }
+ h.IdentityCache = newReadyIdentityMirror(t, now, orgContextIdentityFromUserFixture(users[0]))
+
+ mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil)
+ mockUsers.On("FindByTenantIDs", mock.Anything, []string{tenantID}).Return(users, nil)
+ mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil)
+
+ req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac", nil)
+ resp, err := app.Test(req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+
+ var got map[string]any
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
+ tenantsPayload := got["tenants"].([]any)
+ members := tenantsPayload[0].(map[string]any)["members"].([]any)
+ require.Len(t, members, 1)
+ member := members[0].(map[string]any)
+ require.NotContains(t, member, "grade")
+}
+
func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -1206,7 +1584,7 @@ func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac", "dept-platform"}).Return([]domain.User{}, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac", "platform"}).Return([]domain.User{}, nil)
- req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac", nil)
+ req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac&includeUsers=false", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
@@ -1258,8 +1636,6 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testi
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(1), nil).Once()
mockUserRepo.On("CountByTenantIDs", mock.Anything, []string{"00000000-0000-0000-0000-000000000001"}).
Return(map[string]int64{"00000000-0000-0000-0000-000000000001": 152}, nil).Once()
- mockUserRepo.On("List", mock.Anything, 0, 1, "", []string{"00000000-0000-0000-0000-000000000001"}, "").
- Return([]domain.User{}, int64(152), "", nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
resp, _ := app.Test(req)
@@ -1271,6 +1647,7 @@ func TestTenantHandler_ListTenantsUsesUserRepositoryCountsWhenAvailable(t *testi
assert.Len(t, res.Items, 1)
assert.Equal(t, int64(152), res.Items[0].MemberCount)
+ assert.Equal(t, int64(152), res.Items[0].TotalMemberCount)
mockUserRepo.AssertExpectations(t)
}
@@ -1622,6 +1999,46 @@ func TestTenantHandler_ImportTenantsCSVDoesNotAssignCreatorAsOrganizationMember(
mockSvc.AssertExpectations(t)
}
+func TestTenantHandler_ImportTenantsCSVDoesNotAssignSuperAdminAsCompanyMember(t *testing.T) {
+ app := fiber.New()
+ mockSvc := new(MockTenantService)
+ h := &TenantHandler{Service: mockSvc}
+
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{ID: "system-admin-id", Role: domain.RoleSuperAdmin})
+ return c.Next()
+ })
+ app.Post("/tenants/import", h.ImportTenantsCSV)
+
+ var body bytes.Buffer
+ writer := multipart.NewWriter(&body)
+ part, err := writer.CreateFormFile("file", "tenants.csv")
+ assert.NoError(t, err)
+ _, err = part.Write([]byte("name,type,parent_tenant_id,slug,memo,email_domain\nImported Company,COMPANY,,imported-company,,\n"))
+ assert.NoError(t, err)
+ assert.NoError(t, writer.Close())
+
+ mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return([]domain.Tenant{}, int64(0), nil).Once()
+ mockSvc.On(
+ "RegisterTenant",
+ mock.Anything,
+ "Imported Company",
+ "imported-company",
+ domain.TenantTypeCompany,
+ "",
+ []string{},
+ (*string)(nil),
+ "",
+ ).Return(&domain.Tenant{ID: "imported-company-id", Name: "Imported Company", Slug: "imported-company"}, nil).Once()
+
+ req := httptest.NewRequest("POST", "/tenants/import", &body)
+ req.Header.Set("Content-Type", writer.FormDataContentType())
+ resp, _ := app.Test(req)
+
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ mockSvc.AssertExpectations(t)
+}
+
func TestNormalizeTenantTypeAllowsOrganization(t *testing.T) {
assert.Equal(t, domain.TenantTypeOrganization, normalizeTenantType("organization"))
}
@@ -1801,9 +2218,6 @@ func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T)
mockSvc.On("ApproveTenant", mock.Anything, tenantID).Return(nil).Once()
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "", "").Return(tenants, int64(len(tenants)), nil).Twice()
mockUsers.On("CountByTenantIDs", mock.Anything, []string{familyID, tenantID}).Return(map[string]int64{familyID: 0, tenantID: 0}, nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{familyID, tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 1, "", []string{tenantID}, "").Return([]domain.User{}, int64(0), "", nil).Once()
- mockUsers.On("List", mock.Anything, 0, 10000, "", []string{}, "").Return([]domain.User{}, int64(0), "", nil).Once()
cache.On("DeleteByPrefix", "orgchart:snapshot:v1:").Return(int64(1), nil).Once()
cache.On("Set", "orgchart:snapshot:v1:super_admin:all:none", mock.Anything, time.Duration(0)).Return(nil).Once()
@@ -1811,6 +2225,7 @@ func TestTenantHandler_ApproveTenantRefreshesOrgChartSnapshotCache(t *testing.T)
Service: mockSvc,
UserRepo: mockUsers,
OrgChartCache: cache,
+ IdentityCache: newReadyIdentityMirror(t, now),
}
app.Post("/tenants/:id/approve", h.ApproveTenant)
diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go
index af7f165b..6059f095 100644
--- a/backend/internal/handler/user_handler.go
+++ b/backend/internal/handler/user_handler.go
@@ -95,11 +95,41 @@ func sanitizeUserMetadata(metadata map[string]any) map[string]any {
if key == "hanmacFamily" || key == "userType" {
continue
}
+ if key == "additionalAppointments" {
+ sanitized[key] = normalizeUserAppointmentGrades(value)
+ continue
+ }
sanitized[key] = value
}
return sanitized
}
+func normalizeUserAppointmentGrades(raw any) []any {
+ appointments := userAppointmentSliceFromRaw(raw)
+ for i, item := range appointments {
+ appointment, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ if grade, ok := appointment["grade"].(string); ok {
+ appointment["grade"] = normalizeInternalGradeName(grade)
+ }
+ appointments[i] = appointment
+ }
+ return appointments
+}
+
+func normalizeInternalGradeName(grade string) string {
+ switch strings.ReplaceAll(strings.TrimSpace(grade), " ", "") {
+ case "상무":
+ return "상무이사"
+ case "전무":
+ return "전무이사"
+ default:
+ return strings.TrimSpace(grade)
+ }
+}
+
func userAppointmentSliceFromRaw(raw any) []any {
switch values := raw.(type) {
case []any:
@@ -142,6 +172,144 @@ func userAppointmentTenantKey(raw any) string {
return ""
}
+func userAppointmentMatchesTenant(appointment map[string]any, tenantID string, tenantSlug string) bool {
+ targetID := strings.ToLower(strings.TrimSpace(tenantID))
+ targetSlug := strings.ToLower(strings.TrimSpace(tenantSlug))
+ appointmentID := strings.ToLower(normalizeMetadataString(appointment["tenantId"]))
+ appointmentSlug := strings.ToLower(normalizeMetadataString(appointment["tenantSlug"]))
+ if appointmentSlug == "" {
+ appointmentSlug = strings.ToLower(normalizeMetadataString(appointment["slug"]))
+ }
+ return (targetID != "" && appointmentID == targetID) ||
+ (targetSlug != "" && appointmentSlug == targetSlug)
+}
+
+func tenantBoundGradeFromTraits(traits map[string]any) string {
+ tenantID := extractTraitString(traits, "tenant_id")
+ tenantSlug := extractTraitString(traits, "tenantSlug")
+ appointments := userAppointmentSliceFromRaw(traits["additionalAppointments"])
+ if len(appointments) == 0 {
+ if metadata, ok := traits["metadata"].(map[string]any); ok {
+ appointments = userAppointmentSliceFromRaw(metadata["additionalAppointments"])
+ }
+ }
+ for _, raw := range appointments {
+ appointment, ok := raw.(map[string]any)
+ if !ok || !userAppointmentMatchesTenant(appointment, tenantID, tenantSlug) {
+ continue
+ }
+ if grade := normalizeMetadataString(appointment["grade"]); grade != "" {
+ return grade
+ }
+ }
+ for _, raw := range appointments {
+ appointment, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ if isPrimary, ok := metadataBoolFromMap(appointment, "isPrimary", "primary", "representative", "isRepresentative"); !ok || !isPrimary {
+ continue
+ }
+ if grade := normalizeMetadataString(appointment["grade"]); grade != "" {
+ return grade
+ }
+ }
+ return ""
+}
+
+func tenantBoundGradeFromUser(user domain.User) string {
+ if user.Metadata == nil {
+ return ""
+ }
+ traits := map[string]any{
+ "tenant_id": userTenantIDValue(user),
+ }
+ for key, value := range user.Metadata {
+ traits[key] = value
+ }
+ return tenantBoundGradeFromTraits(traits)
+}
+
+func userTenantIDValue(user domain.User) string {
+ if user.TenantID == nil {
+ return ""
+ }
+ return strings.TrimSpace(*user.TenantID)
+}
+
+func applyTenantBoundGrade(metadata map[string]any, tenant *domain.Tenant, grade string) map[string]any {
+ if metadata == nil {
+ metadata = map[string]any{}
+ }
+ if tenant == nil || strings.TrimSpace(tenant.ID) == "" {
+ return metadata
+ }
+ normalizedGrade := normalizeInternalGradeName(grade)
+ appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"])
+ matched := false
+ for i, raw := range appointments {
+ appointment, ok := raw.(map[string]any)
+ if !ok || !userAppointmentMatchesTenant(appointment, tenant.ID, tenant.Slug) {
+ continue
+ }
+ appointment["tenantId"] = tenant.ID
+ appointment["tenantSlug"] = tenant.Slug
+ appointment["tenantName"] = tenant.Name
+ if normalizedGrade == "" {
+ delete(appointment, "grade")
+ } else {
+ appointment["grade"] = normalizedGrade
+ }
+ appointments[i] = appointment
+ matched = true
+ }
+ if !matched && normalizedGrade != "" {
+ appointments = append(appointments, map[string]any{
+ "tenantId": tenant.ID,
+ "tenantSlug": tenant.Slug,
+ "tenantName": tenant.Name,
+ "grade": normalizedGrade,
+ })
+ }
+ if len(appointments) > 0 {
+ metadata["additionalAppointments"] = appointments
+ }
+ return metadata
+}
+
+func applyTenantBoundGradeToTraits(ctx context.Context, tenantService service.TenantService, traits map[string]any, grade string) {
+ delete(traits, "grade")
+ tenantID := extractTraitString(traits, "tenant_id")
+ tenantSlug := extractTraitString(traits, "tenantSlug")
+ var tenant *domain.Tenant
+ if tenantService != nil {
+ if tenantID != "" {
+ if found, err := tenantService.GetTenant(ctx, tenantID); err == nil {
+ tenant = found
+ }
+ }
+ if tenant == nil && tenantSlug != "" {
+ if found, err := tenantService.GetTenantBySlug(ctx, tenantSlug); err == nil {
+ tenant = found
+ }
+ }
+ }
+ if tenant == nil {
+ tenant = &domain.Tenant{ID: tenantID, Slug: tenantSlug}
+ }
+ if strings.TrimSpace(tenant.ID) == "" && strings.TrimSpace(tenant.Slug) == "" {
+ return
+ }
+ metadata := map[string]any{}
+ if appointments, ok := traits["additionalAppointments"]; ok {
+ metadata["additionalAppointments"] = appointments
+ }
+ metadata = applyTenantBoundGrade(metadata, tenant, grade)
+ if appointments, ok := metadata["additionalAppointments"]; ok {
+ traits["additionalAppointments"] = appointments
+ }
+}
+
func mergeUserAddTenantAppointment(traits map[string]any, metadata map[string]any, tenant *domain.Tenant) map[string]any {
if tenant == nil {
return metadata
@@ -507,14 +675,7 @@ func normalizeAssignableSystemRole(value string) (string, bool) {
}
func gradeFromTraits(traits map[string]any) string {
- value := strings.TrimSpace(extractTraitString(traits, "grade"))
- if value == "" {
- return ""
- }
- if _, ok := domain.NormalizeRoleAlias(value); ok {
- return ""
- }
- return value
+ return tenantBoundGradeFromTraits(traits)
}
func rejectLegacyCompanyCode(value string) error {
@@ -631,6 +792,14 @@ type identityMirrorLister interface {
ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error)
}
+type identityMirrorPageLister interface {
+ ListIdentityMirrorPage(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error)
+}
+
+type identityMirrorStore interface {
+ StoreIdentityMirror(ctx context.Context, identity service.KratosIdentity) error
+}
+
type identityMirrorStatusReader interface {
GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error)
}
@@ -856,68 +1025,29 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
})
}
- identities, err := h.listIdentitiesFromMirrorOrKratos(c.Context())
+ allowedTenantKeys := map[string]bool(nil)
+ if requesterRole != domain.RoleSuperAdmin {
+ allowedTenantKeys = manageableSlugs
+ }
+ page, err := h.listIdentityMirrorPageOrKratos(c.Context(), service.IdentityMirrorPageQuery{
+ Limit: limit,
+ Offset: offset,
+ Cursor: cursorRaw,
+ Search: search,
+ TenantSlug: tenantSlug,
+ TenantID: targetTenantID,
+ AllowedTenantKeys: allowedTenantKeys,
+ })
if err != nil {
slog.Warn("Identity mirror unavailable for user list", "error", err)
return errorJSON(c, fiber.StatusServiceUnavailable, "identity mirror unavailable")
}
-
- filtered := make([]service.KratosIdentity, 0, len(identities))
- searchLower := strings.ToLower(search)
-
- for _, identity := range identities {
- tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
-
- // Tenant Admin & Member filtering
- if requesterRole != domain.RoleSuperAdmin {
- hasAccess := anyTenantKeyAllowed(tenantAccessKeys, manageableSlugs)
- if !hasAccess {
- continue
- }
- }
-
- // Dedicated tenantSlug filter
- if tenantSlug != "" {
- targetKeys := map[string]bool{
- targetTenantID: true,
- strings.ToLower(tenantSlug): true,
- }
- matches := anyTenantKeyAllowed(tenantAccessKeys, targetKeys)
- if !matches {
- continue
- }
- }
-
- if !identityMatchesSearch(identity, searchLower) {
- continue
- }
- filtered = append(filtered, identity)
- }
-
- pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
- total := int64(len(filtered))
- nextCursor := ""
- var pageIdentities []service.KratosIdentity
if cursorRaw != "" {
- pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
- if err != nil {
- return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
- }
offset = 0
- } else {
- if offset > len(filtered) {
- offset = len(filtered)
- }
- end := min(offset+limit, len(filtered))
- pageIdentities = filtered[offset:end]
- if total > int64(end) && len(pageIdentities) > 0 {
- lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
- nextCursor = pagination.Encode(lastTimestamp, lastID)
- }
}
- items := make([]userSummary, 0, len(pageIdentities))
- for _, identity := range pageIdentities {
+ items := make([]userSummary, 0, len(page.Items))
+ for _, identity := range page.Items {
summary := h.mapIdentitySummary(c.Context(), identity)
items = append(items, summary)
}
@@ -926,9 +1056,9 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
Items: items,
Limit: limit,
Offset: offset,
- Total: total,
+ Total: page.Total,
Cursor: cursorRaw,
- NextCursor: nextCursor,
+ NextCursor: page.NextCursor,
})
}
@@ -1024,6 +1154,84 @@ func (h *UserHandler) listIdentitiesFromMirrorOrKratos(ctx context.Context) ([]s
return h.rebuildIdentityMirror(ctx)
}
+func (h *UserHandler) listIdentityMirrorPageOrKratos(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
+ if h != nil && h.IdentityCache != nil {
+ if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.identityMirrorStatusReady(ctx) {
+ return lister.ListIdentityMirrorPage(ctx, query)
+ }
+ }
+
+ identities, err := h.rebuildIdentityMirror(ctx)
+ if err != nil {
+ return service.IdentityMirrorPageResult{}, err
+ }
+ if h != nil && h.IdentityCache != nil {
+ if lister, ok := h.IdentityCache.(identityMirrorPageLister); ok && h.identityMirrorStatusReady(ctx) {
+ return lister.ListIdentityMirrorPage(ctx, query)
+ }
+ }
+ return pageIdentityMirrorSlice(identities, query)
+}
+
+func pageIdentityMirrorSlice(identities []service.KratosIdentity, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
+ if query.Limit <= 0 {
+ query.Limit = 50
+ }
+ if query.Offset < 0 {
+ query.Offset = 0
+ }
+ searchLower := strings.ToLower(strings.TrimSpace(query.Search))
+ targetKeys := make(map[string]bool)
+ for _, value := range []string{query.TenantID, query.TenantSlug} {
+ key := strings.ToLower(strings.TrimSpace(value))
+ if key != "" {
+ targetKeys[key] = true
+ }
+ }
+ filtered := make([]service.KratosIdentity, 0, len(identities))
+ for _, identity := range identities {
+ tenantAccessKeys := identityTenantAccessKeys(identity.Traits)
+ if len(query.AllowedTenantKeys) > 0 && !anyTenantKeyAllowed(tenantAccessKeys, query.AllowedTenantKeys) {
+ continue
+ }
+ if len(targetKeys) > 0 && !anyTenantKeyAllowed(tenantAccessKeys, targetKeys) {
+ continue
+ }
+ if !identityMatchesSearch(identity, searchLower) {
+ continue
+ }
+ filtered = append(filtered, identity)
+ }
+
+ pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
+ total := int64(len(filtered))
+ nextCursor := ""
+ var pageIdentities []service.KratosIdentity
+ if strings.TrimSpace(query.Cursor) != "" {
+ var err error
+ pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, query.Limit, query.Cursor, kratosIdentityCursorKey)
+ if err != nil {
+ return service.IdentityMirrorPageResult{}, err
+ }
+ } else {
+ if query.Offset > len(filtered) {
+ query.Offset = len(filtered)
+ }
+ end := min(query.Offset+query.Limit, len(filtered))
+ pageIdentities = filtered[query.Offset:end]
+ if total > int64(end) && len(pageIdentities) > 0 {
+ lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
+ nextCursor = pagination.Encode(lastTimestamp, lastID)
+ }
+ }
+ return service.IdentityMirrorPageResult{
+ Items: pageIdentities,
+ Total: total,
+ Cursor: query.Cursor,
+ NextCursor: nextCursor,
+ }, nil
+}
+
func (h *UserHandler) WarmIdentityMirror(ctx context.Context) (int, error) {
identities, err := h.rebuildIdentityMirror(ctx)
if err != nil {
@@ -1066,6 +1274,24 @@ func (h *UserHandler) identityMirrorReady(ctx context.Context, identityCount int
status.ObservedCount == int64(identityCount)
}
+func (h *UserHandler) identityMirrorStatusReady(ctx context.Context) bool {
+ if h == nil || h.IdentityCache == nil {
+ return false
+ }
+ reader, ok := h.IdentityCache.(identityMirrorStatusReader)
+ if !ok {
+ return false
+ }
+ status, err := reader.GetIdentityCacheStatus(ctx)
+ if err != nil {
+ return false
+ }
+ return status.RedisReady &&
+ status.Status == "ready" &&
+ status.MirrorVersion == identityMirrorVersion &&
+ status.ObservedCount > 0
+}
+
func (h *UserHandler) flushIdentityMirror(ctx context.Context) {
if h == nil || h.IdentityCache == nil {
return
@@ -1108,6 +1334,10 @@ func (h *UserHandler) storeIdentityMirror(identity service.KratosIdentity) {
if h == nil || h.IdentityCache == nil || strings.TrimSpace(identity.ID) == "" {
return
}
+ if store, ok := h.IdentityCache.(identityMirrorStore); ok {
+ _ = store.StoreIdentityMirror(context.Background(), identity)
+ return
+ }
raw, err := json.Marshal(identity)
if err != nil {
return
@@ -1235,7 +1465,6 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
attributes := map[string]any{
"department": req.Department,
- "grade": strings.TrimSpace(req.Grade),
"position": req.Position,
"jobTitle": req.JobTitle,
"affiliationType": "internal",
@@ -1289,6 +1518,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
tenantID = tenant.ID
req.CompanyCode = tenant.Slug
+ resolvedTenant = tenant
}
attributes["role"] = role
@@ -1308,6 +1538,10 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}
}
+ if strings.TrimSpace(req.Grade) != "" {
+ req.Metadata = applyTenantBoundGrade(req.Metadata, resolvedTenant, req.Grade)
+ }
+
// Merge custom metadata into attributes
for k, v := range req.Metadata {
// Don't overwrite core fields
@@ -1789,6 +2023,13 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
item.Metadata["additionalAppointments"] = resolvedAppointments
}
+ if strings.TrimSpace(item.Grade) != "" {
+ item.Metadata = applyTenantBoundGrade(item.Metadata, &domain.Tenant{
+ ID: tItem.ID,
+ Slug: tItem.Slug,
+ Name: tItem.Name,
+ }, item.Grade)
+ }
normalizeBulkUserAliasMetadata(item.Metadata)
item.Metadata = sanitizeUserMetadata(item.Metadata)
@@ -1800,7 +2041,6 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
attributes := map[string]any{
"department": dept,
- "grade": strings.TrimSpace(item.Grade),
"position": strings.TrimSpace(item.Position),
"jobTitle": strings.TrimSpace(item.JobTitle),
"affiliationType": "internal",
@@ -2350,7 +2590,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
traits["department"] = *req.Department
}
if req.Grade != nil {
- traits["grade"] = *req.Grade
+ applyTenantBoundGradeToTraits(c.Context(), h.TenantService, traits, *req.Grade)
}
if req.Position != nil {
traits["position"] = *req.Position
@@ -2783,7 +3023,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
traits["department"] = strings.TrimSpace(*req.Department)
}
if req.Grade != nil {
- traits["grade"] = strings.TrimSpace(*req.Grade)
+ applyTenantBoundGradeToTraits(c.Context(), h.TenantService, traits, *req.Grade)
}
if req.Position != nil {
traits["position"] = strings.TrimSpace(*req.Position)
@@ -3344,7 +3584,7 @@ func (h *UserHandler) mapLocalUserSummary(ctx context.Context, user domain.User)
TenantSlug: tenantSlug,
CompanyCode: tenantSlug,
Department: user.Department,
- Grade: user.Grade,
+ Grade: tenantBoundGradeFromUser(user),
Position: user.Position,
JobTitle: user.JobTitle,
Metadata: user.Metadata,
diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go
index e5ed5d0f..26e1a3b2 100644
--- a/backend/internal/handler/user_handler_test.go
+++ b/backend/internal/handler/user_handler_test.go
@@ -993,6 +993,22 @@ func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T)
mockOry.AssertExpectations(t)
}
+func TestApplyTenantBoundGradeNormalizesDirectorLevelNames(t *testing.T) {
+ tenant := &domain.Tenant{ID: "tenant-1", Slug: "tenant", Name: "Tenant"}
+
+ metadata := applyTenantBoundGrade(nil, tenant, "상무")
+ appointments := userAppointmentSliceFromRaw(metadata["additionalAppointments"])
+ require.Len(t, appointments, 1)
+ appointment := appointments[0].(map[string]any)
+ require.Equal(t, "상무이사", appointment["grade"])
+
+ metadata = applyTenantBoundGrade(metadata, tenant, "전무")
+ appointments = userAppointmentSliceFromRaw(metadata["additionalAppointments"])
+ require.Len(t, appointments, 1)
+ appointment = appointments[0].(map[string]any)
+ require.Equal(t, "전무이사", appointment["grade"])
+}
+
func TestUserHandler_BulkCreateUsers_AppendsEmailDomainTenantAtLowestPriority(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -1098,9 +1114,17 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
type identityMirrorRedisStub struct {
mockRedisRepo
+ pageCalls int
+ fullCalls int
+ failFull bool
+ lastQuery service.IdentityMirrorPageQuery
}
func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]service.KratosIdentity, error) {
+ s.fullCalls++
+ if s.failFull {
+ return nil, errors.New("full identity mirror materialization is forbidden")
+ }
identities := make([]service.KratosIdentity, 0, len(s.data))
for key, raw := range s.data {
if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
@@ -1118,6 +1142,35 @@ func (s *identityMirrorRedisStub) ListIdentityMirrors(ctx context.Context) ([]se
return identities, nil
}
+func (s *identityMirrorRedisStub) ListIdentityMirrorPage(ctx context.Context, query service.IdentityMirrorPageQuery) (service.IdentityMirrorPageResult, error) {
+ s.pageCalls++
+ s.lastQuery = query
+ identities := make([]service.KratosIdentity, 0, len(s.data))
+ for key, raw := range s.data {
+ if !strings.HasPrefix(key, "identity:mirror:") || key == "identity:mirror:state" {
+ continue
+ }
+ var identity service.KratosIdentity
+ if err := json.Unmarshal([]byte(raw), &identity); err != nil {
+ continue
+ }
+ if strings.TrimSpace(identity.ID) == "" {
+ continue
+ }
+ identities = append(identities, identity)
+ }
+ return pageIdentityMirrorSlice(identities, query)
+}
+
+func (s *identityMirrorRedisStub) StoreIdentityMirror(ctx context.Context, identity service.KratosIdentity) error {
+ raw, err := json.Marshal(identity)
+ if err != nil {
+ return err
+ }
+ s.data[identityMirrorKey(identity.ID)] = string(raw)
+ return nil
+}
+
func (s *identityMirrorRedisStub) GetIdentityCacheStatus(ctx context.Context) (domain.IdentityCacheStatus, error) {
raw := s.data["identity:mirror:state"]
if strings.TrimSpace(raw) == "" {
@@ -1174,7 +1227,7 @@ func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing
h := &UserHandler{
KratosAdmin: mockKratos,
UserRepo: mockRepo,
- IdentityCache: &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
+ IdentityCache: &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(mirrorIdentity.ID): string(rawMirrorIdentity),
"identity:mirror:state": string(rawState),
}}},
@@ -1200,14 +1253,71 @@ func TestUserHandler_ListUsersUsesIdentityMirrorAndDoesNotUseUserRepo(t *testing
require.Len(t, res.Items, 1)
require.Equal(t, "mirror-user-1", res.Items[0].ID)
require.Equal(t, "mirror1@example.com", res.Items[0].Email)
+ cache := h.IdentityCache.(*identityMirrorRedisStub)
+ require.Equal(t, 1, cache.pageCalls)
+ require.Equal(t, 0, cache.fullCalls)
mockRepo.AssertNotCalled(t, "List", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything)
mockKratos.AssertNotCalled(t, "ListIdentities", mock.Anything)
}
+func TestUserHandler_ListUsersPassesQueryToIdentityMirrorPageInsteadOfLoadingFullMirror(t *testing.T) {
+ app := fiber.New()
+ createdAt := time.Date(2026, 6, 17, 8, 50, 0, 0, time.UTC)
+ identities := []service.KratosIdentity{
+ {ID: "user-new", State: "active", CreatedAt: createdAt.Add(2 * time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "new@example.com", "name": "New User"}},
+ {ID: "user-needle", State: "active", CreatedAt: createdAt.Add(time.Minute), UpdatedAt: createdAt, Traits: map[string]any{"email": "needle@example.com", "name": "Needle User"}},
+ {ID: "user-old", State: "active", CreatedAt: createdAt, UpdatedAt: createdAt, Traits: map[string]any{"email": "old@example.com", "name": "Old User"}},
+ }
+ data := map[string]string{}
+ for _, identity := range identities {
+ raw, err := json.Marshal(identity)
+ require.NoError(t, err)
+ data[identityMirrorKey(identity.ID)] = string(raw)
+ }
+ state := domain.IdentityCacheStatus{
+ Status: "ready",
+ RedisReady: true,
+ MirrorVersion: identityMirrorVersion,
+ ObservedCount: int64(len(identities)),
+ }
+ rawState, err := json.Marshal(state)
+ require.NoError(t, err)
+ data["identity:mirror:state"] = string(rawState)
+
+ cache := &identityMirrorRedisStub{
+ mockRedisRepo: mockRedisRepo{data: data},
+ failFull: true,
+ }
+ h := &UserHandler{
+ KratosAdmin: new(MockKratosAdmin),
+ IdentityCache: cache,
+ }
+
+ app.Use(func(c *fiber.Ctx) error {
+ c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleSuperAdmin})
+ return c.Next()
+ })
+ app.Get("/users", h.ListUsers)
+
+ req := httptest.NewRequest("GET", "/users?limit=1&search=needle", nil)
+ resp, err := app.Test(req)
+
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ var res userListResponse
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
+ require.Len(t, res.Items, 1)
+ require.Equal(t, "user-needle", res.Items[0].ID)
+ require.Equal(t, 1, cache.pageCalls)
+ require.Equal(t, 0, cache.fullCalls)
+ require.Equal(t, 1, cache.lastQuery.Limit)
+ require.Equal(t, "needle", cache.lastQuery.Search)
+}
+
func TestUserHandler_ListUsersWarmsIdentityMirrorFromKratosWhenMirrorEmpty(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
- redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{}}}
+ redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{}}}
createdAt := time.Date(2026, 6, 8, 6, 40, 0, 0, time.UTC)
h := &UserHandler{
@@ -1330,7 +1440,7 @@ func TestUserHandler_ListUsersTenantSlugFilterIncludesAdditionalAppointments(t *
func TestUserHandler_WarmIdentityMirrorRebuildsRedisFromKratos(t *testing.T) {
mockKratos := new(MockKratosAdmin)
- redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
+ redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey("stale-user"): `{"id":"stale-user"}`,
}}}
createdAt := time.Date(2026, 6, 12, 18, 30, 0, 0, time.UTC)
@@ -1382,7 +1492,7 @@ func TestUserHandler_ListUsersRebuildsLegacyReadyMirrorWithoutVersion(t *testing
}
rawLegacyState, err := json.Marshal(legacyState)
require.NoError(t, err)
- redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
+ redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(legacyIdentity.ID): string(rawLegacyIdentity),
"identity:mirror:state": string(rawLegacyState),
}}}
@@ -1436,7 +1546,7 @@ func TestUserHandler_ListUsersRebuildsPartialMirrorFromKratos(t *testing.T) {
}
rawPartialIdentity, err := json.Marshal(partialIdentity)
require.NoError(t, err)
- redis := &identityMirrorRedisStub{mockRedisRepo{data: map[string]string{
+ redis := &identityMirrorRedisStub{mockRedisRepo: mockRedisRepo{data: map[string]string{
identityMirrorKey(partialIdentity.ID): string(rawPartialIdentity),
}}}
kratosIdentities := []service.KratosIdentity{
@@ -3946,18 +4056,24 @@ func TestUserHandler_BulkUpdateUsersRejectsInternalDomainMoveToPersonalTenant(t
mockTenant.AssertExpectations(t)
}
-func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
+func TestUserHandler_MapToLocalUserPreservesTenantBoundGradeForCompatibility(t *testing.T) {
handler := &UserHandler{}
identity := service.KratosIdentity{
ID: "user-grade-id",
State: "active",
Traits: map[string]any{
- "email": "grade@example.com",
- "name": "Grade User",
- "role": domain.RoleUser,
- "grade": "수석",
- "position": "팀장",
- "companyCode": "hanmac",
+ "email": "grade@example.com",
+ "name": "Grade User",
+ "role": domain.RoleUser,
+ "grade": "수석",
+ "position": "팀장",
+ "tenant_id": "tenant-1",
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": "tenant-1",
+ "grade": "수석",
+ },
+ },
},
}
@@ -3967,6 +4083,7 @@ func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
assert.Equal(t, "수석", localUser.Grade)
assert.Equal(t, "팀장", localUser.Position)
assert.NotContains(t, localUser.Metadata, "grade")
+ assert.Contains(t, localUser.Metadata, "additionalAppointments")
}
func (m *MockKratosAdmin) CreateUser(ctx context.Context, user *domain.BrokerUser, password string) (string, error) {
diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go
index 5d4ebd86..135d3dcb 100644
--- a/backend/internal/repository/user_repository.go
+++ b/backend/internal/repository/user_repository.go
@@ -69,6 +69,26 @@ func (r *userRepository) withTenantMembershipFilter(db *gorm.DB, tenantIDs []str
clauses = append(clauses, "metadata @> ?::jsonb")
args = append(args, string(payload))
}
+ clauses = append(clauses, `EXISTS (
+ SELECT 1
+ FROM tenants AS membership_tenants
+ WHERE membership_tenants.id IN ?
+ AND users.metadata @> jsonb_build_object(
+ 'additionalAppointments',
+ jsonb_build_array(jsonb_build_object('tenantSlug', membership_tenants.slug))
+ )
+ )`)
+ args = append(args, tenantIDs)
+ clauses = append(clauses, `EXISTS (
+ SELECT 1
+ FROM tenants AS membership_tenants
+ WHERE membership_tenants.id IN ?
+ AND users.metadata @> jsonb_build_object(
+ 'additionalAppointments',
+ jsonb_build_array(jsonb_build_object('tenant_slug', membership_tenants.slug))
+ )
+ )`)
+ args = append(args, tenantIDs)
return db.Where("("+strings.Join(clauses, " OR ")+")", args...)
}
@@ -170,11 +190,63 @@ func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []strin
}
for _, tenantID := range tenantIDs {
- var count int64
- if err := r.withTenantMembershipFilter(r.db.WithContext(ctx).Model(&domain.User{}), []string{tenantID}).Count(&count).Error; err != nil {
- return nil, err
+ tenantID = strings.TrimSpace(tenantID)
+ if tenantID != "" {
+ counts[tenantID] = 0
}
- counts[tenantID] = count
+ }
+
+ type result struct {
+ TenantID string
+ Count int64
+ }
+ var results []result
+ if err := r.db.WithContext(ctx).Raw(`
+ WITH requested_tenants AS (
+ SELECT id, slug
+ FROM tenants
+ WHERE id IN ?
+ ),
+ memberships AS (
+ SELECT users.id AS user_id, users.tenant_id::text AS tenant_id
+ FROM users
+ WHERE users.deleted_at IS NULL
+ AND users.tenant_id IN ?
+
+ UNION ALL
+
+ SELECT users.id AS user_id, appointment ->> 'tenantId' AS tenant_id
+ FROM users
+ CROSS JOIN LATERAL jsonb_array_elements(COALESCE(users.metadata -> 'additionalAppointments', '[]'::jsonb)) AS appointment
+ WHERE users.deleted_at IS NULL
+ AND appointment ->> 'tenantId' IN ?
+
+ UNION ALL
+
+ SELECT users.id AS user_id, requested_tenants.id::text AS tenant_id
+ FROM users
+ CROSS JOIN LATERAL jsonb_array_elements(COALESCE(users.metadata -> 'additionalAppointments', '[]'::jsonb)) AS appointment
+ JOIN requested_tenants ON LOWER(requested_tenants.slug) = LOWER(appointment ->> 'tenantSlug')
+ WHERE users.deleted_at IS NULL
+
+ UNION ALL
+
+ SELECT users.id AS user_id, requested_tenants.id::text AS tenant_id
+ FROM users
+ CROSS JOIN LATERAL jsonb_array_elements(COALESCE(users.metadata -> 'additionalAppointments', '[]'::jsonb)) AS appointment
+ JOIN requested_tenants ON LOWER(requested_tenants.slug) = LOWER(appointment ->> 'tenant_slug')
+ WHERE users.deleted_at IS NULL
+ )
+ SELECT tenant_id, COUNT(DISTINCT user_id) AS count
+ FROM memberships
+ WHERE tenant_id IN ?
+ GROUP BY tenant_id
+ `, tenantIDs, tenantIDs, tenantIDs, tenantIDs).Scan(&results).Error; err != nil {
+ return nil, err
+ }
+
+ for _, result := range results {
+ counts[result.TenantID] = result.Count
}
return counts, nil
}
diff --git a/backend/internal/repository/user_repository_test.go b/backend/internal/repository/user_repository_test.go
index 69b1e069..4f9002bb 100644
--- a/backend/internal/repository/user_repository_test.go
+++ b/backend/internal/repository/user_repository_test.go
@@ -54,6 +54,36 @@ func TestUserRepository(t *testing.T) {
assert.Equal(t, "010-1234-5678", found.Phone)
})
+ t.Run("Create and Update preserve top-level user grade for compatibility", func(t *testing.T) {
+ testDB.Exec("DELETE FROM user_login_ids")
+ testDB.Exec("DELETE FROM users WHERE email IN ?", []string{"grade-create@example.com", "grade-update@example.com"})
+
+ created := &domain.User{
+ Email: "grade-create@example.com",
+ Name: "Grade Create",
+ Role: domain.RoleUser,
+ Grade: "책임",
+ }
+ require.NoError(t, repo.Create(ctx, created))
+
+ found, err := repo.FindByEmail(ctx, created.Email)
+ require.NoError(t, err)
+ require.Equal(t, "책임", found.Grade)
+
+ updated := &domain.User{
+ ID: uuid.NewString(),
+ Email: "grade-update@example.com",
+ Name: "Grade Update",
+ Role: domain.RoleUser,
+ Grade: "수석",
+ }
+ require.NoError(t, repo.Update(ctx, updated))
+
+ found, err = repo.FindByEmail(ctx, updated.Email)
+ require.NoError(t, err)
+ require.Equal(t, "수석", found.Grade)
+ })
+
t.Run("Update preserves archived email reservation", func(t *testing.T) {
testDB.Exec("DELETE FROM user_login_ids")
testDB.Exec("DELETE FROM users")
@@ -273,6 +303,45 @@ func TestUserRepository_ListIncludesAdditionalTenantAppointments(t *testing.T) {
assert.Equal(t, int64(2), counts[additionalTenant.ID])
}
+func TestUserRepository_ListIncludesAdditionalTenantAppointmentsBySlug(t *testing.T) {
+ repo := NewUserRepository(testDB)
+ ctx := context.Background()
+ require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
+ require.NoError(t, testDB.Exec("DELETE FROM users").Error)
+
+ primaryTenant := createUserRepositoryTestTenant(t, "repo-private-primary-tenant")
+ visibleTenant := createUserRepositoryTestTenant(t, "repo-visible-leader-tenant")
+ primaryTenantID := primaryTenant.ID
+ user := domain.User{
+ ID: uuid.NewString(),
+ Email: "slug-appointment-leader@example.com",
+ Name: "Slug Appointment Leader",
+ Role: domain.RoleUser,
+ TenantID: &primaryTenantID,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantSlug": visibleTenant.Slug,
+ "tenantName": visibleTenant.Name,
+ "isOwner": true,
+ },
+ },
+ },
+ }
+ require.NoError(t, repo.Create(ctx, &user))
+
+ listed, total, _, err := repo.List(ctx, 0, 20, "", []string{visibleTenant.ID}, "")
+
+ require.NoError(t, err)
+ require.Equal(t, int64(1), total)
+ require.Len(t, listed, 1)
+ assert.Equal(t, "slug-appointment-leader@example.com", listed[0].Email)
+
+ counts, err := repo.CountByTenantIDs(ctx, []string{visibleTenant.ID})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), counts[visibleTenant.ID])
+}
+
func createUserRepositoryTestTenant(t *testing.T, slug string) domain.Tenant {
t.Helper()
require.NoError(t, testDB.Unscoped().Where("slug = ?", slug).Delete(&domain.Tenant{}).Error)
diff --git a/backend/internal/service/redis_service.go b/backend/internal/service/redis_service.go
index 666f65c6..52135dc8 100644
--- a/backend/internal/service/redis_service.go
+++ b/backend/internal/service/redis_service.go
@@ -2,9 +2,12 @@ package service
import (
"baron-sso-backend/internal/domain"
+ "baron-sso-backend/internal/pagination"
"context"
"encoding/json"
+ "fmt"
"os"
+ "strconv"
"strings"
"time"
@@ -21,10 +24,28 @@ type identityMirrorStateStore struct {
Status string `json:"status"`
LastRefreshedAt *time.Time `json:"lastRefreshedAt,omitempty"`
LastError string `json:"lastError,omitempty"`
+ MirrorVersion string `json:"mirrorVersion,omitempty"`
ObservedCount int64 `json:"observedCount,omitempty"`
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
}
+type IdentityMirrorPageQuery struct {
+ Limit int
+ Offset int
+ Cursor string
+ Search string
+ TenantID string
+ TenantSlug string
+ AllowedTenantKeys map[string]bool
+}
+
+type IdentityMirrorPageResult struct {
+ Items []KratosIdentity
+ Total int64
+ Cursor string
+ NextCursor string
+}
+
// NewRedisService creates and returns a new RedisService
func NewRedisService() (*RedisService, error) {
redisAddr := os.Getenv("REDIS_ADDR")
@@ -199,6 +220,7 @@ func (s *RedisService) GetIdentityCacheStatus(ctx context.Context) (domain.Ident
return domain.IdentityCacheStatus{
Status: status,
RedisReady: true,
+ MirrorVersion: stored.MirrorVersion,
ObservedCount: stored.ObservedCount,
KeyCount: keyCount,
LastRefreshedAt: stored.LastRefreshedAt,
@@ -271,6 +293,269 @@ func (s *RedisService) ListIdentityMirrors(ctx context.Context) ([]KratosIdentit
return identities, nil
}
+func (s *RedisService) StoreIdentityMirror(ctx context.Context, identity KratosIdentity) error {
+ if s == nil || s.Client == nil {
+ return os.ErrInvalid
+ }
+ identityID := strings.TrimSpace(identity.ID)
+ if identityID == "" {
+ return os.ErrInvalid
+ }
+ raw, err := json.Marshal(identity)
+ if err != nil {
+ return err
+ }
+ if err := s.Client.Set(ctx, "identity:mirror:"+identityID, string(raw), 0).Err(); err != nil {
+ return err
+ }
+ score := float64(identityMirrorScoreTime(identity).UnixMilli())
+ if err := s.Client.ZAdd(ctx, "identity:index:created_at", &redis.Z{
+ Score: score,
+ Member: identityID,
+ }).Err(); err != nil {
+ return err
+ }
+ for _, tenantKey := range identityMirrorTenantKeys(identity.Traits) {
+ if err := s.Client.SAdd(ctx, "identity:index:tenant:"+tenantKey, identityID).Err(); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (s *RedisService) ListIdentityMirrorPage(ctx context.Context, query IdentityMirrorPageQuery) (IdentityMirrorPageResult, error) {
+ if s == nil || s.Client == nil {
+ return IdentityMirrorPageResult{}, os.ErrInvalid
+ }
+ if query.Limit <= 0 {
+ query.Limit = 50
+ }
+ if query.Offset < 0 {
+ query.Offset = 0
+ }
+ cursor, err := pagination.Decode(query.Cursor)
+ if err != nil {
+ return IdentityMirrorPageResult{}, err
+ }
+ search := strings.ToLower(strings.TrimSpace(query.Search))
+ targetTenantKeys := identityMirrorTargetTenantKeys(query)
+ maxScore := "+inf"
+ if cursor != nil {
+ maxScore = strconv.FormatInt(cursor.Timestamp.UnixMilli(), 10)
+ }
+
+ const batchSize int64 = 250
+ var offset int64
+ var total int64
+ matched := make([]KratosIdentity, 0, query.Limit+1)
+ pageStart := query.Offset
+ if cursor != nil {
+ pageStart = 0
+ }
+
+ for {
+ zItems, err := s.Client.ZRevRangeByScoreWithScores(ctx, "identity:index:created_at", &redis.ZRangeBy{
+ Max: maxScore,
+ Min: "-inf",
+ Offset: offset,
+ Count: batchSize,
+ }).Result()
+ if err != nil {
+ return IdentityMirrorPageResult{}, err
+ }
+ if len(zItems) == 0 {
+ break
+ }
+ keys := make([]string, 0, len(zItems))
+ for _, item := range zItems {
+ id, ok := item.Member.(string)
+ if !ok || strings.TrimSpace(id) == "" {
+ continue
+ }
+ keys = append(keys, "identity:mirror:"+id)
+ }
+ rawItems, err := s.Client.MGet(ctx, keys...).Result()
+ if err != nil {
+ return IdentityMirrorPageResult{}, err
+ }
+ for _, raw := range rawItems {
+ rawString, ok := raw.(string)
+ if !ok || strings.TrimSpace(rawString) == "" {
+ continue
+ }
+ var identity KratosIdentity
+ if err := json.Unmarshal([]byte(rawString), &identity); err != nil {
+ continue
+ }
+ if strings.TrimSpace(identity.ID) == "" {
+ continue
+ }
+ if cursor != nil {
+ timestamp, id := identityMirrorCursorKey(identity)
+ if !pagination.ComesAfter(timestamp, id, cursor) {
+ continue
+ }
+ }
+ if !identityMirrorMatchesTenantScope(identity, targetTenantKeys, query.AllowedTenantKeys) {
+ continue
+ }
+ if !identityMirrorMatchesSearch(identity, search) {
+ continue
+ }
+ if total >= int64(pageStart) && len(matched) < query.Limit+1 {
+ matched = append(matched, identity)
+ }
+ total++
+ }
+ if len(zItems) < int(batchSize) {
+ break
+ }
+ offset += int64(len(zItems))
+ }
+
+ nextCursor := ""
+ items := matched
+ if len(matched) > query.Limit {
+ items = matched[:query.Limit]
+ lastTimestamp, lastID := identityMirrorCursorKey(items[len(items)-1])
+ nextCursor = pagination.Encode(lastTimestamp, lastID)
+ }
+ return IdentityMirrorPageResult{
+ Items: items,
+ Total: total,
+ Cursor: query.Cursor,
+ NextCursor: nextCursor,
+ }, nil
+}
+
+func identityMirrorScoreTime(identity KratosIdentity) time.Time {
+ if identity.CreatedAt.IsZero() {
+ return time.Unix(0, 0).UTC()
+ }
+ return identity.CreatedAt.UTC()
+}
+
+func identityMirrorCursorKey(identity KratosIdentity) (time.Time, string) {
+ return identityMirrorScoreTime(identity), identity.ID
+}
+
+func identityMirrorTenantKeys(traits map[string]any) []string {
+ keys := make([]string, 0, 4)
+ seen := make(map[string]bool)
+ appendKey := func(value string) {
+ key := strings.ToLower(strings.TrimSpace(value))
+ if key == "" || seen[key] {
+ return
+ }
+ seen[key] = true
+ keys = append(keys, key)
+ }
+ appendKey(identityMirrorTraitString(traits, "tenant_id"))
+ appendKey(identityMirrorTraitString(traits, "tenantSlug"))
+ appointments := identityMirrorAppointments(traits["additionalAppointments"])
+ if len(appointments) == 0 {
+ if metadata, ok := traits["metadata"].(map[string]any); ok {
+ appointments = identityMirrorAppointments(metadata["additionalAppointments"])
+ }
+ }
+ for _, appointment := range appointments {
+ appendKey(identityMirrorAnyString(appointment["tenantId"]))
+ appendKey(identityMirrorAnyString(appointment["tenantSlug"]))
+ appendKey(identityMirrorAnyString(appointment["slug"]))
+ }
+ return keys
+}
+
+func identityMirrorTargetTenantKeys(query IdentityMirrorPageQuery) map[string]bool {
+ targets := make(map[string]bool)
+ for _, value := range []string{query.TenantID, query.TenantSlug} {
+ key := strings.ToLower(strings.TrimSpace(value))
+ if key != "" {
+ targets[key] = true
+ }
+ }
+ return targets
+}
+
+func identityMirrorMatchesTenantScope(identity KratosIdentity, targetTenantKeys map[string]bool, allowedTenantKeys map[string]bool) bool {
+ identityKeys := identityMirrorTenantKeys(identity.Traits)
+ if len(allowedTenantKeys) > 0 && !identityMirrorAnyKeyAllowed(identityKeys, allowedTenantKeys) {
+ return false
+ }
+ if len(targetTenantKeys) > 0 && !identityMirrorAnyKeyAllowed(identityKeys, targetTenantKeys) {
+ return false
+ }
+ return true
+}
+
+func identityMirrorAnyKeyAllowed(keys []string, allowed map[string]bool) bool {
+ for _, key := range keys {
+ if allowed[key] {
+ return true
+ }
+ }
+ return false
+}
+
+func identityMirrorMatchesSearch(identity KratosIdentity, search string) bool {
+ search = strings.TrimSpace(search)
+ if search == "" {
+ return true
+ }
+ values := []string{
+ identity.ID,
+ identityMirrorTraitString(identity.Traits, "email"),
+ identityMirrorTraitString(identity.Traits, "name"),
+ identityMirrorTraitString(identity.Traits, "phone_number"),
+ identityMirrorTraitString(identity.Traits, "loginId"),
+ }
+ for _, value := range values {
+ if strings.Contains(strings.ToLower(value), search) {
+ return true
+ }
+ }
+ rawTraits, err := json.Marshal(identity.Traits)
+ if err != nil {
+ return false
+ }
+ return strings.Contains(strings.ToLower(string(rawTraits)), search)
+}
+
+func identityMirrorTraitString(traits map[string]any, key string) string {
+ if traits == nil {
+ return ""
+ }
+ return identityMirrorAnyString(traits[key])
+}
+
+func identityMirrorAnyString(value any) string {
+ switch typed := value.(type) {
+ case string:
+ return typed
+ case fmt.Stringer:
+ return typed.String()
+ default:
+ return ""
+ }
+}
+
+func identityMirrorAppointments(value any) []map[string]any {
+ switch typed := value.(type) {
+ case []map[string]any:
+ return typed
+ case []any:
+ result := make([]map[string]any, 0, len(typed))
+ for _, item := range typed {
+ if appointment, ok := item.(map[string]any); ok {
+ result = append(result, appointment)
+ }
+ }
+ return result
+ default:
+ return nil
+ }
+}
+
func (s *RedisService) countIdentityCacheKeys(ctx context.Context) (int64, error) {
keys, err := s.identityCacheKeys(ctx)
if err != nil {
diff --git a/backend/internal/service/tenant_service.go b/backend/internal/service/tenant_service.go
index 10415e69..570e01d2 100644
--- a/backend/internal/service/tenant_service.go
+++ b/backend/internal/service/tenant_service.go
@@ -100,22 +100,14 @@ func (s *tenantService) ListJoinedTenants(ctx context.Context, userID string) ([
return []domain.Tenant{}, nil
}
- ownerIDs, _ := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
- adminIDs, _ := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
-
idMap := make(map[string]bool)
+ allIDs := make([]string, 0, len(memberIDs))
for _, id := range memberIDs {
+ id = strings.TrimSpace(id)
+ if id == "" || idMap[id] {
+ continue
+ }
idMap[id] = true
- }
- for _, id := range ownerIDs {
- idMap[id] = true
- }
- for _, id := range adminIDs {
- idMap[id] = true
- }
-
- allIDs := make([]string, 0, len(idMap))
- for id := range idMap {
allIDs = append(allIDs, id)
}
diff --git a/backend/internal/service/tenant_service_test.go b/backend/internal/service/tenant_service_test.go
index be19a03a..503c71f7 100644
--- a/backend/internal/service/tenant_service_test.go
+++ b/backend/internal/service/tenant_service_test.go
@@ -53,7 +53,11 @@ func (m *MockTenantRepoForSvc) FindByDomain(ctx context.Context, domainName stri
}
func (m *MockTenantRepoForSvc) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
- return nil, nil
+ args := m.Called(ctx, ids)
+ if args.Get(0) == nil {
+ return nil, args.Error(1)
+ }
+ return args.Get(0).([]domain.Tenant), args.Error(1)
}
func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error {
@@ -343,3 +347,29 @@ func TestTenantService_ListTenants(t *testing.T) {
assert.Equal(t, tenants, result)
mockRepo.AssertExpectations(t)
}
+
+func TestTenantService_ListJoinedTenants_UsesOnlyMemberRelations(t *testing.T) {
+ mockRepo := new(MockTenantRepoForSvc)
+ mockKeto := new(MockKetoSvcForTenant)
+ svc := NewTenantService(mockRepo, nil, nil, nil)
+ svc.SetKetoService(mockKeto)
+ ctx := context.Background()
+ userID := "user-uuid"
+ memberTenant := domain.Tenant{ID: "tenant-member", Slug: "actual-member"}
+
+ mockKeto.On("ListObjects", ctx, "Tenant", "members", "User:"+userID).
+ Return([]string{memberTenant.ID}, nil).
+ Once()
+ mockRepo.On("FindByIDs", ctx, []string{memberTenant.ID}).
+ Return([]domain.Tenant{memberTenant}, nil).
+ Once()
+
+ result, err := svc.ListJoinedTenants(ctx, userID)
+
+ assert.NoError(t, err)
+ assert.Equal(t, []domain.Tenant{memberTenant}, result)
+ mockKeto.AssertNotCalled(t, "ListObjects", ctx, "Tenant", "owners", "User:"+userID)
+ mockKeto.AssertNotCalled(t, "ListObjects", ctx, "Tenant", "admins", "User:"+userID)
+ mockKeto.AssertExpectations(t)
+ mockRepo.AssertExpectations(t)
+}
diff --git a/backend/internal/service/worksmobile_client.go b/backend/internal/service/worksmobile_client.go
index faac2fc0..92250e03 100644
--- a/backend/internal/service/worksmobile_client.go
+++ b/backend/internal/service/worksmobile_client.go
@@ -53,6 +53,8 @@ type WorksmobileHTTPClient struct {
DomainIDs []int64
OrgUnitWriteDelay time.Duration
tokenCache worksmobileAccessTokenCache
+ levelCache map[int64][]WorksmobileUserLevel
+ levelCacheMu sync.Mutex
now func() time.Time
}
@@ -326,8 +328,21 @@ func (c *WorksmobileHTTPClient) DeleteOrgUnit(ctx context.Context, orgUnitID str
}
func (c *WorksmobileHTTPClient) CreateUser(ctx context.Context, payload WorksmobileUserPayload) error {
+ var err error
payload = normalizeWorksmobileUserCreatePayload(payload)
- return c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload)
+ levelDomainID := worksmobilePayloadLevelDomainID(payload)
+ levelID, err := c.resolveWorksmobilePayloadLevelIDForDomain(ctx, payload, levelDomainID)
+ if err != nil {
+ return err
+ }
+ payload.LevelID = ""
+ if err := c.sendDirectoryJSON(ctx, http.MethodPost, "/v1.0/users", payload); err != nil {
+ return err
+ }
+ if levelID != "" {
+ return c.PatchUserOrganizationLevelByName(ctx, payload.Email, levelDomainID, levelID)
+ }
+ return nil
}
func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error {
@@ -346,6 +361,12 @@ func (c *WorksmobileHTTPClient) UpdateUserOnly(ctx context.Context, payload Work
}
func (c *WorksmobileHTTPClient) updateUserByPatchOnly(ctx context.Context, payload WorksmobileUserPayload) error {
+ levelDomainID := worksmobilePayloadLevelDomainID(payload)
+ levelID, err := c.resolveWorksmobilePayloadLevelIDForDomain(ctx, payload, levelDomainID)
+ if err != nil {
+ return err
+ }
+ payload.LevelID = ""
identifier := strings.TrimSpace(payload.Email)
if identifier == "" {
identifier = strings.TrimSpace(payload.UserExternalKey)
@@ -369,6 +390,9 @@ func (c *WorksmobileHTTPClient) updateUserByPatchOnly(ctx context.Context, paylo
}
return patchErr
}
+ if levelID != "" {
+ return c.PatchUserOrganizationLevelByName(ctx, identifier, levelDomainID, levelID)
+ }
return nil
}
@@ -585,6 +609,221 @@ func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string
return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), payload)
}
+func (c *WorksmobileHTTPClient) PatchUserLevel(ctx context.Context, identifier string, domainID int64, levelID string) error {
+ identifier = strings.TrimSpace(identifier)
+ levelID = strings.TrimSpace(levelID)
+ if identifier == "" {
+ return fmt.Errorf("worksmobile user identifier is required")
+ }
+ if domainID <= 0 {
+ return fmt.Errorf("worksmobile domain id is required")
+ }
+ if levelID == "" {
+ return nil
+ }
+ payload := map[string]any{
+ "domainId": domainID,
+ "level": WorksmobileUserLevelRef{
+ LevelID: levelID,
+ },
+ }
+ return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), payload)
+}
+
+func (c *WorksmobileHTTPClient) PatchUserLevelByName(ctx context.Context, identifier string, domainID int64, levelName string) error {
+ payload, err := c.resolveWorksmobilePayloadLevelID(ctx, WorksmobileUserPayload{
+ DomainID: domainID,
+ LevelID: levelName,
+ })
+ if err != nil {
+ return err
+ }
+ return c.PatchUserLevel(ctx, identifier, domainID, payload.LevelID)
+}
+
+func (c *WorksmobileHTTPClient) PatchUserOrganizationLevelByName(ctx context.Context, identifier string, domainID int64, levelName string) error {
+ identifier = strings.TrimSpace(identifier)
+ if identifier == "" {
+ return fmt.Errorf("worksmobile user identifier is required")
+ }
+ payload, err := c.resolveWorksmobilePayloadLevelID(ctx, WorksmobileUserPayload{
+ DomainID: domainID,
+ LevelID: levelName,
+ })
+ if err != nil {
+ return err
+ }
+
+ var raw map[string]any
+ if err := c.getDirectoryJSON(ctx, "/v1.0/users/"+url.PathEscape(identifier), &raw); err != nil {
+ return err
+ }
+ rawOrganizations, ok := raw["organizations"].([]any)
+ if !ok || len(rawOrganizations) == 0 {
+ return fmt.Errorf("worksmobile user organizations are missing: %s", identifier)
+ }
+ organizations := make([]any, 0, len(rawOrganizations))
+ updated := false
+ for _, rawOrganization := range rawOrganizations {
+ organization, ok := rawOrganization.(map[string]any)
+ if !ok {
+ continue
+ }
+ next := make(map[string]any, len(organization)+1)
+ for key, value := range organization {
+ next[key] = value
+ }
+ if !updated && worksmobileRawDomainID(next["domainId"]) == domainID {
+ next["levelId"] = payload.LevelID
+ updated = true
+ }
+ organizations = append(organizations, next)
+ }
+ if !updated {
+ return fmt.Errorf("worksmobile user organization not found for domain_id=%d: %s", domainID, identifier)
+ }
+ request := map[string]any{
+ "domainId": domainID,
+ "email": firstStringFromMap(raw, "email", "loginId", "userName"),
+ "userName": raw["userName"],
+ "organizations": organizations,
+ }
+ if value := firstStringFromMap(raw, "userExternalKey", "externalKey", "externalId"); value != "" {
+ request["userExternalKey"] = value
+ }
+ return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(identifier), request)
+}
+
+func worksmobileRawDomainID(raw any) int64 {
+ switch value := raw.(type) {
+ case int64:
+ return value
+ case int:
+ return int64(value)
+ case float64:
+ return int64(value)
+ case json.Number:
+ parsed, _ := value.Int64()
+ return parsed
+ case string:
+ parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
+ return parsed
+ default:
+ return 0
+ }
+}
+
+func (c *WorksmobileHTTPClient) ListUserLevels(ctx context.Context, domainID int64) ([]WorksmobileUserLevel, error) {
+ if domainID <= 0 {
+ return nil, fmt.Errorf("worksmobile domain id is required")
+ }
+ c.levelCacheMu.Lock()
+ if c.levelCache != nil {
+ if cached, ok := c.levelCache[domainID]; ok {
+ c.levelCacheMu.Unlock()
+ return cached, nil
+ }
+ }
+ c.levelCacheMu.Unlock()
+
+ var response struct {
+ Levels []WorksmobileUserLevel `json:"levels"`
+ }
+ if err := c.getDirectoryJSON(ctx, "/v1.0/users/levels?domainId="+strconv.FormatInt(domainID, 10), &response); err != nil {
+ return nil, err
+ }
+ c.levelCacheMu.Lock()
+ if c.levelCache == nil {
+ c.levelCache = map[int64][]WorksmobileUserLevel{}
+ }
+ c.levelCache[domainID] = response.Levels
+ c.levelCacheMu.Unlock()
+ return response.Levels, nil
+}
+
+func (c *WorksmobileHTTPClient) resolveWorksmobilePayloadLevelID(ctx context.Context, payload WorksmobileUserPayload) (WorksmobileUserPayload, error) {
+ level := strings.TrimSpace(payload.LevelID)
+ if level == "" {
+ return payload, nil
+ }
+ if isLikelyWorksmobileUUID(level) {
+ payload.LevelID = level
+ return payload, nil
+ }
+ if isWorksmobileExternalKeyLevelID(level) {
+ payload.LevelID = level
+ return payload, nil
+ }
+ levels, err := c.ListUserLevels(ctx, payload.DomainID)
+ if err != nil {
+ return WorksmobileUserPayload{}, err
+ }
+ for _, candidate := range levels {
+ if strings.TrimSpace(candidate.LevelID) == level || strings.TrimSpace(candidate.LevelName) == level {
+ payload.LevelID = strings.TrimSpace(candidate.LevelID)
+ return payload, nil
+ }
+ }
+ return WorksmobileUserPayload{}, fmt.Errorf("worksmobile level not found: domain_id=%d level=%s", payload.DomainID, level)
+}
+
+func (c *WorksmobileHTTPClient) resolveWorksmobilePayloadLevelIDForDomain(ctx context.Context, payload WorksmobileUserPayload, domainID int64) (string, error) {
+ level := strings.TrimSpace(payload.LevelID)
+ if level == "" {
+ return "", nil
+ }
+ levelPayload := payload
+ levelPayload.DomainID = domainID
+ resolved, err := c.resolveWorksmobilePayloadLevelID(ctx, levelPayload)
+ if err != nil {
+ return "", err
+ }
+ return strings.TrimSpace(resolved.LevelID), nil
+}
+
+func worksmobilePayloadLevelDomainID(payload WorksmobileUserPayload) int64 {
+ if payload.LevelDomainID > 0 {
+ return payload.LevelDomainID
+ }
+ if domainID := worksmobilePayloadPrimaryOrganizationDomainID(payload); domainID > 0 {
+ return domainID
+ }
+ return payload.DomainID
+}
+
+func worksmobilePayloadPrimaryOrganizationDomainID(payload WorksmobileUserPayload) int64 {
+ for _, organization := range payload.Organizations {
+ if organization.Primary && organization.DomainID > 0 {
+ return organization.DomainID
+ }
+ }
+ for _, organization := range payload.Organizations {
+ if organization.DomainID > 0 {
+ return organization.DomainID
+ }
+ }
+ return 0
+}
+
+func isLikelyWorksmobileUUID(value string) bool {
+ value = strings.TrimSpace(value)
+ if len(value) != 36 {
+ return false
+ }
+ for i, ch := range value {
+ if i == 8 || i == 13 || i == 18 || i == 23 {
+ if ch != '-' {
+ return false
+ }
+ continue
+ }
+ if (ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') && (ch < 'A' || ch > 'F') {
+ return false
+ }
+ }
+ return true
+}
+
func (c *WorksmobileHTTPClient) DeleteUser(ctx context.Context, userID string) error {
userID = strings.TrimSpace(userID)
if userID == "" {
@@ -1074,6 +1313,15 @@ type WorksmobileUserPatchPayload struct {
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
}
+type WorksmobileUserLevelRef struct {
+ LevelID string `json:"levelId"`
+}
+
+type WorksmobileUserLevel struct {
+ LevelID string `json:"levelId"`
+ LevelName string `json:"levelName"`
+}
+
type WorksmobileOrgUnitPatchPayload struct {
DomainID int64 `json:"domainId"`
Email string `json:"email,omitempty"`
@@ -1268,6 +1516,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse
"employeeId",
"employeeID",
),
+ DomainID: worksmobileRawDomainID(resource["domainId"]),
LevelID: parseWorksmobileUserLevelID(resource),
LevelName: parseWorksmobileUserLevelName(resource),
Task: firstStringFromMap(resource, "task", "job", "jobDescription"),
@@ -1396,6 +1645,9 @@ func parseWorksmobileUserLevelID(resource map[string]any) string {
if level, ok := resource["level"].(map[string]any); ok {
return firstStringFromMap(level, "levelId", "id", "value")
}
+ if value := parseWorksmobileOrganizationLevel(resource, "levelId", "id", "value"); value != "" {
+ return value
+ }
return ""
}
@@ -1406,9 +1658,42 @@ func parseWorksmobileUserLevelName(resource map[string]any) string {
if level, ok := resource["level"].(map[string]any); ok {
return firstStringFromMap(level, "levelName", "displayName", "name")
}
+ if value := parseWorksmobileOrganizationLevel(resource, "levelName", "displayName", "name"); value != "" {
+ return value
+ }
return ""
}
+func parseWorksmobileOrganizationLevel(resource map[string]any, keys ...string) string {
+ rawOrganizations, ok := resource["organizations"].([]any)
+ if !ok {
+ return ""
+ }
+ fallback := ""
+ for _, raw := range rawOrganizations {
+ organization, ok := raw.(map[string]any)
+ if !ok {
+ continue
+ }
+ value := firstStringFromMap(organization, keys...)
+ if value == "" {
+ if level, ok := organization["level"].(map[string]any); ok {
+ value = firstStringFromMap(level, keys...)
+ }
+ }
+ if value == "" {
+ continue
+ }
+ if boolFromMap(organization, "primary") {
+ return value
+ }
+ if fallback == "" {
+ fallback = value
+ }
+ }
+ return fallback
+}
+
type worksmobileOrgUnitDetail struct {
ID string
Name string
@@ -1481,11 +1766,23 @@ func parseWorksmobileUserOrganizationList(raw any) []WorksmobileUserOrganization
if len(orgUnits) == 0 {
continue
}
+ levelID := firstStringFromMap(organization, "levelId")
+ levelName := firstStringFromMap(organization, "levelName")
+ if level, ok := organization["level"].(map[string]any); ok {
+ if levelID == "" {
+ levelID = firstStringFromMap(level, "levelId", "id", "value")
+ }
+ if levelName == "" {
+ levelName = firstStringFromMap(level, "levelName", "displayName", "name")
+ }
+ }
organizations = append(organizations, WorksmobileUserOrganization{
- DomainID: int64FromMap(organization, "domainId"),
- Email: firstStringFromMap(organization, "email"),
- Primary: boolFromMap(organization, "primary"),
- OrgUnits: orgUnits,
+ DomainID: int64FromMap(organization, "domainId"),
+ Email: firstStringFromMap(organization, "email"),
+ Primary: boolFromMap(organization, "primary"),
+ LevelID: levelID,
+ LevelName: levelName,
+ OrgUnits: orgUnits,
})
}
return organizations
diff --git a/backend/internal/service/worksmobile_client_test.go b/backend/internal/service/worksmobile_client_test.go
index ef08f9ae..c9632bcd 100644
--- a/backend/internal/service/worksmobile_client_test.go
+++ b/backend/internal/service/worksmobile_client_test.go
@@ -92,10 +92,263 @@ func TestNewWorksmobileUserPatchPayloadNormalizesMalformedKoreanCellPhone(t *tes
DomainID: 1001,
Email: "phone-canonical@samaneng.com",
CellPhone: "+82+821062836786",
+ LevelID: "level-manager",
UserName: WorksmobileUserName{LastName: "Phone Canonical User"},
+ Organizations: []WorksmobileUserOrganization{
+ {
+ DomainID: 1001,
+ Primary: true,
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:tenant-1", Primary: true}},
+ },
+ },
})
require.Equal(t, "+82 01062836786", payload.CellPhone)
+ data, err := json.Marshal(payload)
+ require.NoError(t, err)
+ require.NotContains(t, string(data), "level-manager")
+}
+
+func TestWorksmobileHTTPClientUpdateUserResolvesLevelNameToLevelObject(t *testing.T) {
+ transport := &captureRoundTripper{
+ responses: []captureResponse{
+ {statusCode: http.StatusOK, body: `{"levels":[{"levelId":"level-deputy","levelName":"대리"}]}`},
+ {statusCode: http.StatusOK, body: `{}`},
+ {statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":true,"orgUnits":[{"orgUnitId":"works-org","primary":true}]}]}`},
+ {statusCode: http.StatusOK, body: `{}`},
+ },
+ }
+ client := &WorksmobileHTTPClient{
+ BaseURL: "https://works.example.test",
+ DirectoryToken: "directory-token-1",
+ HTTPClient: &http.Client{Transport: transport},
+ }
+
+ err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
+ DomainID: 300286336,
+ Email: "grade@samaneng.com",
+ LevelID: "대리",
+ UserName: WorksmobileUserName{LastName: "Grade User"},
+ })
+
+ require.NoError(t, err)
+ require.Len(t, transport.requests, 4)
+ require.Equal(t, http.MethodGet, transport.requests[0].Method)
+ require.Equal(t, "/v1.0/users/levels", transport.requests[0].URL.Path)
+ require.Equal(t, "300286336", transport.requests[0].URL.Query().Get("domainId"))
+ require.Equal(t, http.MethodPatch, transport.requests[1].Method)
+ require.Equal(t, http.MethodGet, transport.requests[2].Method)
+ require.Equal(t, http.MethodPatch, transport.requests[3].Method)
+
+ var fullPatch map[string]any
+ require.Len(t, transport.requestBodies, 2)
+ require.NoError(t, json.Unmarshal(transport.requestBodies[0], &fullPatch))
+ require.NotContains(t, fullPatch, "level")
+ require.NotContains(t, fullPatch, "levelId")
+
+ var levelPatch map[string]any
+ require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
+ organizations := levelPatch["organizations"].([]any)
+ organization := organizations[0].(map[string]any)
+ require.Equal(t, "level-deputy", organization["levelId"])
+ require.NotContains(t, levelPatch, "levelId")
+}
+
+func TestWorksmobileHTTPClientUpdateUserPassesLevelExternalKeyThrough(t *testing.T) {
+ transport := &captureRoundTripper{
+ responses: []captureResponse{
+ {statusCode: http.StatusOK, body: `{}`},
+ {statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":true,"orgUnits":[{"orgUnitId":"works-org","primary":true}]}]}`},
+ {statusCode: http.StatusOK, body: `{}`},
+ },
+ }
+ client := &WorksmobileHTTPClient{
+ BaseURL: "https://works.example.test",
+ DirectoryToken: "directory-token-1",
+ HTTPClient: &http.Client{Transport: transport},
+ }
+
+ err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
+ DomainID: 300286336,
+ Email: "grade@samaneng.com",
+ LevelID: "externalKey:lead",
+ UserName: WorksmobileUserName{LastName: "Grade User"},
+ })
+
+ require.NoError(t, err)
+ require.Len(t, transport.requests, 3)
+ require.Equal(t, http.MethodPatch, transport.requests[0].Method)
+ require.Equal(t, http.MethodGet, transport.requests[1].Method)
+ require.Equal(t, http.MethodPatch, transport.requests[2].Method)
+
+ var levelPatch map[string]any
+ require.Len(t, transport.requestBodies, 2)
+ require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
+ organizations := levelPatch["organizations"].([]any)
+ organization := organizations[0].(map[string]any)
+ require.Equal(t, "externalKey:lead", organization["levelId"])
+}
+
+func TestWorksmobileHTTPClientUpdateUserInfersLevelDomainFromPrimaryOrganization(t *testing.T) {
+ transport := &captureRoundTripper{
+ responses: []captureResponse{
+ {statusCode: http.StatusOK, body: `{}`},
+ {statusCode: http.StatusOK, body: `{"domainId":300285955,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300285955,"primary":false,"orgUnits":[{"orgUnitId":"works-saman","primary":true}]},{"domainId":300286337,"primary":true,"orgUnits":[{"orgUnitId":"works-gpdtdc","primary":true}]}]}`},
+ {statusCode: http.StatusOK, body: `{}`},
+ },
+ }
+ client := &WorksmobileHTTPClient{
+ BaseURL: "https://works.example.test",
+ DirectoryToken: "directory-token-1",
+ HTTPClient: &http.Client{Transport: transport},
+ }
+
+ err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
+ DomainID: 300285955,
+ Email: "grade@samaneng.com",
+ LevelID: "externalKey:prin",
+ UserName: WorksmobileUserName{LastName: "Grade User"},
+ Organizations: []WorksmobileUserOrganization{
+ {
+ DomainID: 300286337,
+ Primary: true,
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:gpdtdc", Primary: true}},
+ },
+ {
+ DomainID: 300285955,
+ Primary: false,
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:saman", Primary: true}},
+ },
+ },
+ })
+
+ require.NoError(t, err)
+ require.Len(t, transport.requests, 3)
+
+ var levelPatch map[string]any
+ require.Len(t, transport.requestBodies, 2)
+ require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
+ require.Equal(t, float64(300286337), levelPatch["domainId"])
+ organizations := levelPatch["organizations"].([]any)
+ require.NotContains(t, organizations[0].(map[string]any), "levelId")
+ require.Equal(t, "externalKey:prin", organizations[1].(map[string]any)["levelId"])
+}
+
+func TestWorksmobileHTTPClientUpdateUserUsesLevelDomainForOrganizationLevel(t *testing.T) {
+ transport := &captureRoundTripper{
+ responses: []captureResponse{
+ {statusCode: http.StatusOK, body: `{"levels":[{"levelId":"level-principal","levelName":"수석"}]}`},
+ {statusCode: http.StatusOK, body: `{}`},
+ {statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":false,"orgUnits":[{"orgUnitId":"works-hanmac","primary":true}]},{"domainId":300286337,"primary":true,"orgUnits":[{"orgUnitId":"works-gsim","primary":true}]}]}`},
+ {statusCode: http.StatusOK, body: `{}`},
+ },
+ }
+ client := &WorksmobileHTTPClient{
+ BaseURL: "https://works.example.test",
+ DirectoryToken: "directory-token-1",
+ HTTPClient: &http.Client{Transport: transport},
+ }
+
+ err := client.UpdateUserOnly(context.Background(), WorksmobileUserPayload{
+ DomainID: 300286336,
+ LevelDomainID: 300286337,
+ Email: "grade@samaneng.com",
+ LevelID: "수석",
+ UserName: WorksmobileUserName{LastName: "Grade User"},
+ })
+
+ require.NoError(t, err)
+ require.Len(t, transport.requests, 4)
+ require.Equal(t, http.MethodGet, transport.requests[0].Method)
+ require.Equal(t, "/v1.0/users/levels", transport.requests[0].URL.Path)
+ require.Equal(t, "300286337", transport.requests[0].URL.Query().Get("domainId"))
+
+ var levelPatch map[string]any
+ require.Len(t, transport.requestBodies, 2)
+ require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPatch))
+ require.Equal(t, float64(300286337), levelPatch["domainId"])
+ organizations := levelPatch["organizations"].([]any)
+ require.NotContains(t, organizations[0].(map[string]any), "levelId")
+ require.Equal(t, "level-principal", organizations[1].(map[string]any)["levelId"])
+}
+
+func TestDecodeWorksmobileUserRequestAcceptsStoredLevelName(t *testing.T) {
+ var payload WorksmobileUserPayload
+
+ err := decodeWorksmobileRequest(domain.JSONMap{
+ "request": map[string]any{
+ "domainId": int64(300286336),
+ "email": "grade@samaneng.com",
+ "levelName": "대리",
+ "userName": map[string]any{"lastName": "Grade User"},
+ },
+ }, &payload)
+
+ require.NoError(t, err)
+ require.Equal(t, "대리", payload.LevelID)
+}
+
+func TestWorksmobileUserPayloadJSONPreservesLevelDomainID(t *testing.T) {
+ encoded, err := json.Marshal(WorksmobileUserPayload{
+ DomainID: 300285955,
+ LevelDomainID: 300286337,
+ Email: "tester@samaneng.com",
+ LevelID: "externalKey:prin",
+ UserName: WorksmobileUserName{LastName: "Tester"},
+ })
+ require.NoError(t, err)
+
+ var raw map[string]any
+ require.NoError(t, json.Unmarshal(encoded, &raw))
+ require.Equal(t, float64(300286337), raw["levelDomainId"])
+
+ var decoded WorksmobileUserPayload
+ require.NoError(t, json.Unmarshal(encoded, &decoded))
+ require.Equal(t, int64(300286337), decoded.LevelDomainID)
+ require.Equal(t, "externalKey:prin", decoded.LevelID)
+}
+
+func TestWorksmobileHTTPClientCreateUserSendsLevelInSeparatePatch(t *testing.T) {
+ transport := &captureRoundTripper{
+ responses: []captureResponse{
+ {statusCode: http.StatusOK, body: `{"levels":[{"levelId":"level-deputy","levelName":"대리"}]}`},
+ {statusCode: http.StatusOK, body: `{}`},
+ {statusCode: http.StatusOK, body: `{"domainId":300286336,"email":"grade@samaneng.com","userExternalKey":"user-grade","userName":{"lastName":"Grade User"},"organizations":[{"domainId":300286336,"primary":true,"orgUnits":[{"orgUnitId":"works-org","primary":true}]}]}`},
+ {statusCode: http.StatusOK, body: `{}`},
+ },
+ }
+ client := &WorksmobileHTTPClient{
+ BaseURL: "https://works.example.test",
+ DirectoryToken: "directory-token-1",
+ HTTPClient: &http.Client{Transport: transport},
+ }
+
+ err := client.CreateUser(context.Background(), WorksmobileUserPayload{
+ DomainID: 300286336,
+ Email: "grade@samaneng.com",
+ LevelID: "대리",
+ UserName: WorksmobileUserName{LastName: "Grade User"},
+ })
+
+ require.NoError(t, err)
+ require.Len(t, transport.requests, 4)
+ require.Equal(t, http.MethodGet, transport.requests[0].Method)
+ require.Equal(t, http.MethodPost, transport.requests[1].Method)
+ require.Equal(t, http.MethodGet, transport.requests[2].Method)
+ require.Equal(t, http.MethodPatch, transport.requests[3].Method)
+
+ var createPayload map[string]any
+ require.Len(t, transport.requestBodies, 2)
+ require.NoError(t, json.Unmarshal(transport.requestBodies[0], &createPayload))
+ require.NotContains(t, createPayload, "level")
+ require.NotContains(t, createPayload, "levelId")
+ require.NotContains(t, createPayload, "levelName")
+
+ var levelPayload map[string]any
+ require.NoError(t, json.Unmarshal(transport.requestBodies[1], &levelPayload))
+ organizations := levelPayload["organizations"].([]any)
+ organization := organizations[0].(map[string]any)
+ require.Equal(t, "level-deputy", organization["levelId"])
}
func TestNewWorksmobileSCIMUserPayloadNormalizesMalformedKoreanCellPhone(t *testing.T) {
@@ -1561,6 +1814,26 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing
require.True(t, *user.OrgUnitManagers["works-org-1"])
}
+func TestParseWorksmobileDirectoryUserReadsOrganizationLevel(t *testing.T) {
+ user := parseWorksmobileDirectoryUser(map[string]any{
+ "userId": "works-user",
+ "email": "tester@samaneng.com",
+ "userName": map[string]any{
+ "lastName": "홍길동",
+ },
+ "organizations": []any{
+ map[string]any{
+ "primary": true,
+ "levelId": "level-1",
+ "levelName": "책임",
+ },
+ },
+ })
+
+ require.Equal(t, "level-1", user.LevelID)
+ require.Equal(t, "책임", user.LevelName)
+}
+
func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.T) {
user := parseWorksmobileDirectoryUser(map[string]any{
"userId": "works-user",
@@ -1572,7 +1845,9 @@ func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.
},
"organizations": []any{
map[string]any{
- "primary": true,
+ "primary": true,
+ "levelId": "level-1",
+ "levelName": "책임",
"orgUnits": []any{
map[string]any{
"orgUnitId": "externalKey:primary-org",
@@ -1598,7 +1873,9 @@ func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.
require.Equal(t, "EMP001", user.EmployeeNumber)
require.Equal(t, []WorksmobileUserOrganization{
{
- Primary: true,
+ Primary: true,
+ LevelID: "level-1",
+ LevelName: "책임",
OrgUnits: []WorksmobileUserOrgUnit{
{OrgUnitID: "externalKey:primary-org", Primary: true, IsManager: boolPtr(false)},
{OrgUnitID: "externalKey:secondary-org", Primary: false, IsManager: boolPtr(true)},
diff --git a/backend/internal/service/worksmobile_mapper.go b/backend/internal/service/worksmobile_mapper.go
index be642bc9..05e654a1 100644
--- a/backend/internal/service/worksmobile_mapper.go
+++ b/backend/internal/service/worksmobile_mapper.go
@@ -38,6 +38,8 @@ type WorksmobileUserPayload struct {
PrivateEmail string `json:"privateEmail,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"`
Locale string `json:"locale,omitempty"`
+ LevelID string `json:"-"`
+ LevelDomainID int64 `json:"levelDomainId,omitempty"`
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
Task string `json:"task,omitempty"`
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
@@ -70,6 +72,8 @@ func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
PrivateEmail string `json:"privateEmail,omitempty"`
AliasEmails []string `json:"aliasEmails,omitempty"`
Locale string `json:"locale,omitempty"`
+ LevelName string `json:"levelName,omitempty"`
+ LevelDomainID int64 `json:"levelDomainId,omitempty"`
PasswordConfig *WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
Task string `json:"task,omitempty"`
Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
@@ -90,22 +94,75 @@ func (p WorksmobileUserPayload) MarshalJSON() ([]byte, error) {
PrivateEmail: p.PrivateEmail,
AliasEmails: p.AliasEmails,
Locale: p.Locale,
+ LevelName: strings.TrimSpace(p.LevelID),
+ LevelDomainID: p.LevelDomainID,
PasswordConfig: passwordConfig,
Task: p.Task,
Organizations: p.Organizations,
})
}
+func (p *WorksmobileUserPayload) UnmarshalJSON(data []byte) error {
+ type payloadJSON struct {
+ DomainID int64 `json:"domainId"`
+ Email string `json:"email"`
+ UserExternalKey string `json:"userExternalKey,omitempty"`
+ UserName WorksmobileUserName `json:"userName"`
+ CellPhone string `json:"cellPhone,omitempty"`
+ EmployeeNumber string `json:"employeeNumber,omitempty"`
+ PrivateEmail string `json:"privateEmail,omitempty"`
+ AliasEmails []string `json:"aliasEmails,omitempty"`
+ Locale string `json:"locale,omitempty"`
+ LevelID string `json:"levelId,omitempty"`
+ LevelName string `json:"levelName,omitempty"`
+ LevelDomainID int64 `json:"levelDomainId,omitempty"`
+ Level *WorksmobileUserLevelRef `json:"level,omitempty"`
+ PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig,omitempty"`
+ Task string `json:"task,omitempty"`
+ Organizations []WorksmobileUserOrganization `json:"organizations,omitempty"`
+ }
+ var raw payloadJSON
+ if err := json.Unmarshal(data, &raw); err != nil {
+ return err
+ }
+ levelID := strings.TrimSpace(raw.LevelName)
+ if levelID == "" {
+ levelID = strings.TrimSpace(raw.LevelID)
+ }
+ if levelID == "" && raw.Level != nil {
+ levelID = strings.TrimSpace(raw.Level.LevelID)
+ }
+ *p = WorksmobileUserPayload{
+ DomainID: raw.DomainID,
+ Email: raw.Email,
+ UserExternalKey: raw.UserExternalKey,
+ UserName: raw.UserName,
+ CellPhone: raw.CellPhone,
+ EmployeeNumber: raw.EmployeeNumber,
+ PrivateEmail: raw.PrivateEmail,
+ AliasEmails: raw.AliasEmails,
+ Locale: raw.Locale,
+ LevelID: levelID,
+ LevelDomainID: raw.LevelDomainID,
+ PasswordConfig: raw.PasswordConfig,
+ Task: raw.Task,
+ Organizations: raw.Organizations,
+ }
+ return nil
+}
+
type WorksmobilePasswordResetPayload struct {
Email string `json:"email"`
PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"`
}
type WorksmobileUserOrganization struct {
- DomainID int64 `json:"domainId,omitempty"`
- Email string `json:"email,omitempty"`
- Primary bool `json:"primary"`
- OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
+ DomainID int64 `json:"domainId,omitempty"`
+ Email string `json:"email,omitempty"`
+ Primary bool `json:"primary"`
+ LevelID string `json:"levelId,omitempty"`
+ LevelName string `json:"levelName,omitempty"`
+ OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"`
}
type WorksmobileUserOrgUnit struct {
@@ -231,6 +288,10 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
if err != nil {
return WorksmobileUserPayload{}, err
}
+ levelID, levelDomainID, err := worksmobileUserLevel(user, tenantByID, rootConfig)
+ if err != nil {
+ return WorksmobileUserPayload{}, err
+ }
if task == "" {
task = strings.TrimSpace(user.JobTitle)
}
@@ -242,6 +303,8 @@ func buildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain
CellPhone: domain.NormalizePhoneNumber(user.Phone),
EmployeeNumber: employeeNumber,
Locale: "ko_KR",
+ LevelID: levelID,
+ LevelDomainID: levelDomainID,
Task: task,
Organizations: organizations,
}
@@ -254,6 +317,7 @@ type worksmobileAppointment struct {
IsPrimary bool
IsManager bool
HasManager bool
+ Grade string
JobTitle string
PositionID string
Source string
@@ -267,6 +331,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
appointments = append([]worksmobileAppointment{{
TenantID: tenant.ID,
IsPrimary: true,
+ Grade: strings.TrimSpace(user.Grade),
JobTitle: strings.TrimSpace(user.JobTitle),
PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
}}, appointments...)
@@ -277,6 +342,7 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
appointments = append([]worksmobileAppointment{{
TenantID: accountDomainTenant.ID,
IsPrimary: true,
+ Grade: strings.TrimSpace(user.Grade),
JobTitle: strings.TrimSpace(user.JobTitle),
PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"),
}}, appointments...)
@@ -286,6 +352,10 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
organizationIndexByDomainID := map[int64]int{}
seen := map[string]bool{}
task := ""
+ fallbackOrganizationIndex := -1
+ fallbackTask := ""
+ primaryOrganizationIndex := -1
+ primaryTask := ""
for _, appointment := range appointments {
if appointment.TenantID == "" || seen[appointment.TenantID] {
continue
@@ -303,8 +373,8 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
continue
}
if isWorksmobileDomainRootTenant(appointmentTenant) {
- if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" && task == "" {
- task = strings.TrimSpace(appointment.JobTitle)
+ if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" && primaryTask == "" {
+ primaryTask = strings.TrimSpace(appointment.JobTitle)
}
seen[appointment.TenantID] = true
continue
@@ -317,50 +387,104 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t
if err != nil {
return nil, "", err
}
- isPrimaryOrganization := !worksmobileOrganizationsHavePrimary(organizations)
+ levelID, levelName := worksmobileOrganizationLevelForAppointment(appointment, tenantByID)
organizationIndex, organizationExists := organizationIndexByDomainID[domainID]
orgUnit := WorksmobileUserOrgUnit{
OrgUnitID: "externalKey:" + appointmentTenant.ID,
Primary: !organizationExists,
PositionID: appointment.PositionID,
}
+ if appointment.IsPrimary {
+ orgUnit.Primary = true
+ }
if appointment.HasManager {
isManager := appointment.IsManager
orgUnit.IsManager = &isManager
}
if organizationExists {
- if isPrimaryOrganization {
- organizations[organizationIndex].Primary = true
- organizations[organizationIndex].Email = worksmobileOrganizationEmail(user, domainTenant)
+ if appointment.IsPrimary {
+ for index := range organizations[organizationIndex].OrgUnits {
+ organizations[organizationIndex].OrgUnits[index].Primary = false
+ }
}
+ worksmobileApplyOrganizationLevel(&organizations[organizationIndex], levelID, levelName, appointment.IsPrimary)
organizations[organizationIndex].OrgUnits = append(organizations[organizationIndex].OrgUnits, orgUnit)
} else {
organizationIndexByDomainID[domainID] = len(organizations)
+ organizationIndex = len(organizations)
organizations = append(organizations, WorksmobileUserOrganization{
- DomainID: domainID,
- Email: worksmobileOrganizationEmail(user, domainTenant),
- Primary: isPrimaryOrganization,
- OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
+ DomainID: domainID,
+ Email: worksmobileOrganizationEmail(user, domainTenant),
+ LevelID: levelID,
+ LevelName: levelName,
+ OrgUnits: []WorksmobileUserOrgUnit{orgUnit},
})
}
- if isPrimaryOrganization && strings.TrimSpace(appointment.JobTitle) != "" {
- task = strings.TrimSpace(appointment.JobTitle)
+ if fallbackOrganizationIndex == -1 {
+ fallbackOrganizationIndex = organizationIndex
+ }
+ if fallbackTask == "" && strings.TrimSpace(appointment.JobTitle) != "" {
+ fallbackTask = strings.TrimSpace(appointment.JobTitle)
+ }
+ if appointment.IsPrimary && primaryOrganizationIndex == -1 {
+ primaryOrganizationIndex = organizationIndex
+ }
+ if appointment.IsPrimary && primaryTask == "" && strings.TrimSpace(appointment.JobTitle) != "" {
+ primaryTask = strings.TrimSpace(appointment.JobTitle)
}
seen[appointment.TenantID] = true
}
if len(organizations) == 0 {
+ if primaryTask != "" {
+ task = primaryTask
+ } else {
+ task = fallbackTask
+ }
return nil, task, nil
}
- if !worksmobileOrganizationsHavePrimary(organizations) {
- organizations[0].Primary = true
- if len(organizations[0].OrgUnits) > 0 {
- organizations[0].OrgUnits[0].Primary = true
- }
+ selectedOrganizationIndex := primaryOrganizationIndex
+ if selectedOrganizationIndex == -1 {
+ selectedOrganizationIndex = fallbackOrganizationIndex
+ }
+ if selectedOrganizationIndex == -1 {
+ selectedOrganizationIndex = 0
+ }
+ for index := range organizations {
+ organizations[index].Primary = index == selectedOrganizationIndex
+ }
+ if len(organizations[selectedOrganizationIndex].OrgUnits) > 0 && !worksmobileOrgUnitsHavePrimary(organizations[selectedOrganizationIndex].OrgUnits) {
+ organizations[selectedOrganizationIndex].OrgUnits[0].Primary = true
+ }
+ if primaryTask != "" {
+ task = primaryTask
+ } else {
+ task = fallbackTask
}
sortWorksmobileOrganizations(organizations)
return organizations, task, nil
}
+func worksmobileOrganizationLevelForAppointment(appointment worksmobileAppointment, tenantByID map[string]domain.Tenant) (string, string) {
+ levelID := worksmobileLevelIDForTenant(appointment.Grade, appointment.TenantID, tenantByID)
+ if levelID == "" {
+ return "", ""
+ }
+ if isWorksmobileExternalKeyLevelID(levelID) {
+ return levelID, WorksmobileLevelDisplayNameForIdentifier(levelID)
+ }
+ return "", levelID
+}
+
+func worksmobileApplyOrganizationLevel(organization *WorksmobileUserOrganization, levelID, levelName string, prefer bool) {
+ if organization == nil || (strings.TrimSpace(levelID) == "" && strings.TrimSpace(levelName) == "") {
+ return
+ }
+ if (strings.TrimSpace(organization.LevelID) == "" && strings.TrimSpace(organization.LevelName) == "") || prefer {
+ organization.LevelID = levelID
+ organization.LevelName = levelName
+ }
+}
+
func worksmobileAppointmentsContainTenant(appointments []worksmobileAppointment, tenantID string) bool {
tenantID = strings.TrimSpace(tenantID)
if tenantID == "" {
@@ -466,6 +590,15 @@ func worksmobileOrganizationsHavePrimary(organizations []WorksmobileUserOrganiza
return false
}
+func worksmobileOrgUnitsHavePrimary(orgUnits []WorksmobileUserOrgUnit) bool {
+ for _, orgUnit := range orgUnits {
+ if orgUnit.Primary {
+ return true
+ }
+ }
+ return false
+}
+
func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment {
rawAppointments, ok := metadata["additionalAppointments"].([]any)
if !ok {
@@ -480,6 +613,7 @@ func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileA
appointment := worksmobileAppointment{
TenantID: metadataString(domain.JSONMap(item), "tenantId", "tenant_id"),
IsPrimary: metadataBool(domain.JSONMap(item), "isPrimary", "primary"),
+ Grade: metadataString(domain.JSONMap(item), "grade"),
JobTitle: metadataString(domain.JSONMap(item), "jobTitle", "job_title", "task"),
PositionID: metadataString(domain.JSONMap(item), "worksmobilePositionId", "positionId", "position_id"),
Source: metadataString(domain.JSONMap(item), "assignmentSource", "source"),
@@ -493,6 +627,193 @@ func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileA
return appointments
}
+func worksmobileUserGrade(user domain.User) string {
+ grade, _ := worksmobileUserGradeWithTenant(user)
+ return grade
+}
+
+func worksmobileUserLevel(user domain.User, tenantByID map[string]domain.Tenant, rootConfig domain.JSONMap) (string, int64, error) {
+ grade, tenantID := worksmobileUserGradeWithTenant(user)
+ grade = worksmobileLevelIDForTenant(grade, tenantID, tenantByID)
+ if grade == "" {
+ return "", 0, nil
+ }
+ tenant, ok := tenantByID[strings.TrimSpace(tenantID)]
+ if !ok {
+ return grade, 0, nil
+ }
+ domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID)
+ domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig)
+ if err != nil {
+ return "", 0, err
+ }
+ return grade, domainID, nil
+}
+
+func worksmobileUserGradeWithTenant(user domain.User) (string, string) {
+ appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
+ for _, appointment := range appointments {
+ if appointment.IsPrimary && strings.TrimSpace(appointment.Grade) != "" {
+ return strings.TrimSpace(appointment.Grade), strings.TrimSpace(appointment.TenantID)
+ }
+ }
+ for _, appointment := range appointments {
+ if strings.TrimSpace(appointment.Grade) != "" {
+ return strings.TrimSpace(appointment.Grade), strings.TrimSpace(appointment.TenantID)
+ }
+ }
+ return "", ""
+}
+
+const worksmobileExternalKeyLevelIDPrefix = "externalKey:"
+
+type worksmobileGPDTDCLevelMapping struct {
+ DisplayName string
+ ExternalKey string
+ Aliases []string
+}
+
+var worksmobileGPDTDCLevelMappings = []worksmobileGPDTDCLevelMapping{
+ {DisplayName: "사장", ExternalKey: "pres", Aliases: []string{"사장"}},
+ {DisplayName: "부사장", ExternalKey: "vp", Aliases: []string{"부사장"}},
+ {DisplayName: "수석 연구원", ExternalKey: "prin", Aliases: []string{"수석", "수석연구원", "수석 연구원"}},
+ {DisplayName: "책임 연구원", ExternalKey: "lead", Aliases: []string{"책임", "책임연구원", "책임 연구원"}},
+ {DisplayName: "선임 연구원", ExternalKey: "sen", Aliases: []string{"선임", "선임연구원", "선임 연구원"}},
+ {DisplayName: "연구원", ExternalKey: "res", Aliases: []string{"연구원"}},
+}
+
+func normalizeWorksmobileGradeForTenant(grade, tenantID string, tenantByID map[string]domain.Tenant) string {
+ grade = strings.TrimSpace(grade)
+ if grade == "" {
+ return ""
+ }
+ if directorLevel := normalizeWorksmobileDirectorLevelName(grade); directorLevel != "" {
+ return directorLevel
+ }
+ if !worksmobileTenantIsGPDTDCDescendant(tenantID, tenantByID) {
+ return grade
+ }
+ if level, ok := worksmobileGPDTDCLevelMappingForGrade(grade); ok {
+ return level.DisplayName
+ }
+ return grade
+}
+
+func normalizeWorksmobileDirectorLevelName(grade string) string {
+ switch strings.ReplaceAll(strings.TrimSpace(grade), " ", "") {
+ case "상무":
+ return "상무이사"
+ case "전무":
+ return "전무이사"
+ default:
+ return ""
+ }
+}
+
+func worksmobileLevelIDForTenant(grade, tenantID string, tenantByID map[string]domain.Tenant) string {
+ displayName := normalizeWorksmobileGradeForTenant(grade, tenantID, tenantByID)
+ if displayName == "" || !worksmobileTenantIsGPDTDCDescendant(tenantID, tenantByID) {
+ return displayName
+ }
+ if level, ok := worksmobileGPDTDCLevelMappingForGrade(displayName); ok {
+ return worksmobileExternalKeyLevelID(level.ExternalKey)
+ }
+ return displayName
+}
+
+func worksmobileExternalKeyLevelID(externalKey string) string {
+ externalKey = strings.TrimSpace(externalKey)
+ if externalKey == "" {
+ return ""
+ }
+ if strings.HasPrefix(externalKey, worksmobileExternalKeyLevelIDPrefix) {
+ return externalKey
+ }
+ return worksmobileExternalKeyLevelIDPrefix + externalKey
+}
+
+func isWorksmobileExternalKeyLevelID(levelID string) bool {
+ return strings.HasPrefix(strings.TrimSpace(levelID), worksmobileExternalKeyLevelIDPrefix)
+}
+
+func worksmobileGPDTDCLevelMappingForGrade(grade string) (worksmobileGPDTDCLevelMapping, bool) {
+ compact := strings.ReplaceAll(strings.TrimSpace(grade), " ", "")
+ if compact == "" {
+ return worksmobileGPDTDCLevelMapping{}, false
+ }
+ for _, level := range worksmobileGPDTDCLevelMappings {
+ for _, alias := range level.Aliases {
+ if strings.ReplaceAll(strings.TrimSpace(alias), " ", "") == compact {
+ return level, true
+ }
+ }
+ }
+ return worksmobileGPDTDCLevelMapping{}, false
+}
+
+func worksmobileGPDTDCLevelMappingForExternalKey(levelID string) (worksmobileGPDTDCLevelMapping, bool) {
+ key := strings.TrimSpace(levelID)
+ key = strings.TrimPrefix(key, worksmobileExternalKeyLevelIDPrefix)
+ if key == "" {
+ return worksmobileGPDTDCLevelMapping{}, false
+ }
+ for _, level := range worksmobileGPDTDCLevelMappings {
+ if level.ExternalKey == key {
+ return level, true
+ }
+ }
+ return worksmobileGPDTDCLevelMapping{}, false
+}
+
+func WorksmobileLevelDisplayNameForIdentifier(levelID string) string {
+ levelID = strings.TrimSpace(levelID)
+ if levelID == "" {
+ return ""
+ }
+ if level, ok := worksmobileGPDTDCLevelMappingForExternalKey(levelID); ok {
+ return level.DisplayName
+ }
+ return levelID
+}
+
+func WorksmobileLevelIdentifierMatchesRemote(expectedLevelID, remoteLevelID, remoteLevelName string) bool {
+ expectedLevelID = strings.TrimSpace(expectedLevelID)
+ remoteLevelID = strings.TrimSpace(remoteLevelID)
+ remoteLevelName = strings.TrimSpace(remoteLevelName)
+ if expectedLevelID == "" {
+ return remoteLevelID == "" && remoteLevelName == ""
+ }
+ if remoteLevelID == expectedLevelID || remoteLevelName == expectedLevelID {
+ return true
+ }
+ if worksmobileDirectorLevelNamesEquivalent(expectedLevelID, remoteLevelName) {
+ return true
+ }
+ if level, ok := worksmobileGPDTDCLevelMappingForExternalKey(expectedLevelID); ok {
+ if remoteLevelID == level.ExternalKey || remoteLevelName == level.DisplayName {
+ return true
+ }
+ for _, alias := range level.Aliases {
+ if strings.TrimSpace(alias) == remoteLevelName {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func worksmobileDirectorLevelNamesEquivalent(expectedLevelName, remoteLevelName string) bool {
+ expectedLevelName = strings.ReplaceAll(strings.TrimSpace(expectedLevelName), " ", "")
+ remoteLevelName = strings.ReplaceAll(strings.TrimSpace(remoteLevelName), " ", "")
+ if expectedLevelName == "" || remoteLevelName == "" {
+ return false
+ }
+ return (expectedLevelName == "상무이사" && remoteLevelName == "상무") ||
+ (expectedLevelName == "상무" && remoteLevelName == "상무이사") ||
+ (expectedLevelName == "전무이사" && remoteLevelName == "전무") ||
+ (expectedLevelName == "전무" && remoteLevelName == "전무이사")
+}
+
func sortWorksmobileOrganizations(organizations []WorksmobileUserOrganization) {
sort.SliceStable(organizations, func(i, j int) bool {
if organizations[i].Primary != organizations[j].Primary {
diff --git a/backend/internal/service/worksmobile_mapper_test.go b/backend/internal/service/worksmobile_mapper_test.go
index 61305376..101af670 100644
--- a/backend/internal/service/worksmobile_mapper_test.go
+++ b/backend/internal/service/worksmobile_mapper_test.go
@@ -91,11 +91,18 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
Email: "john1@samaneng.com",
Name: "John Doe",
Phone: "+19144812222",
- Position: "Manager",
+ Position: "Team Lead",
JobTitle: "Sales management",
TenantID: &tenantID,
Metadata: domain.JSONMap{
"employee_id": "AB001",
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": tenantID,
+ "isPrimary": true,
+ "grade": "Manager",
+ },
+ },
},
}
tenant := domain.Tenant{
@@ -138,6 +145,7 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T)
require.Equal(t, "+19144812222", payload.CellPhone)
require.Equal(t, "AB001", payload.EmployeeNumber)
require.Equal(t, "Sales management", payload.Task)
+ require.Equal(t, "Manager", payload.LevelID)
require.Empty(t, payload.PrivateEmail)
require.Empty(t, payload.AliasEmails)
require.Equal(t, "ko_KR", payload.Locale)
@@ -172,6 +180,71 @@ func TestBuildWorksmobileUserPayloadDeduplicatesKoreanCountryCodeInCellPhone(t *
require.Equal(t, "+821091917771", payload.CellPhone)
}
+func TestBuildWorksmobileUserPayloadIgnoresTopLevelUserGrade(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ tenantID := "33333333-3333-3333-3333-333333333333"
+ user := domain.User{
+ ID: "44444444-4444-4444-4444-444444444444",
+ Email: "john1@samaneng.com",
+ Name: "John Doe",
+ Grade: "책임",
+ TenantID: &tenantID,
+ }
+ tenant := domain.Tenant{
+ ID: tenantID,
+ Slug: "saman",
+ Name: "Saman",
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ }
+
+ payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
+
+ require.NoError(t, err)
+ require.Empty(t, payload.LevelID)
+}
+
+func TestBuildWorksmobileUserPayloadNormalizesDirectorLevelNames(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ tenantID := "33333333-3333-3333-3333-333333333333"
+ tenant := domain.Tenant{
+ ID: tenantID,
+ Slug: "saman",
+ Name: "Saman",
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ }
+ for _, tc := range []struct {
+ grade string
+ expected string
+ }{
+ {grade: "상무", expected: "상무이사"},
+ {grade: "전무", expected: "전무이사"},
+ } {
+ t.Run(tc.grade, func(t *testing.T) {
+ user := domain.User{
+ ID: "44444444-4444-4444-4444-444444444444",
+ Email: "director@samaneng.com",
+ Name: "Director",
+ TenantID: &tenantID,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": tenantID,
+ "isPrimary": true,
+ "grade": tc.grade,
+ },
+ },
+ },
+ }
+
+ payload, err := BuildWorksmobileUserPayload(user, tenant, nil)
+
+ require.NoError(t, err)
+ require.Equal(t, tc.expected, payload.LevelID)
+ require.Equal(t, int64(1001), payload.LevelDomainID)
+ })
+ }
+}
+
func TestWorksmobileUserPayloadJSONOmitsEmptyPasswordConfig(t *testing.T) {
data, err := json.Marshal(WorksmobileUserPayload{
DomainID: 1001,
@@ -315,22 +388,183 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test
)
require.NoError(t, err)
- require.Equal(t, "PM", payload.Task)
+ require.Equal(t, "Engineering", payload.Task)
require.Len(t, payload.Organizations, 2)
- require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
+ require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
- require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
+ require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
- require.NotNil(t, payload.Organizations[0].OrgUnits[0].IsManager)
- require.True(t, *payload.Organizations[0].OrgUnits[0].IsManager)
- require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
+ require.Nil(t, payload.Organizations[0].OrgUnits[0].IsManager)
+ require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
- require.Equal(t, "externalKey:"+primaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
+ require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[1].OrgUnits[0].Primary)
- require.Nil(t, payload.Organizations[1].OrgUnits[0].IsManager)
+ require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager)
+ require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager)
}
-func TestBuildWorksmobileUserPayloadUsesFirstSyncableAppointmentAsWorksmobilePrimary(t *testing.T) {
+func TestBuildWorksmobileUserPayloadMapsAppointmentGradeToOrganizationLevelName(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ rootID := "11111111-1111-1111-1111-111111111111"
+ tenantID := "22222222-2222-2222-2222-222222222222"
+ user := domain.User{
+ ID: "33333333-3333-3333-3333-333333333333",
+ Email: "principal@samaneng.com",
+ Name: "Principal Researcher",
+ TenantID: &tenantID,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": tenantID,
+ "isPrimary": true,
+ "grade": "수석 연구원",
+ },
+ },
+ },
+ }
+ rootTenant := domain.Tenant{
+ ID: rootID,
+ Slug: "saman",
+ Name: "삼안",
+ Type: domain.TenantTypeCompany,
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ }
+ tenant := domain.Tenant{
+ ID: tenantID,
+ Slug: "general-structure-div",
+ Name: "일반구조물 div",
+ Type: domain.TenantTypeOrganization,
+ ParentID: &rootID,
+ }
+
+ payload, err := BuildWorksmobileUserPayloadForDomainTenants(
+ user,
+ tenant,
+ map[string]domain.Tenant{
+ rootID: rootTenant,
+ tenantID: tenant,
+ },
+ nil,
+ )
+
+ require.NoError(t, err)
+ require.Len(t, payload.Organizations, 1)
+ require.Equal(t, "수석 연구원", payload.Organizations[0].LevelName)
+ require.Empty(t, payload.Organizations[0].LevelID)
+}
+
+func TestBuildWorksmobileUserPayloadUsesPrimaryAppointmentGradeForOrganizationLevel(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ rootID := "11111111-1111-1111-1111-111111111111"
+ firstTenantID := "22222222-2222-2222-2222-222222222222"
+ primaryTenantID := "33333333-3333-3333-3333-333333333333"
+ user := domain.User{
+ ID: "44444444-4444-4444-4444-444444444444",
+ Email: "primary-grade@samaneng.com",
+ Name: "Primary Grade User",
+ TenantID: &primaryTenantID,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": firstTenantID,
+ "isPrimary": false,
+ "grade": "책임",
+ },
+ map[string]any{
+ "tenantId": primaryTenantID,
+ "isPrimary": true,
+ "grade": "수석 연구원",
+ },
+ },
+ },
+ }
+ rootTenant := domain.Tenant{
+ ID: rootID,
+ Slug: "saman",
+ Name: "삼안",
+ Type: domain.TenantTypeCompany,
+ Domains: []domain.TenantDomain{{Domain: "samaneng.com"}},
+ }
+ firstTenant := domain.Tenant{
+ ID: firstTenantID,
+ Slug: "first-team",
+ Name: "First Team",
+ Type: domain.TenantTypeOrganization,
+ ParentID: &rootID,
+ }
+ primaryTenant := domain.Tenant{
+ ID: primaryTenantID,
+ Slug: "primary-team",
+ Name: "Primary Team",
+ Type: domain.TenantTypeOrganization,
+ ParentID: &rootID,
+ }
+
+ payload, err := BuildWorksmobileUserPayloadForDomainTenants(
+ user,
+ primaryTenant,
+ map[string]domain.Tenant{
+ rootID: rootTenant,
+ firstTenantID: firstTenant,
+ primaryTenantID: primaryTenant,
+ },
+ nil,
+ )
+
+ require.NoError(t, err)
+ require.Len(t, payload.Organizations, 1)
+ require.Equal(t, "수석 연구원", payload.Organizations[0].LevelName)
+ require.Empty(t, payload.Organizations[0].LevelID)
+}
+
+func TestBuildWorksmobileUserPayloadMapsGPDTDCAppointmentGradeToOrganizationLevelID(t *testing.T) {
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "11111111-1111-1111-1111-111111111111"
+ gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
+ tenantID := "22222222-2222-2222-2222-222222222222"
+ user := domain.User{
+ ID: "33333333-3333-3333-3333-333333333333",
+ Email: "principal@baroncs.co.kr",
+ Name: "GPDTDC Principal",
+ TenantID: &tenantID,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": tenantID,
+ "isPrimary": true,
+ "grade": "수석 연구원",
+ },
+ },
+ },
+ }
+ rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
+ gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
+ tenant := domain.Tenant{
+ ID: tenantID,
+ Slug: "gsim-dev",
+ Name: "GSIM개발",
+ Type: domain.TenantTypeOrganization,
+ ParentID: &gpdtdcID,
+ }
+
+ payload, err := BuildWorksmobileUserPayloadForDomainTenants(
+ user,
+ tenant,
+ map[string]domain.Tenant{
+ rootID: rootTenant,
+ gpdtdcID: gpdtdcTenant,
+ tenantID: tenant,
+ },
+ nil,
+ )
+
+ require.NoError(t, err)
+ require.Len(t, payload.Organizations, 1)
+ require.Equal(t, "externalKey:prin", payload.Organizations[0].LevelID)
+ require.Equal(t, "수석 연구원", payload.Organizations[0].LevelName)
+}
+
+func TestBuildWorksmobileUserPayloadUsesPrimaryAppointmentAsWorksmobilePrimary(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
t.Setenv("HANMAC_DOMAIN_ID", "1002")
hanmacRootID := "11111111-1111-1111-1111-111111111111"
@@ -398,11 +632,11 @@ func TestBuildWorksmobileUserPayloadUsesFirstSyncableAppointmentAsWorksmobilePri
require.NoError(t, err)
require.Len(t, payload.Organizations, 2)
- require.Equal(t, int64(1002), payload.Organizations[0].DomainID)
+ require.Equal(t, int64(1001), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
- require.Equal(t, "externalKey:"+firstTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
+ require.Equal(t, "externalKey:"+secondTenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID)
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
- require.Equal(t, int64(1001), payload.Organizations[1].DomainID)
+ require.Equal(t, int64(1002), payload.Organizations[1].DomainID)
require.False(t, payload.Organizations[1].Primary)
}
@@ -626,6 +860,7 @@ func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOr
map[string]any{
"tenantId": leafTenantID,
"isPrimary": true,
+ "grade": "수석",
},
},
},
@@ -661,6 +896,8 @@ func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOr
require.NoError(t, err)
require.Equal(t, int64(1001), payload.DomainID)
+ require.Equal(t, "externalKey:prin", payload.LevelID)
+ require.Equal(t, int64(1003), payload.LevelDomainID)
require.Len(t, payload.Organizations, 1)
require.Equal(t, int64(1003), payload.Organizations[0].DomainID)
require.True(t, payload.Organizations[0].Primary)
@@ -669,6 +906,137 @@ func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOr
require.True(t, payload.Organizations[0].OrgUnits[0].Primary)
}
+func TestBuildWorksmobileUserPayloadNormalizesGPDTDCResearchLevelName(t *testing.T) {
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "11111111-1111-1111-1111-111111111111"
+ gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
+ leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
+ user := domain.User{
+ ID: "44444444-4444-4444-4444-444444444444",
+ Email: "researcher@baroncs.co.kr",
+ Name: "GPDTDC Researcher",
+ TenantID: &leafTenantID,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": leafTenantID,
+ "isPrimary": true,
+ "grade": "책임연구원",
+ },
+ },
+ },
+ }
+ rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
+ gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
+ leafTenant := domain.Tenant{ID: leafTenantID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID}
+
+ payload, err := BuildWorksmobileUserPayloadForDomainTenants(
+ user,
+ leafTenant,
+ map[string]domain.Tenant{
+ rootID: rootTenant,
+ gpdtdcID: gpdtdcTenant,
+ leafTenantID: leafTenant,
+ },
+ nil,
+ )
+
+ require.NoError(t, err)
+ require.Equal(t, "externalKey:lead", payload.LevelID)
+ require.Equal(t, int64(1003), payload.LevelDomainID)
+}
+
+func TestBuildWorksmobileUserPayloadUsesGPDLevelCSVExternalKeyForPresident(t *testing.T) {
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "11111111-1111-1111-1111-111111111111"
+ gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
+ leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
+ user := domain.User{
+ ID: "44444444-4444-4444-4444-444444444444",
+ Email: "president@baroncs.co.kr",
+ Name: "GPDTDC President",
+ TenantID: &leafTenantID,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": leafTenantID,
+ "isPrimary": true,
+ "grade": "사장",
+ },
+ },
+ },
+ }
+ rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
+ gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
+ leafTenant := domain.Tenant{ID: leafTenantID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID}
+
+ payload, err := BuildWorksmobileUserPayloadForDomainTenants(
+ user,
+ leafTenant,
+ map[string]domain.Tenant{
+ rootID: rootTenant,
+ gpdtdcID: gpdtdcTenant,
+ leafTenantID: leafTenant,
+ },
+ nil,
+ )
+
+ require.NoError(t, err)
+ require.Equal(t, "externalKey:pres", payload.LevelID)
+ require.Equal(t, int64(1003), payload.LevelDomainID)
+}
+
+func TestBuildWorksmobileUserPayloadUsesGPDTDCDirectorLevelNameWithoutExternalKey(t *testing.T) {
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "11111111-1111-1111-1111-111111111111"
+ gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee"
+ leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193"
+ user := domain.User{
+ ID: "44444444-4444-4444-4444-444444444444",
+ Email: "director@baroncs.co.kr",
+ Name: "GPDTDC Director",
+ TenantID: &leafTenantID,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": leafTenantID,
+ "isPrimary": true,
+ "grade": "전무이사",
+ },
+ },
+ },
+ }
+ rootTenant := domain.Tenant{ID: rootID, Slug: HanmacFamilyTenantSlug, Type: domain.TenantTypeCompanyGroup}
+ gpdtdcTenant := domain.Tenant{ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID}
+ leafTenant := domain.Tenant{ID: leafTenantID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID}
+
+ payload, err := BuildWorksmobileUserPayloadForDomainTenants(
+ user,
+ leafTenant,
+ map[string]domain.Tenant{
+ rootID: rootTenant,
+ gpdtdcID: gpdtdcTenant,
+ leafTenantID: leafTenant,
+ },
+ nil,
+ )
+
+ require.NoError(t, err)
+ require.Equal(t, "전무이사", payload.LevelID)
+ require.Equal(t, int64(1003), payload.LevelDomainID)
+}
+
+func TestWorksmobileLevelIdentifierMatchesRemoteAcceptsGPDLevelAliases(t *testing.T) {
+ require.True(t, WorksmobileLevelIdentifierMatchesRemote("externalKey:prin", "91515bed-0d5f-4711-78fa-03894597fd2c", "수석연구원"))
+ require.True(t, WorksmobileLevelIdentifierMatchesRemote("externalKey:lead", "8fde782c-1a46-4bd6-7653-0344a3f66fa5", "책임연구원"))
+ require.True(t, WorksmobileLevelIdentifierMatchesRemote("externalKey:sen", "8c272083-3cca-47a0-79e2-039cba57b2cc", "선임연구원"))
+}
+
+func TestWorksmobileLevelIdentifierMatchesRemoteAcceptsDirectorLevelAliases(t *testing.T) {
+ require.True(t, WorksmobileLevelIdentifierMatchesRemote("상무이사", "level-managing-director", "상무"))
+ require.True(t, WorksmobileLevelIdentifierMatchesRemote("전무이사", "level-executive-director", "전무"))
+}
+
func TestWorksmobileUserPayloadJSONIncludesFalsePrimaryFields(t *testing.T) {
payload := WorksmobileUserPayload{
Email: "user@samaneng.com",
diff --git a/backend/internal/service/worksmobile_relay_worker.go b/backend/internal/service/worksmobile_relay_worker.go
index e160a8fb..034b8878 100644
--- a/backend/internal/service/worksmobile_relay_worker.go
+++ b/backend/internal/service/worksmobile_relay_worker.go
@@ -290,6 +290,9 @@ func decodeWorksmobileRequest(payload domain.JSONMap, target any) error {
if err != nil {
return err
}
+ if _, ok := target.(*WorksmobileUserPayload); ok {
+ return json.Unmarshal(data, target)
+ }
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
return decoder.Decode(target)
diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go
index 1b2515d9..3827fd95 100644
--- a/backend/internal/service/worksmobile_sync_service.go
+++ b/backend/internal/service/worksmobile_sync_service.go
@@ -107,49 +107,69 @@ type WorksmobileComparison struct {
}
type WorksmobileComparisonItem struct {
- ResourceType string `json:"resourceType"`
- BaronID string `json:"baronId,omitempty"`
- BaronSlug string `json:"baronSlug,omitempty"`
- BaronName string `json:"baronName,omitempty"`
- BaronEmail string `json:"baronEmail,omitempty"`
- BaronPhone string `json:"baronPhone,omitempty"`
- BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
- BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
- BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
- BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
- BaronParentID string `json:"baronParentId,omitempty"`
- BaronParentSlug string `json:"baronParentSlug,omitempty"`
- BaronParentName string `json:"baronParentName,omitempty"`
- WorksmobileID string `json:"worksmobileId,omitempty"`
- ExternalKey string `json:"externalKey,omitempty"`
- WorksmobileName string `json:"worksmobileName,omitempty"`
- WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
- WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
- WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
- WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
- WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
- WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
- WorksmobileTask string `json:"worksmobileTask,omitempty"`
- WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
- WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
- WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
- WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
- WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
- WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
- WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
- BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
- BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
- BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
- WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
- WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
- WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
- WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
- WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
- WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
- WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
- WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
- UpdateReasons []string `json:"updateReasons,omitempty"`
- Status string `json:"status"`
+ ResourceType string `json:"resourceType"`
+ BaronID string `json:"baronId,omitempty"`
+ BaronSlug string `json:"baronSlug,omitempty"`
+ BaronName string `json:"baronName,omitempty"`
+ BaronEmail string `json:"baronEmail,omitempty"`
+ BaronPhone string `json:"baronPhone,omitempty"`
+ BaronEmployeeNumber string `json:"baronEmployeeNumber,omitempty"`
+ BaronGrade string `json:"baronGrade,omitempty"`
+ BaronPrimaryOrgID string `json:"baronPrimaryOrgId,omitempty"`
+ BaronPrimaryOrgSlug string `json:"baronPrimaryOrgSlug,omitempty"`
+ BaronPrimaryOrgName string `json:"baronPrimaryOrgName,omitempty"`
+ BaronParentID string `json:"baronParentId,omitempty"`
+ BaronParentSlug string `json:"baronParentSlug,omitempty"`
+ BaronParentName string `json:"baronParentName,omitempty"`
+ WorksmobileID string `json:"worksmobileId,omitempty"`
+ ExternalKey string `json:"externalKey,omitempty"`
+ WorksmobileName string `json:"worksmobileName,omitempty"`
+ WorksmobileEmail string `json:"worksmobileEmail,omitempty"`
+ WorksmobilePhone string `json:"worksmobilePhone,omitempty"`
+ WorksmobileEmployeeNumber string `json:"worksmobileEmployeeNumber,omitempty"`
+ WorksmobileAccountStatus string `json:"worksmobileAccountStatus,omitempty"`
+ WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
+ WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
+ WorksmobileTask string `json:"worksmobileTask,omitempty"`
+ WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
+ WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
+ WorksmobilePrimaryOrgID string `json:"worksmobilePrimaryOrgId,omitempty"`
+ WorksmobilePrimaryOrgName string `json:"worksmobilePrimaryOrgName,omitempty"`
+ WorksmobilePrimaryOrgPositionID string `json:"worksmobilePrimaryOrgPositionId,omitempty"`
+ WorksmobilePrimaryOrgPositionName string `json:"worksmobilePrimaryOrgPositionName,omitempty"`
+ WorksmobilePrimaryOrgIsManager *bool `json:"worksmobilePrimaryOrgIsManager,omitempty"`
+ BaronParentWorksmobileID string `json:"baronParentWorksmobileId,omitempty"`
+ BaronParentWorksmobileName string `json:"baronParentWorksmobileName,omitempty"`
+ BaronParentWorksmobileEmail string `json:"baronParentWorksmobileEmail,omitempty"`
+ WorksmobileParentID string `json:"worksmobileParentId,omitempty"`
+ WorksmobileParentName string `json:"worksmobileParentName,omitempty"`
+ WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"`
+ WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"`
+ WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"`
+ WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"`
+ WorksmobileLastError string `json:"worksmobileLastError,omitempty"`
+ WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"`
+ UserMemberships []WorksmobileUserMembershipComparison `json:"userMemberships,omitempty"`
+ UpdateReasons []string `json:"updateReasons,omitempty"`
+ Status string `json:"status"`
+}
+
+type WorksmobileUserMembershipComparison struct {
+ BaronOrgID string `json:"baronOrgId,omitempty"`
+ BaronOrgSlug string `json:"baronOrgSlug,omitempty"`
+ BaronOrgName string `json:"baronOrgName,omitempty"`
+ BaronGrade string `json:"baronGrade,omitempty"`
+ BaronPrimary bool `json:"baronPrimary,omitempty"`
+ WorksmobileDomainID int64 `json:"worksmobileDomainId,omitempty"`
+ WorksmobileDomainName string `json:"worksmobileDomainName,omitempty"`
+ WorksmobileOrgID string `json:"worksmobileOrgId,omitempty"`
+ WorksmobileOrgName string `json:"worksmobileOrgName,omitempty"`
+ WorksmobileLevelID string `json:"worksmobileLevelId,omitempty"`
+ WorksmobileLevelName string `json:"worksmobileLevelName,omitempty"`
+ WorksmobileOrgPositionID string `json:"worksmobileOrgPositionId,omitempty"`
+ WorksmobileOrgIsManager *bool `json:"worksmobileOrgIsManager,omitempty"`
+ WorksmobilePrimary bool `json:"worksmobilePrimary,omitempty"`
+ GradeNeedsUpdate bool `json:"gradeNeedsUpdate,omitempty"`
}
type worksmobileSyncService struct {
@@ -362,7 +382,7 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
tenantIDs = append(tenantIDs, tenant.ID)
}
}
- users, err := s.comparisonUsers(ctx, tenantIDs)
+ users, err := s.comparisonUsers(ctx, tenantIDs, tenantByID)
if err != nil {
return WorksmobileComparison{}, err
}
@@ -383,7 +403,7 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str
}, nil
}
-func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string) ([]domain.User, error) {
+func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs []string, tenantByID map[string]domain.Tenant) ([]domain.User, error) {
if s.identityMirror != nil {
status, err := s.identityMirror.GetIdentityCacheStatus(ctx)
if err == nil &&
@@ -394,32 +414,100 @@ func (s *worksmobileSyncService) comparisonUsers(ctx context.Context, tenantIDs
if err != nil {
return nil, err
}
- return worksmobileUsersFromIdentityMirror(identities, tenantIDs), nil
+ return worksmobileUsersFromIdentityMirror(identities, tenantIDs, tenantByID), nil
}
}
return s.userRepo.FindByTenantIDs(ctx, tenantIDs)
}
-func worksmobileUsersFromIdentityMirror(identities []KratosIdentity, tenantIDs []string) []domain.User {
+func worksmobileUsersFromIdentityMirror(identities []KratosIdentity, tenantIDs []string, tenantMaps ...map[string]domain.Tenant) []domain.User {
allowed := make(map[string]bool, len(tenantIDs))
for _, tenantID := range tenantIDs {
- allowed[strings.TrimSpace(tenantID)] = true
+ tenantID = strings.TrimSpace(tenantID)
+ if tenantID == "" {
+ continue
+ }
+ allowed[strings.ToLower(tenantID)] = true
+ if len(tenantMaps) > 0 {
+ if tenant, ok := tenantMaps[0][tenantID]; ok {
+ if slug := strings.TrimSpace(tenant.Slug); slug != "" {
+ allowed[strings.ToLower(slug)] = true
+ }
+ }
+ }
}
users := make([]domain.User, 0, len(identities))
for _, identity := range identities {
- tenantID := traitString(identity.Traits, "tenant_id")
- if tenantID == "" || !allowed[tenantID] {
+ if !worksmobileIdentityMirrorMatchesTenant(identity.Traits, allowed) {
continue
}
user := worksmobileUserFromIdentity(identity)
+ if user.TenantID == nil || strings.TrimSpace(*user.TenantID) == "" {
+ if tenantID := worksmobileIdentityMirrorTenantID(identity.Traits, allowed); tenantID != "" {
+ user.TenantID = &tenantID
+ }
+ }
users = append(users, user)
}
return users
}
+func worksmobileIdentityMirrorMatchesTenant(traits map[string]any, allowed map[string]bool) bool {
+ for _, key := range identityMirrorTenantKeys(traits) {
+ if allowed[strings.ToLower(strings.TrimSpace(key))] {
+ return true
+ }
+ }
+ return false
+}
+
+func worksmobileIdentityMirrorTenantID(traits map[string]any, allowed map[string]bool) string {
+ appointments := identityMirrorAppointments(traits["additionalAppointments"])
+ if len(appointments) == 0 {
+ if metadata, ok := traits["metadata"].(map[string]any); ok {
+ appointments = identityMirrorAppointments(metadata["additionalAppointments"])
+ }
+ }
+ for _, appointment := range appointments {
+ if !metadataBool(domain.JSONMap(appointment), "isPrimary", "primary") {
+ continue
+ }
+ if tenantID := worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment, allowed); tenantID != "" {
+ return tenantID
+ }
+ }
+ for _, appointment := range appointments {
+ if tenantID := worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment, allowed); tenantID != "" {
+ return tenantID
+ }
+ }
+ return ""
+}
+
+func worksmobileIdentityMirrorAllowedAppointmentTenantID(appointment map[string]any, allowed map[string]bool) string {
+ tenantID := strings.TrimSpace(identityMirrorAnyString(appointment["tenantId"]))
+ if tenantID == "" {
+ tenantID = strings.TrimSpace(identityMirrorAnyString(appointment["tenant_id"]))
+ }
+ if tenantID != "" && allowed[strings.ToLower(tenantID)] {
+ return tenantID
+ }
+ tenantSlug := strings.TrimSpace(identityMirrorAnyString(appointment["tenantSlug"]))
+ if tenantSlug == "" {
+ tenantSlug = strings.TrimSpace(identityMirrorAnyString(appointment["slug"]))
+ }
+ if tenantSlug != "" && allowed[strings.ToLower(tenantSlug)] {
+ return tenantID
+ }
+ return ""
+}
+
func worksmobileUserFromIdentity(identity KratosIdentity) domain.User {
metadata := domain.JSONMap{}
for key, value := range identity.Traits {
+ if key == "grade" {
+ continue
+ }
metadata[key] = value
}
tenantID := traitString(identity.Traits, "tenant_id")
@@ -438,7 +526,6 @@ func worksmobileUserFromIdentity(identity KratosIdentity) domain.User {
Role: domain.NormalizeRole(traitString(identity.Traits, "role")),
AffiliationType: traitString(identity.Traits, "affiliationType"),
Department: traitString(identity.Traits, "department"),
- Grade: traitString(identity.Traits, "grade"),
Position: traitString(identity.Traits, "position"),
JobTitle: traitString(identity.Traits, "jobTitle"),
Metadata: metadata,
@@ -595,10 +682,8 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan
return nil, errors.New("worksmobile orgunit not found")
}
tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...))
- if tenant, ok := findWorksmobileOrgUnitTenantByRemoteLocalPart(*target, scopeTenants, tenantByID); ok {
- return s.enqueueOrgUnitUpsert(ctx, root, tenant, scopeTenants)
- }
- if isProtectedWorksmobileRemoteOrgUnit(*root, scopeTenants, *target) {
+ _, matchedLocalPart := findWorksmobileOrgUnitTenantByRemoteLocalPart(*target, scopeTenants, tenantByID)
+ if !matchedLocalPart && isProtectedWorksmobileRemoteOrgUnit(*root, scopeTenants, *target) {
return nil, errors.New("protected worksmobile domain root orgunit cannot be deleted")
}
item := &domain.WorksmobileOutbox{
@@ -1622,7 +1707,9 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))]
}
updateReasons := []string(nil)
+ gradeComparison := worksmobileUserGradeComparison{}
if matched {
+ gradeComparison = worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID)
updateReasons = worksmobileUserUpdateReasons(user, remote, localTenants, remoteOrgUnitByExternalID)
}
needsUpdate := len(updateReasons) > 0
@@ -1630,6 +1717,10 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
matchedRemoteIDs[remote.ID] = true
continue
}
+ baronGrade, _ := worksmobileUserComparisonGradeWithTenant(user, remote, localTenants, remoteOrgUnitByExternalID)
+ if strings.TrimSpace(gradeComparison.LocalGrade) != "" {
+ baronGrade = gradeComparison.LocalGrade
+ }
item := WorksmobileComparisonItem{
ResourceType: "USER",
BaronID: user.ID,
@@ -1637,6 +1728,7 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
BaronEmail: user.Email,
BaronPhone: user.Phone,
BaronEmployeeNumber: metadataEmployeeNumber(user.Metadata),
+ BaronGrade: baronGrade,
BaronPrimaryOrgID: worksmobileUserPrimaryOrgID(user),
BaronPrimaryOrgSlug: worksmobileUserPrimaryOrgSlug(user, localTenants),
BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants),
@@ -1665,6 +1757,10 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
item.WorksmobileAccountStatus = worksmobileRemoteAccountStatus(remote)
item.WorksmobileLevelID = remote.LevelID
item.WorksmobileLevelName = remote.LevelName
+ if gradeComparison.NeedsUpdate {
+ item.WorksmobileLevelID = gradeComparison.RemoteLevelID
+ item.WorksmobileLevelName = gradeComparison.RemoteLevelName
+ }
item.WorksmobileTask = remote.Task
item.WorksmobileDomainID = remote.DomainID
item.WorksmobileDomainName = remote.DomainName
@@ -1673,6 +1769,7 @@ func compareWorksmobileUsersWithRemoteGroups(localUsers []domain.User, remoteUse
item.WorksmobilePrimaryOrgPositionID = remote.PrimaryOrgUnitPositionID
item.WorksmobilePrimaryOrgPositionName = remote.PrimaryOrgUnitPositionName
item.WorksmobilePrimaryOrgIsManager = remote.PrimaryOrgUnitIsManager
+ item.UserMemberships = worksmobileUserMembershipComparisons(user, remote, localTenants, remoteOrgUnitByExternalID, gradeComparison)
matchedRemoteIDs[remote.ID] = true
}
result = append(result, item)
@@ -1779,6 +1876,9 @@ func worksmobileUserUpdateReasons(user domain.User, remote WorksmobileRemoteUser
if worksmobileUserEmployeeNumberNeedsUpdate(user, remote) {
reasons = append(reasons, "employee_number")
}
+ if worksmobileUserGradeNeedsUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
+ reasons = append(reasons, "grade")
+ }
if worksmobileUserOrganizationsNeedUpdate(user, remote, localTenants, remoteOrgUnitByExternalID) {
reasons = append(reasons, "organization")
}
@@ -1823,6 +1923,229 @@ func worksmobileUserEmployeeNumberNeedsUpdate(user domain.User, remote Worksmobi
return localEmployeeNumber != remoteEmployeeNumber
}
+func worksmobileUserGradeNeedsUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
+ return worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID).NeedsUpdate
+}
+
+func worksmobileUserComparisonGradeWithTenant(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) (string, string) {
+ comparison := worksmobileCompareUserGrade(user, remote, localTenants, remoteOrgUnitByExternalID)
+ if strings.TrimSpace(comparison.LocalGrade) != "" {
+ return comparison.LocalGrade, comparison.TenantID
+ }
+ tenantID := worksmobileRemotePrimaryTenantID(remote, localTenants, remoteOrgUnitByExternalID)
+ if tenantID == "" {
+ tenantID = worksmobileUserComparisonTenantID(user, localTenants)
+ }
+ if tenantID == "" {
+ return "", ""
+ }
+ for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) {
+ if strings.TrimSpace(appointment.TenantID) != tenantID {
+ continue
+ }
+ grade := normalizeWorksmobileGradeForTenant(appointment.Grade, tenantID, localTenants)
+ if grade == "" {
+ return "", tenantID
+ }
+ return grade, tenantID
+ }
+ return "", tenantID
+}
+
+type worksmobileUserGradeComparison struct {
+ NeedsUpdate bool
+ TenantID string
+ LocalGrade string
+ RemoteLevelID string
+ RemoteLevelName string
+}
+
+type worksmobileRemoteOrganizationLevel struct {
+ levelID string
+ levelName string
+ primary bool
+}
+
+func worksmobileCompareUserGrade(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) worksmobileUserGradeComparison {
+ if localTenants == nil {
+ return worksmobileUserGradeComparison{}
+ }
+ remoteLevelsByTenant := worksmobileRemoteOrganizationLevelsByTenant(remote, localTenants, remoteOrgUnitByExternalID)
+ if len(remoteLevelsByTenant) == 0 {
+ return worksmobileUserGradeComparison{}
+ }
+ fallback := worksmobileUserGradeComparison{}
+ for _, appointment := range worksmobileGradeComparisonAppointments(user, localTenants) {
+ tenantID := strings.TrimSpace(appointment.TenantID)
+ localGrade := normalizeWorksmobileGradeForTenant(appointment.Grade, tenantID, localTenants)
+ if tenantID == "" || localGrade == "" {
+ continue
+ }
+ if _, ok := localTenants[tenantID]; !ok {
+ continue
+ }
+ remoteLevel, ok := remoteLevelsByTenant[tenantID]
+ if !ok {
+ continue
+ }
+ comparison := worksmobileUserGradeComparison{
+ TenantID: tenantID,
+ LocalGrade: localGrade,
+ RemoteLevelID: strings.TrimSpace(remoteLevel.levelID),
+ RemoteLevelName: strings.TrimSpace(remoteLevel.levelName),
+ }
+ if fallback.LocalGrade == "" || remoteLevel.primary {
+ fallback = comparison
+ }
+ if comparison.RemoteLevelName == "" && comparison.RemoteLevelID == "" {
+ comparison.NeedsUpdate = true
+ return comparison
+ }
+ if !worksmobileRemoteLevelMatchesLocalGrade(localGrade, tenantID, comparison.RemoteLevelID, comparison.RemoteLevelName, localTenants) {
+ comparison.NeedsUpdate = true
+ return comparison
+ }
+ }
+ return fallback
+}
+
+func worksmobileRemoteLevelMatchesLocalGrade(localGrade, tenantID, remoteLevelID, remoteLevelName string, localTenants map[string]domain.Tenant) bool {
+ localGrade = strings.TrimSpace(localGrade)
+ remoteLevelID = strings.TrimSpace(remoteLevelID)
+ remoteLevelName = strings.TrimSpace(remoteLevelName)
+ if localGrade == "" {
+ return remoteLevelID == "" && remoteLevelName == ""
+ }
+ if remoteLevelName == localGrade || remoteLevelID == localGrade {
+ return true
+ }
+ expectedLevelID := worksmobileLevelIDForTenant(localGrade, tenantID, localTenants)
+ return WorksmobileLevelIdentifierMatchesRemote(expectedLevelID, remoteLevelID, remoteLevelName)
+}
+
+func worksmobileGradeComparisonAppointments(user domain.User, tenantByID map[string]domain.Tenant) []worksmobileAppointment {
+ appointments := worksmobileAppointmentsFromMetadata(user.Metadata)
+ hasGPDTDCGrade := false
+ for _, appointment := range appointments {
+ if strings.TrimSpace(appointment.Grade) == "" {
+ continue
+ }
+ if worksmobileTenantIsGPDTDCDescendant(appointment.TenantID, tenantByID) {
+ hasGPDTDCGrade = true
+ break
+ }
+ }
+ if !hasGPDTDCGrade {
+ return appointments
+ }
+ filtered := make([]worksmobileAppointment, 0, len(appointments))
+ for _, appointment := range appointments {
+ if worksmobileTenantIsGPDTDCDescendant(appointment.TenantID, tenantByID) {
+ filtered = append(filtered, appointment)
+ }
+ }
+ return filtered
+}
+
+func worksmobileRemoteOrganizationLevelsByTenant(remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) map[string]worksmobileRemoteOrganizationLevel {
+ result := map[string]worksmobileRemoteOrganizationLevel{}
+ if localTenants == nil {
+ return result
+ }
+ organizations := remote.Organizations
+ if len(organizations) == 0 {
+ organizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
+ } else {
+ organizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
+ }
+ for _, organization := range organizations {
+ levelID := strings.TrimSpace(organization.LevelID)
+ levelName := strings.TrimSpace(organization.LevelName)
+ if levelID == "" && levelName == "" && (len(organizations) == 1 || organization.Primary) {
+ levelID = strings.TrimSpace(remote.LevelID)
+ levelName = strings.TrimSpace(remote.LevelName)
+ }
+ for _, orgUnit := range organization.OrgUnits {
+ tenantID := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
+ if tenantID == "" {
+ continue
+ }
+ if _, ok := localTenants[tenantID]; !ok {
+ continue
+ }
+ current := worksmobileRemoteOrganizationLevel{
+ levelID: levelID,
+ levelName: levelName,
+ primary: organization.Primary || orgUnit.Primary,
+ }
+ existing, ok := result[tenantID]
+ if !ok || (!existing.primary && current.primary) {
+ result[tenantID] = current
+ }
+ }
+ }
+ return result
+}
+
+func worksmobileRemotePrimaryTenantID(remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) string {
+ if localTenants == nil {
+ return ""
+ }
+ for _, orgUnitID := range worksmobileRemotePrimaryOrgUnitIDs(remote) {
+ canonicalOrgUnitID := worksmobileCanonicalRemoteOrgUnitID(orgUnitID, remoteOrgUnitByExternalID)
+ tenantID := worksmobileOrgUnitLocalExternalKey(canonicalOrgUnitID)
+ if tenantID == "" {
+ continue
+ }
+ if _, ok := localTenants[tenantID]; ok {
+ return tenantID
+ }
+ }
+ return ""
+}
+
+func worksmobileIsResearchGrade(values ...string) bool {
+ for _, value := range values {
+ normalized := strings.ToLower(strings.TrimSpace(value))
+ if normalized == "" {
+ continue
+ }
+ if strings.Contains(normalized, "연구원") ||
+ strings.Contains(normalized, "선임") ||
+ strings.Contains(normalized, "책임") ||
+ strings.Contains(normalized, "수석") ||
+ strings.Contains(normalized, "research") ||
+ strings.Contains(normalized, "principal") {
+ return true
+ }
+ }
+ return false
+}
+
+func worksmobileTenantIsGPDTDCDescendant(tenantID string, tenantByID map[string]domain.Tenant) bool {
+ tenantID = strings.TrimSpace(tenantID)
+ if tenantID == "" || tenantByID == nil {
+ return false
+ }
+ visited := map[string]bool{}
+ currentID := tenantID
+ for currentID != "" {
+ if visited[currentID] {
+ return false
+ }
+ visited[currentID] = true
+ tenant, ok := tenantByID[currentID]
+ if !ok {
+ return false
+ }
+ if worksmobileTenantDomainIDEnvKey(tenant) == "GPDTDC_DOMAIN_ID" && isWorksmobileDomainRootTenant(tenant) {
+ return true
+ }
+ currentID = worksmobileTenantParentID(tenant)
+ }
+ return false
+}
+
func worksmobileUserOrganizationsNeedUpdate(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) bool {
if localTenants == nil {
return false
@@ -1911,9 +2234,11 @@ func worksmobileRemoteUserLegacyOrganizations(remote WorksmobileRemoteUser, remo
}
return []WorksmobileUserOrganization{
{
- DomainID: remote.DomainID,
- Email: strings.TrimSpace(remote.Email),
- Primary: true,
+ DomainID: remote.DomainID,
+ Email: strings.TrimSpace(remote.Email),
+ Primary: true,
+ LevelID: strings.TrimSpace(remote.LevelID),
+ LevelName: strings.TrimSpace(remote.LevelName),
OrgUnits: []WorksmobileUserOrgUnit{
{
OrgUnitID: worksmobileCanonicalRemoteOrgUnitID(strings.TrimSpace(remote.PrimaryOrgUnitID), remoteOrgUnitByExternalID),
@@ -2009,20 +2334,17 @@ type worksmobileComparableOrgUnit struct {
func worksmobileUserOrganizationsEqual(expected []WorksmobileUserOrganization, remote []WorksmobileUserOrganization) bool {
expectedUnits := flattenExpectedWorksmobileUserOrganizations(expected)
remoteUnits := flattenRemoteWorksmobileUserOrganizations(remote)
- if len(expectedUnits) != len(remoteUnits) {
+ if len(expectedUnits) == 0 {
+ return len(remoteUnits) == 0
+ }
+ if len(remoteUnits) == 0 {
return false
}
- for key, expectedUnit := range expectedUnits {
- remoteUnit, ok := remoteUnits[key]
+ for key, remoteUnit := range remoteUnits {
+ expectedUnit, ok := expectedUnits[key]
if !ok {
return false
}
- if expectedUnit.organizationPrimary != remoteUnit.organizationPrimary {
- return false
- }
- if expectedUnit.unitPrimary != remoteUnit.unitPrimary {
- return false
- }
if expectedUnit.comparePosition && strings.TrimSpace(expectedUnit.positionID) != strings.TrimSpace(remoteUnit.positionID) {
return false
}
@@ -2090,6 +2412,145 @@ func flattenRemoteWorksmobileUserOrganizations(organizations []WorksmobileUserOr
return result
}
+type worksmobileRemoteMembershipDetail struct {
+ domainID int64
+ domainName string
+ orgUnitID string
+ orgUnitName string
+ levelID string
+ levelName string
+ positionID string
+ manager *bool
+ primary bool
+}
+
+func worksmobileUserMembershipComparisons(user domain.User, remote WorksmobileRemoteUser, localTenants map[string]domain.Tenant, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup, gradeComparison worksmobileUserGradeComparison) []WorksmobileUserMembershipComparison {
+ if localTenants == nil {
+ return nil
+ }
+ tenantID := worksmobileUserComparisonTenantID(user, localTenants)
+ if tenantID == "" {
+ return nil
+ }
+ tenant, ok := localTenants[tenantID]
+ if !ok {
+ return nil
+ }
+ expectedOrganizations, _, err := buildWorksmobileUserOrganizations(user, tenant, localTenants, worksmobileComparisonRootConfig(localTenants))
+ if err != nil || len(expectedOrganizations) == 0 {
+ return nil
+ }
+ remoteOrganizations := remote.Organizations
+ if len(remoteOrganizations) == 0 {
+ remoteOrganizations = worksmobileRemoteUserLegacyOrganizations(remote, remoteOrgUnitByExternalID)
+ } else {
+ remoteOrganizations = worksmobileRemoteUserOrganizationsForCompare(remote, remoteOrgUnitByExternalID)
+ }
+ remoteMemberships := worksmobileRemoteMembershipDetailsByKey(remote, remoteOrganizations, remoteOrgUnitByExternalID)
+ appointments := worksmobileAppointmentsByTenantID(user.Metadata)
+ result := make([]WorksmobileUserMembershipComparison, 0)
+ for _, organization := range expectedOrganizations {
+ for _, orgUnit := range organization.OrgUnits {
+ baronOrgID := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
+ if baronOrgID == "" {
+ continue
+ }
+ baronTenant, ok := localTenants[baronOrgID]
+ if !ok {
+ continue
+ }
+ item := WorksmobileUserMembershipComparison{
+ BaronOrgID: baronOrgID,
+ BaronOrgSlug: strings.TrimSpace(baronTenant.Slug),
+ BaronOrgName: strings.TrimSpace(baronTenant.Name),
+ BaronPrimary: organization.Primary || orgUnit.Primary,
+ }
+ if appointment, ok := appointments[baronOrgID]; ok {
+ item.BaronGrade = normalizeWorksmobileGradeForTenant(appointment.Grade, baronOrgID, localTenants)
+ }
+ key := worksmobileComparableOrgUnitKey(organization.DomainID, orgUnit.OrgUnitID)
+ if remoteMembership, ok := remoteMemberships[key]; ok {
+ item.WorksmobileDomainID = remoteMembership.domainID
+ item.WorksmobileDomainName = remoteMembership.domainName
+ item.WorksmobileOrgID = remoteMembership.orgUnitID
+ item.WorksmobileOrgName = remoteMembership.orgUnitName
+ item.WorksmobileLevelID = remoteMembership.levelID
+ item.WorksmobileLevelName = remoteMembership.levelName
+ item.WorksmobileOrgPositionID = remoteMembership.positionID
+ item.WorksmobileOrgIsManager = remoteMembership.manager
+ item.WorksmobilePrimary = remoteMembership.primary
+ }
+ item.GradeNeedsUpdate = gradeComparison.NeedsUpdate && strings.TrimSpace(gradeComparison.TenantID) == baronOrgID
+ result = append(result, item)
+ }
+ }
+ return result
+}
+
+func worksmobileAppointmentsByTenantID(metadata domain.JSONMap) map[string]worksmobileAppointment {
+ result := map[string]worksmobileAppointment{}
+ for _, appointment := range worksmobileAppointmentsFromMetadata(metadata) {
+ tenantID := strings.TrimSpace(appointment.TenantID)
+ if tenantID == "" {
+ continue
+ }
+ result[tenantID] = appointment
+ }
+ return result
+}
+
+func worksmobileRemoteMembershipDetailsByKey(remote WorksmobileRemoteUser, organizations []WorksmobileUserOrganization, remoteOrgUnitByExternalID map[string]WorksmobileRemoteGroup) map[string]worksmobileRemoteMembershipDetail {
+ result := map[string]worksmobileRemoteMembershipDetail{}
+ for _, organization := range organizations {
+ domainID := organization.DomainID
+ if domainID == 0 {
+ domainID = remote.DomainID
+ }
+ domainName := strings.TrimSpace(remote.DomainName)
+ levelID := strings.TrimSpace(organization.LevelID)
+ levelName := strings.TrimSpace(organization.LevelName)
+ if levelID == "" && levelName == "" && (len(organizations) == 1 || organization.Primary) {
+ levelID = strings.TrimSpace(remote.LevelID)
+ levelName = strings.TrimSpace(remote.LevelName)
+ }
+ for _, orgUnit := range organization.OrgUnits {
+ key := worksmobileComparableOrgUnitKey(domainID, orgUnit.OrgUnitID)
+ if key == "" {
+ continue
+ }
+ localExternalKey := worksmobileOrgUnitLocalExternalKey(orgUnit.OrgUnitID)
+ orgUnitID := strings.TrimSpace(orgUnit.OrgUnitID)
+ orgUnitName := ""
+ if localExternalKey != "" {
+ if remoteGroup, ok := remoteOrgUnitByExternalID[localExternalKey]; ok {
+ if strings.TrimSpace(remoteGroup.ID) != "" {
+ orgUnitID = strings.TrimSpace(remoteGroup.ID)
+ }
+ orgUnitName = strings.TrimSpace(remoteGroup.DisplayName)
+ if domainName == "" {
+ domainName = strings.TrimSpace(remoteGroup.DomainName)
+ }
+ }
+ }
+ if orgUnitName == "" && worksmobileOrgUnitIDContains([]string{remote.PrimaryOrgUnitID}, orgUnitID) {
+ orgUnitName = strings.TrimSpace(remote.PrimaryOrgUnitName)
+ }
+ result[key] = worksmobileRemoteMembershipDetail{
+ domainID: domainID,
+ domainName: domainName,
+ orgUnitID: orgUnitID,
+ orgUnitName: orgUnitName,
+ levelID: levelID,
+ levelName: levelName,
+ positionID: strings.TrimSpace(orgUnit.PositionID),
+ manager: orgUnit.IsManager,
+ primary: organization.Primary || orgUnit.Primary,
+ }
+ }
+ }
+ return result
+}
+
func worksmobileComparableOrgUnitKey(domainID int64, orgUnitID string) string {
orgUnitID = strings.TrimSpace(orgUnitID)
if domainID == 0 || orgUnitID == "" {
@@ -2167,10 +2628,7 @@ func worksmobileOrgUnitLocalExternalKey(orgUnitID string) string {
}
func worksmobileUserPrimaryOrgID(user domain.User) string {
- if user.TenantID == nil {
- return ""
- }
- return strings.TrimSpace(*user.TenantID)
+ return worksmobileUserComparisonPrimaryTenantID(user)
}
func worksmobileUserPrimaryOrgName(user domain.User, localTenants map[string]domain.Tenant) string {
diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go
index 187e29d1..f1ff8aba 100644
--- a/backend/internal/service/worksmobile_sync_service_test.go
+++ b/backend/internal/service/worksmobile_sync_service_test.go
@@ -1200,7 +1200,7 @@ func TestWorksmobileSyncServiceEnqueuesExternalKeyPresentWorksOnlyOrgUnitDelete(
require.Equal(t, "baron-tenant-1", outboxRepo.created[0].Payload["externalKey"])
}
-func TestWorksmobileSyncServiceReconcilesWorksOnlyOrgUnitBySlugLocalPart(t *testing.T) {
+func TestWorksmobileSyncServiceDeletesWorksOrgUnitEvenWhenSlugLocalPartMatches(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1001")
rootID := "root-tenant"
orgID := "baron-org-1"
@@ -1244,11 +1244,10 @@ func TestWorksmobileSyncServiceReconcilesWorksOnlyOrgUnitBySlugLocalPart(t *test
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
- require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
- require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
- request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
- require.Equal(t, orgID, request.OrgUnitExternalKey)
- require.Equal(t, "tech-dev-center", outboxRepo.created[0].Payload["matchLocalPart"])
+ require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
+ require.Equal(t, "works-org-1", outboxRepo.created[0].ResourceID)
+ require.Equal(t, "works-org-1", outboxRepo.created[0].Payload["worksmobileId"])
+ require.Equal(t, "legacy-external-key", outboxRepo.created[0].Payload["externalKey"])
}
func TestCompareWorksmobileGroupsFillsParentDisplayFromBaronParentMatch(t *testing.T) {
@@ -1342,11 +1341,10 @@ func TestWorksmobileSyncServiceReconcilesTopLevelWorksOnlyOrgUnitBeforeProtected
require.NoError(t, err)
require.NotNil(t, item)
require.Len(t, outboxRepo.created, 1)
- require.Equal(t, domain.WorksmobileActionUpsert, outboxRepo.created[0].Action)
- require.Equal(t, orgID, outboxRepo.created[0].ResourceID)
- request := outboxRepo.created[0].Payload["request"].(WorksmobileOrgUnitPayload)
- require.Equal(t, orgID, request.OrgUnitExternalKey)
- require.Equal(t, "operations", outboxRepo.created[0].Payload["matchLocalPart"])
+ require.Equal(t, domain.WorksmobileActionDelete, outboxRepo.created[0].Action)
+ require.Equal(t, "works-operations", outboxRepo.created[0].ResourceID)
+ require.Equal(t, "works-operations", outboxRepo.created[0].Payload["worksmobileId"])
+ require.Equal(t, "legacy-operations-id", outboxRepo.created[0].Payload["externalKey"])
}
func TestWorksmobileSyncServiceRejectsProtectedDomainRootOrgUnitDelete(t *testing.T) {
@@ -2172,6 +2170,403 @@ func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) {
require.Equal(t, "needs_update", items[0].Status)
}
+func TestCompareWorksmobileUsersMarksTenantLinkedGradeChangeNeedsUpdate(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "tenant-root"
+ gpdtdcID := "tenant-gpdtdc"
+ tenantID := "tenant-gpdtdc-leaf"
+ user := domain.User{
+ ID: "user-grade",
+ Email: "grade@samaneng.com",
+ Name: "Grade User",
+ TenantID: &tenantID,
+ Status: domain.UserStatusActive,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": tenantID,
+ "isPrimary": true,
+ "grade": "책임",
+ },
+ },
+ },
+ }
+
+ items := compareWorksmobileUsers(
+ []domain.User{user},
+ []WorksmobileRemoteUser{{
+ ID: "works-user-grade",
+ ExternalID: user.ID,
+ Email: user.Email,
+ DisplayName: user.Name,
+ LevelName: "",
+ PrimaryOrgUnitID: "externalKey:" + tenantID,
+ Organizations: []WorksmobileUserOrganization{
+ {
+ DomainID: 1003,
+ Email: user.Email,
+ Primary: true,
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + tenantID, Primary: true}},
+ },
+ },
+ }},
+ true,
+ map[string]domain.Tenant{
+ rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
+ gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
+ tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
+ },
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, "needs_update", items[0].Status)
+ require.Contains(t, items[0].UpdateReasons, "grade")
+ require.Equal(t, "책임 연구원", items[0].BaronGrade)
+}
+
+func TestCompareWorksmobileUsersIncludesMembershipMatchForGradeUpdate(t *testing.T) {
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "tenant-root"
+ gpdtdcID := "tenant-gpdtdc"
+ hmegID := "1d74bebb-c5a1-49d4-bec4-90f0c89ad21f"
+ user := domain.User{
+ ID: "user-hmeg-researcher",
+ Email: "hmeg-researcher@baroncs.co.kr",
+ Name: "HMEG Researcher",
+ TenantID: &hmegID,
+ Status: domain.UserStatusActive,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": hmegID,
+ "isPrimary": true,
+ "grade": "책임연구원",
+ },
+ },
+ },
+ }
+
+ items := compareWorksmobileUsersWithRemoteGroups(
+ []domain.User{user},
+ []WorksmobileRemoteUser{{
+ ID: "works-user-hmeg-researcher",
+ ExternalID: user.ID,
+ Email: user.Email,
+ DisplayName: user.Name,
+ DomainID: 1003,
+ PrimaryOrgUnitID: "works-hmeg",
+ Organizations: []WorksmobileUserOrganization{
+ {
+ DomainID: 1003,
+ Email: user.Email,
+ Primary: true,
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "works-hmeg", Primary: true}},
+ },
+ },
+ }},
+ true,
+ map[string]domain.Tenant{
+ rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
+ gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
+ hmegID: {ID: hmegID, Slug: "hmeg", Name: "HmEG", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
+ },
+ []WorksmobileRemoteGroup{{
+ ID: "works-hmeg",
+ ExternalID: hmegID,
+ DisplayName: "WORKS HmEG",
+ DomainID: 1003,
+ DomainName: "baroncs.co.kr",
+ }},
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, "needs_update", items[0].Status)
+ require.Contains(t, items[0].UpdateReasons, "grade")
+ require.Len(t, items[0].UserMemberships, 1)
+ require.Equal(t, hmegID, items[0].UserMemberships[0].BaronOrgID)
+ require.Equal(t, "HmEG", items[0].UserMemberships[0].BaronOrgName)
+ require.Equal(t, "hmeg", items[0].UserMemberships[0].BaronOrgSlug)
+ require.Equal(t, "책임 연구원", items[0].UserMemberships[0].BaronGrade)
+ require.Equal(t, "works-hmeg", items[0].UserMemberships[0].WorksmobileOrgID)
+ require.Equal(t, "WORKS HmEG", items[0].UserMemberships[0].WorksmobileOrgName)
+ require.True(t, items[0].UserMemberships[0].GradeNeedsUpdate)
+}
+
+func TestCompareWorksmobileUsersDoesNotUpdateWhenWORKSMembershipIsBaronSubset(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "tenant-root"
+ companyID := "tenant-saman"
+ tenantID := "tenant-saman-leaf"
+ gpdtdcID := "tenant-gpdtdc"
+ gpdtdcTenantID := "tenant-is-3"
+ user := domain.User{
+ ID: "user-gpdtdc-grade-with-saman-works-org",
+ Email: "gpdtdc-grade@samaneng.com",
+ Name: "Research Grade User",
+ TenantID: &gpdtdcTenantID,
+ Status: domain.UserStatusActive,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": tenantID,
+ "isPrimary": true,
+ "grade": "팀장",
+ },
+ map[string]any{
+ "tenantId": gpdtdcTenantID,
+ "grade": "책임",
+ },
+ },
+ },
+ }
+
+ items := compareWorksmobileUsers(
+ []domain.User{user},
+ []WorksmobileRemoteUser{{
+ ID: "works-user-gpdtdc-grade",
+ ExternalID: user.ID,
+ Email: user.Email,
+ DisplayName: user.Name,
+ LevelName: "팀장",
+ PrimaryOrgUnitID: "externalKey:" + tenantID,
+ Organizations: []WorksmobileUserOrganization{
+ {
+ DomainID: 1001,
+ Email: user.Email,
+ Primary: true,
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + tenantID, Primary: true}},
+ },
+ },
+ }},
+ true,
+ map[string]domain.Tenant{
+ rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
+ companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
+ tenantID: {ID: tenantID, Slug: "saman-leaf", Name: "삼안 조직", Type: domain.TenantTypeOrganization, ParentID: &companyID},
+ gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
+ gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "is-3", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
+ },
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, "matched", items[0].Status)
+ require.NotContains(t, items[0].UpdateReasons, "grade")
+ require.NotContains(t, items[0].UpdateReasons, "organization")
+}
+
+func TestCompareWorksmobileUsersDoesNotCompareGPDTDCGradeAgainstSamanOrgChartLevel(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "300285955")
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "tenant-root"
+ samanID := "045e0b22-fae7-4229-1724-039c5af16849"
+ samanOrgChartID := "97a7e34d-2042-4793-27dc-03ffd68db801"
+ gpdtdcID := "tenant-gpdtdc"
+ infraBIM1ID := "432b5261-421b-4e5f-914f-32d7d22fd01f"
+ user := domain.User{
+ ID: "abaf0788-2d68-4b7d-b40a-c0251f38ae21",
+ Email: "hwan@samaneng.com",
+ Name: "안효원",
+ TenantID: &infraBIM1ID,
+ Status: domain.UserStatusActive,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": samanOrgChartID,
+ "grade": "선임연구원",
+ },
+ map[string]any{
+ "tenantId": infraBIM1ID,
+ "isPrimary": true,
+ "grade": "선임연구원",
+ },
+ },
+ },
+ }
+ remoteManager := false
+
+ items := compareWorksmobileUsers(
+ []domain.User{user},
+ []WorksmobileRemoteUser{{
+ ID: "045e0b22-fae7-4229-1724-039c5af16849",
+ ExternalID: user.ID,
+ Email: user.Email,
+ DisplayName: user.Name,
+ DomainID: 300285955,
+ PrimaryOrgUnitID: "externalKey:" + samanOrgChartID,
+ PrimaryOrgUnitName: "삼안기술개발센터(조직도용)",
+ PrimaryOrgUnitIsManager: &remoteManager,
+ PrimaryOrgUnitPositionName: "조직장 아님",
+ Organizations: []WorksmobileUserOrganization{
+ {
+ DomainID: 300285955,
+ Email: user.Email,
+ Primary: true,
+ OrgUnits: []WorksmobileUserOrgUnit{{
+ OrgUnitID: "externalKey:" + samanOrgChartID,
+ Primary: true,
+ IsManager: &remoteManager,
+ }},
+ },
+ },
+ }},
+ true,
+ map[string]domain.Tenant{
+ rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
+ samanID: {ID: samanID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
+ samanOrgChartID: {ID: samanOrgChartID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &samanID},
+ gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
+ infraBIM1ID: {ID: infraBIM1ID, Slug: "infra-bim1", Name: "인프라 BIM1", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
+ },
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, "matched", items[0].Status)
+ require.NotContains(t, items[0].UpdateReasons, "grade")
+}
+
+func TestCompareWorksmobileUsersUsesPrimaryAppointmentForGPDTDCGrade(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "tenant-root"
+ companyID := "tenant-saman"
+ orgChartTenantID := "tenant-rnd-saman"
+ gpdtdcID := "tenant-gpdtdc"
+ gpdtdcTenantID := "tenant-is-3"
+ user := domain.User{
+ ID: "user-orgchart-grade",
+ Email: "orgchart-grade@samaneng.com",
+ Name: "Orgchart Grade User",
+ TenantID: &gpdtdcTenantID,
+ Status: domain.UserStatusActive,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": orgChartTenantID,
+ },
+ map[string]any{
+ "tenantId": gpdtdcTenantID,
+ "isPrimary": true,
+ "grade": "수석연구원",
+ },
+ },
+ },
+ }
+
+ items := compareWorksmobileUsers(
+ []domain.User{user},
+ []WorksmobileRemoteUser{{
+ ID: "works-user-orgchart-grade",
+ ExternalID: user.ID,
+ Email: user.Email,
+ DisplayName: user.Name,
+ LevelName: "수석 연구원",
+ PrimaryOrgUnitID: "externalKey:" + gpdtdcTenantID,
+ Organizations: []WorksmobileUserOrganization{
+ {
+ DomainID: 1001,
+ Email: user.Email,
+ Primary: false,
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + orgChartTenantID, Primary: true}},
+ },
+ {
+ DomainID: 1003,
+ Email: user.Email,
+ Primary: true,
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + gpdtdcTenantID, Primary: true}},
+ },
+ },
+ }},
+ true,
+ map[string]domain.Tenant{
+ rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
+ companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
+ orgChartTenantID: {ID: orgChartTenantID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &companyID},
+ gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
+ gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "is-3", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
+ },
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, "matched", items[0].Status)
+ require.NotContains(t, items[0].UpdateReasons, "grade")
+ require.Equal(t, "수석 연구원", items[0].BaronGrade)
+}
+
+func TestCompareWorksmobileUsersMarksConcurrentTenantGradeChangeNeedsUpdate(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "tenant-root"
+ companyID := "tenant-saman"
+ primaryTenantID := "tenant-saman-leaf"
+ gpdtdcID := "tenant-gpdtdc"
+ gpdtdcTenantID := "tenant-is-3"
+ user := domain.User{
+ ID: "user-concurrent-grade",
+ Email: "concurrent-grade@samaneng.com",
+ Name: "Concurrent Grade User",
+ TenantID: &primaryTenantID,
+ Status: domain.UserStatusActive,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": primaryTenantID,
+ "isPrimary": true,
+ "grade": "팀장",
+ },
+ map[string]any{
+ "tenantId": gpdtdcTenantID,
+ "grade": "책임",
+ },
+ },
+ },
+ }
+
+ items := compareWorksmobileUsers(
+ []domain.User{user},
+ []WorksmobileRemoteUser{{
+ ID: "works-user-concurrent-grade",
+ ExternalID: user.ID,
+ Email: user.Email,
+ DisplayName: user.Name,
+ LevelName: "팀장",
+ PrimaryOrgUnitID: "externalKey:" + primaryTenantID,
+ Organizations: []WorksmobileUserOrganization{
+ {
+ DomainID: 1001,
+ Email: user.Email,
+ Primary: true,
+ LevelName: "팀장",
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + primaryTenantID, Primary: true}},
+ },
+ {
+ DomainID: 1003,
+ Email: user.Email,
+ Primary: false,
+ LevelName: "선임",
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + gpdtdcTenantID, Primary: true}},
+ },
+ },
+ }},
+ true,
+ map[string]domain.Tenant{
+ rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
+ companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
+ primaryTenantID: {ID: primaryTenantID, Slug: "saman-leaf", Name: "삼안 조직", Type: domain.TenantTypeOrganization, ParentID: &companyID},
+ gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompanyGroup, ParentID: &rootID},
+ gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "is-3", Name: "기술기획", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
+ },
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, "needs_update", items[0].Status)
+ require.Contains(t, items[0].UpdateReasons, "grade")
+ require.NotContains(t, items[0].UpdateReasons, "organization")
+ require.Equal(t, "책임 연구원", items[0].BaronGrade)
+ require.Equal(t, "선임", items[0].WorksmobileLevelName)
+}
+
func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-saman"
@@ -2307,6 +2702,134 @@ func TestCompareWorksmobileUsersMarksMissingSecondaryOrganizationNeedsUpdate(t *
require.Equal(t, "needs_update", items[0].Status)
}
+func TestCompareWorksmobileUsersIgnoresPrimaryPriorityWhenMembershipsMatch(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "tenant-root"
+ companyID := "tenant-saman"
+ orgChartTenantID := "tenant-rnd-saman"
+ gpdtdcID := "tenant-gpdtdc"
+ gpdtdcTenantID := "tenant-gpdtdc-leaf"
+ user := domain.User{
+ ID: "user-dual-membership",
+ Email: "dual-membership@samaneng.com",
+ Name: "Dual Membership User",
+ TenantID: &gpdtdcTenantID,
+ Status: domain.UserStatusActive,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": orgChartTenantID,
+ "isPrimary": false,
+ },
+ map[string]any{
+ "tenantId": gpdtdcTenantID,
+ "isPrimary": true,
+ },
+ },
+ },
+ }
+
+ items := compareWorksmobileUsers(
+ []domain.User{user},
+ []WorksmobileRemoteUser{{
+ ID: "works-user-dual-membership",
+ ExternalID: user.ID,
+ Email: user.Email,
+ DisplayName: user.Name,
+ PrimaryOrgUnitID: "externalKey:" + orgChartTenantID,
+ Organizations: []WorksmobileUserOrganization{
+ {
+ DomainID: 1001,
+ Email: user.Email,
+ Primary: true,
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + orgChartTenantID, Primary: true}},
+ },
+ {
+ DomainID: 1003,
+ Email: user.Email,
+ Primary: false,
+ OrgUnits: []WorksmobileUserOrgUnit{{OrgUnitID: "externalKey:" + gpdtdcTenantID, Primary: true}},
+ },
+ },
+ }},
+ true,
+ map[string]domain.Tenant{
+ rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
+ companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
+ orgChartTenantID: {ID: orgChartTenantID, Slug: "rnd-saman", Name: "삼안기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &companyID},
+ gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompany, ParentID: &rootID},
+ gpdtdcTenantID: {ID: gpdtdcTenantID, Slug: "people-growth", Name: "인재성장", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
+ },
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, "matched", items[0].Status)
+ require.NotContains(t, items[0].UpdateReasons, "organization")
+}
+
+func TestCompareWorksmobileUsersIgnoresBaronMembershipSuperset(t *testing.T) {
+ t.Setenv("SAMAN_DOMAIN_ID", "1001")
+ rootID := "tenant-root"
+ companyID := "tenant-saman"
+ primaryTenantID := "tenant-primary"
+ secondaryTenantID := "tenant-secondary"
+ user := domain.User{
+ ID: "user-baron-membership-superset",
+ Email: "membership-superset@samaneng.com",
+ Name: "Membership Superset User",
+ TenantID: &primaryTenantID,
+ Status: domain.UserStatusActive,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": primaryTenantID,
+ "isPrimary": true,
+ },
+ map[string]any{
+ "tenantId": secondaryTenantID,
+ },
+ },
+ },
+ }
+
+ items := compareWorksmobileUsers(
+ []domain.User{user},
+ []WorksmobileRemoteUser{{
+ ID: "works-user-membership-superset",
+ ExternalID: user.ID,
+ Email: user.Email,
+ DisplayName: user.Name,
+ DomainID: 1001,
+ PrimaryOrgUnitID: "externalKey:" + primaryTenantID,
+ Organizations: []WorksmobileUserOrganization{
+ {
+ DomainID: 1001,
+ Email: user.Email,
+ Primary: true,
+ OrgUnits: []WorksmobileUserOrgUnit{
+ {
+ OrgUnitID: "externalKey:" + primaryTenantID,
+ Primary: true,
+ },
+ },
+ },
+ },
+ }},
+ true,
+ map[string]domain.Tenant{
+ rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
+ companyID: {ID: companyID, Slug: "saman", Name: "삼안", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}},
+ primaryTenantID: {ID: primaryTenantID, Slug: "primary", Name: "Primary", Type: domain.TenantTypeOrganization, ParentID: &companyID},
+ secondaryTenantID: {ID: secondaryTenantID, Slug: "secondary", Name: "Secondary", Type: domain.TenantTypeOrganization, ParentID: &companyID},
+ },
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, "matched", items[0].Status)
+ require.NotContains(t, items[0].UpdateReasons, "organization")
+}
+
func TestCompareWorksmobileUsersIgnoresOrganizationEmailWhenMembershipMatches(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
@@ -2357,6 +2880,32 @@ func TestCompareWorksmobileUsersIgnoresOrganizationEmailWhenMembershipMatches(t
require.Equal(t, "matched", items[0].Status)
}
+func TestWorksmobileUsersFromIdentityMirrorIncludesAdditionalAppointmentMembership(t *testing.T) {
+ tenantID := "tenant-gpdtdc-leaf"
+ identity := KratosIdentity{
+ ID: "64d4a839-ee04-4c47-b7b3-4ac6428c56b1",
+ Traits: map[string]any{
+ "email": "researcher@samaneng.com",
+ "name": "Researcher User",
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": tenantID,
+ "isPrimary": true,
+ "grade": "책임",
+ },
+ },
+ },
+ }
+
+ users := worksmobileUsersFromIdentityMirror([]KratosIdentity{identity}, []string{tenantID})
+
+ require.Len(t, users, 1)
+ require.Equal(t, identity.ID, users[0].ID)
+ require.NotNil(t, users[0].TenantID)
+ require.Equal(t, tenantID, *users[0].TenantID)
+ require.Equal(t, "책임", worksmobileUserGrade(users[0]))
+}
+
func TestCompareWorksmobileUsersUsesRemoteUserDomainWhenOrganizationDomainIsMissing(t *testing.T) {
t.Setenv("SAMAN_DOMAIN_ID", "1001")
rootID := "tenant-root"
@@ -2517,6 +3066,54 @@ func TestCompareWorksmobileUsersMatchesRemoteOrganizationExternalKey(t *testing.
require.Equal(t, "matched", items[0].Status)
}
+func TestCompareWorksmobileUsersDisplaysPrimaryAppointmentAsBaronPrimaryOrg(t *testing.T) {
+ t.Setenv("HANMAC_DOMAIN_ID", "1002")
+ t.Setenv("GPDTDC_DOMAIN_ID", "1003")
+ rootID := "tenant-root"
+ hanmacCompanyID := "tenant-hanmac"
+ hanmacOrgID := "tenant-hanmac-org"
+ gpdtdcID := "tenant-gpdtdc"
+ gsimID := "tenant-gsim-dev"
+ user := domain.User{
+ ID: "user-gsim-primary",
+ Email: "gsim-primary@hanmaceng.co.kr",
+ Name: "GSIM Primary User",
+ TenantID: &hanmacOrgID,
+ Status: domain.UserStatusActive,
+ Metadata: domain.JSONMap{
+ "additionalAppointments": []any{
+ map[string]any{
+ "tenantId": hanmacOrgID,
+ "isPrimary": false,
+ },
+ map[string]any{
+ "tenantId": gsimID,
+ "isPrimary": true,
+ },
+ },
+ },
+ }
+
+ items := compareWorksmobileUsers(
+ []domain.User{user},
+ nil,
+ true,
+ map[string]domain.Tenant{
+ rootID: {ID: rootID, Slug: HanmacFamilyTenantSlug, Name: "한맥가족", Type: domain.TenantTypeCompanyGroup},
+ hanmacCompanyID: {ID: hanmacCompanyID, Slug: "hanmac", Name: "한맥기술", Type: domain.TenantTypeCompany, ParentID: &rootID, Domains: []domain.TenantDomain{{Domain: "hanmaceng.co.kr"}}},
+ hanmacOrgID: {ID: hanmacOrgID, Slug: "rnd-hanmac", Name: "한맥기술개발센터(조직도용)", Type: domain.TenantTypeOrganization, ParentID: &hanmacCompanyID},
+ gpdtdcID: {ID: gpdtdcID, Slug: "gpdtdc", Name: "총괄기획&기술개발센터", Type: domain.TenantTypeCompany, ParentID: &rootID},
+ gsimID: {ID: gsimID, Slug: "gsim-dev", Name: "GSIM개발", Type: domain.TenantTypeOrganization, ParentID: &gpdtdcID},
+ },
+ )
+
+ require.Len(t, items, 1)
+ require.Equal(t, "missing_in_worksmobile", items[0].Status)
+ require.Equal(t, gsimID, items[0].BaronPrimaryOrgID)
+ require.Equal(t, "gsim-dev", items[0].BaronPrimaryOrgSlug)
+ require.Equal(t, "GSIM개발", items[0].BaronPrimaryOrgName)
+}
+
func TestCompareWorksmobileUsersMarksMissingPrimaryOrganizationNeedsUpdate(t *testing.T) {
t.Setenv("GPDTDC_DOMAIN_ID", "1003")
rootID := "tenant-root"
diff --git a/backups/baron-sso-backup-20260615-105417Z/config/generated-ory.tar.zst b/backups/baron-sso-backup-20260615-105417Z/config/generated-ory.tar.zst
deleted file mode 100644
index 64456cc4..00000000
Binary files a/backups/baron-sso-backup-20260615-105417Z/config/generated-ory.tar.zst and /dev/null differ
diff --git a/backups/baron-sso-backup-20260615-105417Z/postgres/ory_hydra.dump b/backups/baron-sso-backup-20260615-105417Z/postgres/ory_hydra.dump
deleted file mode 100644
index fe7d9fc7..00000000
Binary files a/backups/baron-sso-backup-20260615-105417Z/postgres/ory_hydra.dump and /dev/null differ
diff --git a/backups/baron-sso-backup-20260615-105417Z/postgres/ory_kratos.dump b/backups/baron-sso-backup-20260615-105417Z/postgres/ory_kratos.dump
deleted file mode 100644
index e1dbe39f..00000000
Binary files a/backups/baron-sso-backup-20260615-105417Z/postgres/ory_kratos.dump and /dev/null differ
diff --git a/backups/baron-sso-backup-20260615-105417Z/checksums.sha256 b/backups/baron-sso-backup-20260617-122446Z/checksums.sha256
similarity index 64%
rename from backups/baron-sso-backup-20260615-105417Z/checksums.sha256
rename to backups/baron-sso-backup-20260617-122446Z/checksums.sha256
index a456fc89..a6a4f6f7 100644
--- a/backups/baron-sso-backup-20260615-105417Z/checksums.sha256
+++ b/backups/baron-sso-backup-20260617-122446Z/checksums.sha256
@@ -1,31 +1,31 @@
-5d2d06696fa6813d604ae0fc4a41d83018d3f58fa81c816533fe00c7ba46da48 ./clickhouse/baron_clickhouse/data/baron_sso__audit_logs.native
-d12b45688b414137a44d7162514756b33617046bb5bd2a2fe553d001e9ca7738 ./clickhouse/baron_clickhouse/data/baron_sso__rp_usage_daily_aggregate.native
-1418f34f8c5616446ac91c20e8c5efe451e4fcbbeb1c9acfd55552c02425e725 ./clickhouse/baron_clickhouse/data/baron_sso__rp_usage_events.native
+d4e31971a8e28e3d3b9e307a78b079f55c9420232b8b77301abb0da07a6da925 ./clickhouse/baron_clickhouse/data/baron_sso__audit_logs.native
+a764deb4193edb724a29b0489ea7e3052e724cda3f4a3f2b7d6493b7bbb8b9df ./clickhouse/baron_clickhouse/data/baron_sso__rp_usage_daily_aggregate.native
+d358156ecc5703059f7d171a43b7a952d4db2ac704f5d401ef6bd9c92b7bd8c2 ./clickhouse/baron_clickhouse/data/baron_sso__rp_usage_events.native
9b3eac049187af79f4db488b96f66b5a835807b37c8273cc4fa044e54ff6e1b2 ./clickhouse/baron_clickhouse/schema/baron_sso__audit_logs.sql
6bd39d8db64aad6ef55ff5b9db11993ccf37a3fe1c49460c0e62099a4391925b ./clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate.sql
1b502fcfa9ff305dcd5b4769ff727b6f2500769cf39b37fb780f65a0e609de2a ./clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate_mv.sql
3515ed1f15426aae56b6cc12c4281c456d84477f1571a7ad002cc1869d82c9cd ./clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_events.sql
9c800f51db9a4143fbefb1c79d2046fa85ef66b3174941e17090cb2c4999b7d4 ./clickhouse/baron_clickhouse/tables.tsv
-db941d2a9be77eefba1a361cf36265abc09b8392580eaee7da8f037ca1ab6cd8 ./clickhouse/ory_clickhouse/data/ory__oathkeeper_access_logs.native
+63e07a72fa838eb82240385899311d015fd5a65064f50f014f3294be499cbbd1 ./clickhouse/ory_clickhouse/data/ory__oathkeeper_access_logs.native
1ee61755b025e757d8f0ea5208d083d4b36d68ea2f79fa3eb34e82b9e53eb7a8 ./clickhouse/ory_clickhouse/schema/ory__oathkeeper_access_logs.sql
214144ca9cdbebde7270738176ffea9d0042dcdad2133b43dc0b1f107cf6197e ./clickhouse/ory_clickhouse/tables.tsv
eefcef288ff99ff8477002e4f979e4ea801f5999e65b5e132c6f58182d87049f ./config/compose/compose.infra.yaml
1c5f4eeae27b294711ea4a5dc5edcb87523724459fba37c6d0868ad11864df1d ./config/compose/compose.ory.yaml
-0c57362f2fbf33985d2a3162dba76af8b9860614d94c100fdb3e21f2f1779d8f ./config/compose/docker-compose.yaml
+5a8881f50b86726e0de3334470441ddb9a0c48a73c40c8adfc23e68aab59c368 ./config/compose/docker-compose.yaml
8a670f1cff98b75fb8c7f240a6b38d0170fcf85ad40f8680ebd9260a95c64064 ./config/env.redacted
15359cf3f3f96d522ccbd1311ac0811a9b6c3dfae1c4cd9809e85e02787e59af ./config/gateway.tar.zst
-125f45e479a0d71b0bed760429c6473bbacfc849ce37a673a03afe829f0cb714 ./config/generated-ory.tar.zst
-2108fc3d39c9a29a72759a7b1c1c344bc82915ecef2672b57d6d5f527c8e1284 ./manifest.json
-180986d8e311119606ee1cb021458507086285d960805473a1ce0f2ec00c76f4 ./postgres/baron.dump
-9c1c9939132d0ac1b260dd38ff7278382511cc1577ef792c5d2e45eb63c01e66 ./postgres/globals.sql
-cdf0b5f06148c88027e32515542fb452dfa40429077d8ecaa7dff4c250830f5e ./postgres/ory_hydra.dump
-240557fec153b4660ce4e10feeba09398a4ff35dec26b20675c4cb00725438b2 ./postgres/ory_keto.dump
-38060e8fe88f86b17b55d745dd6be602b036e68634c6190946c64728b0ac6e6e ./postgres/ory_kratos.dump
-e5a062101176bd89c36974ef5edafe43e8b9efacd492c023e46705af158c72ec ./reports/backup-report.md
+beffa23b8ff253a25cf73cef0b8b60bad6d5aeafc210d8111ccea1a60d906baf ./config/generated-ory.tar.zst
+0bbbad1e60b4c18b1988d9853b574c7b91a9976154c47aee2f6f2a219d57099e ./manifest.json
+2932edf10ed8c2863def894c91059e3a7a819746a74f1dc4c31a642a89925571 ./postgres/baron.dump
+3bafa171e8168769da41e33e017cb1135ce8b647fd29d38222054b7bb29b434c ./postgres/globals.sql
+4ccfaa262e78cc96306817dd5e6beec736905f597b6cdfa7f2adaf4231f314c2 ./postgres/ory_hydra.dump
+770b1398b1c72242ada4b6ad0ab9a5bce49b64ba515c4c3d8553e7db0f764b8d ./postgres/ory_keto.dump
+aaf23540dd652bc68bb4e7601e7ab91dc12cd50a6f98baf609f2a3a122acf7ea ./postgres/ory_kratos.dump
+6bdcc558f1add31d3dba5339d4bed59e3e836e5df2c58718328d7a3b8cdde06a ./reports/backup-report.md
c08472517cf91b001f5cf4dbcf9750460e07ab1f7cd0f953aba79067899a724a ./reports/baron-postgres-custom-claim-counts.txt
-a301238b58845a6aa3d2b6f91bbec73fb672040f3527cb12b181a81272fc9793 ./reports/baron-postgres-row-counts.txt
-c8e6e5b0ee8c6eb360581aa0ee6120fd839308fe4d457ffb56749f7d4248184e ./reports/baron_clickhouse-row-counts.txt
-6b307b0f5be3a386c8b61198af2db45552c6c02992461b9e83eb38fc8f95f75b ./reports/ory_clickhouse-row-counts.txt
-fa92334662870832724702f2ac933a942709cae8ec8659b7e004647fc548ad1e ./reports/ory_hydra-row-counts.txt
-6d673a8360ab4a250d7f3ff77796444c26fa1ba66db8c341f01a8c01d15b9aea ./reports/ory_keto-row-counts.txt
-57484ccd98112c483f6cc903e8fb135bc8c60d6ca3ba1b9733f0859227b6919d ./reports/ory_kratos-row-counts.txt
+6f91bc4ae9949e117dad88ac59441f14bc9b2129565538ff125180efa06244db ./reports/baron-postgres-row-counts.txt
+0594c3403299c4c869b59edbe3d86da25d46ab586ce89b3392333fa549dcd845 ./reports/baron_clickhouse-row-counts.txt
+0c94e98c040e81d5fafe927e501cc0fd45aa01b093685411148961cb452bd5ea ./reports/ory_clickhouse-row-counts.txt
+1bc6f516c2a9edcf4290e532f09ed2637cf82c4c430256227e3d8a5dbb063cee ./reports/ory_hydra-row-counts.txt
+3468db707ce990a58314d257147bd5d0d94461c0e57cdb2982972d41bcb6622d ./reports/ory_keto-row-counts.txt
+b204f65234e114c083a9d45fb6cc05c2dcf7110e9f242c41075caa7b319b4a89 ./reports/ory_kratos-row-counts.txt
diff --git a/backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/data/baron_sso__audit_logs.native b/backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/data/baron_sso__audit_logs.native
similarity index 90%
rename from backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/data/baron_sso__audit_logs.native
rename to backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/data/baron_sso__audit_logs.native
index 04ca17b6..d8f34e65 100644
Binary files a/backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/data/baron_sso__audit_logs.native and b/backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/data/baron_sso__audit_logs.native differ
diff --git a/backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_daily_aggregate.native b/backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_daily_aggregate.native
similarity index 91%
rename from backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_daily_aggregate.native
rename to backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_daily_aggregate.native
index 342c8aa9..8b52668e 100644
--- a/backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_daily_aggregate.native
+++ b/backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_daily_aggregate.native
@@ -1,4 +1,6 @@
-
+
+event_dateDateP tenant_idString$f36e2211-8cfd-4813-8618-34e606fe73actenant_typeStringORGANIZATION client_idStringorgfrontclient_nameStringOrgFront
+event_typeStringrp_usage.authorization_grantedevents_countAggregateFunction(count)unique_subjects$AggregateFunction(uniqExact, String)<XK>ObR
event_dateDatecPdPdPdPhPhPhPPPPPPPPPPPPPPPPPPP tenant_idString$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$d4f7f478-fd3b-4ab2-b8f0-8515b45f4fac$3a660456-eceb-472b-a9a9-f2a5b0ce972b$52266543-a90b-4441-99c6-51f454b6059a$52266543-a90b-4441-99c6-51f454b6059a$78f251f6-d35b-422d-92ab-7fabd80bef85$35cc1fdf-6c0e-4b0e-8ce8-1adc918b8cbf$35cc1fdf-6c0e-4b0e-8ce8-1adc918b8cbf$3d147a08-00b9-47c7-940a-d75c36a6ce81$78f251f6-d35b-422d-92ab-7fabd80bef85$78f251f6-d35b-422d-92ab-7fabd80bef85$78f251f6-d35b-422d-92ab-7fabd80bef85$35cc1fdf-6c0e-4b0e-8ce8-1adc918b8cbf$35cc1fdf-6c0e-4b0e-8ce8-1adc918b8cbf$3a660456-eceb-472b-a9a9-f2a5b0ce972b$3a660456-eceb-472b-a9a9-f2a5b0ce972b$3d147a08-00b9-47c7-940a-d75c36a6ce81$78f251f6-d35b-422d-92ab-7fabd80bef85$f36e2211-8cfd-4813-8618-34e606fe73ac$f36e2211-8cfd-4813-8618-34e606fe73actenant_typeStringORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATIONORGANIZATION client_idStringdevfront
adminfrontdevfrontorgfront
adminfrontdevfrontorgfrontorgfrontdevfrontorgfrontdevfront$37290c73-0e5f-4250-ac4d-7b173d6b6ee0devfront$2ddc94e5-6c0f-4456-a025-1c6f438fb046$2ddc94e5-6c0f-4456-a025-1c6f438fb046
diff --git a/backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_events.native b/backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_events.native
similarity index 84%
rename from backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_events.native
rename to backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_events.native
index ca8d0be3..88ab3399 100644
Binary files a/backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_events.native and b/backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/data/baron_sso__rp_usage_events.native differ
diff --git a/backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/schema/baron_sso__audit_logs.sql b/backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/schema/baron_sso__audit_logs.sql
similarity index 100%
rename from backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/schema/baron_sso__audit_logs.sql
rename to backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/schema/baron_sso__audit_logs.sql
diff --git a/backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate.sql b/backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate.sql
similarity index 100%
rename from backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate.sql
rename to backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate.sql
diff --git a/backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate_mv.sql b/backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate_mv.sql
similarity index 100%
rename from backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate_mv.sql
rename to backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_daily_aggregate_mv.sql
diff --git a/backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_events.sql b/backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_events.sql
similarity index 100%
rename from backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_events.sql
rename to backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/schema/baron_sso__rp_usage_events.sql
diff --git a/backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/tables.tsv b/backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/tables.tsv
similarity index 100%
rename from backups/baron-sso-backup-20260615-105417Z/clickhouse/baron_clickhouse/tables.tsv
rename to backups/baron-sso-backup-20260617-122446Z/clickhouse/baron_clickhouse/tables.tsv
diff --git a/backups/baron-sso-backup-20260615-105417Z/clickhouse/ory_clickhouse/data/ory__oathkeeper_access_logs.native b/backups/baron-sso-backup-20260617-122446Z/clickhouse/ory_clickhouse/data/ory__oathkeeper_access_logs.native
similarity index 87%
rename from backups/baron-sso-backup-20260615-105417Z/clickhouse/ory_clickhouse/data/ory__oathkeeper_access_logs.native
rename to backups/baron-sso-backup-20260617-122446Z/clickhouse/ory_clickhouse/data/ory__oathkeeper_access_logs.native
index 32ee98c9..89860a3c 100644
Binary files a/backups/baron-sso-backup-20260615-105417Z/clickhouse/ory_clickhouse/data/ory__oathkeeper_access_logs.native and b/backups/baron-sso-backup-20260617-122446Z/clickhouse/ory_clickhouse/data/ory__oathkeeper_access_logs.native differ
diff --git a/backups/baron-sso-backup-20260615-105417Z/clickhouse/ory_clickhouse/schema/ory__oathkeeper_access_logs.sql b/backups/baron-sso-backup-20260617-122446Z/clickhouse/ory_clickhouse/schema/ory__oathkeeper_access_logs.sql
similarity index 100%
rename from backups/baron-sso-backup-20260615-105417Z/clickhouse/ory_clickhouse/schema/ory__oathkeeper_access_logs.sql
rename to backups/baron-sso-backup-20260617-122446Z/clickhouse/ory_clickhouse/schema/ory__oathkeeper_access_logs.sql
diff --git a/backups/baron-sso-backup-20260615-105417Z/clickhouse/ory_clickhouse/tables.tsv b/backups/baron-sso-backup-20260617-122446Z/clickhouse/ory_clickhouse/tables.tsv
similarity index 100%
rename from backups/baron-sso-backup-20260615-105417Z/clickhouse/ory_clickhouse/tables.tsv
rename to backups/baron-sso-backup-20260617-122446Z/clickhouse/ory_clickhouse/tables.tsv
diff --git a/backups/baron-sso-backup-20260615-105417Z/config/compose/compose.infra.yaml b/backups/baron-sso-backup-20260617-122446Z/config/compose/compose.infra.yaml
similarity index 100%
rename from backups/baron-sso-backup-20260615-105417Z/config/compose/compose.infra.yaml
rename to backups/baron-sso-backup-20260617-122446Z/config/compose/compose.infra.yaml
diff --git a/backups/baron-sso-backup-20260615-105417Z/config/compose/compose.ory.yaml b/backups/baron-sso-backup-20260617-122446Z/config/compose/compose.ory.yaml
similarity index 100%
rename from backups/baron-sso-backup-20260615-105417Z/config/compose/compose.ory.yaml
rename to backups/baron-sso-backup-20260617-122446Z/config/compose/compose.ory.yaml
diff --git a/backups/baron-sso-backup-20260615-105417Z/config/compose/docker-compose.yaml b/backups/baron-sso-backup-20260617-122446Z/config/compose/docker-compose.yaml
similarity index 92%
rename from backups/baron-sso-backup-20260615-105417Z/config/compose/docker-compose.yaml
rename to backups/baron-sso-backup-20260617-122446Z/config/compose/docker-compose.yaml
index 49fc0262..59c7cd8f 100644
--- a/backups/baron-sso-backup-20260615-105417Z/config/compose/docker-compose.yaml
+++ b/backups/baron-sso-backup-20260617-122446Z/config/compose/docker-compose.yaml
@@ -55,11 +55,14 @@ services:
build:
context: .
dockerfile: ./adminfront/Dockerfile
+ target: dev
args:
VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: adminfront
container_name: baron_adminfront
+ command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
+ working_dir: /workspace/adminfront
env_file:
- .env
environment:
@@ -67,6 +70,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
+ - DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${ADMINFRONT_PORT:-5173}:5173"
volumes:
@@ -84,11 +88,14 @@ services:
build:
context: .
dockerfile: ./devfront/Dockerfile
+ target: dev
args:
VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: devfront
container_name: baron_devfront
+ command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
+ working_dir: /workspace/devfront
env_file:
- .env
environment:
@@ -96,6 +103,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
+ - DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${DEVFRONT_PORT:-5174}:5173"
volumes:
@@ -113,11 +121,14 @@ services:
build:
context: .
dockerfile: ./orgfront/Dockerfile
+ target: dev
args:
VITE_ORGFRONT_PUBLIC_URL: ${ORGFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: orgfront
container_name: baron_orgfront
+ command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5175"]
+ working_dir: /workspace/orgfront
env_file:
- .env
environment:
@@ -125,6 +136,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
+ - DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${ORGFRONT_PORT:-5175}:5175"
volumes:
diff --git a/backups/baron-sso-backup-20260615-105417Z/config/env.redacted b/backups/baron-sso-backup-20260617-122446Z/config/env.redacted
similarity index 100%
rename from backups/baron-sso-backup-20260615-105417Z/config/env.redacted
rename to backups/baron-sso-backup-20260617-122446Z/config/env.redacted
diff --git a/backups/baron-sso-backup-20260615-105417Z/config/gateway.tar.zst b/backups/baron-sso-backup-20260617-122446Z/config/gateway.tar.zst
similarity index 100%
rename from backups/baron-sso-backup-20260615-105417Z/config/gateway.tar.zst
rename to backups/baron-sso-backup-20260617-122446Z/config/gateway.tar.zst
diff --git a/backups/baron-sso-backup-20260617-122446Z/config/generated-ory.tar.zst b/backups/baron-sso-backup-20260617-122446Z/config/generated-ory.tar.zst
new file mode 100644
index 00000000..3370627e
Binary files /dev/null and b/backups/baron-sso-backup-20260617-122446Z/config/generated-ory.tar.zst differ
diff --git a/backups/baron-sso-backup-20260615-105417Z/manifest.json b/backups/baron-sso-backup-20260617-122446Z/manifest.json
similarity index 83%
rename from backups/baron-sso-backup-20260615-105417Z/manifest.json
rename to backups/baron-sso-backup-20260617-122446Z/manifest.json
index 5d3c0956..b4c0a19e 100644
--- a/backups/baron-sso-backup-20260615-105417Z/manifest.json
+++ b/backups/baron-sso-backup-20260617-122446Z/manifest.json
@@ -1,7 +1,7 @@
{
"format_version": "1",
- "created_at": "2026-06-15T10:54:33Z",
- "git_commit": "4d468cd39f66",
+ "created_at": "2026-06-17T12:24:46Z",
+ "git_commit": "b2808759d22a",
"mode": "maintenance",
"environment_scope": "same-env-only",
"services": ["postgres", "ory-postgres", "clickhouse", "ory-clickhouse", "config"],
diff --git a/backups/baron-sso-backup-20260615-105417Z/postgres/baron.dump b/backups/baron-sso-backup-20260617-122446Z/postgres/baron.dump
similarity index 63%
rename from backups/baron-sso-backup-20260615-105417Z/postgres/baron.dump
rename to backups/baron-sso-backup-20260617-122446Z/postgres/baron.dump
index b830e0c2..05900840 100644
Binary files a/backups/baron-sso-backup-20260615-105417Z/postgres/baron.dump and b/backups/baron-sso-backup-20260617-122446Z/postgres/baron.dump differ
diff --git a/backups/baron-sso-backup-20260615-105417Z/postgres/globals.sql b/backups/baron-sso-backup-20260617-122446Z/postgres/globals.sql
similarity index 77%
rename from backups/baron-sso-backup-20260615-105417Z/postgres/globals.sql
rename to backups/baron-sso-backup-20260617-122446Z/postgres/globals.sql
index f1dd5307..a2966f8b 100644
--- a/backups/baron-sso-backup-20260615-105417Z/postgres/globals.sql
+++ b/backups/baron-sso-backup-20260617-122446Z/postgres/globals.sql
@@ -2,7 +2,7 @@
-- PostgreSQL database cluster dump
--
-\restrict Nh0reka1aBJKKDfxlc9L4ubRixmPembXVECzqwEVE1GjdnFtzDPBHXZ6jU72Ib3
+\restrict 0VWmw0eDD5rSeTkFM3kTkxmXdU53ob4gyB6AWfAtjn8ay3NdNHvQwnlEuVR9eTY
SET default_transaction_read_only = off;
@@ -27,7 +27,7 @@ ALTER ROLE ory WITH SUPERUSER INHERIT CREATEROLE CREATEDB LOGIN REPLICATION BYPA
-\unrestrict Nh0reka1aBJKKDfxlc9L4ubRixmPembXVECzqwEVE1GjdnFtzDPBHXZ6jU72Ib3
+\unrestrict 0VWmw0eDD5rSeTkFM3kTkxmXdU53ob4gyB6AWfAtjn8ay3NdNHvQwnlEuVR9eTY
--
-- PostgreSQL database cluster dump complete
diff --git a/backups/baron-sso-backup-20260617-122446Z/postgres/ory_hydra.dump b/backups/baron-sso-backup-20260617-122446Z/postgres/ory_hydra.dump
new file mode 100644
index 00000000..dd61f927
Binary files /dev/null and b/backups/baron-sso-backup-20260617-122446Z/postgres/ory_hydra.dump differ
diff --git a/backups/baron-sso-backup-20260615-105417Z/postgres/ory_keto.dump b/backups/baron-sso-backup-20260617-122446Z/postgres/ory_keto.dump
similarity index 51%
rename from backups/baron-sso-backup-20260615-105417Z/postgres/ory_keto.dump
rename to backups/baron-sso-backup-20260617-122446Z/postgres/ory_keto.dump
index f9b25951..9b2a9b23 100644
Binary files a/backups/baron-sso-backup-20260615-105417Z/postgres/ory_keto.dump and b/backups/baron-sso-backup-20260617-122446Z/postgres/ory_keto.dump differ
diff --git a/backups/baron-sso-backup-20260617-122446Z/postgres/ory_kratos.dump b/backups/baron-sso-backup-20260617-122446Z/postgres/ory_kratos.dump
new file mode 100644
index 00000000..0ea4d179
Binary files /dev/null and b/backups/baron-sso-backup-20260617-122446Z/postgres/ory_kratos.dump differ
diff --git a/baron-sso-backup-20260615-105417Z.tar.zst b/baron-sso-backup-20260615-105417Z.tar.zst
deleted file mode 100644
index ac3c6938..00000000
Binary files a/baron-sso-backup-20260615-105417Z.tar.zst and /dev/null differ
diff --git a/base_level.csv b/base_level.csv
new file mode 100644
index 00000000..c48bbd02
--- /dev/null
+++ b/base_level.csv
@@ -0,0 +1,14 @@
+"Name","External Key"
+"회장","chair"
+"사장","pres"
+"부회장","vchair"
+"고문","advisor"
+"부사장","vp"
+"전무이사","execdir"
+"상무이사","mandir"
+"이사","dir"
+"부장","gm"
+"차장","dgm"
+"과장","mgr"
+"대리","asstmgr"
+"사원","staff"
diff --git a/common/config/vite.base.ts b/common/config/vite.base.ts
index fffb528c..9f9730c2 100644
--- a/common/config/vite.base.ts
+++ b/common/config/vite.base.ts
@@ -14,6 +14,7 @@ const reactPackageDir = path.dirname(require.resolve("react/package.json"));
const reactDomPackageDir = path.dirname(
require.resolve("react-dom/package.json"),
);
+const usePolling = process.env.DEV_SERVER_WATCH_POLLING === "true";
export const commonViteConfig: UserConfig = {
plugins: [react()],
@@ -33,6 +34,7 @@ export const commonViteConfig: UserConfig = {
fs: {
allow: [appWorkspaceDir, commonWorkspaceDir, "/workspace/common"],
},
+ watch: usePolling ? { interval: 300, usePolling: true } : undefined,
},
};
diff --git a/devfront/Dockerfile b/devfront/Dockerfile
index 32d7cdef..ed1c21b0 100644
--- a/devfront/Dockerfile
+++ b/devfront/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:lts AS build
+FROM node:lts AS deps
WORKDIR /workspace
@@ -20,6 +20,17 @@ ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
RUN pnpm install --frozen-lockfile --ignore-scripts
+FROM deps AS dev
+
+WORKDIR /workspace/devfront
+ENV NODE_ENV=development
+
+EXPOSE 5173
+
+CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
+
+FROM deps AS build
+
WORKDIR /workspace/devfront
RUN npm run build
diff --git a/devfront/e2e-evidence/tenant-access-allowed-tenant-added.png b/devfront/e2e-evidence/tenant-access-allowed-tenant-added.png
new file mode 100644
index 00000000..c78e6981
Binary files /dev/null and b/devfront/e2e-evidence/tenant-access-allowed-tenant-added.png differ
diff --git a/devfront/e2e-evidence/tenant-access-allowed-tenant-deleted.png b/devfront/e2e-evidence/tenant-access-allowed-tenant-deleted.png
new file mode 100644
index 00000000..81710b6c
Binary files /dev/null and b/devfront/e2e-evidence/tenant-access-allowed-tenant-deleted.png differ
diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts
index cfb1eb55..523fa9ef 100644
--- a/devfront/playwright.config.ts
+++ b/devfront/playwright.config.ts
@@ -13,7 +13,12 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
const skipWebServer =
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true";
-const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4174";
+const port = Number.parseInt(process.env.PORT ?? "5174", 10);
+const defaultBaseUrl = `http://127.0.0.1:${port}`;
+const baseURL =
+ process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL || defaultBaseUrl;
+const usePreviewServer =
+ process.env.CI === "true" || process.env.PLAYWRIGHT_USE_PREVIEW === "true";
/**
* Read environment variables from file.
@@ -73,9 +78,10 @@ export default defineConfig({
webServer: skipWebServer
? undefined
: {
- command:
- "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 4174",
- url: baseURL,
- reuseExistingServer: false,
+ command: usePreviewServer
+ ? `VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port ${port}`
+ : `VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite --host 127.0.0.1 --strictPort --port ${port}`,
+ url: defaultBaseUrl,
+ reuseExistingServer: !process.env.CI && !process.env.PORT,
},
});
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 49fc0262..59c7cd8f 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -55,11 +55,14 @@ services:
build:
context: .
dockerfile: ./adminfront/Dockerfile
+ target: dev
args:
VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: adminfront
container_name: baron_adminfront
+ command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
+ working_dir: /workspace/adminfront
env_file:
- .env
environment:
@@ -67,6 +70,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
+ - DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${ADMINFRONT_PORT:-5173}:5173"
volumes:
@@ -84,11 +88,14 @@ services:
build:
context: .
dockerfile: ./devfront/Dockerfile
+ target: dev
args:
VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: devfront
container_name: baron_devfront
+ command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
+ working_dir: /workspace/devfront
env_file:
- .env
environment:
@@ -96,6 +103,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
+ - DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${DEVFRONT_PORT:-5174}:5173"
volumes:
@@ -113,11 +121,14 @@ services:
build:
context: .
dockerfile: ./orgfront/Dockerfile
+ target: dev
args:
VITE_ORGFRONT_PUBLIC_URL: ${ORGFRONT_URL}
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: orgfront
container_name: baron_orgfront
+ command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5175"]
+ working_dir: /workspace/orgfront
env_file:
- .env
environment:
@@ -125,6 +136,7 @@ services:
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
+ - DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports:
- "${ORGFRONT_PORT:-5175}:5175"
volumes:
diff --git a/docs/SoT_Architecture_Policy.md b/docs/SoT_Architecture_Policy.md
index 42cab310..b1c94bc9 100644
--- a/docs/SoT_Architecture_Policy.md
+++ b/docs/SoT_Architecture_Policy.md
@@ -8,7 +8,7 @@ Baron SSO에서 인증 identity, 권한 관계, OAuth/OIDC 위임의 원장은 O
- Authorization/ReBAC 원장: Ory Keto
- OAuth/OIDC client, consent, token state 원장: Ory Hydra
-Backend DB는 Ory를 대체하는 원장이 아닙니다. Ory에 저장되지 않거나 Ory API로 필요한 방식의 조회가 불가능한 업무 데이터의 read model, 감사 로그, 처리 상태, 성능 cache 보조 데이터만 허용합니다.
+Backend DB는 Ory를 대체하는 원장이 아닙니다. 특히 사용자 identity/profile/소속/조직도 노출 데이터에 대해 Backend DB `users`를 원장 또는 read model로 사용하지 않습니다. Ory와 무관한 감사 로그, 처리 상태, 외부 연동 작업 상태처럼 별도 원장이 명시된 데이터만 Backend DB에 둘 수 있습니다.
Ory에서 Redis cache로 웜업된 데이터는 Backend가 cursor 기반 API로 front 또는 외부 API에 제공합니다. frontend는 Redis나 Backend DB 복제본을 원장처럼 직접 소비하지 않습니다.
@@ -19,7 +19,7 @@ Ory에서 Redis cache로 웜업된 데이터는 Backend가 cursor 기반 API로
- Ory Kratos identity가 subject, credentials, recovery/verification address, 인증 식별자의 원장입니다.
- Kratos identity 변경은 Backend의 중앙 `IdentityWriteService`를 경유해야 합니다.
- Redis identity mirror는 빠른 단건/목록/검색 조회를 위한 cache입니다. stale 가능성을 API 응답에 드러내야 합니다.
-- Backend DB `users`는 Ory에 저장되지 않거나 Ory에서 필요한 방식으로 조회할 수 없는 Baron 운영 데이터의 read model입니다.
+- Backend DB `users`는 사용자 identity/profile/소속 조회 read model이 아닙니다. 남은 의존은 제거 대상이며, 조회 API는 Kratos identity mirror 또는 Kratos Admin API fallback을 기준으로 해야 합니다.
### 2.2 Permissions & Relationships
@@ -41,14 +41,14 @@ Ory에서 Redis cache로 웜업된 데이터는 Backend가 cursor 기반 API로
2. Backend가 중앙 service를 통해 Ory API를 동기 호출합니다.
3. Ory write 성공 후 Ory ID로 재조회합니다.
4. Redis mirror를 갱신하거나 갱신 실패 시 `stale`/`failed` 상태를 기록합니다.
-5. Ory에 저장되지 않거나 조회 불가능한 read model만 Backend DB에 갱신합니다.
+5. 사용자 identity/profile/소속 데이터는 Backend DB `users`에 read model로 갱신하지 않습니다. Ory와 별도 원장이 명시된 처리 상태만 Backend DB에 기록합니다.
### 3.2 Read Path
- Self context: Ory session/token 또는 Ory API를 기준으로 검증합니다.
-- Admin/list context: Backend가 Redis mirror와 허용된 read model을 조합해 cursor 기반 API로 제공합니다.
+- Admin/list context: Backend가 Redis identity mirror 또는 Ory Admin API fallback을 기준으로 cursor 기반 API를 제공합니다.
- API response는 `identityTotal`, read model count, mirror status를 구분해야 합니다.
### 3.3 Conflict Resolution
-불일치가 발견되면 Ory Stack의 데이터를 기준으로 Redis mirror와 Backend read model을 보정합니다. Backend read model이나 token claim assembly 결과를 Ory보다 우선하는 근거로 사용하지 않습니다.
+불일치가 발견되면 Ory Stack의 데이터를 기준으로 Redis mirror를 보정합니다. Backend DB `users`나 token claim assembly 결과를 Ory보다 우선하는 근거로 사용하지 않습니다.
diff --git a/docs/admin-user-list-ssot-cache-and-local-user-db.md b/docs/admin-user-list-ssot-cache-and-local-user-db.md
new file mode 100644
index 00000000..1bf0cc7e
--- /dev/null
+++ b/docs/admin-user-list-ssot-cache-and-local-user-db.md
@@ -0,0 +1,110 @@
+# Admin User List SSOT, Cache, and Local User DB 제거 정책
+
+작성일: 2026-06-17
+
+## 결론
+
+AdminFront 사용자 목록은 테넌트 목록과 같은 cursor/search 경험을 제공해야 하지만, 사용자 identity의 SSOT는 Ory Kratos입니다. 성능 목표를 위해 cache를 사용할 수 있고 사용해야 하지만, cache나 PostgreSQL `users` table을 사용자 identity/profile/소속 조회의 원장 또는 read model로 사용하면 안 됩니다.
+
+테넌트 목록은 현재 primary DB cursor/search/sort query와 batch aggregation으로 개선되어 있으며, Redis cache는 필수 구현으로 들어가지 않았습니다. 다만 #1191의 정책처럼 Redis cache는 tenant tree edge, scope 계산, page response, member count aggregate 같은 보조 성능 계층으로 허용됩니다.
+
+사용자 목록은 데이터 원장이 Kratos이고 목록 조회가 Redis identity mirror를 거치므로, 목표 수치에 도달하려면 단순 KV mirror 전체 scan이 아니라 cursor/search/scope에 맞는 Redis index cache가 필요합니다.
+
+## 사용자 목록 SSOT 원칙
+
+- Kratos `identities`가 subject, credentials, recovery/verifiable address, identity state의 원장입니다.
+- Redis identity mirror/index는 Kratos identity를 빠르게 조회하기 위한 cache입니다.
+- Backend DB `users`는 Kratos를 대체하는 사용자 원장도, 사용자 조회 read model도 아닙니다.
+- 사용자 목록 API는 Kratos에서 warm-up된 Redis mirror를 기준으로 응답해야 하며, identity 존재 여부와 identity total은 Kratos mirror 기준을 우선해야 합니다.
+- Redis mirror가 stale/failed/empty이면 이를 숨기지 말고 API 응답 또는 운영 상태에 드러내야 합니다.
+- cache miss 또는 drift가 발견되면 Kratos 기준으로 Redis mirror를 보정해야 하며, local `users` DB를 정상 fallback처럼 사용하면 안 됩니다.
+
+## Cache 사용 가능 범위
+
+허용되는 cache:
+
+- `identity:mirror:{identityID}`: Kratos identity summary JSON
+- `identity:mirror:state`: mirror status, observed count, refreshed time, error
+- sorted/index set: `createdAt,id` 기반 cursor page 조회
+- normalized search token index: email, name, login ID, phone, selected metadata search
+- tenant access key index: primary tenant, joined tenant, additional appointment tenant id/slug
+- short TTL page response cache: role/scope/search/tenantSlug/sort/direction/cursor/limit 포함 key
+
+제약:
+
+- cache key는 query scope를 모두 포함해야 합니다.
+- 권한 범위가 들어간 cache는 user id 또는 permission scope hash를 포함해야 합니다.
+- Kratos write-through 실패 시 mirror state를 `stale` 또는 `failed`로 전환해야 합니다.
+- Redis 장애 시 local DB를 identity SSOT fallback처럼 사용하지 않습니다. 가능한 경우 Kratos API fallback을 쓰고, 불가능하면 identity mirror unavailable로 실패시킵니다.
+- cache는 감사 근거 또는 권한 판정 단독 근거가 아닙니다. 권한 관계의 원장은 Keto입니다.
+
+## 현재 사용자 목록 병목
+
+현재 `GET /v1/admin/users`는 `listIdentitiesFromMirrorOrKratos()`를 통해 identity mirror 전체를 배열로 읽은 다음 handler 메모리에서 검색, tenant filter, 권한 scope, sort, pagination을 수행합니다.
+
+Redis mirror 구현도 `SCAN identity:mirror:*` 후 key별 `GET`으로 전체 identity를 materialize합니다. 따라서 API 응답은 cursor를 반환하지만, 서버 내부 비용은 page 단위가 아니라 전체 mirror 크기에 비례합니다.
+
+테넌트 목록과 같은 성능 특성을 만들려면 `/v1/admin/users`의 cursor/search/scope 조건이 Redis identity index 또는 Kratos-backed query boundary까지 내려가야 합니다.
+
+## Local `users` DB 잔존 의존 제거 대상
+
+PostgreSQL `users` table은 admin user list, org-context, orgfront snapshot의 사용자 identity/profile/소속 read model로 사용하면 안 됩니다. 현재 코드 기준으로 남아 있는 의존은 유지 근거가 아니라 제거 또는 별도 원장 재정의가 필요한 대상입니다.
+
+| 기능 | 사용 위치 | 정리 방향 |
+| --- | --- | --- |
+| 사용자 생성/수정 후 local sync | `user_handler.go`, `auth_handler.go` | Kratos write-through 후 Redis mirror 갱신으로 대체하고 local sync 제거 여부를 추적합니다. |
+| custom login ID index | `user_login_ids`, `IsLoginIDTaken`, `FindTenantIDByLoginID` | Kratos traits/credentials identifier 또는 별도 명시 원장으로 재정의합니다. |
+| tenant membership count | `TenantHandler.countTenantMembers`, `UserRepo.CountByTenantIDs` | Redis identity mirror/Keto relation 기준 aggregate로 대체합니다. |
+| org context member export | `TenantHandler.loadOrgContextMembers` | Kratos identity mirror 기준으로 전환합니다. |
+| admin CSV export/import | `ExportUsersCSV`, bulk create/update | Kratos/mirror 기반 export와 command import로 분리합니다. |
+| WORKS Mobile sync/comparison | `worksmobile_sync_service.go` | Kratos identity mirror와 WORKS API 비교로 전환합니다. |
+| Hanmac email/local-part policy | `hanmac_email_policy.go` | Kratos identifier/mirror index 기준 uniqueness로 전환합니다. |
+| user status 운영 정책 | `users.status`, `deleted_at` | Kratos traits/state 또는 명시된 별도 원장으로 재정의합니다. |
+| profile/session 보조 | `auth_handler.go` | Kratos session/identity traits와 Redis mirror 기준으로 전환합니다. |
+
+따라서 local `users` DB를 사용자 조회의 primary source 또는 read model로 유지하는 방향은 최신 정책과 맞지 않습니다.
+
+## 사용자 목록 개선 방향
+
+1. Redis identity mirror index를 확장합니다.
+ - 기본 정렬: `createdAt desc, id desc`
+ - cursor: timestamp + identity id
+ - search: normalized email/name/login ID/phone/token index
+ - tenant filter: primary tenant id/slug와 additional appointments 기반 index
+
+2. `/v1/admin/users` query boundary를 분리합니다.
+ - 입력: `limit`, `cursor`, `search`, `tenantSlug`, `sort`, `direction`, requester scope
+ - 출력: page identities, `identityTotal`, `nextCursor`, `mirrorStatus`
+ - handler는 전체 identity slice를 직접 만들지 않습니다.
+
+3. row enrichment는 page 단위 batch로 수행합니다.
+ - primary tenant summary lookup batch
+ - joined tenant/relation lookup batch 또는 bounded cache
+ - per-row `GetTenant`/`ListJoinedTenants` 반복 호출 금지
+
+4. local `users` DB join을 제거합니다.
+ - 사용자 identity/profile/소속/상태는 Kratos mirror 기준으로 응답
+ - Kratos mirror에 없는 local DB row를 목록 identity로 승격하지 않음
+ - 남은 local DB 의존은 drift/deprecation report에만 노출
+
+5. AdminFront는 tenant 목록과 같은 query lifecycle로 맞춥니다.
+ - `useInfiniteQuery` queryKey에 normalized/deferred search, tenantSlug, sort, direction 포함
+ - 검색/정렬 변경 시 cursor를 재시작
+ - tenant filter option 로딩은 사용자 첫 page 렌더를 막지 않음
+
+## 테스트 기준
+
+RED 테스트는 다음 실패를 먼저 보여야 합니다.
+
+- search/cursor 요청에서 backend가 전체 mirror를 매번 materialize하는 경로가 호출됨
+- 첫 page 밖 사용자가 검색되어야 하지만 cursor/search/index 계약이 없어서 전체 scan에 의존함
+- page item 50개 매핑 시 tenant lookup 또는 joined tenant lookup이 row 수만큼 반복됨
+- Redis mirror stale 상태에서 local DB만으로 정상 목록처럼 응답함
+
+최종 통과 기준:
+
+- Kratos mirror 기준 identity result와 API `items`가 동일합니다.
+- Redis index cache hit/miss가 같은 결과를 냅니다.
+- Redis stale/failed 상태가 숨겨지지 않습니다.
+- local DB row만 있고 Kratos mirror에 없는 사용자는 일반 admin user list에 identity item으로 나오지 않습니다.
+- 3,500건 이상 사용자 데이터에서 첫 화면 1500ms 이하, 검색 500ms 이하를 유지합니다.
diff --git a/docs/integrations-org-context-json-api.md b/docs/integrations-org-context-json-api.md
index 6fffbd18..d1153302 100644
--- a/docs/integrations-org-context-json-api.md
+++ b/docs/integrations-org-context-json-api.md
@@ -2,7 +2,7 @@
## 목적
-외부 연동앱이 계정 세션 없이 M2M 방식으로 Baron SSO의 조직구성을 조회할 수 있게 한다. 조직구성은 Ory SSOT에서 웜업한 Redis cache와 Ory에 저장되지 않거나 조회가 불가능한 Backend read model을 Backend가 조합해 cursor 기반 API로 제공한다. Backend DB나 claim output을 SSOT로 사용하지 않으며, iframe 또는 `postMessage` 계약은 사용하지 않는다.
+외부 연동앱이 계정 세션 없이 M2M 방식으로 Baron SSO의 조직구성을 조회할 수 있게 한다. 조직구성과 사용자 멤버 정보는 Ory SSOT에서 웜업한 Redis cache 또는 Ory Admin API fallback을 기준으로 제공한다. Backend DB `users`나 claim output을 SSOT 또는 read model로 사용하지 않으며, iframe 또는 `postMessage` 계약은 사용하지 않는다.
## 인증
diff --git a/docs/make-dev-vite-hmr-policy.md b/docs/make-dev-vite-hmr-policy.md
new file mode 100644
index 00000000..67389262
--- /dev/null
+++ b/docs/make-dev-vite-hmr-policy.md
@@ -0,0 +1,78 @@
+# make dev Vite HMR Policy
+
+`make dev`는 로컬 개발 중 소스 변경이 즉시 화면에 반영되는 환경이어야 합니다. `adminfront`, `devfront`, `orgfront`는 `make dev`에서 production `dist`를 정적 서빙하지 않고 Vite dev server로 실행합니다.
+
+## Policy
+
+- `make dev`로 실행되는 React frontends는 Vite HMR 모드여야 합니다.
+- 대상 서비스는 `adminfront`, `devfront`, `orgfront`입니다.
+- 각 서비스는 Docker Compose에서 해당 Dockerfile의 `dev` target으로 빌드해야 합니다.
+- 각 서비스의 working directory는 bind-mounted source path인 `/workspace/`이어야 합니다.
+- 각 서비스 command는 `npm run dev -- --host 0.0.0.0 --port ` 형태여야 합니다.
+- Docker bind mount 환경에서 파일 변경 감지가 누락되지 않도록 `DEV_SERVER_WATCH_POLLING=true`가 기본값이어야 합니다.
+- production image는 기존처럼 `production` target에서 build output `dist`를 `serve_frontend_prod.mjs`로 정적 서빙할 수 있습니다. 이 정책은 `make dev` runtime에만 적용합니다.
+
+## Current Ports
+
+- AdminFront: `http://localhost:5173`
+- DevFront: `http://localhost:5174`
+- OrgFront: `http://localhost:5175`
+
+## Required Structure
+
+Each React frontend Dockerfile must keep these stages:
+
+- `deps`: install workspace dependencies.
+- `dev`: run Vite dev server from `/workspace/`.
+- `build`: run production build.
+- `production`: serve built `dist` with the static server.
+
+`docker-compose.yaml` must select `target: dev` for the three React frontends. This prevents `make dev` from accidentally serving stale `/app/dist` output.
+
+## Regression Guard
+
+The policy is checked by:
+
+```sh
+test/frontend_dev_bind_mount_policy_test.sh
+test/playwright_frontend_runtime_policy_test.sh
+```
+
+The test verifies that:
+
+- source directories are bind-mounted into `/workspace/`;
+- `node_modules` stays in container volumes;
+- each React frontend uses `target: dev`;
+- each React frontend runs `npm run dev`;
+- each React frontend enables polling watch by default;
+- `serve_frontend_prod.mjs` is not used by the Compose dev service;
+- Dockerfiles keep separate `dev`, `build`, and `production` stages.
+
+If this test fails, do not work around it by refreshing the browser or rebuilding manually. Fix the Compose/Dockerfile runtime so `make dev` remains a true HMR development mode.
+
+## Playwright Runtime Policy
+
+Local Playwright tests should use Vite dev servers by default. This keeps local E2E feedback aligned with `make dev` and avoids testing stale production `dist` output while actively editing frontend code.
+
+Gitea Actions and other CI runs should use build output through Vite preview. This validates production bundle behavior before merge.
+
+Required behavior:
+
+- Local default:
+ - AdminFront uses Vite dev server on port `5173`.
+ - DevFront uses Vite dev server on port `5174`.
+ - OrgFront uses Vite dev server on port `5175`.
+- CI or explicit preview mode:
+ - `CI=true` or `PLAYWRIGHT_USE_PREVIEW=true` switches React frontend Playwright web servers to `build` + `preview`.
+- Existing running server mode:
+ - `BASE_URL` can be used for AdminFront and OrgFront.
+ - `PLAYWRIGHT_BASE_URL` or `BASE_URL` can be used for DevFront.
+ - `PLAYWRIGHT_SKIP_WEBSERVER=true` disables DevFront's managed web server.
+
+Use explicit preview locally only when testing production bundle behavior:
+
+```sh
+PLAYWRIGHT_USE_PREVIEW=true npm --prefix adminfront test
+PLAYWRIGHT_USE_PREVIEW=true npm --prefix devfront test
+PLAYWRIGHT_USE_PREVIEW=true npm --prefix orgfront test
+```
diff --git a/docs/worksmobile-sync-policy.md b/docs/worksmobile-sync-policy.md
new file mode 100644
index 00000000..2f2f5e1d
--- /dev/null
+++ b/docs/worksmobile-sync-policy.md
@@ -0,0 +1,38 @@
+# Worksmobile Sync Policy
+
+## 겸직 소속 비교 정책
+
+Baron과 WORKS 모두 한 사용자가 여러 조직에 동시에 소속될 수 있다. 따라서 사용자 소속 비교는 배열 순서나 대표 조직 우선순위만으로 불일치를 판단하지 않는다.
+
+### Membership Set 우선
+
+사용자 소속 비교의 기본 단위는 `domainID + orgUnitID`로 구성한 membership set이다.
+
+- Baron expected organization과 WORKS remote organization의 membership set이 같으면 같은 소속으로 본다.
+- 같은 membership set 안에서 `organization.primary` 또는 `orgUnit.primary` 우선순위만 다른 경우는 보정 사유가 아니다.
+- GPDTDC와 본소속이 모두 있는 사용자도 같은 규칙을 적용한다.
+
+### 보정 대상
+
+다음 차이는 계속 보정 대상이다.
+
+- Baron에는 있는데 WORKS에 없는 orgUnit membership
+- WORKS에는 있는데 Baron에는 없는 orgUnit membership
+- 비교 대상 position 값 차이
+- 비교 대상 manager 값 차이
+
+### 보정 제외
+
+다음 차이는 사용자 `organization` update reason으로 만들지 않는다.
+
+- 같은 membership set에서 GPDTDC와 본소속의 primary 우선순위만 다른 경우
+- 같은 membership set에서 WORKS의 조직 배열 순서만 다른 경우
+
+## Grade 비교 정책
+
+직급(`grade`) 차이는 WORKS 사용자 보정 대상이다. 단, 비교 기준은 사용자 전역 `user.grade`가 아니라 테넌트 소속 정보에 연결된 `additionalAppointments[].grade`이다.
+
+- Baron의 테넌트 소속별 `grade`는 같은 WORKS `orgUnit` membership이 속한 organization level과 비교한다.
+- 같은 membership에서 Baron 테넌트 소속 `grade`와 WORKS organization level이 다르거나 WORKS level이 비어 있으면 `grade` update reason으로 본다.
+- GPDTDC 산하 테넌트의 연구원 직급과 그 외 테넌트의 일반 직급은 직급체계가 다르므로 서로 교차 비교하지 않는다.
+- GPDTDC와 본소속을 모두 가진 사용자는 각 membership의 테넌트 소속 `grade`와 해당 WORKS organization level만 각각 비교한다.
diff --git a/gpd_level.csv b/gpd_level.csv
new file mode 100644
index 00000000..b1c709cb
--- /dev/null
+++ b/gpd_level.csv
@@ -0,0 +1,8 @@
+"Name","External Key"
+"사장","pres"
+"부사장","vp"
+"전무이사","execdir"
+"수석 연구원","prin"
+"책임 연구원","lead"
+"선임 연구원","sen"
+"연구원","res"
diff --git a/orgfront/Dockerfile b/orgfront/Dockerfile
index 706bada0..d7f41709 100644
--- a/orgfront/Dockerfile
+++ b/orgfront/Dockerfile
@@ -1,4 +1,4 @@
-FROM node:lts AS build
+FROM node:lts AS deps
WORKDIR /workspace
@@ -20,6 +20,17 @@ ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
RUN pnpm install --frozen-lockfile --ignore-scripts
+FROM deps AS dev
+
+WORKDIR /workspace/orgfront
+ENV NODE_ENV=development
+
+EXPOSE 5175
+
+CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5175"]
+
+FROM deps AS build
+
WORKDIR /workspace/orgfront
RUN npm run build
diff --git a/orgfront/e2e-evidence/orgchart-is3-hanchiyoung-20260616.png b/orgfront/e2e-evidence/orgchart-is3-hanchiyoung-20260616.png
new file mode 100644
index 00000000..41ed37e5
Binary files /dev/null and b/orgfront/e2e-evidence/orgchart-is3-hanchiyoung-20260616.png differ
diff --git a/orgfront/e2e-evidence/orgchart-leader-long-name-20260616.png b/orgfront/e2e-evidence/orgchart-leader-long-name-20260616.png
new file mode 100644
index 00000000..44128137
Binary files /dev/null and b/orgfront/e2e-evidence/orgchart-leader-long-name-20260616.png differ
diff --git a/orgfront/playwright.config.ts b/orgfront/playwright.config.ts
index 0673c05b..73b3b439 100644
--- a/orgfront/playwright.config.ts
+++ b/orgfront/playwright.config.ts
@@ -10,11 +10,13 @@ const { shouldIncludeWebKit } =
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
: undefined;
-const port = Number.parseInt(process.env.PORT ?? "4175", 10);
+const port = Number.parseInt(process.env.PORT ?? "5175", 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 testOidcAuthority = "http://localhost:5000/oidc";
+const usePreviewServer =
+ process.env.CI === "true" || process.env.PLAYWRIGHT_USE_PREVIEW === "true";
/**
* Read environment variables from file.
@@ -79,7 +81,7 @@ export default defineConfig({
webServer: process.env.BASE_URL
? undefined
: {
- command: process.env.CI
+ command: usePreviewServer
? `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run build && VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run preview -- --host 127.0.0.1 --port ${port}`
: `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run dev -- --host 127.0.0.1 --port ${port}`,
url: defaultBaseUrl,
diff --git a/orgfront/src/features/orgchart/orgChartSnapshotTime.ts b/orgfront/src/features/orgchart/orgChartSnapshotTime.ts
new file mode 100644
index 00000000..62d5dd21
--- /dev/null
+++ b/orgfront/src/features/orgchart/orgChartSnapshotTime.ts
@@ -0,0 +1,41 @@
+import type { OrgChartSnapshotResponse } from "../../lib/adminApi";
+
+export function resolveOrgChartSnapshotTimestamp(
+ snapshot?: Pick<
+ OrgChartSnapshotResponse,
+ "generatedAt" | "tenants" | "users"
+ >,
+) {
+ if (!snapshot) return "";
+ if (snapshot.generatedAt?.trim()) return snapshot.generatedAt.trim();
+
+ const timestamps = [
+ ...snapshot.tenants.map((tenant) => tenant.updatedAt),
+ ...snapshot.users.map((user) => user.updatedAt),
+ ]
+ .map((value) => Date.parse(value))
+ .filter((value) => Number.isFinite(value));
+ if (timestamps.length === 0) return "";
+
+ return new Date(Math.max(...timestamps)).toISOString();
+}
+
+export function formatOrgChartSnapshotTimestamp(value: string) {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "";
+
+ const parts = new Intl.DateTimeFormat("en-CA", {
+ timeZone: "Asia/Seoul",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: false,
+ }).formatToParts(date);
+ const part = (type: Intl.DateTimeFormatPartTypes) =>
+ parts.find((item) => item.type === type)?.value ?? "";
+
+ return `${part("year")}-${part("month")}-${part("day")} ${part("hour")}:${part("minute")}:${part("second")} KST`;
+}
diff --git a/orgfront/src/features/orgchart/pickerTree.test.ts b/orgfront/src/features/orgchart/pickerTree.test.ts
index 5c48fcc6..2879b06b 100644
--- a/orgfront/src/features/orgchart/pickerTree.test.ts
+++ b/orgfront/src/features/orgchart/pickerTree.test.ts
@@ -114,6 +114,32 @@ describe("buildOrgPickerTree", () => {
]);
});
+ it("can expose every visible top-level tenant as picker roots", () => {
+ const tenants = [
+ tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
+ tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
+ tenant("commercial-id", "COMPANY_GROUP", "Commercial", "commercial"),
+ tenant(
+ "external-company-id",
+ "COMPANY",
+ "외부기업",
+ "external-company",
+ "commercial-id",
+ ),
+ ];
+
+ const tree = buildOrgPickerTree({
+ tenants,
+ users: [] satisfies UserSummary[],
+ rootTenantId: "all",
+ });
+
+ expect(tree.roots.map((node) => node.id)).toEqual([
+ "hanmac-family-id",
+ "commercial-id",
+ ]);
+ });
+
it("excludes internal and private tenants from picker choices by default", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
index 41d0128f..175a95bb 100644
--- a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
+++ b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx
@@ -4,12 +4,14 @@ import {
buildUsersMap,
clampScale,
filterSystemGlobalTenants,
+ formatOrgChartSnapshotTimestamp,
getMemberGridMetrics,
getOrgNodeHeaderFill,
getSemanticZoomMode,
layoutForest,
type OrgNode,
resolveOrgChartFamilyRoot,
+ resolveOrgChartSnapshotTimestamp,
} from "./OrgChartPage";
function orgNode(id: string, children: OrgNode[] = [], level = 0): OrgNode {
@@ -255,6 +257,94 @@ describe("org chart layout", () => {
]);
});
+ it("places organization owners before higher rank members in the same organization", () => {
+ const members = [
+ { ...member("executive"), name: "임원", grade: "전무이사" },
+ {
+ ...member("owner"),
+ name: "조직장",
+ grade: "사원",
+ metadata: {
+ additionalAppointments: [
+ {
+ tenantSlug: "root",
+ isOwner: true,
+ },
+ ],
+ },
+ },
+ { ...member("principal"), name: "수석", grade: "수석연구원" },
+ ];
+ const layout = layoutForest(
+ [
+ {
+ ...orgNode("root"),
+ members,
+ totalCount: members.length,
+ totalMemberIds: new Set(members.map((item) => item.id)),
+ },
+ ],
+ new Set(),
+ );
+ const rootNode = layout.nodes.find((item) => item.node.id === "root");
+
+ expect(rootNode?.members.map((item) => item.id)).toEqual([
+ "owner",
+ "executive",
+ "principal",
+ ]);
+ });
+
+ it("does not place representative organization members before leaders", () => {
+ const members = [
+ { ...member("executive"), name: "임원", grade: "전무이사" },
+ {
+ ...member("representative"),
+ name: "대표조직장",
+ grade: "사원",
+ metadata: {
+ additionalAppointments: [
+ {
+ tenantSlug: "root",
+ representative: true,
+ },
+ ],
+ },
+ },
+ {
+ ...member("team-head"),
+ name: "팀장",
+ grade: "선임",
+ metadata: {
+ additionalAppointments: [
+ {
+ tenantSlug: "root",
+ position: "팀장",
+ },
+ ],
+ },
+ },
+ ];
+ const layout = layoutForest(
+ [
+ {
+ ...orgNode("root"),
+ members,
+ totalCount: members.length,
+ totalMemberIds: new Set(members.map((item) => item.id)),
+ },
+ ],
+ new Set(),
+ );
+ const rootNode = layout.nodes.find((item) => item.node.id === "root");
+
+ expect(rootNode?.members.map((item) => item.id)).toEqual([
+ "team-head",
+ "executive",
+ "representative",
+ ]);
+ });
+
it("expands node width for long organization names even without members", () => {
const shortLayout = layoutForest([orgNode("short")], new Set());
const longLayout = layoutForest(
@@ -272,6 +362,7 @@ describe("org chart layout", () => {
const longNode = longLayout.nodes.find((item) => item.node.id === "long");
expect(longNode?.width).toBeGreaterThan(shortNode?.width ?? 0);
+ expect(longNode?.width).toBeGreaterThan(640);
});
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
@@ -356,7 +447,7 @@ describe("org chart layout", () => {
expect(rootEdges.filter((edge) => edge.visibleByDefault)).toHaveLength(3);
});
- it("places the deepest child subtree in the first multi-column section", () => {
+ it("preserves source data order in multi-column sections", () => {
const children = [
orgNode("shallow-1", [], 1),
orgNode("shallow-2", [], 1),
@@ -378,11 +469,14 @@ describe("org chart layout", () => {
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
- const rootEdges = layout.edges.filter((edge) =>
- edge.key.startsWith("root->"),
- );
+ const directChildren = layout.nodes
+ .filter((node) => node.node.level === 1)
+ .sort((a, b) => a.y - b.y || a.x - b.x)
+ .map((node) => node.node.id);
- expect(rootEdges.map((edge) => edge.key)).toContain("root->deep");
+ expect(directChildren.slice(0, children.length)).toEqual(
+ children.map((child) => child.id),
+ );
});
it("centers a parent over the full child span in multi-column mode", () => {
@@ -491,6 +585,24 @@ describe("org chart layout", () => {
expect(getSemanticZoomMode(0.8)).toBe("detail");
});
+ it("uses the snapshot generatedAt as the org chart data timestamp", () => {
+ expect(
+ resolveOrgChartSnapshotTimestamp({
+ generatedAt: "2026-06-17T07:10:11Z",
+ tenants: [tenantNode("tenant", "COMPANY", "Tenant", "tenant")],
+ users: [
+ {
+ ...member("user"),
+ updatedAt: "2026-06-17T09:10:11Z",
+ },
+ ],
+ }),
+ ).toBe("2026-06-17T07:10:11Z");
+ expect(formatOrgChartSnapshotTimestamp("2026-06-17T07:10:11Z")).toBe(
+ "2026-06-17 16:10:11 KST",
+ );
+ });
+
it("uses distinct header fills by organization depth", () => {
expect(getOrgNodeHeaderFill(0, "family")).toBe("#000000");
expect(getOrgNodeHeaderFill(0, "saman")).toBe("#f58220");
@@ -606,13 +718,13 @@ describe("org chart layout", () => {
]);
});
- it("maps legacy companyCode users to matching tenant slugs", () => {
+ it("does not map scalar-only tenant slugs without a membership source", () => {
const usersMap = buildUsersMap(
[
{
- ...member("engineering-user"),
+ ...member("scalar-only-user"),
companyCode: "engineering",
- tenantSlug: undefined,
+ tenantSlug: "engineering",
tenant: undefined,
joinedTenants: undefined,
},
@@ -621,8 +733,41 @@ describe("org chart layout", () => {
{ activeOnly: true },
);
- expect(usersMap.get("engineering")?.map((user) => user.id)).toEqual([
- "engineering-user",
+ expect(usersMap.get("engineering")).toBeUndefined();
+ });
+
+ it("maps super admin users when they have an actual org chart appointment", () => {
+ const usersMap = buildUsersMap(
+ [
+ {
+ ...member("super-admin-leader"),
+ role: "super_admin",
+ companyCode: "is-1",
+ tenantSlug: "is-2",
+ tenant: undefined,
+ joinedTenants: undefined,
+ metadata: {
+ additionalAppointments: [
+ {
+ tenantSlug: "is-3",
+ isManager: true,
+ },
+ ],
+ },
+ },
+ ],
+ [
+ tenantNode("is-1", "ORGANIZATION", "IS1", "is-1"),
+ tenantNode("is-2", "ORGANIZATION", "IS2", "is-2"),
+ tenantNode("is-3", "ORGANIZATION", "IS3", "is-3"),
+ ],
+ { activeOnly: true },
+ );
+
+ expect(usersMap.get("is-1")).toBeUndefined();
+ expect(usersMap.get("is-2")).toBeUndefined();
+ expect(usersMap.get("is-3")?.map((user) => user.id)).toEqual([
+ "super-admin-leader",
]);
});
diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx
index a332f07d..57cfa4e8 100644
--- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx
+++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx
@@ -18,6 +18,11 @@ import { getOrgRankWeight } from "../rankPriority";
import { filterTenantsByVisibility, getOrgUnitType } from "../tenantVisibility";
import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay";
+export {
+ formatOrgChartSnapshotTimestamp,
+ resolveOrgChartSnapshotTimestamp,
+} from "../orgChartSnapshotTime";
+
export type OrgNode = {
id: string;
name: string;
@@ -105,7 +110,6 @@ const MEMBER_CARD_CHAR_WIDTH = 12;
const MEMBER_CARD_TEXT_PADDING_X = 28;
const MEMBER_COLUMN_MAX_WIDTH = 280;
const NODE_HEADER_CHAR_WIDTH = 17;
-const NODE_HEADER_WRAP_THRESHOLD = 420;
const NODE_HEADER_TEXT_PADDING_X = 112;
const NODE_HEADER_TYPE_BADGE_WIDTH = 48;
const MEMBER_GRID_TARGET_ASPECT_RATIO = 2;
@@ -162,7 +166,7 @@ function getManagerWeight(
user: UserSummary,
tenant?: { id: string; slug: string },
) {
- return getUserOrgProfile(user, tenant).isManager ? 0 : 1;
+ return getUserOrgProfile(user, tenant).leadershipWeight;
}
function compareOrgMembers(
@@ -272,14 +276,9 @@ function getNodeHeaderWidth(node: OrgNode) {
const typeBadgeWidth = node.orgUnitType ? NODE_HEADER_TYPE_BADGE_WIDTH : 0;
const titleTextWidth =
getDisplayTextWidthUnit(node.name) * NODE_HEADER_CHAR_WIDTH;
- const oneLineWidth =
- NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth;
- if (oneLineWidth <= NODE_HEADER_WRAP_THRESHOLD) {
- return Math.ceil(oneLineWidth);
- }
- const estimatedTitleWidth =
- NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth / 2;
- return Math.ceil(estimatedTitleWidth);
+ return Math.ceil(
+ NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth,
+ );
}
function getNodeWidth(
@@ -562,19 +561,8 @@ function getRowOffsets(heights: number[], gap: number) {
return offsets;
}
-function getLayoutMaxDepth(layout: ChartLayout) {
- return Math.max(...layout.nodes.map((visualNode) => visualNode.node.level));
-}
-
function orderChildLayoutsForMultiColumn(childLayouts: ChartLayout[]) {
- return childLayouts
- .map((layout, index) => ({
- depth: getLayoutMaxDepth(layout),
- index,
- layout,
- }))
- .sort((a, b) => b.depth - a.depth || a.index - b.index)
- .map((entry) => entry.layout);
+ return childLayouts;
}
function rangesOverlap(
@@ -1109,20 +1097,7 @@ function isSystemGlobalTenant(
}
function isSystemGlobalUser(user: UserSummary) {
- const normalizedRole = user.role.toLowerCase().replaceAll("_", "-");
-
- return (
- normalizedRole === "super-admin" ||
- normalizedRole === "superadmin" ||
- normalizedRole === "system-admin" ||
- isSystemGlobalTenant(user.tenant) ||
- isSystemGlobalTenant({
- id: user.tenantSlug || "",
- slug: user.tenantSlug || "",
- type: user.role,
- name: user.role,
- })
- );
+ return isSystemGlobalTenant(user.tenant);
}
function findNodeByTenantId(
@@ -1390,34 +1365,6 @@ export function buildUsersMap(
if (!isVisibleOrgChartUser(user)) continue;
const tenantIds = new Set();
- const primarySlug = normalizeOrgSlug(user.tenantSlug);
- const legacyCompanySlug = normalizeOrgSlug(user.companyCode);
- if (
- primarySlug &&
- !isSystemGlobalTenant({
- id: primarySlug,
- slug: primarySlug,
- type: primarySlug,
- name: primarySlug,
- })
- ) {
- addTenantSlugCandidate(tenantIds, membershipTenantIndexes, primarySlug);
- }
- if (
- legacyCompanySlug &&
- !isSystemGlobalTenant({
- id: legacyCompanySlug,
- slug: legacyCompanySlug,
- type: legacyCompanySlug,
- name: legacyCompanySlug,
- })
- ) {
- addTenantSlugCandidate(
- tenantIds,
- membershipTenantIndexes,
- legacyCompanySlug,
- );
- }
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
addTenantIdCandidate(tenantIds, membershipTenantIndexes, user.tenant.id);
addTenantSlugCandidate(
@@ -1607,7 +1554,6 @@ export function TenantOrgChartPage() {
() => getOrgSelectionLabel(familyRoot, selectedTenantFilter) ?? "한맥가족",
[familyRoot, selectedTenantFilter],
);
-
React.useEffect(() => {
if (!tenantId) return;
const searchRoots = familyRoot ? [familyRoot] : rootNodes;
@@ -1800,7 +1746,7 @@ export function TenantOrgChartPage() {
data-testid="orgchart-dashboard-shell"
>
-
+
fetchOrgChartSnapshot(),
+ staleTime: 0,
+ refetchOnMount: "always",
+ refetchOnWindowFocus: true,
+ });
+ const timestamp = React.useMemo(() => {
+ const value = resolveOrgChartSnapshotTimestamp(snapshotQuery.data);
+ return value ? formatOrgChartSnapshotTimestamp(value) : "";
+ }, [snapshotQuery.data]);
+ const isRefreshing = snapshotQuery.isFetching || isRefreshingSnapshot;
+
+ const refreshSnapshot = React.useCallback(() => {
+ setIsRefreshingSnapshot(true);
+ void fetchOrgChartSnapshot({ refresh: true })
+ .then((snapshot) => {
+ queryClient.setQueryData(
+ ["orgchart-snapshot", { cache: "redis" }],
+ snapshot,
+ );
+ })
+ .finally(() => setIsRefreshingSnapshot(false));
+ }, [queryClient]);
+
+ return (
+
+
+ {timestamp || "-"}
+
+
+
+
+
+ );
+}
+
export function OrgFrontLayout() {
const location = useLocation();
const isChartRoute =
@@ -24,32 +84,38 @@ export function OrgFrontLayout() {
className="sticky top-0 z-30 shrink-0 border-b border-border bg-background/95 backdrop-blur"
data-testid="orgfront-topbar"
>
-
+
-
- {navItems.map(({ to, label, icon: Icon }) => (
-
- [
- "inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm font-semibold transition",
- isActive
- ? "border-primary bg-primary text-primary-foreground"
- : "border-border bg-card text-muted-foreground hover:text-foreground",
- ].join(" ")
- }
- >
-
- {label}
-
- ))}
-
+
+
+ {navItems.map(({ to, label, icon: Icon }) => (
+
+ [
+ "inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm font-semibold transition",
+ isActive
+ ? "border-primary bg-primary text-primary-foreground"
+ : "border-border bg-card text-muted-foreground hover:text-foreground",
+ ].join(" ")
+ }
+ >
+
+ {label}
+
+ ))}
+
+
+
diff --git a/orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx b/orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx
index f62a3e5a..6bbae5f2 100644
--- a/orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx
+++ b/orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx
@@ -274,6 +274,8 @@ describe("OrgPickerEmbedPage orgchart data source", () => {
id: "user-1",
name: "Snapshot User",
email: "user-1@example.com",
+ rootTenantName: "Hanmac Family",
+ leafTenantName: "Snapshot Company",
},
],
},
diff --git a/orgfront/src/features/orgchart/userDisplay.test.ts b/orgfront/src/features/orgchart/userDisplay.test.ts
index cd67cde8..0c7c8561 100644
--- a/orgfront/src/features/orgchart/userDisplay.test.ts
+++ b/orgfront/src/features/orgchart/userDisplay.test.ts
@@ -101,6 +101,25 @@ describe("getOrgChartUserDisplayName", () => {
),
).toBe("홍길동 책임");
});
+
+ it("does not fall back to the user grade for a tenant-bound display", () => {
+ expect(
+ getOrgChartUserDisplayName(
+ user({
+ grade: "책임",
+ metadata: {
+ additionalAppointments: [
+ {
+ tenantId: "tenant-1",
+ tenantSlug: "hanmac",
+ },
+ ],
+ },
+ }),
+ { id: "tenant-1", slug: "hanmac" },
+ ),
+ ).toBe("홍길동");
+ });
});
describe("getUserOrgProfile", () => {
@@ -164,4 +183,24 @@ describe("getUserOrgProfile", () => {
false,
);
});
+
+ it("does not treat representative organization membership as an organization leader", () => {
+ const profile = getUserOrgProfile(
+ user({
+ metadata: {
+ additionalAppointments: [
+ {
+ tenantSlug: "hanmac",
+ representative: true,
+ position: "팀원",
+ },
+ ],
+ },
+ }),
+ { id: "tenant-1", slug: "hanmac" },
+ );
+
+ expect(profile.isLeader).toBe(false);
+ expect(profile.isHighlighted).toBe(false);
+ });
});
diff --git a/orgfront/src/features/orgchart/userDisplay.ts b/orgfront/src/features/orgchart/userDisplay.ts
index 25018542..0d9c3314 100644
--- a/orgfront/src/features/orgchart/userDisplay.ts
+++ b/orgfront/src/features/orgchart/userDisplay.ts
@@ -7,6 +7,8 @@ type UserAppointment = {
isAdmin?: boolean;
isManager?: boolean;
isOwner?: boolean;
+ isRepresentative?: boolean;
+ representative?: boolean;
grade?: string;
jobTitle?: string;
position?: string;
@@ -33,12 +35,39 @@ function getUserAppointments(user: UserSummary): UserAppointment[] {
isAdmin: item.isAdmin === true,
isManager: item.isManager === true,
isOwner: item.isOwner === true,
+ isRepresentative: item.isRepresentative === true,
+ representative: item.representative === true,
grade: normalizeText(item.grade),
jobTitle: normalizeText(item.jobTitle),
position: normalizeText(item.position),
}));
}
+function isOrganizationLeaderPosition(position: string) {
+ const normalized = position.replace(/\s+/g, "");
+ if (!normalized) return false;
+
+ return [
+ "센터장",
+ "그룹장",
+ "본부장",
+ "실장",
+ "부문장",
+ "부서장",
+ "팀장",
+ "파트장",
+ "셀장",
+ "디비전장",
+ "디비젼장",
+ "division장",
+ "divisionhead",
+ "manager",
+ "leader",
+ "lead",
+ "head",
+ ].some((keyword) => normalized.toLowerCase().includes(keyword));
+}
+
export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
const appointment = getUserAppointments(user).find((item) => {
if (tenant?.id && item.tenantId === tenant.id) return true;
@@ -51,16 +80,24 @@ export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
}
return false;
});
+ const grade =
+ tenant && appointment ? appointment.grade : normalizeText(user.grade);
+
+ const position = appointment?.position || normalizeText(user.position);
+ const hasExplicitLeaderFlag =
+ appointment?.isManager === true || appointment?.isOwner === true;
+ const hasLeaderPosition = isOrganizationLeaderPosition(position);
+ const isLeader = hasExplicitLeaderFlag || hasLeaderPosition;
return {
- grade: appointment?.grade || normalizeText(user.grade),
- isHighlighted:
- appointment?.isAdmin === true ||
- appointment?.isManager === true ||
- appointment?.isOwner === true,
- isManager: appointment?.isManager === true,
+ grade,
+ isHighlighted: appointment?.isAdmin === true || isLeader,
+ isLeader,
+ leadershipWeight: hasExplicitLeaderFlag ? 0 : hasLeaderPosition ? 1 : 2,
+ isManager: isLeader,
+ isOwner: appointment?.isOwner === true,
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
- position: appointment?.position || normalizeText(user.position),
+ position,
};
}
diff --git a/orgfront/src/lib/adminApi.ts b/orgfront/src/lib/adminApi.ts
index 95c6f824..6e1957be 100644
--- a/orgfront/src/lib/adminApi.ts
+++ b/orgfront/src/lib/adminApi.ts
@@ -163,6 +163,7 @@ export async function fetchAllTenants({
export type OrgChartSnapshotResponse = {
tenants: TenantSummary[];
users: UserSummary[];
+ generatedAt?: string;
cache?: {
source: "redis" | "database";
hit: boolean;
@@ -170,11 +171,15 @@ export type OrgChartSnapshotResponse = {
};
};
-export async function fetchOrgChartSnapshot() {
+export async function fetchOrgChartSnapshot({
+ refresh = false,
+}: {
+ refresh?: boolean;
+} = {}) {
const { data } = await apiClient.get
(
"/v1/admin/orgchart/snapshot",
{
- params: { cache: "redis" },
+ params: { cache: "redis", ...(refresh ? { refresh: "true" } : {}) },
},
);
return data;
diff --git a/orgfront/tests/orgchart-picker.spec.ts b/orgfront/tests/orgchart-picker.spec.ts
index fca21c48..66dca1ba 100644
--- a/orgfront/tests/orgchart-picker.spec.ts
+++ b/orgfront/tests/orgchart-picker.spec.ts
@@ -179,6 +179,7 @@ async function installOrgPickerApiMock(
user("user-sales", "Sales User", "sales"),
];
const orgChartSnapshot = {
+ generatedAt: "2026-06-17T07:10:11Z",
tenants,
users,
};
@@ -230,16 +231,49 @@ test.beforeEach(async ({ page }) => {
test("developer navigation exposes chart, picker, and embed preview", async ({
page,
}) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
await page.goto(withShareToken("/chart"));
await expect(page.getByRole("link", { name: "조직도" })).toBeVisible();
await expect(page.getByRole("link", { name: "조직 선택기" })).toBeVisible();
await expect(page.getByRole("link", { name: "임베딩 검증" })).toBeVisible();
+ await expect(page.getByTestId("orgchart-render-status-panel")).toHaveText(
+ "2026-06-17 16:10:11 KST",
+ );
+ let statusPanelBox = await page
+ .getByTestId("orgchart-render-status-panel")
+ .boundingBox();
+ expect(
+ (page.viewportSize()?.width ?? 1600) -
+ ((statusPanelBox?.x ?? 0) + (statusPanelBox?.width ?? 0)),
+ ).toBeLessThanOrEqual(20);
+
+ await page.getByRole("link", { name: "조직 선택기" }).click();
+ await expect(page.getByTestId("orgchart-render-status-panel")).toHaveText(
+ "2026-06-17 16:10:11 KST",
+ );
+ statusPanelBox = await page
+ .getByTestId("orgchart-render-status-panel")
+ .boundingBox();
+ expect(
+ (page.viewportSize()?.width ?? 1600) -
+ ((statusPanelBox?.x ?? 0) + (statusPanelBox?.width ?? 0)),
+ ).toBeLessThanOrEqual(20);
await page.getByRole("link", { name: "임베딩 검증" }).click();
await expect(
page.getByRole("heading", { name: "임베딩 검증" }),
).toBeVisible();
+ await expect(page.getByTestId("orgchart-render-status-panel")).toHaveText(
+ "2026-06-17 16:10:11 KST",
+ );
+ statusPanelBox = await page
+ .getByTestId("orgchart-render-status-panel")
+ .boundingBox();
+ expect(
+ (page.viewportSize()?.width ?? 1600) -
+ ((statusPanelBox?.x ?? 0) + (statusPanelBox?.width ?? 0)),
+ ).toBeLessThanOrEqual(20);
await expect(
page
.frameLocator("iframe")
diff --git a/orgfront/tests/orgchart-vector-render.spec.ts b/orgfront/tests/orgchart-vector-render.spec.ts
index 735c69b2..126d25ed 100644
--- a/orgfront/tests/orgchart-vector-render.spec.ts
+++ b/orgfront/tests/orgchart-vector-render.spec.ts
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
+import { captureEvidence } from "./helpers/evidence";
function tenant(
id: string,
@@ -31,6 +32,12 @@ function user(id: string, name: string, companyCode: string) {
role: "user",
status: "active",
companyCode,
+ tenant: {
+ id: companyCode,
+ slug: companyCode,
+ type: "USER_GROUP",
+ name: companyCode,
+ },
grade: "사원",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
@@ -333,6 +340,139 @@ test("org chart orders managers before top executive members by rank priority",
]);
});
+test("org chart renders an IS3 super admin when an org appointment exists", async ({
+ page,
+}) => {
+ await page.route("**/api/v1/public/orgchart**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({
+ sharedWith: "Playwright",
+ tenants: [
+ tenant("hanmac-family", "한맥가족", "hanmac-family"),
+ tenant(
+ "gpdtdc",
+ "총괄기획&기술개발센터",
+ "gpdtdc",
+ "hanmac-family",
+ "COMPANY",
+ ),
+ tenant("gpd", "총괄기획실", "gpd", "gpdtdc", "ORGANIZATION"),
+ tenant(
+ "intigrated-system",
+ "통합시스템",
+ "intigrated-system",
+ "gpd",
+ "ORGANIZATION",
+ { visibility: "public", orgUnitType: "디비전" },
+ ),
+ tenant("is-3", "IS3", "is-3", "intigrated-system", "ORGANIZATION", {
+ visibility: "public",
+ orgUnitType: "팀",
+ }),
+ ],
+ users: [
+ {
+ id: "675a3d46-45ad-4e8c-8c22-959a38302826",
+ email: "cyhan@samaneng.com",
+ name: "한치영",
+ role: "super_admin",
+ status: "active",
+ tenantSlug: "is-3",
+ companyCode: "is-3",
+ tenant: undefined,
+ grade: "",
+ metadata: {
+ additionalAppointments: [
+ {
+ grade: "책임",
+ isManager: true,
+ isPrimary: true,
+ tenantId: "is-3",
+ tenantName: "IS3",
+ tenantSlug: "is-3",
+ },
+ ],
+ },
+ createdAt: "2026-06-16T00:00:00.000Z",
+ updatedAt: "2026-06-16T00:00:00.000Z",
+ },
+ {
+ ...user("is3-executive", "상위직급", "is-3"),
+ grade: "전무이사",
+ },
+ ],
+ }),
+ });
+ });
+
+ await page.goto("/chart?token=is3-manager");
+
+ const is3Node = page.locator('[data-testid="orgchart-node-is-3"]');
+ await expect(is3Node).toBeVisible();
+ await expect(
+ is3Node.locator(
+ '[data-testid="orgchart-member-675a3d46-45ad-4e8c-8c22-959a38302826"]',
+ ),
+ ).toContainText("한치영 책임");
+ await expect(
+ is3Node.locator('[data-testid="orgchart-member-is3-executive"]'),
+ ).toContainText("상위직급 전무");
+
+ const orderedMemberIds = await is3Node
+ .locator('[data-testid^="orgchart-member-"]')
+ .evaluateAll((elements) =>
+ elements.map((element) => element.getAttribute("data-testid")),
+ );
+
+ expect(orderedMemberIds).toEqual([
+ "orgchart-member-675a3d46-45ad-4e8c-8c22-959a38302826",
+ "orgchart-member-is3-executive",
+ ]);
+});
+
+test("org chart ignores stale scalar org fields without a membership source", async ({
+ page,
+}) => {
+ await page.route("**/api/v1/public/orgchart**", async (route) => {
+ await route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify({
+ sharedWith: "Playwright",
+ tenants: [
+ tenant("hanmac-family", "한맥가족", "hanmac-family"),
+ tenant("is-1", "IS1", "is-1", "hanmac-family", "ORGANIZATION"),
+ tenant("is-2", "IS2", "is-2", "hanmac-family", "ORGANIZATION"),
+ tenant("is-3", "IS3", "is-3", "hanmac-family", "ORGANIZATION"),
+ ],
+ users: [
+ {
+ id: "stale-system-admin",
+ email: "stale-system-admin@example.com",
+ name: "Stale System Admin",
+ role: "system_admin",
+ status: "active",
+ tenantSlug: "is-2",
+ companyCode: "is-1",
+ tenant: undefined,
+ joinedTenants: undefined,
+ metadata: { additionalAppointments: [] },
+ grade: "사원",
+ createdAt: "2026-06-16T00:00:00.000Z",
+ updatedAt: "2026-06-16T00:00:00.000Z",
+ },
+ ],
+ }),
+ });
+ });
+
+ await page.goto("/chart?token=stale-scalar");
+
+ await expect(
+ page.locator('[data-testid="orgchart-member-stale-system-admin"]'),
+ ).toHaveCount(0);
+});
+
test("org chart expands organization node width so long names are not clipped", async ({
page,
}) => {
@@ -551,17 +691,31 @@ test("org chart places multi-tenant users only on leaf memberships without dupli
test("org chart allows a user in a hanmac-family descendant tenant", async ({
page,
-}) => {
+}, testInfo) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ let snapshotRequests = 0;
await page.addInitScript(() => {
window.localStorage.setItem("playwright_auth_bypass", "1");
window.localStorage.setItem("dev_tenant_id", "saman-id");
});
await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => {
+ snapshotRequests += 1;
+ const url = new URL(route.request().url());
expect(route.request().headers()["x-tenant-id"]).toBe("saman-id");
+ expect(url.searchParams.get("cache")).toBe("redis");
+ if (snapshotRequests === 1) {
+ expect(url.searchParams.get("refresh")).toBeNull();
+ } else {
+ expect(url.searchParams.get("refresh")).toBe("true");
+ }
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
+ generatedAt:
+ snapshotRequests === 1
+ ? "2026-06-17T07:10:11Z"
+ : "2026-06-17T08:20:21Z",
tenants: [
tenant(
"hanmac-family-id",
@@ -607,10 +761,63 @@ test("org chart allows a user in a hanmac-family descendant tenant", async ({
await expect(
page.getByRole("button", { name: "조직: 한맥가족" }),
).toBeVisible();
+ await expect(page.getByTestId("orgchart-selection-status-panel")).toHaveCount(
+ 0,
+ );
+ await expect(page.getByTestId("orgchart-render-status-panel")).toHaveText(
+ "2026-06-17 16:10:11 KST",
+ );
+ await expect(
+ page.getByTestId("orgchart-render-status-panel"),
+ ).not.toContainText("데이터 기준");
+ const headerBox = await page.getByTestId("orgfront-topbar").boundingBox();
+ const viewportWidth = page.viewportSize()?.width ?? 1600;
+ const statusPanelBox = await page
+ .getByTestId("orgchart-render-status-panel")
+ .boundingBox();
+ expect(headerBox).not.toBeNull();
+ expect(statusPanelBox).not.toBeNull();
+ expect(
+ viewportWidth - ((statusPanelBox?.x ?? 0) + (statusPanelBox?.width ?? 0)),
+ ).toBeLessThanOrEqual(20);
+ expect(
+ (headerBox?.y ?? 0) +
+ (headerBox?.height ?? 0) -
+ ((statusPanelBox?.y ?? 0) + (statusPanelBox?.height ?? 0)),
+ ).toBeLessThanOrEqual(6);
+ await expect(page.getByTestId("orgchart-render-status-panel")).toHaveCSS(
+ "border-top-width",
+ "0px",
+ );
+ await expect(page.getByTestId("orgchart-render-status-panel")).toHaveCSS(
+ "box-shadow",
+ "none",
+ );
+ await expect(page.getByTestId("orgchart-render-status-panel")).toHaveCSS(
+ "background-color",
+ "rgba(0, 0, 0, 0)",
+ );
+ await expect(page.getByTestId("orgchart-render-status-panel")).toHaveCSS(
+ "opacity",
+ "0.72",
+ );
+ await expect(page.getByRole("button", { name: "새로고침" })).toHaveCSS(
+ "background-color",
+ "rgba(0, 0, 0, 0)",
+ );
+ await expect(page.getByRole("button", { name: "새로고침" })).toHaveCSS(
+ "border-top-width",
+ "0px",
+ );
+ await page.getByRole("button", { name: "새로고침" }).click();
+ await expect(page.getByTestId("orgchart-render-status-panel")).toHaveText(
+ "2026-06-17 17:20:21 KST",
+ );
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg.getByText("한맥가족", { exact: true })).toBeVisible();
await expect(svg.getByText("삼안", { exact: true })).toBeVisible();
await expect(svg.getByText(/Saman Descendant User/)).toBeVisible();
+ await captureEvidence(page, testInfo, "orgchart-topbar-data-refresh-aligned");
});
test("org chart logs authenticated snapshot failures with actionable diagnostics", async ({
diff --git a/test/frontend_dev_bind_mount_policy_test.sh b/test/frontend_dev_bind_mount_policy_test.sh
index 5b524820..bd4a3ac0 100644
--- a/test/frontend_dev_bind_mount_policy_test.sh
+++ b/test/frontend_dev_bind_mount_policy_test.sh
@@ -23,10 +23,41 @@ assert_not_contains() {
fi
}
+assert_service_contains() {
+ local service="$1"
+ local pattern="$2"
+ awk -v service=" $service:" '
+ $0 == service { in_service = 1; next }
+ in_service && /^ [[:alnum:]_-]+:/ { in_service = 0 }
+ in_service { print }
+ ' "$COMPOSE_FILE" | grep -Fq -- "$pattern" || fail "$service service must contain: $pattern"
+}
+
+assert_service_not_contains() {
+ local service="$1"
+ local pattern="$2"
+ if awk -v service=" $service:" '
+ $0 == service { in_service = 1; next }
+ in_service && /^ [[:alnum:]_-]+:/ { in_service = 0 }
+ in_service { print }
+ ' "$COMPOSE_FILE" | grep -Fq -- "$pattern"; then
+ fail "$service service must not contain: $pattern"
+ fi
+}
+
for app in adminfront devfront orgfront; do
assert_contains "./$app:/workspace/$app"
assert_contains "/workspace/$app/node_modules"
assert_not_contains "./$app:/app"
+ assert_service_contains "$app" "target: dev"
+ assert_service_contains "$app" "working_dir: /workspace/$app"
+ assert_service_contains "$app" 'command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port",'
+ assert_service_contains "$app" 'DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}'
+ assert_service_not_contains "$app" "serve_frontend_prod.mjs"
+ grep -Fq -- "FROM deps AS dev" "$ROOT_DIR/$app/Dockerfile" || fail "$app Dockerfile must define a deps-based dev target"
+ grep -Fq -- 'CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port",' "$ROOT_DIR/$app/Dockerfile" || fail "$app Dockerfile dev target must run Vite dev server"
+ grep -Fq -- "FROM deps AS build" "$ROOT_DIR/$app/Dockerfile" || fail "$app Dockerfile must keep production build separate from dev"
+ grep -Fq -- "FROM node:24-alpine AS production" "$ROOT_DIR/$app/Dockerfile" || fail "$app Dockerfile must keep production static serving target"
done
assert_contains 'target: ${USERFRONT_BUILD_TARGET:-dev}'
diff --git a/test/gpd_level_order_policy_test.sh b/test/gpd_level_order_policy_test.sh
new file mode 100644
index 00000000..41ce26e9
--- /dev/null
+++ b/test/gpd_level_order_policy_test.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+csv_path="$repo_root/gpd_level.csv"
+
+fail() {
+ echo "ERROR: $*" >&2
+ exit 1
+}
+
+[ -f "$csv_path" ] || fail "gpd_level.csv must exist"
+
+line_of() {
+ local name="$1"
+ local line
+ line="$(awk -F, -v name="\"${name}\"" '$1 == name { print NR; exit }' "$csv_path")"
+ [ -n "$line" ] || fail "gpd_level.csv must contain ${name}"
+ printf '%s\n' "$line"
+}
+
+vice_president_line="$(line_of "부사장")"
+executive_director_line="$(line_of "전무이사")"
+principal_researcher_line="$(line_of "수석 연구원")"
+
+[ "$vice_president_line" -lt "$executive_director_line" ] || \
+ fail "전무이사 must be below 부사장 in GPDTDC level order"
+[ "$executive_director_line" -lt "$principal_researcher_line" ] || \
+ fail "전무이사 must be above 수석 연구원 in GPDTDC level order"
+
+grep -Fxq '"전무이사","execdir"' "$csv_path" || \
+ fail "전무이사 external key must be execdir"
+
+echo "OK: GPDTDC level order includes 전무이사 between 부사장 and 수석 연구원"
diff --git a/test/playwright_frontend_runtime_policy_test.sh b/test/playwright_frontend_runtime_policy_test.sh
new file mode 100644
index 00000000..6502d07b
--- /dev/null
+++ b/test/playwright_frontend_runtime_policy_test.sh
@@ -0,0 +1,51 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+fail() {
+ echo "ERROR: $*" >&2
+ exit 1
+}
+
+assert_contains() {
+ local file="$1"
+ local pattern="$2"
+ grep -Fq -- "$pattern" "$file" || fail "$file must contain: $pattern"
+}
+
+assert_not_contains() {
+ local file="$1"
+ local pattern="$2"
+ if grep -Fq -- "$pattern" "$file"; then
+ fail "$file must not contain: $pattern"
+ fi
+}
+
+admin_config="$ROOT_DIR/adminfront/playwright.config.ts"
+dev_config="$ROOT_DIR/devfront/playwright.config.ts"
+org_config="$ROOT_DIR/orgfront/playwright.config.ts"
+
+for file in "$admin_config" "$dev_config" "$org_config"; do
+ assert_contains "$file" 'process.env.CI === "true" || process.env.PLAYWRIGHT_USE_PREVIEW === "true"'
+ assert_contains "$file" "usePreviewServer"
+done
+
+assert_contains "$admin_config" 'process.env.PORT ?? "5173"'
+assert_contains "$admin_config" 'pnpm exec vite --host 127.0.0.1 --port ${port} --strictPort'
+assert_contains "$admin_config" 'pnpm exec vite preview --host 127.0.0.1 --port ${port} --strictPort'
+
+assert_contains "$dev_config" 'process.env.PORT ?? "5174"'
+assert_contains "$dev_config" 'process.env.PLAYWRIGHT_BASE_URL || process.env.BASE_URL'
+assert_contains "$dev_config" '|| defaultBaseUrl'
+assert_contains "$dev_config" './node_modules/.bin/vite --host 127.0.0.1 --strictPort --port ${port}'
+assert_contains "$dev_config" './node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port ${port}'
+assert_not_contains "$dev_config" "http://127.0.0.1:4174"
+assert_not_contains "$dev_config" "--port 4174"
+
+assert_contains "$org_config" 'process.env.PORT ?? "5175"'
+assert_contains "$org_config" 'npm run dev -- --host 127.0.0.1 --port ${port}'
+assert_contains "$org_config" 'npm run preview -- --host 127.0.0.1 --port ${port}'
+assert_not_contains "$org_config" 'process.env.PORT ?? "4175"'
+
+echo "OK: local Playwright uses Vite dev servers and CI/preview mode uses dist preview"
diff --git a/works_users_saman.CSV b/works_users_saman.CSV
index 32138f07..fcbbfdcf 100644
--- a/works_users_saman.CSV
+++ b/works_users_saman.CSV
@@ -1,6 +1,6 @@
email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1,sub_email
evenlee@samaneng.com,이용운,010-37619642,user,asset-management,,부사장,,,216009,,,,,,,
-dschoi@naver.com,최대선,010-73089488,user,saman,,회장,,,216016,,,,,,,
+dschoi@samaneng.com,최대선,010-73089488,user,saman,,회장,,,216016,,,,,,,
cds@samaneng.com,최동식,010-33604146,user,saman,,사장,,,216007,,,,,,,
dwan@samaneng.com,안대원,010-37864397,user,hr & admin,,전무이사,,,209075,,,,,,,
jhmoon@samaneng.com,문제현,010-82594387,user,hr & admin,,차장,,,217072,,,,,,,