forked from baron/baron-sso
feat: integrate orgfront and expose internal ids
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
Reference in New Issue
Block a user