forked from baron/baron-sso
feat: update worksmobile sync and restore planning
This commit is contained in:
@@ -1,9 +1,16 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type React from "react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import * as adminApi from "../../lib/adminApi";
|
||||
import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage";
|
||||
import TenantListPage from "../tenants/routes/TenantListPage";
|
||||
import UserCreatePage from "../users/UserCreatePage";
|
||||
@@ -198,7 +205,17 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
baronId: "user-2",
|
||||
baronName: "New User",
|
||||
baronEmail: "new@example.com",
|
||||
status: "baron_only",
|
||||
worksmobileJobStatus: "failed",
|
||||
worksmobileJobRetryCount: 2,
|
||||
worksmobileLastError: "worksmobile api failed",
|
||||
status: "missing_in_worksmobile",
|
||||
},
|
||||
{
|
||||
resourceType: "USER",
|
||||
baronId: "user-3",
|
||||
baronName: "Next User",
|
||||
baronEmail: "next@example.com",
|
||||
status: "missing_in_worksmobile",
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
@@ -213,12 +230,55 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
},
|
||||
],
|
||||
})),
|
||||
fetchWorksmobileCredentialBatches: vi.fn(async () => [
|
||||
{
|
||||
batchId: "credential-batch-1",
|
||||
operation: "worksmobile_user_sync",
|
||||
userCount: 1,
|
||||
processedCount: 1,
|
||||
failedCount: 1,
|
||||
hasPasswords: true,
|
||||
failures: [
|
||||
{
|
||||
userId: "failed-user",
|
||||
email: "failed-user@samaneng.com",
|
||||
status: "failed",
|
||||
retryCount: 2,
|
||||
lastError: "worksmobile api failed",
|
||||
updatedAt: "2026-06-01T04:05:00Z",
|
||||
},
|
||||
],
|
||||
createdAt: "2026-06-01T04:00:00Z",
|
||||
updatedAt: "2026-06-01T04:00:00Z",
|
||||
},
|
||||
{
|
||||
batchId: "credential-batch-pending",
|
||||
operation: "worksmobile_user_sync",
|
||||
userCount: 2,
|
||||
pendingCount: 1,
|
||||
processingCount: 1,
|
||||
processedCount: 0,
|
||||
failedCount: 0,
|
||||
hasPasswords: true,
|
||||
createdAt: "2026-06-01T04:10:00Z",
|
||||
updatedAt: "2026-06-01T04:10:00Z",
|
||||
},
|
||||
]),
|
||||
enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })),
|
||||
retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })),
|
||||
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => new Blob(["id"])),
|
||||
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => ({
|
||||
blob: new Blob(["id"]),
|
||||
filename: "worksmobile_initial_passwords.csv",
|
||||
})),
|
||||
enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })),
|
||||
enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })),
|
||||
enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })),
|
||||
resetWorksmobileUserPassword: vi.fn(async () => ({ id: "job-reset" })),
|
||||
deleteWorksmobileCredentialBatchPasswords: vi.fn(async () => ({
|
||||
batchId: "credential-batch-1",
|
||||
userCount: 1,
|
||||
hasPasswords: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, entry = "/") {
|
||||
@@ -292,6 +352,165 @@ describe("adminfront large page coverage smoke", () => {
|
||||
|
||||
expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument();
|
||||
expect(screen.getByText("Baron / Works 비교")).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("최근 실패: worksmobile api failed"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "초기 비밀번호 CSV" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not automatically download the selected Worksmobile user credential batch after create enqueue", async () => {
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("New User");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
expect.any(String),
|
||||
),
|
||||
);
|
||||
const credentialBatchId = vi.mocked(
|
||||
adminApi.enqueueWorksmobileUserSync,
|
||||
).mock.calls[0][2];
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues selected Worksmobile user create enqueue after one row fails", async () => {
|
||||
vi.mocked(adminApi.enqueueWorksmobileUserSync)
|
||||
.mockRejectedValueOnce(new Error("sync failed"))
|
||||
.mockResolvedValueOnce({ id: "job-user-3" } as never);
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("New User");
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Next User 선택" }));
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"tenant-company",
|
||||
"user-2",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"tenant-company",
|
||||
"user-3",
|
||||
expect.any(String),
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("downloads or deletes Worksmobile credential batches from history", async () => {
|
||||
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
|
||||
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("credential-batch-1");
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-pending 비밀번호 CSV 다운로드",
|
||||
}),
|
||||
).toBeDisabled();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 비밀번호 CSV 다운로드",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
adminApi.downloadWorksmobileInitialPasswordsCSV,
|
||||
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 비밀번호 값 삭제",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
adminApi.deleteWorksmobileCredentialBatchPasswords,
|
||||
).toHaveBeenCalledWith("tenant-company", "credential-batch-1"),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "credential-batch-1 실패 사유 보기",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("failed-user@samaneng.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("worksmobile api failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enqueues Worksmobile password reset as a credential batch", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/worksmobile"
|
||||
element={<TenantWorksmobilePage />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/worksmobile",
|
||||
);
|
||||
|
||||
await screen.findByText("Worksmobile 연동");
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "양쪽 다 있음" })[0]);
|
||||
await screen.findAllByText("Engineer User");
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Engineer User 비밀번호 재설정",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(adminApi.resetWorksmobileUserPassword).toHaveBeenCalledWith(
|
||||
"tenant-company",
|
||||
"user-1",
|
||||
expect.any(String),
|
||||
),
|
||||
);
|
||||
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
formatWorksmobilePersonName,
|
||||
formatWorksmobileUpdateDetails,
|
||||
getDefaultGroupComparisonFilters,
|
||||
getDefaultUserComparisonFilters,
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
@@ -460,6 +461,17 @@ describe("TenantWorksmobilePage comparison helpers", () => {
|
||||
).toEqual([rows[0]]);
|
||||
});
|
||||
|
||||
it("shows update-needed user rows by default", () => {
|
||||
const rows = [
|
||||
{ resourceType: "USER", status: "needs_update", baronId: "user-1" },
|
||||
{ resourceType: "USER", status: "matched", baronId: "user-2" },
|
||||
];
|
||||
|
||||
expect(
|
||||
filterWorksmobileComparisonRows(rows, getDefaultUserComparisonFilters()),
|
||||
).toEqual([rows[0]]);
|
||||
});
|
||||
|
||||
it("formats update details for changed organization rows", () => {
|
||||
expect(
|
||||
formatWorksmobileUpdateDetails({
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Download,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
@@ -38,15 +41,19 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
deleteWorksmobileCredentialBatchPasswords,
|
||||
downloadWorksmobileInitialPasswordsCSV,
|
||||
enqueueWorksmobileBackfillDryRun,
|
||||
enqueueWorksmobileOrgUnitDelete,
|
||||
enqueueWorksmobileOrgUnitSync,
|
||||
enqueueWorksmobileUserSync,
|
||||
fetchWorksmobileComparison,
|
||||
fetchWorksmobileCredentialBatches,
|
||||
fetchWorksmobileOverview,
|
||||
resetWorksmobileUserPassword,
|
||||
retryWorksmobileJob,
|
||||
type WorksmobileComparisonItem,
|
||||
type WorksmobileCredentialBatch,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
@@ -61,11 +68,13 @@ import {
|
||||
formatWorksmobilePersonName,
|
||||
formatWorksmobileUpdateDetails,
|
||||
getDefaultGroupComparisonFilters,
|
||||
getDefaultUserComparisonFilters,
|
||||
getDefaultWorksmobileComparisonColumns,
|
||||
getWorksmobileComparisonStatusLabel,
|
||||
getWorksmobileRowSelectionKey,
|
||||
getWorksmobileSelectedActionIds,
|
||||
getWorksmobileSelectedWorksOnlyOrgUnitIds,
|
||||
isImmutableWorksmobileAccount,
|
||||
summarizeWorksmobileComparison,
|
||||
type WorksmobileComparisonColumnKey,
|
||||
type WorksmobileComparisonColumnVisibility,
|
||||
@@ -73,6 +82,17 @@ import {
|
||||
type WorksmobileComparisonSummary,
|
||||
} from "./worksmobileComparison";
|
||||
|
||||
type InitialPasswordDownloadVariables = {
|
||||
batchId?: string;
|
||||
};
|
||||
|
||||
export function createWorksmobileCredentialBatchId() {
|
||||
if (globalThis.crypto?.randomUUID) {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `worksmobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
export function TenantWorksmobilePage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
@@ -80,7 +100,7 @@ export function TenantWorksmobilePage() {
|
||||
const [userId, setUserId] = React.useState("");
|
||||
const [userFilters, setUserFilters] = React.useState<
|
||||
WorksmobileComparisonFilter[]
|
||||
>(["baron_only", "works_only"]);
|
||||
>(getDefaultUserComparisonFilters);
|
||||
const [groupFilters, setGroupFilters] = React.useState<
|
||||
WorksmobileComparisonFilter[]
|
||||
>(getDefaultGroupComparisonFilters);
|
||||
@@ -115,6 +135,12 @@ export function TenantWorksmobilePage() {
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const credentialBatchesQuery = useQuery({
|
||||
queryKey: ["worksmobile-credential-batches", tenantId],
|
||||
queryFn: () => fetchWorksmobileCredentialBatches(tenantId),
|
||||
enabled: tenantId.length > 0,
|
||||
});
|
||||
|
||||
const dryRunMutation = useMutation({
|
||||
mutationFn: () => enqueueWorksmobileBackfillDryRun(tenantId),
|
||||
onSuccess: () => {
|
||||
@@ -142,7 +168,8 @@ export function TenantWorksmobilePage() {
|
||||
});
|
||||
|
||||
const initialPasswordDownloadMutation = useMutation({
|
||||
mutationFn: () => downloadWorksmobileInitialPasswordsCSV(tenantId),
|
||||
mutationFn: (variables?: InitialPasswordDownloadVariables) =>
|
||||
downloadWorksmobileInitialPasswordsCSV(tenantId, variables?.batchId),
|
||||
onSuccess: ({ blob, filename }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -160,6 +187,20 @@ export function TenantWorksmobilePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteCredentialBatchPasswordsMutation = useMutation({
|
||||
mutationFn: (batchId: string) =>
|
||||
deleteWorksmobileCredentialBatchPasswords(tenantId, batchId),
|
||||
onSuccess: () => {
|
||||
toast.success("비밀번호 값을 삭제했습니다.");
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("비밀번호 값 삭제 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const orgUnitSyncMutation = useMutation({
|
||||
mutationFn: () => enqueueWorksmobileOrgUnitSync(tenantId, orgUnitId.trim()),
|
||||
onSuccess: () => {
|
||||
@@ -194,26 +235,60 @@ export function TenantWorksmobilePage() {
|
||||
resourceKind: "users" | "groups";
|
||||
ids: string[];
|
||||
}) => {
|
||||
const credentialBatchId =
|
||||
resourceKind === "users"
|
||||
? createWorksmobileCredentialBatchId()
|
||||
: undefined;
|
||||
const failures: string[] = [];
|
||||
let successCount = 0;
|
||||
for (const id of ids) {
|
||||
if (resourceKind === "users") {
|
||||
await enqueueWorksmobileUserSync(tenantId, id);
|
||||
} else {
|
||||
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
||||
try {
|
||||
if (resourceKind === "users") {
|
||||
await enqueueWorksmobileUserSync(tenantId, id, credentialBatchId);
|
||||
} else {
|
||||
await enqueueWorksmobileOrgUnitSync(tenantId, id);
|
||||
}
|
||||
successCount += 1;
|
||||
} catch (error) {
|
||||
failures.push(`${id}: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
return { resourceKind, count: ids.length };
|
||||
|
||||
if (successCount === 0 && failures.length > 0) {
|
||||
throw new Error(failures.slice(0, 3).join("\n"));
|
||||
}
|
||||
|
||||
return {
|
||||
resourceKind,
|
||||
count: successCount,
|
||||
failureCount: failures.length,
|
||||
credentialBatchId:
|
||||
resourceKind === "users" && successCount > 0
|
||||
? credentialBatchId
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
onSuccess: ({ resourceKind, count }) => {
|
||||
onSuccess: ({ resourceKind, count, failureCount }) => {
|
||||
if (resourceKind === "users") {
|
||||
setSelectedUserRowKeys([]);
|
||||
} else {
|
||||
setSelectedGroupRowKeys([]);
|
||||
}
|
||||
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
||||
description: `${count}건`,
|
||||
});
|
||||
if (failureCount > 0) {
|
||||
toast.error("일부 WORKS 생성 작업 등록 실패", {
|
||||
description: `성공 ${count}건, 실패 ${failureCount}건`,
|
||||
});
|
||||
} else {
|
||||
toast.success("WORKS 생성 작업을 등록했습니다.", {
|
||||
description:
|
||||
resourceKind === "users"
|
||||
? `${count}건, 비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.`
|
||||
: `${count}건`,
|
||||
});
|
||||
}
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("WORKS 생성 작업 등록 실패", {
|
||||
@@ -222,6 +297,30 @@ export function TenantWorksmobilePage() {
|
||||
},
|
||||
});
|
||||
|
||||
const resetWorksmobilePasswordMutation = useMutation({
|
||||
mutationFn: ({
|
||||
userId,
|
||||
credentialBatchId,
|
||||
}: {
|
||||
userId: string;
|
||||
credentialBatchId: string;
|
||||
}) => resetWorksmobileUserPassword(tenantId, userId, credentialBatchId),
|
||||
onSuccess: () => {
|
||||
toast.success("WORKS 비밀번호 재설정 작업을 등록했습니다.", {
|
||||
description:
|
||||
"비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.",
|
||||
});
|
||||
overviewQuery.refetch();
|
||||
comparisonQuery.refetch();
|
||||
credentialBatchesQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("WORKS 비밀번호 재설정 등록 실패", {
|
||||
description: getErrorMessage(error),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const syncSelectedOrgUnitsMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
baronIds,
|
||||
@@ -294,7 +393,10 @@ export function TenantWorksmobilePage() {
|
||||
createSelectedMutation.isPending &&
|
||||
createSelectedMutation.variables?.resourceKind === "users";
|
||||
const isSyncingGroups = syncSelectedOrgUnitsMutation.isPending;
|
||||
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
|
||||
const isRefreshing =
|
||||
overviewQuery.isFetching ||
|
||||
comparisonQuery.isFetching ||
|
||||
credentialBatchesQuery.isFetching;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 max-w-full space-y-6">
|
||||
@@ -311,24 +413,13 @@ export function TenantWorksmobilePage() {
|
||||
</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();
|
||||
credentialBatchesQuery.refetch();
|
||||
}}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
@@ -346,6 +437,29 @@ export function TenantWorksmobilePage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<CredentialBatchHistory
|
||||
batches={credentialBatchesQuery.data ?? []}
|
||||
loading={credentialBatchesQuery.isLoading}
|
||||
downloadingBatchId={
|
||||
initialPasswordDownloadMutation.isPending
|
||||
? initialPasswordDownloadMutation.variables?.batchId
|
||||
: undefined
|
||||
}
|
||||
deletingBatchId={deleteCredentialBatchPasswordsMutation.variables}
|
||||
onDownload={(batchId) =>
|
||||
initialPasswordDownloadMutation.mutate({ batchId })
|
||||
}
|
||||
onDelete={(batchId) => {
|
||||
if (
|
||||
window.confirm(
|
||||
"이 배치의 실제 비밀번호 값을 삭제할까요? 생성 이력은 유지됩니다.",
|
||||
)
|
||||
) {
|
||||
deleteCredentialBatchPasswordsMutation.mutate(batchId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -408,6 +522,23 @@ export function TenantWorksmobilePage() {
|
||||
ids,
|
||||
})
|
||||
}
|
||||
resettingPasswordUserId={
|
||||
resetWorksmobilePasswordMutation.isPending
|
||||
? resetWorksmobilePasswordMutation.variables?.userId
|
||||
: undefined
|
||||
}
|
||||
onResetUserPassword={(userId) => {
|
||||
if (
|
||||
window.confirm(
|
||||
"선택한 WORKS 계정의 비밀번호를 재설정할까요? 새 비밀번호는 배치 처리 완료 후 히스토리에서 CSV로 다운로드할 수 있습니다.",
|
||||
)
|
||||
) {
|
||||
resetWorksmobilePasswordMutation.mutate({
|
||||
userId,
|
||||
credentialBatchId: createWorksmobileCredentialBatchId(),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ComparisonTable
|
||||
title={t(
|
||||
@@ -605,6 +736,216 @@ function getWorksmobileComparisonStatusVariant(status: string) {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function formatCredentialBatchDate(value?: string) {
|
||||
if (!value) return "-";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
return date.toLocaleString("ko-KR", {
|
||||
timeZone: "Asia/Seoul",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function CredentialBatchHistory({
|
||||
batches,
|
||||
loading,
|
||||
downloadingBatchId,
|
||||
deletingBatchId,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: {
|
||||
batches: WorksmobileCredentialBatch[];
|
||||
loading: boolean;
|
||||
downloadingBatchId?: string;
|
||||
deletingBatchId?: string;
|
||||
onDownload: (batchId: string) => void;
|
||||
onDelete: (batchId: string) => void;
|
||||
}) {
|
||||
const [expandedBatchIds, setExpandedBatchIds] = React.useState<string[]>([]);
|
||||
const toggleExpanded = (batchId: string) => {
|
||||
setExpandedBatchIds((current) =>
|
||||
current.includes(batchId)
|
||||
? current.filter((id) => id !== batchId)
|
||||
: [...current, batchId],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">비밀번호 파일 히스토리</CardTitle>
|
||||
<CardDescription>
|
||||
생성 배치별 CSV를 다시 받거나 전달 완료된 배치의 실제 비밀번호 값을
|
||||
삭제합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="w-full max-w-full overflow-x-auto rounded-md border">
|
||||
<Table className="min-w-max">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="min-w-56 whitespace-nowrap">
|
||||
배치
|
||||
</TableHead>
|
||||
<TableHead className="w-24 whitespace-nowrap">사용자</TableHead>
|
||||
<TableHead className="min-w-36 whitespace-nowrap">
|
||||
상태
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
생성
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44 whitespace-nowrap">
|
||||
삭제
|
||||
</TableHead>
|
||||
<TableHead className="w-24 whitespace-nowrap">관리</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
불러오는 중...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && batches.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground">
|
||||
생성된 비밀번호 배치가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{batches.map((batch) => {
|
||||
const isComplete =
|
||||
(batch.pendingCount ?? 0) === 0 &&
|
||||
(batch.processingCount ?? 0) === 0;
|
||||
const isExpanded = expandedBatchIds.includes(batch.batchId);
|
||||
const failures = batch.failures ?? [];
|
||||
return (
|
||||
<React.Fragment key={batch.batchId}>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
{failures.length > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 실패 사유 보기`}
|
||||
onClick={() => toggleExpanded(batch.batchId)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<span>{batch.batchId}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono">
|
||||
{batch.userCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
<span className="mr-2">
|
||||
성공 {batch.processedCount ?? 0}
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
대기 {batch.pendingCount ?? 0}
|
||||
</span>
|
||||
<span className="mr-2">
|
||||
처리 {batch.processingCount ?? 0}
|
||||
</span>
|
||||
<span>실패 {batch.failedCount ?? 0}</span>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{formatCredentialBatchDate(batch.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{batch.hasPasswords
|
||||
? "보관 중"
|
||||
: formatCredentialBatchDate(batch.deletedAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 비밀번호 CSV 다운로드`}
|
||||
disabled={
|
||||
!batch.hasPasswords ||
|
||||
!isComplete ||
|
||||
downloadingBatchId === batch.batchId
|
||||
}
|
||||
onClick={() => onDownload(batch.batchId)}
|
||||
>
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`${batch.batchId} 비밀번호 값 삭제`}
|
||||
disabled={
|
||||
!batch.hasPasswords ||
|
||||
deletingBatchId === batch.batchId
|
||||
}
|
||||
onClick={() => onDelete(batch.batchId)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded && failures.length > 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="bg-muted/30">
|
||||
<div className="space-y-2 text-xs">
|
||||
{failures.map((failure) => (
|
||||
<div
|
||||
key={`${failure.userId ?? failure.email}:${failure.lastError}`}
|
||||
className="grid gap-1 md:grid-cols-[minmax(12rem,1fr)_5rem_minmax(18rem,2fr)]"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{failure.email ?? failure.userId ?? "-"}
|
||||
</div>
|
||||
{failure.userId && (
|
||||
<div className="font-mono text-muted-foreground">
|
||||
{failure.userId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{failure.status} / retry{" "}
|
||||
{failure.retryCount ?? 0}
|
||||
</div>
|
||||
<div className="break-words">
|
||||
{failure.lastError ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonSummary({
|
||||
title,
|
||||
summary,
|
||||
@@ -740,6 +1081,8 @@ function ComparisonTable({
|
||||
deleteActionLabel,
|
||||
deleteActionDisabled = false,
|
||||
onDeleteSelected,
|
||||
resettingPasswordUserId,
|
||||
onResetUserPassword,
|
||||
}: {
|
||||
title: string;
|
||||
rows: WorksmobileComparisonItem[];
|
||||
@@ -768,6 +1111,8 @@ function ComparisonTable({
|
||||
deleteActionLabel?: string;
|
||||
deleteActionDisabled?: boolean;
|
||||
onDeleteSelected?: (ids: string[]) => void;
|
||||
resettingPasswordUserId?: string;
|
||||
onResetUserPassword?: (userId: string) => void;
|
||||
}) {
|
||||
const [columnSettingsOpen, setColumnSettingsOpen] = React.useState(false);
|
||||
const selectableKeys = rows
|
||||
@@ -841,6 +1186,15 @@ function ComparisonTable({
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const canResetPassword = (row: WorksmobileComparisonItem) =>
|
||||
Boolean(
|
||||
onResetUserPassword &&
|
||||
row.resourceType === "USER" &&
|
||||
row.baronId &&
|
||||
row.status !== "missing_in_worksmobile" &&
|
||||
!isImmutableWorksmobileAccount(row),
|
||||
);
|
||||
|
||||
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
|
||||
onVisibleColumnsChange((current) => ({
|
||||
...current,
|
||||
@@ -1169,21 +1523,40 @@ function ComparisonTable({
|
||||
{showManageColumn && 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>
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 재설정`}
|
||||
disabled={
|
||||
!canResetPassword(row) ||
|
||||
resettingPasswordUserId === row.baronId
|
||||
}
|
||||
onClick={() => {
|
||||
if (row.baronId) {
|
||||
onResetUserPassword?.(row.baronId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
@@ -300,6 +300,9 @@ export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
|
||||
}
|
||||
|
||||
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||
if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) {
|
||||
return [`최근 실패: ${row.worksmobileLastError}`];
|
||||
}
|
||||
if (row.status !== "needs_update") {
|
||||
return [];
|
||||
}
|
||||
@@ -310,6 +313,21 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
|
||||
if (baronName && worksmobileName && baronName !== worksmobileName) {
|
||||
details.push(`이름: ${worksmobileName} -> ${baronName}`);
|
||||
}
|
||||
if (row.resourceType === "USER") {
|
||||
const expectedExternalKey = row.baronId?.trim() ?? "";
|
||||
const actualExternalKey = row.externalKey?.trim() ?? "";
|
||||
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
|
||||
details.push(
|
||||
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
|
||||
);
|
||||
}
|
||||
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
|
||||
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
|
||||
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
|
||||
details.push(`이메일: ${actualEmail} -> ${expectedEmail}`);
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
const expectedParent =
|
||||
row.baronParentWorksmobileName ??
|
||||
@@ -395,6 +413,10 @@ export const comparisonFilterOptions: Array<{
|
||||
|
||||
export const userFilterOptions = comparisonFilterOptions;
|
||||
|
||||
export function getDefaultUserComparisonFilters(): WorksmobileComparisonFilter[] {
|
||||
return ["baron_only", "needs_update", "works_only"];
|
||||
}
|
||||
|
||||
export function getDefaultGroupComparisonFilters(): WorksmobileComparisonFilter[] {
|
||||
return ["baron_only", "needs_update", "works_only"];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import UserDetailPage from "./UserDetailPage";
|
||||
|
||||
const updateUserMock = vi.hoisted(() => vi.fn());
|
||||
const profileRoleMock = vi.hoisted(() => ({ role: "super_admin" }));
|
||||
|
||||
vi.mock("../../lib/i18n", () => createI18nMock());
|
||||
|
||||
vi.mock("../../lib/adminApi", () => ({
|
||||
deleteUser: vi.fn(),
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
})),
|
||||
fetchMe: vi.fn(async () => ({
|
||||
id: "admin-user",
|
||||
role: profileRoleMock.role,
|
||||
name: "Admin",
|
||||
email: "admin@example.com",
|
||||
})),
|
||||
fetchPasswordPolicy: vi.fn(async () => ({ minLength: 12 })),
|
||||
fetchTenant: vi.fn(),
|
||||
fetchUser: vi.fn(async () => ({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
name: "사용자",
|
||||
phone: "01012345678",
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug: "hanmac",
|
||||
tenant: {
|
||||
id: "tenant-hanmac",
|
||||
type: "COMPANY",
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
description: "",
|
||||
status: "active",
|
||||
memberCount: 1,
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
},
|
||||
joinedTenants: [],
|
||||
metadata: {
|
||||
employee_id: {
|
||||
"0": "h",
|
||||
"1": "j",
|
||||
"2": "k",
|
||||
"3": "w",
|
||||
"4": "o",
|
||||
"5": "n",
|
||||
},
|
||||
},
|
||||
createdAt: "2026-06-01T00:00:00Z",
|
||||
updatedAt: "2026-06-01T00:00:00Z",
|
||||
})),
|
||||
fetchUserRpHistory: vi.fn(async () => []),
|
||||
updateUser: updateUserMock,
|
||||
}));
|
||||
|
||||
function renderUserDetailPage() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={["/users/user-1"]}>
|
||||
<Routes>
|
||||
<Route path="/users/:id" element={<UserDetailPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("UserDetailPage Worksmobile employee number", () => {
|
||||
beforeEach(() => {
|
||||
updateUserMock.mockReset();
|
||||
updateUserMock.mockResolvedValue({});
|
||||
profileRoleMock.role = "super_admin";
|
||||
});
|
||||
|
||||
it("shows and saves metadata employee_id from the user edit form", async () => {
|
||||
renderUserDetailPage();
|
||||
|
||||
const employeeInput = await screen.findByLabelText("사번");
|
||||
|
||||
expect(employeeInput).toHaveValue("hjkwon");
|
||||
|
||||
fireEvent.change(employeeInput, { target: { value: "EMP001" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
expect(updateUserMock).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ employee_id: "EMP001" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows super admin to save a changed email", async () => {
|
||||
renderUserDetailPage();
|
||||
|
||||
const emailInput = await screen.findByLabelText("이메일");
|
||||
fireEvent.change(emailInput, { target: { value: "changed@example.com" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
expect(updateUserMock).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
expect.objectContaining({
|
||||
email: "changed@example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps email read-only for non-super admin", async () => {
|
||||
profileRoleMock.role = "tenant_admin";
|
||||
renderUserDetailPage();
|
||||
|
||||
const emailInput = await screen.findByLabelText("이메일");
|
||||
|
||||
expect(emailInput).toBeDisabled();
|
||||
});
|
||||
|
||||
it("removes metadata employee_id when the field is cleared", async () => {
|
||||
renderUserDetailPage();
|
||||
|
||||
const employeeInput = await screen.findByLabelText("사번");
|
||||
|
||||
fireEvent.change(employeeInput, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
|
||||
|
||||
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
|
||||
const payload = updateUserMock.mock.calls[0][1];
|
||||
expect(payload.metadata).not.toHaveProperty("employee_id");
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,7 @@ import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||
email: string;
|
||||
metadata: Record<string, unknown> & {
|
||||
employee_id?: string;
|
||||
sub_email?: string | string[];
|
||||
};
|
||||
};
|
||||
@@ -130,6 +131,29 @@ function cleanMetadataValue(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeEmployeeIDMetadataValue(value: unknown) {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return String(value).trim();
|
||||
}
|
||||
if (!isMetadataRecord(value)) {
|
||||
return "";
|
||||
}
|
||||
const entries = Object.entries(value)
|
||||
.map(([key, fieldValue]) => ({
|
||||
index: Number(key),
|
||||
value: typeof fieldValue === "string" ? fieldValue : "",
|
||||
}))
|
||||
.filter((entry) => Number.isInteger(entry.index) && entry.value.length > 0)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
if (entries.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return entries
|
||||
.map((entry) => entry.value)
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeSubEmails(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
@@ -699,6 +723,9 @@ function UserDetailPage() {
|
||||
string,
|
||||
Record<string, string | number | boolean>
|
||||
>) || {}),
|
||||
employee_id: normalizeEmployeeIDMetadataValue(
|
||||
user.metadata?.employee_id,
|
||||
),
|
||||
sub_email: Array.isArray(user.metadata?.sub_email)
|
||||
? user.metadata.sub_email
|
||||
: typeof user.metadata?.sub_email === "string"
|
||||
@@ -837,15 +864,22 @@ function UserDetailPage() {
|
||||
...safeMetadata,
|
||||
...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }),
|
||||
};
|
||||
const employeeID = String(data.metadata?.employee_id ?? "").trim();
|
||||
if (employeeID) {
|
||||
metadata.employee_id = employeeID;
|
||||
} else {
|
||||
delete metadata.employee_id;
|
||||
}
|
||||
|
||||
const payload: UserUpdateRequest = {
|
||||
...data,
|
||||
metadata,
|
||||
};
|
||||
// email cannot be updated directly via this API in current backend implementation,
|
||||
// so we delete it from payload if it spread
|
||||
// @ts-expect-error
|
||||
delete payload.email;
|
||||
if (profileRole !== "super_admin") {
|
||||
delete payload.email;
|
||||
} else {
|
||||
payload.email = data.email.trim();
|
||||
}
|
||||
payload.role = undefined;
|
||||
|
||||
if (userCategory === "personal") {
|
||||
@@ -1107,9 +1141,19 @@ function UserDetailPage() {
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
value={user.email}
|
||||
disabled
|
||||
className="bg-muted/50 border-none font-medium h-11"
|
||||
type="email"
|
||||
disabled={profileRole !== "super_admin"}
|
||||
{...register("email", {
|
||||
required: t(
|
||||
"msg.admin.users.detail.email_required",
|
||||
"이메일을 입력하세요.",
|
||||
),
|
||||
})}
|
||||
className={
|
||||
profileRole === "super_admin"
|
||||
? "h-11 shadow-sm"
|
||||
: "bg-muted/50 border-none font-medium h-11"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -1146,6 +1190,37 @@ function UserDetailPage() {
|
||||
className="h-11 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="metadata_employee_id"
|
||||
className="text-xs font-bold uppercase text-muted-foreground"
|
||||
>
|
||||
사번
|
||||
</Label>
|
||||
<Input
|
||||
id="metadata_employee_id"
|
||||
maxLength={20}
|
||||
{...register("metadata.employee_id", {
|
||||
setValueAs: (value) =>
|
||||
typeof value === "string" ? value.trim() : value,
|
||||
maxLength: {
|
||||
value: 20,
|
||||
message:
|
||||
"Worksmobile 사번은 20자 이하로 입력해야 합니다.",
|
||||
},
|
||||
})}
|
||||
className="h-11 shadow-sm"
|
||||
/>
|
||||
{errors.metadata?.employee_id && (
|
||||
<p className="text-xs text-destructive">
|
||||
{String(errors.metadata.employee_id.message)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
Worksmobile employeeNumber로 전송됩니다. 1~20자만
|
||||
허용됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 md:grid-cols-2 pt-6 border-t border-dashed">
|
||||
|
||||
@@ -115,6 +115,13 @@ function hanmacEmailStatusLabel(preview?: HanmacImportEmailPreview) {
|
||||
return "";
|
||||
}
|
||||
|
||||
function userImportErrorLabel(user: BulkUserItem) {
|
||||
if (!user.importErrors?.includes("duplicateEmail")) {
|
||||
return "";
|
||||
}
|
||||
return "중복 이메일";
|
||||
}
|
||||
|
||||
function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
||||
if (!preview) return "text-muted-foreground";
|
||||
if (preview.status === "blockingError") return "text-destructive";
|
||||
@@ -355,6 +362,9 @@ export function UserBulkUploadModal({
|
||||
const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some(
|
||||
(preview) => preview?.status === "blockingError",
|
||||
);
|
||||
const hasBlockingImportRows = previewData.some(
|
||||
(user) => (user.importErrors?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
const triggerProps = {
|
||||
disabled: mutation.isPending,
|
||||
@@ -576,11 +586,22 @@ export function UserBulkUploadModal({
|
||||
<td className="p-2">{u.name}</td>
|
||||
<td className="p-2">{u.tenantSlug || "-"}</td>
|
||||
<td
|
||||
className={`p-2 text-xs ${hanmacEmailStatusClass(
|
||||
hanmacEmailPreviews[index],
|
||||
)}`}
|
||||
className={`p-2 text-xs ${
|
||||
u.importErrors?.length
|
||||
? "text-destructive"
|
||||
: hanmacEmailStatusClass(
|
||||
hanmacEmailPreviews[index],
|
||||
)
|
||||
}`}
|
||||
>
|
||||
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
|
||||
{u.importErrors?.length
|
||||
? "오류"
|
||||
: hanmacEmailStatusLabel(
|
||||
hanmacEmailPreviews[index],
|
||||
)}
|
||||
{u.importErrors?.length ? (
|
||||
<div>{userImportErrorLabel(u)}</div>
|
||||
) : null}
|
||||
{hanmacEmailPreviews[index]?.reason && (
|
||||
<div>{hanmacEmailPreviews[index]?.reason}</div>
|
||||
)}
|
||||
@@ -665,7 +686,8 @@ export function UserBulkUploadModal({
|
||||
previewData.length === 0 ||
|
||||
mutation.isPending ||
|
||||
preparing ||
|
||||
hasBlockingHanmacEmailRows
|
||||
hasBlockingHanmacEmailRows ||
|
||||
hasBlockingImportRows
|
||||
}
|
||||
className="w-full sm:w-auto"
|
||||
data-testid="bulk-start-btn"
|
||||
|
||||
@@ -97,6 +97,22 @@ test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-sl
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve exported user_id for UUID based restore", () => {
|
||||
const csv = `user_id,email,name,tenant_id,tenant_slug
|
||||
9f8cc1b1-af8d-45d4-946c-924a529c2556,restore@test.com,Restore User,tenant-id,restore-tenant`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
userId: "9f8cc1b1-af8d-45d4-946c-924a529c2556",
|
||||
email: "restore@test.com",
|
||||
name: "Restore User",
|
||||
tenantId: "tenant-id",
|
||||
tenantSlug: "restore-tenant",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse one nullable additional appointment from numbered columns", () => {
|
||||
const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
|
||||
dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002
|
||||
@@ -146,4 +162,28 @@ primary@samaneng.com,Primary User,rnd-saman,EMP001,secondary@hanmaceng.co.kr`;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should mark duplicate bulk alias emails as blocking import errors", () => {
|
||||
const csv = `email,name,tenant_slug,sub_email
|
||||
user1@samaneng.com,User One,rnd-saman,shared@hanmaceng.co.kr
|
||||
user2@samaneng.com,User Two,rnd-saman,shared@hanmaceng.co.kr`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].importErrors).toContain("duplicateEmail");
|
||||
expect(result[1].importErrors).toContain("duplicateEmail");
|
||||
});
|
||||
|
||||
it("should mark a primary email reused as a sub email as a blocking import error", () => {
|
||||
const csv = `email,name,tenant_slug,sub_email
|
||||
user1@samaneng.com,User One,rnd-saman,user2@samaneng.com
|
||||
user2@samaneng.com,User Two,rnd-saman,alias@hanmaceng.co.kr`;
|
||||
|
||||
const result = parseUserCSV(csv);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].importErrors).toContain("duplicateEmail");
|
||||
expect(result[1].importErrors).toContain("duplicateEmail");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,9 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
const value = values[index];
|
||||
if (value === undefined || value === "") continue;
|
||||
|
||||
if (header === "email") {
|
||||
if (header === "user_id") {
|
||||
item.userId = value;
|
||||
} else if (header === "email") {
|
||||
item.email = value;
|
||||
} else if (header === "name") {
|
||||
item.name = value;
|
||||
@@ -186,7 +188,7 @@ export function parseUserCSV(text: string): BulkUserItem[] {
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
return markBulkEmailDuplicateErrors(data);
|
||||
}
|
||||
|
||||
function cleanAdditionalAppointment(
|
||||
@@ -335,6 +337,66 @@ function uniqueEmails(values: string[]) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function bulkUserImportErrorList(user: BulkUserItem) {
|
||||
return Array.isArray(user.importErrors) ? user.importErrors : [];
|
||||
}
|
||||
|
||||
function withBulkUserImportError(user: BulkUserItem, error: string) {
|
||||
const errors = Array.from(new Set([...bulkUserImportErrorList(user), error]));
|
||||
return { ...user, importErrors: errors };
|
||||
}
|
||||
|
||||
function bulkUserAliasEmails(user: BulkUserItem) {
|
||||
return uniqueEmails([
|
||||
...metadataEmailList(user.metadata.sub_email),
|
||||
...metadataEmailList(user.metadata.aliasEmails),
|
||||
...metadataEmailList(user.metadata.secondary_emails),
|
||||
...metadataEmailList(user.metadata.worksmobileAliasEmails),
|
||||
]);
|
||||
}
|
||||
|
||||
function markBulkEmailDuplicateErrors(users: BulkUserItem[]) {
|
||||
const duplicateIndexes = new Set<number>();
|
||||
const owners = new Map<string, Set<number>>();
|
||||
|
||||
users.forEach((user, index) => {
|
||||
const primaryEmail = user.email.trim().toLowerCase();
|
||||
const aliases = bulkUserAliasEmails(user);
|
||||
const rowEmails = new Set<string>();
|
||||
|
||||
if (primaryEmail) {
|
||||
rowEmails.add(primaryEmail);
|
||||
}
|
||||
for (const alias of aliases) {
|
||||
if (primaryEmail && alias === primaryEmail) {
|
||||
duplicateIndexes.add(index);
|
||||
}
|
||||
rowEmails.add(alias);
|
||||
}
|
||||
|
||||
for (const email of rowEmails) {
|
||||
const existing = owners.get(email) ?? new Set<number>();
|
||||
existing.add(index);
|
||||
owners.set(email, existing);
|
||||
}
|
||||
});
|
||||
|
||||
for (const indexes of owners.values()) {
|
||||
if (indexes.size < 2) {
|
||||
continue;
|
||||
}
|
||||
for (const index of indexes) {
|
||||
duplicateIndexes.add(index);
|
||||
}
|
||||
}
|
||||
|
||||
return users.map((user, index) =>
|
||||
duplicateIndexes.has(index)
|
||||
? withBulkUserImportError(user, "duplicateEmail")
|
||||
: user,
|
||||
);
|
||||
}
|
||||
|
||||
function addWorksmobileAliasEmails(
|
||||
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
|
||||
emails: string[],
|
||||
|
||||
Reference in New Issue
Block a user