1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/tenant-user-list-ui-improvement

This commit is contained in:
2026-05-14 11:06:39 +09:00
23 changed files with 910 additions and 185 deletions

View File

@@ -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<typeof integrityReport>((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";

View File

@@ -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,18 +66,34 @@ 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;
}
}
function recheckStatusText(status: "idle" | "running" | "success" | "error") {
switch (status) {
case "running":
return t(
"msg.admin.integrity.recheck.running",
"정합성 검사를 실행 중입니다.",
);
case "success":
return t("msg.admin.integrity.recheck.success", "검사가 완료되었습니다.");
case "error":
return t("msg.admin.integrity.recheck.error", "검사에 실패했습니다.");
default:
return "";
}
}
function OrphanLoginIDTable({
items,
selectedIds,
@@ -89,7 +106,10 @@ function OrphanLoginIDTable({
if (items.length === 0) {
return (
<div className="rounded border border-border/60 px-3 py-6 text-center text-sm text-muted-foreground">
ID가 .
{t(
"msg.admin.integrity.orphan_login_ids.empty",
"삭제할 유령 로그인 ID가 없습니다.",
)}
</div>
);
}
@@ -100,12 +120,24 @@ function OrphanLoginIDTable({
<table className="w-full min-w-[760px] text-sm">
<thead className="bg-muted/50 text-left text-muted-foreground">
<tr>
<th className="w-12 px-3 py-2"></th>
<th className="px-3 py-2">Login ID</th>
<th className="px-3 py-2">Field</th>
<th className="px-3 py-2">User</th>
<th className="px-3 py-2">Tenant</th>
<th className="px-3 py-2"></th>
<th className="w-12 px-3 py-2">
{t("ui.admin.integrity.table.select", "선택")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.login_id", "Login ID")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.field", "Field")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.user", "User")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.tenant", "Tenant")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.reason", "사유")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
@@ -114,7 +146,11 @@ function OrphanLoginIDTable({
<td className="px-3 py-2">
<input
type="checkbox"
aria-label={`${item.loginId} 선택`}
aria-label={t(
"ui.admin.integrity.table.select_item",
"{{loginId}} 선택",
{ loginId: item.loginId },
)}
checked={selectedSet.has(item.id)}
onChange={() => onToggle(item.id)}
className="h-4 w-4 rounded border-input"
@@ -156,6 +192,9 @@ function OrphanLoginIDTable({
function DataIntegrityContent() {
const queryClient = useQueryClient();
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error"
>("idle");
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ["data-integrity-report"],
queryFn: fetchDataIntegrityReport,
@@ -188,36 +227,68 @@ 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);
}
};
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 (
<main className="space-y-6 p-6 md:p-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">System</p>
<p className="text-sm text-muted-foreground">
{t("ui.admin.integrity.kicker", "System")}
</p>
<h2 className="text-2xl font-semibold tracking-tight">
{t("ui.admin.integrity.title", "데이터 정합성 검증")}
</h2>
</div>
<Button
type="button"
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
>
<Database size={16} />
</Button>
<div className="flex flex-col items-end gap-1">
<Button
type="button"
variant="outline"
onClick={handleRecheck}
disabled={isLoading || isFetching || isManualRechecking}
>
<Database size={16} />
{isManualRechecking
? t("ui.admin.integrity.recheck.running", "검사 중")
: t("ui.admin.integrity.recheck.run", "다시 검사")}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
{recheckMessage}
</output>
) : null}
</div>
</div>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message || "정합성 리포트를 불러오지 못했습니다."}
{(error as Error)?.message ||
t(
"msg.admin.integrity.report.load_error",
"정합성 리포트를 불러오지 못했습니다.",
)}
</section>
) : null}
@@ -228,10 +299,17 @@ function DataIntegrityContent() {
<ShieldAlert size={18} />
</div>
<div>
<h3 className="text-base font-semibold">Read model integrity</h3>
<h3 className="text-base font-semibold">
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
)}
</h3>
<p className="text-sm text-muted-foreground">
Ory SoT를 backend DB read model의
.
{t(
"msg.admin.integrity.read_model.description",
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
)}
</p>
</div>
</div>
@@ -243,29 +321,39 @@ function DataIntegrityContent() {
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground"> </div>
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.integrity.loading", "불러오는 중")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"></dt>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.passed", "정상")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.failures", "실패 건수")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
@@ -319,10 +407,17 @@ function DataIntegrityContent() {
<section className="rounded-lg border border-border bg-card p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-base font-semibold"> ID </h3>
<h3 className="text-base font-semibold">
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
/ ID를
.
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
</p>
</div>
<Button
@@ -333,18 +428,24 @@ function DataIntegrityContent() {
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
>
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
</Button>
</div>
{orphanLoginIDsQuery.isError ? (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
ID .
{t(
"msg.admin.integrity.orphan_login_ids.load_error",
"유령 로그인 ID 대상을 불러오지 못했습니다.",
)}
</div>
) : null}
{deleteMutation.data ? (
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{deleteMutation.data.deletedCount} ID를
.
{t(
"msg.admin.integrity.orphan_login_ids.delete_success",
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
{ count: deleteMutation.data.deletedCount },
)}
</div>
) : null}
<OrphanLoginIDTable
@@ -364,9 +465,14 @@ export default function DataIntegrityPage() {
fallback={
<main className="p-6 md:p-8">
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="text-lg font-semibold"> </h2>
<h2 className="text-lg font-semibold">
{t("ui.admin.integrity.forbidden.title", "접근 권한이 없습니다")}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
super_admin .
{t(
"msg.admin.integrity.forbidden.description",
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
)}
</p>
</section>
</main>

View File

@@ -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 data integrity report..."
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"

View File

@@ -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 = "시스템"
loading = "불러오는 중"
title = "데이터 정합성 검증"
[ui.admin.integrity.forbidden]
title = "접근 권한이 없습니다"
[ui.admin.integrity.orphan_login_ids]
delete = "선택 삭제"
title = "유령 로그인 ID 정리"
[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 = "로그인 ID"
reason = "사유"
select = "선택"
select_item = "{{loginId}} 선택"
tenant = "테넌트"
user = "사용자"
[ui.admin.nav]
org_chart = "조직도"
api_keys = "API 키"

View File

@@ -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 = ""

View File

@@ -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,
}) => {

View File

@@ -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,

View File

@@ -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,
@@ -68,8 +83,14 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
FieldKey: "emp_id",
LoginID: "EMP-" + suffix,
}).Error)
// Missing UserID for UserLoginID cannot be inserted due to FK constraint fk_users_user_login_ids.
// So we don't test orphan_user_login_id_users here.
require.NoError(t, testDB.Create(&domain.UserLoginID{
ID: 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)
@@ -80,7 +101,69 @@ func TestCheckDataIntegrityDetectsTenantAndUserProblems(t *testing.T) {
requireIntegrityCheck(t, report, "tenant_integrity", "orphan_tenant_parents", domain.DataIntegrityStatusFail, 1)
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_tenant_memberships", domain.DataIntegrityStatusFail, 1)
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_tenants", domain.DataIntegrityStatusFail, 1)
requireIntegrityCheck(t, report, "user_integrity", "orphan_user_login_id_users", domain.DataIntegrityStatusPass, 0)
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) {
@@ -189,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
}

View File

@@ -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))
@@ -57,12 +53,10 @@ func TestClearOrphanUserTenantMemberships(t *testing.T) {
require.NotNil(t, foundActive.TenantID)
require.NotNil(t, foundActive.Tenant)
assert.Equal(t, activeTenant.ID, *foundActive.TenantID)
assert.Equal(t, activeTenant.Slug, foundActive.Tenant.Slug)
foundOrphan, err := repo.FindByEmail(ctx, orphanUser.Email)
require.NoError(t, err)
assert.Nil(t, foundOrphan.TenantID)
assert.Nil(t, foundOrphan.Tenant)
count, err = CountOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)

View File

@@ -47,13 +47,12 @@ func TestUserProjectionRepository_ReplaceAllFromKratosMarksReadyAndRemovesStaleU
UpdatedAt: time.Now(),
},
{
ID: "00000000-0000-0000-0000-000000000102",
Email: "two@example.com",
Name: "Two",
TenantID: &tenantID,
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(),
},
}

View File

@@ -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,19 +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 tenants")
tenantA := &domain.Tenant{Name: "Tenant A", Slug: "tenant-a", Type: domain.TenantTypeCompany}
tenantB := &domain.Tenant{Name: "Tenant B", Slug: "tenant-b", Type: domain.TenantTypeCompany}
_ = testDB.Create(tenantA)
_ = testDB.Create(tenantB)
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", 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", TenantID: nil},
{Email: "u4@none.com", Name: "U4"},
}
for _, u := range users {
_ = repo.Create(ctx, &u)
@@ -102,21 +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 tenants")
tenantA := &domain.Tenant{Name: "Tenant A", Slug: "tenant-a", Type: domain.TenantTypeCompany}
_ = testDB.Create(tenantA)
testDB.Exec("DELETE FROM tenant_domains")
tenantA := createUserRepositoryTestTenant(t, "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}
arrayDeleted := &domain.User{Email: "array-deleted@a.com", Name: "Array 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"})
@@ -174,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
}

