forked from baron/baron-sso
org chart 연동기능 추가
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -46,14 +46,14 @@ import {
|
||||
bulkDeleteUsers,
|
||||
bulkUpdateUsers,
|
||||
deleteUser,
|
||||
exportUsersCSVUrl,
|
||||
exportUsersCSV,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
|
||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||
|
||||
type UserSchemaField = {
|
||||
@@ -144,6 +144,41 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: () => exportUsersCSV(search, selectedCompany),
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t(
|
||||
"msg.admin.users.export_error",
|
||||
"사용자 내보내기에 실패했습니다.",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const statusMutation = useMutation({
|
||||
mutationFn: ({ userId, status }: { userId: string; status: string }) =>
|
||||
updateUser(userId, { status }),
|
||||
onSuccess: () => {
|
||||
query.refetch();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t("msg.admin.users.status_error", "사용자 상태 변경에 실패했습니다."),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearch(searchDraft);
|
||||
setPage(1);
|
||||
@@ -156,8 +191,7 @@ function UserListPage() {
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const url = exportUsersCSVUrl(search, selectedCompany);
|
||||
window.open(url, "_blank");
|
||||
exportMutation.mutate();
|
||||
};
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
@@ -275,7 +309,12 @@ function UserListPage() {
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleExport} className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
className="gap-2"
|
||||
disabled={exportMutation.isPending}
|
||||
>
|
||||
<FileDown size={16} />
|
||||
{t("ui.common.export", "내보내기")}
|
||||
</Button>
|
||||
@@ -428,21 +467,12 @@ function UserListPage() {
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t(
|
||||
"ui.admin.users.list.table.name_email",
|
||||
"NAME / EMAIL",
|
||||
"이름 / 이메일 / 전화번호",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.admin.users.list.table.tenant_dept",
|
||||
"TENANT / DEPT",
|
||||
)}
|
||||
</TableHead>
|
||||
{/* Dynamic Columns from Schema */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
@@ -464,7 +494,7 @@ function UserListPage() {
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
colSpan={5 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
@@ -474,7 +504,7 @@ function UserListPage() {
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7 + userSchema.length}
|
||||
colSpan={5 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
@@ -513,35 +543,47 @@ function UserListPage() {
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||
<User size={16} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<div
|
||||
className="truncate text-sm"
|
||||
data-testid={`user-contact-${user.id}`}
|
||||
>
|
||||
<span className="font-medium">{user.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
{user.email}
|
||||
</span>
|
||||
{user.phone && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
{user.phone}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{t(`ui.admin.role.${user.role}`, user.role)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
user.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="font-medium text-blue-600">
|
||||
{user.tenant?.name || user.tenantSlug || "-"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department || "-"}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={user.status === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
statusMutation.mutate({
|
||||
userId: user.id,
|
||||
status: checked ? "active" : "inactive",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.toggle_status",
|
||||
"{{name}} 활성 상태",
|
||||
{ name: user.name },
|
||||
)}
|
||||
data-testid={`user-status-toggle-${user.id}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
@@ -625,16 +667,6 @@ function UserListPage() {
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성화")}
|
||||
</Button>
|
||||
<UserBulkMoveGroupModal
|
||||
userIds={selectedUserIds}
|
||||
selectedUsers={items.filter((u) =>
|
||||
selectedUserIds.includes(u.id),
|
||||
)}
|
||||
onSuccess={() => {
|
||||
query.refetch();
|
||||
setSelectedUserIds([]);
|
||||
}}
|
||||
/>
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
118
adminfront/src/features/users/orgChartPicker.test.ts
Normal file
118
adminfront/src/features/users/orgChartPicker.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
buildOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
|
||||
describe("orgChartPicker", () => {
|
||||
it("builds the tenant picker embed URL from VITE_ORGCHART_URL", () => {
|
||||
expect(buildOrgChartTenantPickerUrl("https://orgchart.example.com/")).toBe(
|
||||
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600",
|
||||
);
|
||||
});
|
||||
|
||||
it("adds tenantId to the tenant picker URL when Hanmac family scope is known", () => {
|
||||
expect(
|
||||
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
|
||||
tenantId: "hanmac-family-id",
|
||||
}),
|
||||
).toBe(
|
||||
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600&tenantId=hanmac-family-id",
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps the picker URL with the org-chart auto login entry", () => {
|
||||
expect(
|
||||
buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
"https://orgchart.example.com",
|
||||
{
|
||||
tenantId: "hanmac-family-id",
|
||||
},
|
||||
),
|
||||
).toBe(
|
||||
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses the first tenant id and name from orgfront confirm messages", () => {
|
||||
expect(
|
||||
parseOrgChartTenantSelection({
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
mode: "single",
|
||||
selections: [
|
||||
{
|
||||
type: "tenant",
|
||||
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||
name: "기술기획",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||
name: "기술기획",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores non-tenant or malformed picker messages", () => {
|
||||
expect(
|
||||
parseOrgChartTenantSelection({
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
mode: "single",
|
||||
selections: [{ type: "user", id: "u-1", name: "User" }],
|
||||
},
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
expect(parseOrgChartTenantSelection({ type: "other" })).toBeNull();
|
||||
});
|
||||
|
||||
it("filters Hanmac family subtree and system tenants from non-family tenant choices", () => {
|
||||
const visibleTenants = filterNonHanmacFamilyTenants(
|
||||
[
|
||||
{
|
||||
id: "system-id",
|
||||
slug: "system",
|
||||
name: "System",
|
||||
type: "SYSTEM",
|
||||
parentId: undefined,
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
],
|
||||
"hanmac-family-id",
|
||||
);
|
||||
|
||||
expect(visibleTenants.map((tenant) => tenant.slug)).toEqual(["external"]);
|
||||
});
|
||||
});
|
||||
142
adminfront/src/features/users/orgChartPicker.ts
Normal file
142
adminfront/src/features/users/orgChartPicker.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
export type OrgChartTenantSelection = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type TenantFilterTarget = {
|
||||
id?: string;
|
||||
slug?: string;
|
||||
type?: string;
|
||||
parentId?: string | null;
|
||||
};
|
||||
|
||||
type OrgChartPickerMessage = {
|
||||
type?: unknown;
|
||||
payload?: {
|
||||
selections?: Array<{
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type OrgChartTenantPickerOptions = {
|
||||
tenantId?: string;
|
||||
};
|
||||
|
||||
function isSystemTenant(tenant: TenantFilterTarget) {
|
||||
const slug = tenant.slug?.trim().toLowerCase();
|
||||
const type = tenant.type?.trim().toUpperCase();
|
||||
|
||||
return (
|
||||
!tenant.id?.trim() ||
|
||||
!tenant.slug?.trim() ||
|
||||
type === "SYSTEM" ||
|
||||
slug === "system" ||
|
||||
slug === "global"
|
||||
);
|
||||
}
|
||||
|
||||
function isInTenantSubtree<T extends TenantFilterTarget>(
|
||||
tenant: T,
|
||||
rootTenantId: string,
|
||||
tenantById: Map<string, T>,
|
||||
) {
|
||||
if (!rootTenantId) {
|
||||
return false;
|
||||
}
|
||||
if (tenant.id === rootTenantId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const visitedTenantIds = new Set<string>();
|
||||
let parentId = tenant.parentId ?? "";
|
||||
while (parentId) {
|
||||
if (parentId === rootTenantId) {
|
||||
return true;
|
||||
}
|
||||
if (visitedTenantIds.has(parentId)) {
|
||||
return false;
|
||||
}
|
||||
visitedTenantIds.add(parentId);
|
||||
parentId = tenantById.get(parentId)?.parentId ?? "";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
|
||||
tenants: T[],
|
||||
hanmacFamilyTenantId?: string,
|
||||
) {
|
||||
const rootTenantId = hanmacFamilyTenantId?.trim() ?? "";
|
||||
const tenantById = new Map(
|
||||
tenants
|
||||
.filter((tenant) => tenant.id?.trim())
|
||||
.map((tenant) => [tenant.id as string, tenant]),
|
||||
);
|
||||
|
||||
return tenants.filter(
|
||||
(tenant) =>
|
||||
!isSystemTenant(tenant) &&
|
||||
!isInTenantSubtree(tenant, rootTenantId, tenantById),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildOrgChartTenantPickerUrl(
|
||||
baseUrl?: string,
|
||||
options: OrgChartTenantPickerOptions = {},
|
||||
) {
|
||||
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
|
||||
const params = new URLSearchParams({
|
||||
mode: "single",
|
||||
select: "tenant",
|
||||
width: "400",
|
||||
height: "600",
|
||||
});
|
||||
const tenantId = options.tenantId?.trim();
|
||||
if (tenantId) {
|
||||
params.set("tenantId", tenantId);
|
||||
}
|
||||
|
||||
return `${normalizedBase}/embed/picker?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
baseUrl?: string,
|
||||
options: OrgChartTenantPickerOptions = {},
|
||||
) {
|
||||
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
|
||||
const pickerUrl = buildOrgChartTenantPickerUrl("", options);
|
||||
const params = new URLSearchParams({
|
||||
auto: "1",
|
||||
returnTo: pickerUrl,
|
||||
});
|
||||
|
||||
return `${normalizedBase}/login?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function parseOrgChartTenantSelection(
|
||||
message: unknown,
|
||||
): OrgChartTenantSelection | null {
|
||||
const data = message as OrgChartPickerMessage;
|
||||
if (data?.type !== "orgfront:picker:confirm") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = data.payload?.selections?.[0];
|
||||
if (
|
||||
selection?.type !== "tenant" ||
|
||||
typeof selection.id !== "string" ||
|
||||
typeof selection.name !== "string" ||
|
||||
selection.id.trim() === ""
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: selection.id,
|
||||
name: selection.name,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user