From 5c8a338085c43f5cb09fff34528ad0aada7922c3 Mon Sep 17 00:00:00 2001 From: Lectom Date: Mon, 1 Jun 2026 17:01:53 +0900 Subject: [PATCH] feat: update worksmobile sync and restore planning --- adminfront/scripts/runtime-mode.sh | 2 +- .../coverage/adminLargePages.test.tsx | 225 +++++++- .../routes/TenantWorksmobilePage.test.ts | 12 + .../tenants/routes/TenantWorksmobilePage.tsx | 451 ++++++++++++++-- .../tenants/routes/worksmobileComparison.ts | 22 + .../UserDetailPage.employeeNumber.test.tsx | 155 ++++++ .../src/features/users/UserDetailPage.tsx | 89 ++- .../users/components/UserBulkUploadModal.tsx | 32 +- .../features/users/utils/csvParser.test.ts | 40 ++ .../src/features/users/utils/csvParser.ts | 66 ++- adminfront/src/lib/adminApi.contract.test.ts | 40 ++ adminfront/src/lib/adminApi.ts | 81 ++- backend/cmd/server/main.go | 3 + backend/internal/domain/worksmobile.go | 9 +- backend/internal/handler/user_handler.go | 195 ++++++- backend/internal/handler/user_handler_test.go | 297 ++++++++++ .../internal/handler/worksmobile_handler.go | 61 ++- .../handler/worksmobile_handler_test.go | 123 ++++- .../worksmobile_outbox_repository.go | 20 + .../internal/service/kratos_admin_service.go | 6 + backend/internal/service/ory_service.go | 6 + backend/internal/service/ory_service_test.go | 71 +++ .../internal/service/worksmobile_client.go | 112 +++- .../service/worksmobile_client_test.go | 225 +++++++- .../service/worksmobile_live_flow_test.go | 4 +- .../internal/service/worksmobile_mapper.go | 276 ++++++++-- .../service/worksmobile_mapper_test.go | 173 +++++- .../service/worksmobile_relay_worker.go | 17 + .../service/worksmobile_sync_service.go | 508 +++++++++++++++++- .../service/worksmobile_sync_service_test.go | 347 +++++++++++- common/pnpm-lock.yaml | 78 +-- devfront/scripts/runtime-mode.sh | 2 +- docs/backup-restore-design.md | 349 ++++++++++++ orgfront/scripts/runtime-mode.sh | 2 +- user_bulk_gpdtdc.CSV | 6 +- userfront/scripts/dev-server.sh | 60 +++ 36 files changed, 3922 insertions(+), 243 deletions(-) create mode 100644 adminfront/src/features/users/UserDetailPage.employeeNumber.test.tsx create mode 100644 docs/backup-restore-design.md diff --git a/adminfront/scripts/runtime-mode.sh b/adminfront/scripts/runtime-mode.sh index 481ae1ca..c70cf241 100644 --- a/adminfront/scripts/runtime-mode.sh +++ b/adminfront/scripts/runtime-mode.sh @@ -106,7 +106,7 @@ ensure_frontend_dependencies() { return 0 fi if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then - (cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --no-frozen-lockfile --ignore-scripts) + (cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts) else npm ci fi diff --git a/adminfront/src/features/coverage/adminLargePages.test.tsx b/adminfront/src/features/coverage/adminLargePages.test.tsx index 5ac4055d..9c4537c8 100644 --- a/adminfront/src/features/coverage/adminLargePages.test.tsx +++ b/adminfront/src/features/coverage/adminLargePages.test.tsx @@ -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( + + } + /> + , + "/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( + + } + /> + , + "/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( + + } + /> + , + "/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( + + } + /> + , + "/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(); }); }); diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts index 68257648..b19a9785 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.test.ts @@ -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({ diff --git a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx index a9002610..79f19672 100644 --- a/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantWorksmobilePage.tsx @@ -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 (
@@ -311,24 +413,13 @@ export function TenantWorksmobilePage() {

-
+ + initialPasswordDownloadMutation.mutate({ batchId }) + } + onDelete={(batchId) => { + if ( + window.confirm( + "이 배치의 실제 비밀번호 값을 삭제할까요? 생성 이력은 유지됩니다.", + ) + ) { + deleteCredentialBatchPasswordsMutation.mutate(batchId); + } + }} + /> +
@@ -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(), + }); + } + }} /> void; + onDelete: (batchId: string) => void; +}) { + const [expandedBatchIds, setExpandedBatchIds] = React.useState([]); + const toggleExpanded = (batchId: string) => { + setExpandedBatchIds((current) => + current.includes(batchId) + ? current.filter((id) => id !== batchId) + : [...current, batchId], + ); + }; + + return ( + + + 비밀번호 파일 히스토리 + + 생성 배치별 CSV를 다시 받거나 전달 완료된 배치의 실제 비밀번호 값을 + 삭제합니다. + + + +
+ + + + + 배치 + + 사용자 + + 상태 + + + 생성 + + + 삭제 + + 관리 + + + + {loading && ( + + + 불러오는 중... + + + )} + {!loading && batches.length === 0 && ( + + + 생성된 비밀번호 배치가 없습니다. + + + )} + {batches.map((batch) => { + const isComplete = + (batch.pendingCount ?? 0) === 0 && + (batch.processingCount ?? 0) === 0; + const isExpanded = expandedBatchIds.includes(batch.batchId); + const failures = batch.failures ?? []; + return ( + + + +
+ {failures.length > 0 && ( + + )} + {batch.batchId} +
+
+ + {batch.userCount} + + + + 성공 {batch.processedCount ?? 0} + + + 대기 {batch.pendingCount ?? 0} + + + 처리 {batch.processingCount ?? 0} + + 실패 {batch.failedCount ?? 0} + + + {formatCredentialBatchDate(batch.createdAt)} + + + {batch.hasPasswords + ? "보관 중" + : formatCredentialBatchDate(batch.deletedAt)} + + +
+ + +
+
+
+ {isExpanded && failures.length > 0 && ( + + +
+ {failures.map((failure) => ( +
+
+
+ {failure.email ?? failure.userId ?? "-"} +
+ {failure.userId && ( +
+ {failure.userId} +
+ )} +
+
+ {failure.status} / retry{" "} + {failure.retryCount ?? 0} +
+
+ {failure.lastError ?? "-"} +
+
+ ))} +
+
+
+ )} +
+ ); + })} +
+
+
+
+
+ ); +} + 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") && ( {row.resourceType === "USER" && ( - +
+ + +
)}
)} diff --git a/adminfront/src/features/tenants/routes/worksmobileComparison.ts b/adminfront/src/features/tenants/routes/worksmobileComparison.ts index cc74886a..afaf728a 100644 --- a/adminfront/src/features/tenants/routes/worksmobileComparison.ts +++ b/adminfront/src/features/tenants/routes/worksmobileComparison.ts @@ -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"]; } diff --git a/adminfront/src/features/users/UserDetailPage.employeeNumber.test.tsx b/adminfront/src/features/users/UserDetailPage.employeeNumber.test.tsx new file mode 100644 index 00000000..bb5d406c --- /dev/null +++ b/adminfront/src/features/users/UserDetailPage.employeeNumber.test.tsx @@ -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( + + + + } /> + + + , + ); +} + +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"); + }); +}); diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 9c4fcc87..2bddda26 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -95,6 +95,7 @@ import { resolvePersonalTenant } from "./utils/personalTenant"; type UserFormValues = Omit & { email: string; metadata: Record & { + 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 >) || {}), + 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() {
@@ -1146,6 +1190,37 @@ function UserDetailPage() { className="h-11 shadow-sm" />
+
+ + + typeof value === "string" ? value.trim() : value, + maxLength: { + value: 20, + message: + "Worksmobile 사번은 20자 이하로 입력해야 합니다.", + }, + })} + className="h-11 shadow-sm" + /> + {errors.metadata?.employee_id && ( +

+ {String(errors.metadata.employee_id.message)} +

+ )} +

+ Worksmobile employeeNumber로 전송됩니다. 1~20자만 + 허용됩니다. +

