1
0
forked from baron/baron-sso

worksmobile 연동 & ory stack 26.2.0으로 업그레이드

This commit is contained in:
2026-05-06 09:30:00 +09:00
parent 3dcdd97882
commit 2495fcb13d
74 changed files with 8698 additions and 212 deletions

View File

@@ -13,6 +13,7 @@ import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage";
import UserCreatePage from "../features/users/UserCreatePage";
@@ -49,6 +50,7 @@ export const router = createBrowserRouter(
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
],
},
{

View File

@@ -18,8 +18,8 @@ import { createTenant, fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import {
formatDomainConflictMessage,
type ServerDomainConflict,
formatDomainConflictMessage,
} from "../utils/domainTags";
function TenantCreatePage() {
@@ -151,6 +151,12 @@ function TenantCreatePage() {
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",

View File

@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { canShowWorksmobileEntry } from "./TenantDetailPage";
describe("TenantDetailPage Worksmobile entry visibility", () => {
it("shows Worksmobile entry only for hanmac-family root tenant", () => {
expect(
canShowWorksmobileEntry({
id: "hanmac-family-id",
slug: "hanmac-family",
parentId: undefined,
}),
).toBe(true);
expect(
canShowWorksmobileEntry({
id: "hanmac-child-id",
slug: "hanmac-family",
parentId: "root-id",
}),
).toBe(false);
expect(
canShowWorksmobileEntry({
id: "other-id",
slug: "other",
parentId: undefined,
}),
).toBe(false);
});
});

View File

@@ -1,10 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export function canShowWorksmobileEntry(tenant?: {
id?: string;
slug?: string;
parentId?: string | null;
}) {
return tenant?.slug === "hanmac-family" && !tenant.parentId;
}
function TenantDetailPage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
@@ -23,9 +30,11 @@ function TenantDetailPage() {
const canAccessSchema =
profile?.role === "super_admin" || profile?.role === "tenant_admin";
const showWorksmobileEntry = canShowWorksmobileEntry(tenantQuery.data);
const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization");
const isWorksmobileTab = location.pathname.includes("/worksmobile");
return (
<div className="space-y-8">
@@ -51,6 +60,7 @@ function TenantDetailPage() {
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
!isPermissionsTab &&
!location.pathname.includes("/schema") &&
!isWorksmobileTab &&
!isOrganizationTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
@@ -90,6 +100,18 @@ function TenantDetailPage() {
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
)}
{showWorksmobileEntry && (
<Link
to={`/tenants/${tenantId}/worksmobile`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isWorksmobileTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_worksmobile", "Worksmobile")}
</Link>
)}
</div>
{/* Outlet for nested routes */}

View File

@@ -0,0 +1,57 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import TenantDetailPage from "./TenantDetailPage";
vi.mock("../../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
fetchTenant: vi.fn(async () => ({
id: "hanmac-family-id",
name: "한맥 가족",
slug: "hanmac-family",
parentId: null,
})),
}));
function renderTenantDetailPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/hanmac-family-id"]}>
<Routes>
<Route path="/tenants/:tenantId/*" element={<TenantDetailPage />}>
<Route index element={<div>profile</div>} />
<Route path="worksmobile" element={<div>worksmobile</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantDetailPage Worksmobile navigation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("opens Worksmobile management in the current admin route", async () => {
renderTenantDetailPage();
const link = await screen.findByRole("link", { name: /Worksmobile/i });
expect(link).toHaveAttribute(
"href",
"/tenants/hanmac-family-id/worksmobile",
);
expect(link).not.toHaveAttribute("target");
expect(link).not.toHaveAttribute("rel");
});
});

View File

@@ -25,8 +25,8 @@ import {
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import {
formatDomainConflictMessage,
type ServerDomainConflict,
formatDomainConflictMessage,
} from "../utils/domainTags";
import { isSeedTenant } from "../utils/protectedTenants";
@@ -230,6 +230,12 @@ export function TenantProfilePage() {
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from "vitest";
import {
canCreateWorksmobileRow,
filterWorksmobileComparisonRows,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
getWorksmobileComparisonStatusLabel,
summarizeWorksmobileComparison,
userFilterOptions,
} from "./TenantWorksmobilePage";
describe("TenantWorksmobilePage comparison helpers", () => {
it("summarizes comparison rows by status", () => {
const summary = summarizeWorksmobileComparison([
{ resourceType: "USER", status: "matched" },
{ resourceType: "USER", status: "missing_in_worksmobile" },
{ resourceType: "USER", status: "missing_in_baron" },
{ resourceType: "USER", status: "missing_external_key" },
{ resourceType: "USER", status: "missing_in_baron" },
]);
expect(summary).toEqual({
total: 5,
matched: 1,
missingInWorksmobile: 1,
missingInBaron: 2,
missingExternalKey: 1,
});
});
it("returns Korean labels for known comparison statuses", () => {
expect(getWorksmobileComparisonStatusLabel("matched")).toBe("일치");
expect(getWorksmobileComparisonStatusLabel("missing_in_worksmobile")).toBe(
"WORKS 없음",
);
expect(getWorksmobileComparisonStatusLabel("missing_in_baron")).toBe(
"Baron 없음",
);
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
"ex_key 없음",
);
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
"unknown_status",
);
});
it("allows WORKS creation only for Baron rows missing in WORKS", () => {
expect(
canCreateWorksmobileRow({
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "user-1",
}),
).toBe(true);
expect(
canCreateWorksmobileRow({
resourceType: "USER",
status: "missing_in_worksmobile",
}),
).toBe(false);
expect(
canCreateWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-user-1",
}),
).toBe(false);
});
it("filters user comparison rows by selected relationship", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "baron-only",
baronName: "Baron only",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-only",
worksmobileName: "WORKS only",
},
{
resourceType: "USER",
status: "matched",
baronId: "matched",
worksmobileId: "works-matched",
},
{
resourceType: "USER",
status: "missing_external_key",
worksmobileId: "missing-external-key",
},
];
expect(filterWorksmobileComparisonRows(rows, ["baron_only"])).toEqual([
rows[0],
]);
expect(filterWorksmobileComparisonRows(rows, ["works_only"])).toEqual([
rows[1],
rows[3],
]);
expect(filterWorksmobileComparisonRows(rows, ["matched"])).toEqual([
rows[2],
]);
expect(
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
).toEqual([rows[0], rows[1], rows[3]]);
expect(filterWorksmobileComparisonRows(rows, [])).toEqual(rows);
expect(
filterWorksmobileComparisonRows(rows, [
"baron_only",
"works_only",
"matched",
]),
).toEqual(rows);
});
it("orders user comparison filter options from Baron-only first", () => {
expect(userFilterOptions.map((option) => option.value)).toEqual([
"baron_only",
"works_only",
"matched",
]);
});
it("formats WORKS account name with level on one line", () => {
expect(
formatWorksmobilePersonName({
resourceType: "USER",
status: "matched",
worksmobileName: "홍길동",
worksmobileLevelName: "책임",
}),
).toBe("홍길동 책임");
});
it("formats WORKS organization details with task and manager status", () => {
expect(
formatWorksmobileOrgDetails({
resourceType: "USER",
status: "matched",
worksmobileTask: "기술검토",
worksmobilePrimaryOrgPositionName: "팀장",
worksmobilePrimaryOrgIsManager: true,
}),
).toEqual(["직책 팀장", "직무 기술검토", "조직장"]);
});
});

