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