forked from baron/baron-sso
userfront e2e 전체 테스트
This commit is contained in:
72
adminfront/src/features/auth/LoginPage.test.tsx
Normal file
72
adminfront/src/features/auth/LoginPage.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import LoginPage from "./LoginPage";
|
||||
|
||||
const mockSigninRedirect = vi.fn();
|
||||
const mockUseAuth = vi.fn();
|
||||
|
||||
vi.mock("react-oidc-context", () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
function renderLoginPage(initialEntry: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<LoginPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("LoginPage", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "crypto", {
|
||||
configurable: true,
|
||||
value: {},
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
mockSigninRedirect.mockReset();
|
||||
mockUseAuth.mockReturnValue({
|
||||
activeNavigator: undefined,
|
||||
error: undefined,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
signinRedirect: mockSigninRedirect,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows an actionable error instead of starting PKCE when WebCrypto is unavailable", async () => {
|
||||
renderLoginPage("/login?returnTo=%2F");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /SSO 계정으로 로그인/i }));
|
||||
|
||||
expect(mockSigninRedirect).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
/SSO 로그인을 시작할 수 없습니다/,
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the returnTo query when starting SSO manually", async () => {
|
||||
Object.defineProperty(window, "crypto", {
|
||||
configurable: true,
|
||||
value: { subtle: {} },
|
||||
});
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
renderLoginPage("/login?returnTo=%2Fusers%3Fpage%3D2");
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /SSO 계정으로 로그인/i }));
|
||||
|
||||
expect(mockSigninRedirect).toHaveBeenCalledWith({
|
||||
state: {
|
||||
returnTo: "/users?page=2",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { AlertTriangle, ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -10,15 +10,36 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { canStartBrowserPkceLogin } from "../../lib/authConfig";
|
||||
import { debugLog } from "../../lib/debugLog";
|
||||
|
||||
const insecurePkceMessage =
|
||||
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
|
||||
|
||||
function isPkceSetupFailure(error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message);
|
||||
}
|
||||
|
||||
function LoginPage() {
|
||||
const auth = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const autoStartedRef = useRef(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const returnTo = searchParams.get("returnTo") || "/";
|
||||
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||
const authErrorMessage = useMemo(() => {
|
||||
const message = auth.error?.message;
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
if (message.includes("Crypto.subtle")) {
|
||||
return insecurePkceMessage;
|
||||
}
|
||||
return message;
|
||||
}, [auth.error?.message]);
|
||||
const visibleLoginError = loginError || authErrorMessage;
|
||||
|
||||
useEffect(() => {
|
||||
debugLog("[LoginPage] Auth state check:", {
|
||||
@@ -42,21 +63,46 @@ function LoginPage() {
|
||||
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
|
||||
return;
|
||||
}
|
||||
if (!canStartBrowserPkceLogin()) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
autoStartedRef.current = true;
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
void auth
|
||||
.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isPkceSetupFailure(error)) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
console.error("Auto login redirect failed", error);
|
||||
});
|
||||
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
|
||||
|
||||
const handleSSOLogin = () => {
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo: "/",
|
||||
},
|
||||
});
|
||||
const handleSSOLogin = async () => {
|
||||
try {
|
||||
setLoginError(null);
|
||||
if (!canStartBrowserPkceLogin()) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
await auth.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isPkceSetupFailure(error)) {
|
||||
setLoginError(insecurePkceMessage);
|
||||
return;
|
||||
}
|
||||
console.error("Redirect login failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -85,11 +131,7 @@ function LoginPage() {
|
||||
variant="ghost"
|
||||
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
void auth.signinRedirect({
|
||||
state: {
|
||||
returnTo,
|
||||
},
|
||||
});
|
||||
void handleSSOLogin();
|
||||
}}
|
||||
>
|
||||
다시 시도하기
|
||||
@@ -127,6 +169,16 @@ function LoginPage() {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{visibleLoginError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm leading-5 text-destructive"
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{visibleLoginError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
|
||||
관리자 전역 세션은 보안을 위해 15분간 유지됩니다.
|
||||
<br />
|
||||
|
||||
@@ -128,4 +128,23 @@ nullable@test.com,Nullable User,010-1111-1111,user,primary-tenant,개발팀,책
|
||||
});
|
||||
expect(result[1].additionalAppointments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should preserve sub_email as secondary email metadata without replacing primary email", () => {
|
||||
const csv = `email,name,tenant_slug,employee_id,sub_email
|
||||
primary@samaneng.com,Primary User,rnd-saman,EMP001,secondary@hanmaceng.co.kr`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
email: "primary@samaneng.com",
|
||||
tenantSlug: "rnd-saman",
|
||||
metadata: {
|
||||
employee_id: "EMP001",
|
||||
sub_email: "secondary@hanmaceng.co.kr",
|
||||
secondary_emails: ["secondary@hanmaceng.co.kr"],
|
||||
aliasEmails: ["secondary@hanmaceng.co.kr"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,13 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
for (let i = 1; i < records.length; i++) {
|
||||
const values = records[i].map((v) => v.trim());
|
||||
if (values.every((value) => value === "")) continue;
|
||||
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
|
||||
const item: Partial<BulkUserItem> & {
|
||||
metadata: Record<string, unknown>;
|
||||
} = {
|
||||
metadata: {},
|
||||
};
|
||||
const additionalAppointment: BulkUserAppointment & {
|
||||
metadata: Record<string, string>;
|
||||
metadata: Record<string, unknown>;
|
||||
} = {
|
||||
metadata: {},
|
||||
};
|
||||
@@ -94,6 +96,8 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
item.jobTitle = value;
|
||||
} else if (header === "employee_id") {
|
||||
item.metadata.employee_id = value;
|
||||
} else if (header === "secondary_emails") {
|
||||
applySecondaryEmailMetadata(item, value);
|
||||
} else if (header === "tenant_slug1") {
|
||||
additionalAppointment.tenantSlug = value;
|
||||
} else if (header === "department1") {
|
||||
@@ -117,6 +121,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
item.metadata.personal_email = value;
|
||||
} else if (header === "subemail") {
|
||||
item.metadata.naverworks_sub_email = value;
|
||||
addWorksmobileAliasEmails(item, splitEmailTokens(value).slice(1));
|
||||
item.email = firstEmailToken(value) || item.email;
|
||||
} else if (header === "nickname") {
|
||||
item.metadata.naverworks_nickname = value;
|
||||
@@ -185,7 +190,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
}
|
||||
|
||||
function cleanAdditionalAppointment(
|
||||
appointment: BulkUserAppointment & { metadata: Record<string, string> },
|
||||
appointment: BulkUserAppointment & { metadata: Record<string, unknown> },
|
||||
) {
|
||||
const metadata =
|
||||
Object.keys(appointment.metadata).length > 0
|
||||
@@ -210,7 +215,31 @@ function cleanAdditionalAppointment(
|
||||
}
|
||||
|
||||
function normalizeHeader(header: string) {
|
||||
return header
|
||||
const raw = header.trim().replace(/^\uFEFF/, "");
|
||||
const lower = raw.toLowerCase();
|
||||
const separatorNormalized = lower.replace(/-+/g, "_").replace(/_+/g, "_");
|
||||
const compactKorean = raw.replace(/\s+/g, "");
|
||||
|
||||
if (
|
||||
[
|
||||
"sub_email",
|
||||
"secondary_email",
|
||||
"secondary_emails",
|
||||
"additional_email",
|
||||
"additional_emails",
|
||||
"alias_email",
|
||||
"alias_emails",
|
||||
"worksmobile_alias_email",
|
||||
"worksmobile_alias_emails",
|
||||
].includes(separatorNormalized) ||
|
||||
["보조이메일", "보조메일", "추가이메일", "추가메일"].includes(
|
||||
compactKorean,
|
||||
)
|
||||
) {
|
||||
return "secondary_emails";
|
||||
}
|
||||
|
||||
return raw
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^\uFEFF/, "")
|
||||
@@ -264,12 +293,69 @@ function parseCSVRecords(text: string) {
|
||||
}
|
||||
|
||||
function firstEmailToken(value: string) {
|
||||
return (
|
||||
value
|
||||
.split(/[;,]/)
|
||||
.map((token) => token.trim())
|
||||
.find((token) => token.includes("@")) ?? ""
|
||||
);
|
||||
return splitEmailTokens(value)[0] ?? "";
|
||||
}
|
||||
|
||||
function splitEmailTokens(value: string) {
|
||||
return value
|
||||
.split(/[;,\n\r\t]/)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.includes("@"));
|
||||
}
|
||||
|
||||
function metadataString(value: unknown) {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function metadataEmailList(value: unknown) {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return splitEmailTokens(value);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function uniqueEmails(values: string[]) {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const value of values) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function addWorksmobileAliasEmails(
|
||||
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
|
||||
emails: string[],
|
||||
) {
|
||||
const aliases = uniqueEmails([
|
||||
...metadataEmailList(item.metadata.aliasEmails),
|
||||
...emails,
|
||||
]);
|
||||
if (aliases.length > 0) {
|
||||
item.metadata.aliasEmails = aliases;
|
||||
item.metadata.worksmobileAliasEmails = aliases;
|
||||
}
|
||||
}
|
||||
|
||||
function applySecondaryEmailMetadata(
|
||||
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
|
||||
value: string,
|
||||
) {
|
||||
const emails = splitEmailTokens(value);
|
||||
item.metadata.sub_email = value;
|
||||
item.metadata.secondary_emails = uniqueEmails([
|
||||
...metadataEmailList(item.metadata.secondary_emails),
|
||||
...emails,
|
||||
]);
|
||||
addWorksmobileAliasEmails(item, emails);
|
||||
}
|
||||
|
||||
function splitOrganizationPath(value: string) {
|
||||
@@ -280,28 +366,32 @@ function splitOrganizationPath(value: string) {
|
||||
}
|
||||
|
||||
function applyNaverWorksFallbacks(
|
||||
item: Partial<BulkUserItem> & { metadata: Record<string, string> },
|
||||
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
|
||||
) {
|
||||
if (!item.name) {
|
||||
const firstName = item.metadata.naverworks_first_name ?? "";
|
||||
const lastName = item.metadata.naverworks_last_name ?? "";
|
||||
const firstName = metadataString(item.metadata.naverworks_first_name);
|
||||
const lastName = metadataString(item.metadata.naverworks_last_name);
|
||||
item.name = [firstName, lastName].filter(Boolean).join(" ").trim();
|
||||
if (!item.name && item.metadata.naverworks_nickname) {
|
||||
item.name = item.metadata.naverworks_nickname;
|
||||
const nickname = metadataString(item.metadata.naverworks_nickname);
|
||||
if (!item.name && nickname) {
|
||||
item.name = nickname;
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.email) {
|
||||
item.email = item.metadata.personal_email;
|
||||
item.email = metadataString(item.metadata.personal_email);
|
||||
}
|
||||
|
||||
if (!item.phone) {
|
||||
const countryCode = item.metadata.naverworks_mobile_country_code ?? "";
|
||||
const number = item.metadata.naverworks_mobile_numbers ?? "";
|
||||
const countryCode = metadataString(
|
||||
item.metadata.naverworks_mobile_country_code,
|
||||
);
|
||||
const number = metadataString(item.metadata.naverworks_mobile_numbers);
|
||||
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
|
||||
}
|
||||
|
||||
if (!item.grade && item.metadata.naverworks_level) {
|
||||
item.grade = item.metadata.naverworks_level;
|
||||
const level = metadataString(item.metadata.naverworks_level);
|
||||
if (!item.grade && level) {
|
||||
item.grade = level;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,4 +101,60 @@ describe("applyGeneralPlanningOfficePriority", () => {
|
||||
employee_id: "EMP001",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses GPDTDC as the Baron representative while keeping the first affiliation primary for WorksMobile", () => {
|
||||
const user: BulkUserItem = {
|
||||
email: "gpdtdc-dual@test.com",
|
||||
name: "GPDTDC Dual User",
|
||||
tenantSlug: "rnd-saman",
|
||||
department: "삼안기술연구소",
|
||||
grade: "책임",
|
||||
metadata: {
|
||||
employee_id: "SAMAN001",
|
||||
},
|
||||
additionalAppointments: [
|
||||
{
|
||||
tenantSlug: "tdc",
|
||||
tenantName: "기술개발센터",
|
||||
grade: "책임연구원",
|
||||
metadata: {
|
||||
employee_id: "B24051",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = applyGeneralPlanningOfficePriority(user, [
|
||||
tenant("family", "한맥가족사", "hanmac-family"),
|
||||
tenant("gpdtdc", "총괄기획&기술개발센터", "gpdtdc", "family"),
|
||||
tenant("tdc", "기술개발센터", "tdc", "gpdtdc"),
|
||||
tenant("saman", "삼안", "rnd-saman"),
|
||||
]);
|
||||
|
||||
expect(result.tenantSlug).toBe("gpdtdc");
|
||||
expect(result.tenantImport).toMatchObject({
|
||||
slug: "gpdtdc",
|
||||
name: "총괄기획&기술개발센터",
|
||||
});
|
||||
expect(result.metadata.employee_id).toBe("SAMAN001");
|
||||
expect(result.additionalAppointments).toEqual([
|
||||
expect.objectContaining({
|
||||
tenantSlug: "rnd-saman",
|
||||
isPrimary: true,
|
||||
department: "삼안기술연구소",
|
||||
grade: "책임",
|
||||
metadata: {
|
||||
employee_id: "SAMAN001",
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
tenantSlug: "tdc",
|
||||
isPrimary: false,
|
||||
grade: "책임연구원",
|
||||
metadata: {
|
||||
employee_id: "B24051",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { BulkUserItem, TenantSummary } from "../../../lib/adminApi";
|
||||
import type {
|
||||
BulkUserAppointment,
|
||||
BulkUserItem,
|
||||
TenantSummary,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export function applyGeneralPlanningOfficePriority(
|
||||
user: BulkUserItem,
|
||||
tenants: TenantSummary[],
|
||||
): BulkUserItem {
|
||||
const gpdtdcRepresentative = applyGPDTDCRepresentativeTenant(user, tenants);
|
||||
if (gpdtdcRepresentative) {
|
||||
return gpdtdcRepresentative;
|
||||
}
|
||||
|
||||
const firstAdditional = user.additionalAppointments?.[0];
|
||||
const secondarySlug = firstAdditional?.tenantSlug;
|
||||
|
||||
@@ -67,6 +76,82 @@ export function applyGeneralPlanningOfficePriority(
|
||||
};
|
||||
}
|
||||
|
||||
function applyGPDTDCRepresentativeTenant(
|
||||
user: BulkUserItem,
|
||||
tenants: TenantSummary[],
|
||||
): BulkUserItem | undefined {
|
||||
const root = findGPDTDCRootTenant(tenants);
|
||||
if (!root) return undefined;
|
||||
|
||||
const primarySlug = user.tenantSlug || "";
|
||||
const hasPrimaryUnderRoot = isUnderTenant(primarySlug, root, tenants);
|
||||
const hasAppointmentUnderRoot = (user.additionalAppointments ?? []).some(
|
||||
(appointment) => isUnderTenant(appointment.tenantSlug || "", root, tenants),
|
||||
);
|
||||
if (!hasPrimaryUnderRoot && !hasAppointmentUnderRoot) return undefined;
|
||||
if (primarySlug === root.slug) return undefined;
|
||||
|
||||
const worksmobileAppointments: BulkUserAppointment[] = [];
|
||||
const seen = new Set<string>();
|
||||
const addAppointment = (
|
||||
appointment: BulkUserAppointment,
|
||||
fallbackKey: string,
|
||||
) => {
|
||||
const key = appointment.tenantSlug || appointment.tenantId || fallbackKey;
|
||||
if (!key || key === root.slug || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
worksmobileAppointments.push(appointment);
|
||||
};
|
||||
|
||||
addAppointment(buildPrimaryAppointment(user), "primary");
|
||||
for (const appointment of user.additionalAppointments ?? []) {
|
||||
addAppointment(
|
||||
{ ...appointment, isPrimary: false },
|
||||
appointment.tenantSlug || "",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
tenantSlug: root.slug,
|
||||
tenantImport: {
|
||||
...(user.tenantImport ?? {}),
|
||||
sourceTenantId: undefined,
|
||||
slug: root.slug,
|
||||
name: root.name,
|
||||
},
|
||||
additionalAppointments:
|
||||
worksmobileAppointments.length > 0 ? worksmobileAppointments : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrimaryAppointment(user: BulkUserItem): BulkUserAppointment {
|
||||
return {
|
||||
...(user.tenantId ? { tenantId: user.tenantId } : {}),
|
||||
...(user.tenantSlug ? { tenantSlug: user.tenantSlug } : {}),
|
||||
isPrimary: true,
|
||||
isOwner: false,
|
||||
...(user.department ? { department: user.department } : {}),
|
||||
...(user.grade ? { grade: user.grade } : {}),
|
||||
...(user.position ? { position: user.position } : {}),
|
||||
...(user.jobTitle ? { jobTitle: user.jobTitle } : {}),
|
||||
metadata: { ...user.metadata },
|
||||
};
|
||||
}
|
||||
|
||||
function findGPDTDCRootTenant(tenants: TenantSummary[]) {
|
||||
return tenants.find((tenant) => {
|
||||
const slug = tenant.slug.trim().toLowerCase();
|
||||
const name = tenant.name.replace(/\s+/g, "").toLowerCase();
|
||||
return (
|
||||
slug === "gpdtdc" ||
|
||||
name === "gpdtdc" ||
|
||||
name.includes("총괄기획&기술개발센터") ||
|
||||
name.includes("총괄기획기술개발센터")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isUnderGeneralPlanningOffice(
|
||||
tenantSlug: string,
|
||||
tenants: TenantSummary[],
|
||||
@@ -80,6 +165,20 @@ function isUnderGeneralPlanningOffice(
|
||||
return false;
|
||||
}
|
||||
|
||||
function isUnderTenant(
|
||||
tenantSlug: string,
|
||||
root: TenantSummary,
|
||||
tenants: TenantSummary[],
|
||||
): boolean {
|
||||
let current = tenants.find((tenant) => tenant.slug === tenantSlug);
|
||||
while (current) {
|
||||
if (current.id === root.id || current.slug === root.slug) return true;
|
||||
if (!current.parentId) break;
|
||||
current = tenants.find((tenant) => tenant.id === current?.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user