View File

@@ -0,0 +1,854 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Download, RefreshCw, RotateCcw } from "lucide-react";
import * as React from "react";
import { useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Checkbox } from "../../../components/ui/checkbox";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
type WorksmobileComparisonItem,
downloadWorksmobileInitialPasswordsCSV,
enqueueWorksmobileBackfillDryRun,
enqueueWorksmobileOrgUnitSync,
enqueueWorksmobileUserSync,
fetchWorksmobileComparison,
fetchWorksmobileOverview,
retryWorksmobileJob,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export type WorksmobileComparisonFilter =
| "works_only"
| "baron_only"
| "matched";
export function TenantWorksmobilePage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const [orgUnitId, setOrgUnitId] = React.useState("");
const [userId, setUserId] = React.useState("");
const [userFilters, setUserFilters] = React.useState<
WorksmobileComparisonFilter[]
>(["baron_only", "works_only"]);
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const [selectedGroupIds, setSelectedGroupIds] = React.useState<string[]>([]);
const overviewQuery = useQuery({
queryKey: ["worksmobile", tenantId],
queryFn: () => fetchWorksmobileOverview(tenantId),
enabled: tenantId.length > 0,
});
const comparisonQuery = useQuery({
queryKey: ["worksmobile-comparison", tenantId],
queryFn: () => fetchWorksmobileComparison(tenantId, true),
enabled: tenantId.length > 0,
});
const dryRunMutation = useMutation({
mutationFn: () => enqueueWorksmobileBackfillDryRun(tenantId),
onSuccess: () => {
toast.success("Backfill Dry-run 작업을 등록했습니다.");
overviewQuery.refetch();
},
onError: (error) => {
toast.error("Backfill Dry-run 실패", {
description: getErrorMessage(error),
});
},
});
const retryMutation = useMutation({
mutationFn: (jobId: string) => retryWorksmobileJob(tenantId, jobId),
onSuccess: () => {
toast.success("재시도 작업을 등록했습니다.");
overviewQuery.refetch();
},
onError: (error) => {
toast.error("재시도 작업 등록 실패", {
description: getErrorMessage(error),
});
},
});
const initialPasswordDownloadMutation = useMutation({
mutationFn: () => downloadWorksmobileInitialPasswordsCSV(tenantId),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: (error) => {
toast.error("초기 비밀번호 CSV 다운로드 실패", {
description: getErrorMessage(error),
});
},
});
const orgUnitSyncMutation = useMutation({
mutationFn: () => enqueueWorksmobileOrgUnitSync(tenantId, orgUnitId.trim()),
onSuccess: () => {
toast.success("조직 Sync 작업을 등록했습니다.");
overviewQuery.refetch();
},
onError: (error) => {
toast.error("조직 Sync 작업 등록 실패", {
description: getErrorMessage(error),
});
},
});
const userSyncMutation = useMutation({
mutationFn: () => enqueueWorksmobileUserSync(tenantId, userId.trim()),
onSuccess: () => {
toast.success("구성원 Sync 작업을 등록했습니다.");
overviewQuery.refetch();
},
onError: (error) => {
toast.error("구성원 Sync 작업 등록 실패", {
description: getErrorMessage(error),
});
},
});
const createSelectedMutation = useMutation({
mutationFn: async ({
resourceKind,
ids,
}: {
resourceKind: "users" | "groups";
ids: string[];
}) => {
for (const id of ids) {
if (resourceKind === "users") {
await enqueueWorksmobileUserSync(tenantId, id);
} else {
await enqueueWorksmobileOrgUnitSync(tenantId, id);
}
}
return { resourceKind, count: ids.length };
},
onSuccess: ({ resourceKind, count }) => {
if (resourceKind === "users") {
setSelectedUserIds([]);
} else {
setSelectedGroupIds([]);
}
toast.success("WORKS 생성 작업을 등록했습니다.", {
description: `${count}`,
});
overviewQuery.refetch();
comparisonQuery.refetch();
},
onError: (error) => {
toast.error("WORKS 생성 작업 등록 실패", {
description: getErrorMessage(error),
});
},
});
if (overviewQuery.isError) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{t(
"ui.admin.tenants.worksmobile.forbidden",
"한맥가족 테넌트에서만 Worksmobile 연동을 관리할 수 있습니다.",
)}
</div>
);
}
const overview = overviewQuery.data;
const comparisonUsers = comparisonQuery.data?.users ?? [];
const comparisonGroups = comparisonQuery.data?.groups ?? [];
const filteredComparisonUsers = filterWorksmobileComparisonRows(
comparisonUsers,
userFilters,
);
const userSummary = summarizeWorksmobileComparison(comparisonUsers);
const groupSummary = summarizeWorksmobileComparison(comparisonGroups);
const isCreatingUsers =
createSelectedMutation.isPending &&
createSelectedMutation.variables?.resourceKind === "users";
const isCreatingGroups =
createSelectedMutation.isPending &&
createSelectedMutation.variables?.resourceKind === "groups";
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
return (
<div className="space-y-6">
<header className="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-xl font-semibold">
{t("ui.admin.tenants.worksmobile.title", "Worksmobile 연동")}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.tenants.worksmobile.subtitle",
"한맥가족 Directory 조직/구성원 동기화 상태를 확인하고 실패 작업을 재시도합니다.",
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => initialPasswordDownloadMutation.mutate()}
disabled={initialPasswordDownloadMutation.isPending}
>
<Download size={16} />
{t(
"ui.admin.tenants.worksmobile.initial_password_csv",
"초기 비밀번호 CSV",
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
overviewQuery.refetch();
comparisonQuery.refetch();
}}
disabled={isRefreshing}
>
<RefreshCw size={16} />
{t("ui.admin.tenants.worksmobile.refresh", "새로고침")}
</Button>
<Button
type="button"
onClick={() => dryRunMutation.mutate()}
disabled={dryRunMutation.isPending}
>
<RefreshCw size={16} />
{t("ui.admin.tenants.worksmobile.dry_run", "Backfill Dry-run")}
</Button>
</div>
</header>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
<CardTitle className="text-base">
{t("ui.admin.tenants.worksmobile.compare", "Baron / Works 비교")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.tenants.worksmobile.compare_description",
"구성원은 기본적으로 Baron 또는 WORKS 한쪽에만 있는 항목을 보여줍니다.",
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<ComparisonSummary
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
summary={userSummary}
/>
<ComparisonSummary
title={t(
"ui.admin.tenants.worksmobile.compare_groups",
"조직/그룹",
)}
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}
actionLabel="선택 구성원 WORKS에 생성"
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
onCreateSelected={() =>
createSelectedMutation.mutate({
resourceKind: "users",
ids: selectedUserIds,
})
}
/>
<ComparisonTable
title={t(
"ui.admin.tenants.worksmobile.compare_groups",
"조직/그룹",
)}
rows={comparisonGroups}
loading={comparisonQuery.isLoading}
selectedIds={selectedGroupIds}
onSelectedIdsChange={setSelectedGroupIds}
actionLabel="선택 조직 WORKS에 생성"
actionDisabled={
isCreatingGroups || createSelectedMutation.isPending
}
onCreateSelected={() =>
createSelectedMutation.mutate({
resourceKind: "groups",
ids: selectedGroupIds,
})
}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("ui.admin.tenants.worksmobile.single_sync", "단건 동기화")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.tenants.worksmobile.single_sync_description",
"Baron UUID 기준으로 조직 또는 구성원 sync 작업을 생성합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
<div className="flex gap-2">
<Input
value={orgUnitId}
onChange={(event) => setOrgUnitId(event.target.value)}
placeholder="orgUnit tenant UUID"
/>
<Button
type="button"
onClick={() => orgUnitSyncMutation.mutate()}
disabled={!orgUnitId.trim() || orgUnitSyncMutation.isPending}
>
{t("ui.admin.tenants.worksmobile.sync_orgunit", "조직 Sync")}
</Button>
</div>
<div className="flex gap-2">
<Input
value={userId}
onChange={(event) => setUserId(event.target.value)}
placeholder="Kratos user UUID"
/>
<Button
type="button"
onClick={() => userSyncMutation.mutate()}
disabled={!userId.trim() || userSyncMutation.isPending}
>
{t("ui.admin.tenants.worksmobile.sync_user", "구성원 Sync")}
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("ui.admin.tenants.worksmobile.recent_jobs", "최근 작업")}
</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>resource</TableHead>
<TableHead>action</TableHead>
<TableHead>status</TableHead>
<TableHead>retry</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{(overview?.recentJobs ?? []).map((job) => (
<TableRow key={job.id}>
<TableCell>
{job.resourceType}:{job.resourceId}
</TableCell>
<TableCell>{job.action}</TableCell>
<TableCell>{job.status}</TableCell>
<TableCell>{job.retryCount}</TableCell>
<TableCell>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => retryMutation.mutate(job.id)}
disabled={retryMutation.isPending}
>
<RotateCcw size={16} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export type WorksmobileComparisonSummary = {
total: number;
matched: number;
missingInWorksmobile: number;
missingInBaron: number;
missingExternalKey: number;
};
export function summarizeWorksmobileComparison(
rows: WorksmobileComparisonItem[],
): WorksmobileComparisonSummary {
return rows.reduce<WorksmobileComparisonSummary>(
(summary, row) => {
if (row.status === "matched") {
summary.matched += 1;
} else if (row.status === "missing_in_worksmobile") {
summary.missingInWorksmobile += 1;
} else if (row.status === "missing_in_baron") {
summary.missingInBaron += 1;
} else if (row.status === "missing_external_key") {
summary.missingExternalKey += 1;
}
return summary;
},
{
total: rows.length,
matched: 0,
missingInWorksmobile: 0,
missingInBaron: 0,
missingExternalKey: 0,
},
);
}
export function getWorksmobileComparisonStatusLabel(status: string) {
switch (status) {
case "matched":
return "일치";
case "missing_in_worksmobile":
return "WORKS 없음";
case "missing_in_baron":
return "Baron 없음";
case "missing_external_key":
return "ex_key 없음";
default:
return status;
}
}
export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
}
export function filterWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
filters: WorksmobileComparisonFilter[],
) {
if (filters.length === 0 || filters.length === userFilterOptions.length) {
return rows;
}
const allowedStatuses = new Set(
filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
);
return rows.filter((row) => allowedStatuses.has(row.status));
}
export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) {
return [
row.worksmobileName,
row.worksmobileLevelName ?? row.worksmobileLevelId,
]
.filter(Boolean)
.join(" ");
}
export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
const details: string[] = [];
const position =
row.worksmobilePrimaryOrgPositionName ??
row.worksmobilePrimaryOrgPositionId;
if (position) {
details.push(`직책 ${position}`);
}
if (row.worksmobileTask) {
details.push(`직무 ${row.worksmobileTask}`);
}
if (typeof row.worksmobilePrimaryOrgIsManager === "boolean") {
details.push(row.worksmobilePrimaryOrgIsManager ? "조직장" : "조직장 아님");
}
return details;
}
export const userFilterOptions: Array<{
value: WorksmobileComparisonFilter;
label: string;
}> = [
{ value: "baron_only", label: "Baron에만 있음" },
{ value: "works_only", label: "WORKS에만 있음" },
{ value: "matched", label: "양쪽에 다 있음" },
];
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
{
baron_only: ["missing_in_worksmobile"],
works_only: ["missing_in_baron", "missing_external_key"],
matched: ["matched"],
};
function getErrorMessage(error: unknown) {
const responseData = (error as { response?: { data?: unknown } })?.response
?.data;
if (typeof responseData === "string") {
return responseData;
}
if (responseData && typeof responseData === "object") {
const data = responseData as { error?: unknown; message?: unknown };
if (typeof data.error === "string") {
return data.error;
}
if (typeof data.message === "string") {
return data.message;
}
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
function getWorksmobileComparisonStatusVariant(status: string) {
if (status === "matched") {
return "success";
}
if (status === "missing_external_key") {
return "warning";
}
return "secondary";
}
function ComparisonSummary({
title,
summary,
}: {
title: string;
summary: WorksmobileComparisonSummary;
}) {
return (
<div className="rounded-md border p-3">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-sm font-medium">{title}</span>
<Badge variant="outline">{summary.total}</Badge>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">WORKS </span>
<span className="font-mono">{summary.missingInWorksmobile}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Baron </span>
<span className="font-mono">{summary.missingInBaron}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">ex_key </span>
<span className="font-mono">{summary.missingExternalKey}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground"></span>
<span className="font-mono">{summary.matched}</span>
</div>
</div>
</div>
);
}
function ComparisonTable({
title,
rows,
loading,
selectedIds,
onSelectedIdsChange,
actionLabel,
actionDisabled,
onCreateSelected,
}: {
title: string;
rows: WorksmobileComparisonItem[];
loading: boolean;
selectedIds: string[];
onSelectedIdsChange: (ids: string[]) => void;
actionLabel: string;
actionDisabled: boolean;
onCreateSelected: () => 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 toggleAll = (checked: boolean | "indeterminate") => {
onSelectedIdsChange(checked === true ? creatableIds : []);
};
const toggleRow = (
id: string | undefined,
checked: boolean | "indeterminate",
) => {
if (!id) {
return;
}
if (checked === true) {
onSelectedIdsChange([...new Set([...selectedIds, id])]);
return;
}
onSelectedIdsChange(selectedIds.filter((selectedId) => selectedId !== id));
};
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>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10 whitespace-nowrap">
<Checkbox
aria-label={`${title} 전체 선택`}
checked={allCreatableSelected}
disabled={creatableIds.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>
</TableRow>
</TableHeader>
<TableBody>
{loading && (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
...
</TableCell>
</TableRow>
)}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={10} className="text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{rows.map((row) => (
<TableRow
key={`${row.status}:${row.baronId ?? row.worksmobileId ?? row.externalKey}`}
>
<TableCell className="whitespace-nowrap">
<Checkbox
aria-label={`${row.baronName ?? row.baronId ?? row.worksmobileName ?? row.worksmobileId ?? "row"} 선택`}
checked={Boolean(
row.baronId && selectedIds.includes(row.baronId),
)}
disabled={!canCreateWorksmobileRow(row)}
onCheckedChange={(checked) =>
toggleRow(row.baronId, 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 ?? ""}
</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 ?? ""}
</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>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
function ComparisonDomainCell({
name,
id,
}: {
name?: string;
id?: number;
}) {
if (!name && !id) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="space-y-1">
<div>{name ?? "-"}</div>
<div className="font-mono text-xs text-muted-foreground">{id ?? ""}</div>
</div>
);
}
function ComparisonOrgCell({
name,
id,
details = [],
}: {
name?: string;
id?: string;
details?: string[];
}) {
if (!name && !id && details.length === 0) {
return <span className="text-muted-foreground">-</span>;
}
return (
<div className="space-y-1">
<div>{name ?? "-"}</div>
<div className="font-mono text-xs text-muted-foreground">{id ?? ""}</div>
{details.length > 0 && (
<div className="text-xs text-muted-foreground">
{details.join(" · ")}
</div>
)}
</div>
);
}

View File

@@ -120,7 +120,7 @@ describe("tenantCsvImport", () => {
[
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain",
"local-parent-id,Parent Tenant,COMPANY,,parent-local,,",
"local-child-id,Child Tenant,USER_GROUP,local-parent-id,child-local,,",
"local-child-id,Child Tenant,ORGANIZATION,local-parent-id,child-local,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
@@ -141,7 +141,7 @@ describe("tenantCsvImport", () => {
"staging-parent-id,Parent Tenant,COMPANY,,parent-staging,,",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-staging,,",
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-staging,,",
);
expect(csv).not.toContain("local-parent-id");
expect(csv).not.toContain("local-child-id");
@@ -152,7 +152,7 @@ describe("tenantCsvImport", () => {
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"Parent Tenant,COMPANY,,parent-slug,,",
"Child Tenant,USER_GROUP,parent-slug,child-slug,,",
"Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
@@ -171,7 +171,7 @@ describe("tenantCsvImport", () => {
expect(rows[1].parentTenantSlug).toBe("parent-slug");
expect(csv).toContain(
"staging-child-id,Child Tenant,USER_GROUP,staging-parent-id,child-slug,,",
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-slug,,",
);
});
});

View File

@@ -87,6 +87,7 @@ const getTenantIcon = (type?: string) => {
return Briefcase;
case "PERSONAL":
return UserCircle;
case "ORGANIZATION":
case "USER_GROUP":
return Network;
default:

View File

@@ -44,6 +44,13 @@ import {
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import { Switch } from "../../components/ui/switch";
import {
Tabs,
@@ -78,6 +85,7 @@ import {
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";
import { userStatusLabel, userStatusValues } from "./userStatus";
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
metadata: Record<string, Record<string, string | number | boolean>>;
@@ -123,12 +131,40 @@ function createEmptyAppointment(): AppointmentDraft {
tenantId: "",
tenantName: "",
tenantSlug: "",
isPrimary: false,
isOwner: false,
jobTitle: "",
position: "",
};
}
function normalizePrimaryAppointments(
appointments: AppointmentDraft[],
): AppointmentDraft[] {
const leafIndexes = appointments
.map((appointment, index) =>
appointment.tenantId.trim().length > 0 ? index : -1,
)
.filter((index) => index >= 0);
if (leafIndexes.length === 1) {
const primaryIndex = leafIndexes[0];
return appointments.map((appointment, index) => ({
...appointment,
isPrimary: index === primaryIndex,
}));
}
const selectedIndex = appointments.findIndex(
(appointment) => appointment.isPrimary === true,
);
return appointments.map((appointment, index) => ({
...appointment,
isPrimary:
selectedIndex >= 0 &&
index === selectedIndex &&
appointment.tenantId.trim().length > 0,
}));
}
function validateManualPassword(
password: string,
policy?: PasswordPolicyResponse,
@@ -485,15 +521,17 @@ function UserDetailPage() {
try {
const tenant = await resolveTenantSelection(selection, tenants);
setAdditionalAppointments((current) =>
current.map((appointment, index) =>
index === target.index
? {
...appointment,
tenantId: tenant.id,
tenantName: tenant.name,
tenantSlug: tenant.slug,
}
: appointment,
normalizePrimaryAppointments(
current.map((appointment, index) =>
index === target.index
? {
...appointment,
tenantId: tenant.id,
tenantName: tenant.name,
tenantSlug: tenant.slug,
}
: appointment,
),
),
);
setPickerTarget(null);
@@ -536,15 +574,30 @@ function UserDetailPage() {
patch: Partial<UserAppointment>,
) => {
setAdditionalAppointments((current) =>
current.map((appointment, currentIndex) =>
currentIndex === index ? { ...appointment, ...patch } : appointment,
normalizePrimaryAppointments(
current.map((appointment, currentIndex) =>
currentIndex === index ? { ...appointment, ...patch } : appointment,
),
),
);
};
const setPrimaryAppointment = (index: number, checked: boolean) => {
setAdditionalAppointments((current) =>
normalizePrimaryAppointments(
current.map((appointment, currentIndex) => ({
...appointment,
isPrimary: checked && currentIndex === index,
})),
),
);
};
const removeAppointment = (index: number) => {
setAdditionalAppointments((current) =>
current.filter((_, currentIndex) => currentIndex !== index),
normalizePrimaryAppointments(
current.filter((_, currentIndex) => currentIndex !== index),
),
);
};
@@ -602,7 +655,10 @@ function UserDetailPage() {
tenantSlug:
user.companyCode ||
user.joinedTenants?.find(
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
(t) =>
t.type === "COMPANY" ||
t.type === "COMPANY_GROUP" ||
t.type === "ORGANIZATION",
)?.slug ||
"",
department: user.department || "",
@@ -636,38 +692,45 @@ function UserDetailPage() {
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
);
setAdditionalAppointments(
Array.isArray(rawAppointments)
? (rawAppointments as UserAppointment[]).map((appointment) => ({
...appointment,
draftId: createDraftId(),
}))
: isUserHanmacFamily
? familyFallbackTenants.length > 0
? familyFallbackTenants.map((tenant) => ({
draftId: createDraftId(),
tenantId: tenant.id,
tenantName: tenant.name,
tenantSlug: tenant.slug,
isOwner:
metadata.primaryTenantIsOwner === true &&
tenant.id === fallbackAppointment?.id,
jobTitle: user.jobTitle,
position: user.position,
}))
: fallbackAppointment
? [
{
draftId: createDraftId(),
tenantId: fallbackAppointment.id,
tenantName: fallbackAppointment.name,
tenantSlug: fallbackAppointment.slug,
isOwner: metadata.primaryTenantIsOwner === true,
jobTitle: user.jobTitle,
position: user.position,
},
]
: []
: [],
normalizePrimaryAppointments(
Array.isArray(rawAppointments)
? (rawAppointments as UserAppointment[]).map((appointment) => ({
...appointment,
isPrimary:
appointment.isPrimary === true ||
appointment.tenantId === metadata.primaryTenantId,
draftId: createDraftId(),
}))
: isUserHanmacFamily
? familyFallbackTenants.length > 0
? familyFallbackTenants.map((tenant) => ({
draftId: createDraftId(),
tenantId: tenant.id,
tenantName: tenant.name,
tenantSlug: tenant.slug,
isPrimary: tenant.id === fallbackAppointment?.id,
isOwner:
metadata.primaryTenantIsOwner === true &&
tenant.id === fallbackAppointment?.id,
jobTitle: user.jobTitle,
position: user.position,
}))
: fallbackAppointment
? [
{
draftId: createDraftId(),
tenantId: fallbackAppointment.id,
tenantName: fallbackAppointment.name,
tenantSlug: fallbackAppointment.slug,
isPrimary: true,
isOwner: metadata.primaryTenantIsOwner === true,
jobTitle: user.jobTitle,
position: user.position,
},
]
: []
: [],
),
);
}
}, [hanmacFamilyTenantId, tenants, user, reset]);
@@ -748,19 +811,37 @@ function UserDetailPage() {
tenantId: appointment.tenantId,
tenantSlug: appointment.tenantSlug,
tenantName: appointment.tenantName,
isPrimary: appointment.isPrimary === true,
isOwner: appointment.isOwner,
jobTitle: appointment.jobTitle,
position: appointment.position,
}));
const primaryAppointment = appointments.find(
(appointment) => appointment.isPrimary,
);
payload.tenantSlug = undefined;
payload.department = undefined;
payload.position = undefined;
payload.jobTitle = undefined;
payload.additionalAppointments = appointments;
if (primaryAppointment) {
payload.tenantSlug = primaryAppointment.tenantSlug;
payload.primaryTenantId = primaryAppointment.tenantId;
payload.primaryTenantName = primaryAppointment.tenantName;
payload.primaryTenantIsOwner = primaryAppointment.isOwner;
}
payload.metadata = {
...metadata,
additionalAppointments: appointments,
...(primaryAppointment
? {
primaryTenantId: primaryAppointment.tenantId,
primaryTenantName: primaryAppointment.tenantName,
primaryTenantSlug: primaryAppointment.tenantSlug,
primaryTenantIsOwner: primaryAppointment.isOwner,
}
: {}),
};
}
@@ -791,6 +872,9 @@ function UserDetailPage() {
filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
[userAffiliatedTenants, hanmacFamilyTenantId],
);
const primaryAppointmentLeafCount = additionalAppointments.filter(
(appointment) => appointment.tenantId.trim().length > 0,
).length;
if (isLoading) {
return (
@@ -857,7 +941,10 @@ function UserDetailPage() {
{user.tenant?.name ||
user.companyCode ||
user.joinedTenants?.find(
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
(t) =>
t.type === "COMPANY" ||
t.type === "COMPANY_GROUP" ||
t.type === "ORGANIZATION",
)?.name ||
t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}
</Badge>
@@ -1001,21 +1088,23 @@ function UserDetailPage() {
>
{t("ui.admin.users.detail.form.status", "상태")}
</Label>
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
<Switch
id="status"
checked={watchedStatus === "active"}
onCheckedChange={(checked) =>
setValue("status", checked ? "active" : "inactive")
}
/>
<span className="text-sm text-muted-foreground">
{t(
`ui.common.status.${watchedStatus}`,
watchedStatus || "inactive",
)}
</span>
</div>
<Select
value={watchedStatus || "inactive"}
onValueChange={(status) => setValue("status", status)}
>
<SelectTrigger id="status" className="h-11">
<SelectValue>
{userStatusLabel(watchedStatus || "inactive")}
</SelectValue>
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
@@ -1160,6 +1249,26 @@ function UserDetailPage() {
{appointment.tenantSlug}
</span>
)}
<label className="flex items-center gap-3 text-sm">
<Switch
aria-label={t(
"ui.admin.users.detail.form.appointment_primary",
"대표 조직",
)}
checked={appointment.isPrimary === true}
disabled={
!appointment.tenantId ||
primaryAppointmentLeafCount <= 1
}
onCheckedChange={(checked) =>
setPrimaryAppointment(index, checked)
}
/>
{t(
"ui.admin.users.detail.form.appointment_primary",
"대표 조직",
)}
</label>
<label className="flex items-center gap-3 text-sm">
<Checkbox
checked={appointment.isOwner}

View File

@@ -32,7 +32,13 @@ import {
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import { Switch } from "../../components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import {
Table,
TableBody,
@@ -55,6 +61,7 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
import { userStatusLabel, userStatusValues } from "./userStatus";
type UserSchemaField = {
key: string;
@@ -579,28 +586,40 @@ function UserListPage() {
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) =>
<Select
value={user.status}
onValueChange={(status) =>
statusMutation.mutate({
userId: user.id,
status: checked ? "active" : "inactive",
status,
})
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
}
aria-label={t(
"ui.admin.users.list.toggle_status",
"{{name}} 활성 상태",
{ name: user.name },
)}
data-testid={`user-status-toggle-${user.id}`}
/>
<span className="text-sm text-muted-foreground">
{t(`ui.common.status.${user.status}`, user.status)}
</span>
>
<SelectTrigger
className="h-8 w-36"
aria-label={t(
"ui.admin.users.list.status_select",
"{{name}} 상태",
{ name: user.name },
)}
data-testid={`user-status-select-${user.id}`}
>
<SelectValue>
{userStatusLabel(user.status)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
@@ -683,6 +702,24 @@ function UserListPage() {
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("suspended")}
data-testid="bulk-suspended-btn"
>
{t("ui.common.status.suspended", "정지")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("leave_of_absence")}
data-testid="bulk-leave-of-absence-btn"
>
{t("ui.common.status.leave_of_absence", "휴직")}
</Button>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"

View File

@@ -0,0 +1,14 @@
import { t } from "../../lib/i18n";
export const userStatusValues = [
"active",
"inactive",
"suspended",
"leave_of_absence",
] as const;
export type UserStatusValue = (typeof userStatusValues)[number];
export function userStatusLabel(status: string) {
return t(`ui.common.status.${status}`, status);
}

View File

@@ -44,6 +44,36 @@ test@test.com,Test,baron`;
expect(result[0].tenantSlug).toBe("baron");
});
it("should parse NAVERWORKS member CSV sample into Baron bulk user fields", () => {
const csv = `"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time"
"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
email: "john1@company.com",
loginId: "john.doe",
name: "John Doe",
phone: "+19144812222",
department: "myteam",
position: "Manager",
jobTitle: "Sales management",
tenantImport: {
name: "myteam",
parentTenantName: "org.3",
},
metadata: {
personal_email: "john@naver.com",
employee_id: "AB001",
naverworks_user_type: "Permanent Employee",
naverworks_level: "Manager",
naverworks_organization_path: "org.1|org.2|org.3|myteam",
naverworks_workplace: "New York",
},
});
});
it("should parse tenant conflict metadata for import resolution", () => {
const csv = `email,name,tenant_id,tenant_slug,tenant_name,tenant_type,parent_tenant_slug,tenant_memo,email_domain
test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-slug,Imported memo,missing.example.com`;

View File

@@ -1,18 +1,17 @@
import type { BulkUserItem } from "../../../lib/adminApi";
export function parseUserCSV(text: string): BulkUserItem[] {
const lines = text.split(/\r?\n/);
if (lines.length < 2) {
const records = parseCSVRecords(text.replace(/^\uFEFF/, ""));
if (records.length < 2) {
return [];
}
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
const headers = records[0].map(normalizeHeader);
const data: BulkUserItem[] = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const values = lines[i].split(",").map((v) => v.trim());
for (let i = 1; i < records.length; i++) {
const values = records[i].map((v) => v.trim());
if (values.every((value) => value === "")) continue;
const item: Partial<BulkUserItem> & { metadata: Record<string, string> } = {
metadata: {},
};
@@ -84,11 +83,70 @@ export function parseUserCSV(text: string): BulkUserItem[] {
item.position = value;
} else if (header === "jobtitle") {
item.jobTitle = value;
} else if (header === "lastname") {
item.metadata.naverworks_last_name = value;
} else if (header === "firstname") {
item.metadata.naverworks_first_name = value;
} else if (header === "id") {
item.loginId = value;
item.metadata.naverworks_id = value;
} else if (header === "personalemail") {
item.metadata.personal_email = value;
} else if (header === "subemail") {
item.metadata.naverworks_sub_email = value;
item.email = firstEmailToken(value) || item.email;
} else if (header === "nickname") {
item.metadata.naverworks_nickname = value;
} else if (header === "usertype") {
item.metadata.naverworks_user_type = value;
} else if (header === "level") {
item.metadata.naverworks_level = value;
} else if (header === "organization") {
item.metadata.naverworks_organization_path = value;
const parts = splitOrganizationPath(value);
const leaf = parts.at(-1) ?? "";
const parent = parts.at(-2) ?? "";
if (leaf) {
item.department = leaf;
item.tenantImport = {
...(item.tenantImport ?? {}),
name: leaf,
parentTenantName: parent,
};
}
} else if (header === "companymainphone") {
item.metadata.naverworks_company_main_phone = value;
} else if (header === "mobilecountrycode") {
item.metadata.naverworks_mobile_country_code = value;
} else if (header === "mobilenumbers") {
item.metadata.naverworks_mobile_numbers = value;
} else if (header === "language") {
item.metadata.naverworks_language = value;
} else if (header === "responsibilities") {
item.jobTitle = value;
} else if (header === "workplace") {
item.metadata.naverworks_workplace = value;
} else if (header === "sns") {
item.metadata.naverworks_sns = value;
} else if (header === "snsid") {
item.metadata.naverworks_sns_id = value;
} else if (header === "birthdaysolarlunar") {
item.metadata.naverworks_birthday_calendar = value;
} else if (header === "birthday") {
item.metadata.naverworks_birthday = value;
} else if (header === "entrydate") {
item.metadata.naverworks_entry_date = value;
} else if (header === "employeenumber") {
item.metadata.employee_id = value;
} else if (header === "accountactivationtime") {
item.metadata.naverworks_account_activation_time = value;
} else {
item.metadata[header] = value;
}
}
applyNaverWorksFallbacks(item);
if (item.email && item.name) {
data.push(item as BulkUserItem);
}
@@ -96,3 +154,100 @@ export function parseUserCSV(text: string): BulkUserItem[] {
return data;
}
function normalizeHeader(header: string) {
return header
.trim()
.toLowerCase()
.replace(/^\uFEFF/, "")
.replace(/[^a-z0-9_]/g, "");
}
function parseCSVRecords(text: string) {
const records: string[][] = [];
let field = "";
let row: string[] = [];
let quoted = false;
for (let index = 0; index < text.length; index++) {
const char = text[index];
const next = text[index + 1];
if (char === '"') {
if (quoted && next === '"') {
field += '"';
index++;
} else {
quoted = !quoted;
}
continue;
}
if (char === "," && !quoted) {
row.push(field);
field = "";
continue;
}
if ((char === "\n" || char === "\r") && !quoted) {
if (char === "\r" && next === "\n") index++;
row.push(field);
records.push(row);
field = "";
row = [];
continue;
}
field += char;
}
if (field !== "" || row.length > 0) {
row.push(field);
records.push(row);
}
return records;
}
function firstEmailToken(value: string) {
return (
value
.split(/[;,]/)
.map((token) => token.trim())
.find((token) => token.includes("@")) ?? ""
);
}
function splitOrganizationPath(value: string) {
return value
.split("|")
.map((part) => part.trim())
.filter(Boolean);
}
function applyNaverWorksFallbacks(
item: Partial<BulkUserItem> & { metadata: Record<string, string> },
) {
if (!item.name) {
const firstName = item.metadata.naverworks_first_name ?? "";
const lastName = item.metadata.naverworks_last_name ?? "";
item.name = [firstName, lastName].filter(Boolean).join(" ").trim();
if (!item.name && item.metadata.naverworks_nickname) {
item.name = item.metadata.naverworks_nickname;
}
}
if (!item.email) {
item.email = item.metadata.personal_email;
}
if (!item.phone) {
const countryCode = item.metadata.naverworks_mobile_country_code ?? "";
const number = item.metadata.naverworks_mobile_numbers ?? "";
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
}
if (!item.position && item.metadata.naverworks_level) {
item.position = item.metadata.naverworks_level;
}
}

View File

@@ -21,7 +21,7 @@ export type AuditLogListResponse = {
export type TenantSummary = {
id: string;
type: string; // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP
type: string; // 허용 타입: PERSONAL, COMPANY, COMPANY_GROUP, ORGANIZATION, USER_GROUP
name: string;
slug: string;
description: string;
@@ -447,6 +447,7 @@ export type UserAppointment = {
tenantId: string;
tenantSlug?: string;
tenantName: string;
isPrimary?: boolean;
isOwner: boolean;
jobTitle?: string;
position?: string;
@@ -491,6 +492,60 @@ export type BulkUserResponse = {
results: BulkUserResult[];
};
export type WorksmobileOutboxItem = {
id: string;
resourceType: string;
resourceId: string;
action: string;
status: string;
retryCount: number;
lastError?: string;
createdAt: string;
updatedAt: string;
};
export type WorksmobileOverview = {
tenant: TenantSummary;
config: {
enabled: boolean;
tokenConfigured: boolean;
};
recentJobs: WorksmobileOutboxItem[];
};
export type WorksmobileComparisonItem = {
resourceType: string;
baronId?: string;
baronName?: string;
baronEmail?: string;
baronPrimaryOrgId?: string;
baronPrimaryOrgName?: string;
baronParentId?: string;
baronParentName?: string;
worksmobileId?: string;
externalKey?: string;
worksmobileName?: string;
worksmobileEmail?: string;
worksmobileLevelId?: string;
worksmobileLevelName?: string;
worksmobileTask?: string;
worksmobileDomainId?: number;
worksmobileDomainName?: string;
worksmobilePrimaryOrgId?: string;
worksmobilePrimaryOrgName?: string;
worksmobilePrimaryOrgPositionId?: string;
worksmobilePrimaryOrgPositionName?: string;
worksmobilePrimaryOrgIsManager?: boolean;
worksmobileParentId?: string;
worksmobileParentName?: string;
status: string;
};
export type WorksmobileComparison = {
users: WorksmobileComparisonItem[];
groups: WorksmobileComparisonItem[];
};
export async function fetchUsers(
limit = 50,
offset = 0,
@@ -561,12 +616,86 @@ export async function bulkCreateUsers(users: BulkUserItem[]) {
return data;
}
export async function fetchWorksmobileOverview(tenantId: string) {
const { data } = await apiClient.get<WorksmobileOverview>(
`/v1/admin/tenants/${tenantId}/worksmobile`,
);
return data;
}
export async function fetchWorksmobileComparison(
tenantId: string,
includeMatched = false,
) {
const { data } = await apiClient.get<WorksmobileComparison>(
`/v1/admin/tenants/${tenantId}/worksmobile/comparison`,
{
params: { includeMatched },
},
);
return data;
}
export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) {
const response = await apiClient.get<Blob>(
`/v1/admin/tenants/${tenantId}/worksmobile/initial-passwords.csv`,
{
responseType: "blob",
},
);
const dispositionHeader = response.headers["content-disposition"];
const disposition = Array.isArray(dispositionHeader)
? dispositionHeader[0]
: String(dispositionHeader ?? "");
const filenameMatch = disposition?.match(/filename="?([^"]+)"?/i);
return {
blob: response.data,
filename: filenameMatch?.[1] ?? "worksmobile_initial_passwords.csv",
};
}
export async function enqueueWorksmobileBackfillDryRun(tenantId: string) {
const { data } = await apiClient.post(
`/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`,
);
return data;
}
export async function enqueueWorksmobileOrgUnitSync(
tenantId: string,
orgUnitId: string,
) {
const { data } = await apiClient.post<WorksmobileOutboxItem>(
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${orgUnitId}/sync`,
);
return data;
}
export async function enqueueWorksmobileUserSync(
tenantId: string,
userId: string,
) {
const { data } = await apiClient.post<WorksmobileOutboxItem>(
`/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`,
);
return data;
}
export async function retryWorksmobileJob(tenantId: string, jobId: string) {
const { data } = await apiClient.post(
`/v1/admin/tenants/${tenantId}/worksmobile/jobs/${jobId}/retry`,
);
return data;
}
export async function bulkUpdateUsers(payload: {
userIds: string[];
status?: string;
role?: string;
tenantSlug?: string;
department?: string;
position?: string;
jobTitle?: string;
}) {
const requestPayload: typeof payload & { companyCode?: string } = {
...payload,

View File

@@ -16,6 +16,7 @@ saman = "Saman"
[domain.tenant_type]
company = "Company"
company_group = "Company Group"
organization = "Organization"
personal = "Personal"
user_group = "User Group"
@@ -1282,9 +1283,12 @@ active = "Active"
blocked = "Blocked"
failure = "Failure"
inactive = "Inactive"
leave_of_absence = "Leave of absence"
ok = "Ok"
pending = "Pending"
status = "Status"
success = "Success"
suspended = "Suspended"
[test]
key = "Test"

View File

@@ -16,6 +16,7 @@ saman = "삼안"
[domain.tenant_type]
company = "COMPANY (일반 기업)"
company_group = "COMPANY_GROUP (그룹사/지주사)"
organization = "ORGANIZATION (정규 조직)"
personal = "PERSONAL (개인 워크스페이스)"
user_group = "USER_GROUP (내부 부서/팀)"
@@ -1284,9 +1285,12 @@ active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "비활성"
leave_of_absence = "휴직"
ok = "정상"
pending = "준비 중"
status = "상태"
success = "성공"
suspended = "정지"
[test]
key = "테스트"

View File

@@ -16,6 +16,7 @@ saman = ""
[domain.tenant_type]
company = ""
company_group = ""
organization = ""
personal = ""
user_group = ""
@@ -1284,9 +1285,12 @@ active = ""
blocked = ""
failure = ""
inactive = ""
leave_of_absence = ""
ok = ""
pending = ""
status = ""
success = ""
suspended = ""
[test]
key = ""