1
0
forked from baron/baron-sso

adminfront 조직 통계오류 보정. Kratos Projection용 통계테이블 구조 추가

This commit is contained in:
2026-05-11 13:01:55 +09:00
parent 9a64a16cb9
commit 843b4100ad
36 changed files with 2022 additions and 169 deletions

View File

@@ -0,0 +1,99 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchUserProjectionStatus,
reconcileUserProjection,
resetUserProjection,
} from "../../lib/adminApi";
import UserProjectionPage from "./UserProjectionPage";
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchUserProjectionStatus: vi.fn(async () => ({
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
})),
reconcileUserProjection: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
updatedAt: "2026-05-11T03:01:00Z",
})),
resetUserProjection: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
updatedAt: "2026-05-11T03:02:00Z",
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<UserProjectionPage />
</QueryClientProvider>,
);
}
describe("UserProjectionPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders projection status for super_admin", async () => {
renderPage();
expect(
await screen.findByText("사용자 Projection 관리"),
).toBeInTheDocument();
expect(
await screen.findByText("Kratos users projection"),
).toBeInTheDocument();
expect(screen.getByText("ready")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
expect(fetchUserProjectionStatus).toHaveBeenCalled();
});
it("runs reconcile and reset actions for super_admin", async () => {
renderPage();
await screen.findByText("사용자 Projection 관리");
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
await waitFor(() => {
expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
await waitFor(() => {
expect(resetUserProjection).toHaveBeenCalledTimes(1);
});
});
it("blocks non-super admins", async () => {
currentRole = "tenant_admin";
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(
screen.queryByText("사용자 Projection 관리"),
).not.toBeInTheDocument();
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,206 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Database, RefreshCw, RotateCcw } from "lucide-react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
fetchUserProjectionStatus,
reconcileUserProjection,
resetUserProjection,
} from "../../lib/adminApi";
function formatDateTime(value?: string) {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
}
function ProjectionStatusBadge({
ready,
status,
}: {
ready: boolean;
status: string;
}) {
if (ready) {
return <Badge variant="success">ready</Badge>;
}
if (status === "failed") {
return <Badge variant="warning">failed</Badge>;
}
return <Badge variant="secondary">{status || "not ready"}</Badge>;
}
function UserProjectionContent() {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["user-projection-status"],
queryFn: fetchUserProjectionStatus,
});
const invalidate = async () => {
await queryClient.invalidateQueries({
queryKey: ["user-projection-status"],
});
};
const reconcileMutation = useMutation({
mutationFn: reconcileUserProjection,
onSuccess: invalidate,
});
const resetMutation = useMutation({
mutationFn: resetUserProjection,
onSuccess: invalidate,
});
const handleReset = () => {
const confirmed = window.confirm(
"사용자 projection을 Kratos 기준으로 다시 구축하시겠습니까?",
);
if (confirmed) {
resetMutation.mutate();
}
};
const isWorking = reconcileMutation.isPending || resetMutation.isPending;
const actionResult = reconcileMutation.data ?? resetMutation.data;
const actionError = reconcileMutation.error ?? resetMutation.error;
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>
<h2 className="text-2xl font-semibold tracking-tight">
Projection
</h2>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => reconcileMutation.mutate()}
disabled={isWorking}
>
<RefreshCw size={16} />
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isWorking}
>
<RotateCcw size={16} />
</Button>
</div>
</div>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
"projection 상태를 불러오지 못했습니다."}
</section>
) : null}
{actionResult ? (
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{actionResult.syncedUsers} projection을 .
</section>
) : null}
{actionError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(actionError as Error)?.message || "projection 작업에 실패했습니다."}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 border-b border-border pb-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
<Database size={18} />
</div>
<div>
<h3 className="text-base font-semibold">Kratos users projection</h3>
<p className="text-sm text-muted-foreground">
Backend DB read model .
</p>
</div>
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground"> </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>
<dd className="mt-1">
<ProjectionStatusBadge
ready={data?.ready ?? false}
status={data?.status ?? "unknown"}
/>
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
Projection
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.projectedUsers ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.lastSyncedAt)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.updatedAt)}
</dd>
</div>
</dl>
)}
{data?.lastError ? (
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
<span>{data.lastError}</span>
</div>
) : null}
</section>
</main>
);
}
export default function UserProjectionPage() {
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"> </h2>
<p className="mt-2 text-sm text-muted-foreground">
super_admin .
</p>
</section>
</main>
}
>
<UserProjectionContent />
</RoleGuard>
);
}