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 (
{items.map((item) => ( ))}
{t("ui.admin.integrity.table.select", "선택")} {t("ui.admin.integrity.table.login_id", "Login ID")} {t("ui.admin.integrity.table.field", "Field")} {t("ui.admin.integrity.table.user", "User")} {t("ui.admin.integrity.table.tenant", "Tenant")} {t("ui.admin.integrity.table.reason", "사유")}
onToggle(item.id)} className="h-4 w-4 rounded border-input" /> {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 ? ( {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 권한으로만 접근할 수 있습니다.", )}

} >
); }