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

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