1
0
forked from baron/baron-sso

chore: snapshot local state before dev merge

This commit is contained in:
2026-06-17 21:25:42 +09:00
parent b2808759d2
commit 49560e8a8c
107 changed files with 8958 additions and 939 deletions

View File

@@ -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

View 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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -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}`,

View File

@@ -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,개인 사용자 기본 루트 테넌트,,,,
1 id name type parent_tenant_slug slug memo email_domain visibility org_unit_type worksmobile_sync
10 e57cb22c-383e-4489-8c2f-0c5431917e86 (주)피티씨 COMPANY baron-group ptc pre-cast.co.kr
11 4d0f26b9-702c-4bc6-8996-46e9eedfdeb7 MH_manager USER_GROUP hanmac-family mhd 맨아워 대시보드 권한 보유자그룹 private no
12 e41adf79-3d15-4807-8303-afbdb0f2bab7 SW_uploader USER_GROUP hanmac-family sw-uploader 소프트웨어 배포 권한 그룹 private no
13 9607eb7b-04d2-42ab-80fe-780fe21c7e8f ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916 Personal 일반회사 PERSONAL COMPANY_GROUP personal commercial 개인 사용자 기본 루트 테넌트 외부 기업회원 루트 테넌트
14 d19c10f0-0224-4bbb-bf3e-ce579c5338ea 공공기관 COMPANY_GROUP public-org 공공기관 기본 루트 테넌트
15 78accec5-8eba-4324-b8f1-10ab360011fe 교육/학생 COMPANY_GROUP edu 교육기관 및 학생 기본 루트 테넌트
16 9607eb7b-04d2-42ab-80fe-780fe21c7e8f 개인사용자 PERSONAL personal 개인 사용자 기본 루트 테넌트

View File

@@ -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" &&

View File

@@ -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 /> },
],
},
],

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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 연동",

View File

@@ -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({

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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"
>

View File

@@ -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",
);
});
});

View File

@@ -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,
}));
}

View File

@@ -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(

View File

@@ -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[];

View 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);
});
});

View 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);
});
});

View File

@@ -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,
}) => {

View File

@@ -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(

View File

@@ -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",