import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
Database,
ShieldAlert,
} from "lucide-react";
import { useState } from "react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
type DataIntegrityCheck,
type DataIntegrityStatus,
deleteOrphanUserLoginIDs,
fetchDataIntegrityReport,
fetchOrphanUserLoginIDs,
type OrphanUserLoginID,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
import { UserProjectionContent } from "../projections/UserProjectionPage";
function statusLabel(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return t("ui.admin.integrity.status.pass", "정상");
case "warning":
return t("ui.admin.integrity.status.warning", "주의");
case "fail":
return t("ui.admin.integrity.status.fail", "실패");
default:
return status;
}
}
function statusBadgeVariant(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "success";
case "warning":
return "warning";
default:
return "warning";
}
}
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(getAdminDateLocale(), {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
}
function CheckIcon({ check }: { check: DataIntegrityCheck }) {
if (check.status === "pass") {
return ;
}
if (check.status === "warning") {
return ;
}
return ;
}
function reasonLabel(reason: string) {
switch (reason) {
case "missing_user":
return t("ui.admin.integrity.reason.missing_user", "사용자 없음");
case "deleted_user":
return t("ui.admin.integrity.reason.deleted_user", "삭제된 사용자");
case "missing_tenant":
return t("ui.admin.integrity.reason.missing_tenant", "테넌트 없음");
case "deleted_tenant":
return t("ui.admin.integrity.reason.deleted_tenant", "삭제된 테넌트");
default:
return reason;
}
}
function integritySectionLabel(key: string, fallback: string) {
switch (key) {
case "tenant_integrity":
return t("ui.admin.integrity.section.tenant_integrity", fallback);
case "user_integrity":
return t("ui.admin.integrity.section.user_integrity", fallback);
default:
return fallback;
}
}
function integritySectionDescription(key: string) {
switch (key) {
case "tenant_integrity":
return t(
"msg.admin.integrity.section.tenant_integrity.description",
"테넌트 slug 중복과 부모 관계 이상을 확인합니다.",
);
case "user_integrity":
return t(
"msg.admin.integrity.section.user_integrity.description",
"사용자와 로그인 ID 참조의 고아 레코드를 확인합니다.",
);
default:
return "";
}
}
function integrityCheckLabel(key: string, fallback: string) {
switch (key) {
case "duplicate_tenant_slugs":
return t(
"ui.admin.integrity.check.duplicate_tenant_slugs.title",
fallback,
);
case "orphan_tenant_parents":
return t(
"ui.admin.integrity.check.orphan_tenant_parents.title",
fallback,
);
case "orphan_user_tenant_memberships":
return t(
"ui.admin.integrity.check.orphan_user_tenant_memberships.title",
fallback,
);
case "orphan_user_login_id_tenants":
return t(
"ui.admin.integrity.check.orphan_user_login_id_tenants.title",
fallback,
);
case "orphan_user_login_id_users":
return t(
"ui.admin.integrity.check.orphan_user_login_id_users.title",
fallback,
);
default:
return fallback;
}
}
function integrityCheckDescription(key: string, fallback: string) {
switch (key) {
case "duplicate_tenant_slugs":
return t(
"msg.admin.integrity.check.duplicate_tenant_slugs.description",
fallback,
);
case "orphan_tenant_parents":
return t(
"msg.admin.integrity.check.orphan_tenant_parents.description",
fallback,
);
case "orphan_user_tenant_memberships":
return t(
"msg.admin.integrity.check.orphan_user_tenant_memberships.description",
fallback,
);
case "orphan_user_login_id_tenants":
return t(
"msg.admin.integrity.check.orphan_user_login_id_tenants.description",
fallback,
);
case "orphan_user_login_id_users":
return t(
"msg.admin.integrity.check.orphan_user_login_id_users.description",
fallback,
);
default:
return fallback;
}
}
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 pageTabClassName(active: boolean) {
return `relative px-6 py-3 text-sm font-medium transition-colors ${
active
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`;
}
function OrphanLoginIDTable({
items,
selectedIds,
onToggle,
}: {
items: OrphanUserLoginID[];
selectedIds: string[];
onToggle: (id: string) => void;
}) {
if (items.length === 0) {
return (
{t(
"msg.admin.integrity.orphan_login_ids.empty",
"삭제할 유령 로그인 ID가 없습니다.",
)}
);
}
const selectedSet = new Set(selectedIds);
return (
|
{t("ui.admin.integrity.table.select", "선택")}
|
{t("ui.admin.integrity.table.login_id", "Login ID")}
|
{t("ui.admin.integrity.table.field", "Field")}
|
{t("ui.admin.integrity.table.user", "User")}
|
{t("ui.admin.integrity.table.tenant", "Tenant")}
|
{t("ui.admin.integrity.table.reason", "사유")}
|
{items.map((item) => (
|
onToggle(item.id)}
className="h-4 w-4 rounded border-input"
/>
|
{item.loginId} |
{item.fieldKey}
|
{item.userEmail || "-"}
{item.userId}
|
{item.tenantSlug || "-"}
{item.tenantId}
|
{item.reasons.map((reason) => (
{reasonLabel(reason)}
))}
|
))}
);
}
function DataIntegrityContent() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
"integrity",
);
const [selectedOrphanIds, setSelectedOrphanIds] = useState([]);
const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error"
>("idle");
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ["data-integrity-report"],
queryFn: fetchDataIntegrityReport,
});
const orphanLoginIDsQuery = useQuery({
queryKey: ["orphan-user-login-ids"],
queryFn: fetchOrphanUserLoginIDs,
});
const deleteMutation = useMutation({
mutationFn: deleteOrphanUserLoginIDs,
onSuccess: async () => {
setSelectedOrphanIds([]);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["data-integrity-report"] }),
queryClient.invalidateQueries({ queryKey: ["orphan-user-login-ids"] }),
]);
},
});
const orphanItems = orphanLoginIDsQuery.data?.items ?? [];
const toggleOrphanID = (id: string) => {
setSelectedOrphanIds((current) =>
current.includes(id)
? current.filter((selectedID) => selectedID !== id)
: [...current, id],
);
};
const handleDeleteSelected = () => {
if (selectedOrphanIds.length === 0) {
return;
}
const confirmed = window.confirm(
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 (
{t("ui.admin.integrity.title", "데이터 정합성 검증")}
{t(
"msg.admin.integrity.subtitle",
"Review integrity status and inspect checks across the admin data model.",
)}
{activeTab === "integrity" ? (
{recheckMessage ? (
) : null}
) : null}
{activeTab === "integrity" ? (
{isError ? (
{(error as Error)?.message ||
t(
"msg.admin.integrity.report.load_error",
"정합성 리포트를 불러오지 못했습니다.",
)}
) : null}
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
)}
{t(
"msg.admin.integrity.read_model.description",
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
)}
{data ? (
{statusLabel(data.status)}
) : null}
{isLoading ? (
{t("ui.admin.integrity.loading", "불러오는 중")}
) : (
-
{t("ui.admin.integrity.summary.total_checks", "검사 항목")}
-
{data?.summary.totalChecks ?? 0}
-
{t("ui.admin.integrity.summary.passed", "정상")}
-
{data?.summary.passed ?? 0}
-
{t("ui.admin.integrity.summary.failures", "실패 건수")}
-
{data?.summary.failures ?? 0}
-
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
-
{formatDateTime(data?.checkedAt)}
)}
{(data?.sections ?? []).map((section) => (
{integritySectionLabel(section.key, section.label)}
{integritySectionDescription(section.key)}
{statusLabel(section.status)}
{section.checks.map((check) => (
{integrityCheckLabel(check.key, check.label)}
{integrityCheckDescription(
check.key,
check.description,
)}
{statusLabel(check.status)}
{check.count}
))}
))}
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
{orphanLoginIDsQuery.isError ? (
{t(
"msg.admin.integrity.orphan_login_ids.load_error",
"유령 로그인 ID 대상을 불러오지 못했습니다.",
)}
) : null}
{deleteMutation.data ? (
{t(
"msg.admin.integrity.orphan_login_ids.delete_success",
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
{ count: deleteMutation.data.deletedCount },
)}
) : null}
) : (
)}
);
}
export default function DataIntegrityPage() {
return (
{t("ui.admin.integrity.forbidden.title", "접근 권한이 없습니다")}
{t(
"msg.admin.integrity.forbidden.description",
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
)}
}
>
);
}