1
0
forked from baron/baron-sso

feat: integrate orgfront and expose internal ids

This commit is contained in:
2026-04-30 09:33:39 +09:00
parent 02375af08d
commit 9ce7a67f58
116 changed files with 22992 additions and 33 deletions

View File

@@ -24,6 +24,7 @@ import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";
@@ -114,6 +115,9 @@ function AppLayout() {
const isSuperAdmin = isTest || effectiveRole === "super_admin";
const isTenantAdmin = effectiveRole === "tenant_admin";
const manageableCount = profile?.manageableTenants?.length ?? 0;
const orgfrontUrl = buildAuthenticatedOrgChartUrl(
import.meta.env.ORGFRONT_URL || "http://localhost:5175",
);
const filteredItems = items.filter((item) => {
if (isTest) return true;
@@ -129,7 +133,7 @@ function AppLayout() {
});
filteredItems.splice(2, 0, {
label: "ui.admin.nav.org_chart",
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});
@@ -152,7 +156,7 @@ function AppLayout() {
0,
{
label: "ui.admin.nav.org_chart",
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
to: orgfrontUrl,
icon: Network,
isExternal: true,
},
@@ -161,7 +165,7 @@ function AppLayout() {
// 일반 사용자(Tenant Member)도 조직도 메뉴를 볼 수 있도록 추가합니다.
filteredItems.splice(1, 0, {
label: "ui.admin.nav.org_chart",
to: import.meta.env.VITE_ORGCHART_URL || "http://localhost:5175",
to: orgfrontUrl,
icon: Network,
isExternal: true,
});

View File

@@ -439,6 +439,9 @@ function TenantListPage() {
}
/>
</TableHead>
<TableHead className="min-w-[220px]">
{t("ui.admin.tenants.table.id", "ID")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
@@ -465,7 +468,7 @@ function TenantListPage() {
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={8}>
<TableCell colSpan={9}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
@@ -473,7 +476,7 @@ function TenantListPage() {
{!query.isLoading && tenants.length === 0 && (
<TableRow>
<TableCell
colSpan={8}
colSpan={9}
className="text-center py-8 text-muted-foreground"
>
{t(
@@ -493,6 +496,12 @@ function TenantListPage() {
}
/>
</TableCell>
<TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
data-testid={`tenant-internal-id-${tenant.id}`}
>
{tenant.id}
</TableCell>
<TableCell className="font-semibold">
{tenant.name}
</TableCell>

View File

@@ -236,7 +236,7 @@ function UserCreatePage() {
});
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.VITE_ORGCHART_URL,
import.meta.env.ORGFRONT_URL,
{
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
},

View File

@@ -73,6 +73,8 @@ import {
type OrgChartTenantSelection,
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
isHanmacFamilyTenant,
isHanmacFamilyUser,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
@@ -369,7 +371,10 @@ function UserDetailPage() {
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
});
const tenants = tenantsData?.items ?? [];
const tenants = React.useMemo(
() => tenantsData?.items ?? [],
[tenantsData?.items],
);
const rpHistoryQuery = useQuery({
queryKey: ["user-rp-history", userId],
@@ -498,7 +503,7 @@ function UserDetailPage() {
);
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.VITE_ORGCHART_URL,
import.meta.env.ORGFRONT_URL,
{
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
},
@@ -642,15 +647,33 @@ function UserDetailPage() {
Record<string, string | number | boolean>
>) || {},
});
const isUserHanmacFamily = isHanmacFamilyUser(
user,
tenants,
hanmacFamilyTenantId,
);
const resolvedUserType =
metadata.userType === "personal" ||
user.companyCode === "personal"
? "personal"
: metadata.hanmacFamily === true
: isUserHanmacFamily
? "hanmac"
: "external";
setUserType(resolvedUserType);
setIsHanmacFamily(resolvedUserType === "hanmac");
const familyFallbackTenants = [
...(user.joinedTenants ?? []),
...(user.tenant ? [user.tenant] : []),
].filter(
(tenant, index, allTenants) =>
allTenants.findIndex((item) => item.id === tenant.id) ===
index &&
isHanmacFamilyTenant(
tenant,
tenants,
hanmacFamilyTenantId,
),
);
setAdditionalAppointments(
Array.isArray(rawAppointments)
? (rawAppointments as UserAppointment[]).map(
@@ -659,22 +682,38 @@ function UserDetailPage() {
draftId: createDraftId(),
}),
)
: metadata.hanmacFamily === true && fallbackAppointment
? [
{
: isUserHanmacFamily
? familyFallbackTenants.length > 0
? familyFallbackTenants.map((tenant) => ({
draftId: createDraftId(),
tenantId: fallbackAppointment.id,
tenantName: fallbackAppointment.name,
tenantSlug: fallbackAppointment.slug,
isOwner: metadata.primaryTenantIsOwner === true,
tenantId: tenant.id,
tenantName: tenant.name,
tenantSlug: tenant.slug,
isOwner:
metadata.primaryTenantIsOwner === true &&
tenant.id === fallbackAppointment?.id,
jobTitle: user.jobTitle,
position: user.position,
},
]
}))
: fallbackAppointment
? [
{
draftId: createDraftId(),
tenantId: fallbackAppointment.id,
tenantName: fallbackAppointment.name,
tenantSlug: fallbackAppointment.slug,
isOwner:
metadata.primaryTenantIsOwner ===
true,
jobTitle: user.jobTitle,
position: user.position,
},
]
: []
: [],
);
}
}, [user, reset]);
}, [hanmacFamilyTenantId, tenants, user, reset]);
const mutation = useMutation({
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),

View File

@@ -464,6 +464,9 @@ function UserListPage() {
onChange={toggleSelectAll}
/>
</TableHead>
<TableHead className="min-w-[220px]">
{t("ui.admin.users.list.table.id", "ID")}
</TableHead>
<TableHead className="min-w-[200px]">
{t(
"ui.admin.users.list.table.name_email",
@@ -494,7 +497,7 @@ function UserListPage() {
{query.isLoading && (
<TableRow>
<TableCell
colSpan={5 + userSchema.length}
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
{t("msg.common.loading", "로딩 중...")}
@@ -504,7 +507,7 @@ function UserListPage() {
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell
colSpan={5 + userSchema.length}
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
{t(
@@ -538,6 +541,12 @@ function UserListPage() {
}
/>
</TableCell>
<TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
data-testid={`user-internal-id-${user.id}`}
>
{user.id}
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">

View File

@@ -1,13 +1,15 @@
import { describe, expect, it } from "vitest";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
buildAuthenticatedOrgChartUrl,
buildOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
isHanmacFamilyUser,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
describe("orgChartPicker", () => {
it("builds the tenant picker embed URL from VITE_ORGCHART_URL", () => {
it("builds the tenant picker embed URL from ORGFRONT_URL", () => {
expect(buildOrgChartTenantPickerUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600",
);
@@ -36,6 +38,12 @@ describe("orgChartPicker", () => {
);
});
it("builds the chart navigation URL through the org-chart auto login entry", () => {
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart",
);
});
it("parses the first tenant id and name from orgfront confirm messages", () => {
expect(
parseOrgChartTenantSelection({
@@ -115,4 +123,50 @@ describe("orgChartPicker", () => {
expect(visibleTenants.map((tenant) => tenant.slug)).toEqual(["external"]);
});
it("detects existing users as Hanmac family from tenant subtree without metadata flag", () => {
const tenants = [
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "기술기획",
type: "USER_GROUP",
parentId: "hanmac-company-id",
},
];
expect(
isHanmacFamilyUser(
{
companyCode: "external",
tenant: tenants[0],
joinedTenants: [tenants[3]],
metadata: {},
},
tenants,
"hanmac-family-id",
),
).toBe(true);
});
});

View File

@@ -8,6 +8,15 @@ export type TenantFilterTarget = {
slug?: string;
type?: string;
parentId?: string | null;
name?: string;
};
export type HanmacFamilyUserTarget = {
companyCode?: string;
tenantSlug?: string;
tenant?: TenantFilterTarget;
joinedTenants?: TenantFilterTarget[];
metadata?: Record<string, unknown>;
};
type OrgChartPickerMessage = {
@@ -25,6 +34,10 @@ type OrgChartTenantPickerOptions = {
tenantId?: string;
};
type OrgChartLoginOptions = {
returnTo?: string;
};
function isSystemTenant(tenant: TenantFilterTarget) {
const slug = tenant.slug?.trim().toLowerCase();
const type = tenant.type?.trim().toUpperCase();
@@ -66,11 +79,77 @@ function isInTenantSubtree<T extends TenantFilterTarget>(
return false;
}
function resolveHanmacFamilyTenantId<T extends TenantFilterTarget>(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const envTenantId = hanmacFamilyTenantId?.trim();
if (envTenantId) return envTenantId;
return (
tenants.find((tenant) => tenant.slug?.toLowerCase() === "hanmac-family")
?.id ?? ""
);
}
export function isHanmacFamilyTenant<T extends TenantFilterTarget>(
tenant: T | undefined,
tenants: T[],
hanmacFamilyTenantId?: string,
) {
if (!tenant || !tenant.id) return false;
const rootTenantId = resolveHanmacFamilyTenantId(
tenants,
hanmacFamilyTenantId,
);
if (!rootTenantId) return false;
const tenantById = new Map(
tenants
.filter((item) => item.id?.trim())
.map((item) => [item.id as string, item]),
);
const target = tenantById.get(tenant.id) ?? tenant;
return isInTenantSubtree(target, rootTenantId, tenantById);
}
export function isHanmacFamilyUser<T extends TenantFilterTarget>(
user: HanmacFamilyUserTarget,
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const metadata = user.metadata ?? {};
if (metadata.hanmacFamily === true || metadata.userType === "hanmac") {
return true;
}
const tenantBySlug = new Map(
tenants
.filter((tenant) => tenant.slug?.trim())
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
);
const tenantCandidates = [
user.tenant,
...(user.joinedTenants ?? []),
tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""),
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
];
return tenantCandidates.some((tenant) =>
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
);
}
export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const rootTenantId = hanmacFamilyTenantId?.trim() ?? "";
const rootTenantId = resolveHanmacFamilyTenantId(
tenants,
hanmacFamilyTenantId,
);
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
@@ -107,11 +186,19 @@ export function buildAuthenticatedOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const pickerUrl = buildOrgChartTenantPickerUrl("", options);
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
}
export function buildAuthenticatedOrgChartUrl(
baseUrl?: string,
options: OrgChartLoginOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const returnTo = options.returnTo?.trim() || "/chart";
const params = new URLSearchParams({
auto: "1",
returnTo: pickerUrl,
returnTo,
});
return `${normalizedBase}/login?${params.toString()}`;