From 92e607aee8f6c72fecbc0a72c5de980243930194 Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 14 May 2026 09:23:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EC=A4=91=EB=B3=B5=EC=8B=A4=ED=96=89=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integrity/DataIntegrityPage.test.tsx | 85 +++++++++++++------ .../features/integrity/DataIntegrityPage.tsx | 54 ++++++++++-- adminfront/tests/data_integrity.spec.ts | 17 ++++ docs/data-integrity-management.md | 1 + 4 files changed, 120 insertions(+), 37 deletions(-) diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx index 19f93442..0b36c1ed 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx @@ -11,36 +11,37 @@ import DataIntegrityPage from "./DataIntegrityPage"; let currentRole = "super_admin"; +const integrityReport = { + status: "fail", + checkedAt: "2026-05-14T00:00:00Z", + summary: { + totalChecks: 2, + passed: 1, + warnings: 0, + failures: 1, + }, + sections: [ + { + key: "tenant_integrity", + label: "테넌트 정합성", + status: "fail", + checks: [ + { + key: "duplicate_tenant_slugs", + label: "중복 테넌트 slug", + description: "active tenant slug의 대소문자 무시 중복을 검사합니다.", + status: "fail", + severity: "error", + count: 1, + }, + ], + }, + ], +}; + vi.mock("../../lib/adminApi", () => ({ fetchMe: vi.fn(async () => ({ role: currentRole })), - fetchDataIntegrityReport: vi.fn(async () => ({ - status: "fail", - checkedAt: "2026-05-14T00:00:00Z", - summary: { - totalChecks: 2, - passed: 1, - warnings: 0, - failures: 1, - }, - sections: [ - { - key: "tenant_integrity", - label: "테넌트 정합성", - status: "fail", - checks: [ - { - key: "duplicate_tenant_slugs", - label: "중복 테넌트 slug", - description: - "active tenant slug의 대소문자 무시 중복을 검사합니다.", - status: "fail", - severity: "error", - count: 1, - }, - ], - }, - ], - })), + fetchDataIntegrityReport: vi.fn(async () => integrityReport), fetchOrphanUserLoginIDs: vi.fn(async () => ({ items: [ { @@ -123,6 +124,34 @@ describe("DataIntegrityPage", () => { ]); }); + it("disables recheck button and shows manual recheck progress", async () => { + let finishRecheck: (value: typeof integrityReport) => void = () => {}; + const pendingRecheck = new Promise((resolve) => { + finishRecheck = resolve; + }); + + renderPage(); + + expect(await screen.findByText("중복 테넌트 slug")).toBeInTheDocument(); + vi.mocked(fetchDataIntegrityReport).mockImplementationOnce( + () => pendingRecheck, + ); + + fireEvent.click(screen.getByRole("button", { name: "다시 검사" })); + + expect(screen.getByRole("button", { name: "검사 중" })).toBeDisabled(); + expect( + screen.getByText("정합성 검사를 실행 중입니다."), + ).toBeInTheDocument(); + + finishRecheck(integrityReport); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "다시 검사" })).toBeEnabled(); + }); + expect(screen.getByText("검사가 완료되었습니다.")).toBeInTheDocument(); + }); + it("blocks non-super admins", async () => { currentRole = "tenant_admin"; diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx index ff365f08..170637e3 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -77,6 +77,19 @@ function reasonLabel(reason: string) { } } +function recheckStatusText(status: "idle" | "running" | "success" | "error") { + switch (status) { + case "running": + return "정합성 검사를 실행 중입니다."; + case "success": + return "검사가 완료되었습니다."; + case "error": + return "검사에 실패했습니다."; + default: + return ""; + } +} + function OrphanLoginIDTable({ items, selectedIds, @@ -156,6 +169,9 @@ function OrphanLoginIDTable({ function DataIntegrityContent() { const queryClient = useQueryClient(); const [selectedOrphanIds, setSelectedOrphanIds] = useState([]); + const [recheckStatus, setRecheckStatus] = useState< + "idle" | "running" | "success" | "error" + >("idle"); const { data, isLoading, isError, error, refetch, isFetching } = useQuery({ queryKey: ["data-integrity-report"], queryFn: fetchDataIntegrityReport, @@ -194,6 +210,16 @@ function DataIntegrityContent() { deleteMutation.mutate(selectedOrphanIds); } }; + const isManualRechecking = recheckStatus === "running"; + const handleRecheck = async () => { + if (isManualRechecking) { + return; + } + setRecheckStatus("running"); + const result = await refetch(); + setRecheckStatus(result.isError ? "error" : "success"); + }; + const recheckMessage = recheckStatusText(recheckStatus); return (
@@ -204,15 +230,25 @@ function DataIntegrityContent() { 데이터 정합성 검증 - +
+ + {recheckMessage ? ( + + {recheckMessage} + + ) : null} +
{isError ? ( diff --git a/adminfront/tests/data_integrity.spec.ts b/adminfront/tests/data_integrity.spec.ts index 44ae06c2..d3cc0914 100644 --- a/adminfront/tests/data_integrity.spec.ts +++ b/adminfront/tests/data_integrity.spec.ts @@ -3,6 +3,7 @@ import { expect, test } from "@playwright/test"; test.describe("Data integrity management", () => { test.beforeEach(async ({ page }) => { let orphanLoginIDDeleted = false; + let integrityReportRequests = 0; await page.addInitScript(() => { window.localStorage.setItem("locale", "ko"); @@ -133,6 +134,10 @@ test.describe("Data integrity management", () => { return; } if (url.includes("/api/v1/admin/integrity")) { + integrityReportRequests += 1; + if (integrityReportRequests > 1) { + await new Promise((resolve) => setTimeout(resolve, 150)); + } await route.fulfill({ json: { status: "fail", @@ -184,6 +189,18 @@ test.describe("Data integrity management", () => { await expect(page.getByRole("button", { name: "다시 검사" })).toBeVisible(); }); + test("shows manual recheck progress and completion", async ({ page }) => { + await page.goto("/system/data-integrity"); + + await expect(page.getByText("중복 테넌트 slug")).toBeVisible(); + await page.getByRole("button", { name: "다시 검사" }).click(); + + await expect(page.getByRole("button", { name: "검사 중" })).toBeDisabled(); + await expect(page.getByText("정합성 검사를 실행 중입니다.")).toBeVisible(); + await expect(page.getByText("검사가 완료되었습니다.")).toBeVisible(); + await expect(page.getByRole("button", { name: "다시 검사" })).toBeEnabled(); + }); + test("deletes selected orphan login ID targets after confirmation", async ({ page, }) => { diff --git a/docs/data-integrity-management.md b/docs/data-integrity-management.md index eade096a..b0877611 100644 --- a/docs/data-integrity-management.md +++ b/docs/data-integrity-management.md @@ -46,6 +46,7 @@ Baron SSO의 신원/권한 SoT는 Ory Stack(Kratos, Keto, Hydra)입니다. 이 ## adminfront 동작 - `super_admin`은 사이드바의 `데이터 정합성` 메뉴에서 리포트를 볼 수 있습니다. +- `다시 검사` 실행 중에는 버튼이 비활성화되고 `검사 중` 상태가 표시됩니다. 요청이 끝나면 완료 또는 실패 상태 문구가 화면에 남습니다. - `super_admin`은 같은 메뉴에서 유령 로그인 ID 대상을 확인하고, 체크박스로 선택한 뒤 확인 대화상자를 거쳐 삭제할 수 있습니다. - `super_admin`은 adminfront 개요 화면 하단에서도 최종 검증 상태, 실패 건수, 검사 시각, 섹션별 상태 요약을 볼 수 있습니다. - `tenant_admin` 등 non-super role은 화면 접근 시 권한 없음 메시지만 봅니다. From 8bca1277236fc534cf0014bf05cd4d76f51fab81 Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 14 May 2026 09:49:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?orgfront=20=EC=BD=94=EB=93=9C=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80,=20=EB=B0=B1=EC=97=94=EB=93=9C?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/integrity/DataIntegrityPage.tsx | 150 +++++++++++++----- adminfront/src/locales/en.toml | 66 ++++++++ adminfront/src/locales/ko.toml | 66 ++++++++ adminfront/src/locales/template.toml | 66 ++++++++ .../data_integrity_repository_test.go | 112 ++++++++++++- .../user_membership_maintenance_test.go | 24 +-- .../user_projection_repository_test.go | 12 +- .../repository/user_repository_test.go | 41 +++-- common/locales/en.toml | 1 + common/locales/ko.toml | 1 + common/locales/template.toml | 1 + locales/en.toml | 96 +++++++++++ locales/ko.toml | 96 +++++++++++ locales/template.toml | 96 +++++++++++ .../orgchart/routes/OrgChartPage.test.tsx | 21 +++ .../features/orgchart/routes/OrgChartPage.tsx | 14 +- 16 files changed, 786 insertions(+), 77 deletions(-) diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx index 170637e3..50284603 100644 --- a/adminfront/src/features/integrity/DataIntegrityPage.tsx +++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx @@ -17,15 +17,16 @@ import { fetchDataIntegrityReport, fetchOrphanUserLoginIDs, } from "../../lib/adminApi"; +import { t } from "../../lib/i18n"; function statusLabel(status: DataIntegrityStatus) { switch (status) { case "pass": - return "정상"; + return t("ui.admin.integrity.status.pass", "정상"); case "warning": - return "주의"; + return t("ui.admin.integrity.status.warning", "주의"); case "fail": - return "실패"; + return t("ui.admin.integrity.status.fail", "실패"); default: return status; } @@ -65,13 +66,13 @@ function CheckIcon({ check }: { check: DataIntegrityCheck }) { function reasonLabel(reason: string) { switch (reason) { case "missing_user": - return "사용자 없음"; + return t("ui.admin.integrity.reason.missing_user", "사용자 없음"); case "deleted_user": - return "삭제된 사용자"; + return t("ui.admin.integrity.reason.deleted_user", "삭제된 사용자"); case "missing_tenant": - return "테넌트 없음"; + return t("ui.admin.integrity.reason.missing_tenant", "테넌트 없음"); case "deleted_tenant": - return "삭제된 테넌트"; + return t("ui.admin.integrity.reason.deleted_tenant", "삭제된 테넌트"); default: return reason; } @@ -80,11 +81,14 @@ function reasonLabel(reason: string) { function recheckStatusText(status: "idle" | "running" | "success" | "error") { switch (status) { case "running": - return "정합성 검사를 실행 중입니다."; + return t( + "msg.admin.integrity.recheck.running", + "정합성 검사를 실행 중입니다.", + ); case "success": - return "검사가 완료되었습니다."; + return t("msg.admin.integrity.recheck.success", "검사가 완료되었습니다."); case "error": - return "검사에 실패했습니다."; + return t("msg.admin.integrity.recheck.error", "검사에 실패했습니다."); default: return ""; } @@ -102,7 +106,10 @@ function OrphanLoginIDTable({ if (items.length === 0) { return (
- 삭제할 유령 로그인 ID가 없습니다. + {t( + "msg.admin.integrity.orphan_login_ids.empty", + "삭제할 유령 로그인 ID가 없습니다.", + )}
); } @@ -113,12 +120,24 @@ function OrphanLoginIDTable({ - - - - - - + + + + + + @@ -127,7 +146,11 @@ function OrphanLoginIDTable({
선택Login IDFieldUserTenant사유 + {t("ui.admin.integrity.table.select", "선택")} + + {t("ui.admin.integrity.table.login_id", "Login ID")} + + {t("ui.admin.integrity.table.field", "Field")} + + {t("ui.admin.integrity.table.user", "User")} + + {t("ui.admin.integrity.table.tenant", "Tenant")} + + {t("ui.admin.integrity.table.reason", "사유")} +
onToggle(item.id)} className="h-4 w-4 rounded border-input" @@ -204,7 +227,11 @@ function DataIntegrityContent() { return; } const confirmed = window.confirm( - `선택한 ${selectedOrphanIds.length}개의 유령 로그인 ID를 삭제하시겠습니까?`, + t( + "msg.admin.integrity.orphan_login_ids.delete_confirm", + "선택한 {{count}}개의 유령 로그인 ID를 삭제하시겠습니까?", + { count: selectedOrphanIds.length }, + ), ); if (confirmed) { deleteMutation.mutate(selectedOrphanIds); @@ -225,9 +252,11 @@ function DataIntegrityContent() {
-

System

+

+ {t("ui.admin.integrity.kicker", "System")} +

- 데이터 정합성 검증 + {t("ui.admin.integrity.title", "데이터 정합성 검증")}

@@ -238,7 +267,9 @@ function DataIntegrityContent() { disabled={isLoading || isFetching || isManualRechecking} > - {isManualRechecking ? "검사 중" : "다시 검사"} + {isManualRechecking + ? t("ui.admin.integrity.recheck.running", "검사 중") + : t("ui.admin.integrity.recheck.run", "다시 검사")} {recheckMessage ? ( - {(error as Error)?.message || "정합성 리포트를 불러오지 못했습니다."} + {(error as Error)?.message || + t( + "msg.admin.integrity.report.load_error", + "정합성 리포트를 불러오지 못했습니다.", + )} ) : null} @@ -264,10 +299,17 @@ function DataIntegrityContent() {
-

Read model integrity

+

+ {t( + "ui.admin.integrity.read_model.title", + "Read model integrity", + )} +

- Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 - 확인합니다. + {t( + "msg.admin.integrity.read_model.description", + "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.", + )}

@@ -279,29 +321,39 @@ function DataIntegrityContent() { {isLoading ? ( -
불러오는 중
+
+ {t("ui.admin.integrity.loading", "불러오는 중")} +
) : (
-
검사 항목
+
+ {t("ui.admin.integrity.summary.total_checks", "검사 항목")} +
{data?.summary.totalChecks ?? 0}
-
정상
+
+ {t("ui.admin.integrity.summary.passed", "정상")} +
{data?.summary.passed ?? 0}
-
실패 건수
+
+ {t("ui.admin.integrity.summary.failures", "실패 건수")} +
{data?.summary.failures ?? 0}
-
검사 시각
+
+ {t("ui.admin.integrity.summary.checked_at", "검사 시각")} +
{formatDateTime(data?.checkedAt)}
@@ -355,10 +407,17 @@ function DataIntegrityContent() {
-

유령 로그인 ID 정리

+

+ {t( + "ui.admin.integrity.orphan_login_ids.title", + "유령 로그인 ID 정리", + )} +

- 삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 - 확인한 뒤 선택 삭제합니다. + {t( + "msg.admin.integrity.orphan_login_ids.description", + "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.", + )}

{orphanLoginIDsQuery.isError ? (
- 유령 로그인 ID 대상을 불러오지 못했습니다. + {t( + "msg.admin.integrity.orphan_login_ids.load_error", + "유령 로그인 ID 대상을 불러오지 못했습니다.", + )}
) : null} {deleteMutation.data ? (
- {deleteMutation.data.deletedCount}개의 유령 로그인 ID를 - 삭제했습니다. + {t( + "msg.admin.integrity.orphan_login_ids.delete_success", + "{{count}}개의 유령 로그인 ID를 삭제했습니다.", + { count: deleteMutation.data.deletedCount }, + )}
) : null}
-

접근 권한이 없습니다

+

+ {t("ui.admin.integrity.forbidden.title", "접근 권한이 없습니다")} +

- 이 화면은 super_admin 권한으로만 접근할 수 있습니다. + {t( + "msg.admin.integrity.forbidden.description", + "이 화면은 super_admin 권한으로만 접근할 수 있습니다.", + )}

diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 45dbbd91..21b604e3 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -141,6 +141,27 @@ remove_confirm = "Remove Confirm" remove_success = "Remove Success" title = "Title" +[msg.admin.integrity.forbidden] +description = "This screen is available only to super_admin." + +[msg.admin.integrity.orphan_login_ids] +delete_confirm = "Delete {{count}} selected orphan login IDs?" +delete_success = "Deleted {{count}} orphan login IDs." +description = "Review login IDs that reference deleted or missing users/tenants, then delete selected rows." +empty = "No orphan login IDs to delete." +load_error = "Failed to load orphan login ID targets." + +[msg.admin.integrity.read_model] +description = "Checks anomalies in the backend DB read model without overwriting the Ory SoT." + +[msg.admin.integrity.recheck] +error = "Check failed." +running = "Running integrity check." +success = "Check completed." + +[msg.admin.integrity.report] +load_error = "Failed to load the integrity report." + [msg.admin.groups.prompt] user_id = "User Id" @@ -837,6 +858,51 @@ name = "NAME" plane = "ADMIN PLANE" subtitle = "Manage your organization" +[ui.admin.integrity] +kicker = "System" +loading = "Loading" +title = "Data Integrity Check" + +[ui.admin.integrity.forbidden] +title = "Access denied" + +[ui.admin.integrity.orphan_login_ids] +delete = "Delete selected" +title = "Orphan Login ID Cleanup" + +[ui.admin.integrity.read_model] +title = "Read model integrity" + +[ui.admin.integrity.reason] +deleted_tenant = "Deleted tenant" +deleted_user = "Deleted user" +missing_tenant = "Missing tenant" +missing_user = "Missing user" + +[ui.admin.integrity.recheck] +run = "Run again" +running = "Checking" + +[ui.admin.integrity.status] +fail = "Failed" +pass = "Passed" +warning = "Warning" + +[ui.admin.integrity.summary] +checked_at = "Checked at" +failures = "Failures" +passed = "Passed" +total_checks = "Checks" + +[ui.admin.integrity.table] +field = "Field" +login_id = "Login ID" +reason = "Reason" +select = "Select" +select_item = "Select {{loginId}}" +tenant = "Tenant" +user = "User" + [ui.admin.nav] org_chart = "Org Chart" api_keys = "API Keys" diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 9fd06477..068b13c5 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -141,6 +141,27 @@ remove_confirm = "제거하시겠습니까?" remove_success = "구성원이 제외되었습니다." title = "[{{name}}] 멤버 관리" +[msg.admin.integrity.forbidden] +description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다." + +[msg.admin.integrity.orphan_login_ids] +delete_confirm = "선택한 {{count}}개의 유령 로그인 ID를 삭제하시겠습니까?" +delete_success = "{{count}}개의 유령 로그인 ID를 삭제했습니다." +description = "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다." +empty = "삭제할 유령 로그인 ID가 없습니다." +load_error = "유령 로그인 ID 대상을 불러오지 못했습니다." + +[msg.admin.integrity.read_model] +description = "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다." + +[msg.admin.integrity.recheck] +error = "검사에 실패했습니다." +running = "정합성 검사를 실행 중입니다." +success = "검사가 완료되었습니다." + +[msg.admin.integrity.report] +load_error = "정합성 리포트를 불러오지 못했습니다." + [msg.admin.groups.prompt] user_id = "추가할 사용자의 UUID를 입력하세요:" @@ -839,6 +860,51 @@ name = "NAME" plane = "ADMIN PLANE" subtitle = "Manage your organization" +[ui.admin.integrity] +kicker = "System" +loading = "불러오는 중" +title = "데이터 정합성 검증" + +[ui.admin.integrity.forbidden] +title = "접근 권한이 없습니다" + +[ui.admin.integrity.orphan_login_ids] +delete = "선택 삭제" +title = "유령 로그인 ID 정리" + +[ui.admin.integrity.read_model] +title = "Read model integrity" + +[ui.admin.integrity.reason] +deleted_tenant = "삭제된 테넌트" +deleted_user = "삭제된 사용자" +missing_tenant = "테넌트 없음" +missing_user = "사용자 없음" + +[ui.admin.integrity.recheck] +run = "다시 검사" +running = "검사 중" + +[ui.admin.integrity.status] +fail = "실패" +pass = "정상" +warning = "주의" + +[ui.admin.integrity.summary] +checked_at = "검사 시각" +failures = "실패 건수" +passed = "정상" +total_checks = "검사 항목" + +[ui.admin.integrity.table] +field = "Field" +login_id = "Login ID" +reason = "사유" +select = "선택" +select_item = "{{loginId}} 선택" +tenant = "Tenant" +user = "User" + [ui.admin.nav] org_chart = "조직도" api_keys = "API 키" diff --git a/adminfront/src/locales/template.toml b/adminfront/src/locales/template.toml index 5eb2cd84..f0d6aedb 100644 --- a/adminfront/src/locales/template.toml +++ b/adminfront/src/locales/template.toml @@ -146,6 +146,27 @@ remove_confirm = "" remove_success = "" title = "" +[msg.admin.integrity.forbidden] +description = "" + +[msg.admin.integrity.orphan_login_ids] +delete_confirm = "" +delete_success = "" +description = "" +empty = "" +load_error = "" + +[msg.admin.integrity.read_model] +description = "" + +[msg.admin.integrity.recheck] +error = "" +running = "" +success = "" + +[msg.admin.integrity.report] +load_error = "" + [msg.admin.groups.prompt] user_id = "" @@ -852,6 +873,51 @@ name = "" plane = "" subtitle = "" +[ui.admin.integrity] +kicker = "" +loading = "" +title = "" + +[ui.admin.integrity.forbidden] +title = "" + +[ui.admin.integrity.orphan_login_ids] +delete = "" +title = "" + +[ui.admin.integrity.read_model] +title = "" + +[ui.admin.integrity.reason] +deleted_tenant = "" +deleted_user = "" +missing_tenant = "" +missing_user = "" + +[ui.admin.integrity.recheck] +run = "" +running = "" + +[ui.admin.integrity.status] +fail = "" +pass = "" +warning = "" + +[ui.admin.integrity.summary] +checked_at = "" +failures = "" +passed = "" +total_checks = "" + +[ui.admin.integrity.table] +field = "" +login_id = "" +reason = "" +select = "" +select_item = "" +tenant = "" +user = "" + [ui.admin.nav] org_chart = "" api_keys = "" diff --git a/backend/internal/repository/data_integrity_repository_test.go b/backend/internal/repository/data_integrity_repository_test.go index f530e7ef..174dd506 100644 --- a/backend/internal/repository/data_integrity_repository_test.go +++ b/backend/internal/repository/data_integrity_repository_test.go @@ -3,11 +3,15 @@ package repository import ( "baron-sso-backend/internal/domain" "context" + "errors" + "fmt" "testing" "time" "github.com/google/uuid" + "github.com/lib/pq" "github.com/stretchr/testify/require" + "gorm.io/gorm" ) func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) { @@ -60,7 +64,18 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) { CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), } + deletedLoginUser := domain.User{ + ID: uuid.NewString(), + Email: "deleted-login-user-" + suffix + "@example.com", + Name: "Deleted Login User", + Role: domain.RoleUser, + TenantID: &child.ID, + Status: domain.UserStatusActive, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + } require.NoError(t, testDB.Create(&orphanUser).Error) + require.NoError(t, testDB.Create(&deletedLoginUser).Error) require.NoError(t, testDB.Create(&domain.UserLoginID{ ID: uuid.NewString(), UserID: orphanUser.ID, @@ -70,11 +85,12 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) { }).Error) require.NoError(t, testDB.Create(&domain.UserLoginID{ ID: uuid.NewString(), - UserID: uuid.NewString(), + UserID: deletedLoginUser.ID, TenantID: child.ID, FieldKey: "emp_id", LoginID: "MISSING-" + suffix, }).Error) + require.NoError(t, testDB.Delete(&domain.User{}, "id = ?", deletedLoginUser.ID).Error) report, err := CheckDataIntegrity(ctx, testDB) require.NoError(t, err) @@ -88,6 +104,68 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) { requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, 1) } +func TestCheckDataIntegrityDetectsHardOrphanUserLoginIDRows(t *testing.T) { + ctx := context.Background() + suffix := uuid.NewString() + rollback := errors.New("rollback hard orphan fixture") + + err := testDB.Transaction(func(tx *gorm.DB) error { + var constraintNames []string + if err := tx.Raw(` +SELECT conname +FROM pg_constraint +WHERE conrelid = 'user_login_ids'::regclass + AND contype = 'f' +`).Scan(&constraintNames).Error; err != nil { + return err + } + + for _, constraintName := range constraintNames { + statement := fmt.Sprintf("ALTER TABLE user_login_ids DROP CONSTRAINT %s", pq.QuoteIdentifier(constraintName)) + if err := tx.Exec(statement).Error; err != nil { + return err + } + } + + before, err := CheckDataIntegrity(ctx, tx) + if err != nil { + return err + } + beforeTenantCount, err := integrityCheckCount(before, "user_integrity", "orphan_user_login_id_tenants") + if err != nil { + return err + } + beforeUserCount, err := integrityCheckCount(before, "user_integrity", "orphan_user_login_id_users") + if err != nil { + return err + } + + if err := tx.Create(&domain.UserLoginID{ + ID: uuid.NewString(), + UserID: uuid.NewString(), + TenantID: uuid.NewString(), + FieldKey: "emp_id", + LoginID: "HARD-ORPHAN-" + suffix, + }).Error; err != nil { + return err + } + + report, err := CheckDataIntegrity(ctx, tx) + if err != nil { + return err + } + if err := expectIntegrityCheck(report, "user_integrity", "orphan_user_login_id_tenants", domain.DataIntegrityStatusFail, beforeTenantCount+1); err != nil { + return err + } + if err := expectIntegrityCheck(report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusFail, beforeUserCount+1); err != nil { + return err + } + + return rollback + }) + require.ErrorIs(t, err, rollback) +} + func TestListAndDeleteOrphanUserLoginIDsOnlyDeletesRevalidatedTargets(t *testing.T) { ctx := context.Background() suffix := uuid.NewString() @@ -194,17 +272,41 @@ func TestListAndDeleteOrphanUserLoginIDsOnlyDeletesRevalidatedTargets(t *testing func requireIntegrityCheck(t *testing.T, report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) { t.Helper() + require.NoError(t, expectIntegrityCheck(report, sectionKey, checkKey, status, count)) +} + +func expectIntegrityCheck(report domain.DataIntegrityReport, sectionKey, checkKey string, status domain.DataIntegrityStatus, count int64) error { + check, ok := findIntegrityCheck(report, sectionKey, checkKey) + if !ok { + return fmt.Errorf("integrity check %s/%s not found", sectionKey, checkKey) + } + if check.Status != status { + return fmt.Errorf("integrity check %s/%s status = %s, want %s", sectionKey, checkKey, check.Status, status) + } + if check.Count != count { + return fmt.Errorf("integrity check %s/%s count = %d, want %d", sectionKey, checkKey, check.Count, count) + } + return nil +} + +func integrityCheckCount(report domain.DataIntegrityReport, sectionKey, checkKey string) (int64, error) { + check, ok := findIntegrityCheck(report, sectionKey, checkKey) + if !ok { + return 0, fmt.Errorf("integrity check %s/%s not found", sectionKey, checkKey) + } + return check.Count, nil +} + +func findIntegrityCheck(report domain.DataIntegrityReport, sectionKey, checkKey string) (domain.DataIntegrityCheck, bool) { for _, section := range report.Sections { if section.Key != sectionKey { continue } for _, check := range section.Checks { if check.Key == checkKey { - require.Equal(t, status, check.Status) - require.Equal(t, count, check.Count) - return + return check, true } } } - t.Fatalf("integrity check %s/%s not found", sectionKey, checkKey) + return domain.DataIntegrityCheck{}, false } diff --git a/backend/internal/repository/user_membership_maintenance_test.go b/backend/internal/repository/user_membership_maintenance_test.go index d71589a3..64b80b9c 100644 --- a/backend/internal/repository/user_membership_maintenance_test.go +++ b/backend/internal/repository/user_membership_maintenance_test.go @@ -26,20 +26,16 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) { require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error) activeUser := &domain.User{ - Email: "active-membership@example.com", - Name: "Active Membership", - Role: "user", - TenantID: &activeTenant.ID, - CompanyCode: activeTenant.Slug, - CompanyCodes: []string{activeTenant.Slug}, + Email: "active-membership@example.com", + Name: "Active Membership", + Role: "user", + TenantID: &activeTenant.ID, } orphanUser := &domain.User{ - Email: "orphan-membership@example.com", - Name: "Orphan Membership", - Role: "user", - TenantID: &deletedTenant.ID, - CompanyCode: deletedTenant.Slug, - CompanyCodes: []string{deletedTenant.Slug}, + Email: "orphan-membership@example.com", + Name: "Orphan Membership", + Role: "user", + TenantID: &deletedTenant.ID, } require.NoError(t, repo.Create(ctx, activeUser)) require.NoError(t, repo.Create(ctx, orphanUser)) @@ -56,14 +52,10 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) { require.NoError(t, err) require.NotNil(t, foundActive.TenantID) assert.Equal(t, activeTenant.ID, *foundActive.TenantID) - assert.Equal(t, activeTenant.Slug, foundActive.CompanyCode) - assert.Equal(t, []string{activeTenant.Slug}, []string(foundActive.CompanyCodes)) foundOrphan, err := repo.FindByEmail(ctx, orphanUser.Email) require.NoError(t, err) assert.Nil(t, foundOrphan.TenantID) - assert.Empty(t, foundOrphan.CompanyCode) - assert.Empty(t, foundOrphan.CompanyCodes) count, err = CountOrphanUserTenantMemberships(ctx, testDB) require.NoError(t, err) diff --git a/backend/internal/repository/user_projection_repository_test.go b/backend/internal/repository/user_projection_repository_test.go index c0f92041..9930d31f 100644 --- a/backend/internal/repository/user_projection_repository_test.go +++ b/backend/internal/repository/user_projection_repository_test.go @@ -47,12 +47,12 @@ func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyAndRemovesStaleU UpdatedAt: time.Now(), }, { - ID: "00000000-0000-0000-0000-000000000102", - Email: "two@example.com", - Name: "Two", - CompanyCodes: []string{tenantSlug}, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: "00000000-0000-0000-0000-000000000102", + Email: "two@example.com", + Name: "Two", + TenantID: &tenantID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), }, } diff --git a/backend/internal/repository/user_repository_test.go b/backend/internal/repository/user_repository_test.go index 0313c94d..2454f3b4 100644 --- a/backend/internal/repository/user_repository_test.go +++ b/backend/internal/repository/user_repository_test.go @@ -5,7 +5,9 @@ import ( "context" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestUserRepository(t *testing.T) { @@ -76,13 +78,17 @@ func TestUserRepository(t *testing.T) { t.Run("CountByCompanyCodes", func(t *testing.T) { // Clean start for this subtest + testDB.Exec("DELETE FROM user_login_ids") testDB.Exec("DELETE FROM users") + testDB.Exec("DELETE FROM tenant_domains") + tenantA := createUserRepositoryTestTenant(t, "tenant-a") + tenantB := createUserRepositoryTestTenant(t, "tenant-b") users := []domain.User{ - {Email: "u1@a.com", Name: "U1", CompanyCode: "tenant-a"}, - {Email: "u2@a.com", Name: "U2", CompanyCode: "tenant-a"}, - {Email: "u3@b.com", Name: "U3", CompanyCode: "tenant-b"}, - {Email: "u4@none.com", Name: "U4", CompanyCode: ""}, + {Email: "u1@a.com", Name: "U1", TenantID: &tenantA.ID}, + {Email: "u2@a.com", Name: "U2", TenantID: &tenantA.ID}, + {Email: "u3@b.com", Name: "U3", TenantID: &tenantB.ID}, + {Email: "u4@none.com", Name: "U4"}, } for _, u := range users { _ = repo.Create(ctx, &u) @@ -96,17 +102,20 @@ func TestUserRepository(t *testing.T) { }) t.Run("CountByCompanyCodes excludes soft deleted cache rows", func(t *testing.T) { + testDB.Exec("DELETE FROM user_login_ids") testDB.Exec("DELETE FROM users") + testDB.Exec("DELETE FROM tenant_domains") + tenantA := createUserRepositoryTestTenant(t, "tenant-a") - active := &domain.User{Email: "active@a.com", Name: "Active", CompanyCode: "tenant-a"} - deleted := &domain.User{Email: "deleted@a.com", Name: "Deleted", CompanyCode: "tenant-a"} - arrayDeleted := &domain.User{Email: "array-deleted@a.com", Name: "Array Deleted", CompanyCodes: []string{"tenant-a"}} + active := &domain.User{Email: "active@a.com", Name: "Active", TenantID: &tenantA.ID} + deleted := &domain.User{Email: "deleted@a.com", Name: "Deleted", TenantID: &tenantA.ID} + secondDeleted := &domain.User{Email: "second-deleted@a.com", Name: "Second Deleted", TenantID: &tenantA.ID} assert.NoError(t, repo.Create(ctx, active)) assert.NoError(t, repo.Create(ctx, deleted)) - assert.NoError(t, repo.Create(ctx, arrayDeleted)) + assert.NoError(t, repo.Create(ctx, secondDeleted)) assert.NoError(t, repo.Delete(ctx, deleted.ID)) - assert.NoError(t, repo.Delete(ctx, arrayDeleted.ID)) + assert.NoError(t, repo.Delete(ctx, secondDeleted.ID)) counts, err := repo.CountByCompanyCodes(ctx, []string{"tenant-a"}) @@ -164,3 +173,17 @@ func TestUserRepository(t *testing.T) { assert.Equal(t, "E002", saved[0].LoginID) }) } + +func createUserRepositoryTestTenant(t *testing.T, slug string) domain.Tenant { + t.Helper() + require.NoError(t, testDB.Unscoped().Where("slug = ?", slug).Delete(&domain.Tenant{}).Error) + tenant := domain.Tenant{ + ID: uuid.NewString(), + Name: "Tenant " + slug, + Slug: slug, + Type: domain.TenantTypeCompany, + Status: domain.TenantStatusActive, + } + require.NoError(t, testDB.Create(&tenant).Error) + return tenant +} diff --git a/common/locales/en.toml b/common/locales/en.toml index e27eff98..55082e0c 100644 --- a/common/locales/en.toml +++ b/common/locales/en.toml @@ -15,6 +15,7 @@ actions = "Actions" add = "Add" all = "All" admin_only = "Admin Only" +apply = "Apply" approve = "Approve" assign = "Assign" back = "Back" diff --git a/common/locales/ko.toml b/common/locales/ko.toml index 7e86dd7b..7e1acee5 100644 --- a/common/locales/ko.toml +++ b/common/locales/ko.toml @@ -15,6 +15,7 @@ actions = "액션" add = "추가" all = "전체" admin_only = "관리자 전용" +apply = "적용" approve = "승인" assign = "할당" back = "돌아가기" diff --git a/common/locales/template.toml b/common/locales/template.toml index 3c16a2b3..e1a8b0dc 100644 --- a/common/locales/template.toml +++ b/common/locales/template.toml @@ -15,6 +15,7 @@ actions = "" add = "" all = "" admin_only = "" +apply = "" approve = "" assign = "" back = "" diff --git a/locales/en.toml b/locales/en.toml index d5dd34d8..f2439997 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -2606,3 +2606,99 @@ toggle_label = "Show active sessions only" [msg.userfront.audit.filter] description = "Toggle to view only active sessions." + +[msg.admin.integrity.forbidden] +description = "This screen is available only to super_admin." + +[msg.admin.integrity.orphan_login_ids] +delete_confirm = "Delete {{count}} selected orphan login IDs?" +delete_success = "Deleted {{count}} orphan login IDs." +description = "Review login IDs that reference deleted or missing users/tenants, then delete selected rows." +empty = "No orphan login IDs to delete." +load_error = "Failed to load orphan login ID targets." + +[msg.admin.integrity.read_model] +description = "Checks anomalies in the backend DB read model without overwriting the Ory SoT." + +[msg.admin.integrity.recheck] +error = "Check failed." +running = "Running integrity check." +success = "Check completed." + +[msg.admin.integrity.report] +load_error = "Failed to load the integrity report." + +[ui.admin.integrity] +kicker = "System" +loading = "Loading" +title = "Data Integrity Check" + +[ui.admin.integrity.forbidden] +title = "Access denied" + +[ui.admin.integrity.orphan_login_ids] +delete = "Delete selected" +title = "Orphan Login ID Cleanup" + +[ui.admin.integrity.read_model] +title = "Read model integrity" + +[ui.admin.integrity.reason] +deleted_tenant = "Deleted tenant" +deleted_user = "Deleted user" +missing_tenant = "Missing tenant" +missing_user = "Missing user" + +[ui.admin.integrity.recheck] +run = "Run again" +running = "Checking" + +[ui.admin.integrity.status] +fail = "Failed" +pass = "Passed" +warning = "Warning" + +[ui.admin.integrity.summary] +checked_at = "Checked at" +failures = "Failures" +passed = "Passed" +total_checks = "Checks" + +[ui.admin.integrity.table] +field = "Field" +login_id = "Login ID" +reason = "Reason" +select = "Select" +select_item = "Select {{loginId}}" +tenant = "Tenant" +user = "User" + +[msg.admin.api_keys.list] +edit_scopes_desc = "Edit the scopes granted to this API key." +rotate_confirm = "Rotate the secret for this API key?" +rotate_secret_notice = "The new secret is shown only once." + +[msg.admin.tenants] +export_error = "Failed to export tenants." + +[ui.admin.api_keys.list] +edit_scopes = "Edit scopes" +rotate_secret = "Rotate secret" +rotate_secret_done = "Secret rotated" +save_scopes = "Save scopes" + +[ui.admin.overview.summary] +total_users = "Total Users" + +[ui.admin.tenants.sub] +export = "Export" + +[ui.admin.users.bulk] +permission_placeholder = "Select permission" +status_placeholder = "Select status" + +[ui.dev.profile.org] +tenant_slug = "Tenant slug" + +[ui.userfront.profile.field] +tenant_slug = "Tenant slug" diff --git a/locales/ko.toml b/locales/ko.toml index 440b0d02..0571d494 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -3029,3 +3029,99 @@ toggle_label = "활성 세션만 보기" [msg.userfront.audit.filter] description = "활성화된 세션만 보려면 토글을 켜주세요." + +[msg.admin.integrity.forbidden] +description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다." + +[msg.admin.integrity.orphan_login_ids] +delete_confirm = "선택한 {{count}}개의 유령 로그인 ID를 삭제하시겠습니까?" +delete_success = "{{count}}개의 유령 로그인 ID를 삭제했습니다." +description = "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다." +empty = "삭제할 유령 로그인 ID가 없습니다." +load_error = "유령 로그인 ID 대상을 불러오지 못했습니다." + +[msg.admin.integrity.read_model] +description = "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다." + +[msg.admin.integrity.recheck] +error = "검사에 실패했습니다." +running = "정합성 검사를 실행 중입니다." +success = "검사가 완료되었습니다." + +[msg.admin.integrity.report] +load_error = "정합성 리포트를 불러오지 못했습니다." + +[ui.admin.integrity] +kicker = "System" +loading = "불러오는 중" +title = "데이터 정합성 검증" + +[ui.admin.integrity.forbidden] +title = "접근 권한이 없습니다" + +[ui.admin.integrity.orphan_login_ids] +delete = "선택 삭제" +title = "유령 로그인 ID 정리" + +[ui.admin.integrity.read_model] +title = "Read model integrity" + +[ui.admin.integrity.reason] +deleted_tenant = "삭제된 테넌트" +deleted_user = "삭제된 사용자" +missing_tenant = "테넌트 없음" +missing_user = "사용자 없음" + +[ui.admin.integrity.recheck] +run = "다시 검사" +running = "검사 중" + +[ui.admin.integrity.status] +fail = "실패" +pass = "정상" +warning = "주의" + +[ui.admin.integrity.summary] +checked_at = "검사 시각" +failures = "실패 건수" +passed = "정상" +total_checks = "검사 항목" + +[ui.admin.integrity.table] +field = "Field" +login_id = "Login ID" +reason = "사유" +select = "선택" +select_item = "{{loginId}} 선택" +tenant = "Tenant" +user = "User" + +[msg.admin.api_keys.list] +edit_scopes_desc = "API 키에 부여할 권한 범위를 수정합니다." +rotate_confirm = "이 API 키의 Secret을 재발급할까요?" +rotate_secret_notice = "새 Secret은 지금 한 번만 표시됩니다." + +[msg.admin.tenants] +export_error = "테넌트 내보내기에 실패했습니다." + +[ui.admin.api_keys.list] +edit_scopes = "권한 수정" +rotate_secret = "Secret 재발급" +rotate_secret_done = "Secret 재발급 완료" +save_scopes = "권한 저장" + +[ui.admin.overview.summary] +total_users = "전체 사용자 수" + +[ui.admin.tenants.sub] +export = "내보내기" + +[ui.admin.users.bulk] +permission_placeholder = "권한 선택" +status_placeholder = "상태 선택" + +[ui.dev.profile.org] +tenant_slug = "테넌트 slug" + +[ui.userfront.profile.field] +tenant_slug = "테넌트 slug" diff --git a/locales/template.toml b/locales/template.toml index 0d25ebd8..09c7dc04 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -2908,3 +2908,99 @@ toggle_label = "" [msg.userfront.audit.filter] description = "" + +[msg.admin.integrity.forbidden] +description = "" + +[msg.admin.integrity.orphan_login_ids] +delete_confirm = "" +delete_success = "" +description = "" +empty = "" +load_error = "" + +[msg.admin.integrity.read_model] +description = "" + +[msg.admin.integrity.recheck] +error = "" +running = "" +success = "" + +[msg.admin.integrity.report] +load_error = "" + +[ui.admin.integrity] +kicker = "" +loading = "" +title = "" + +[ui.admin.integrity.forbidden] +title = "" + +[ui.admin.integrity.orphan_login_ids] +delete = "" +title = "" + +[ui.admin.integrity.read_model] +title = "" + +[ui.admin.integrity.reason] +deleted_tenant = "" +deleted_user = "" +missing_tenant = "" +missing_user = "" + +[ui.admin.integrity.recheck] +run = "" +running = "" + +[ui.admin.integrity.status] +fail = "" +pass = "" +warning = "" + +[ui.admin.integrity.summary] +checked_at = "" +failures = "" +passed = "" +total_checks = "" + +[ui.admin.integrity.table] +field = "" +login_id = "" +reason = "" +select = "" +select_item = "" +tenant = "" +user = "" + +[msg.admin.api_keys.list] +edit_scopes_desc = "" +rotate_confirm = "" +rotate_secret_notice = "" + +[msg.admin.tenants] +export_error = "" + +[ui.admin.api_keys.list] +edit_scopes = "" +rotate_secret = "" +rotate_secret_done = "" +save_scopes = "" + +[ui.admin.overview.summary] +total_users = "" + +[ui.admin.tenants.sub] +export = "" + +[ui.admin.users.bulk] +permission_placeholder = "" +status_placeholder = "" + +[ui.dev.profile.org] +tenant_slug = "" + +[ui.userfront.profile.field] +tenant_slug = "" diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx index b5d7a05a..8d39d14a 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { type OrgNode, buildOrgSelectionOptions, + buildUsersMap, clampScale, getOrgNodeHeaderFill, getSemanticZoomMode, @@ -385,4 +386,24 @@ describe("org chart layout", () => { buildOrgSelectionOptions(familyRoot).map((option) => option.label), ).toEqual(["총괄기획&기술개발센터", "삼안", "한맥기술", "바론그룹"]); }); + + it("maps legacy companyCode users to matching tenant slugs", () => { + const usersMap = buildUsersMap( + [ + { + ...member("engineering-user"), + companyCode: "engineering", + tenantSlug: undefined, + tenant: undefined, + joinedTenants: undefined, + }, + ], + [tenantNode("engineering", "ORGANIZATION", "Engineering", "engineering")], + { activeOnly: true }, + ); + + expect(usersMap.get("engineering")?.map((user) => user.id)).toEqual([ + "engineering-user", + ]); + }); }); diff --git a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx index 68d8c1ce..8f6e4781 100644 --- a/orgfront/src/features/orgchart/routes/OrgChartPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgChartPage.tsx @@ -1132,7 +1132,7 @@ function getLeafMembershipSlugs( }); } -function buildUsersMap( +export function buildUsersMap( users: UserSummary[], rootNodes: TenantNode[], options: { activeOnly: boolean }, @@ -1146,6 +1146,7 @@ function buildUsersMap( const slugs = new Set(); const primarySlug = user.tenantSlug?.toLowerCase() || ""; + const legacyCompanySlug = user.companyCode?.toLowerCase() || ""; if ( primarySlug && !isSystemGlobalTenant({ @@ -1157,6 +1158,17 @@ function buildUsersMap( ) { slugs.add(primarySlug); } + if ( + legacyCompanySlug && + !isSystemGlobalTenant({ + id: legacyCompanySlug, + slug: legacyCompanySlug, + type: legacyCompanySlug, + name: legacyCompanySlug, + }) + ) { + slugs.add(legacyCompanySlug); + } if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) { slugs.add(user.tenant.slug.toLowerCase()); } From d77199bdbc4ee16d632332895f9f141c4a67c590 Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 14 May 2026 10:15:50 +0900 Subject: [PATCH 3/3] Fix code-check locale and headless test failures --- adminfront/src/locales/en.toml | 2 +- adminfront/src/locales/ko.toml | 12 ++++++------ .../internal/handler/auth_handler_login_test.go | 12 ++++++++++++ locales/en.toml | 6 ++---- locales/ko.toml | 16 +++++++--------- locales/template.toml | 4 +--- userfront/assets/translations/en.toml | 2 +- userfront/assets/translations/ko.toml | 2 +- userfront/assets/translations/template.toml | 1 + 9 files changed, 32 insertions(+), 25 deletions(-) diff --git a/adminfront/src/locales/en.toml b/adminfront/src/locales/en.toml index 21b604e3..e762bb3d 100644 --- a/adminfront/src/locales/en.toml +++ b/adminfront/src/locales/en.toml @@ -860,7 +860,7 @@ subtitle = "Manage your organization" [ui.admin.integrity] kicker = "System" -loading = "Loading" +loading = "Loading data integrity report..." title = "Data Integrity Check" [ui.admin.integrity.forbidden] diff --git a/adminfront/src/locales/ko.toml b/adminfront/src/locales/ko.toml index 068b13c5..8a1bc6ad 100644 --- a/adminfront/src/locales/ko.toml +++ b/adminfront/src/locales/ko.toml @@ -861,7 +861,7 @@ plane = "ADMIN PLANE" subtitle = "Manage your organization" [ui.admin.integrity] -kicker = "System" +kicker = "시스템" loading = "불러오는 중" title = "데이터 정합성 검증" @@ -873,7 +873,7 @@ delete = "선택 삭제" title = "유령 로그인 ID 정리" [ui.admin.integrity.read_model] -title = "Read model integrity" +title = "읽기 모델 정합성" [ui.admin.integrity.reason] deleted_tenant = "삭제된 테넌트" @@ -897,13 +897,13 @@ passed = "정상" total_checks = "검사 항목" [ui.admin.integrity.table] -field = "Field" -login_id = "Login ID" +field = "필드" +login_id = "로그인 ID" reason = "사유" select = "선택" select_item = "{{loginId}} 선택" -tenant = "Tenant" -user = "User" +tenant = "테넌트" +user = "사용자" [ui.admin.nav] org_chart = "조직도" diff --git a/backend/internal/handler/auth_handler_login_test.go b/backend/internal/handler/auth_handler_login_test.go index 8b1f141c..defd071d 100644 --- a/backend/internal/handler/auth_handler_login_test.go +++ b/backend/internal/handler/auth_handler_login_test.go @@ -359,6 +359,8 @@ func mustHeadlessClientAssertionWithAlgorithm(t *testing.T, privateKey any, alg } func runHeadlessPasswordLoginWithAssertion(t *testing.T, jwks map[string]any, clientAssertion string) *http.Response { + t.Helper() + t.Setenv("BACKEND_PUBLIC_URL", "") return runHeadlessPasswordLoginWithAssertionRequest(t, jwks, clientAssertion, "http://example.com/api/v1/auth/headless/password/login", nil) } @@ -454,6 +456,8 @@ func runHeadlessPasswordLoginWithAssertionAndLogger( clientAssertion string, logger *slog.Logger, ) *http.Response { + t.Helper() + t.Setenv("BACKEND_PUBLIC_URL", "") return runHeadlessPasswordLoginWithAssertionAndLoggerRequest( t, jwks, @@ -799,6 +803,8 @@ func TestPasswordLogin_UserFront_AuditIncludesDefaultClientMetadata(t *testing.T } func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) { + t.Setenv("BACKEND_PUBLIC_URL", "") + if !testsupport.PortBindingAvailable() { t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") } @@ -1019,6 +1025,8 @@ func TestHeadlessPasswordLogin_AuditIncludesClientMetadata(t *testing.T) { } func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured(t *testing.T) { + t.Setenv("BACKEND_PUBLIC_URL", "") + if !testsupport.PortBindingAvailable() { t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") } @@ -1106,6 +1114,8 @@ func TestHeadlessPasswordLogin_IgnoresInlineHeadlessJWKSWhenJWKSURIIsConfigured( } func TestHeadlessPasswordLogin_RefreshesJWKSWhenSignatureFailsForCachedKid(t *testing.T) { + t.Setenv("BACKEND_PUBLIC_URL", "") + if !testsupport.PortBindingAvailable() { t.Skip("skipping headless login tests because this environment cannot bind local TCP listeners") } @@ -1418,6 +1428,8 @@ func TestHeadlessPasswordLogin_AudienceMismatchReturnsDetailedCode(t *testing.T) } func TestHeadlessPasswordLogin_AcceptsForwardedHTTPSAudience(t *testing.T) { + t.Setenv("BACKEND_PUBLIC_URL", "") + privateKey, jwks := mustHeadlessRSAJWK(t) clientAssertion := mustHeadlessClientAssertion( t, diff --git a/locales/en.toml b/locales/en.toml index f2439997..7c0724da 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -2514,6 +2514,7 @@ department = "Department" email = "Email" name = "Name" tenant = "Tenant" +tenant_slug = "Tenant slug" [ui.userfront.profile.password] change = "Change" @@ -2630,7 +2631,7 @@ load_error = "Failed to load the integrity report." [ui.admin.integrity] kicker = "System" -loading = "Loading" +loading = "Loading data integrity report..." title = "Data Integrity Check" [ui.admin.integrity.forbidden] @@ -2699,6 +2700,3 @@ status_placeholder = "Select status" [ui.dev.profile.org] tenant_slug = "Tenant slug" - -[ui.userfront.profile.field] -tenant_slug = "Tenant slug" diff --git a/locales/ko.toml b/locales/ko.toml index 0571d494..377ce3dc 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -2938,6 +2938,7 @@ department = "소속" email = "이메일" name = "이름" tenant = "소속 테넌트" +tenant_slug = "테넌트 slug" [ui.userfront.profile.password] change = "비밀번호 변경" @@ -3052,7 +3053,7 @@ success = "검사가 완료되었습니다." load_error = "정합성 리포트를 불러오지 못했습니다." [ui.admin.integrity] -kicker = "System" +kicker = "시스템" loading = "불러오는 중" title = "데이터 정합성 검증" @@ -3064,7 +3065,7 @@ delete = "선택 삭제" title = "유령 로그인 ID 정리" [ui.admin.integrity.read_model] -title = "Read model integrity" +title = "읽기 모델 정합성" [ui.admin.integrity.reason] deleted_tenant = "삭제된 테넌트" @@ -3088,13 +3089,13 @@ passed = "정상" total_checks = "검사 항목" [ui.admin.integrity.table] -field = "Field" -login_id = "Login ID" +field = "필드" +login_id = "로그인 ID" reason = "사유" select = "선택" select_item = "{{loginId}} 선택" -tenant = "Tenant" -user = "User" +tenant = "테넌트" +user = "사용자" [msg.admin.api_keys.list] edit_scopes_desc = "API 키에 부여할 권한 범위를 수정합니다." @@ -3122,6 +3123,3 @@ status_placeholder = "상태 선택" [ui.dev.profile.org] tenant_slug = "테넌트 slug" - -[ui.userfront.profile.field] -tenant_slug = "테넌트 slug" diff --git a/locales/template.toml b/locales/template.toml index 09c7dc04..90a2039f 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -2817,6 +2817,7 @@ department = "" email = "" name = "" tenant = "" +tenant_slug = "" [ui.userfront.profile.password] change = "" @@ -3001,6 +3002,3 @@ status_placeholder = "" [ui.dev.profile.org] tenant_slug = "" - -[ui.userfront.profile.field] -tenant_slug = "" diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index af3ad5f9..0e5fed73 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -599,7 +599,7 @@ department = "Department" email = "Email" name = "Name" tenant = "Tenant" -tenant_slug = "Tenant Slug" +tenant_slug = "Tenant slug" [ui.userfront.profile.password] change = "Change" diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index d953140d..e423a2a5 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -821,7 +821,7 @@ department = "소속" email = "이메일" name = "이름" tenant = "소속 테넌트" -tenant_slug = "테넌트 Slug" +tenant_slug = "테넌트 slug" [ui.userfront.profile.password] change = "비밀번호 변경" diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 497bb8c6..28b9cff8 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -793,6 +793,7 @@ department = "" email = "" name = "" tenant = "" +tenant_slug = "" [ui.userfront.profile.password] change = ""