forked from baron/baron-sso
chore: snapshot local state before dev merge
This commit is contained in:
@@ -40,6 +40,20 @@ baron_sso/
|
||||
* AdminFront: 사용자 관리 등 Admin 기능
|
||||
* DevFront: RP 관리 등 개발자 기능
|
||||
|
||||
## 개발 실행 정책
|
||||
|
||||
`make dev`는 로컬 개발용 실행 모드이며, React 기반 `adminfront`, `devfront`, `orgfront`는 모두 Vite HMR 모드로 동작해야 합니다. 이 세 서비스는 Docker Compose에서 Dockerfile `dev` target을 사용하고 `/workspace/<app>` bind mount 위에서 `npm run dev -- --host 0.0.0.0`로 실행합니다. `make dev` 경로에서 production `dist`를 `serve_frontend_prod.mjs`로 정적 서빙하면 안 됩니다.
|
||||
|
||||
현재 개발 포트는 다음과 같습니다.
|
||||
|
||||
- AdminFront: `http://localhost:5173`
|
||||
- DevFront: `http://localhost:5174`
|
||||
- OrgFront: `http://localhost:5175`
|
||||
|
||||
자세한 정책과 회귀 테스트는 [make dev Vite HMR Policy](docs/make-dev-vite-hmr-policy.md)를 확인하세요. 정책 회귀는 `test/frontend_dev_bind_mount_policy_test.sh`에서 검사합니다.
|
||||
|
||||
로컬 Playwright E2E도 기본적으로 Vite dev server를 봅니다. Gitea Actions 같은 CI에서는 `CI=true`로 production bundle을 `vite preview`로 검증합니다. 로컬에서 production bundle을 명시적으로 검증하려면 `PLAYWRIGHT_USE_PREVIEW=true`를 사용하세요. 이 정책은 `test/playwright_frontend_runtime_policy_test.sh`에서 검사합니다.
|
||||
|
||||
|
||||
## 🏗 아키텍처 (Architecture)
|
||||
|
||||
@@ -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에 경고 상태를 함께 전달해야 합니다.
|
||||
|
||||
|
||||
+12
-1
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"metric": "tenant-profile-local-performance",
|
||||
"tenantId": "56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"actualApiBaseUrl": "http://localhost:5173/api",
|
||||
"measuredAt": "2026-06-16T23:45:00.441Z",
|
||||
"browser": "chromium",
|
||||
"samples": [
|
||||
{
|
||||
"sample": 1,
|
||||
"configFieldsVisibleMs": 424,
|
||||
"networkIdleMs": 862,
|
||||
"orgUnitType": "센터",
|
||||
"visibility": "public",
|
||||
"worksmobileSync": "enabled",
|
||||
"apiTimings": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/user/me",
|
||||
"status": 200,
|
||||
"durationMs": 134
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"status": 200,
|
||||
"durationMs": 184
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sample": 2,
|
||||
"configFieldsVisibleMs": 376,
|
||||
"networkIdleMs": 751,
|
||||
"orgUnitType": "센터",
|
||||
"visibility": "public",
|
||||
"worksmobileSync": "enabled",
|
||||
"apiTimings": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/user/me",
|
||||
"status": 200,
|
||||
"durationMs": 20
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"status": 200,
|
||||
"durationMs": 133
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sample": 3,
|
||||
"configFieldsVisibleMs": 400,
|
||||
"networkIdleMs": 797,
|
||||
"orgUnitType": "센터",
|
||||
"visibility": "public",
|
||||
"worksmobileSync": "enabled",
|
||||
"apiTimings": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/user/me",
|
||||
"status": 200,
|
||||
"durationMs": 21
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"status": 200,
|
||||
"durationMs": 156
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sample": 4,
|
||||
"configFieldsVisibleMs": 431,
|
||||
"networkIdleMs": 843,
|
||||
"orgUnitType": "센터",
|
||||
"visibility": "public",
|
||||
"worksmobileSync": "enabled",
|
||||
"apiTimings": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/user/me",
|
||||
"status": 200,
|
||||
"durationMs": 25
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"status": 200,
|
||||
"durationMs": 178
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"sample": 5,
|
||||
"configFieldsVisibleMs": 380,
|
||||
"networkIdleMs": 758,
|
||||
"orgUnitType": "센터",
|
||||
"visibility": "public",
|
||||
"worksmobileSync": "enabled",
|
||||
"apiTimings": [
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/user/me",
|
||||
"status": 200,
|
||||
"durationMs": 24
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "http://playwright-mock/api/v1/admin/tenants/56cd0fd7-b62a-43c0-8db9-74a30468d7cb",
|
||||
"status": 200,
|
||||
"durationMs": 129
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"configFieldsVisibleMs": {
|
||||
"min": 376,
|
||||
"max": 431,
|
||||
"p50": 400,
|
||||
"p95": 431
|
||||
},
|
||||
"networkIdleMs": {
|
||||
"min": 751,
|
||||
"max": 862,
|
||||
"p50": 797,
|
||||
"p95": 862
|
||||
}
|
||||
},
|
||||
"screenshotPath": "/home/lectom/repos/baron-sso/adminfront/e2e-evidence/tenant-profile-performance-local.png"
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -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}`,
|
||||
|
||||
@@ -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,개인 사용자 기본 루트 테넌트,,,,
|
||||
|
||||
|
@@ -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" &&
|
||||
|
||||
+149
-43
@@ -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<RouteModule>) {
|
||||
return async () => {
|
||||
const module = await loader();
|
||||
return { Component: module.default };
|
||||
};
|
||||
}
|
||||
|
||||
function lazyNamed<TModule, TKey extends keyof TModule>(
|
||||
loader: () => Promise<TModule>,
|
||||
key: TKey,
|
||||
) {
|
||||
return async () => {
|
||||
const module = await loader();
|
||||
return { Component: module[key] as ComponentType };
|
||||
};
|
||||
}
|
||||
|
||||
export const adminRoutes: RouteObject[] = [
|
||||
{
|
||||
path: "/login",
|
||||
@@ -43,42 +44,147 @@ export const adminRoutes: RouteObject[] = [
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <GlobalOverviewPage /> },
|
||||
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||
{ path: "auth", element: <AuthPage /> },
|
||||
{ path: "users", element: <UserListPage /> },
|
||||
{ path: "users/custom-claims", element: <GlobalCustomClaimsPage /> },
|
||||
{ path: "users/new", element: <UserCreatePage /> },
|
||||
{ path: "users/:id", element: <UserDetailPage /> },
|
||||
{ path: "tenants", element: <TenantListPage /> },
|
||||
{ path: "tenants/new", element: <TenantCreatePage /> },
|
||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
||||
{
|
||||
index: true,
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/overview/GlobalOverviewPage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "audit-logs",
|
||||
lazy: lazyDefault(() => import("../features/audit/AuditLogsPage")),
|
||||
},
|
||||
{
|
||||
path: "auth",
|
||||
lazy: lazyDefault(() => import("../features/auth/AuthPage")),
|
||||
},
|
||||
{
|
||||
path: "users",
|
||||
lazy: lazyDefault(() => import("../features/users/UserListPage")),
|
||||
},
|
||||
{
|
||||
path: "users/custom-claims",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/users/GlobalCustomClaimsPage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "users/new",
|
||||
lazy: lazyDefault(() => import("../features/users/UserCreatePage")),
|
||||
},
|
||||
{
|
||||
path: "users/:id",
|
||||
lazy: lazyDefault(() => import("../features/users/UserDetailPage")),
|
||||
},
|
||||
{
|
||||
path: "tenants",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/tenants/routes/TenantListPage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "tenants/new",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/tenants/routes/TenantCreatePage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "worksmobile",
|
||||
lazy: lazyNamed(
|
||||
() => import("../features/tenants/routes/TenantWorksmobilePage"),
|
||||
"TenantWorksmobilePage",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "permissions-direct",
|
||||
element: <TenantFineGrainedPermissionsPage />,
|
||||
lazy: lazyNamed(
|
||||
() =>
|
||||
import(
|
||||
"../features/tenants/routes/TenantFineGrainedPermissionsPage"
|
||||
),
|
||||
"TenantFineGrainedPermissionsPage",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId",
|
||||
element: <TenantDetailPage />,
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/tenants/routes/TenantDetailPage"),
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <TenantProfilePage /> },
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
{
|
||||
index: true,
|
||||
lazy: lazyNamed(
|
||||
() => import("../features/tenants/routes/TenantProfilePage"),
|
||||
"TenantProfilePage",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "permissions",
|
||||
lazy: lazyNamed(
|
||||
() =>
|
||||
import(
|
||||
"../features/tenants/routes/TenantAdminsAndOwnersTab"
|
||||
),
|
||||
"TenantAdminsAndOwnersTab",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "organization",
|
||||
lazy: lazyDefault(
|
||||
() =>
|
||||
import(
|
||||
"../features/user-groups/routes/TenantUserGroupsTab"
|
||||
),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "schema",
|
||||
lazy: lazyNamed(
|
||||
() => import("../features/tenants/routes/TenantSchemaPage"),
|
||||
"TenantSchemaPage",
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "relations",
|
||||
element: <TenantFineGrainedPermissionsTab />,
|
||||
lazy: lazyNamed(
|
||||
() =>
|
||||
import(
|
||||
"../features/tenants/routes/TenantFineGrainedPermissionsTab"
|
||||
),
|
||||
"TenantFineGrainedPermissionsTab",
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "tenants/:tenantId/organization/:id",
|
||||
element: <TenantUserGroupsTab />,
|
||||
lazy: lazyDefault(
|
||||
() =>
|
||||
import("../features/user-groups/routes/TenantUserGroupsTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "api-keys",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/api-keys/ApiKeyListPage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "api-keys/new",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/api-keys/ApiKeyCreatePage"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "system/ory-ssot",
|
||||
lazy: lazyDefault(() => import("../features/ory-ssot/OrySSOTPage")),
|
||||
},
|
||||
{
|
||||
path: "system/data-integrity",
|
||||
lazy: lazyDefault(
|
||||
() => import("../features/integrity/DataIntegrityPage"),
|
||||
),
|
||||
},
|
||||
{ path: "api-keys", element: <ApiKeyListPage /> },
|
||||
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
|
||||
{ path: "system/ory-ssot", element: <OrySSOTPage /> },
|
||||
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<TenantSortKey>([
|
||||
"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<HTMLDivElement | null>(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() {
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedBulkType} onValueChange={setSelectedBulkType}>
|
||||
<SelectTrigger
|
||||
className="h-8 w-[180px] bg-transparent border-background/20 text-background text-xs"
|
||||
data-testid="tenant-bulk-type-select"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.bulk.type_placeholder",
|
||||
"유형 선택",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bulkTenantTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(
|
||||
`domain.tenant_type.${option.value.toLowerCase()}`,
|
||||
option.label,
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={selectedBulkVisibility}
|
||||
onValueChange={(value) =>
|
||||
setSelectedBulkVisibility(value as TenantVisibility)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-[130px] bg-transparent border-background/20 text-background text-xs"
|
||||
data-testid="tenant-bulk-visibility-select"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.bulk.visibility_placeholder",
|
||||
"공개 범위",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TENANT_VISIBILITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={handleApplyBulkStatus}
|
||||
disabled={
|
||||
!selectedBulkStatus || bulkUpdateStatusMutation.isPending
|
||||
(!selectedBulkStatus &&
|
||||
!selectedBulkType &&
|
||||
!selectedBulkVisibility) ||
|
||||
bulkUpdateTenantsMutation.isPending
|
||||
}
|
||||
data-testid="tenant-bulk-apply-status-btn"
|
||||
data-testid="tenant-bulk-apply-btn"
|
||||
>
|
||||
{t("ui.common.apply", "적용")}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../../test/i18nMock";
|
||||
import { TenantProfilePage } from "./TenantProfilePage";
|
||||
|
||||
const fetchAllTenantsMock = vi.hoisted(() => vi.fn());
|
||||
const fetchTenantMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../../lib/adminApi", () => ({
|
||||
approveTenant: vi.fn(),
|
||||
deleteTenant: vi.fn(),
|
||||
fetchAllTenants: fetchAllTenantsMock,
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-1",
|
||||
role: "super_admin",
|
||||
})),
|
||||
fetchTenant: fetchTenantMock,
|
||||
updateTenant: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["/tenants/tenant-leaf"]}>
|
||||
{ui}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("TenantProfilePage initial profile loading", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchTenantMock.mockResolvedValue({
|
||||
id: "tenant-leaf",
|
||||
type: "USER_GROUP",
|
||||
parentId: "tenant-company",
|
||||
name: "기술기획",
|
||||
slug: "tech-planning",
|
||||
description: "",
|
||||
status: "active",
|
||||
domains: [],
|
||||
memberCount: 0,
|
||||
config: {
|
||||
orgUnitType: "팀",
|
||||
visibility: "internal",
|
||||
worksmobileExcluded: true,
|
||||
},
|
||||
createdAt: "2026-06-17T00:00:00Z",
|
||||
updatedAt: "2026-06-17T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders tenant config fields from the tenant response before the full tenant list resolves", async () => {
|
||||
fetchAllTenantsMock.mockReturnValue(new Promise(() => undefined));
|
||||
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route path="/tenants/:tenantId" element={<TenantProfilePage />} />
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
expect(await screen.findByDisplayValue("기술기획")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tenant-org-unit-type-select")).toHaveValue("팀");
|
||||
expect(screen.getByLabelText("공개 범위")).toHaveValue("internal");
|
||||
expect(screen.getByLabelText("WORKS 연동")).toHaveValue("excluded");
|
||||
expect(fetchAllTenantsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
>
|
||||
<Label className="text-sm font-semibold">
|
||||
<Label
|
||||
htmlFor="tenant-org-unit-type"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.tenants.profile.org_unit_type",
|
||||
"조직 세부타입",
|
||||
@@ -385,7 +394,10 @@ export function TenantProfilePage() {
|
||||
</select>
|
||||
</div>
|
||||
<div data-testid="tenant-visibility-slot" className="space-y-1">
|
||||
<Label className="text-sm font-semibold">
|
||||
<Label
|
||||
htmlFor="tenant-visibility"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
|
||||
</Label>
|
||||
<select
|
||||
@@ -411,7 +423,10 @@ export function TenantProfilePage() {
|
||||
data-testid="tenant-worksmobile-excluded-slot"
|
||||
className="space-y-1"
|
||||
>
|
||||
<Label className="text-sm font-semibold">
|
||||
<Label
|
||||
htmlFor="worksmobileExcluded"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.tenants.profile.worksmobile_sync",
|
||||
"WORKS 연동",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
<div
|
||||
className={worksmobileComparisonTableHeadContentClassName}
|
||||
>
|
||||
상위 Works 조직
|
||||
WORKS 조직 매칭
|
||||
</div>
|
||||
</TableHead>
|
||||
)}
|
||||
@@ -1724,33 +1725,17 @@ function ComparisonTable({
|
||||
)}
|
||||
{isColumnVisible("worksmobileOrg") && (
|
||||
<TableCell>
|
||||
<ComparisonOrgCell
|
||||
name={
|
||||
row.resourceType === "GROUP"
|
||||
? getWorksmobileParentName(row)
|
||||
: row.worksmobilePrimaryOrgName
|
||||
}
|
||||
email={
|
||||
row.resourceType === "GROUP"
|
||||
? getWorksmobileParentEmail(row)
|
||||
: undefined
|
||||
}
|
||||
id={
|
||||
row.resourceType === "GROUP"
|
||||
? row.worksmobileParentId
|
||||
: row.worksmobilePrimaryOrgId
|
||||
}
|
||||
details={
|
||||
row.resourceType === "GROUP"
|
||||
? formatWorksmobileParentOrgDetails(row)
|
||||
: formatWorksmobileOrgDetails(row)
|
||||
}
|
||||
missingLabel={
|
||||
row.resourceType === "GROUP"
|
||||
? "상위 Works 조직 정보 없음"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{row.resourceType === "USER" ? (
|
||||
<ComparisonUserMembershipCell row={row} />
|
||||
) : (
|
||||
<ComparisonOrgCell
|
||||
name={getWorksmobileParentName(row)}
|
||||
email={getWorksmobileParentEmail(row)}
|
||||
id={row.worksmobileParentId}
|
||||
details={formatWorksmobileParentOrgDetails(row)}
|
||||
missingLabel="상위 Works 조직 정보 없음"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
{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 (
|
||||
<div className="space-y-1">
|
||||
{membershipDetails.map((detail) => (
|
||||
<div key={detail} className="text-xs leading-relaxed">
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ComparisonOrgCell
|
||||
name={row.worksmobilePrimaryOrgName}
|
||||
id={row.worksmobilePrimaryOrgId}
|
||||
details={formatWorksmobileOrgDetails(row)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonOrgCell({
|
||||
name,
|
||||
email,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<UserUpdateRequest, "metadata"> & {
|
||||
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<UserCategory>("external");
|
||||
React.useState<UserCategory>("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"
|
||||
>
|
||||
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
||||
<TabsTrigger
|
||||
value="hanmac"
|
||||
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
한맥가족 구성원
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="external"
|
||||
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
외부 기업 회원
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="personal"
|
||||
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
개인 회원
|
||||
</TabsTrigger>
|
||||
{USER_MEMBERSHIP_TENANT_TABS.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="-mb-px rounded-b-none rounded-t-md border border-transparent border-b-border bg-muted/40 px-4 py-2 text-muted-foreground shadow-none data-[state=active]:border-border data-[state=active]:border-b-background data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-none"
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{userCategory === "external" && (
|
||||
{isRepresentativeTenantCategory && (
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
@@ -1671,7 +1667,7 @@ function UserDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userCategory === "hanmac" && (
|
||||
{userCategory === "hanmac-family" && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
@@ -1893,7 +1889,7 @@ function UserDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userCategory === "external" && (
|
||||
{isRepresentativeTenantCategory && (
|
||||
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
|
||||
@@ -97,7 +97,7 @@ import {
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
|
||||
import { normalizeAdminRole } from "../../lib/roles";
|
||||
import {
|
||||
downloadUserTemplate,
|
||||
UserBulkUploadModal,
|
||||
@@ -121,7 +121,7 @@ type UserSortKey = string;
|
||||
const USER_ROW_ESTIMATED_HEIGHT = 64;
|
||||
const USER_ROW_OVERSCAN = 2;
|
||||
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
|
||||
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
|
||||
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 220] as const;
|
||||
const userMetadataColumnWidth = 160;
|
||||
const userCreatedColumnWidth = 150;
|
||||
type UserRowVirtualizer = Virtualizer<HTMLDivElement, HTMLTableRowElement>;
|
||||
@@ -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<SortConfig<UserSortKey> | 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")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={userTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("role")}
|
||||
>
|
||||
<div className={userTableHeadContentClassName}>
|
||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||
{getSortIcon("role")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className={userTableHeadInteractiveClassName}
|
||||
onClick={() => requestSort("tenant_dept")}
|
||||
@@ -1206,36 +1167,6 @@ function UserListPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={assignableSystemRoleValue(user.role)}
|
||||
onValueChange={(value) =>
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: [user.id],
|
||||
role: value,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
bulkUpdateMutation.isPending ||
|
||||
!isSuperAdminRole(profile?.role) ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bulkPermissionOptions.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
@@ -1302,31 +1233,6 @@ function UserListPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{canPromoteSuperAdmin && (
|
||||
<Select
|
||||
value={selectedBulkPermission}
|
||||
onValueChange={setSelectedBulkPermission}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-[120px] bg-transparent border-background/20 text-background text-xs"
|
||||
data-testid="bulk-permission-select"
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"ui.admin.users.bulk.permission_placeholder",
|
||||
"권한 선택",
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bulkPermissionOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.labelKey, option.fallback)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -1335,25 +1241,18 @@ function UserListPage() {
|
||||
const payload: {
|
||||
userIds: string[];
|
||||
status?: UserStatusValue;
|
||||
role?: string;
|
||||
} = { userIds: selectedUserIds };
|
||||
let hasChanges = false;
|
||||
if (selectedBulkStatus) {
|
||||
payload.status = selectedBulkStatus;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (selectedBulkPermission && canPromoteSuperAdmin) {
|
||||
payload.role = selectedBulkPermission;
|
||||
hasChanges = true;
|
||||
}
|
||||
if (hasChanges) {
|
||||
bulkUpdateMutation.mutate(payload);
|
||||
}
|
||||
}}
|
||||
disabled={
|
||||
(!selectedBulkStatus && !selectedBulkPermission) ||
|
||||
bulkUpdateMutation.isPending ||
|
||||
!isWritable
|
||||
!selectedBulkStatus || bulkUpdateMutation.isPending || !isWritable
|
||||
}
|
||||
data-testid="bulk-apply-btn"
|
||||
>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
|
||||
export type UserMembershipTenantTabId =
|
||||
| "hanmac-family"
|
||||
| "commercial"
|
||||
| "public-org"
|
||||
| "edu"
|
||||
| "personal";
|
||||
|
||||
export type UserMembershipTenantTab = {
|
||||
id: UserMembershipTenantTabId;
|
||||
label: string;
|
||||
rootSlug: string;
|
||||
seedTenantId?: string;
|
||||
};
|
||||
|
||||
type OrgChartPickerMessage = {
|
||||
type?: unknown;
|
||||
payload?: {
|
||||
@@ -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<T extends TenantFilterTarget>(
|
||||
);
|
||||
}
|
||||
|
||||
function resolveMembershipRoot<T extends TenantFilterTarget>(
|
||||
tab: UserMembershipTenantTab,
|
||||
tenants: T[],
|
||||
) {
|
||||
const rootSlug = tab.rootSlug.toLowerCase();
|
||||
return (
|
||||
tenants.find(
|
||||
(tenant) => tab.seedTenantId && tenant.id === tab.seedTenantId,
|
||||
) ??
|
||||
tenants.find((tenant) => tenant.slug?.trim().toLowerCase() === rootSlug)
|
||||
);
|
||||
}
|
||||
|
||||
export function classifyTenantByMembershipRoot<T extends TenantFilterTarget>(
|
||||
target: TenantFilterTarget | undefined,
|
||||
tenants: T[],
|
||||
) {
|
||||
const tenant = resolveTenantTarget(target, tenants);
|
||||
if (!tenant?.id) return undefined;
|
||||
|
||||
const tenantById = new Map(
|
||||
tenants
|
||||
.filter((item) => item.id?.trim())
|
||||
.map((item) => [item.id as string, item]),
|
||||
);
|
||||
|
||||
return USER_MEMBERSHIP_TENANT_TABS.find((tab) => {
|
||||
const root = resolveMembershipRoot(tab, tenants);
|
||||
if (!root?.id) return false;
|
||||
const resolvedTenant = tenantById.get(tenant.id ?? "") ?? tenant;
|
||||
return isInTenantSubtree(resolvedTenant, root.id, tenantById);
|
||||
});
|
||||
}
|
||||
|
||||
export function filterTenantsByMembershipRoot<T extends TenantFilterTarget>(
|
||||
tenants: T[],
|
||||
tabId: UserMembershipTenantTabId,
|
||||
) {
|
||||
const tab = USER_MEMBERSHIP_TENANT_TABS.find((item) => item.id === tabId);
|
||||
if (!tab) return [];
|
||||
|
||||
const root = resolveMembershipRoot(tab, tenants);
|
||||
if (!root?.id) return [];
|
||||
|
||||
const tenantById = new Map(
|
||||
tenants
|
||||
.filter((tenant) => tenant.id?.trim())
|
||||
.map((tenant) => [tenant.id as string, tenant]),
|
||||
);
|
||||
|
||||
return tenants.filter(
|
||||
(tenant) =>
|
||||
!isSystemTenant(tenant) &&
|
||||
isPublicRepresentativeTenant(tenant) &&
|
||||
isInTenantSubtree(tenant, root.id as string, tenantById),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveUserMembershipTenantTab<T extends TenantFilterTarget>(
|
||||
user: HanmacFamilyUserTarget,
|
||||
tenants: T[],
|
||||
) {
|
||||
const metadataAppointments = Array.isArray(
|
||||
user.metadata?.additionalAppointments,
|
||||
)
|
||||
? user.metadata.additionalAppointments
|
||||
.map((appointment) => appointment as TenantFilterTarget)
|
||||
.filter(
|
||||
(appointment) =>
|
||||
typeof appointment.tenantId === "string" ||
|
||||
typeof appointment.id === "string" ||
|
||||
typeof appointment.tenantSlug === "string" ||
|
||||
typeof appointment.slug === "string",
|
||||
)
|
||||
.map((appointment) => ({
|
||||
id: appointment.id ?? appointment.tenantId,
|
||||
slug: appointment.slug ?? appointment.tenantSlug,
|
||||
parentId: appointment.parentId,
|
||||
type: appointment.type,
|
||||
name: appointment.name ?? appointment.tenantName,
|
||||
}))
|
||||
: [];
|
||||
const tenantBySlug = new Map(
|
||||
tenants
|
||||
.filter((tenant) => tenant.slug?.trim())
|
||||
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
|
||||
);
|
||||
const tenantById = new Map(
|
||||
tenants
|
||||
.filter((tenant) => tenant.id?.trim())
|
||||
.map((tenant) => [tenant.id as string, tenant]),
|
||||
);
|
||||
const candidates = [
|
||||
user.tenant,
|
||||
...(user.joinedTenants ?? []),
|
||||
...metadataAppointments,
|
||||
...metadataAppointments.map((appointment) =>
|
||||
tenantById.get(appointment.id ?? ""),
|
||||
),
|
||||
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
|
||||
];
|
||||
|
||||
return (
|
||||
USER_MEMBERSHIP_TENANT_TABS.find((tab) =>
|
||||
candidates.some(
|
||||
(candidate) =>
|
||||
classifyTenantByMembershipRoot(candidate, tenants)?.id === tab.id,
|
||||
),
|
||||
) ?? USER_MEMBERSHIP_TENANT_TABS[0]
|
||||
);
|
||||
}
|
||||
|
||||
function isGPDTDCTenant<T extends TenantFilterTarget>(
|
||||
target: TenantFilterTarget | undefined,
|
||||
tenants: T[],
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -310,11 +310,13 @@ export async function fetchTenants(
|
||||
parentId?: string,
|
||||
cursor?: string,
|
||||
search?: string,
|
||||
sort?: string,
|
||||
direction?: "asc" | "desc",
|
||||
) {
|
||||
const { data } = await apiClient.get<TenantListResponse>(
|
||||
"/v1/admin/tenants",
|
||||
{
|
||||
params: { limit, offset, parentId, cursor, search },
|
||||
params: { limit, offset, parentId, cursor, search, sort, direction },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
@@ -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[];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>[] = [];
|
||||
|
||||
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,
|
||||
}) => {
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | 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<Record<string, unknown>> = [];
|
||||
const relationDeletes: Array<Record<string, unknown>> = [];
|
||||
|
||||
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<string, unknown> | 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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19
-19
@@ -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
|
||||
BIN
Binary file not shown.
+3
-1
@@ -1,4 +1,6 @@
|
||||
|
||||
|
||||
event_dateDate�P tenant_idString$f36e2211-8cfd-4813-8618-34e606fe73actenant_typeStringORGANIZATION client_idStringorgfrontclient_nameStringOrgFront
|
||||
event_typeStringrp_usage.authorization_grantedevents_countAggregateFunction(count)unique_subjects$AggregateFunction(uniqExact, String)<ðÞX„K>Ob×ÑR
|
||||
event_dateDatecPdPdPdPhPhPhP…P…P…P…P†P†P†P†P†P†P‡P‡P‡P‡P‡P‡P‡P‡P 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
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+12
@@ -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:
|
||||
Binary file not shown.
+2
-2
@@ -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"],
|
||||
BIN
Binary file not shown.
+2
-2
@@ -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
|
||||
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
"Name","External Key"
|
||||
"회장","chair"
|
||||
"사장","pres"
|
||||
"부회장","vchair"
|
||||
"고문","advisor"
|
||||
"부사장","vp"
|
||||
"전무이사","execdir"
|
||||
"상무이사","mandir"
|
||||
"이사","dir"
|
||||
"부장","gm"
|
||||
"차장","dgm"
|
||||
"과장","mgr"
|
||||
"대리","asstmgr"
|
||||
"사원","staff"
|
||||
|
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+12
-1
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 300 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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보다 우선하는 근거로 사용하지 않습니다.
|
||||
|
||||
@@ -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 이하를 유지합니다.
|
||||
@@ -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` 계약은 사용하지 않는다.
|
||||
|
||||
## 인증
|
||||
|
||||
|
||||
@@ -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/<app>`이어야 합니다.
|
||||
- 각 서비스 command는 `npm run dev -- --host 0.0.0.0 --port <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/<app>`.
|
||||
- `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/<app>`;
|
||||
- `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
|
||||
```
|
||||
@@ -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만 각각 비교한다.
|
||||
@@ -0,0 +1,8 @@
|
||||
"Name","External Key"
|
||||
"사장","pres"
|
||||
"부사장","vp"
|
||||
"전무이사","execdir"
|
||||
"수석 연구원","prin"
|
||||
"책임 연구원","lead"
|
||||
"선임 연구원","sen"
|
||||
"연구원","res"
|
||||
|
+12
-1
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -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,
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string>();
|
||||
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"
|
||||
>
|
||||
<header className="z-20 grid shrink-0 grid-cols-1 items-center gap-3 border-b border-[#f2c484]/30 bg-[linear-gradient(145deg,rgba(10,42,34,0.98)_0%,rgba(15,58,47,0.98)_52%,rgba(26,86,69,0.98)_100%)] px-6 py-4 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
|
||||
<div className="flex min-w-0 justify-start">
|
||||
<div className="flex min-w-0 flex-wrap items-start justify-start gap-2">
|
||||
<OrgSelectionPicker
|
||||
onChange={updateSelectedOrg}
|
||||
options={orgSelectionOptions}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { GitBranch, Network, PanelTop } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { GitBranch, Network, PanelTop, RefreshCw } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import { fetchOrgChartSnapshot } from "../../../lib/adminApi";
|
||||
import {
|
||||
formatOrgChartSnapshotTimestamp,
|
||||
resolveOrgChartSnapshotTimestamp,
|
||||
} from "../orgChartSnapshotTime";
|
||||
|
||||
const navItems = [
|
||||
{ to: "/chart", label: "조직도", icon: Network },
|
||||
@@ -7,6 +14,59 @@ const navItems = [
|
||||
{ to: "/embed-preview", label: "임베딩 검증", icon: PanelTop },
|
||||
];
|
||||
|
||||
function OrgFrontSnapshotStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
const [isRefreshingSnapshot, setIsRefreshingSnapshot] = React.useState(false);
|
||||
const snapshotQuery = useQuery({
|
||||
queryKey: ["orgchart-snapshot", { cache: "redis" }],
|
||||
queryFn: () => 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 (
|
||||
<div
|
||||
className="flex h-7 min-w-[190px] max-w-full shrink-0 items-center justify-end gap-1.5 bg-transparent text-muted-foreground opacity-[0.72]"
|
||||
data-testid="orgchart-render-status-panel"
|
||||
>
|
||||
<p
|
||||
className="min-w-0 flex-1 truncate text-right text-[11px] font-bold"
|
||||
data-testid="orgchart-data-timestamp"
|
||||
>
|
||||
{timestamp || "-"}
|
||||
</p>
|
||||
<button
|
||||
aria-label="새로고침"
|
||||
className="inline-flex h-6 w-6 shrink-0 appearance-none items-center justify-center rounded border-0 bg-transparent p-0 text-muted-foreground shadow-none transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isRefreshing}
|
||||
onClick={refreshSnapshot}
|
||||
title="새로고침"
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw className={isRefreshing ? "animate-spin" : ""} size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex w-full flex-col gap-3 px-4 pb-1 pt-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Baron Orgfront
|
||||
</p>
|
||||
<h1 className="text-xl font-semibold">조직도 서비스</h1>
|
||||
</div>
|
||||
<nav className="flex flex-wrap gap-2" aria-label="주요 메뉴">
|
||||
{navItems.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"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(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div
|
||||
className="flex min-w-0 flex-wrap items-end justify-end gap-2 md:self-end"
|
||||
data-testid="orgfront-topbar-actions"
|
||||
>
|
||||
<nav className="flex flex-wrap gap-2" aria-label="주요 메뉴">
|
||||
{navItems.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
"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(" ")
|
||||
}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<OrgFrontSnapshotStatus />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user