forked from baron/baron-sso
worksmobile 관리화면 보완.
This commit is contained in:
@@ -534,7 +534,7 @@ function AppLayout() {
|
||||
</nav>
|
||||
</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">
|
||||
<div className="flex items-center justify-between px-5 py-4 md:px-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
@@ -730,7 +730,7 @@ function AppLayout() {
|
||||
</div>
|
||||
</div>
|
||||
</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 />
|
||||
</main>
|
||||
<RoleSwitcher />
|
||||
|
||||
@@ -483,10 +483,10 @@ function TenantListPage() {
|
||||
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<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">
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">
|
||||
<TableHead className="w-[48px] whitespace-nowrap">
|
||||
<Checkbox
|
||||
checked={
|
||||
tenants.length > 0 &&
|
||||
@@ -498,28 +498,28 @@ function TenantListPage() {
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[220px]">
|
||||
<TableHead className="w-[280px] whitespace-nowrap">
|
||||
{t("ui.admin.tenants.table.id", "ID")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<TableHead className="w-[220px] whitespace-nowrap">
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<TableHead className="w-[140px] whitespace-nowrap">
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<TableHead className="w-[180px] whitespace-nowrap">
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<TableHead className="w-[120px] whitespace-nowrap">
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<TableHead className="w-[120px] whitespace-nowrap">
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<TableHead className="w-[180px] whitespace-nowrap">
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
<TableHead className="w-[160px] whitespace-nowrap text-right">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -575,21 +575,18 @@ function TenantListPage() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{t(
|
||||
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
|
||||
tenant.type,
|
||||
)}
|
||||
{tenant.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
@@ -605,15 +602,15 @@ function TenantListPage() {
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<TableCell className="whitespace-nowrap font-medium">
|
||||
{tenant.memberCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<TableCell className="whitespace-nowrap text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildWorksmobilePasswordManageUrl,
|
||||
canOpenWorksmobilePasswordManage,
|
||||
canCreateWorksmobileRow,
|
||||
canSelectWorksmobileRow,
|
||||
filterWorksmobileComparisonRows,
|
||||
formatWorksmobileOrgDetails,
|
||||
formatWorksmobilePersonName,
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
isImmutableWorksmobileAccount,
|
||||
summarizeWorksmobileComparison,
|
||||
userFilterOptions,
|
||||
} from "./TenantWorksmobilePage";
|
||||
@@ -69,6 +76,121 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
).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", () => {
|
||||
const rows = [
|
||||
{
|
||||
@@ -149,4 +271,77 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
}),
|
||||
).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 { Download, RefreshCw, RotateCcw } from "lucide-react";
|
||||
import {
|
||||
Download,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
@@ -12,6 +18,15 @@ import {
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
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 {
|
||||
Table,
|
||||
@@ -47,8 +62,18 @@ export function TenantWorksmobilePage() {
|
||||
const [userFilters, setUserFilters] = React.useState<
|
||||
WorksmobileComparisonFilter[]
|
||||
>(["baron_only", "works_only"]);
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const [selectedGroupIds, setSelectedGroupIds] = React.useState<string[]>([]);
|
||||
const [selectedUserRowKeys, setSelectedUserRowKeys] = React.useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [selectedGroupRowKeys, setSelectedGroupRowKeys] = React.useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [userVisibleColumns, setUserVisibleColumns] = React.useState(
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
);
|
||||
const [groupVisibleColumns, setGroupVisibleColumns] = React.useState(
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
);
|
||||
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: ["worksmobile", tenantId],
|
||||
@@ -152,9 +177,9 @@ export function TenantWorksmobilePage() {
|
||||
},
|
||||
onSuccess: ({ resourceKind, count }) => {
|
||||
if (resourceKind === "users") {
|
||||
setSelectedUserIds([]);
|
||||
setSelectedUserRowKeys([]);
|
||||
} else {
|
||||
setSelectedGroupIds([]);
|
||||
setSelectedGroupRowKeys([]);
|
||||
}
|
||||
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
||||
description: `${count}건`,
|
||||
@@ -198,7 +223,7 @@ export function TenantWorksmobilePage() {
|
||||
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
|
||||
|
||||
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">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
@@ -247,7 +272,7 @@ export function TenantWorksmobilePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
@@ -261,7 +286,7 @@ export function TenantWorksmobilePage() {
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="min-w-0 space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<ComparisonSummary
|
||||
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
|
||||
@@ -275,41 +300,26 @@ export function TenantWorksmobilePage() {
|
||||
summary={groupSummary}
|
||||
/>
|
||||
</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
|
||||
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
|
||||
rows={filteredComparisonUsers}
|
||||
loading={comparisonQuery.isLoading}
|
||||
selectedIds={selectedUserIds}
|
||||
onSelectedIdsChange={setSelectedUserIds}
|
||||
selectedKeys={selectedUserRowKeys}
|
||||
onSelectedKeysChange={setSelectedUserRowKeys}
|
||||
filters={userFilters}
|
||||
onFiltersChange={(nextFilters) => {
|
||||
setUserFilters(nextFilters);
|
||||
setSelectedUserRowKeys([]);
|
||||
}}
|
||||
visibleColumns={userVisibleColumns}
|
||||
onVisibleColumnsChange={setUserVisibleColumns}
|
||||
passwordManageTenantId={overview?.config.adminTenantId}
|
||||
actionLabel="선택 구성원 WORKS에 생성"
|
||||
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
|
||||
onCreateSelected={() =>
|
||||
onCreateSelected={(ids) =>
|
||||
createSelectedMutation.mutate({
|
||||
resourceKind: "users",
|
||||
ids: selectedUserIds,
|
||||
ids,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -320,16 +330,21 @@ export function TenantWorksmobilePage() {
|
||||
)}
|
||||
rows={comparisonGroups}
|
||||
loading={comparisonQuery.isLoading}
|
||||
selectedIds={selectedGroupIds}
|
||||
onSelectedIdsChange={setSelectedGroupIds}
|
||||
selectedKeys={selectedGroupRowKeys}
|
||||
onSelectedKeysChange={setSelectedGroupRowKeys}
|
||||
filters={undefined}
|
||||
onFiltersChange={undefined}
|
||||
visibleColumns={groupVisibleColumns}
|
||||
onVisibleColumnsChange={setGroupVisibleColumns}
|
||||
passwordManageTenantId={undefined}
|
||||
actionLabel="선택 조직 WORKS에 생성"
|
||||
actionDisabled={
|
||||
isCreatingGroups || createSelectedMutation.isPending
|
||||
}
|
||||
onCreateSelected={() =>
|
||||
onCreateSelected={(ids) =>
|
||||
createSelectedMutation.mutate({
|
||||
resourceKind: "groups",
|
||||
ids: selectedGroupIds,
|
||||
ids,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -435,6 +450,54 @@ export type WorksmobileComparisonSummary = {
|
||||
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(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
): WorksmobileComparisonSummary {
|
||||
@@ -480,6 +543,54 @@ export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
|
||||
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(
|
||||
rows: WorksmobileComparisonItem[],
|
||||
filters: WorksmobileComparisonFilter[],
|
||||
@@ -519,13 +630,56 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
|
||||
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<{
|
||||
value: WorksmobileComparisonFilter;
|
||||
label: string;
|
||||
}> = [
|
||||
{ value: "baron_only", label: "Baron에만 있음" },
|
||||
{ value: "works_only", label: "WORKS에만 있음" },
|
||||
{ value: "matched", label: "양쪽에 다 있음" },
|
||||
{ value: "baron_only", label: "바론에만 있음" },
|
||||
{ value: "works_only", label: "웍스에만 있음" },
|
||||
{ value: "matched", label: "양쪽 다 있음" },
|
||||
];
|
||||
|
||||
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
|
||||
@@ -605,8 +759,13 @@ function ComparisonTable({
|
||||
title,
|
||||
rows,
|
||||
loading,
|
||||
selectedIds,
|
||||
onSelectedIdsChange,
|
||||
selectedKeys,
|
||||
onSelectedKeysChange,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
visibleColumns,
|
||||
onVisibleColumnsChange,
|
||||
passwordManageTenantId,
|
||||
actionLabel,
|
||||
actionDisabled,
|
||||
onCreateSelected,
|
||||
@@ -614,101 +773,238 @@ function ComparisonTable({
|
||||
title: string;
|
||||
rows: WorksmobileComparisonItem[];
|
||||
loading: boolean;
|
||||
selectedIds: string[];
|
||||
onSelectedIdsChange: (ids: string[]) => void;
|
||||
selectedKeys: string[];
|
||||
onSelectedKeysChange: (ids: string[]) => void;
|
||||
filters?: WorksmobileComparisonFilter[];
|
||||
onFiltersChange?: (filters: WorksmobileComparisonFilter[]) => void;
|
||||
visibleColumns: WorksmobileComparisonColumnVisibility;
|
||||
onVisibleColumnsChange: React.Dispatch<
|
||||
React.SetStateAction<WorksmobileComparisonColumnVisibility>
|
||||
>;
|
||||
passwordManageTenantId?: string;
|
||||
actionLabel: string;
|
||||
actionDisabled: boolean;
|
||||
onCreateSelected: () => void;
|
||||
onCreateSelected: (ids: string[]) => void;
|
||||
}) {
|
||||
const creatableIds = rows
|
||||
.filter(canCreateWorksmobileRow)
|
||||
.map((row) => row.baronId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const allCreatableSelected =
|
||||
creatableIds.length > 0 &&
|
||||
creatableIds.every((id) => selectedIds.includes(id));
|
||||
const selectableKeys = rows
|
||||
.filter(canSelectWorksmobileRow)
|
||||
.map(getWorksmobileRowSelectionKey)
|
||||
.filter(Boolean);
|
||||
const selectedActionIds = getWorksmobileSelectedActionIds(
|
||||
rows,
|
||||
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") => {
|
||||
onSelectedIdsChange(checked === true ? creatableIds : []);
|
||||
onSelectedKeysChange(checked === true ? selectableKeys : []);
|
||||
};
|
||||
|
||||
const toggleRow = (
|
||||
id: string | undefined,
|
||||
row: WorksmobileComparisonItem,
|
||||
checked: boolean | "indeterminate",
|
||||
) => {
|
||||
if (!id) {
|
||||
const key = getWorksmobileRowSelectionKey(row);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
if (checked === true) {
|
||||
onSelectedIdsChange([...new Set([...selectedIds, id])]);
|
||||
onSelectedKeysChange([...new Set([...selectedKeys, key])]);
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h4 className="text-sm font-medium">{title}</h4>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onCreateSelected}
|
||||
disabled={selectedIds.length === 0 || actionDisabled}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<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
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => onCreateSelected(selectedActionIds)}
|
||||
disabled={selectedActionIds.length === 0 || actionDisabled}
|
||||
>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<div className="w-full max-w-full overflow-x-auto rounded-md border">
|
||||
<Table className="min-w-max">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10 whitespace-nowrap">
|
||||
<Checkbox
|
||||
aria-label={`${title} 전체 선택`}
|
||||
checked={allCreatableSelected}
|
||||
disabled={creatableIds.length === 0}
|
||||
checked={allSelectableSelected}
|
||||
disabled={selectableKeys.length === 0}
|
||||
onCheckedChange={toggleAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-24 whitespace-nowrap">상태</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
Baron ID
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
Baron
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
Baron 조직
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
WORKS ID
|
||||
</TableHead>
|
||||
<TableHead className="min-w-40 whitespace-nowrap">
|
||||
external_key
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
WORKS 도메인
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
WORKS
|
||||
</TableHead>
|
||||
<TableHead className="min-w-52 whitespace-nowrap">
|
||||
WORKS 조직
|
||||
</TableHead>
|
||||
{isColumnVisible("status") && (
|
||||
<TableHead className="w-24 whitespace-nowrap">상태</TableHead>
|
||||
)}
|
||||
{isColumnVisible("baronId") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
Baron ID
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("baron") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
Baron
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("baronOrg") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
Baron 조직
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobileId") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
WORKS ID
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("externalKey") && (
|
||||
<TableHead className="min-w-40 whitespace-nowrap">
|
||||
external_key
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobileDomain") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
WORKS 도메인
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobile") && (
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
WORKS
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("worksmobileOrg") && (
|
||||
<TableHead className="min-w-52 whitespace-nowrap">
|
||||
WORKS 조직
|
||||
</TableHead>
|
||||
)}
|
||||
{isColumnVisible("manage") && (
|
||||
<TableHead className="w-14 whitespace-nowrap">관리</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
<TableCell
|
||||
colSpan={tableColSpan}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
불러오는 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-muted-foreground">
|
||||
<TableCell
|
||||
colSpan={tableColSpan}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
표시할 차이가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -720,87 +1016,126 @@ function ComparisonTable({
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Checkbox
|
||||
aria-label={`${row.baronName ?? row.baronId ?? row.worksmobileName ?? row.worksmobileId ?? "row"} 선택`}
|
||||
checked={Boolean(
|
||||
row.baronId && selectedIds.includes(row.baronId),
|
||||
checked={selectedKeys.includes(
|
||||
getWorksmobileRowSelectionKey(row),
|
||||
)}
|
||||
disabled={!canCreateWorksmobileRow(row)}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleRow(row.baronId, checked)
|
||||
}
|
||||
disabled={!canSelectWorksmobileRow(row)}
|
||||
onCheckedChange={(checked) => toggleRow(row, checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge
|
||||
className="whitespace-nowrap"
|
||||
variant={getWorksmobileComparisonStatusVariant(row.status)}
|
||||
>
|
||||
{getWorksmobileComparisonStatusLabel(row.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{row.baronId ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div>{row.baronName ?? "-"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.baronEmail ?? ""}
|
||||
{isColumnVisible("status") && (
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge
|
||||
className="whitespace-nowrap"
|
||||
variant={getWorksmobileComparisonStatusVariant(
|
||||
row.status,
|
||||
)}
|
||||
>
|
||||
{getWorksmobileComparisonStatusLabel(row.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
{isColumnVisible("baronId") && (
|
||||
<TableCell className="font-mono text-xs">
|
||||
{row.baronId ?? "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{isColumnVisible("baron") && (
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div>{row.baronName ?? "-"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.baronEmail ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ComparisonOrgCell
|
||||
name={
|
||||
row.resourceType === "GROUP"
|
||||
? row.baronParentName
|
||||
: row.baronPrimaryOrgName
|
||||
}
|
||||
id={
|
||||
row.resourceType === "GROUP"
|
||||
? row.baronParentId
|
||||
: row.baronPrimaryOrgId
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{row.worksmobileId ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{row.externalKey ?? "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ComparisonDomainCell
|
||||
name={row.worksmobileDomainName}
|
||||
id={row.worksmobileDomainId}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div>{formatWorksmobilePersonName(row) || "-"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.worksmobileEmail ?? ""}
|
||||
</TableCell>
|
||||
)}
|
||||
{isColumnVisible("baronOrg") && (
|
||||
<TableCell>
|
||||
<ComparisonOrgCell
|
||||
name={
|
||||
row.resourceType === "GROUP"
|
||||
? row.baronParentName
|
||||
: row.baronPrimaryOrgName
|
||||
}
|
||||
id={
|
||||
row.resourceType === "GROUP"
|
||||
? row.baronParentId
|
||||
: row.baronPrimaryOrgId
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{isColumnVisible("worksmobileId") && (
|
||||
<TableCell className="font-mono text-xs">
|
||||
{row.worksmobileId ?? "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{isColumnVisible("externalKey") && (
|
||||
<TableCell className="font-mono text-xs">
|
||||
{row.externalKey ?? "-"}
|
||||
</TableCell>
|
||||
)}
|
||||
{isColumnVisible("worksmobileDomain") && (
|
||||
<TableCell>
|
||||
<ComparisonDomainCell
|
||||
name={row.worksmobileDomainName}
|
||||
id={row.worksmobileDomainId}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{isColumnVisible("worksmobile") && (
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div>{formatWorksmobilePersonName(row) || "-"}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{row.worksmobileEmail ?? ""}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ComparisonOrgCell
|
||||
name={
|
||||
row.resourceType === "GROUP"
|
||||
? row.worksmobileParentName
|
||||
: row.worksmobilePrimaryOrgName
|
||||
}
|
||||
id={
|
||||
row.resourceType === "GROUP"
|
||||
? row.worksmobileParentId
|
||||
: row.worksmobilePrimaryOrgId
|
||||
}
|
||||
details={
|
||||
row.resourceType === "GROUP"
|
||||
? []
|
||||
: formatWorksmobileOrgDetails(row)
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableCell>
|
||||
)}
|
||||
{isColumnVisible("worksmobileOrg") && (
|
||||
<TableCell>
|
||||
<ComparisonOrgCell
|
||||
name={
|
||||
row.resourceType === "GROUP"
|
||||
? row.worksmobileParentName
|
||||
: row.worksmobilePrimaryOrgName
|
||||
}
|
||||
id={
|
||||
row.resourceType === "GROUP"
|
||||
? row.worksmobileParentId
|
||||
: row.worksmobilePrimaryOrgId
|
||||
}
|
||||
details={
|
||||
row.resourceType === "GROUP"
|
||||
? []
|
||||
: formatWorksmobileOrgDetails(row)
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -508,7 +508,9 @@ export type WorksmobileOverview = {
|
||||
tenant: TenantSummary;
|
||||
config: {
|
||||
enabled: boolean;
|
||||
domainMappings?: Record<string, number>;
|
||||
tokenConfigured: boolean;
|
||||
adminTenantId?: string;
|
||||
};
|
||||
recentJobs: WorksmobileOutboxItem[];
|
||||
};
|
||||
|
||||
@@ -57,6 +57,7 @@ test.describe("Tenants Management", () => {
|
||||
});
|
||||
|
||||
test("should list tenants", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 900, height: 700 });
|
||||
const internalTenantId = "c5839444-2de0-4a37-99b0-4f94d3de8bea";
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
@@ -93,6 +94,15 @@ test.describe("Tenants Management", () => {
|
||||
timeout: 10000,
|
||||
});
|
||||
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 }) => {
|
||||
|
||||
@@ -197,7 +197,7 @@ test.describe("Worksmobile tenant management", () => {
|
||||
await expect(page.getByText("domainMappings")).not.toBeVisible();
|
||||
await expect(page.getByText("SCIM token")).not.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("기술본부", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
|
||||
@@ -208,47 +208,47 @@ test.describe("Worksmobile tenant management", () => {
|
||||
|
||||
const filterButtons = page
|
||||
.getByRole("button", {
|
||||
name: /Baron에만 있음|WORKS에만 있음|양쪽에 다 있음/,
|
||||
name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/,
|
||||
})
|
||||
.allTextContents();
|
||||
await expect.poll(() => filterButtons).toEqual([
|
||||
"Baron에만 있음",
|
||||
"WORKS에만 있음",
|
||||
"양쪽에 다 있음",
|
||||
"바론에만 있음",
|
||||
"웍스에만 있음",
|
||||
"양쪽 다 있음",
|
||||
]);
|
||||
|
||||
await page.getByRole("button", { name: "WORKS에만 있음" }).click();
|
||||
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 page.getByRole("button", { name: "양쪽 다 있음" }).click();
|
||||
await expect(page.getByText("홍길동")).toHaveCount(2);
|
||||
await expect(page.getByText("기술기획", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("team-tech", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("WORKS 기술기획")).toBeVisible();
|
||||
await expect(page.getByText("works-team-tech")).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 expect(page.getByText("홍길동")).toHaveCount(2);
|
||||
await expect(page.getByText("김누락")).not.toBeVisible();
|
||||
await expect(page.getByText("박웍스")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "WORKS에만 있음" }).click();
|
||||
await page.getByRole("button", { name: "바론에만 있음" }).click();
|
||||
await expect(page.getByText("홍길동")).toHaveCount(2);
|
||||
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("홍길동")).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 page
|
||||
@@ -360,4 +360,148 @@ test.describe("Worksmobile tenant management", () => {
|
||||
page.getByText(/WORKS API rejected user creation/),
|
||||
).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"`
|
||||
DomainMappings map[string]int64 `json:"domainMappings"`
|
||||
TokenConfigured bool `json:"tokenConfigured"`
|
||||
AdminTenantID string `json:"adminTenantId,omitempty"`
|
||||
}
|
||||
|
||||
type WorksmobileTenantOverview struct {
|
||||
@@ -115,6 +116,7 @@ func (s *worksmobileSyncService) GetTenantOverview(ctx context.Context, tenantID
|
||||
Enabled: WorksmobileEnabled(tenant.Config),
|
||||
DomainMappings: WorksmobileDomainMappings(tenant.Config),
|
||||
TokenConfigured: worksmobileDirectoryAuthConfigured(),
|
||||
AdminTenantID: strings.TrimSpace(os.Getenv("WORKS_ADMIN_TENANT_ID")),
|
||||
},
|
||||
RecentJobs: jobs,
|
||||
}, nil
|
||||
|
||||
@@ -56,6 +56,26 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te
|
||||
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) {
|
||||
parentID := "root-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}`로 전환합니다.
|
||||
- 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를 둡니다.
|
||||
|
||||
Reference in New Issue
Block a user