1
0
forked from baron/baron-sso

worksmobile 관리화면 보완.

This commit is contained in:
2026-05-06 10:37:34 +09:00
parent 3169dd958a
commit 6cdd0fd81e
10 changed files with 943 additions and 219 deletions

View File

@@ -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 />

View File

@@ -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"

View File

@@ -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);
});
});

View File

@@ -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>

View File

@@ -508,7 +508,9 @@ export type WorksmobileOverview = {
tenant: TenantSummary;
config: {
enabled: boolean;
domainMappings?: Record<string, number>;
tokenConfigured: boolean;
adminTenantId?: string;
};
recentJobs: WorksmobileOutboxItem[];
};

View File

@@ -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 }) => {

View File

@@ -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();
});
});

View File

@@ -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

View File

@@ -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{

View File

@@ -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를 둡니다.