View File

@@ -16,6 +16,7 @@ actions = "Actions"
add = "Add"
all = "All"
admin_only = "Admin Only"
apply = "Apply"
approve = "Approve"
assign = "Assign"
back = "Back"

View File

@@ -16,6 +16,7 @@ actions = "액션"
add = "추가"
all = "전체"
admin_only = "관리자 전용"
apply = "적용"
approve = "승인"
assign = "할당"
back = "돌아가기"

View File

@@ -16,6 +16,7 @@ actions = ""
add = ""
all = ""
admin_only = ""
apply = ""
approve = ""
assign = ""
back = ""

View File

@@ -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은 화면 접근 시 권한 없음 메시지만 봅니다.

View File

@@ -2514,6 +2514,7 @@ department = "Department"
email = "Email"
name = "Name"
tenant = "Tenant"
tenant_slug = "Tenant slug"
[ui.userfront.profile.password]
change = "Change"
@@ -2607,26 +2608,95 @@ toggle_label = "Show active sessions only"
[msg.userfront.audit.filter]
description = "Toggle to view only active sessions."
[]
"msg.admin.api_keys.list.edit_scopes_desc" = "temp"
"msg.admin.api_keys.list.rotate_confirm" = "temp"
"msg.admin.api_keys.list.rotate_secret_notice" = "temp"
"msg.admin.tenants.bulk.update_error" = "temp"
"msg.admin.tenants.bulk.update_success" = "temp"
"msg.admin.tenants.export_error" = "temp"
"msg.admin.tenants.status_error" = "temp"
"ui.admin.api_keys.list.edit_scopes" = "temp"
"ui.admin.api_keys.list.rotate_secret" = "temp"
"ui.admin.api_keys.list.rotate_secret_done" = "temp"
"ui.admin.api_keys.list.save_scopes" = "temp"
"ui.admin.overview.summary.total_users" = "temp"
"ui.admin.tenants.bulk.selected_count" = "temp"
"ui.admin.tenants.bulk.status_placeholder" = "temp"
"ui.admin.tenants.data_mgmt" = "temp"
"ui.admin.tenants.sub.export" = "temp"
"ui.admin.tenants.toggle_status" = "temp"
"ui.admin.users.bulk.permission_placeholder" = "temp"
"ui.admin.users.bulk.status_placeholder" = "temp"
"ui.admin.users.data_mgmt" = "temp"
"ui.dev.profile.org.tenant_slug" = "temp"
"ui.userfront.profile.field.tenant_slug" = "temp"
[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 data integrity report..."
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"

View File

@@ -2938,6 +2938,7 @@ department = "소속"
email = "이메일"
name = "이름"
tenant = "소속 테넌트"
tenant_slug = "테넌트 slug"
[ui.userfront.profile.password]
change = "비밀번호 변경"
@@ -3030,26 +3031,95 @@ toggle_label = "활성 세션만 보기"
[msg.userfront.audit.filter]
description = "활성화된 세션만 보려면 토글을 켜주세요."
[]
"msg.admin.api_keys.list.edit_scopes_desc" = "temp"
"msg.admin.api_keys.list.rotate_confirm" = "temp"
"msg.admin.api_keys.list.rotate_secret_notice" = "temp"
"msg.admin.tenants.bulk.update_error" = "temp"
"msg.admin.tenants.bulk.update_success" = "temp"
"msg.admin.tenants.export_error" = "temp"
"msg.admin.tenants.status_error" = "temp"
"ui.admin.api_keys.list.edit_scopes" = "temp"
"ui.admin.api_keys.list.rotate_secret" = "temp"
"ui.admin.api_keys.list.rotate_secret_done" = "temp"
"ui.admin.api_keys.list.save_scopes" = "temp"
"ui.admin.overview.summary.total_users" = "temp"
"ui.admin.tenants.bulk.selected_count" = "temp"
"ui.admin.tenants.bulk.status_placeholder" = "temp"
"ui.admin.tenants.data_mgmt" = "temp"
"ui.admin.tenants.sub.export" = "temp"
"ui.admin.tenants.toggle_status" = "temp"
"ui.admin.users.bulk.permission_placeholder" = "temp"
"ui.admin.users.bulk.status_placeholder" = "temp"
"ui.admin.users.data_mgmt" = "temp"
"ui.dev.profile.org.tenant_slug" = "temp"
"ui.userfront.profile.field.tenant_slug" = "temp"
[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 = "시스템"
loading = "불러오는 중"
title = "데이터 정합성 검증"
[ui.admin.integrity.forbidden]
title = "접근 권한이 없습니다"
[ui.admin.integrity.orphan_login_ids]
delete = "선택 삭제"
title = "유령 로그인 ID 정리"
[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 = "로그인 ID"
reason = "사유"
select = "선택"
select_item = "{{loginId}} 선택"
tenant = "테넌트"
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"

View File

@@ -2817,6 +2817,7 @@ department = ""
email = ""
name = ""
tenant = ""
tenant_slug = ""
[ui.userfront.profile.password]
change = ""
@@ -2909,26 +2910,95 @@ toggle_label = ""
[msg.userfront.audit.filter]
description = ""
[]
"msg.admin.api_keys.list.edit_scopes_desc" = "temp"
"msg.admin.api_keys.list.rotate_confirm" = "temp"
"msg.admin.api_keys.list.rotate_secret_notice" = "temp"
"msg.admin.tenants.bulk.update_error" = "temp"
"msg.admin.tenants.bulk.update_success" = "temp"
"msg.admin.tenants.export_error" = "temp"
"msg.admin.tenants.status_error" = "temp"
"ui.admin.api_keys.list.edit_scopes" = "temp"
"ui.admin.api_keys.list.rotate_secret" = "temp"
"ui.admin.api_keys.list.rotate_secret_done" = "temp"
"ui.admin.api_keys.list.save_scopes" = "temp"
"ui.admin.overview.summary.total_users" = "temp"
"ui.admin.tenants.bulk.selected_count" = "temp"
"ui.admin.tenants.bulk.status_placeholder" = "temp"
"ui.admin.tenants.data_mgmt" = "temp"
"ui.admin.tenants.sub.export" = "temp"
"ui.admin.tenants.toggle_status" = "temp"
"ui.admin.users.bulk.permission_placeholder" = "temp"
"ui.admin.users.bulk.status_placeholder" = "temp"
"ui.admin.users.data_mgmt" = "temp"
"ui.dev.profile.org.tenant_slug" = "temp"
"ui.userfront.profile.field.tenant_slug" = "temp"
[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 = ""

View File

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

View File

@@ -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<string>();
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());
}

View File

@@ -599,6 +599,7 @@ department = "Department"
email = "Email"
name = "Name"
tenant = "Tenant"
tenant_slug = "Tenant slug"
[ui.userfront.profile.password]
change = "Change"

View File

@@ -821,6 +821,7 @@ department = "소속"
email = "이메일"
name = "이름"
tenant = "소속 테넌트"
tenant_slug = "테넌트 slug"
[ui.userfront.profile.password]
change = "비밀번호 변경"

View File

@@ -793,6 +793,7 @@ department = ""
email = ""
name = ""
tenant = ""
tenant_slug = ""
[ui.userfront.profile.password]
change = ""