+
diff --git a/adminfront/src/features/users/components/UserBulkUploadModal.tsx b/adminfront/src/features/users/components/UserBulkUploadModal.tsx index 1c473094..434e76bc 100644 --- a/adminfront/src/features/users/components/UserBulkUploadModal.tsx +++ b/adminfront/src/features/users/components/UserBulkUploadModal.tsx @@ -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({ {u.name} {u.tenantSlug || "-"} - {hanmacEmailStatusLabel(hanmacEmailPreviews[index])} + {u.importErrors?.length + ? "오류" + : hanmacEmailStatusLabel( + hanmacEmailPreviews[index], + )} + {u.importErrors?.length ? ( +
{userImportErrorLabel(u)}
+ ) : null} {hanmacEmailPreviews[index]?.reason && (
{hanmacEmailPreviews[index]?.reason}
)} @@ -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" diff --git a/adminfront/src/features/users/utils/csvParser.test.ts b/adminfront/src/features/users/utils/csvParser.test.ts index 101ca7e7..55b4aba0 100644 --- a/adminfront/src/features/users/utils/csvParser.test.ts +++ b/adminfront/src/features/users/utils/csvParser.test.ts @@ -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"); + }); }); diff --git a/adminfront/src/features/users/utils/csvParser.ts b/adminfront/src/features/users/utils/csvParser.ts index 16673a65..439e6a63 100644 --- a/adminfront/src/features/users/utils/csvParser.ts +++ b/adminfront/src/features/users/utils/csvParser.ts @@ -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(); + const owners = new Map>(); + + users.forEach((user, index) => { + const primaryEmail = user.email.trim().toLowerCase(); + const aliases = bulkUserAliasEmails(user); + const rowEmails = new Set(); + + 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(); + 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 & { metadata: Record }, emails: string[], diff --git a/adminfront/src/lib/adminApi.contract.test.ts b/adminfront/src/lib/adminApi.contract.test.ts index 5c6de00e..f5da134a 100644 --- a/adminfront/src/lib/adminApi.contract.test.ts +++ b/adminfront/src/lib/adminApi.contract.test.ts @@ -73,7 +73,12 @@ describe("adminApi endpoint contracts", () => { await adminApi.fetchUser("user-1"); await adminApi.fetchWorksmobileOverview("tenant-1"); await adminApi.fetchWorksmobileComparison("tenant-1", true); + await adminApi.fetchWorksmobileCredentialBatches("tenant-1"); await adminApi.downloadWorksmobileInitialPasswordsCSV("tenant-1"); + await adminApi.downloadWorksmobileInitialPasswordsCSV( + "tenant-1", + "credential-batch-1", + ); await adminApi.fetchPasswordPolicy(); await adminApi.fetchUserRpHistory("user-1"); await adminApi.fetchMe(); @@ -104,6 +109,16 @@ describe("adminApi endpoint contracts", () => { "/v1/admin/tenants/tenant-1/worksmobile/comparison", { params: { includeMatched: true } }, ); + expect(apiClient.get).toHaveBeenCalledWith( + "/v1/admin/tenants/tenant-1/worksmobile/credential-batches", + ); + expect(apiClient.get).toHaveBeenCalledWith( + "/v1/admin/tenants/tenant-1/worksmobile/initial-passwords.csv", + { + params: { batchId: "credential-batch-1" }, + responseType: "blob", + }, + ); expect(await adminApi.exportTenantsCSV(true, "parent-1")).toMatchObject({ filename: "export.csv", }); @@ -148,6 +163,20 @@ describe("adminApi endpoint contracts", () => { await adminApi.enqueueWorksmobileOrgUnitSync("tenant-1", "org/unit"); await adminApi.enqueueWorksmobileOrgUnitDelete("tenant-1", "org/unit"); await adminApi.enqueueWorksmobileUserSync("tenant-1", "user-1"); + await adminApi.enqueueWorksmobileUserSync( + "tenant-1", + "user-2", + "credential-batch-1", + ); + await adminApi.resetWorksmobileUserPassword( + "tenant-1", + "user-2", + "credential-batch-2", + ); + await adminApi.deleteWorksmobileCredentialBatchPasswords( + "tenant-1", + "credential-batch-1", + ); await adminApi.retryWorksmobileJob("tenant-1", "job-1"); await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" }); await adminApi.bulkDeleteUsers(["user-1"]); @@ -178,6 +207,17 @@ describe("adminApi endpoint contracts", () => { expect(apiClient.post).toHaveBeenCalledWith( "/v1/admin/tenants/tenant-1/worksmobile/orgunits/org%2Funit/sync", ); + expect(apiClient.post).toHaveBeenCalledWith( + "/v1/admin/tenants/tenant-1/worksmobile/users/user-2/sync", + { credentialBatchId: "credential-batch-1" }, + ); + expect(apiClient.post).toHaveBeenCalledWith( + "/v1/admin/tenants/tenant-1/worksmobile/users/user-2/password/reset", + { credentialBatchId: "credential-batch-2" }, + ); + expect(apiClient.delete).toHaveBeenCalledWith( + "/v1/admin/tenants/tenant-1/worksmobile/credential-batches/credential-batch-1/passwords", + ); expect(apiClient.delete).toHaveBeenCalledWith( "/v1/admin/relying-parties/client-1/owners/User:user-1", ); diff --git a/adminfront/src/lib/adminApi.ts b/adminfront/src/lib/adminApi.ts index f75e56d2..1032b5a8 100644 --- a/adminfront/src/lib/adminApi.ts +++ b/adminfront/src/lib/adminApi.ts @@ -676,6 +676,7 @@ export type UserCreateResponse = UserSummary & { }; export type UserUpdateRequest = { + email?: string; loginId?: string; password?: string; name?: string; @@ -725,6 +726,7 @@ export type BulkUserAppointment = { }; export type BulkUserItem = { + userId?: string; email: string; loginId?: string; name: string; @@ -750,6 +752,7 @@ export type BulkUserItem = { emailDomain?: string; }; metadata: Record; + importErrors?: string[]; }; export type BulkUserResult = { @@ -790,6 +793,30 @@ export type WorksmobileOverview = { recentJobs: WorksmobileOutboxItem[]; }; +export type WorksmobileCredentialBatch = { + batchId: string; + operation?: string; + userCount: number; + pendingCount?: number; + processingCount?: number; + processedCount?: number; + failedCount?: number; + hasPasswords: boolean; + deletedAt?: string; + failures?: WorksmobileCredentialBatchFailure[]; + createdAt?: string; + updatedAt?: string; +}; + +export type WorksmobileCredentialBatchFailure = { + userId?: string; + email?: string; + status: string; + retryCount: number; + lastError?: string; + updatedAt?: string; +}; + export type WorksmobileComparisonItem = { resourceType: string; baronId?: string; @@ -823,6 +850,10 @@ export type WorksmobileComparisonItem = { worksmobileParentName?: string; worksmobileParentEmail?: string; worksmobileParentExternalKey?: string; + worksmobileJobStatus?: string; + worksmobileJobRetryCount?: number; + worksmobileLastError?: string; + worksmobileLastAttemptAt?: string; status: string; }; @@ -906,10 +937,22 @@ export async function fetchWorksmobileComparison( return data; } -export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) { +export async function fetchWorksmobileCredentialBatches(tenantId: string) { + const { data } = await apiClient.get( + `/v1/admin/tenants/${tenantId}/worksmobile/credential-batches`, + ); + return data; +} + +export async function downloadWorksmobileInitialPasswordsCSV( + tenantId: string, + batchId?: string, +) { + const trimmedBatchId = batchId?.trim(); const response = await apiClient.get( `/v1/admin/tenants/${tenantId}/worksmobile/initial-passwords.csv`, { + ...(trimmedBatchId ? { params: { batchId: trimmedBatchId } } : {}), responseType: "blob", }, ); @@ -924,6 +967,16 @@ export async function downloadWorksmobileInitialPasswordsCSV(tenantId: string) { }; } +export async function deleteWorksmobileCredentialBatchPasswords( + tenantId: string, + batchId: string, +) { + const { data } = await apiClient.delete( + `/v1/admin/tenants/${tenantId}/worksmobile/credential-batches/${encodeURIComponent(batchId)}/passwords`, + ); + return data; +} + export async function enqueueWorksmobileBackfillDryRun(tenantId: string) { const { data } = await apiClient.post( `/v1/admin/tenants/${tenantId}/worksmobile/backfill/dry-run`, @@ -954,10 +1007,30 @@ export async function enqueueWorksmobileOrgUnitDelete( export async function enqueueWorksmobileUserSync( tenantId: string, userId: string, + credentialBatchId?: string, ) { - const { data } = await apiClient.post( - `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`, - ); + const trimmedBatchId = credentialBatchId?.trim(); + const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`; + const { data } = trimmedBatchId + ? await apiClient.post(path, { + credentialBatchId: trimmedBatchId, + }) + : await apiClient.post(path); + return data; +} + +export async function resetWorksmobileUserPassword( + tenantId: string, + userId: string, + credentialBatchId?: string, +) { + const trimmedBatchId = credentialBatchId?.trim(); + const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/password/reset`; + const { data } = trimmedBatchId + ? await apiClient.post(path, { + credentialBatchId: trimmedBatchId, + }) + : await apiClient.post(path); return data; } diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index f94a7e72..153f40e2 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -750,11 +750,14 @@ func main() { admin.Get("/tenants/:tenantId/worksmobile", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetOverview) admin.Get("/tenants/:tenantId/worksmobile/comparison", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.GetComparison) + admin.Get("/tenants/:tenantId/worksmobile/credential-batches", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ListCredentialBatches) + admin.Delete("/tenants/:tenantId/worksmobile/credential-batches/:batchId/passwords", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteCredentialBatchPasswords) admin.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DownloadInitialPasswordsCSV) admin.Post("/tenants/:tenantId/worksmobile/backfill/dry-run", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.BackfillDryRun) admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncOrgUnit) admin.Post("/tenants/:tenantId/worksmobile/orgunits/:orgUnitId/delete", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.DeleteOrgUnit) admin.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.SyncUser) + admin.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.ResetUserPassword) admin.Post("/tenants/:tenantId/worksmobile/jobs/:jobId/retry", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), worksmobileHandler.RetryJob) // Organization & Org-Chart Management (Tenant Admin/Super Admin) diff --git a/backend/internal/domain/worksmobile.go b/backend/internal/domain/worksmobile.go index a21f17d8..7beb9c73 100644 --- a/backend/internal/domain/worksmobile.go +++ b/backend/internal/domain/worksmobile.go @@ -20,10 +20,11 @@ const ( ) const ( - WorksmobileActionUpsert = "UPSERT" - WorksmobileActionDelete = "DELETE" - WorksmobileActionDryRun = "DRY_RUN" - WorksmobileActionSuspend = "SUSPEND" + WorksmobileActionUpsert = "UPSERT" + WorksmobileActionDelete = "DELETE" + WorksmobileActionDryRun = "DRY_RUN" + WorksmobileActionSuspend = "SUSPEND" + WorksmobileActionPasswordReset = "PASSWORD_RESET" ) type WorksmobileOutbox struct { diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go index 40e4bb8a..6a2f692e 100644 --- a/backend/internal/handler/user_handler.go +++ b/backend/internal/handler/user_handler.go @@ -13,8 +13,10 @@ import ( "fmt" "log/slog" "net/http" + "net/mail" "os" "regexp" + "sort" "strconv" "strings" "time" @@ -850,6 +852,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error { } type bulkUserItem struct { + UserID string `json:"userId"` Email string `json:"email"` LoginID string `json:"loginId"` Name string `json:"name"` @@ -906,6 +909,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { var hanmacScope *hanmacEmailScope var hanmacLocalParts map[string]bool hanmacScopeLoaded := false + bulkEmailErrors := validateBulkUserEmailUniqueness(req.Users) // Pre-fetch tenant data to avoid redundant DB calls type tenantCacheItem struct { @@ -1011,7 +1015,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { return cacheTenantItem(buildTenantCacheItem(tenant)), nil } - for _, item := range req.Users { + for index, item := range req.Users { email := strings.TrimSpace(item.Email) name := strings.TrimSpace(item.Name) tenantID := strings.TrimSpace(item.TenantID) @@ -1026,6 +1030,10 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { results = append(results, bulkUserResult{Email: email, Success: false, Message: tenantSlugErr.Error()}) continue } + if message, exists := bulkEmailErrors[index]; exists { + results = append(results, bulkUserResult{Email: email, Success: false, Status: "blockingError", Message: message}) + continue + } var tItem tenantCacheItem var err error @@ -1192,6 +1200,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { } item.Metadata["additionalAppointments"] = resolvedAppointments } + normalizeBulkUserAliasMetadata(item.Metadata) item.Metadata = sanitizeUserMetadata(item.Metadata) password, _ := utils.GeneratePasswordWithPolicy(policy) @@ -1252,6 +1261,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error { } identityID, err := h.OryProvider.CreateUser(&domain.BrokerUser{ + ID: strings.TrimSpace(item.UserID), Email: userEmail, Name: item.Name, PhoneNumber: userPhone, @@ -1845,6 +1855,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } var req struct { + Email *string `json:"email"` LoginID *string `json:"loginId"` Password *string `json:"password"` Name *string `json:"name"` @@ -1948,6 +1959,31 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { if traits == nil { traits = map[string]interface{}{} } + if req.Email != nil { + currentEmail := strings.TrimSpace(extractTraitString(traits, "email")) + nextEmail := strings.ToLower(strings.TrimSpace(*req.Email)) + if nextEmail == "" { + return errorJSON(c, fiber.StatusBadRequest, "email is required") + } + parsed, parseErr := mail.ParseAddress(nextEmail) + if parseErr != nil || !strings.EqualFold(parsed.Address, nextEmail) { + return errorJSON(c, fiber.StatusBadRequest, "invalid email") + } + if !strings.EqualFold(currentEmail, nextEmail) { + if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin { + return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user email") + } + if h.UserRepo != nil { + if existing, err := h.UserRepo.FindByEmail(c.Context(), nextEmail); err == nil && existing != nil && existing.ID != userID { + return errorJSON(c, fiber.StatusConflict, "email is already used by another user") + } + if taken, err := h.UserRepo.IsLoginIDTaken(c.Context(), nextEmail); err == nil && taken { + return errorJSON(c, fiber.StatusConflict, "email is already used as a login ID") + } + } + traits["email"] = nextEmail + } + } delete(traits, "hanmacFamily") delete(traits, "userType") @@ -2048,6 +2084,13 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error { } } } + if subEmailRaw, exists := req.Metadata["sub_email"]; exists { + subEmails := normalizeUserSubEmailValues(subEmailRaw) + traits["sub_email"] = subEmails + traits["aliasEmails"] = subEmails + traits["secondary_emails"] = subEmails + traits["worksmobileAliasEmails"] = subEmails + } // [LoginID Sync based on Tenant Settings] // Perform sync AFTER metadata merge to ensure traits contains current values @@ -2860,6 +2903,156 @@ func normalizeCustomLoginIDsTrait(traits map[string]interface{}) { } } +func normalizeUserSubEmailValues(raw any) []interface{} { + values := make([]string, 0) + switch typed := raw.(type) { + case []string: + values = append(values, typed...) + case []interface{}: + for _, item := range typed { + values = append(values, fmt.Sprint(item)) + } + case string: + values = append(values, typed) + default: + if raw != nil { + values = append(values, fmt.Sprint(raw)) + } + } + + seen := map[string]bool{} + result := make([]interface{}, 0, len(values)) + for _, value := range values { + for _, part := range strings.Split(value, ",") { + normalized := strings.ToLower(strings.TrimSpace(part)) + if normalized == "" || seen[normalized] { + continue + } + seen[normalized] = true + result = append(result, normalized) + } + } + return result +} + +func validateBulkUserEmailUniqueness(users []bulkUserItem) map[int]string { + owners := map[string]map[int]bool{} + errorsByIndex := map[int]string{} + + for index, user := range users { + primaryEmail := normalizeBulkUserEmail(user.Email) + aliases := bulkUserAliasEmailSet(user.Metadata) + rowEmails := map[string]bool{} + if primaryEmail != "" { + rowEmails[primaryEmail] = true + } + for alias := range aliases { + if primaryEmail != "" && alias == primaryEmail { + errorsByIndex[index] = "duplicate email in bulk request: " + alias + } + rowEmails[alias] = true + } + for email := range rowEmails { + if owners[email] == nil { + owners[email] = map[int]bool{} + } + owners[email][index] = true + } + } + + for email, indexes := range owners { + if len(indexes) < 2 { + continue + } + for index := range indexes { + errorsByIndex[index] = "duplicate email in bulk request: " + email + } + } + + return errorsByIndex +} + +func normalizeBulkUserAliasMetadata(metadata map[string]any) { + if metadata == nil { + return + } + aliases := bulkUserAliasEmailSet(metadata) + hasAliasField := false + for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} { + if _, exists := metadata[key]; exists { + hasAliasField = true + break + } + } + if !hasAliasField { + return + } + values := make([]interface{}, 0, len(aliases)) + for alias := range aliases { + values = append(values, alias) + } + sort.Slice(values, func(i, j int) bool { + return fmt.Sprint(values[i]) < fmt.Sprint(values[j]) + }) + metadata["sub_email"] = values + metadata["aliasEmails"] = values + metadata["secondary_emails"] = values + metadata["worksmobileAliasEmails"] = values +} + +func bulkUserAliasEmailSet(metadata map[string]any) map[string]bool { + aliases := map[string]bool{} + for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} { + for _, value := range bulkUserEmailValues(metadata[key]) { + aliases[value] = true + } + } + return aliases +} + +func bulkUserEmailValues(raw any) []string { + values := make([]string, 0) + switch typed := raw.(type) { + case []string: + values = append(values, typed...) + case []interface{}: + for _, item := range typed { + values = append(values, fmt.Sprint(item)) + } + case string: + values = append(values, typed) + default: + if raw != nil { + values = append(values, fmt.Sprint(raw)) + } + } + + result := make([]string, 0, len(values)) + for _, value := range values { + for _, token := range strings.FieldsFunc(value, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' + }) { + email := normalizeBulkUserEmail(token) + if email != "" { + result = append(result, email) + } + } + } + return result +} + +func normalizeBulkUserEmail(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + return "" + } + parsed, err := mail.ParseAddress(normalized) + if err != nil { + return normalized + } + return strings.ToLower(strings.TrimSpace(parsed.Address)) +} + func formatTime(value time.Time) string { if value.IsZero() { return "" diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go index 91563777..876c7931 100644 --- a/backend/internal/handler/user_handler_test.go +++ b/backend/internal/handler/user_handler_test.go @@ -600,6 +600,171 @@ func TestUserHandler_BulkCreateUsers(t *testing.T) { }) } +func TestUserHandler_BulkCreateUsersPreservesRequestedUserID(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + mockOry := new(MockOryProvider) + mockTenant := new(MockTenantServiceForUser) + const requestedUserID = "9f8cc1b1-af8d-45d4-946c-924a529c2556" + + h := &UserHandler{ + KratosAdmin: mockKratos, + OryProvider: mockOry, + TenantService: mockTenant, + } + + app.Post("/users/bulk", h.BulkCreateUsers) + + mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil) + mockTenant.On("GetTenant", mock.Anything, "tenant-123").Return(&domain.Tenant{ + ID: "tenant-123", + Slug: "restore-tenant", + Config: domain.JSONMap{}, + }, nil).Once() + mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool { + return user != nil && user.ID == requestedUserID && user.Email == "restore@test.com" + }), mock.Anything).Return(requestedUserID, nil).Once() + + payload := map[string]any{ + "users": []map[string]any{ + { + "userId": requestedUserID, + "email": "restore@test.com", + "name": "Restore User", + "tenantId": "tenant-123", + "tenantSlug": "restore-tenant", + "metadata": map[string]any{}, + }, + }, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest("POST", "/users/bulk", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, _ := app.Test(req) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + results := result["results"].([]any) + require.Len(t, results, 1) + row := results[0].(map[string]any) + assert.True(t, row["success"].(bool)) + assert.Equal(t, requestedUserID, row["userId"]) + mockOry.AssertExpectations(t) +} + +func TestUserHandler_BulkCreateUsersRejectsDuplicateAliasEmailsInBatch(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + mockOry := new(MockOryProvider) + mockTenant := new(MockTenantServiceForUser) + h := &UserHandler{ + KratosAdmin: mockKratos, + OryProvider: mockOry, + TenantService: mockTenant, + } + app.Post("/users/bulk", h.BulkCreateUsers) + + mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once() + + payload := map[string]interface{}{ + "users": []map[string]interface{}{ + { + "email": "user1@samaneng.com", + "name": "User One", + "tenantSlug": "rnd-saman", + "metadata": map[string]interface{}{ + "sub_email": []interface{}{"shared@hanmaceng.co.kr"}, + }, + }, + { + "email": "user2@samaneng.com", + "name": "User Two", + "tenantSlug": "rnd-saman", + "metadata": map[string]interface{}{ + "worksmobileAliasEmails": []interface{}{"shared@hanmaceng.co.kr"}, + }, + }, + }, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/users/bulk", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + results := result["results"].([]interface{}) + require.Len(t, results, 2) + for _, item := range results { + row := item.(map[string]interface{}) + require.False(t, row["success"].(bool)) + require.Equal(t, "blockingError", row["status"]) + require.Contains(t, row["message"].(string), "duplicate email") + } + mockOry.AssertExpectations(t) + mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything) + mockTenant.AssertNotCalled(t, "GetTenantBySlug", mock.Anything, mock.Anything) +} + +func TestUserHandler_BulkCreateUsersRejectsPrimaryEmailUsedAsSubEmail(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + mockOry := new(MockOryProvider) + mockTenant := new(MockTenantServiceForUser) + h := &UserHandler{ + KratosAdmin: mockKratos, + OryProvider: mockOry, + TenantService: mockTenant, + } + app.Post("/users/bulk", h.BulkCreateUsers) + + mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once() + + payload := map[string]interface{}{ + "users": []map[string]interface{}{ + { + "email": "user1@samaneng.com", + "name": "User One", + "tenantSlug": "rnd-saman", + "metadata": map[string]interface{}{ + "sub_email": []interface{}{"user2@samaneng.com"}, + }, + }, + { + "email": "user2@samaneng.com", + "name": "User Two", + "tenantSlug": "rnd-saman", + }, + }, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/users/bulk", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + var result map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + results := result["results"].([]interface{}) + require.Len(t, results, 2) + for _, item := range results { + row := item.(map[string]interface{}) + require.False(t, row["success"].(bool)) + require.Equal(t, "blockingError", row["status"]) + require.Contains(t, row["message"].(string), "duplicate email") + } + mockOry.AssertExpectations(t) + mockOry.AssertNotCalled(t, "CreateUser", mock.Anything, mock.Anything) + mockTenant.AssertNotCalled(t, "GetTenantBySlug", mock.Anything, mock.Anything) +} + func TestUserHandler_BulkCreateUsers_ResolvesAdditionalAppointment(t *testing.T) { app := fiber.New() mockKratos := new(MockKratosAdmin) @@ -1429,6 +1594,138 @@ func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) { mockKratos.AssertExpectations(t) } +func TestUserHandler_UpdateUser_AllowsSuperAdminEmailChange(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + h := &UserHandler{KratosAdmin: mockKratos} + app.Put("/users/:id", func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin}) + return h.UpdateUser(c) + }) + + userID := "u-1" + mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ + ID: userID, + Traits: map[string]interface{}{ + "email": "old@example.com", + "name": "사용자", + "role": domain.RoleUser, + }, + State: "active", + }, nil).Once() + mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { + return traits["email"] == "new@example.com" + }), "").Return(&service.KratosIdentity{ + ID: userID, + Traits: map[string]interface{}{ + "email": "new@example.com", + "name": "사용자", + "role": domain.RoleUser, + }, + State: "active", + }, nil).Once() + + body, _ := json.Marshal(map[string]interface{}{"email": "new@example.com"}) + req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + mockKratos.AssertExpectations(t) +} + +func TestUserHandler_UpdateUserClearsWorksmobileAliasMetadataWhenSubEmailIsCleared(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + h := &UserHandler{KratosAdmin: mockKratos} + app.Put("/users/:id", func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "admin-1", Role: domain.RoleSuperAdmin}) + return h.UpdateUser(c) + }) + + userID := "u-1" + mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ + ID: userID, + Traits: map[string]interface{}{ + "email": "user@example.com", + "name": "사용자", + "role": domain.RoleUser, + "sub_email": []interface{}{"alias@hanmaceng.co.kr"}, + "aliasEmails": []interface{}{"alias@hanmaceng.co.kr"}, + "secondary_emails": []interface{}{"alias@hanmaceng.co.kr"}, + "worksmobileAliasEmails": []interface{}{"alias@hanmaceng.co.kr"}, + }, + State: "active", + }, nil).Once() + mockKratos.On("UpdateIdentity", mock.Anything, userID, mock.MatchedBy(func(traits map[string]interface{}) bool { + for _, key := range []string{"sub_email", "aliasEmails", "secondary_emails", "worksmobileAliasEmails"} { + values, ok := traits[key].([]interface{}) + if !ok || len(values) != 0 { + return false + } + } + + return true + }), "").Return(&service.KratosIdentity{ + ID: userID, + Traits: map[string]interface{}{ + "email": "user@example.com", + "name": "사용자", + "role": domain.RoleUser, + "sub_email": []interface{}{}, + "aliasEmails": []interface{}{}, + "secondary_emails": []interface{}{}, + "worksmobileAliasEmails": []interface{}{}, + }, + State: "active", + }, nil).Once() + + body, _ := json.Marshal(map[string]interface{}{ + "metadata": map[string]interface{}{ + "sub_email": []interface{}{}, + }, + }) + req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + mockKratos.AssertExpectations(t) +} + +func TestUserHandler_UpdateUser_RejectsNonSuperAdminEmailChange(t *testing.T) { + app := fiber.New() + mockKratos := new(MockKratosAdmin) + h := &UserHandler{KratosAdmin: mockKratos} + app.Put("/users/:id", func(c *fiber.Ctx) error { + c.Locals("user_profile", &domain.UserProfileResponse{ID: "user-2", Role: domain.RoleUser}) + return h.UpdateUser(c) + }) + + userID := "u-1" + mockKratos.On("GetIdentity", mock.Anything, userID).Return(&service.KratosIdentity{ + ID: userID, + Traits: map[string]interface{}{ + "email": "old@example.com", + "name": "사용자", + "role": domain.RoleUser, + }, + State: "active", + }, nil).Once() + + body, _ := json.Marshal(map[string]interface{}{"email": "new@example.com"}) + req := httptest.NewRequest(http.MethodPut, "/users/"+userID, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + mockKratos.AssertExpectations(t) + mockKratos.AssertNotCalled(t, "UpdateIdentity", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + func TestSyncCustomLoginIDs_IgnoresFlatMetadataMaps(t *testing.T) { mockTenant := new(MockTenantServiceForUser) tenantID := "tenant-uuid" diff --git a/backend/internal/handler/worksmobile_handler.go b/backend/internal/handler/worksmobile_handler.go index 15bdc89d..16c0d10b 100644 --- a/backend/internal/handler/worksmobile_handler.go +++ b/backend/internal/handler/worksmobile_handler.go @@ -72,13 +72,30 @@ func (h *WorksmobileHandler) DeleteOrgUnit(c *fiber.Ctx) error { func (h *WorksmobileHandler) SyncUser(c *fiber.Ctx) error { userID := strings.TrimSpace(c.Params("userId")) - job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID) + credentialBatchID, err := parseWorksmobileCredentialBatchID(c) + if err != nil { + return errorJSON(c, fiber.StatusBadRequest, err.Error()) + } + job, err := h.Service.EnqueueUserSync(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID) if err != nil { return worksmobileGuardError(c, err, "sync_user", "user_id", userID) } return c.Status(fiber.StatusAccepted).JSON(job) } +func (h *WorksmobileHandler) ResetUserPassword(c *fiber.Ctx) error { + userID := strings.TrimSpace(c.Params("userId")) + credentialBatchID, err := parseWorksmobileCredentialBatchID(c) + if err != nil { + return errorJSON(c, fiber.StatusBadRequest, err.Error()) + } + job, err := h.Service.EnqueueUserPasswordReset(c.Context(), strings.TrimSpace(c.Params("tenantId")), userID, credentialBatchID) + if err != nil { + return worksmobileGuardError(c, err, "reset_user_password", "user_id", userID) + } + return c.Status(fiber.StatusAccepted).JSON(job) +} + func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error { jobID := strings.TrimSpace(c.Params("jobId")) job, err := h.Service.RetryJob(c.Context(), strings.TrimSpace(c.Params("tenantId")), jobID) @@ -89,18 +106,18 @@ func (h *WorksmobileHandler) RetryJob(c *fiber.Ctx) error { } func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error { - credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId"))) + credentials, err := h.Service.ListInitialPasswordCredentials(c.Context(), strings.TrimSpace(c.Params("tenantId")), strings.TrimSpace(c.Query("batchId"))) if err != nil { return worksmobileGuardError(c, err, "download_initial_passwords") } var buf bytes.Buffer writer := csv.NewWriter(&buf) - if err := writer.Write([]string{"email", "initialPassword", "status", "lastError"}); err != nil { + if err := writer.Write([]string{"email", "name", "primaryLeafOrgName", "initialPassword", "status", "lastError"}); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } for _, credential := range credentials { - if err := writer.Write([]string{credential.Email, credential.InitialPassword, credential.Status, credential.LastError}); err != nil { + if err := writer.Write([]string{credential.Email, credential.Name, credential.PrimaryLeafOrgName, credential.InitialPassword, credential.Status, credential.LastError}); err != nil { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } } @@ -114,6 +131,42 @@ func (h *WorksmobileHandler) DownloadInitialPasswordsCSV(c *fiber.Ctx) error { return c.Send(buf.Bytes()) } +func (h *WorksmobileHandler) ListCredentialBatches(c *fiber.Ctx) error { + batches, err := h.Service.ListCredentialBatches(c.Context(), strings.TrimSpace(c.Params("tenantId"))) + if err != nil { + return worksmobileGuardError(c, err, "list_credential_batches") + } + return c.JSON(batches) +} + +func (h *WorksmobileHandler) DeleteCredentialBatchPasswords(c *fiber.Ctx) error { + batchID := strings.TrimSpace(c.Params("batchId")) + batch, err := h.Service.DeleteCredentialBatchPasswords(c.Context(), strings.TrimSpace(c.Params("tenantId")), batchID) + if err != nil { + return worksmobileGuardError(c, err, "delete_credential_batch_passwords", "batch_id", batchID) + } + return c.JSON(batch) +} + +type worksmobileCredentialBatchRequest struct { + CredentialBatchID string `json:"credentialBatchId"` +} + +func parseWorksmobileCredentialBatchID(c *fiber.Ctx) (string, error) { + batchID := strings.TrimSpace(c.Query("credentialBatchId")) + if len(bytes.TrimSpace(c.Body())) == 0 { + return batchID, nil + } + var req worksmobileCredentialBatchRequest + if err := c.BodyParser(&req); err != nil { + return "", err + } + if bodyBatchID := strings.TrimSpace(req.CredentialBatchID); bodyBatchID != "" { + return bodyBatchID, nil + } + return batchID, nil +} + func worksmobileOverviewAllowed(overview service.WorksmobileTenantOverview) bool { return overview.Tenant.Slug == service.HanmacFamilyTenantSlug && overview.Tenant.ParentID == nil } diff --git a/backend/internal/handler/worksmobile_handler_test.go b/backend/internal/handler/worksmobile_handler_test.go index bcaafc7d..f0a11ec0 100644 --- a/backend/internal/handler/worksmobile_handler_test.go +++ b/backend/internal/handler/worksmobile_handler_test.go @@ -9,6 +9,7 @@ import ( "io" "log/slog" "net/http/httptest" + "strings" "testing" "github.com/gofiber/fiber/v2" @@ -51,7 +52,13 @@ func TestWorksmobileHandlerReturnsOverviewForHanmacTenant(t *testing.T) { func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) { h := NewWorksmobileHandler(&fakeWorksmobileAdminService{ credentials: []service.WorksmobileInitialPasswordCredential{ - {Email: "user@hanmaceng.co.kr", InitialPassword: "Aa1!Aa1!Aa1!Aa1!", Status: "processed"}, + { + Email: "user@hanmaceng.co.kr", + Name: "홍길동", + PrimaryLeafOrgName: "인재성장", + InitialPassword: "Aa1!Aa1!Aa1!Aa1!", + Status: "processed", + }, }, }) app := fiber.New() @@ -63,8 +70,87 @@ func TestWorksmobileHandlerDownloadsInitialPasswordCSV(t *testing.T) { require.Contains(t, resp.Header.Get("Content-Disposition"), "worksmobile_initial_passwords.csv") body, err := io.ReadAll(resp.Body) require.NoError(t, err) - require.Contains(t, string(body), "email,initialPassword,status,lastError") - require.Contains(t, string(body), "user@hanmaceng.co.kr,Aa1!Aa1!Aa1!Aa1!,processed,") + require.Contains(t, string(body), "email,name,primaryLeafOrgName,initialPassword,status,lastError") + require.Contains(t, string(body), "user@hanmaceng.co.kr,홍길동,인재성장,Aa1!Aa1!Aa1!Aa1!,processed,") +} + +func TestWorksmobileHandlerPassesInitialPasswordBatchID(t *testing.T) { + fakeService := &fakeWorksmobileAdminService{ + credentials: []service.WorksmobileInitialPasswordCredential{ + {Email: "batch-user@hanmaceng.co.kr", InitialPassword: "BatchPass1!", Status: "pending"}, + }, + } + h := NewWorksmobileHandler(fakeService) + app := fiber.New() + app.Get("/tenants/:tenantId/worksmobile/initial-passwords.csv", h.DownloadInitialPasswordsCSV) + + resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/initial-passwords.csv?batchId=batch-1", nil)) + + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + require.Equal(t, "batch-1", fakeService.downloadCredentialBatchID) +} + +func TestWorksmobileHandlerPassesSyncUserCredentialBatchID(t *testing.T) { + fakeService := &fakeWorksmobileAdminService{} + h := NewWorksmobileHandler(fakeService) + app := fiber.New() + app.Post("/tenants/:tenantId/worksmobile/users/:userId/sync", h.SyncUser) + + req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/sync", strings.NewReader(`{"credentialBatchId":"batch-1"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + + require.NoError(t, err) + require.Equal(t, fiber.StatusAccepted, resp.StatusCode) + require.Equal(t, "batch-1", fakeService.syncUserCredentialBatchID) +} + +func TestWorksmobileHandlerPassesPasswordResetCredentialBatchID(t *testing.T) { + fakeService := &fakeWorksmobileAdminService{} + h := NewWorksmobileHandler(fakeService) + app := fiber.New() + app.Post("/tenants/:tenantId/worksmobile/users/:userId/password/reset", h.ResetUserPassword) + + req := httptest.NewRequest("POST", "/tenants/hanmac-id/worksmobile/users/user-1/password/reset", strings.NewReader(`{"credentialBatchId":"batch-1"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + + require.NoError(t, err) + require.Equal(t, fiber.StatusAccepted, resp.StatusCode) + require.Equal(t, "batch-1", fakeService.resetPasswordCredentialBatchID) +} + +func TestWorksmobileHandlerReturnsCredentialBatchHistory(t *testing.T) { + h := NewWorksmobileHandler(&fakeWorksmobileAdminService{ + credentialBatches: []service.WorksmobileCredentialBatch{ + {BatchID: "batch-1", UserCount: 2, HasPasswords: true}, + }, + }) + app := fiber.New() + app.Get("/tenants/:tenantId/worksmobile/credential-batches", h.ListCredentialBatches) + + resp, err := app.Test(httptest.NewRequest("GET", "/tenants/hanmac-id/worksmobile/credential-batches", nil)) + + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), `"batchId":"batch-1"`) + require.Contains(t, string(body), `"userCount":2`) +} + +func TestWorksmobileHandlerDeletesCredentialBatchPasswords(t *testing.T) { + fakeService := &fakeWorksmobileAdminService{} + h := NewWorksmobileHandler(fakeService) + app := fiber.New() + app.Delete("/tenants/:tenantId/worksmobile/credential-batches/:batchId/passwords", h.DeleteCredentialBatchPasswords) + + resp, err := app.Test(httptest.NewRequest("DELETE", "/tenants/hanmac-id/worksmobile/credential-batches/batch-1/passwords", nil)) + + require.NoError(t, err) + require.Equal(t, fiber.StatusOK, resp.StatusCode) + require.Equal(t, "batch-1", fakeService.deletedCredentialBatchID) } func TestWorksmobileHandlerLogsActionFailures(t *testing.T) { @@ -91,9 +177,14 @@ func TestWorksmobileHandlerLogsActionFailures(t *testing.T) { } type fakeWorksmobileAdminService struct { - overview service.WorksmobileTenantOverview - credentials []service.WorksmobileInitialPasswordCredential - syncUserErr error + overview service.WorksmobileTenantOverview + credentials []service.WorksmobileInitialPasswordCredential + syncUserErr error + syncUserCredentialBatchID string + resetPasswordCredentialBatchID string + downloadCredentialBatchID string + deletedCredentialBatchID string + credentialBatches []service.WorksmobileCredentialBatch } func (f *fakeWorksmobileAdminService) GetTenantOverview(ctx context.Context, tenantID string) (service.WorksmobileTenantOverview, error) { @@ -116,17 +207,33 @@ func (f *fakeWorksmobileAdminService) EnqueueOrgUnitDelete(ctx context.Context, return &domain.WorksmobileOutbox{ID: "job-orgunit-delete", ResourceID: orgUnitID, Action: domain.WorksmobileActionDelete}, nil } -func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) { +func (f *fakeWorksmobileAdminService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) { + f.syncUserCredentialBatchID = credentialBatchID if f.syncUserErr != nil { return nil, f.syncUserErr } return &domain.WorksmobileOutbox{ID: "job-user", ResourceID: userID}, nil } +func (f *fakeWorksmobileAdminService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) { + f.resetPasswordCredentialBatchID = credentialBatchID + return &domain.WorksmobileOutbox{ID: "job-user-password-reset", ResourceID: userID}, nil +} + func (f *fakeWorksmobileAdminService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) { return &domain.WorksmobileOutbox{ID: jobID}, nil } -func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]service.WorksmobileInitialPasswordCredential, error) { +func (f *fakeWorksmobileAdminService) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]service.WorksmobileInitialPasswordCredential, error) { + f.downloadCredentialBatchID = credentialBatchID return f.credentials, nil } + +func (f *fakeWorksmobileAdminService) ListCredentialBatches(ctx context.Context, tenantID string) ([]service.WorksmobileCredentialBatch, error) { + return f.credentialBatches, nil +} + +func (f *fakeWorksmobileAdminService) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (service.WorksmobileCredentialBatch, error) { + f.deletedCredentialBatchID = credentialBatchID + return service.WorksmobileCredentialBatch{BatchID: credentialBatchID}, nil +} diff --git a/backend/internal/repository/worksmobile_outbox_repository.go b/backend/internal/repository/worksmobile_outbox_repository.go index 8da5f5a8..e6f88d7c 100644 --- a/backend/internal/repository/worksmobile_outbox_repository.go +++ b/backend/internal/repository/worksmobile_outbox_repository.go @@ -12,6 +12,8 @@ import ( type WorksmobileOutboxRepository interface { Create(ctx context.Context, item *domain.WorksmobileOutbox) error ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) + ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) + UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) FindByID(ctx context.Context, id string) (*domain.WorksmobileOutbox, error) MarkRetry(ctx context.Context, id string) error @@ -56,6 +58,24 @@ func (r *worksmobileOutboxRepository) ListRecent(ctx context.Context, limit int) return rows, err } +func (r *worksmobileOutboxRepository) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) { + query := r.db.WithContext(ctx). + Where("resource_type = ? AND payload ->> 'tenantRootId' = ? AND coalesce(payload ->> 'credentialBatchId', '') <> ?", domain.WorksmobileResourceUser, tenantRootID, "") + if credentialBatchID != "" { + query = query.Where("payload ->> 'credentialBatchId' = ?", credentialBatchID) + } + var rows []domain.WorksmobileOutbox + err := query.Order("created_at desc").Find(&rows).Error + return rows, err +} + +func (r *worksmobileOutboxRepository) UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error { + return r.db.WithContext(ctx).Model(&domain.WorksmobileOutbox{}).Where("id = ?", id).Updates(map[string]any{ + "payload": payload, + "updated_at": time.Now(), + }).Error +} + func (r *worksmobileOutboxRepository) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) { if limit <= 0 || limit > 100 { limit = 20 diff --git a/backend/internal/service/kratos_admin_service.go b/backend/internal/service/kratos_admin_service.go index d040bee7..2f67c9cc 100644 --- a/backend/internal/service/kratos_admin_service.go +++ b/backend/internal/service/kratos_admin_service.go @@ -290,6 +290,9 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker }, "state": "active", } + if requestedID := strings.TrimSpace(user.ID); requestedID != "" { + payload["id"] = requestedID + } body, _ := json.Marshal(payload) endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities" @@ -316,6 +319,9 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { return "", err } + if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID { + return "", fmt.Errorf("kratos admin: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID) + } return created.ID, nil } diff --git a/backend/internal/service/ory_service.go b/backend/internal/service/ory_service.go index 99affaaa..8a1db385 100644 --- a/backend/internal/service/ory_service.go +++ b/backend/internal/service/ory_service.go @@ -134,6 +134,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri }, }, } + if requestedID := strings.TrimSpace(user.ID); requestedID != "" { + payload["id"] = requestedID + } verifiable := []map[string]interface{}{ { "value": user.Email, @@ -179,6 +182,9 @@ func (o *OryProvider) CreateUser(user *domain.BrokerUser, password string) (stri if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { return "", fmt.Errorf("ory provider: decode create identity response failed: %w", err) } + if requestedID := strings.TrimSpace(user.ID); requestedID != "" && created.ID != requestedID { + return "", fmt.Errorf("ory provider: requested identity id was not preserved requested=%s actual=%s", requestedID, created.ID) + } slog.Info("Ory identity created", "identity_id", created.ID, "email", user.Email) return created.ID, nil diff --git a/backend/internal/service/ory_service_test.go b/backend/internal/service/ory_service_test.go index f7791089..4546dcb6 100644 --- a/backend/internal/service/ory_service_test.go +++ b/backend/internal/service/ory_service_test.go @@ -1,6 +1,7 @@ package service import ( + "baron-sso-backend/internal/domain" "bytes" "encoding/json" "io" @@ -35,6 +36,76 @@ type roundTripperFunc func(req *http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } +func TestCreateUserSendsRequestedIdentityID(t *testing.T) { + const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode([]map[string]string{}) + return + case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost: + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + if payload["id"] != requestedID { + t.Fatalf("expected id=%s, got=%v", requestedID, payload["id"]) + } + _ = json.NewEncoder(w).Encode(map[string]string{"id": requestedID}) + return + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + }) + + provider := &OryProvider{ + KratosAdminURL: "http://kratos-admin.local", + HTTPClient: clientForHandler(handler), + } + + id, err := provider.CreateUser(&domain.BrokerUser{ + ID: requestedID, + Email: "restore@test.com", + Name: "Restore User", + }, "Sup3rStr0ng!Pass#2026") + if err != nil { + t.Fatalf("CreateUser returned error: %v", err) + } + if id != requestedID { + t.Fatalf("expected %s, got %s", requestedID, id) + } +} + +func TestCreateUserRejectsRequestedIdentityIDMismatch(t *testing.T) { + const requestedID = "9f8cc1b1-af8d-45d4-946c-924a529c2556" + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/admin/identities" && r.Method == http.MethodGet: + _ = json.NewEncoder(w).Encode([]map[string]string{}) + return + case r.URL.Path == "/admin/identities" && r.Method == http.MethodPost: + _ = json.NewEncoder(w).Encode(map[string]string{"id": "generated-id"}) + return + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) + } + }) + + provider := &OryProvider{ + KratosAdminURL: "http://kratos-admin.local", + HTTPClient: clientForHandler(handler), + } + + _, err := provider.CreateUser(&domain.BrokerUser{ + ID: requestedID, + Email: "restore@test.com", + Name: "Restore User", + }, "Sup3rStr0ng!Pass#2026") + if err == nil || !strings.Contains(err.Error(), "requested identity id was not preserved") { + t.Fatalf("expected requested identity id mismatch error, got: %v", err) + } +} + func TestUpdateUserPassword_Success(t *testing.T) { const ( loginID = "user@example.com" diff --git a/backend/internal/service/worksmobile_client.go b/backend/internal/service/worksmobile_client.go index 68888573..a985fde0 100644 --- a/backend/internal/service/worksmobile_client.go +++ b/backend/internal/service/worksmobile_client.go @@ -30,6 +30,8 @@ type WorksmobileDirectoryClient interface { DeleteOrgUnit(ctx context.Context, orgUnitID string) error CreateUser(ctx context.Context, payload WorksmobileUserPayload) error UpsertUser(ctx context.Context, payload WorksmobileUserPayload) error + AddUserAliasEmail(ctx context.Context, userID string, email string) error + ResetUserPassword(ctx context.Context, userID string, password string) error DeleteUser(ctx context.Context, userID string) error SetUserActive(ctx context.Context, userID string, active bool) error ListUsers(ctx context.Context) ([]WorksmobileRemoteUser, error) @@ -283,6 +285,45 @@ func (c *WorksmobileHTTPClient) UpsertUser(ctx context.Context, payload Worksmob return err } +func (c *WorksmobileHTTPClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error { + userID = strings.TrimSpace(userID) + email = strings.TrimSpace(email) + if userID == "" { + return fmt.Errorf("worksmobile user id is required") + } + if email == "" { + return fmt.Errorf("worksmobile alias email is required") + } + err := c.sendDirectoryJSON( + ctx, + http.MethodPost, + "/v1.0/users/"+url.PathEscape(userID)+"/alias-emails/"+url.PathEscape(email), + nil, + ) + if apiErr, ok := err.(WorksmobileHTTPError); ok && apiErr.StatusCode == http.StatusConflict { + return nil + } + return err +} + +func (c *WorksmobileHTTPClient) ResetUserPassword(ctx context.Context, userID string, password string) error { + userID = strings.TrimSpace(userID) + password = strings.TrimSpace(password) + if userID == "" { + return fmt.Errorf("worksmobile user id is required") + } + if password == "" { + return fmt.Errorf("worksmobile password is required") + } + payload := map[string]any{ + "passwordConfig": WorksmobilePasswordConfig{ + PasswordCreationType: "ADMIN", + Password: password, + }, + } + return c.sendDirectoryJSON(ctx, http.MethodPatch, "/v1.0/users/"+url.PathEscape(userID), payload) +} + func (c *WorksmobileHTTPClient) PatchUser(ctx context.Context, identifier string, payload WorksmobileUserPatchPayload) error { identifier = strings.TrimSpace(identifier) if identifier == "" { @@ -756,22 +797,23 @@ type WorksmobileOrgUnitPatchPayload struct { } type WorksmobileRemoteUser struct { - ID string `json:"id"` - ExternalID string `json:"externalId"` - UserName string `json:"userName"` - Email string `json:"email"` - DisplayName string `json:"displayName"` - LevelID string `json:"levelId"` - LevelName string `json:"levelName"` - Task string `json:"task"` - DomainID int64 `json:"domainId"` - DomainName string `json:"domainName"` - PrimaryOrgUnitID string `json:"primaryOrgUnitId"` - PrimaryOrgUnitName string `json:"primaryOrgUnitName"` - PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"` - PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"` - PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"` - Active bool `json:"active"` + ID string `json:"id"` + ExternalID string `json:"externalId"` + UserName string `json:"userName"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + LevelID string `json:"levelId"` + LevelName string `json:"levelName"` + Task string `json:"task"` + DomainID int64 `json:"domainId"` + DomainName string `json:"domainName"` + PrimaryOrgUnitID string `json:"primaryOrgUnitId"` + PrimaryOrgUnitName string `json:"primaryOrgUnitName"` + PrimaryOrgUnitPositionID string `json:"primaryOrgUnitPositionId"` + PrimaryOrgUnitPositionName string `json:"primaryOrgUnitPositionName"` + PrimaryOrgUnitIsManager *bool `json:"primaryOrgUnitIsManager,omitempty"` + OrgUnitManagers map[string]*bool `json:"orgUnitManagers,omitempty"` + Active bool `json:"active"` } type WorksmobileRemoteGroup struct { @@ -907,6 +949,7 @@ func parseWorksmobileDirectoryUser(resource map[string]any) WorksmobileRemoteUse user.PrimaryOrgUnitPositionID = primaryOrgUnit.PositionID user.PrimaryOrgUnitPositionName = primaryOrgUnit.PositionName user.PrimaryOrgUnitIsManager = primaryOrgUnit.IsManager + user.OrgUnitManagers = parseWorksmobileOrgUnitManagers(resource) return user } @@ -1029,6 +1072,43 @@ func parseWorksmobilePrimaryOrgUnitDetail(resource map[string]any) worksmobileOr return worksmobileOrgUnitDetail{} } +func parseWorksmobileOrgUnitManagers(resource map[string]any) map[string]*bool { + result := map[string]*bool{} + collectWorksmobileOrgUnitManagers(resource["organizations"], result) + collectWorksmobileOrgUnitManagers(resource["orgUnits"], result) + for key, raw := range resource { + if !strings.Contains(strings.ToLower(key), "works") { + continue + } + if values, ok := raw.(map[string]any); ok { + collectWorksmobileOrgUnitManagers(values["organizations"], result) + collectWorksmobileOrgUnitManagers(values["orgUnits"], result) + } + } + if len(result) == 0 { + return nil + } + return result +} + +func collectWorksmobileOrgUnitManagers(raw any, result map[string]*bool) { + values, ok := raw.([]any) + if !ok { + return + } + for _, item := range values { + orgUnit, ok := item.(map[string]any) + if !ok { + continue + } + if id := firstStringFromMap(orgUnit, "orgUnitId", "id", "value"); id != "" { + result[id] = boolPointerFromMap(orgUnit, "isManager", "manager") + } + collectWorksmobileOrgUnitManagers(orgUnit["organizations"], result) + collectWorksmobileOrgUnitManagers(orgUnit["orgUnits"], result) + } +} + func parseWorksmobileParentOrgUnit(resource map[string]any) (string, string) { id := firstStringFromMap(resource, "parentOrgUnitId", "parentId") name := firstStringFromMap(resource, "parentOrgUnitName", "parentName") diff --git a/backend/internal/service/worksmobile_client_test.go b/backend/internal/service/worksmobile_client_test.go index c30d96f2..b4cb92a8 100644 --- a/backend/internal/service/worksmobile_client_test.go +++ b/backend/internal/service/worksmobile_client_test.go @@ -113,6 +113,50 @@ func TestWorksmobileHTTPClientUpsertUserPatchesOnCreateConflictWithoutPasswordOr require.Equal(t, "user-1", patchPayload["userExternalKey"]) } +func TestWorksmobileHTTPClientAddUserAliasEmailPostsDirectoryAliasEndpoint(t *testing.T) { + transport := &captureRoundTripper{ + statusCode: http.StatusCreated, + body: `{}`, + } + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + DirectoryToken: "directory-token-1", + HTTPClient: &http.Client{Transport: transport}, + } + + err := client.AddUserAliasEmail(context.Background(), "ypshim@samaneng.com", "ypshim@hanmaceng.co.kr") + + require.NoError(t, err) + require.NotNil(t, transport.request) + require.Equal(t, http.MethodPost, transport.request.Method) + require.Equal(t, "/v1.0/users/ypshim@samaneng.com/alias-emails/ypshim@hanmaceng.co.kr", transport.request.URL.Path) + require.Equal(t, "Bearer directory-token-1", transport.request.Header.Get("Authorization")) +} + +func TestWorksmobileHTTPClientResetUserPasswordPatchesPasswordConfig(t *testing.T) { + transport := &captureRoundTripper{ + statusCode: http.StatusOK, + body: `{}`, + } + client := &WorksmobileHTTPClient{ + BaseURL: "https://works.example.test", + DirectoryToken: "directory-token-1", + HTTPClient: &http.Client{Transport: transport}, + } + + err := client.ResetUserPassword(context.Background(), "target@samaneng.com", "Aa1!Aa1!Aa1!Aa1!") + + require.NoError(t, err) + require.NotNil(t, transport.request) + require.Equal(t, http.MethodPatch, transport.request.Method) + require.Equal(t, "/v1.0/users/target@samaneng.com", transport.request.URL.Path) + var payload map[string]any + require.NoError(t, json.Unmarshal(transport.requestBody, &payload)) + passwordConfig := payload["passwordConfig"].(map[string]any) + require.Equal(t, "ADMIN", passwordConfig["passwordCreationType"]) + require.Equal(t, "Aa1!Aa1!Aa1!Aa1!", passwordConfig["password"]) +} + func TestWorksmobileHTTPClientCreateUserRequiresDirectoryToken(t *testing.T) { client := &WorksmobileHTTPClient{ BaseURL: "https://works.example.test", @@ -472,6 +516,71 @@ func TestWorksmobileRelayWorkerProcessesUserCreateAndMarksProcessed(t *testing.T require.Equal(t, "tester@samaneng.com", client.createdUsers[0].Email) } +func TestWorksmobileRelayWorkerRegistersAliasEmailsAfterUserUpsert(t *testing.T) { + repo := &fakeWorksmobileOutboxRepo{ + ready: []domain.WorksmobileOutbox{ + { + ID: "job-1", + ResourceType: domain.WorksmobileResourceUser, + ResourceID: "user-1", + Action: domain.WorksmobileActionUpsert, + Status: domain.WorksmobileOutboxStatusPending, + Payload: worksmobileUserOutboxPayload("root-1", WorksmobileUserPayload{ + Email: "ypshim@samaneng.com", + UserExternalKey: "user-1", + AliasEmails: []string{"ypshim@hanmaceng.co.kr"}, + PasswordConfig: WorksmobilePasswordConfig{ + PasswordCreationType: "ADMIN", + Password: "Aa1!Aa1!Aa1!Aa1!", + }, + }), + }, + }, + } + client := &fakeWorksmobileDirectoryClient{} + worker := NewWorksmobileRelayWorker(repo, client) + + err := worker.ProcessOnce(context.Background()) + + require.NoError(t, err) + require.Equal(t, []string{"job-1"}, repo.processedIDs) + require.Equal(t, "ypshim@samaneng.com", client.createdUsers[0].Email) + require.Empty(t, client.createdUsers[0].AliasEmails) + require.Equal(t, []string{"ypshim@samaneng.com:ypshim@hanmaceng.co.kr"}, client.aliasEmails) +} + +func TestWorksmobileRelayWorkerProcessesUserPasswordResetAndMarksProcessed(t *testing.T) { + repo := &fakeWorksmobileOutboxRepo{ + ready: []domain.WorksmobileOutbox{ + { + ID: "job-reset", + ResourceType: domain.WorksmobileResourceUser, + ResourceID: "user-1", + Action: domain.WorksmobileActionPasswordReset, + Status: domain.WorksmobileOutboxStatusPending, + Payload: domain.JSONMap{ + "loginEmail": "target@samaneng.com", + "request": map[string]any{ + "email": "target@samaneng.com", + "passwordConfig": map[string]any{ + "passwordCreationType": "ADMIN", + "password": "Aa1!Aa1!Aa1!Aa1!", + }, + }, + }, + }, + }, + } + client := &fakeWorksmobileDirectoryClient{} + worker := NewWorksmobileRelayWorker(repo, client) + + err := worker.ProcessOnce(context.Background()) + + require.NoError(t, err) + require.Equal(t, []string{"job-reset"}, repo.processedIDs) + require.Equal(t, []string{"target@samaneng.com:Aa1!Aa1!Aa1!Aa1!"}, client.passwordResets) +} + func TestWorksmobileRelayWorkerProcessesUserSuspendAndMarksProcessed(t *testing.T) { repo := &fakeWorksmobileOutboxRepo{ ready: []domain.WorksmobileOutbox{ @@ -615,7 +724,7 @@ func TestCompareWorksmobileUsersIncludesBaronAndWorksPrimaryOrg(t *testing.T) { require.Equal(t, "WORKS 기술기획", items[0].WorksmobilePrimaryOrgName) } -func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t *testing.T) { +func TestCompareWorksmobileUsersMarksEmailMatchWithoutExternalIDNeedsUpdate(t *testing.T) { localUsers := []domain.User{ {ID: "user-1", Email: "tester@samaneng.com", Name: "Tester"}, } @@ -626,13 +735,37 @@ func TestCompareWorksmobileUsersMatchesByEmailWhenDirectoryAPIOmitsExternalID(t diffOnly := compareWorksmobileUsers(localUsers, remoteUsers, false, nil) all := compareWorksmobileUsers(localUsers, remoteUsers, true, nil) - require.Empty(t, diffOnly) + require.Len(t, diffOnly, 1) + require.Equal(t, "needs_update", diffOnly[0].Status) require.Len(t, all, 1) - require.Equal(t, "matched", all[0].Status) + require.Equal(t, "needs_update", all[0].Status) require.Equal(t, "works-1", all[0].WorksmobileID) require.Empty(t, all[0].ExternalKey) } +func TestCompareWorksmobileUsersIncludesRecentFailedJobForMissingUser(t *testing.T) { + localUsers := []domain.User{ + {ID: "user-1", Email: "missing@samaneng.com", Name: "Missing"}, + } + jobSummaries := map[string]worksmobileUserJobSummary{ + "user-1": { + Status: domain.WorksmobileOutboxStatusFailed, + RetryCount: 3, + LastError: "worksmobile api failed", + LastAttemptAt: "2026-06-01T05:00:00Z", + }, + } + + items := compareWorksmobileUsers(localUsers, nil, false, nil, jobSummaries) + + require.Len(t, items, 1) + require.Equal(t, "missing_in_worksmobile", items[0].Status) + require.Equal(t, domain.WorksmobileOutboxStatusFailed, items[0].WorksmobileJobStatus) + require.Equal(t, 3, items[0].WorksmobileJobRetryCount) + require.Equal(t, "worksmobile api failed", items[0].WorksmobileLastError) + require.Equal(t, "2026-06-01T05:00:00Z", items[0].WorksmobileLastAttemptAt) +} + func TestCompareWorksmobileUsersIncludesWorksOnlyRowsWithoutExternalIDWhenIncludingMatched(t *testing.T) { remoteUsers := []WorksmobileRemoteUser{ {ID: "works-1", ExternalID: "", Email: "works-only@samaneng.com", DisplayName: "Works Only"}, @@ -894,6 +1027,41 @@ func TestParseWorksmobileDirectoryUserIncludesFullNameLevelAndOrgRole(t *testing require.Equal(t, "팀장", user.PrimaryOrgUnitPositionName) require.NotNil(t, user.PrimaryOrgUnitIsManager) require.True(t, *user.PrimaryOrgUnitIsManager) + require.NotNil(t, user.OrgUnitManagers["works-org-1"]) + require.True(t, *user.OrgUnitManagers["works-org-1"]) +} + +func TestParseWorksmobileDirectoryUserIncludesAllOrgUnitManagerFlags(t *testing.T) { + user := parseWorksmobileDirectoryUser(map[string]any{ + "userId": "works-user", + "email": "tester@samaneng.com", + "userName": map[string]any{ + "lastName": "홍길동", + }, + "organizations": []any{ + map[string]any{ + "primary": true, + "orgUnits": []any{ + map[string]any{ + "orgUnitId": "externalKey:primary-org", + "primary": true, + "isManager": false, + }, + map[string]any{ + "orgUnitId": "externalKey:secondary-org", + "primary": false, + "isManager": true, + }, + }, + }, + }, + }) + + require.Len(t, user.OrgUnitManagers, 2) + require.NotNil(t, user.OrgUnitManagers["externalKey:primary-org"]) + require.False(t, *user.OrgUnitManagers["externalKey:primary-org"]) + require.NotNil(t, user.OrgUnitManagers["externalKey:secondary-org"]) + require.True(t, *user.OrgUnitManagers["externalKey:secondary-org"]) } func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) { @@ -908,11 +1076,14 @@ func TestParseWorksmobileDirectoryGroupExtractsMailLocalPart(t *testing.T) { } type fakeWorksmobileOutboxRepo struct { - ready []domain.WorksmobileOutbox - created []domain.WorksmobileOutbox - processingIDs []string - processedIDs []string - failedIDs []string + recent []domain.WorksmobileOutbox + ready []domain.WorksmobileOutbox + created []domain.WorksmobileOutbox + credentialBatchJobs []domain.WorksmobileOutbox + payloadUpdates []domain.JSONMap + processingIDs []string + processedIDs []string + failedIDs []string } func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.WorksmobileOutbox) error { @@ -921,7 +1092,31 @@ func (f *fakeWorksmobileOutboxRepo) Create(ctx context.Context, item *domain.Wor } func (f *fakeWorksmobileOutboxRepo) ListRecent(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) { - return nil, nil + return f.recent, nil +} + +func (f *fakeWorksmobileOutboxRepo) ListCredentialBatchJobs(ctx context.Context, tenantRootID, credentialBatchID string) ([]domain.WorksmobileOutbox, error) { + rows := make([]domain.WorksmobileOutbox, 0) + for _, row := range f.credentialBatchJobs { + if stringValue(row.Payload["tenantRootId"]) != tenantRootID { + continue + } + if credentialBatchID != "" && stringValue(row.Payload["credentialBatchId"]) != credentialBatchID { + continue + } + rows = append(rows, row) + } + return rows, nil +} + +func (f *fakeWorksmobileOutboxRepo) UpdatePayload(ctx context.Context, id string, payload domain.JSONMap) error { + f.payloadUpdates = append(f.payloadUpdates, payload) + for i := range f.credentialBatchJobs { + if f.credentialBatchJobs[i].ID == id { + f.credentialBatchJobs[i].Payload = payload + } + } + return nil } func (f *fakeWorksmobileOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.WorksmobileOutbox, error) { @@ -958,6 +1153,8 @@ type fakeWorksmobileDirectoryClient struct { deletedUsers []string activeUsers []string suspendedUsers []string + aliasEmails []string + passwordResets []string users []WorksmobileRemoteUser orgUnitMatchKeys []string groups []WorksmobileRemoteGroup @@ -1062,6 +1259,16 @@ func (f *fakeWorksmobileDirectoryClient) UpsertUser(ctx context.Context, payload return nil } +func (f *fakeWorksmobileDirectoryClient) AddUserAliasEmail(ctx context.Context, userID string, email string) error { + f.aliasEmails = append(f.aliasEmails, userID+":"+email) + return nil +} + +func (f *fakeWorksmobileDirectoryClient) ResetUserPassword(ctx context.Context, userID string, password string) error { + f.passwordResets = append(f.passwordResets, userID+":"+password) + return nil +} + func (f *fakeWorksmobileDirectoryClient) DeleteUser(ctx context.Context, userID string) error { f.deletedUsers = append(f.deletedUsers, userID) return nil diff --git a/backend/internal/service/worksmobile_live_flow_test.go b/backend/internal/service/worksmobile_live_flow_test.go index c06b3570..59cd7e8c 100644 --- a/backend/internal/service/worksmobile_live_flow_test.go +++ b/backend/internal/service/worksmobile_live_flow_test.go @@ -56,7 +56,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) { require.NoError(t, outboxRepo.MarkProcessed(ctx, job.ID)) continue } - item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID) + item, err := syncService.EnqueueUserSync(ctx, root.ID, user.ID, "") require.NoError(t, err) require.NotEmpty(t, item) require.NoError(t, outboxRepo.MarkRetry(ctx, job.ID)) @@ -70,7 +70,7 @@ func TestWorksmobileLiveSamanUsersDirectoryProvisioning(t *testing.T) { require.Equal(t, domain.WorksmobileOutboxStatusProcessed, processed.Status) } - credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID) + credentials, err := syncService.ListInitialPasswordCredentials(ctx, root.ID, "") require.NoError(t, err) seen := map[string]bool{} for _, credential := range credentials { diff --git a/backend/internal/service/worksmobile_mapper.go b/backend/internal/service/worksmobile_mapper.go index 0a7ce7df..5f7de72d 100644 --- a/backend/internal/service/worksmobile_mapper.go +++ b/backend/internal/service/worksmobile_mapper.go @@ -51,15 +51,21 @@ type WorksmobilePasswordConfig struct { Password string `json:"password"` } +type WorksmobilePasswordResetPayload struct { + Email string `json:"email"` + PasswordConfig WorksmobilePasswordConfig `json:"passwordConfig"` +} + type WorksmobileUserOrganization struct { DomainID int64 `json:"domainId,omitempty"` - Primary bool `json:"primary,omitempty"` + Email string `json:"email,omitempty"` + Primary bool `json:"primary"` OrgUnits []WorksmobileUserOrgUnit `json:"orgUnits"` } type WorksmobileUserOrgUnit struct { OrgUnitID string `json:"orgUnitId"` - Primary bool `json:"primary,omitempty"` + Primary bool `json:"primary"` PositionID string `json:"positionId,omitempty"` IsManager *bool `json:"isManager,omitempty"` } @@ -156,12 +162,11 @@ func BuildWorksmobileUserPayloadForDomainTenants(user domain.User, tenant domain tenantByID = map[string]domain.Tenant{} } tenantByID[tenant.ID] = tenant - domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID) - domainID, err := ResolveWorksmobileDomainIDFromTenant(domainTenant, rootConfig) + domainID, err := ResolveWorksmobileAccountDomainIDFromEmail(user.Email, tenant, rootConfig) if err != nil { return WorksmobileUserPayload{}, err } - employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number") + employeeNumber := metadataEmployeeNumber(user.Metadata) organizations, task, err := buildWorksmobileUserOrganizations(user, tenant, tenantByID, rootConfig) if err != nil { return WorksmobileUserPayload{}, err @@ -202,28 +207,19 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t if len(appointments) == 0 { appointments = []worksmobileAppointment{{TenantID: tenant.ID, IsPrimary: true}} } - primaryTenantID := metadataString(user.Metadata, "primaryTenantId", "primary_tenant_id") - if primaryTenantID == "" && user.TenantID != nil { - primaryTenantID = *user.TenantID - } - hasPrimary := false - for i := range appointments { - if appointments[i].TenantID == primaryTenantID || appointments[i].IsPrimary { - appointments[i].IsPrimary = true - hasPrimary = true - break - } - } - if !hasPrimary { - for i := range appointments { - if appointments[i].TenantID == tenant.ID { - appointments[i].IsPrimary = true - break - } - } + accountDomainTenant := worksmobileAccountDomainTenantFromEmail(user.Email, tenant, tenantByID) + accountDomainEnvKey := worksmobileTenantDomainIDEnvKey(accountDomainTenant) + if !worksmobileAppointmentsContainDomain(appointments, tenantByID, accountDomainEnvKey) && accountDomainTenant.ID != "" { + appointments = append([]worksmobileAppointment{{ + TenantID: accountDomainTenant.ID, + IsPrimary: true, + JobTitle: strings.TrimSpace(user.JobTitle), + PositionID: metadataString(user.Metadata, "worksmobilePositionId", "positionId", "position_id"), + }}, appointments...) } - organizations := make([]WorksmobileUserOrganization, 0, len(appointments)) + organizations := make([]WorksmobileUserOrganization, 0) + organizationIndexByDomainID := map[int64]int{} seen := map[string]bool{} task := "" for _, appointment := range appointments { @@ -242,21 +238,34 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t if err != nil { return nil, "", err } + isAccountDomain := worksmobileTenantDomainIDEnvKey(domainTenant) == accountDomainEnvKey + isPrimaryOrganization := isAccountDomain && !worksmobileOrganizationsHavePrimary(organizations) + organizationIndex, organizationExists := organizationIndexByDomainID[domainID] orgUnit := WorksmobileUserOrgUnit{ OrgUnitID: "externalKey:" + appointmentTenant.ID, - Primary: appointment.IsPrimary, + Primary: !organizationExists, PositionID: appointment.PositionID, } if appointment.HasManager { isManager := appointment.IsManager orgUnit.IsManager = &isManager } - organizations = append(organizations, WorksmobileUserOrganization{ - DomainID: domainID, - Primary: appointment.IsPrimary, - OrgUnits: []WorksmobileUserOrgUnit{orgUnit}, - }) - if appointment.IsPrimary && strings.TrimSpace(appointment.JobTitle) != "" { + if organizationExists { + if isPrimaryOrganization { + organizations[organizationIndex].Primary = true + organizations[organizationIndex].Email = worksmobileOrganizationEmail(user, domainTenant) + } + organizations[organizationIndex].OrgUnits = append(organizations[organizationIndex].OrgUnits, orgUnit) + } else { + organizationIndexByDomainID[domainID] = len(organizations) + organizations = append(organizations, WorksmobileUserOrganization{ + DomainID: domainID, + Email: worksmobileOrganizationEmail(user, domainTenant), + Primary: isPrimaryOrganization, + OrgUnits: []WorksmobileUserOrgUnit{orgUnit}, + }) + } + if isPrimaryOrganization && strings.TrimSpace(appointment.JobTitle) != "" { task = strings.TrimSpace(appointment.JobTitle) } seen[appointment.TenantID] = true @@ -264,10 +273,39 @@ func buildWorksmobileUserOrganizations(user domain.User, tenant domain.Tenant, t if len(organizations) == 0 { return nil, "", errors.New("no valid worksmobile organization") } + if !worksmobileOrganizationsHavePrimary(organizations) { + organizations[0].Primary = true + if len(organizations[0].OrgUnits) > 0 { + organizations[0].OrgUnits[0].Primary = true + } + } sortWorksmobileOrganizations(organizations) return organizations, task, nil } +func worksmobileAppointmentsContainDomain(appointments []worksmobileAppointment, tenantByID map[string]domain.Tenant, envKey string) bool { + for _, appointment := range appointments { + tenant, ok := tenantByID[appointment.TenantID] + if !ok { + continue + } + domainTenant := worksmobileDomainClassificationTenant(tenant, tenantByID) + if worksmobileTenantDomainIDEnvKey(domainTenant) == envKey { + return true + } + } + return false +} + +func worksmobileOrganizationsHavePrimary(organizations []WorksmobileUserOrganization) bool { + for _, organization := range organizations { + if organization.Primary { + return true + } + } + return false +} + func worksmobileAppointmentsFromMetadata(metadata domain.JSONMap) []worksmobileAppointment { rawAppointments, ok := metadata["additionalAppointments"].([]any) if !ok { @@ -326,7 +364,7 @@ func BuildWorksmobileAliasEmails(user domain.User, tenant domain.Tenant) []strin } { candidates = append(candidates, metadataStringList(user.Metadata, key)...) } - employeeNumber := metadataString(user.Metadata, "employee_id", "employeeNumber", "employee_number") + employeeNumber := metadataEmployeeNumber(user.Metadata) if isHanmacWorksmobileTenant(tenant) && employeeNumber != "" { candidates = append(candidates, employeeNumber+"@hanmaceng.co.kr") } @@ -351,26 +389,21 @@ func normalizeWorksmobileAliasEmails(primaryEmail string, candidates []string) [ return result } -func ValidateWorksmobileAliasLocalParts(primaryEmail string, aliasEmails []string, existingLocalParts map[string]string) error { - seen := map[string]string{} - primaryLocalPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail) - if err != nil { - return err - } - seen[primaryLocalPart] = primaryEmail +func ValidateWorksmobileAliasEmails(primaryEmail string, aliasEmails []string, existingEmails map[string]string) error { + seen := map[string]string{strings.ToLower(strings.TrimSpace(primaryEmail)): primaryEmail} for _, aliasEmail := range aliasEmails { - localPart, err := domain.ExtractNormalizedEmailLocalPart(aliasEmail) - if err != nil { + normalized := strings.ToLower(strings.TrimSpace(aliasEmail)) + if _, err := mail.ParseAddress(normalized); err != nil { return err } - if previous, ok := seen[localPart]; ok { - return fmt.Errorf("worksmobile alias local-part duplicates %s: %s and %s", localPart, previous, aliasEmail) + if previous, ok := seen[normalized]; ok { + return fmt.Errorf("worksmobile alias email duplicates: %s and %s", previous, aliasEmail) } - if owner, ok := existingLocalParts[localPart]; ok { - return fmt.Errorf("worksmobile alias local-part %s는 이미 사용 중입니다: %s", localPart, owner) + if owner, ok := existingEmails[normalized]; ok { + return fmt.Errorf("worksmobile alias email %s는 이미 사용 중입니다: %s", normalized, owner) } - seen[localPart] = aliasEmail + seen[normalized] = aliasEmail } return nil } @@ -446,6 +479,91 @@ func ResolveWorksmobileDomainIDFromTenant(tenant domain.Tenant, _ domain.JSONMap return 0, fmt.Errorf("worksmobile domain id env is missing for tenant: %s", envKey) } +func ResolveWorksmobileAccountDomainIDFromEmail(email string, fallbackTenant domain.Tenant, rootConfig domain.JSONMap) (int64, error) { + switch worksmobileEmailDomainName(email) { + case "samaneng.com": + if domainID, ok := worksmobileDomainIDFromEnv("SAMAN_DOMAIN_ID"); ok { + return domainID, nil + } + case "hanmaceng.co.kr": + if domainID, ok := worksmobileDomainIDFromEnv("HANMAC_DOMAIN_ID"); ok { + return domainID, nil + } + case "baroncs.co.kr": + if domainID, ok := worksmobileDomainIDFromEnv("GPDTDC_DOMAIN_ID"); ok { + return domainID, nil + } + case "brsw.kr": + if domainID, ok := worksmobileDomainIDFromEnv("BARONGROUP_DOMAIN_ID"); ok { + return domainID, nil + } + } + return ResolveWorksmobileDomainIDFromTenant(fallbackTenant, rootConfig) +} + +func worksmobileAccountDomainTenantFromEmail(email string, fallbackTenant domain.Tenant, tenantByID map[string]domain.Tenant) domain.Tenant { + envKey := worksmobileDomainIDEnvKeyFromEmail(email) + for _, tenant := range tenantByID { + if isWorksmobileDomainRootTenant(tenant) && worksmobileTenantDomainIDEnvKey(tenant) == envKey { + return tenant + } + } + for _, tenant := range tenantByID { + if worksmobileTenantDomainIDEnvKey(tenant) == envKey { + return worksmobileDomainClassificationTenant(tenant, tenantByID) + } + } + return worksmobileDomainClassificationTenant(fallbackTenant, tenantByID) +} + +func worksmobileDomainIDEnvKeyFromEmail(email string) string { + switch worksmobileEmailDomainName(email) { + case "samaneng.com": + return "SAMAN_DOMAIN_ID" + case "hanmaceng.co.kr": + return "HANMAC_DOMAIN_ID" + case "baroncs.co.kr": + return "GPDTDC_DOMAIN_ID" + case "brsw.kr": + return "BARONGROUP_DOMAIN_ID" + default: + return worksmobileTenantDomainIDEnvKey(domain.Tenant{}) + } +} + +func worksmobileEmailDomainName(email string) string { + address, err := mail.ParseAddress(strings.TrimSpace(email)) + if err != nil { + return "" + } + parts := strings.Split(address.Address, "@") + if len(parts) != 2 { + return "" + } + return strings.ToLower(strings.TrimSpace(parts[1])) +} + +func worksmobileOrganizationEmail(user domain.User, domainTenant domain.Tenant) string { + domainName := worksmobileTenantMailDomain(domainTenant) + if domainName == "" { + return "" + } + primaryEmail := strings.ToLower(strings.TrimSpace(user.Email)) + if worksmobileEmailDomainName(primaryEmail) == domainName { + return primaryEmail + } + for _, alias := range BuildWorksmobileAliasEmails(user, domainTenant) { + if worksmobileEmailDomainName(alias) == domainName { + return alias + } + } + localPart, err := domain.ExtractNormalizedEmailLocalPart(primaryEmail) + if err != nil || localPart == "" { + return "" + } + return localPart + "@" + domainName +} + func worksmobileTenantDomainIDEnvKey(tenant domain.Tenant) string { if tenantHasDomain(tenant, "samaneng.com") || tenantMatchesAny(tenant, "saman", "삼안") { return "SAMAN_DOMAIN_ID" @@ -597,6 +715,70 @@ func metadataString(metadata domain.JSONMap, keys ...string) string { return "" } +func metadataEmployeeNumber(metadata domain.JSONMap) string { + for _, key := range []string{"employee_id", "employeeNumber", "employee_number"} { + value, ok := metadata[key] + if !ok { + continue + } + if normalized := normalizeMetadataEmployeeNumber(value); normalized != "" { + return normalized + } + } + return "" +} + +func normalizeMetadataEmployeeNumber(value any) string { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return strings.TrimSpace(fmt.Sprint(v)) + case map[string]any: + return normalizeMetadataCharacterMap(v) + case domain.JSONMap: + return normalizeMetadataCharacterMap(map[string]any(v)) + case map[string]string: + converted := make(map[string]any, len(v)) + for key, value := range v { + converted[key] = value + } + return normalizeMetadataCharacterMap(converted) + default: + return "" + } +} + +func normalizeMetadataCharacterMap(value map[string]any) string { + type characterEntry struct { + index int + value string + } + entries := make([]characterEntry, 0, len(value)) + for key, raw := range value { + index, err := strconv.Atoi(key) + if err != nil { + return "" + } + part, ok := raw.(string) + if !ok || part == "" { + return "" + } + entries = append(entries, characterEntry{index: index, value: part}) + } + if len(entries) == 0 { + return "" + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].index < entries[j].index + }) + var builder strings.Builder + for _, entry := range entries { + builder.WriteString(entry.value) + } + return strings.TrimSpace(builder.String()) +} + func metadataBool(metadata domain.JSONMap, keys ...string) bool { value, _ := metadataOptionalBool(metadata, keys...) return value diff --git a/backend/internal/service/worksmobile_mapper_test.go b/backend/internal/service/worksmobile_mapper_test.go index 7ae66696..c7644b48 100644 --- a/backend/internal/service/worksmobile_mapper_test.go +++ b/backend/internal/service/worksmobile_mapper_test.go @@ -2,6 +2,7 @@ package service import ( "baron-sso-backend/internal/domain" + "encoding/json" "strings" "testing" @@ -134,6 +135,36 @@ func TestBuildWorksmobileUserPayloadMapsBaronUserAndPrimaryTenant(t *testing.T) require.Equal(t, "externalKey:"+tenantID, payload.Organizations[0].OrgUnits[0].OrgUnitID) } +func TestBuildWorksmobileUserPayloadNormalizesLegacyCharacterMapEmployeeID(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + tenantID := "33333333-3333-3333-3333-333333333333" + user := domain.User{ + ID: "44444444-4444-4444-4444-444444444444", + Email: "john1@samaneng.com", + Name: "John Doe", + TenantID: &tenantID, + Metadata: domain.JSONMap{ + "employee_id": map[string]any{ + "0": "j", + "1": "o", + "2": "h", + "3": "n", + }, + }, + } + tenant := domain.Tenant{ + ID: tenantID, + Slug: "saman", + Name: "Saman", + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + } + + payload, err := BuildWorksmobileUserPayload(user, tenant, nil) + + require.NoError(t, err) + require.Equal(t, "john", payload.EmployeeNumber) +} + func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") t.Setenv("HANMAC_DOMAIN_ID", "1002") @@ -198,7 +229,7 @@ func TestBuildWorksmobileUserPayloadMapsAdditionalAppointmentsToOrgUnits(t *test require.Equal(t, int64(1002), payload.Organizations[1].DomainID) require.False(t, payload.Organizations[1].Primary) require.Equal(t, "externalKey:"+secondaryTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID) - require.False(t, payload.Organizations[1].OrgUnits[0].Primary) + require.True(t, payload.Organizations[1].OrgUnits[0].Primary) require.NotNil(t, payload.Organizations[1].OrgUnits[0].IsManager) require.True(t, *payload.Organizations[1].OrgUnits[0].IsManager) } @@ -259,7 +290,7 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese ) require.NoError(t, err) - require.Equal(t, int64(1003), payload.DomainID) + require.Equal(t, int64(1001), payload.DomainID) require.Equal(t, "First affiliation task", payload.Task) require.Len(t, payload.Organizations, 2) require.Equal(t, int64(1001), payload.Organizations[0].DomainID) @@ -269,7 +300,99 @@ func TestBuildWorksmobileUserPayloadKeepsFirstAffiliationPrimaryWhenBaronReprese require.Equal(t, int64(1003), payload.Organizations[1].DomainID) require.False(t, payload.Organizations[1].Primary) require.Equal(t, "externalKey:"+secondTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID) - require.False(t, payload.Organizations[1].OrgUnits[0].Primary) + require.True(t, payload.Organizations[1].OrgUnits[0].Primary) +} + +func TestBuildWorksmobileUserPayloadUsesEmailDomainForAccountDomainWhenPrimaryOrgIsGPDTDC(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + t.Setenv("GPDTDC_DOMAIN_ID", "1003") + samanID := "11111111-1111-1111-1111-111111111111" + gpdtdcID := "5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee" + leafTenantID := "52f06c97-9d6f-4819-971b-43303062e193" + user := domain.User{ + ID: "44444444-4444-4444-4444-444444444444", + Email: "dhlee@samaneng.com", + Name: "GPDTDC Saman User", + TenantID: &leafTenantID, + Metadata: domain.JSONMap{ + "additionalAppointments": []any{ + map[string]any{ + "tenantId": leafTenantID, + "isPrimary": true, + }, + }, + }, + } + samanTenant := domain.Tenant{ + ID: samanID, + Slug: "saman", + Name: "삼안", + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + } + gpdtdcTenant := domain.Tenant{ + ID: gpdtdcID, + Slug: "gpdtdc", + Name: "총괄기획&기술개발센터", + } + leafTenant := domain.Tenant{ + ID: leafTenantID, + Slug: "infra-bim2", + Name: "인프라 BIM2", + ParentID: &gpdtdcID, + } + + payload, err := BuildWorksmobileUserPayloadForDomainTenants( + user, + leafTenant, + map[string]domain.Tenant{ + samanID: samanTenant, + gpdtdcID: gpdtdcTenant, + leafTenantID: leafTenant, + }, + nil, + ) + + require.NoError(t, err) + require.Equal(t, int64(1001), payload.DomainID) + require.Len(t, payload.Organizations, 2) + require.Equal(t, int64(1001), payload.Organizations[0].DomainID) + require.True(t, payload.Organizations[0].Primary) + require.Equal(t, "dhlee@samaneng.com", payload.Organizations[0].Email) + require.Equal(t, "externalKey:"+samanID, payload.Organizations[0].OrgUnits[0].OrgUnitID) + require.True(t, payload.Organizations[0].OrgUnits[0].Primary) + require.Equal(t, int64(1003), payload.Organizations[1].DomainID) + require.False(t, payload.Organizations[1].Primary) + require.Equal(t, "dhlee@baroncs.co.kr", payload.Organizations[1].Email) + require.Equal(t, "externalKey:"+leafTenantID, payload.Organizations[1].OrgUnits[0].OrgUnitID) + require.True(t, payload.Organizations[1].OrgUnits[0].Primary) +} + +func TestWorksmobileUserPayloadJSONIncludesFalsePrimaryFields(t *testing.T) { + payload := WorksmobileUserPayload{ + Email: "user@samaneng.com", + Organizations: []WorksmobileUserOrganization{ + { + DomainID: 1001, + Primary: true, + OrgUnits: []WorksmobileUserOrgUnit{ + {OrgUnitID: "externalKey:primary", Primary: true}, + }, + }, + { + DomainID: 1003, + Primary: false, + OrgUnits: []WorksmobileUserOrgUnit{ + {OrgUnitID: "externalKey:secondary", Primary: false}, + }, + }, + }, + } + + data, err := json.Marshal(payload) + + require.NoError(t, err) + require.Contains(t, string(data), `"primary":false`) + require.Contains(t, string(data), `"orgUnitId":"externalKey:secondary","primary":false`) } func TestResolveWorksmobileDomainIDFromTenantIgnoresRootDomainMappings(t *testing.T) { @@ -441,19 +564,51 @@ func TestBuildWorksmobileUserPayloadAddsSubEmailMetadataAlias(t *testing.T) { require.Equal(t, []string{"alias1@hanmaceng.co.kr", "alias2@hanmaceng.co.kr"}, payload.AliasEmails) } -func TestValidateWorksmobileAliasLocalPartsRejectsPrimaryAndAliasCollisions(t *testing.T) { - err := ValidateWorksmobileAliasLocalParts( +func TestBuildWorksmobileUserPayloadKeepsSubEmailAliasWithPrimaryLocalPart(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + tenantID := "33333333-3333-3333-3333-333333333333" + user := domain.User{ + ID: "44444444-4444-4444-4444-444444444444", + Email: "ypshim@samaneng.com", + Name: "Saman User", + TenantID: &tenantID, + Metadata: domain.JSONMap{ + "sub_email": "ypshim@hanmaceng.co.kr", + }, + } + tenant := domain.Tenant{ + ID: tenantID, + Slug: "saman", + Name: "삼안", + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + } + + payload, err := BuildWorksmobileUserPayload(user, tenant, nil) + + require.NoError(t, err) + require.Equal(t, []string{"ypshim@hanmaceng.co.kr"}, payload.AliasEmails) +} + +func TestValidateWorksmobileAliasEmailsAllowsSameLocalPartOnDifferentDomains(t *testing.T) { + err := ValidateWorksmobileAliasEmails( "main@samaneng.com", []string{"main@hanmaceng.co.kr"}, map[string]string{}, ) - require.Error(t, err) - require.Contains(t, err.Error(), "local-part") + require.NoError(t, err) - err = ValidateWorksmobileAliasLocalParts( + err = ValidateWorksmobileAliasEmails( + "main@samaneng.com", + []string{"main@samaneng.com"}, + map[string]string{}, + ) + require.Error(t, err) + require.Contains(t, err.Error(), "duplicates") + + err = ValidateWorksmobileAliasEmails( "main@samaneng.com", []string{"alias@hanmaceng.co.kr"}, - map[string]string{"alias": "existing-user"}, + map[string]string{"alias@hanmaceng.co.kr": "existing-user"}, ) require.Error(t, err) require.Contains(t, err.Error(), "이미 사용 중") diff --git a/backend/internal/service/worksmobile_relay_worker.go b/backend/internal/service/worksmobile_relay_worker.go index 3e971466..c17f347f 100644 --- a/backend/internal/service/worksmobile_relay_worker.go +++ b/backend/internal/service/worksmobile_relay_worker.go @@ -100,9 +100,16 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil { return err } + aliasEmails := append([]string(nil), payload.AliasEmails...) + payload.AliasEmails = nil if err := w.client.UpsertUser(ctx, payload); err != nil { return err } + for _, aliasEmail := range aliasEmails { + if err := w.client.AddUserAliasEmail(ctx, payload.Email, aliasEmail); err != nil { + return err + } + } if stringValue(job.Payload["baronStatus"]) == domain.UserStatusActive { return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), true) } @@ -111,6 +118,16 @@ func (w *WorksmobileRelayWorker) dispatch(ctx context.Context, job domain.Worksm return w.client.DeleteUser(ctx, worksmobileOutboxUserIdentifier(job)) case domain.WorksmobileActionSuspend: return w.client.SetUserActive(ctx, worksmobileOutboxUserIdentifier(job), false) + case domain.WorksmobileActionPasswordReset: + var payload WorksmobilePasswordResetPayload + if err := decodeWorksmobileRequest(job.Payload, &payload); err != nil { + return err + } + identifier := strings.TrimSpace(payload.Email) + if identifier == "" { + identifier = worksmobileOutboxUserIdentifier(job) + } + return w.client.ResetUserPassword(ctx, identifier, payload.PasswordConfig.Password) default: return nil } diff --git a/backend/internal/service/worksmobile_sync_service.go b/backend/internal/service/worksmobile_sync_service.go index 6d318f6b..733eedeb 100644 --- a/backend/internal/service/worksmobile_sync_service.go +++ b/backend/internal/service/worksmobile_sync_service.go @@ -5,9 +5,11 @@ import ( "baron-sso-backend/internal/repository" "context" "errors" + "net/mail" "os" "sort" "strings" + "time" ) const HanmacFamilyTenantSlug = "hanmac-family" @@ -25,9 +27,12 @@ type WorksmobileAdminService interface { EnqueueBackfillDryRun(ctx context.Context, tenantID string) (WorksmobileBackfillDryRun, error) EnqueueOrgUnitSync(ctx context.Context, tenantID, orgUnitID string) (*domain.WorksmobileOutbox, error) EnqueueOrgUnitDelete(ctx context.Context, tenantID, worksmobileOrgUnitID string) (*domain.WorksmobileOutbox, error) - EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) + EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) + EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) - ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error) + ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error) + ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error) + DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error) } type WorksmobileConfigSummary struct { @@ -49,10 +54,36 @@ type WorksmobileBackfillDryRun struct { } type WorksmobileInitialPasswordCredential struct { - Email string `json:"email"` - InitialPassword string `json:"initialPassword"` - Status string `json:"status"` - LastError string `json:"lastError,omitempty"` + Email string `json:"email"` + Name string `json:"name,omitempty"` + PrimaryLeafOrgName string `json:"primaryLeafOrgName,omitempty"` + InitialPassword string `json:"initialPassword"` + Status string `json:"status"` + LastError string `json:"lastError,omitempty"` +} + +type WorksmobileCredentialBatch struct { + BatchID string `json:"batchId"` + Operation string `json:"operation,omitempty"` + UserCount int `json:"userCount"` + PendingCount int `json:"pendingCount"` + ProcessingCount int `json:"processingCount"` + ProcessedCount int `json:"processedCount"` + FailedCount int `json:"failedCount"` + HasPasswords bool `json:"hasPasswords"` + DeletedAt string `json:"deletedAt,omitempty"` + Failures []WorksmobileCredentialBatchFailure `json:"failures,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type WorksmobileCredentialBatchFailure struct { + UserID string `json:"userId,omitempty"` + Email string `json:"email,omitempty"` + Status string `json:"status"` + RetryCount int `json:"retryCount"` + LastError string `json:"lastError,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` } type WorksmobileComparison struct { @@ -93,6 +124,10 @@ type WorksmobileComparisonItem struct { WorksmobileParentName string `json:"worksmobileParentName,omitempty"` WorksmobileParentEmail string `json:"worksmobileParentEmail,omitempty"` WorksmobileParentExternalKey string `json:"worksmobileParentExternalKey,omitempty"` + WorksmobileJobStatus string `json:"worksmobileJobStatus,omitempty"` + WorksmobileJobRetryCount int `json:"worksmobileJobRetryCount,omitempty"` + WorksmobileLastError string `json:"worksmobileLastError,omitempty"` + WorksmobileLastAttemptAt string `json:"worksmobileLastAttemptAt,omitempty"` Status string `json:"status"` } @@ -185,8 +220,10 @@ func (s *worksmobileSyncService) GetComparison(ctx context.Context, tenantID str return WorksmobileComparison{}, err } + recentJobs, _ := s.outboxRepo.ListRecent(ctx, 1000) + return WorksmobileComparison{ - Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID), + Users: compareWorksmobileUsers(users, remoteUsers, includeMatched, tenantByID, worksmobileUserJobSummaries(recentJobs)), Groups: compareWorksmobileGroups(append([]domain.Tenant{*root}, tenants...), remoteGroups, includeMatched), }, nil } @@ -340,7 +377,7 @@ func (s *worksmobileSyncService) EnqueueOrgUnitDelete(ctx context.Context, tenan return item, nil } -func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID string) (*domain.WorksmobileOutbox, error) { +func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return nil, err @@ -394,18 +431,104 @@ func (s *worksmobileSyncService) EnqueueUserSync(ctx context.Context, tenantID, DedupeKey: "user:" + strings.ToLower(action) + ":" + user.ID, Payload: worksmobileUserOutboxPayload(root.ID, payload, user.Status), } + item.Payload["displayName"] = strings.TrimSpace(user.Name) + item.Payload["primaryLeafOrgName"] = worksmobileUserPrimaryOrgName(*user, tenantByID) + if batchID := strings.TrimSpace(credentialBatchID); batchID != "" { + item.Payload["credentialBatchId"] = batchID + item.Payload["credentialOperation"] = "worksmobile_user_sync" + item.Payload["credentialBatchCreatedAt"] = time.Now().UTC().Format(time.RFC3339Nano) + } if err := s.outboxRepo.Create(ctx, item); err != nil { return nil, err } return item, nil } -func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID string) ([]WorksmobileInitialPasswordCredential, error) { +func (s *worksmobileSyncService) EnqueueUserPasswordReset(ctx context.Context, tenantID, userID, credentialBatchID string) (*domain.WorksmobileOutbox, error) { root, err := s.hanmacRoot(ctx, tenantID) if err != nil { return nil, err } - jobs, err := s.outboxRepo.ListRecent(ctx, 1000) + user, err := s.userRepo.FindByID(ctx, userID) + if err != nil { + return nil, err + } + if user.TenantID == nil { + return nil, errors.New("target user has no tenant") + } + tenant, err := s.tenantService.GetTenant(ctx, *user.TenantID) + if err != nil { + return nil, err + } + tenantRoot, ok, err := s.rootForTenant(ctx, *tenant) + if err != nil { + return nil, err + } + if !ok || tenantRoot.ID != root.ID { + return nil, errors.New("target user is outside hanmac-family subtree") + } + if !domain.IsWorksProvisionedUserStatus(user.Status) { + return nil, errors.New("target user status is excluded from Worksmobile password reset") + } + scopeTenants, err := s.hanmacSubtree(ctx, root.ID) + if err != nil { + return nil, err + } + tenantByID := worksmobileTenantByID(append([]domain.Tenant{*root}, scopeTenants...)) + payload, err := BuildWorksmobileUserPayloadForDomainTenants(*user, *tenant, tenantByID, root.Config) + if err != nil { + return nil, err + } + password := GenerateWorksmobileInitialPassword() + request := WorksmobilePasswordResetPayload{ + Email: strings.TrimSpace(payload.Email), + PasswordConfig: WorksmobilePasswordConfig{ + PasswordCreationType: "ADMIN", + Password: password, + }, + } + batchID := strings.TrimSpace(credentialBatchID) + batchCreatedAt := time.Now().UTC().Format(time.RFC3339Nano) + dedupeSuffix := batchID + if dedupeSuffix == "" { + dedupeSuffix = batchCreatedAt + } + item := &domain.WorksmobileOutbox{ + ResourceType: domain.WorksmobileResourceUser, + ResourceID: user.ID, + Action: domain.WorksmobileActionPasswordReset, + DedupeKey: "user:password-reset:" + user.ID + ":" + dedupeSuffix, + Payload: domain.JSONMap{ + "tenantRootId": root.ID, + "loginEmail": request.Email, + "userExternalKey": user.ID, + "initialPassword": password, + "displayName": strings.TrimSpace(user.Name), + "primaryLeafOrgName": worksmobileUserPrimaryOrgName(*user, tenantByID), + "credentialBatchId": batchID, + "credentialOperation": "worksmobile_password_reset", + "credentialBatchCreatedAt": batchCreatedAt, + "request": request, + }, + } + if err := s.outboxRepo.Create(ctx, item); err != nil { + return nil, err + } + return item, nil +} + +func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Context, tenantID, credentialBatchID string) ([]WorksmobileInitialPasswordCredential, error) { + root, err := s.hanmacRoot(ctx, tenantID) + if err != nil { + return nil, err + } + credentialBatchID = strings.TrimSpace(credentialBatchID) + var jobs []domain.WorksmobileOutbox + if credentialBatchID != "" { + jobs, err = s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID) + } else { + jobs, err = s.outboxRepo.ListRecent(ctx, 1000) + } if err != nil { return nil, err } @@ -418,6 +541,9 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont if stringValue(job.Payload["tenantRootId"]) != root.ID { continue } + if credentialBatchID != "" && stringValue(job.Payload["credentialBatchId"]) != credentialBatchID { + continue + } email := stringValue(job.Payload["loginEmail"]) password := stringValue(job.Payload["initialPassword"]) if email == "" || password == "" || seen[email] { @@ -425,15 +551,60 @@ func (s *worksmobileSyncService) ListInitialPasswordCredentials(ctx context.Cont } seen[email] = true credentials = append(credentials, WorksmobileInitialPasswordCredential{ - Email: email, - InitialPassword: password, - Status: job.Status, - LastError: job.LastError, + Email: email, + Name: stringValue(job.Payload["displayName"]), + PrimaryLeafOrgName: stringValue(job.Payload["primaryLeafOrgName"]), + InitialPassword: password, + Status: job.Status, + LastError: job.LastError, }) } return credentials, nil } +func (s *worksmobileSyncService) ListCredentialBatches(ctx context.Context, tenantID string) ([]WorksmobileCredentialBatch, error) { + root, err := s.hanmacRoot(ctx, tenantID) + if err != nil { + return nil, err + } + jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, "") + if err != nil { + return nil, err + } + return aggregateWorksmobileCredentialBatches(jobs), nil +} + +func (s *worksmobileSyncService) DeleteCredentialBatchPasswords(ctx context.Context, tenantID, credentialBatchID string) (WorksmobileCredentialBatch, error) { + root, err := s.hanmacRoot(ctx, tenantID) + if err != nil { + return WorksmobileCredentialBatch{}, err + } + credentialBatchID = strings.TrimSpace(credentialBatchID) + if credentialBatchID == "" { + return WorksmobileCredentialBatch{}, errors.New("credential batch id is required") + } + jobs, err := s.outboxRepo.ListCredentialBatchJobs(ctx, root.ID, credentialBatchID) + if err != nil { + return WorksmobileCredentialBatch{}, err + } + if len(jobs) == 0 { + return WorksmobileCredentialBatch{}, errors.New("credential batch not found") + } + deletedAt := time.Now().UTC().Format(time.RFC3339) + for i := range jobs { + nextPayload := scrubWorksmobileCredentialPayload(jobs[i].Payload, deletedAt) + if err := s.outboxRepo.UpdatePayload(ctx, jobs[i].ID, nextPayload); err != nil { + return WorksmobileCredentialBatch{}, err + } + jobs[i].Payload = nextPayload + } + batches := aggregateWorksmobileCredentialBatches(jobs) + if len(batches) == 0 { + return WorksmobileCredentialBatch{}, errors.New("credential batch not found") + } + return batches[0], nil +} + func (s *worksmobileSyncService) RetryJob(ctx context.Context, tenantID, jobID string) (*domain.WorksmobileOutbox, error) { if _, err := s.hanmacRoot(ctx, tenantID); err != nil { return nil, err @@ -663,7 +834,7 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context if existingUser.ID == user.ID { continue } - addWorksmobileLocalPart(existing, existingUser.Email, existingUser.ID) + addWorksmobileEmail(existing, existingUser.Email, existingUser.ID) if existingUser.TenantID == nil { continue } @@ -672,16 +843,16 @@ func (s *worksmobileSyncService) validateUserAliasLocalParts(ctx context.Context continue } for _, alias := range BuildWorksmobileAliasEmails(existingUser, tenant) { - addWorksmobileLocalPart(existing, alias, existingUser.ID) + addWorksmobileEmail(existing, alias, existingUser.ID) } } - return ValidateWorksmobileAliasLocalParts(payload.Email, payload.AliasEmails, existing) + return ValidateWorksmobileAliasEmails(payload.Email, payload.AliasEmails, existing) } -func addWorksmobileLocalPart(target map[string]string, email string, owner string) { - localPart, err := domain.ExtractNormalizedEmailLocalPart(email) - if err == nil && localPart != "" { - target[localPart] = owner +func addWorksmobileEmail(target map[string]string, email string, owner string) { + normalized := strings.ToLower(strings.TrimSpace(email)) + if _, err := mail.ParseAddress(normalized); err == nil && normalized != "" { + target[normalized] = owner } } @@ -833,6 +1004,196 @@ func worksmobileUserOutboxPayload(rootID string, payload WorksmobileUserPayload, return outboxPayload } +func aggregateWorksmobileCredentialBatches(jobs []domain.WorksmobileOutbox) []WorksmobileCredentialBatch { + byBatchID := map[string]*WorksmobileCredentialBatch{} + for _, job := range jobs { + batchID := stringValue(job.Payload["credentialBatchId"]) + if batchID == "" { + continue + } + batch, ok := byBatchID[batchID] + if !ok { + createdAt := worksmobileCredentialBatchCreatedAt(job) + batch = &WorksmobileCredentialBatch{ + BatchID: batchID, + Operation: stringValue(job.Payload["credentialOperation"]), + CreatedAt: createdAt, + UpdatedAt: job.UpdatedAt, + } + byBatchID[batchID] = batch + } + batch.UserCount++ + if batch.Operation == "" { + batch.Operation = stringValue(job.Payload["credentialOperation"]) + } + jobBatchCreatedAt := worksmobileCredentialBatchCreatedAt(job) + if jobBatchCreatedAt.Before(batch.CreatedAt) || batch.CreatedAt.IsZero() { + batch.CreatedAt = jobBatchCreatedAt + } + if job.UpdatedAt.After(batch.UpdatedAt) { + batch.UpdatedAt = job.UpdatedAt + } + switch job.Status { + case domain.WorksmobileOutboxStatusPending: + batch.PendingCount++ + case domain.WorksmobileOutboxStatusProcessing: + batch.ProcessingCount++ + case domain.WorksmobileOutboxStatusProcessed: + batch.ProcessedCount++ + case domain.WorksmobileOutboxStatusFailed: + batch.FailedCount++ + batch.Failures = append(batch.Failures, WorksmobileCredentialBatchFailure{ + UserID: job.ResourceID, + Email: worksmobileCredentialJobEmail(job), + Status: job.Status, + RetryCount: job.RetryCount, + LastError: strings.TrimSpace(job.LastError), + UpdatedAt: job.UpdatedAt.Format(time.RFC3339), + }) + } + if worksmobilePayloadHasPassword(job.Payload) { + batch.HasPasswords = true + } + if deletedAt := stringValue(job.Payload["credentialDeletedAt"]); deletedAt != "" { + batch.DeletedAt = deletedAt + } + } + batches := make([]WorksmobileCredentialBatch, 0, len(byBatchID)) + for _, batch := range byBatchID { + batches = append(batches, *batch) + } + sort.Slice(batches, func(i, j int) bool { + return batches[i].CreatedAt.After(batches[j].CreatedAt) + }) + return batches +} + +func worksmobileCredentialBatchCreatedAt(job domain.WorksmobileOutbox) time.Time { + if value := stringValue(job.Payload["credentialBatchCreatedAt"]); value != "" { + if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil { + return parsed.UTC() + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + return parsed.UTC() + } + } + if !job.UpdatedAt.IsZero() && !job.CreatedAt.IsZero() && job.UpdatedAt.After(job.CreatedAt) { + return job.UpdatedAt.UTC() + } + return job.CreatedAt.UTC() +} + +func worksmobileCredentialJobEmail(job domain.WorksmobileOutbox) string { + if email := stringValue(job.Payload["loginEmail"]); email != "" { + return email + } + switch request := job.Payload["request"].(type) { + case WorksmobileUserPayload: + return strings.TrimSpace(request.Email) + case WorksmobilePasswordResetPayload: + return strings.TrimSpace(request.Email) + case map[string]any: + return stringValue(request["email"]) + case domain.JSONMap: + return stringValue(request["email"]) + default: + return "" + } +} + +func scrubWorksmobileCredentialPayload(payload domain.JSONMap, deletedAt string) domain.JSONMap { + nextPayload := make(domain.JSONMap, len(payload)+1) + for key, value := range payload { + nextPayload[key] = value + } + delete(nextPayload, "initialPassword") + nextPayload["credentialDeletedAt"] = deletedAt + nextPayload["request"] = scrubWorksmobileRequestPassword(nextPayload["request"]) + return nextPayload +} + +func scrubWorksmobileRequestPassword(request any) any { + switch v := request.(type) { + case WorksmobileUserPayload: + v.PasswordConfig.Password = "" + return v + case WorksmobilePasswordResetPayload: + v.PasswordConfig.Password = "" + return v + case map[string]any: + next := make(map[string]any, len(v)) + for key, value := range v { + next[key] = value + } + next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"]) + return next + case domain.JSONMap: + next := make(domain.JSONMap, len(v)) + for key, value := range v { + next[key] = value + } + next["passwordConfig"] = scrubWorksmobilePasswordConfig(next["passwordConfig"]) + return next + default: + return request + } +} + +func scrubWorksmobilePasswordConfig(config any) any { + switch v := config.(type) { + case WorksmobilePasswordConfig: + v.Password = "" + return v + case map[string]any: + next := make(map[string]any, len(v)) + for key, value := range v { + next[key] = value + } + next["password"] = "" + return next + case domain.JSONMap: + next := make(domain.JSONMap, len(v)) + for key, value := range v { + next[key] = value + } + next["password"] = "" + return next + default: + return config + } +} + +func worksmobilePayloadHasPassword(payload domain.JSONMap) bool { + if stringValue(payload["initialPassword"]) != "" { + return true + } + switch request := payload["request"].(type) { + case WorksmobileUserPayload: + return strings.TrimSpace(request.PasswordConfig.Password) != "" + case WorksmobilePasswordResetPayload: + return strings.TrimSpace(request.PasswordConfig.Password) != "" + case map[string]any: + return worksmobilePasswordConfigHasPassword(request["passwordConfig"]) + case domain.JSONMap: + return worksmobilePasswordConfigHasPassword(request["passwordConfig"]) + default: + return false + } +} + +func worksmobilePasswordConfigHasPassword(config any) bool { + switch v := config.(type) { + case WorksmobilePasswordConfig: + return strings.TrimSpace(v.Password) != "" + case map[string]any: + return stringValue(v["password"]) != "" + case domain.JSONMap: + return stringValue(v["password"]) != "" + default: + return false + } +} + func stringValue(value any) string { switch v := value.(type) { case string: @@ -842,7 +1203,40 @@ func stringValue(value any) string { } } -func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant) []WorksmobileComparisonItem { +type worksmobileUserJobSummary struct { + Status string + RetryCount int + LastError string + LastAttemptAt string +} + +func worksmobileUserJobSummaries(jobs []domain.WorksmobileOutbox) map[string]worksmobileUserJobSummary { + result := map[string]worksmobileUserJobSummary{} + for _, job := range jobs { + if job.ResourceType != domain.WorksmobileResourceUser { + continue + } + if job.ResourceID == "" { + continue + } + if _, exists := result[job.ResourceID]; exists { + continue + } + result[job.ResourceID] = worksmobileUserJobSummary{ + Status: job.Status, + RetryCount: job.RetryCount, + LastError: strings.TrimSpace(job.LastError), + LastAttemptAt: job.UpdatedAt.Format(time.RFC3339), + } + } + return result +} + +func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []WorksmobileRemoteUser, includeMatched bool, localTenants map[string]domain.Tenant, jobSummaries ...map[string]worksmobileUserJobSummary) []WorksmobileComparisonItem { + jobSummaryByUserID := map[string]worksmobileUserJobSummary{} + if len(jobSummaries) > 0 && jobSummaries[0] != nil { + jobSummaryByUserID = jobSummaries[0] + } remoteByExternalID := map[string]WorksmobileRemoteUser{} remoteByEmail := map[string]WorksmobileRemoteUser{} for _, remote := range remoteUsers { @@ -872,7 +1266,8 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile if !matched { remote, matched = remoteByEmail[strings.ToLower(strings.TrimSpace(user.Email))] } - if matched && !includeMatched { + needsUpdate := matched && worksmobileUserNeedsUpdate(user, remote) + if matched && !includeMatched && !needsUpdate { matchedRemoteIDs[remote.ID] = true continue } @@ -886,8 +1281,19 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile BaronPrimaryOrgName: worksmobileUserPrimaryOrgName(user, localTenants), Status: "missing_in_worksmobile", } + if summary, ok := jobSummaryByUserID[user.ID]; ok { + item.WorksmobileJobStatus = summary.Status + item.WorksmobileJobRetryCount = summary.RetryCount + item.WorksmobileLastAttemptAt = summary.LastAttemptAt + if summary.Status == domain.WorksmobileOutboxStatusFailed { + item.WorksmobileLastError = summary.LastError + } + } if matched { item.Status = "matched" + if needsUpdate { + item.Status = "needs_update" + } item.WorksmobileID = remote.ID item.ExternalKey = remote.ExternalID item.WorksmobileName = remote.DisplayName @@ -958,6 +1364,62 @@ func compareWorksmobileUsers(localUsers []domain.User, remoteUsers []Worksmobile return result } +func worksmobileUserNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool { + if strings.TrimSpace(remote.ExternalID) != strings.TrimSpace(user.ID) { + return true + } + if strings.TrimSpace(remote.DisplayName) != strings.TrimSpace(user.Name) { + return true + } + if strings.ToLower(strings.TrimSpace(remote.Email)) != strings.ToLower(strings.TrimSpace(user.Email)) { + return true + } + if worksmobileUserManagerNeedsUpdate(user, remote) { + return true + } + return false +} + +func worksmobileUserManagerNeedsUpdate(user domain.User, remote WorksmobileRemoteUser) bool { + localManagers := worksmobileUserExplicitOrgUnitManagers(user) + if len(localManagers) == 0 { + return false + } + remoteManagers := remote.OrgUnitManagers + if len(remoteManagers) == 0 && remote.PrimaryOrgUnitID != "" { + remoteManagers = map[string]*bool{remote.PrimaryOrgUnitID: remote.PrimaryOrgUnitIsManager} + } + for remoteOrgUnitID, remoteManager := range remoteManagers { + if remoteManager == nil { + continue + } + localManager, ok := localManagers[worksmobileOrgUnitLocalExternalKey(remoteOrgUnitID)] + if ok && localManager != *remoteManager { + return true + } + } + return false +} + +func worksmobileUserExplicitOrgUnitManagers(user domain.User) map[string]bool { + managers := map[string]bool{} + for _, appointment := range worksmobileAppointmentsFromMetadata(user.Metadata) { + if appointment.TenantID == "" || !appointment.HasManager { + continue + } + managers[appointment.TenantID] = appointment.IsManager + } + return managers +} + +func worksmobileOrgUnitLocalExternalKey(orgUnitID string) string { + normalized := strings.TrimSpace(orgUnitID) + if after, ok := strings.CutPrefix(normalized, "externalKey:"); ok { + return strings.TrimSpace(after) + } + return normalized +} + func worksmobileUserPrimaryOrgID(user domain.User) string { if user.TenantID == nil { return "" diff --git a/backend/internal/service/worksmobile_sync_service_test.go b/backend/internal/service/worksmobile_sync_service_test.go index 16e85fc2..7db689a0 100644 --- a/backend/internal/service/worksmobile_sync_service_test.go +++ b/backend/internal/service/worksmobile_sync_service_test.go @@ -4,11 +4,12 @@ import ( "baron-sso-backend/internal/domain" "context" "testing" + "time" "github.com/stretchr/testify/require" ) -func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *testing.T) { +func TestWorksmobileSyncServiceRejectsAliasEmailAlreadyUsedByOtherUser(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" tenantID := "saman-tenant" @@ -36,7 +37,7 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te } existing := domain.User{ ID: "existing-user", - Email: "used@samaneng.com", + Email: "used@hanmaceng.co.kr", Name: "Existing", TenantID: &tenantID, } @@ -48,7 +49,7 @@ func TestWorksmobileSyncServiceRejectsAliasLocalPartAlreadyUsedByOtherUser(t *te nil, ) - item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID) + item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "") require.Nil(t, item) require.Error(t, err) @@ -88,7 +89,7 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t nil, ) - item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID) + item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "") require.NoError(t, err) require.NotNil(t, item) @@ -101,6 +102,253 @@ func TestWorksmobileSyncServiceEnqueuesSuspendedUserStatusWithOrganizations(t *t require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"]) } +func TestWorksmobileSyncServiceEnqueuesUserCredentialBatchID(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + rootID := "root-tenant" + tenantID := "saman-tenant" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "Hanmac Family", + } + tenant := domain.Tenant{ + ID: tenantID, + Slug: "saman", + Name: "Saman", + Type: domain.TenantTypeCompany, + ParentID: &rootID, + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + } + target := domain.User{ + ID: "target-user", + Email: "target@samaneng.com", + Name: "Target", + Status: domain.UserStatusActive, + TenantID: &tenantID, + } + outboxRepo := &fakeWorksmobileOutboxRepo{} + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}}, + &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}}, + outboxRepo, + nil, + ) + + item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "batch-1") + + require.NoError(t, err) + require.NotNil(t, item) + require.Len(t, outboxRepo.created, 1) + require.Equal(t, "batch-1", outboxRepo.created[0].Payload["credentialBatchId"]) + require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"]) + require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"]) + require.Equal(t, "Saman", outboxRepo.created[0].Payload["primaryLeafOrgName"]) +} + +func TestWorksmobileSyncServiceEnqueuesUserPasswordResetCredentialBatch(t *testing.T) { + t.Setenv("SAMAN_DOMAIN_ID", "1001") + rootID := "root-tenant" + tenantID := "saman-leaf" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "Hanmac Family", + } + tenant := domain.Tenant{ + ID: tenantID, + Slug: "people-growth", + Name: "인재성장", + Type: domain.TenantTypeOrganization, + ParentID: &rootID, + Domains: []domain.TenantDomain{{Domain: "samaneng.com"}}, + } + target := domain.User{ + ID: "target-user", + Email: "target@samaneng.com", + Name: "Target", + Status: domain.UserStatusActive, + TenantID: &tenantID, + } + outboxRepo := &fakeWorksmobileOutboxRepo{} + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root, tenantID: tenant}, list: []domain.Tenant{root, tenant}}, + &fakeWorksmobileUserRepo{byID: map[string]domain.User{target.ID: target}, byTenant: []domain.User{target}}, + outboxRepo, + nil, + ) + + item, err := service.EnqueueUserPasswordReset(context.Background(), rootID, target.ID, "reset-batch-1") + + require.NoError(t, err) + require.NotNil(t, item) + require.Len(t, outboxRepo.created, 1) + require.Equal(t, domain.WorksmobileActionPasswordReset, outboxRepo.created[0].Action) + require.Equal(t, "reset-batch-1", outboxRepo.created[0].Payload["credentialBatchId"]) + require.Equal(t, "worksmobile_password_reset", outboxRepo.created[0].Payload["credentialOperation"]) + require.NotEmpty(t, outboxRepo.created[0].Payload["credentialBatchCreatedAt"]) + require.Equal(t, "target@samaneng.com", outboxRepo.created[0].Payload["loginEmail"]) + require.Equal(t, "Target", outboxRepo.created[0].Payload["displayName"]) + require.Equal(t, "인재성장", outboxRepo.created[0].Payload["primaryLeafOrgName"]) + require.NotEmpty(t, outboxRepo.created[0].Payload["initialPassword"]) +} + +func TestWorksmobileSyncServiceFiltersInitialPasswordsByCredentialBatchID(t *testing.T) { + rootID := "root-tenant" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "Hanmac Family", + } + outboxRepo := &fakeWorksmobileOutboxRepo{ + credentialBatchJobs: []domain.WorksmobileOutbox{ + { + ResourceType: domain.WorksmobileResourceUser, + Status: domain.WorksmobileOutboxStatusProcessed, + Payload: domain.JSONMap{ + "tenantRootId": rootID, + "loginEmail": "batch-user@samaneng.com", + "displayName": "Batch User", + "primaryLeafOrgName": "인재성장", + "initialPassword": "BatchPass1!", + "credentialBatchId": "batch-1", + }, + }, + { + ResourceType: domain.WorksmobileResourceUser, + Status: domain.WorksmobileOutboxStatusProcessed, + Payload: domain.JSONMap{ + "tenantRootId": rootID, + "loginEmail": "other-user@samaneng.com", + "initialPassword": "OtherPass1!", + "credentialBatchId": "batch-2", + }, + }, + { + ResourceType: domain.WorksmobileResourceUser, + Status: domain.WorksmobileOutboxStatusProcessed, + Payload: domain.JSONMap{ + "tenantRootId": rootID, + "loginEmail": "legacy-user@samaneng.com", + "initialPassword": "LegacyPass1!", + }, + }, + }, + } + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}}, + &fakeWorksmobileUserRepo{}, + outboxRepo, + nil, + ) + + credentials, err := service.ListInitialPasswordCredentials(context.Background(), rootID, "batch-1") + + require.NoError(t, err) + require.Equal(t, []WorksmobileInitialPasswordCredential{ + { + Email: "batch-user@samaneng.com", + Name: "Batch User", + PrimaryLeafOrgName: "인재성장", + InitialPassword: "BatchPass1!", + Status: domain.WorksmobileOutboxStatusProcessed, + }, + }, credentials) +} + +func TestWorksmobileSyncServiceDeletesCredentialBatchPasswordsButKeepsHistory(t *testing.T) { + rootID := "root-tenant" + root := domain.Tenant{ + ID: rootID, + Slug: HanmacFamilyTenantSlug, + Name: "Hanmac Family", + } + outboxRepo := &fakeWorksmobileOutboxRepo{ + credentialBatchJobs: []domain.WorksmobileOutbox{ + { + ID: "job-1", + ResourceType: domain.WorksmobileResourceUser, + Status: domain.WorksmobileOutboxStatusProcessed, + Payload: domain.JSONMap{ + "tenantRootId": rootID, + "loginEmail": "batch-user@samaneng.com", + "initialPassword": "BatchPass1!", + "credentialBatchId": "batch-1", + "credentialOperation": "worksmobile_user_sync", + "request": map[string]any{"passwordConfig": map[string]any{"password": "BatchPass1!"}}, + }, + }, + { + ID: "job-2", + ResourceID: "failed-user", + ResourceType: domain.WorksmobileResourceUser, + Status: domain.WorksmobileOutboxStatusFailed, + RetryCount: 2, + LastError: "worksmobile api failed", + Payload: domain.JSONMap{ + "tenantRootId": rootID, + "loginEmail": "failed-user@samaneng.com", + "initialPassword": "FailedPass1!", + "credentialBatchId": "batch-1", + "credentialOperation": "worksmobile_user_sync", + "request": map[string]any{"passwordConfig": map[string]any{"password": "FailedPass1!"}}, + }, + }, + }, + } + service := NewWorksmobileSyncService( + &fakeWorksmobileTenantService{tenants: map[string]domain.Tenant{rootID: root}, list: []domain.Tenant{root}}, + &fakeWorksmobileUserRepo{}, + outboxRepo, + nil, + ) + + before, err := service.ListCredentialBatches(context.Background(), rootID) + require.NoError(t, err) + require.Len(t, before, 1) + require.True(t, before[0].HasPasswords) + require.Equal(t, 1, before[0].FailedCount) + require.Len(t, before[0].Failures, 1) + require.Equal(t, "failed-user", before[0].Failures[0].UserID) + require.Equal(t, "failed-user@samaneng.com", before[0].Failures[0].Email) + require.Equal(t, "worksmobile api failed", before[0].Failures[0].LastError) + + after, err := service.DeleteCredentialBatchPasswords(context.Background(), rootID, "batch-1") + + require.NoError(t, err) + require.Equal(t, "batch-1", after.BatchID) + require.False(t, after.HasPasswords) + require.Equal(t, 2, after.UserCount) + require.NotEmpty(t, after.DeletedAt) + require.Len(t, outboxRepo.payloadUpdates, 2) + require.Empty(t, stringValue(outboxRepo.payloadUpdates[0]["initialPassword"])) + require.Empty(t, stringValue(outboxRepo.payloadUpdates[1]["initialPassword"])) + request := outboxRepo.payloadUpdates[0]["request"].(map[string]any) + passwordConfig := request["passwordConfig"].(map[string]any) + require.Empty(t, stringValue(passwordConfig["password"])) +} + +func TestAggregateWorksmobileCredentialBatchesUsesCredentialBatchCreatedAt(t *testing.T) { + oldCreatedAt := time.Date(2026, 5, 29, 1, 4, 15, 0, time.UTC) + batchCreatedAt := time.Date(2026, 6, 1, 7, 20, 0, 0, time.UTC) + + batches := aggregateWorksmobileCredentialBatches([]domain.WorksmobileOutbox{ + { + ID: "job-1", + CreatedAt: oldCreatedAt, + UpdatedAt: batchCreatedAt.Add(time.Minute), + Status: domain.WorksmobileOutboxStatusPending, + Payload: domain.JSONMap{ + "credentialBatchId": "batch-1", + "credentialOperation": "worksmobile_user_sync", + "credentialBatchCreatedAt": batchCreatedAt.Format(time.RFC3339), + }, + }, + }) + + require.Len(t, batches, 1) + require.Equal(t, batchCreatedAt, batches[0].CreatedAt) +} + func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) { t.Setenv("SAMAN_DOMAIN_ID", "1001") rootID := "root-tenant" @@ -133,7 +381,7 @@ func TestWorksmobileSyncServiceDeprovisionsArchivedUser(t *testing.T) { nil, ) - item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID) + item, err := service.EnqueueUserSync(context.Background(), rootID, target.ID, "") require.NoError(t, err) require.NotNil(t, item) @@ -1139,6 +1387,95 @@ func TestWorksmobileSyncServiceBackfillDryRunSkipsArchivedUsers(t *testing.T) { require.Equal(t, 1, outboxRepo.created[0].Payload["userCount"]) } +func TestCompareWorksmobileUsersMarksManagerChangeNeedsUpdate(t *testing.T) { + tenantID := "tenant-leaf" + user := domain.User{ + ID: "user-manager", + Email: "manager@samaneng.com", + Name: "Manager User", + TenantID: &tenantID, + Status: domain.UserStatusActive, + Metadata: domain.JSONMap{ + "additionalAppointments": []any{ + map[string]any{ + "tenantId": tenantID, + "isPrimary": true, + "isManager": true, + }, + }, + }, + } + remoteManager := false + items := compareWorksmobileUsers( + []domain.User{user}, + []WorksmobileRemoteUser{{ + ID: "works-user-manager", + ExternalID: user.ID, + Email: user.Email, + DisplayName: user.Name, + PrimaryOrgUnitID: "externalKey:" + tenantID, + PrimaryOrgUnitIsManager: &remoteManager, + }}, + true, + map[string]domain.Tenant{ + tenantID: {ID: tenantID, Name: "Leaf", Type: domain.TenantTypeOrganization}, + }, + ) + + require.Len(t, items, 1) + require.Equal(t, "needs_update", items[0].Status) +} + +func TestCompareWorksmobileUsersMarksSecondaryManagerChangeNeedsUpdate(t *testing.T) { + primaryTenantID := "tenant-company" + secondaryTenantID := "tenant-gpdtdc-leaf" + user := domain.User{ + ID: "user-secondary-manager", + Email: "secondary-manager@samaneng.com", + Name: "Secondary Manager User", + TenantID: &secondaryTenantID, + Status: domain.UserStatusActive, + Metadata: domain.JSONMap{ + "additionalAppointments": []any{ + map[string]any{ + "tenantId": primaryTenantID, + "isPrimary": true, + }, + map[string]any{ + "tenantId": secondaryTenantID, + "isPrimary": false, + "isManager": true, + }, + }, + }, + } + remotePrimaryManager := false + remoteSecondaryManager := false + items := compareWorksmobileUsers( + []domain.User{user}, + []WorksmobileRemoteUser{{ + ID: "works-user-secondary-manager", + ExternalID: user.ID, + Email: user.Email, + DisplayName: user.Name, + PrimaryOrgUnitID: "externalKey:" + primaryTenantID, + PrimaryOrgUnitIsManager: &remotePrimaryManager, + OrgUnitManagers: map[string]*bool{ + "externalKey:" + primaryTenantID: &remotePrimaryManager, + "externalKey:" + secondaryTenantID: &remoteSecondaryManager, + }, + }}, + true, + map[string]domain.Tenant{ + primaryTenantID: {ID: primaryTenantID, Name: "Company", Type: domain.TenantTypeCompany}, + secondaryTenantID: {ID: secondaryTenantID, Name: "GPDTDC Leaf", Type: domain.TenantTypeOrganization}, + }, + ) + + require.Len(t, items, 1) + require.Equal(t, "needs_update", items[0].Status) +} + type fakeWorksmobileTenantService struct { tenants map[string]domain.Tenant list []domain.Tenant diff --git a/common/pnpm-lock.yaml b/common/pnpm-lock.yaml index 2771c452..fcbdf6fa 100644 --- a/common/pnpm-lock.yaml +++ b/common/pnpm-lock.yaml @@ -63,8 +63,8 @@ importers: specifier: ^3.3.0 version: 3.3.1(oidc-client-ts@3.5.0)(react@19.2.6) react-router-dom: - specifier: ^6.28.2 - version: 6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: ^7.15.1 + version: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tailwind-merge: specifier: ^3.4.0 version: 3.6.0 @@ -469,7 +469,7 @@ importers: specifier: ^8.0.14 version: 8.0.14(@types/node@25.7.0)(jiti@1.21.7) vitest: - specifier: ^4.1.6 + specifier: 4.1.6 version: 4.1.6(@types/node@25.7.0)(@vitest/coverage-v8@4.1.6)(jsdom@28.1.0)(vite@8.0.14(@types/node@25.7.0)(jiti@1.21.7)) packages: @@ -549,28 +549,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.16': resolution: {integrity: sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.16': resolution: {integrity: sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.16': resolution: {integrity: sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.16': resolution: {integrity: sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==} @@ -1095,10 +1091,6 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - '@remix-run/router@1.23.2': - resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} - engines: {node: '>=14.0.0'} - '@rolldown/binding-android-arm64@1.0.0': resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1164,84 +1156,72 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-gnu@1.0.2': resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0': resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-arm64-musl@1.0.2': resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0': resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-ppc64-gnu@1.0.2': resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0': resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.2': resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0': resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.2': resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0': resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-linux-x64-musl@1.0.2': resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0': resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} @@ -1940,28 +1920,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2219,13 +2195,6 @@ packages: '@types/react': optional: true - react-router-dom@6.30.3: - resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - react-router-dom@7.15.0: resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==} engines: {node: '>=20.0.0'} @@ -2233,11 +2202,12 @@ packages: react: '>=18' react-dom: '>=18' - react-router@6.30.3: - resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} - engines: {node: '>=14.0.0'} + react-router-dom@7.16.0: + resolution: {integrity: sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==} + engines: {node: '>=20.0.0'} peerDependencies: - react: '>=16.8' + react: '>=18' + react-dom: '>=18' react-router@7.15.0: resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==} @@ -2249,6 +2219,16 @@ packages: react-dom: optional: true + react-router@7.16.0: + resolution: {integrity: sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -3210,8 +3190,6 @@ snapshots: '@radix-ui/rect@1.1.1': {} - '@remix-run/router@1.23.2': {} - '@rolldown/binding-android-arm64@1.0.0': optional: true @@ -4199,23 +4177,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 - react-router-dom@6.30.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - '@remix-run/router': 1.23.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-router: 6.30.3(react@19.2.6) - react-router-dom@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) react-router: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-router@6.30.3(react@19.2.6): + react-router-dom@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: - '@remix-run/router': 1.23.2 react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-router: 7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-router@7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: @@ -4225,6 +4197,14 @@ snapshots: optionalDependencies: react-dom: 19.2.6(react@19.2.6) + react-router@7.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + cookie: 1.1.1 + react: 19.2.6 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.6(react@19.2.6) + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.6): dependencies: get-nonce: 1.0.1 diff --git a/devfront/scripts/runtime-mode.sh b/devfront/scripts/runtime-mode.sh index d07c9053..5374b8ff 100644 --- a/devfront/scripts/runtime-mode.sh +++ b/devfront/scripts/runtime-mode.sh @@ -106,7 +106,7 @@ ensure_frontend_dependencies() { return 0 fi if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then - (cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --no-frozen-lockfile --ignore-scripts) + (cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts) else npm ci fi diff --git a/docs/backup-restore-design.md b/docs/backup-restore-design.md new file mode 100644 index 00000000..06341a94 --- /dev/null +++ b/docs/backup-restore-design.md @@ -0,0 +1,349 @@ +# Baron SSO 전체 시스템 백업 및 복구 설계 + +## 목적 + +Baron SSO의 전체 시스템 백업/복구는 CSV export/import의 확장판이 아니라, 서비스 저장소 전체를 일관된 시점으로 보존하고 복원하는 재해 복구 기능이다. + +핵심 목표는 다음과 같다. + +- 사용자, 조직, 권한, RP, WORKS 연동 참조에 쓰이는 UUID를 그대로 보존한다. +- Kratos identity subject와 Baron local user ID가 어긋나지 않게 복구한다. +- Hydra/Keto/Oathkeeper 기반 인증/인가 상태를 서비스 가능한 수준으로 복원한다. +- 복구 후 WORKS externalKey 기반 비교/동기화가 기존 연동과 이어지도록 한다. +- 백업 산출물의 무결성, 보안, 복구 가능성을 검증 가능한 절차로 만든다. + +## 배경과 결론 + +사용자 CSV는 `user_id`를 포함해 내보낼 수 있지만, 실제 사용자 계정의 주체 ID는 Kratos identity ID다. Kratos Admin API의 identity 생성 요청은 `id` 필드를 받지 않으므로, CSV import만으로 기존 사용자 UUID를 보장할 수 없다. + +따라서 사용자 UUID 보존이 필요한 복구는 반드시 Kratos DB까지 포함한 full backup/restore로 처리해야 한다. CSV import는 운영 편의 기능으로 유지하되, 재해 복구나 WORKS 연동 보존 목적의 원장 복구 수단으로 간주하지 않는다. + +## 백업 대상 + +### 필수 저장소 + +| 대상 | 저장소 | 포함 이유 | 복구 필수 여부 | +| --- | --- | --- | --- | +| Baron 애플리케이션 DB | `baron_postgres` | users, tenants, user_login_ids, user_groups, api_keys, outbox, WORKS mapping, RP metadata 등 | 필수 | +| Kratos DB | `ory_postgres`의 `ory_kratos` | identity UUID, credentials, verifiable/recovery addresses, sessions | 필수 | +| Hydra DB | `ory_postgres`의 `ory_hydra` | OAuth2 clients, consent, token/session 관련 상태 | 필수 | +| Keto DB | `ory_postgres`의 `ory_keto` | ReBAC relation tuple 원장 | 필수 | +| Baron ClickHouse | `baron_clickhouse` | 감사 로그, 운영 추적 데이터 | 운영 정책상 필수 | +| Ory ClickHouse | `ory_clickhouse` | Ory/Oathkeeper/Vector 계열 로그 | 운영 정책상 필수 | +| 설정/비밀값 | `.env`, generated Ory config, WORKS private key, gateway/Oathkeeper config | 동일 환경 재기동과 외부 연동에 필요 | 필수 | + +### 선택 저장소 + +| 대상 | 처리 원칙 | +| --- | --- | +| Redis | 로그인 pending state, cache, short code 등 휘발성 데이터다. full restore에서는 원칙적으로 제외하고 재시작 시 비운다. 무중단 이전 시나리오에서만 snapshot을 검토한다. | +| 프론트 빌드 산출물 | 소스/이미지 태그로 재생성한다. 별도 보관은 배포 재현성을 위한 선택 항목이다. | +| 로컬 개발 산출물 | reports, coverage, test-results 등은 백업 대상에서 제외한다. | + +## 백업 산출물 형식 + +백업 단위는 압축된 디렉터리 또는 object storage prefix로 관리한다. + +```text +baron-sso-backup-YYYYMMDD-HHMMSSZ/ + manifest.json + checksums.sha256 + postgres/ + baron.dump + ory_kratos.dump + ory_hydra.dump + ory_keto.dump + globals.sql + clickhouse/ + baron_clickhouse/ + ory_clickhouse/ + config/ + env.redacted + env.encrypted + ory/ + gateway/ + reports/ + row-counts.json + restore-readiness.json +``` + +`manifest.json`에는 최소한 다음 정보를 기록한다. + +- backup format version +- 생성 시각, git commit, 이미지 태그 +- 서비스별 DB schema/migration version +- 각 dump 파일의 checksum, 크기, 생성 명령 버전 +- 백업 모드: `offline`, `maintenance`, `online-best-effort` +- 암호화 방식과 key id +- 복구 대상 환경 제한: `same-env-only`, `staging-rehearsal`, `cross-env` + +## 백업 모드 + +### 1. Offline backup + +가장 안전한 모드다. 모든 writer를 중지한 뒤 dump한다. + +순서: + +1. gateway 또는 Oathkeeper에서 maintenance mode 활성화 +2. backend, relay worker, vector 등 write producer 중지 +3. Kratos/Hydra/Keto public/admin 요청 차단 또는 컨테이너 중지 +4. Baron Postgres dump +5. Ory Postgres의 Kratos/Hydra/Keto DB dump +6. ClickHouse backup +7. 설정/비밀값 백업 +8. checksum과 row count 생성 +9. 서비스 재개 + +이 모드는 사용자 UUID, WORKS mapping, Keto relation, OAuth consent 상태의 일관성이 가장 좋다. + +### 2. Maintenance backup + +짧은 점검 모드에서 writer만 막고 read는 제한적으로 허용한다. 운영 기본 모드로 권장한다. + +필수 조건: + +- 사용자 생성/삭제/수정 차단 +- 테넌트/조직 변경 차단 +- WORKS outbox relay 중지 +- Keto outbox relay 중지 +- OAuth client 변경 차단 + +### 3. Online best-effort backup + +무중단 스냅샷이다. 저장소가 여러 개라 cross-store 일관성을 보장할 수 없다. 감사 로그나 분석용 백업에는 가능하지만, 재해 복구용 원본으로는 사용하지 않는다. + +## Postgres 백업 전략 + +Postgres는 논리 dump를 기본으로 한다. + +- `pg_dump -Fc` 형식 사용 +- DB별 dump 파일 분리 +- `pg_dumpall --globals-only`로 role/extension/권한 정보 별도 보관 +- 백업 전후 row count와 핵심 UUID sample 기록 + +대상 DB: + +- Baron DB: `DB_NAME` +- Kratos DB: `ory_kratos` +- Hydra DB: `ory_hydra` +- Keto DB: `ory_keto` + +복구는 빈 DB에 `pg_restore --clean --if-exists`로 수행한다. 기존 운영 DB에 덮어쓰는 in-place restore는 금지한다. + +## ClickHouse 백업 전략 + +ClickHouse는 감사 로그 성격이 강하므로 정책을 분리한다. + +- 재해 복구: ClickHouse native backup 또는 volume snapshot 사용 +- 장기 보관: 파티션 단위 export 또는 object storage backup +- 복구 검증: 날짜 파티션별 row count와 min/max timestamp 비교 + +ClickHouse 백업 실패가 인증 기능 복구를 막지는 않지만, 감사 로그 보존 정책상 별도 실패로 취급한다. + +## Redis 처리 원칙 + +Redis는 기본적으로 백업하지 않는다. + +복구 후 영향: + +- 로그인 pending flow 만료 +- short code/link login flow 재시작 필요 +- headless JWKS cache 재생성 +- 세션 cache miss 발생 가능 + +Kratos/Hydra 자체 session/token 원장은 Postgres 쪽에 있으므로 Redis가 비어 있어도 서비스는 재수렴해야 한다. + +## 설정과 비밀값 + +DB dump만으로는 복구가 불완전하다. 다음 항목은 암호화해서 함께 보관한다. + +- `.env` 또는 배포 환경 변수 +- Ory generated config +- Hydra system secret, cookie secret +- Kratos courier/config secret +- Keto config +- Oathkeeper rules/config +- WORKS Admin OAuth client private key +- API gateway 설정 +- object storage backup key id + +`env.redacted`는 검토용이고, 실제 복구에는 `env.encrypted`만 사용한다. + +## 복구 절차 + +### Full restore 기본 절차 + +1. 대상 환경을 새로 준비한다. +2. 모든 애플리케이션 서비스를 중지한다. +3. 기존 DB가 있으면 별도 보관 후 빈 DB를 만든다. +4. Postgres globals를 복구한다. +5. Baron DB를 복구한다. +6. Kratos/Hydra/Keto DB를 복구한다. +7. ClickHouse를 복구한다. +8. 설정/비밀값을 복호화해 배치한다. +9. migration은 자동 실행하지 않고 현재 dump의 schema version을 확인한다. +10. Ory Stack을 기동한다. +11. backend를 기동한다. +12. relay worker는 아직 켜지 않는다. +13. post-restore verification을 수행한다. +14. WORKS comparison dry-run을 수행한다. +15. 문제가 없을 때 relay worker와 외부 동기화를 재개한다. + +### Post-restore verification + +필수 검증: + +- Kratos identity 수와 Baron users 수 비교 +- Baron `users.id`가 Kratos `identities.id`에 존재하는지 확인 +- tenant parent tree 참조 무결성 확인 +- `user_login_ids.user_id`, `user_login_ids.tenant_id` 참조 무결성 확인 +- Keto relation subject/object가 복구된 사용자/테넌트를 참조하는지 확인 +- Hydra client와 Baron RP metadata 참조 확인 +- WORKS mapping의 BaronResourceID가 복구된 사용자/테넌트를 참조하는지 확인 +- super admin 로그인 확인 +- 일반 사용자 로그인 확인 +- 대표 RP OIDC login/consent 확인 +- WORKS comparison dry-run에서 externalKey 기준 대량 삭제/생성 후보가 없는지 확인 + +## WORKS 연동 복구 정책 + +WORKS 자체 데이터는 Baron 백업으로 복구하지 않는다. Baron이 보존해야 하는 것은 WORKS와 연결되는 참조 키다. + +필수 보존: + +- 사용자 UUID +- 테넌트 UUID +- WORKS resource mapping +- WORKS outbox 처리 상태 +- WORKS domain mapping/config + +복구 직후 정책: + +- relay worker 자동 실행 금지 +- comparison dry-run 먼저 실행 +- externalKey 기준으로 Baron/WORKS가 매칭되는지 확인 +- 대량 delete/upsert가 감지되면 동기화 중단 +- 확인 후 필요한 사용자/조직만 재동기화 + +outbox는 복구 모드에 따라 처리한다. + +| 복구 모드 | outbox 정책 | +| --- | --- | +| 같은 운영 환경 재해 복구 | outbox 상태 보존, 단 relay 재개 전 dry-run 필수 | +| staging rehearsal | outbox relay 비활성화, 외부 WORKS 호출 금지 | +| cross-env migration | outbox는 보존하되 실행하지 않고 별도 remap 정책 필요 | + +## CSV export/import의 위치 + +CSV는 다음 목적으로만 사용한다. + +- 운영자가 사용자/조직을 일괄 등록하거나 보정 +- 일부 필드를 검토하기 위한 추출 +- dry-run 입력 보조 자료 + +CSV는 다음 목적에 사용하지 않는다. + +- Kratos identity UUID 보존 복구 +- 비밀번호/credential 복구 +- OAuth consent/token/session 복구 +- Keto relation 원장 복구 +- WORKS mapping 원장 복구 + +## 구현 계획 + +### Phase 1: 백업/복구 스크립트와 문서화 + +Estimate Time: 3~5d + +- `scripts/backup/full-backup.sh` +- `scripts/backup/full-restore.sh` +- `scripts/backup/verify-backup.sh` +- `scripts/backup/verify-restore.sh` +- `manifest.json` 생성기 +- checksum 생성 +- row count report 생성 + +### Phase 2: staging restore rehearsal + +Estimate Time: 3~5d + +- 백업 파일로 격리된 staging stack 복구 +- Ory/Baron/Postgres/ClickHouse 복구 검증 +- 로그인/OIDC/관리자 화면 smoke test +- WORKS 외부 호출 차단 상태에서 comparison dry-run + +### Phase 3: 운영 자동화 + +Estimate Time: 5~8d + +- 정기 백업 스케줄링 +- 암호화 및 object storage 업로드 +- retention 정책 +- 실패 알림 +- restore rehearsal 주기화 + +### Phase 4: 관리 UI + +Estimate Time: 5~8d + +- backup 목록 조회 +- backup 생성 요청 +- restore readiness report 조회 +- staging rehearsal 결과 조회 +- 운영 restore는 UI에서 직접 실행하지 않고 승인 절차와 runbook으로 제한 + +## 테스트 전략 + +### 단위 테스트 + +- manifest 생성 검증 +- checksum 검증 +- row count report 비교 +- restore readiness parser 검증 + +### 통합 테스트 + +- fixture DB 생성 +- full backup 실행 +- 빈 DB로 restore +- 핵심 테이블 row count 비교 +- 사용자/테넌트 UUID 동일성 비교 +- Kratos identity와 Baron user ID 일치 검증 + +### E2E 테스트 + +- super admin 로그인 +- 일반 사용자 로그인 +- AdminFront 사용자 목록 조회 +- UserFront 로그인 +- 대표 RP OIDC 로그인 +- WORKS comparison dry-run + +### 실패 테스트 + +- 누락된 dump 파일 +- checksum 불일치 +- schema version 불일치 +- 일부 DB만 복구된 상태 +- Kratos identity는 있는데 Baron user가 없는 상태 +- Baron user는 있는데 Kratos identity가 없는 상태 +- WORKS mapping이 복구되지 않은 상태 + +## 운영 정책 + +- 백업은 암호화하지 않은 상태로 저장하지 않는다. +- 운영 restore는 빈 환경 또는 새 볼륨에만 수행한다. +- restore 전 현재 운영 DB는 별도 snapshot으로 보존한다. +- restore 후 WORKS relay는 수동 승인 전까지 비활성화한다. +- 월 1회 이상 staging restore rehearsal을 수행한다. +- schema migration 직전 수동 backup을 강제한다. + +## 남은 결정 사항 + +- RPO/RTO 목표값 +- 백업 저장 위치와 암호화 key 관리 방식 +- ClickHouse 장기 보관 기간 +- WORKS outbox replay 정책의 운영 기본값 +- 운영 restore 승인자와 절차 +- restore rehearsal 자동 실행 주기 diff --git a/orgfront/scripts/runtime-mode.sh b/orgfront/scripts/runtime-mode.sh index 5dbd8eb6..4c3bb88a 100644 --- a/orgfront/scripts/runtime-mode.sh +++ b/orgfront/scripts/runtime-mode.sh @@ -106,7 +106,7 @@ ensure_frontend_dependencies() { return 0 fi if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then - (cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --no-frozen-lockfile --ignore-scripts) + (cd /workspace/common && CI=true pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts) else npm ci fi diff --git a/user_bulk_gpdtdc.CSV b/user_bulk_gpdtdc.CSV index 1391a0da..eace57f3 100644 --- a/user_bulk_gpdtdc.CSV +++ b/user_bulk_gpdtdc.CSV @@ -69,7 +69,7 @@ tskim@samaneng.com,김태식A,010-9965-9940,user,rnd-saman,,,,,222182,design-pla jhkang@samaneng.com,강정훈,010-9891-8798,user,rnd-saman,,,,,222212,strana,,연구원,,,B22048,b22048@hanmaceng.co.kr jhkim14@samaneng.com,김재현,010-2534-7837,user,rnd-saman,,,,,222231,watch-bim,,수석연구원,,,B22051,b22051@hanmaceng.co.kr yjchoi1@samaneng.com,최윤진,010-2349-6687,user,rnd-saman,,,,,222240,way-draw,,연구원,,,B22052,b22052@hanmaceng.co.kr -wkkim@samaneng.com,김원기,010-4727-8530,user,rnd-saman,,,,,222242,infra-bim1,,책임연구원,,,B22057,kwongi79@hanmaceng.co.kr +wkkim@samaneng.com,김원기,010-4727-8530,user,rnd-saman,,,,,222242,infra-bim1,,책임연구원,,,B22057, jhlee@samaneng.com,이준호,010-2514-6898,user,rnd-saman,,,,,223046,structural-software,,연구원,,,B23003,b23003@hanmaceng.co.kr jhchoi3@samaneng.com,최진헌,010-8638-8079,user,rnd-saman,,,,,222272,strana,,선임연구원,,,B22063,b22063@hanmaceng.co.kr hulee1@samaneng.com,이한울,010-9271-8997,user,rnd-saman,,,,,222294,web-design,,연구원,,,B22069,b22069@hanmaceng.co.kr @@ -94,7 +94,7 @@ hmin@samaneng.com,민홍,010-8654-5461,user,rnd-saman,,,,,223313,gsim,,선임연 hwan@samaneng.com,안효원,010-3358-4260,user,rnd-saman,,,,,223228,infra-bim1,,선임연구원,,,B23040,b23040@hanmaceng.co.kr sihan@samaneng.com,한성일,010-4322-1100,user,rnd-saman,,,,,223226,abut-control,,책임연구원,,,B23042,b23042@hanmaceng.co.kr jhkim25@samaneng.com,김재환,010-8962-3743,user,rnd-saman,,,,,223229,structural-design,,책임연구원,,,B23041,b23041@hanmaceng.co.kr -gy9411@naver.com,이가연,010-2430-5102,user,rnd-saman,,,,,223269,slope-structures,,연구원,,,B23047,b23047@hanmaceng.co.kr +gylee1@samaneng.com,이가연,010-2430-5102,user,rnd-saman,,,,,223269,slope-structures,,연구원,,,B23047,b23047@hanmaceng.co.kr yskim3@samaneng.com,김예서,010-9167-6132,user,rnd-saman,,,,,223280,land-map-cell,,연구원,,,B23051,b23051@hanmaceng.co.kr jhpyo@samaneng.com,표재학,010-2522-4984,user,rnd-saman,,,,,223281,primal-plan,,연구원,,,B23052,b23052@hanmaceng.co.kr sjkim6@samaneng.com,김신지,010-7667-8256,user,rnd-saman,,,,,223361,tech-planning,,연구원,,,B23064,b23064@hanmaceng.co.kr @@ -140,7 +140,7 @@ hrlee1@samaneng.com,이해랑,010-8628-0094,user,rnd-saman,,,,,225175,modeler,, jhsim@samaneng.com,심재훈,010-6633-3366,user,rnd-saman,,,,,225183,tunnel,,수석연구원,,,B25025,b25025@hanmaceng.co.kr shkim4@samaneng.com,김수현,010-5645-5153,user,rnd-saman,,,,,225215,design-planning,,선임연구원,,,B25027,b25027@hanmaceng.co.kr smbaek@samaneng.com,백승민,010-7156-8542,user,rnd-saman,,,,,225319,hmeg,,책임연구원,,,B25035,b25035@hanmaceng.co.kr -swpark3@saman.com,박상원,010-4794-0148,user,rnd-saman,,,,,225336,cm-planning,,연구원,,,B25036,b25036@hanmaceng.co.kr +swpark3@samaneng.com,박상원,010-4794-0148,user,rnd-saman,,,,,225336,cm-planning,,연구원,,,B25036,b25036@hanmaceng.co.kr smyoun@samaneng.com,윤석무,010-9780-8901,user,rnd-saman,,,,,226049,solution-dev,,연구원,,,B26002,b26002@hanmaceng.co.kr jhpark4@samaneng.com,박종혁,010-4211-2090,user,rnd-saman,,,,,226072,infra-bim2,,연구원,,,B26003,b26003@hanmaceng.co.kr dhhong@samaneng.com,홍덕현,010-5360-7314,user,rnd-saman,,,,,226073,structural-design,,연구원,,,B26004,b26004@hanmaceng.co.kr diff --git a/userfront/scripts/dev-server.sh b/userfront/scripts/dev-server.sh index a17fe2d4..75a5d7fc 100644 --- a/userfront/scripts/dev-server.sh +++ b/userfront/scripts/dev-server.sh @@ -27,11 +27,71 @@ warm_get() { "http://127.0.0.1:${USERFRONT_INTERNAL_PORT}${path}" >/dev/null 2>&1 } +wait_for_userfront_build() { + flutter_pid="$1" + attempt=1 + + while [ "$attempt" -le "$USERFRONT_BOOT_WARMUP_ATTEMPTS" ]; do + if [ -f "build/web/index.html" ]; then + return 0 + fi + if ! kill -0 "$flutter_pid" 2>/dev/null; then + echo "[userfront-boot] warmup skipped because flutter exited before build/web/index.html was ready" >&2 + return 1 + fi + attempt=$((attempt + 1)) + sleep "$USERFRONT_BOOT_WARMUP_INTERVAL_SECONDS" + done + + echo "[userfront-boot] warmup skipped after ${USERFRONT_BOOT_WARMUP_ATTEMPTS} build readiness attempts" >&2 + return 1 +} + +reset_userfront_service_worker() { + cat > build/web/flutter_service_worker.js <<'EOF' +self.addEventListener("install", (event) => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + if (self.caches) { + const keys = await self.caches.keys(); + await Promise.all( + keys + .filter( + (key) => + key.indexOf("baron-userfront-") === 0 || + key.indexOf("flutter-app-cache") === 0, + ) + .map((key) => self.caches.delete(key)), + ); + } + + await self.registration.unregister(); + + const clients = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + await Promise.all(clients.map((client) => client.navigate(client.url))); + })(), + ); +}); +EOF +} + warm_userfront_once() { flutter_pid="$1" attempt=1 started_at="$(date +%s)" + if ! wait_for_userfront_build "$flutter_pid"; then + return 0 + fi + reset_userfront_service_worker + while [ "$attempt" -le "$USERFRONT_BOOT_WARMUP_ATTEMPTS" ]; do if wget -qO- "http://127.0.0.1:${USERFRONT_INTERNAL_PORT}/flutter_bootstrap.js" >/dev/null 2>&1; then break