forked from baron/baron-sso
640 lines
22 KiB
TypeScript
640 lines
22 KiB
TypeScript
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 <CheckCircle2 className="text-emerald-600" size={18} />;
|
|
}
|
|
if (check.status === "warning") {
|
|
return <AlertTriangle className="text-amber-600" size={18} />;
|
|
}
|
|
return <ShieldAlert className="text-destructive" size={18} />;
|
|
}
|
|
|
|
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 (
|
|
<div className="rounded border border-border/60 px-3 py-6 text-center text-sm text-muted-foreground">
|
|
{t(
|
|
"msg.admin.integrity.orphan_login_ids.empty",
|
|
"삭제할 유령 로그인 ID가 없습니다.",
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const selectedSet = new Set(selectedIds);
|
|
return (
|
|
<div className="overflow-x-auto rounded border border-border/60">
|
|
<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">
|
|
{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">
|
|
{items.map((item) => (
|
|
<tr key={item.id}>
|
|
<td className="px-3 py-2">
|
|
<input
|
|
type="checkbox"
|
|
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"
|
|
/>
|
|
</td>
|
|
<td className="px-3 py-2 font-medium">{item.loginId}</td>
|
|
<td className="px-3 py-2 text-muted-foreground">
|
|
{item.fieldKey}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div>{item.userEmail || "-"}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{item.userId}
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div>{item.tenantSlug || "-"}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{item.tenantId}
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex flex-wrap gap-1">
|
|
{item.reasons.map((reason) => (
|
|
<Badge key={reason} variant="warning">
|
|
{reasonLabel(reason)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DataIntegrityContent() {
|
|
const queryClient = useQueryClient();
|
|
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
|
|
"integrity",
|
|
);
|
|
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,
|
|
});
|
|
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 (
|
|
<main className="space-y-6">
|
|
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
|
|
<div className="flex min-w-0 items-start gap-3">
|
|
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
|
|
<Database size={20} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h2 className="text-3xl font-semibold">
|
|
{t("ui.admin.integrity.title", "데이터 정합성 검증")}
|
|
</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t(
|
|
"msg.admin.integrity.subtitle",
|
|
"Review integrity status and inspect checks across the admin data model.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{activeTab === "integrity" ? (
|
|
<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>
|
|
) : null}
|
|
</header>
|
|
|
|
<div
|
|
className="flex border-b border-border"
|
|
role="tablist"
|
|
aria-label="데이터 정합성 탭"
|
|
>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === "integrity"}
|
|
className={pageTabClassName(activeTab === "integrity")}
|
|
onClick={() => setActiveTab("integrity")}
|
|
>
|
|
{t("ui.admin.integrity.tab_checks", "정합성 검사")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activeTab === "projection"}
|
|
className={pageTabClassName(activeTab === "projection")}
|
|
onClick={() => setActiveTab("projection")}
|
|
>
|
|
{t("ui.admin.integrity.tab_user_projection", "사용자 동기화")}
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === "integrity" ? (
|
|
<div className="space-y-4 pb-6 animate-in fade-in duration-500">
|
|
{isError ? (
|
|
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
|
{(error as Error)?.message ||
|
|
t(
|
|
"msg.admin.integrity.report.load_error",
|
|
"정합성 리포트를 불러오지 못했습니다.",
|
|
)}
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="rounded-lg border border-border bg-card p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
|
|
<div>
|
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
|
{t(
|
|
"ui.admin.integrity.read_model.title",
|
|
"Read model integrity",
|
|
)}
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t(
|
|
"msg.admin.integrity.read_model.description",
|
|
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
{data ? (
|
|
<Badge variant={statusBadgeVariant(data.status)}>
|
|
{statusLabel(data.status)}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<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">
|
|
{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">
|
|
{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">
|
|
{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">
|
|
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
|
|
</dt>
|
|
<dd className="mt-1 text-sm">
|
|
{formatDateTime(data?.checkedAt)}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
)}
|
|
</section>
|
|
|
|
<div className="space-y-4">
|
|
{(data?.sections ?? []).map((section) => (
|
|
<section
|
|
key={section.key}
|
|
className="rounded-lg border border-border bg-card p-5"
|
|
>
|
|
<div className="mb-4 flex items-center justify-between gap-3">
|
|
<div className="space-y-1">
|
|
<h3 className="text-lg font-bold flex items-center gap-2">
|
|
{integritySectionLabel(section.key, section.label)}
|
|
</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{integritySectionDescription(section.key)}
|
|
</p>
|
|
</div>
|
|
<Badge variant={statusBadgeVariant(section.status)}>
|
|
{statusLabel(section.status)}
|
|
</Badge>
|
|
</div>
|
|
<div className="divide-y divide-border">
|
|
{section.checks.map((check) => (
|
|
<div
|
|
key={check.key}
|
|
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
|
|
>
|
|
<div className="flex gap-3">
|
|
<CheckIcon check={check} />
|
|
<div>
|
|
<div className="font-medium">
|
|
{integrityCheckLabel(check.key, check.label)}
|
|
</div>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{integrityCheckDescription(
|
|
check.key,
|
|
check.description,
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 md:justify-end">
|
|
<Badge variant={statusBadgeVariant(check.status)}>
|
|
{statusLabel(check.status)}
|
|
</Badge>
|
|
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
|
|
{check.count}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
))}
|
|
</div>
|
|
|
|
<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-lg font-bold flex items-center gap-2">
|
|
{t(
|
|
"ui.admin.integrity.orphan_login_ids.title",
|
|
"유령 로그인 ID 정리",
|
|
)}
|
|
</h3>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
{t(
|
|
"msg.admin.integrity.orphan_login_ids.description",
|
|
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={handleDeleteSelected}
|
|
disabled={
|
|
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">
|
|
{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">
|
|
{t(
|
|
"msg.admin.integrity.orphan_login_ids.delete_success",
|
|
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
|
|
{ count: deleteMutation.data.deletedCount },
|
|
)}
|
|
</div>
|
|
) : null}
|
|
<OrphanLoginIDTable
|
|
items={orphanItems}
|
|
selectedIds={selectedOrphanIds}
|
|
onToggle={toggleOrphanID}
|
|
/>
|
|
</section>
|
|
</div>
|
|
) : (
|
|
<div className="animate-in fade-in duration-500">
|
|
<UserProjectionContent embedded />
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default function DataIntegrityPage() {
|
|
return (
|
|
<RoleGuard
|
|
roles={["super_admin"]}
|
|
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">
|
|
{t("ui.admin.integrity.forbidden.title", "접근 권한이 없습니다")}
|
|
</h2>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
{t(
|
|
"msg.admin.integrity.forbidden.description",
|
|
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
|
|
)}
|
|
</p>
|
|
</section>
|
|
</main>
|
|
}
|
|
>
|
|
<DataIntegrityContent />
|
|
</RoleGuard>
|
|
);
|
|
}
|