forked from baron/baron-sso
worksmobile 관리화면 보완.
This commit is contained in:
@@ -534,7 +534,7 @@ function AppLayout() {
|
|||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative min-w-0">
|
||||||
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
|
<header className="sticky top-0 z-50 border-b border-border bg-background/90 backdrop-blur">
|
||||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -730,7 +730,7 @@ function AppLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="px-5 py-6 md:px-10 md:py-10">
|
<main className="min-w-0 px-5 py-6 md:px-10 md:py-10">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<RoleSwitcher />
|
<RoleSwitcher />
|
||||||
|
|||||||
@@ -483,10 +483,10 @@ function TenantListPage() {
|
|||||||
|
|
||||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
<Table>
|
<Table className="min-w-[1180px]">
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[40px]">
|
<TableHead className="w-[48px] whitespace-nowrap">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={
|
||||||
tenants.length > 0 &&
|
tenants.length > 0 &&
|
||||||
@@ -498,28 +498,28 @@ function TenantListPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="min-w-[220px]">
|
<TableHead className="w-[280px] whitespace-nowrap">
|
||||||
{t("ui.admin.tenants.table.id", "ID")}
|
{t("ui.admin.tenants.table.id", "ID")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead className="w-[220px] whitespace-nowrap">
|
||||||
{t("ui.admin.tenants.table.name", "NAME")}
|
{t("ui.admin.tenants.table.name", "NAME")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead className="w-[140px] whitespace-nowrap">
|
||||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead className="w-[180px] whitespace-nowrap">
|
||||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead className="w-[120px] whitespace-nowrap">
|
||||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead className="w-[120px] whitespace-nowrap">
|
||||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead className="w-[180px] whitespace-nowrap">
|
||||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="w-[160px] whitespace-nowrap text-right">
|
||||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -575,21 +575,18 @@ function TenantListPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="whitespace-nowrap">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-[10px] font-mono"
|
className="text-[10px] font-mono"
|
||||||
>
|
>
|
||||||
{t(
|
{tenant.type}
|
||||||
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
|
|
||||||
tenant.type,
|
|
||||||
)}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell className="font-mono text-xs">
|
||||||
{tenant.slug}
|
{tenant.slug}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="whitespace-nowrap">
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
tenant.status === "active"
|
tenant.status === "active"
|
||||||
@@ -605,15 +602,15 @@ function TenantListPage() {
|
|||||||
)}
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="whitespace-nowrap font-medium">
|
||||||
{tenant.memberCount}
|
{tenant.memberCount}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs">
|
<TableCell className="whitespace-nowrap text-xs">
|
||||||
{tenant.updatedAt
|
{tenant.updatedAt
|
||||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||||
: "-"}
|
: "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="whitespace-nowrap text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
buildWorksmobilePasswordManageUrl,
|
||||||
|
canOpenWorksmobilePasswordManage,
|
||||||
canCreateWorksmobileRow,
|
canCreateWorksmobileRow,
|
||||||
|
canSelectWorksmobileRow,
|
||||||
filterWorksmobileComparisonRows,
|
filterWorksmobileComparisonRows,
|
||||||
formatWorksmobileOrgDetails,
|
formatWorksmobileOrgDetails,
|
||||||
formatWorksmobilePersonName,
|
formatWorksmobilePersonName,
|
||||||
|
getDefaultWorksmobileComparisonColumns,
|
||||||
|
getWorksmobileRowSelectionKey,
|
||||||
|
getWorksmobileSelectedActionIds,
|
||||||
getWorksmobileComparisonStatusLabel,
|
getWorksmobileComparisonStatusLabel,
|
||||||
|
isImmutableWorksmobileAccount,
|
||||||
summarizeWorksmobileComparison,
|
summarizeWorksmobileComparison,
|
||||||
userFilterOptions,
|
userFilterOptions,
|
||||||
} from "./TenantWorksmobilePage";
|
} from "./TenantWorksmobilePage";
|
||||||
@@ -69,6 +76,121 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows selection for Baron-only, WORKS-only, and matched rows", () => {
|
||||||
|
expect(
|
||||||
|
canSelectWorksmobileRow({
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "missing_in_worksmobile",
|
||||||
|
baronId: "user-1",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
canSelectWorksmobileRow({
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "missing_in_baron",
|
||||||
|
worksmobileId: "works-user-1",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
canSelectWorksmobileRow({
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "matched",
|
||||||
|
baronId: "user-2",
|
||||||
|
worksmobileId: "works-user-2",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not allow selection for immutable WORKS accounts", () => {
|
||||||
|
expect(
|
||||||
|
isImmutableWorksmobileAccount({
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "missing_in_baron",
|
||||||
|
worksmobileEmail: "cyhan@samaneng.com",
|
||||||
|
worksmobileId: "works-cyhan",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
canSelectWorksmobileRow({
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "missing_in_baron",
|
||||||
|
worksmobileEmail: "CYHAN1@HANMACENG.CO.KR",
|
||||||
|
worksmobileId: "works-cyhan1",
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canSelectWorksmobileRow({
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "missing_in_baron",
|
||||||
|
worksmobileEmail: "normal@samaneng.com",
|
||||||
|
worksmobileId: "works-normal",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not allow password management for immutable WORKS accounts", () => {
|
||||||
|
expect(
|
||||||
|
canOpenWorksmobilePasswordManage(
|
||||||
|
{
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "missing_in_baron",
|
||||||
|
worksmobileEmail: "su-@samaneng.com",
|
||||||
|
worksmobileDomainId: 300285955,
|
||||||
|
worksmobileId: "works-su",
|
||||||
|
},
|
||||||
|
"works-tenant-1",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps row selection keys separate from Baron action ids", () => {
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "missing_in_worksmobile",
|
||||||
|
baronId: "baron-only",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "missing_in_baron",
|
||||||
|
worksmobileId: "works-only",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "matched",
|
||||||
|
baronId: "matched-baron",
|
||||||
|
worksmobileId: "matched-works",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedKeys = rows.map(getWorksmobileRowSelectionKey);
|
||||||
|
|
||||||
|
expect(selectedKeys).toEqual([
|
||||||
|
"USER:baron:baron-only",
|
||||||
|
"USER:works:works-only",
|
||||||
|
"USER:baron:matched-baron",
|
||||||
|
]);
|
||||||
|
expect(getWorksmobileSelectedActionIds(rows, selectedKeys)).toEqual([
|
||||||
|
"baron-only",
|
||||||
|
"matched-baron",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses compact comparison columns by default", () => {
|
||||||
|
expect(getDefaultWorksmobileComparisonColumns()).toEqual({
|
||||||
|
status: true,
|
||||||
|
baronId: false,
|
||||||
|
baron: true,
|
||||||
|
baronOrg: true,
|
||||||
|
worksmobileId: false,
|
||||||
|
externalKey: false,
|
||||||
|
worksmobileDomain: true,
|
||||||
|
worksmobile: true,
|
||||||
|
worksmobileOrg: true,
|
||||||
|
manage: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("filters user comparison rows by selected relationship", () => {
|
it("filters user comparison rows by selected relationship", () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
{
|
{
|
||||||
@@ -149,4 +271,77 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
|||||||
}),
|
}),
|
||||||
).toEqual(["직책 팀장", "직무 기술검토", "조직장"]);
|
).toEqual(["직책 팀장", "직무 기술검토", "조직장"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("builds the WORKS admin password management URL from remote user identifiers", () => {
|
||||||
|
const url = buildWorksmobilePasswordManageUrl({
|
||||||
|
tenantId: " works-tenant-1 ",
|
||||||
|
domainId: 300285955,
|
||||||
|
userIdNo: " works-user-1 ",
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = new URL(url);
|
||||||
|
expect(parsed.origin + parsed.pathname).toBe(
|
||||||
|
"https://auth.worksmobile.com/integrate/password/manage",
|
||||||
|
);
|
||||||
|
expect(parsed.searchParams.get("usage")).toBe("admin");
|
||||||
|
expect(parsed.searchParams.get("targetUserTenantId")).toBe(
|
||||||
|
"works-tenant-1",
|
||||||
|
);
|
||||||
|
expect(parsed.searchParams.get("targetUserDomainId")).toBe("300285955");
|
||||||
|
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
|
||||||
|
expect(parsed.searchParams.get("accessUrl")).toBe(
|
||||||
|
"https://admin.worksmobile.com/assets/self-close.html",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open WORKS password management without required identifiers", () => {
|
||||||
|
const row = {
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "matched",
|
||||||
|
worksmobileDomainId: 300285955,
|
||||||
|
worksmobileId: "works-user-1",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(canOpenWorksmobilePasswordManage(row, "works-tenant-1")).toBe(true);
|
||||||
|
expect(canOpenWorksmobilePasswordManage(row, undefined)).toBe(false);
|
||||||
|
expect(
|
||||||
|
canOpenWorksmobilePasswordManage(
|
||||||
|
{ ...row, worksmobileDomainId: undefined },
|
||||||
|
"works-tenant-1",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canOpenWorksmobilePasswordManage(
|
||||||
|
{ ...row, worksmobileId: undefined },
|
||||||
|
"works-tenant-1",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canOpenWorksmobilePasswordManage(
|
||||||
|
{ ...row, resourceType: "GROUP" },
|
||||||
|
"works-tenant-1",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
buildWorksmobilePasswordManageUrl({
|
||||||
|
tenantId: "works-tenant-1",
|
||||||
|
domainId: 0,
|
||||||
|
userIdNo: "works-user-1",
|
||||||
|
}),
|
||||||
|
).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows WORKS password management for WORKS-only user rows", () => {
|
||||||
|
expect(
|
||||||
|
canOpenWorksmobilePasswordManage(
|
||||||
|
{
|
||||||
|
resourceType: "USER",
|
||||||
|
status: "missing_in_baron",
|
||||||
|
worksmobileDomainId: 300285955,
|
||||||
|
worksmobileId: "works-user-1",
|
||||||
|
},
|
||||||
|
"works-tenant-1",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { Download, RefreshCw, RotateCcw } from "lucide-react";
|
import {
|
||||||
|
Download,
|
||||||
|
KeyRound,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Settings2,
|
||||||
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Badge } from "../../../components/ui/badge";
|
import { Badge } from "../../../components/ui/badge";
|
||||||
@@ -12,6 +18,15 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
import { Checkbox } from "../../../components/ui/checkbox";
|
import { Checkbox } from "../../../components/ui/checkbox";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../../../components/ui/dialog";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -47,8 +62,18 @@ export function TenantWorksmobilePage() {
|
|||||||
const [userFilters, setUserFilters] = React.useState<
|
const [userFilters, setUserFilters] = React.useState<
|
||||||
WorksmobileComparisonFilter[]
|
WorksmobileComparisonFilter[]
|
||||||
>(["baron_only", "works_only"]);
|
>(["baron_only", "works_only"]);
|
||||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
const [selectedUserRowKeys, setSelectedUserRowKeys] = React.useState<
|
||||||
const [selectedGroupIds, setSelectedGroupIds] = React.useState<string[]>([]);
|
string[]
|
||||||
|
>([]);
|
||||||
|
const [selectedGroupRowKeys, setSelectedGroupRowKeys] = React.useState<
|
||||||
|
string[]
|
||||||
|
>([]);
|
||||||
|
const [userVisibleColumns, setUserVisibleColumns] = React.useState(
|
||||||
|
getDefaultWorksmobileComparisonColumns,
|
||||||
|
);
|
||||||
|
const [groupVisibleColumns, setGroupVisibleColumns] = React.useState(
|
||||||
|
getDefaultWorksmobileComparisonColumns,
|
||||||
|
);
|
||||||
|
|
||||||
const overviewQuery = useQuery({
|
const overviewQuery = useQuery({
|
||||||
queryKey: ["worksmobile", tenantId],
|
queryKey: ["worksmobile", tenantId],
|
||||||
@@ -152,9 +177,9 @@ export function TenantWorksmobilePage() {
|
|||||||
},
|
},
|
||||||
onSuccess: ({ resourceKind, count }) => {
|
onSuccess: ({ resourceKind, count }) => {
|
||||||
if (resourceKind === "users") {
|
if (resourceKind === "users") {
|
||||||
setSelectedUserIds([]);
|
setSelectedUserRowKeys([]);
|
||||||
} else {
|
} else {
|
||||||
setSelectedGroupIds([]);
|
setSelectedGroupRowKeys([]);
|
||||||
}
|
}
|
||||||
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
||||||
description: `${count}건`,
|
description: `${count}건`,
|
||||||
@@ -198,7 +223,7 @@ export function TenantWorksmobilePage() {
|
|||||||
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
|
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="min-w-0 max-w-full space-y-6">
|
||||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold">
|
<h3 className="text-xl font-semibold">
|
||||||
@@ -247,7 +272,7 @@ export function TenantWorksmobilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Card>
|
<Card className="min-w-0 overflow-hidden">
|
||||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
@@ -261,7 +286,7 @@ export function TenantWorksmobilePage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="min-w-0 space-y-4">
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
<ComparisonSummary
|
<ComparisonSummary
|
||||||
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
|
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
|
||||||
@@ -275,41 +300,26 @@ export function TenantWorksmobilePage() {
|
|||||||
summary={groupSummary}
|
summary={groupSummary}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{userFilterOptions.map((option) => (
|
|
||||||
<Button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant={
|
|
||||||
userFilters.includes(option.value) ? "default" : "outline"
|
|
||||||
}
|
|
||||||
aria-pressed={userFilters.includes(option.value)}
|
|
||||||
onClick={() => {
|
|
||||||
setUserFilters((current) =>
|
|
||||||
current.includes(option.value)
|
|
||||||
? current.filter((value) => value !== option.value)
|
|
||||||
: [...current, option.value],
|
|
||||||
);
|
|
||||||
setSelectedUserIds([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<ComparisonTable
|
<ComparisonTable
|
||||||
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
|
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
|
||||||
rows={filteredComparisonUsers}
|
rows={filteredComparisonUsers}
|
||||||
loading={comparisonQuery.isLoading}
|
loading={comparisonQuery.isLoading}
|
||||||
selectedIds={selectedUserIds}
|
selectedKeys={selectedUserRowKeys}
|
||||||
onSelectedIdsChange={setSelectedUserIds}
|
onSelectedKeysChange={setSelectedUserRowKeys}
|
||||||
|
filters={userFilters}
|
||||||
|
onFiltersChange={(nextFilters) => {
|
||||||
|
setUserFilters(nextFilters);
|
||||||
|
setSelectedUserRowKeys([]);
|
||||||
|
}}
|
||||||
|
visibleColumns={userVisibleColumns}
|
||||||
|
onVisibleColumnsChange={setUserVisibleColumns}
|
||||||
|
passwordManageTenantId={overview?.config.adminTenantId}
|
||||||
actionLabel="선택 구성원 WORKS에 생성"
|
actionLabel="선택 구성원 WORKS에 생성"
|
||||||
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
|
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
|
||||||
onCreateSelected={() =>
|
onCreateSelected={(ids) =>
|
||||||
createSelectedMutation.mutate({
|
createSelectedMutation.mutate({
|
||||||
resourceKind: "users",
|
resourceKind: "users",
|
||||||
ids: selectedUserIds,
|
ids,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -320,16 +330,21 @@ export function TenantWorksmobilePage() {
|
|||||||
)}
|
)}
|
||||||
rows={comparisonGroups}
|
rows={comparisonGroups}
|
||||||
loading={comparisonQuery.isLoading}
|
loading={comparisonQuery.isLoading}
|
||||||
selectedIds={selectedGroupIds}
|
selectedKeys={selectedGroupRowKeys}
|
||||||
onSelectedIdsChange={setSelectedGroupIds}
|
onSelectedKeysChange={setSelectedGroupRowKeys}
|
||||||
|
filters={undefined}
|
||||||
|
onFiltersChange={undefined}
|
||||||
|
visibleColumns={groupVisibleColumns}
|
||||||
|
onVisibleColumnsChange={setGroupVisibleColumns}
|
||||||
|
passwordManageTenantId={undefined}
|
||||||
actionLabel="선택 조직 WORKS에 생성"
|
actionLabel="선택 조직 WORKS에 생성"
|
||||||
actionDisabled={
|
actionDisabled={
|
||||||
isCreatingGroups || createSelectedMutation.isPending
|
isCreatingGroups || createSelectedMutation.isPending
|
||||||
}
|
}
|
||||||
onCreateSelected={() =>
|
onCreateSelected={(ids) =>
|
||||||
createSelectedMutation.mutate({
|
createSelectedMutation.mutate({
|
||||||
resourceKind: "groups",
|
resourceKind: "groups",
|
||||||
ids: selectedGroupIds,
|
ids,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -435,6 +450,54 @@ export type WorksmobileComparisonSummary = {
|
|||||||
missingExternalKey: number;
|
missingExternalKey: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WorksmobileComparisonColumnKey =
|
||||||
|
| "status"
|
||||||
|
| "baronId"
|
||||||
|
| "baron"
|
||||||
|
| "baronOrg"
|
||||||
|
| "worksmobileId"
|
||||||
|
| "externalKey"
|
||||||
|
| "worksmobileDomain"
|
||||||
|
| "worksmobile"
|
||||||
|
| "worksmobileOrg"
|
||||||
|
| "manage";
|
||||||
|
|
||||||
|
export type WorksmobileComparisonColumnVisibility = Record<
|
||||||
|
WorksmobileComparisonColumnKey,
|
||||||
|
boolean
|
||||||
|
>;
|
||||||
|
|
||||||
|
const worksmobileComparisonColumnOptions: Array<{
|
||||||
|
key: WorksmobileComparisonColumnKey;
|
||||||
|
label: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "status", label: "상태" },
|
||||||
|
{ key: "baronId", label: "Baron ID" },
|
||||||
|
{ key: "baron", label: "Baron" },
|
||||||
|
{ key: "baronOrg", label: "Baron 조직" },
|
||||||
|
{ key: "worksmobileId", label: "WORKS ID" },
|
||||||
|
{ key: "externalKey", label: "external_key" },
|
||||||
|
{ key: "worksmobileDomain", label: "WORKS 도메인" },
|
||||||
|
{ key: "worksmobile", label: "WORKS" },
|
||||||
|
{ key: "worksmobileOrg", label: "WORKS 조직" },
|
||||||
|
{ key: "manage", label: "관리" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
|
||||||
|
return {
|
||||||
|
status: true,
|
||||||
|
baronId: false,
|
||||||
|
baron: true,
|
||||||
|
baronOrg: true,
|
||||||
|
worksmobileId: false,
|
||||||
|
externalKey: false,
|
||||||
|
worksmobileDomain: true,
|
||||||
|
worksmobile: true,
|
||||||
|
worksmobileOrg: true,
|
||||||
|
manage: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function summarizeWorksmobileComparison(
|
export function summarizeWorksmobileComparison(
|
||||||
rows: WorksmobileComparisonItem[],
|
rows: WorksmobileComparisonItem[],
|
||||||
): WorksmobileComparisonSummary {
|
): WorksmobileComparisonSummary {
|
||||||
@@ -480,6 +543,54 @@ export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
|
|||||||
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
|
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const immutableWorksmobileAccountEmails = new Set([
|
||||||
|
"cyhan@samaneng.com",
|
||||||
|
"cyhan1@hanmaceng.co.kr",
|
||||||
|
"cyhan2@baroncs.co.kr",
|
||||||
|
"cyhan3@brsw.kr",
|
||||||
|
"su-@samaneng.com",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) {
|
||||||
|
return (
|
||||||
|
row.resourceType === "USER" &&
|
||||||
|
immutableWorksmobileAccountEmails.has(
|
||||||
|
row.worksmobileEmail?.trim().toLowerCase() ?? "",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) {
|
||||||
|
if (row.baronId) {
|
||||||
|
return `${row.resourceType}:baron:${row.baronId}`;
|
||||||
|
}
|
||||||
|
if (row.worksmobileId) {
|
||||||
|
return `${row.resourceType}:works:${row.worksmobileId}`;
|
||||||
|
}
|
||||||
|
if (row.externalKey) {
|
||||||
|
return `${row.resourceType}:external:${row.externalKey}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) {
|
||||||
|
return (
|
||||||
|
Boolean(getWorksmobileRowSelectionKey(row)) &&
|
||||||
|
!isImmutableWorksmobileAccount(row)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorksmobileSelectedActionIds(
|
||||||
|
rows: WorksmobileComparisonItem[],
|
||||||
|
selectedKeys: string[],
|
||||||
|
) {
|
||||||
|
const selected = new Set(selectedKeys);
|
||||||
|
return rows
|
||||||
|
.filter((row) => selected.has(getWorksmobileRowSelectionKey(row)))
|
||||||
|
.map((row) => row.baronId)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
}
|
||||||
|
|
||||||
export function filterWorksmobileComparisonRows(
|
export function filterWorksmobileComparisonRows(
|
||||||
rows: WorksmobileComparisonItem[],
|
rows: WorksmobileComparisonItem[],
|
||||||
filters: WorksmobileComparisonFilter[],
|
filters: WorksmobileComparisonFilter[],
|
||||||
@@ -519,13 +630,56 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
|
|||||||
return details;
|
return details;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildWorksmobilePasswordManageUrl({
|
||||||
|
tenantId,
|
||||||
|
domainId,
|
||||||
|
userIdNo,
|
||||||
|
}: {
|
||||||
|
tenantId?: string;
|
||||||
|
domainId?: number;
|
||||||
|
userIdNo?: string;
|
||||||
|
}) {
|
||||||
|
const normalizedTenantId = tenantId?.trim();
|
||||||
|
const normalizedUserIdNo = userIdNo?.trim();
|
||||||
|
if (!normalizedTenantId || !domainId || domainId <= 0 || !normalizedUserIdNo) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
|
||||||
|
url.searchParams.set("usage", "admin");
|
||||||
|
url.searchParams.set("targetUserTenantId", normalizedTenantId);
|
||||||
|
url.searchParams.set("targetUserDomainId", String(domainId));
|
||||||
|
url.searchParams.set("targetUserIdNo", normalizedUserIdNo);
|
||||||
|
url.searchParams.set(
|
||||||
|
"accessUrl",
|
||||||
|
"https://admin.worksmobile.com/assets/self-close.html",
|
||||||
|
);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canOpenWorksmobilePasswordManage(
|
||||||
|
row: WorksmobileComparisonItem,
|
||||||
|
tenantId?: string,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
row.resourceType === "USER" &&
|
||||||
|
!isImmutableWorksmobileAccount(row) &&
|
||||||
|
Boolean(
|
||||||
|
buildWorksmobilePasswordManageUrl({
|
||||||
|
tenantId,
|
||||||
|
domainId: row.worksmobileDomainId,
|
||||||
|
userIdNo: row.worksmobileId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const userFilterOptions: Array<{
|
export const userFilterOptions: Array<{
|
||||||
value: WorksmobileComparisonFilter;
|
value: WorksmobileComparisonFilter;
|
||||||
label: string;
|
label: string;
|
||||||
}> = [
|
}> = [
|
||||||
{ value: "baron_only", label: "Baron에만 있음" },
|
{ value: "baron_only", label: "바론에만 있음" },
|
||||||
{ value: "works_only", label: "WORKS에만 있음" },
|
{ value: "works_only", label: "웍스에만 있음" },
|
||||||
{ value: "matched", label: "양쪽에 다 있음" },
|
{ value: "matched", label: "양쪽 다 있음" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
|
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
|
||||||
@@ -605,8 +759,13 @@ function ComparisonTable({
|
|||||||
title,
|
title,
|
||||||
rows,
|
rows,
|
||||||
loading,
|
loading,
|
||||||
selectedIds,
|
selectedKeys,
|
||||||
onSelectedIdsChange,
|
onSelectedKeysChange,
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
visibleColumns,
|
||||||
|
onVisibleColumnsChange,
|
||||||
|
passwordManageTenantId,
|
||||||
actionLabel,
|
actionLabel,
|
||||||
actionDisabled,
|
actionDisabled,
|
||||||
onCreateSelected,
|
onCreateSelected,
|
||||||
@@ -614,101 +773,238 @@ function ComparisonTable({
|
|||||||
title: string;
|
title: string;
|
||||||
rows: WorksmobileComparisonItem[];
|
rows: WorksmobileComparisonItem[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
selectedIds: string[];
|
selectedKeys: string[];
|
||||||
onSelectedIdsChange: (ids: string[]) => void;
|
onSelectedKeysChange: (ids: string[]) => void;
|
||||||
|
filters?: WorksmobileComparisonFilter[];
|
||||||
|
onFiltersChange?: (filters: WorksmobileComparisonFilter[]) => void;
|
||||||
|
visibleColumns: WorksmobileComparisonColumnVisibility;
|
||||||
|
onVisibleColumnsChange: React.Dispatch<
|
||||||
|
React.SetStateAction<WorksmobileComparisonColumnVisibility>
|
||||||
|
>;
|
||||||
|
passwordManageTenantId?: string;
|
||||||
actionLabel: string;
|
actionLabel: string;
|
||||||
actionDisabled: boolean;
|
actionDisabled: boolean;
|
||||||
onCreateSelected: () => void;
|
onCreateSelected: (ids: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const creatableIds = rows
|
const selectableKeys = rows
|
||||||
.filter(canCreateWorksmobileRow)
|
.filter(canSelectWorksmobileRow)
|
||||||
.map((row) => row.baronId)
|
.map(getWorksmobileRowSelectionKey)
|
||||||
.filter((id): id is string => Boolean(id));
|
.filter(Boolean);
|
||||||
const allCreatableSelected =
|
const selectedActionIds = getWorksmobileSelectedActionIds(
|
||||||
creatableIds.length > 0 &&
|
rows,
|
||||||
creatableIds.every((id) => selectedIds.includes(id));
|
selectedKeys,
|
||||||
|
);
|
||||||
|
const allSelectableSelected =
|
||||||
|
selectableKeys.length > 0 &&
|
||||||
|
selectableKeys.every((key) => selectedKeys.includes(key));
|
||||||
|
const visibleColumnCount = worksmobileComparisonColumnOptions.filter(
|
||||||
|
(column) => visibleColumns[column.key] !== false,
|
||||||
|
).length;
|
||||||
|
const tableColSpan = visibleColumnCount + 1;
|
||||||
|
|
||||||
const toggleAll = (checked: boolean | "indeterminate") => {
|
const toggleAll = (checked: boolean | "indeterminate") => {
|
||||||
onSelectedIdsChange(checked === true ? creatableIds : []);
|
onSelectedKeysChange(checked === true ? selectableKeys : []);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleRow = (
|
const toggleRow = (
|
||||||
id: string | undefined,
|
row: WorksmobileComparisonItem,
|
||||||
checked: boolean | "indeterminate",
|
checked: boolean | "indeterminate",
|
||||||
) => {
|
) => {
|
||||||
if (!id) {
|
const key = getWorksmobileRowSelectionKey(row);
|
||||||
|
if (!key) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (checked === true) {
|
if (checked === true) {
|
||||||
onSelectedIdsChange([...new Set([...selectedIds, id])]);
|
onSelectedKeysChange([...new Set([...selectedKeys, key])]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSelectedIdsChange(selectedIds.filter((selectedId) => selectedId !== id));
|
onSelectedKeysChange(
|
||||||
|
selectedKeys.filter((selectedKey) => selectedKey !== key),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPasswordManage = (row: WorksmobileComparisonItem) => {
|
||||||
|
const url = buildWorksmobilePasswordManageUrl({
|
||||||
|
tenantId: passwordManageTenantId,
|
||||||
|
domainId: row.worksmobileDomainId,
|
||||||
|
userIdNo: row.worksmobileId,
|
||||||
|
});
|
||||||
|
if (!url) return;
|
||||||
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
|
||||||
|
onVisibleColumnsChange((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: current[key] === false,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isColumnVisible = (key: WorksmobileComparisonColumnKey) =>
|
||||||
|
visibleColumns[key] !== false;
|
||||||
|
|
||||||
|
const toggleFilter = (filter: WorksmobileComparisonFilter) => {
|
||||||
|
if (!filters || !onFiltersChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFiltersChange(
|
||||||
|
filters.includes(filter)
|
||||||
|
? filters.filter((value) => value !== filter)
|
||||||
|
: [...filters, filter],
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h4 className="text-sm font-medium">{title}</h4>
|
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||||
|
<h4 className="text-lg font-semibold leading-none">{title}</h4>
|
||||||
|
{filters && onFiltersChange && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{userFilterOptions.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={
|
||||||
|
filters.includes(option.value) ? "default" : "outline"
|
||||||
|
}
|
||||||
|
aria-pressed={filters.includes(option.value)}
|
||||||
|
onClick={() => toggleFilter(option.value)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="outline" size="sm">
|
||||||
|
<Settings2 size={16} />
|
||||||
|
컬럼 설정
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title} 컬럼 설정</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
이 테이블에 표시할 비교 컬럼을 선택하세요.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-2 py-2">
|
||||||
|
{worksmobileComparisonColumnOptions.map((column) => (
|
||||||
|
<label
|
||||||
|
key={column.key}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
|
checked={isColumnVisible(column.key)}
|
||||||
|
onChange={() => toggleColumn(column.key)}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{column.label}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button type="button" variant="secondary">
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onCreateSelected}
|
onClick={() => onCreateSelected(selectedActionIds)}
|
||||||
disabled={selectedIds.length === 0 || actionDisabled}
|
disabled={selectedActionIds.length === 0 || actionDisabled}
|
||||||
>
|
>
|
||||||
{actionLabel}
|
{actionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto">
|
</div>
|
||||||
<Table>
|
<div className="w-full max-w-full overflow-x-auto rounded-md border">
|
||||||
|
<Table className="min-w-max">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-10 whitespace-nowrap">
|
<TableHead className="w-10 whitespace-nowrap">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={`${title} 전체 선택`}
|
aria-label={`${title} 전체 선택`}
|
||||||
checked={allCreatableSelected}
|
checked={allSelectableSelected}
|
||||||
disabled={creatableIds.length === 0}
|
disabled={selectableKeys.length === 0}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
{isColumnVisible("status") && (
|
||||||
<TableHead className="w-24 whitespace-nowrap">상태</TableHead>
|
<TableHead className="w-24 whitespace-nowrap">상태</TableHead>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("baronId") && (
|
||||||
<TableHead className="min-w-44 whitespace-nowrap">
|
<TableHead className="min-w-44 whitespace-nowrap">
|
||||||
Baron ID
|
Baron ID
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("baron") && (
|
||||||
<TableHead className="min-w-44 whitespace-nowrap">
|
<TableHead className="min-w-44 whitespace-nowrap">
|
||||||
Baron
|
Baron
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("baronOrg") && (
|
||||||
<TableHead className="min-w-44 whitespace-nowrap">
|
<TableHead className="min-w-44 whitespace-nowrap">
|
||||||
Baron 조직
|
Baron 조직
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("worksmobileId") && (
|
||||||
<TableHead className="min-w-44 whitespace-nowrap">
|
<TableHead className="min-w-44 whitespace-nowrap">
|
||||||
WORKS ID
|
WORKS ID
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("externalKey") && (
|
||||||
<TableHead className="min-w-40 whitespace-nowrap">
|
<TableHead className="min-w-40 whitespace-nowrap">
|
||||||
external_key
|
external_key
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("worksmobileDomain") && (
|
||||||
<TableHead className="min-w-44 whitespace-nowrap">
|
<TableHead className="min-w-44 whitespace-nowrap">
|
||||||
WORKS 도메인
|
WORKS 도메인
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("worksmobile") && (
|
||||||
<TableHead className="min-w-44 whitespace-nowrap">
|
<TableHead className="min-w-44 whitespace-nowrap">
|
||||||
WORKS
|
WORKS
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("worksmobileOrg") && (
|
||||||
<TableHead className="min-w-52 whitespace-nowrap">
|
<TableHead className="min-w-52 whitespace-nowrap">
|
||||||
WORKS 조직
|
WORKS 조직
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("manage") && (
|
||||||
|
<TableHead className="w-14 whitespace-nowrap">관리</TableHead>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{loading && (
|
{loading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={10} className="text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={tableColSpan}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
불러오는 중...
|
불러오는 중...
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{!loading && rows.length === 0 && (
|
{!loading && rows.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={10} className="text-muted-foreground">
|
<TableCell
|
||||||
|
colSpan={tableColSpan}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
표시할 차이가 없습니다.
|
표시할 차이가 없습니다.
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -720,26 +1016,31 @@ function ComparisonTable({
|
|||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
aria-label={`${row.baronName ?? row.baronId ?? row.worksmobileName ?? row.worksmobileId ?? "row"} 선택`}
|
aria-label={`${row.baronName ?? row.baronId ?? row.worksmobileName ?? row.worksmobileId ?? "row"} 선택`}
|
||||||
checked={Boolean(
|
checked={selectedKeys.includes(
|
||||||
row.baronId && selectedIds.includes(row.baronId),
|
getWorksmobileRowSelectionKey(row),
|
||||||
)}
|
)}
|
||||||
disabled={!canCreateWorksmobileRow(row)}
|
disabled={!canSelectWorksmobileRow(row)}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => toggleRow(row, checked)}
|
||||||
toggleRow(row.baronId, checked)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
{isColumnVisible("status") && (
|
||||||
<TableCell className="whitespace-nowrap">
|
<TableCell className="whitespace-nowrap">
|
||||||
<Badge
|
<Badge
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap"
|
||||||
variant={getWorksmobileComparisonStatusVariant(row.status)}
|
variant={getWorksmobileComparisonStatusVariant(
|
||||||
|
row.status,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{getWorksmobileComparisonStatusLabel(row.status)}
|
{getWorksmobileComparisonStatusLabel(row.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("baronId") && (
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell className="font-mono text-xs">
|
||||||
{row.baronId ?? "-"}
|
{row.baronId ?? "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("baron") && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div>{row.baronName ?? "-"}</div>
|
<div>{row.baronName ?? "-"}</div>
|
||||||
@@ -748,6 +1049,8 @@ function ComparisonTable({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("baronOrg") && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<ComparisonOrgCell
|
<ComparisonOrgCell
|
||||||
name={
|
name={
|
||||||
@@ -762,18 +1065,26 @@ function ComparisonTable({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("worksmobileId") && (
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell className="font-mono text-xs">
|
||||||
{row.worksmobileId ?? "-"}
|
{row.worksmobileId ?? "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("externalKey") && (
|
||||||
<TableCell className="font-mono text-xs">
|
<TableCell className="font-mono text-xs">
|
||||||
{row.externalKey ?? "-"}
|
{row.externalKey ?? "-"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("worksmobileDomain") && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<ComparisonDomainCell
|
<ComparisonDomainCell
|
||||||
name={row.worksmobileDomainName}
|
name={row.worksmobileDomainName}
|
||||||
id={row.worksmobileDomainId}
|
id={row.worksmobileDomainId}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("worksmobile") && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div>{formatWorksmobilePersonName(row) || "-"}</div>
|
<div>{formatWorksmobilePersonName(row) || "-"}</div>
|
||||||
@@ -782,6 +1093,8 @@ function ComparisonTable({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("worksmobileOrg") && (
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<ComparisonOrgCell
|
<ComparisonOrgCell
|
||||||
name={
|
name={
|
||||||
@@ -801,6 +1114,28 @@ function ComparisonTable({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{isColumnVisible("manage") && (
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{row.resourceType === "USER" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 관리`}
|
||||||
|
disabled={
|
||||||
|
!canOpenWorksmobilePasswordManage(
|
||||||
|
row,
|
||||||
|
passwordManageTenantId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onClick={() => openPasswordManage(row)}
|
||||||
|
>
|
||||||
|
<KeyRound size={16} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -508,7 +508,9 @@ export type WorksmobileOverview = {
|
|||||||
tenant: TenantSummary;
|
tenant: TenantSummary;
|
||||||
config: {
|
config: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
domainMappings?: Record<string, number>;
|
||||||
tokenConfigured: boolean;
|
tokenConfigured: boolean;
|
||||||
|
adminTenantId?: string;
|
||||||
};
|
};
|
||||||
recentJobs: WorksmobileOutboxItem[];
|
recentJobs: WorksmobileOutboxItem[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ test.describe("Tenants Management", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should list tenants", async ({ page }) => {
|
test("should list tenants", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 900, height: 700 });
|
||||||
const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea";
|
const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea";
|
||||||
|
|
||||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||||
@@ -93,6 +94,15 @@ test.describe("Tenants Management", () => {
|
|||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
await expect(page.locator("table")).toContainText(internalTenantId);
|
await expect(page.locator("table")).toContainText(internalTenantId);
|
||||||
|
await expect(page.locator("table")).toContainText("COMPANY");
|
||||||
|
await expect(page.locator("table")).not.toContainText("일반 기업");
|
||||||
|
|
||||||
|
const headerWhiteSpace = await page
|
||||||
|
.locator("table thead th")
|
||||||
|
.evaluateAll((headers) =>
|
||||||
|
headers.map((header) => window.getComputedStyle(header).whiteSpace),
|
||||||
|
);
|
||||||
|
expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should create a new tenant", async ({ page }) => {
|
test("should create a new tenant", async ({ page }) => {
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
await expect(page.getByText("domainMappings")).not.toBeVisible();
|
await expect(page.getByText("domainMappings")).not.toBeVisible();
|
||||||
await expect(page.getByText("SCIM token")).not.toBeVisible();
|
await expect(page.getByText("SCIM token")).not.toBeVisible();
|
||||||
await expect(page.getByText("김누락")).toBeVisible();
|
await expect(page.getByText("김누락")).toBeVisible();
|
||||||
await expect(page.getByText("박웍스")).not.toBeVisible();
|
await expect(page.getByText("박웍스")).toBeVisible();
|
||||||
await expect(page.getByText("WORKS 전용 조직")).toBeVisible();
|
await expect(page.getByText("WORKS 전용 조직")).toBeVisible();
|
||||||
await expect(page.getByText("기술본부", { exact: true })).toBeVisible();
|
await expect(page.getByText("기술본부", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
|
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
|
||||||
@@ -208,47 +208,47 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
|
|
||||||
const filterButtons = page
|
const filterButtons = page
|
||||||
.getByRole("button", {
|
.getByRole("button", {
|
||||||
name: /Baron에만 있음|WORKS에만 있음|양쪽에 다 있음/,
|
name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/,
|
||||||
})
|
})
|
||||||
.allTextContents();
|
.allTextContents();
|
||||||
await expect.poll(() => filterButtons).toEqual([
|
await expect.poll(() => filterButtons).toEqual([
|
||||||
"Baron에만 있음",
|
"바론에만 있음",
|
||||||
"WORKS에만 있음",
|
"웍스에만 있음",
|
||||||
"양쪽에 다 있음",
|
"양쪽 다 있음",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await page.getByRole("button", { name: "WORKS에만 있음" }).click();
|
await page.getByRole("button", { name: "웍스에만 있음" }).click();
|
||||||
await expect(page.getByText("박웍스")).toBeVisible();
|
await expect(page.getByText("박웍스")).not.toBeVisible();
|
||||||
await expect(page.getByText("김누락")).toBeVisible();
|
await expect(page.getByText("김누락")).toBeVisible();
|
||||||
await expect(page.getByText("홍길동")).not.toBeVisible();
|
await expect(page.getByText("홍길동")).not.toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "양쪽에 다 있음" }).click();
|
await page.getByRole("button", { name: "양쪽 다 있음" }).click();
|
||||||
await expect(page.getByText("홍길동")).toHaveCount(2);
|
await expect(page.getByText("홍길동")).toHaveCount(2);
|
||||||
await expect(page.getByText("기술기획", { exact: true })).toBeVisible();
|
await expect(page.getByText("기술기획", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("team-tech", { exact: true })).toBeVisible();
|
await expect(page.getByText("team-tech", { exact: true })).toBeVisible();
|
||||||
await expect(page.getByText("WORKS 기술기획")).toBeVisible();
|
await expect(page.getByText("WORKS 기술기획")).toBeVisible();
|
||||||
await expect(page.getByText("works-team-tech")).toBeVisible();
|
await expect(page.getByText("works-team-tech")).toBeVisible();
|
||||||
await expect(page.getByText("김누락")).toBeVisible();
|
await expect(page.getByText("김누락")).toBeVisible();
|
||||||
await expect(page.getByText("박웍스")).toBeVisible();
|
await expect(page.getByText("박웍스")).not.toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Baron에만 있음" }).click();
|
await page.getByRole("button", { name: "바론에만 있음" }).click();
|
||||||
await expect(page.getByText("홍길동")).toHaveCount(2);
|
|
||||||
await expect(page.getByText("김누락")).not.toBeVisible();
|
|
||||||
await expect(page.getByText("박웍스")).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "WORKS에만 있음" }).click();
|
|
||||||
await expect(page.getByText("홍길동")).toHaveCount(2);
|
await expect(page.getByText("홍길동")).toHaveCount(2);
|
||||||
await expect(page.getByText("김누락")).not.toBeVisible();
|
await expect(page.getByText("김누락")).not.toBeVisible();
|
||||||
await expect(page.getByText("박웍스")).not.toBeVisible();
|
await expect(page.getByText("박웍스")).not.toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "양쪽에 다 있음" }).click();
|
await page.getByRole("button", { name: "웍스에만 있음" }).click();
|
||||||
|
await expect(page.getByText("홍길동")).toHaveCount(2);
|
||||||
|
await expect(page.getByText("김누락")).not.toBeVisible();
|
||||||
|
await expect(page.getByText("박웍스")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "양쪽 다 있음" }).click();
|
||||||
|
await expect(page.getByText("김누락")).not.toBeVisible();
|
||||||
|
await expect(page.getByText("박웍스")).toBeVisible();
|
||||||
|
await expect(page.getByText("홍길동")).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "바론에만 있음" }).click();
|
||||||
await expect(page.getByText("김누락")).toBeVisible();
|
await expect(page.getByText("김누락")).toBeVisible();
|
||||||
await expect(page.getByText("박웍스")).toBeVisible();
|
await expect(page.getByText("박웍스")).toBeVisible();
|
||||||
await expect(page.getByText("홍길동")).toHaveCount(2);
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Baron에만 있음" }).click();
|
|
||||||
await expect(page.getByText("김누락")).toBeVisible();
|
|
||||||
await expect(page.getByText("박웍스")).not.toBeVisible();
|
|
||||||
await expect(page.getByText("홍길동")).not.toBeVisible();
|
await expect(page.getByText("홍길동")).not.toBeVisible();
|
||||||
|
|
||||||
await page
|
await page
|
||||||
@@ -360,4 +360,148 @@ test.describe("Worksmobile tenant management", () => {
|
|||||||
page.getByText(/WORKS API rejected user creation/),
|
page.getByText(/WORKS API rejected user creation/),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("keeps wide comparison columns inside table scroll and blocks immutable WORKS accounts", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 900, height: 700 });
|
||||||
|
|
||||||
|
await page.route("**/api/v1/**", async (route) => {
|
||||||
|
const url = new URL(route.request().url());
|
||||||
|
const method = route.request().method();
|
||||||
|
const headers = { "Access-Control-Allow-Origin": "*" };
|
||||||
|
|
||||||
|
if (url.pathname.endsWith("/user/me")) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: { id: "admin-user", name: "Admin", role: "super_admin" },
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith("/admin/tenants/hanmac-family-id") &&
|
||||||
|
method === "GET"
|
||||||
|
) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "hanmac-family-id",
|
||||||
|
name: "한맥 가족",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith("/admin/tenants/hanmac-family-id/worksmobile") &&
|
||||||
|
method === "GET"
|
||||||
|
) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
tenant: {
|
||||||
|
id: "hanmac-family-id",
|
||||||
|
name: "한맥 가족",
|
||||||
|
slug: "hanmac-family",
|
||||||
|
parentId: null,
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
adminTenantId: "works-tenant-1",
|
||||||
|
},
|
||||||
|
recentJobs: [],
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
url.pathname.endsWith(
|
||||||
|
"/admin/tenants/hanmac-family-id/worksmobile/comparison",
|
||||||
|
) &&
|
||||||
|
method === "GET"
|
||||||
|
) {
|
||||||
|
return route.fulfill({
|
||||||
|
json: {
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
resourceType: "USER",
|
||||||
|
worksmobileId:
|
||||||
|
"works-user-with-extra-long-identifier-for-scroll-check",
|
||||||
|
externalKey: "external-key-with-extra-long-identifier",
|
||||||
|
worksmobileName: "긴 WORKS 사용자",
|
||||||
|
worksmobileEmail:
|
||||||
|
"long-works-user-name-for-scroll@samaneng.com",
|
||||||
|
worksmobileDomainId: 300285955,
|
||||||
|
worksmobileDomainName: "samaneng.com",
|
||||||
|
worksmobilePrimaryOrgId:
|
||||||
|
"works-primary-org-with-extra-long-identifier",
|
||||||
|
worksmobilePrimaryOrgName: "긴 WORKS 조직",
|
||||||
|
status: "missing_in_baron",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resourceType: "USER",
|
||||||
|
worksmobileId: "works-cyhan",
|
||||||
|
worksmobileName: "변경 불가 계정",
|
||||||
|
worksmobileEmail: "cyhan@samaneng.com",
|
||||||
|
worksmobileDomainId: 300285955,
|
||||||
|
worksmobileDomainName: "samaneng.com",
|
||||||
|
status: "missing_in_baron",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return route.fulfill({ json: { items: [], total: 0 }, headers });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/tenants/hanmac-family-id/worksmobile");
|
||||||
|
await expect(page.getByText("긴 WORKS 사용자")).toBeVisible();
|
||||||
|
|
||||||
|
const userColumnButton = page
|
||||||
|
.getByRole("heading", { name: "구성원" })
|
||||||
|
.locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]")
|
||||||
|
.getByRole("button", { name: "컬럼 설정" });
|
||||||
|
await userColumnButton.click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog", { name: "구성원 컬럼 설정" });
|
||||||
|
await dialog.getByLabel("Baron ID").check();
|
||||||
|
await dialog.getByLabel("WORKS ID").check();
|
||||||
|
await dialog.getByLabel("external_key").check();
|
||||||
|
await dialog.getByRole("button", { name: "닫기" }).click();
|
||||||
|
|
||||||
|
const pageOverflow = await page.evaluate(() => ({
|
||||||
|
documentScrollWidth: document.documentElement.scrollWidth,
|
||||||
|
bodyScrollWidth: document.body.scrollWidth,
|
||||||
|
viewportWidth: document.documentElement.clientWidth,
|
||||||
|
}));
|
||||||
|
expect(
|
||||||
|
Math.max(pageOverflow.documentScrollWidth, pageOverflow.bodyScrollWidth),
|
||||||
|
).toBeLessThanOrEqual(pageOverflow.viewportWidth + 1);
|
||||||
|
|
||||||
|
const userTableScroll = await page.locator("table").first().evaluate(
|
||||||
|
(table) => {
|
||||||
|
const container = table.parentElement?.parentElement as HTMLElement;
|
||||||
|
return {
|
||||||
|
clientWidth: container.clientWidth,
|
||||||
|
overflowX: window.getComputedStyle(container).overflowX,
|
||||||
|
scrollWidth: container.scrollWidth,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(userTableScroll.overflowX).toBe("auto");
|
||||||
|
expect(userTableScroll.scrollWidth).toBeGreaterThan(
|
||||||
|
userTableScroll.clientWidth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const immutableRow = page.getByRole("row", {
|
||||||
|
name: /cyhan@samaneng\.com/,
|
||||||
|
});
|
||||||
|
await expect(immutableRow.getByRole("checkbox")).toBeDisabled();
|
||||||
|
await expect(
|
||||||
|
immutableRow.getByRole("button", { name: /비밀번호 관리/ }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type WorksmobileConfigSummary struct {
|
|||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
DomainMappings map[string]int64 `json:"domainMappings"`
|
DomainMappings map[string]int64 `json:"domainMappings"`
|
||||||
TokenConfigured bool `json:"tokenConfigured"`
|
TokenConfigured bool `json:"tokenConfigured"`
|
||||||
|
AdminTenantID string `json:"adminTenantId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorksmobileTenantOverview struct {
|
type WorksmobileTenantOverview struct {
|
||||||
@@ -115,6 +116,7 @@ func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID
|
|||||||
Enabled: WorksmobileEnabled(tenant.Config),
|
Enabled: WorksmobileEnabled(tenant.Config),
|
||||||
DomainMappings: WorksmobileDomainMappings(tenant.Config),
|
DomainMappings: WorksmobileDomainMappings(tenant.Config),
|
||||||
TokenConfigured: worksmobileDirectoryAuthConfigured(),
|
TokenConfigured: worksmobileDirectoryAuthConfigured(),
|
||||||
|
AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")),
|
||||||
},
|
},
|
||||||
RecentJobs: jobs,
|
RecentJobs: jobs,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
|
|||||||
require.Empty(t, outboxRepo.created)
|
require.Empty(t, outboxRepo.created)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorksmobileSyncServiceOverviewExposesAdminTenantIDForPasswordManageLink(t *testing.T) {
|
||||||
|
t.Setenv("WORKS_ADMIN_TENANT_ID", "works-tenant-1")
|
||||||
|
root := domain.Tenant{
|
||||||
|
ID: "root-tenant",
|
||||||
|
Slug: HanmacFamilyTenantSlug,
|
||||||
|
Name: "한맥가족",
|
||||||
|
}
|
||||||
|
service := NewWorksmobileSyncService(
|
||||||
|
&fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{root.ID: root}},
|
||||||
|
&fakeWorksmobileUserRepo{},
|
||||||
|
&fakeWorksmobileOutboxRepo{},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
overview, err := service.GetTenantOverview(context.Background(), root.ID)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "works-tenant-1", overview.Config.AdminTenantID)
|
||||||
|
}
|
||||||
|
|
||||||
func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) {
|
func TestCompareWorksmobileGroupsUsesOrganizationsAndBarongroupChildCompanies(t *testing.T) {
|
||||||
parentID := "root-tenant"
|
parentID := "root-tenant"
|
||||||
root := domain.Tenant{
|
root := domain.Tenant{
|
||||||
|
|||||||
@@ -192,6 +192,25 @@ Worksmobile 구성원 수정 API에는 PUT(`user-update-put`)과 PATCH(`user-upd
|
|||||||
- 기존 WORKS Mobile 구성원에 대한 일반 속성/조직/겸직 동기화는 생성 효율을 위해 먼저 `POST /v1.0/users`를 시도하고, `409 Conflict`일 때 `PATCH /v1.0/users/{email}`로 전환합니다.
|
- 기존 WORKS Mobile 구성원에 대한 일반 속성/조직/겸직 동기화는 생성 효율을 위해 먼저 `POST /v1.0/users`를 시도하고, `409 Conflict`일 때 `PATCH /v1.0/users/{email}`로 전환합니다.
|
||||||
- PUT은 전체 교체 성격이 강하고 누락 필드 초기화 위험이 있으므로 현 scope에서는 사용하지 않습니다. 모든 Baron -> WORKS 변경 반영은 부분 수정 PATCH를 우선합니다.
|
- PUT은 전체 교체 성격이 강하고 누락 필드 초기화 위험이 있으므로 현 scope에서는 사용하지 않습니다. 모든 Baron -> WORKS 변경 반영은 부분 수정 PATCH를 우선합니다.
|
||||||
|
|
||||||
|
### 구성원 비밀번호 관리 링크
|
||||||
|
|
||||||
|
Baron SSO는 생성 이후 WORKS Mobile 비밀번호 값을 직접 수정하지 않습니다. 운영자가 비밀번호 수정을 요청할 때는 해당 WORKS 계정의 식별자를 이용해 WORKS Mobile 관리자 비밀번호 관리 화면을 새 창으로 엽니다.
|
||||||
|
|
||||||
|
사용 URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://auth.worksmobile.com/integrate/password/manage?usage=admin&targetUserTenantId={회사테넌트}&targetUserDomainId={회사도메인}&targetUserIdNo={변경대상works_USER_ID}&accessUrl=https://admin.worksmobile.com/assets/self-close.html
|
||||||
|
```
|
||||||
|
|
||||||
|
전제와 기준:
|
||||||
|
|
||||||
|
- 브라우저 사용자는 `auth.worksmobile.com`에 관리자 권한으로 로그인되어 있어야 합니다.
|
||||||
|
- `targetUserTenantId`는 Baron tenant UUID가 아니라 WORKS Mobile 회사 tenant 식별자입니다. Baron SSO backend는 `WORKS_ADMIN_TENANT_ID` 환경 변수로 이 값을 adminfront overview에 노출합니다.
|
||||||
|
- `targetUserDomainId`는 WORKS Mobile 비교 결과의 `worksmobileDomainId`를 사용합니다.
|
||||||
|
- `targetUserIdNo`는 WORKS Mobile 비교 결과의 `worksmobileId`를 사용합니다.
|
||||||
|
- adminfront는 세 값이 모두 있을 때만 비밀번호 관리 버튼을 활성화합니다.
|
||||||
|
- 이 링크는 WORKS Mobile 관리자 화면을 여는 기능이며, Baron SSO backend에서 password 또는 `passwordConfig` 변경 API를 호출하지 않습니다.
|
||||||
|
|
||||||
## 비동기 아키텍처 권장안
|
## 비동기 아키텍처 권장안
|
||||||
|
|
||||||
Worksmobile API를 handler에서 직접 호출하지 않고, 별도 outbox와 relay worker를 둡니다.
|
Worksmobile API를 handler에서 직접 호출하지 않고, 별도 outbox와 relay worker를 둡니다.
|
||||||
|
|||||||
Reference in New Issue
Block a user