1
0
forked from baron/baron-sso

feat: update worksmobile sync and restore planning

This commit is contained in:
2026-06-01 17:01:53 +09:00
parent 6574fb54b9
commit 5c8a338085
36 changed files with 3922 additions and 243 deletions

View File

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

View File

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

View File

@@ -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"];
}