forked from baron/baron-sso
chore: snapshot local state before dev merge
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
134
adminfront/e2e-evidence/tenant-profile-performance-local.json
Normal file
134
adminfront/e2e-evidence/tenant-profile-performance-local.json
Normal file
@@ -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"
|
||||
}
|
||||
BIN
adminfront/e2e-evidence/tenant-profile-performance-local.png
Normal file
BIN
adminfront/e2e-evidence/tenant-profile-performance-local.png
Normal file
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" &&
|
||||
|
||||
@@ -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[];
|
||||
|
||||
402
adminfront/tests/tenant-performance.spec.ts
Normal file
402
adminfront/tests/tenant-performance.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
219
adminfront/tests/tenant-profile-performance-local.spec.ts
Normal file
219
adminfront/tests/tenant-profile-performance-local.spec.ts
Normal file
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user