forked from baron/baron-sso
Merge branch 'dev' into feature/tenant-user-list-ui-improvement
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 키"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ actions = "Actions"
|
||||
add = "Add"
|
||||
all = "All"
|
||||
admin_only = "Admin Only"
|
||||
apply = "Apply"
|
||||
approve = "Approve"
|
||||
assign = "Assign"
|
||||
back = "Back"
|
||||
|
||||
@@ -16,6 +16,7 @@ actions = "액션"
|
||||
add = "추가"
|
||||
all = "전체"
|
||||
admin_only = "관리자 전용"
|
||||
apply = "적용"
|
||||
approve = "승인"
|
||||
assign = "할당"
|
||||
back = "돌아가기"
|
||||
|
||||
@@ -16,6 +16,7 @@ actions = ""
|
||||
add = ""
|
||||
all = ""
|
||||
admin_only = ""
|
||||
apply = ""
|
||||
approve = ""
|
||||
assign = ""
|
||||
back = ""
|
||||
|
||||
@@ -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은 화면 접근 시 권한 없음 메시지만 봅니다.
|
||||
|
||||
116
locales/en.toml
116
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"
|
||||
@@ -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"
|
||||
|
||||
116
locales/ko.toml
116
locales/ko.toml
@@ -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"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -599,6 +599,7 @@ department = "Department"
|
||||
email = "Email"
|
||||
name = "Name"
|
||||
tenant = "Tenant"
|
||||
tenant_slug = "Tenant slug"
|
||||
|
||||
[ui.userfront.profile.password]
|
||||
change = "Change"
|
||||
|
||||
@@ -821,6 +821,7 @@ department = "소속"
|
||||
email = "이메일"
|
||||
name = "이름"
|
||||
tenant = "소속 테넌트"
|
||||
tenant_slug = "테넌트 slug"
|
||||
|
||||
[ui.userfront.profile.password]
|
||||
change = "비밀번호 변경"
|
||||
|
||||
@@ -793,6 +793,7 @@ department = ""
|
||||
email = ""
|
||||
name = ""
|
||||
tenant = ""
|
||||
tenant_slug = ""
|
||||
|
||||
[ui.userfront.profile.password]
|
||||
change = ""
|
||||
|
||||
Reference in New Issue
Block a user