setActiveTab("projection")}
>
- {t("ui.admin.integrity.tab_user_projection", "사용자 동기화")}
+ {t("ui.admin.integrity.tab_ory_ssot", "Ory SSOT 시스템")}
diff --git a/adminfront/src/features/projections/UserProjectionPage.test.tsx b/adminfront/src/features/projections/UserProjectionPage.test.tsx
index 2ec44387..1c95ebbe 100644
--- a/adminfront/src/features/projections/UserProjectionPage.test.tsx
+++ b/adminfront/src/features/projections/UserProjectionPage.test.tsx
@@ -2,9 +2,8 @@ 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,
+ fetchOrySSOTSystemStatus,
+ flushIdentityCache,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import UserProjectionPage from "./UserProjectionPage";
@@ -15,22 +14,27 @@ 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,
+ fetchOrySSOTSystemStatus: vi.fn(async () => ({
+ userProjection: {
+ name: "kratos_users",
+ status: "ready",
+ ready: true,
+ lastSyncedAt: "2026-05-11T03:00:00Z",
+ updatedAt: "2026-05-11T03:00:10Z",
+ projectedUsers: 152,
+ },
+ identityCache: {
+ status: "ready",
+ redisReady: true,
+ observedCount: 151,
+ lastRefreshedAt: "2026-05-11T03:00:00Z",
+ updatedAt: "2026-05-11T03:00:10Z",
+ keyCount: 153,
+ },
})),
- reconcileUserProjection: vi.fn(async () => ({
+ flushIdentityCache: vi.fn(async () => ({
status: "success",
- syncedUsers: 152,
- updatedAt: "2026-05-11T03:01:00Z",
- })),
- resetUserProjection: vi.fn(async () => ({
- status: "success",
- syncedUsers: 152,
+ flushedKeys: 153,
updatedAt: "2026-05-11T03:02:00Z",
})),
}));
@@ -58,35 +62,33 @@ describe("UserProjectionPage", () => {
window.localStorage.setItem("locale", "ko");
});
- it("renders projection status for super_admin", async () => {
+ it("renders Ory SSOT and Redis identity cache status for super_admin", async () => {
renderPage();
- expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
+ expect(await screen.findByText("Ory SSOT 시스템")).toBeInTheDocument();
expect(
await screen.findByText(
- "Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
+ "Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
),
).toBeInTheDocument();
- expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
- expect(screen.getByText("준비됨")).toBeInTheDocument();
+ expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
+ expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
+ expect(screen.getByText("관측 identity")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
- expect(fetchUserProjectionStatus).toHaveBeenCalled();
+ expect(screen.getByText("151")).toBeInTheDocument();
+ expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
- it("runs reconcile and reset actions for super_admin", async () => {
+ it("flushes only the Redis identity cache for super_admin", async () => {
renderPage();
- await screen.findByText("사용자 동기화 관리");
- fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
+ await screen.findByText("Ory SSOT 시스템");
+ expect(screen.queryByRole("button", { name: /재동기화/ })).toBeNull();
+ expect(screen.queryByRole("button", { name: /초기화 후 재구축/ })).toBeNull();
+ fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
await waitFor(() => {
- expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
- });
-
- fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
-
- await waitFor(() => {
- expect(resetUserProjection).toHaveBeenCalledTimes(1);
+ expect(flushIdentityCache).toHaveBeenCalledTimes(1);
});
});
@@ -96,21 +98,21 @@ describe("UserProjectionPage", () => {
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
- expect(screen.queryByText("사용자 동기화 관리")).not.toBeInTheDocument();
- expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
+ expect(screen.queryByText("Ory SSOT 시스템")).not.toBeInTheDocument();
+ expect(fetchOrySSOTSystemStatus).not.toHaveBeenCalled();
});
it("renders localized labels in English", async () => {
window.localStorage.setItem("locale", "en");
renderPage();
+ expect(await screen.findByText("Ory SSOT System")).toBeInTheDocument();
expect(
- await screen.findByText("User Projection Management"),
+ await screen.findByText(
+ "Review Kratos source-of-truth and Redis identity cache status separately.",
+ ),
).toBeInTheDocument();
- expect(
- await screen.findByText("Review and sync the Kratos user read model."),
- ).toBeInTheDocument();
- expect(screen.getByText("Re-sync")).toBeInTheDocument();
- expect(await screen.findByText("ready")).toBeInTheDocument();
+ expect(screen.getByText("Redis cache flush")).toBeInTheDocument();
+ expect((await screen.findAllByText("ready")).length).toBeGreaterThan(0);
});
});
diff --git a/adminfront/src/features/projections/UserProjectionPage.tsx b/adminfront/src/features/projections/UserProjectionPage.tsx
index d1f27743..c53fe0bb 100644
--- a/adminfront/src/features/projections/UserProjectionPage.tsx
+++ b/adminfront/src/features/projections/UserProjectionPage.tsx
@@ -1,56 +1,43 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { AlertTriangle, RefreshCw, RotateCcw, Users } from "lucide-react";
+import { AlertTriangle, Database, Trash2 } 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,
+ fetchOrySSOTSystemStatus,
+ flushIdentityCache,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
function formatDateTime(value?: string) {
- if (!value) {
- return "-";
- }
+ if (!value) return "-";
const date = new Date(value);
- if (Number.isNaN(date.getTime())) {
- return value;
- }
+ if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(getAdminDateLocale(), {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
}
-function ProjectionStatusBadge({
- ready,
- status,
-}: {
- ready: boolean;
- status: string;
-}) {
+function StatusBadge({ ready, status }: { ready: boolean; status: string }) {
if (ready) {
return (
- {t("ui.admin.user_projection.status.ready", "ready")}
+ {t("ui.admin.ory_ssot.status.ready", "ready")}
);
}
if (status === "failed") {
return (
- {t("ui.admin.user_projection.status.failed", "failed")}
+ {t("ui.admin.ory_ssot.status.failed", "failed")}
);
}
return (
- {status
- ? status
- : t("ui.admin.user_projection.status.not_ready", "not ready")}
+ {status ? status : t("ui.admin.ory_ssot.status.not_ready", "not ready")}
);
}
@@ -62,41 +49,31 @@ export function UserProjectionContent({
}) {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
- queryKey: ["user-projection-status"],
- queryFn: fetchUserProjectionStatus,
+ queryKey: ["ory-ssot-system-status"],
+ queryFn: fetchOrySSOTSystemStatus,
});
- const invalidate = async () => {
- await queryClient.invalidateQueries({
- queryKey: ["user-projection-status"],
- });
- };
-
- const reconcileMutation = useMutation({
- mutationFn: reconcileUserProjection,
- onSuccess: invalidate,
+ const flushMutation = useMutation({
+ mutationFn: flushIdentityCache,
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: ["ory-ssot-system-status"],
+ });
+ },
});
- const resetMutation = useMutation({
- mutationFn: resetUserProjection,
- onSuccess: invalidate,
- });
-
- const handleReset = () => {
+ const handleFlush = () => {
const confirmed = window.confirm(
t(
- "msg.admin.user_projection.reset_confirm",
- "Rebuild user projection from the Kratos source of truth?",
+ "msg.admin.ory_ssot.flush_confirm",
+ "Flush only Redis identity cache keys?",
),
);
- if (confirmed) {
- resetMutation.mutate();
- }
+ if (confirmed) flushMutation.mutate();
};
- const isWorking = reconcileMutation.isPending || resetMutation.isPending;
- const actionResult = reconcileMutation.data ?? resetMutation.data;
- const actionError = reconcileMutation.error ?? resetMutation.error;
+ const projection = data?.userProjection;
+ const identityCache = data?.identityCache;
const header = (
-
+
- {t("ui.admin.user_projection.title", "User Projection Management")}
+ {t("ui.admin.ory_ssot.title", "Ory SSOT System")}
{t(
- "msg.admin.user_projection.subtitle",
- "Review and sync the Kratos user read model.",
+ "msg.admin.ory_ssot.subtitle",
+ "Review Kratos source-of-truth and Redis identity cache status separately.",
)}
-
-
-
-
+
);
@@ -151,28 +120,28 @@ export function UserProjectionContent({
{(error as Error)?.message ||
t(
- "msg.admin.user_projection.load_error",
- "Failed to load projection status.",
+ "msg.admin.ory_ssot.load_error",
+ "Failed to load Ory SSOT system status.",
)}
) : null}
- {actionResult ? (
+ {flushMutation.data ? (
{t(
- "msg.admin.user_projection.action_success",
- "Refreshed the projection for {{count}} users.",
- { count: actionResult.syncedUsers },
+ "msg.admin.ory_ssot.flush_success",
+ "Flushed {{count}} Redis identity cache keys.",
+ { count: flushMutation.data.flushedKeys },
)}
) : null}
- {actionError ? (
+ {flushMutation.error ? (
- {(actionError as Error)?.message ||
+ {(flushMutation.error as Error)?.message ||
t(
- "msg.admin.user_projection.action_error",
- "Projection operation failed.",
+ "msg.admin.ory_ssot.flush_error",
+ "Redis identity cache flush failed.",
)}
) : null}
@@ -180,16 +149,16 @@ export function UserProjectionContent({
-
+
{t(
- "ui.admin.user_projection.card.title",
- "Kratos users projection",
+ "ui.admin.ory_ssot.projection_card.title",
+ "Backend user read model",
)}
{t(
- "ui.admin.user_projection.card.description",
- "Current user read model state referenced by backend DB statistics.",
+ "ui.admin.ory_ssot.projection_card.description",
+ "PostgreSQL read model status used by admin search and statistics.",
)}
@@ -197,58 +166,131 @@ export function UserProjectionContent({
{isLoading ? (
- {t("ui.admin.user_projection.loading", "Loading")}
+ {t("ui.admin.ory_ssot.loading", "Loading")}
) : (
-
- {t("ui.admin.user_projection.summary.status", "Status")}
+ {t("ui.admin.ory_ssot.summary.status", "Status")}
-
-
+
+
+
+ -
+ {t("ui.admin.ory_ssot.summary.local_users", "Local users")}
+
+ -
+ {projection?.projectedUsers ?? 0}
+
+
+
+ -
+ {t(
+ "ui.admin.ory_ssot.summary.last_synced",
+ "Last read-model refresh",
+ )}
+
+ -
+ {formatDateTime(projection?.lastSyncedAt)}
+
+
+
+ -
+ {t("ui.admin.ory_ssot.summary.updated_at", "Updated at")}
+
+ -
+ {formatDateTime(projection?.updatedAt)}
+
+
+
+ )}
+
+ {projection?.lastError ? (
+
+
+ {projection.lastError}
+
+ ) : null}
+
+
+
+
+
+
+ {t("ui.admin.ory_ssot.cache_card.title", "Redis identity cache")}
+
+
+ {t(
+ "ui.admin.ory_ssot.cache_card.description",
+ "Redis mirror/cache status for Kratos identity list and lookup operations.",
+ )}
+
+
+
+
+ {isLoading ? (
+
+ {t("ui.admin.ory_ssot.loading", "Loading")}
+
+ ) : (
+
+
+ -
+ {t("ui.admin.ory_ssot.summary.status", "Status")}
+
+ -
+
-
{t(
- "ui.admin.user_projection.summary.projected_users",
- "Projected users",
+ "ui.admin.ory_ssot.summary.observed_identities",
+ "Observed identities",
)}
-
- {data?.projectedUsers ?? 0}
+ {identityCache?.observedCount ?? 0}
+
+
+
+ -
+ {t("ui.admin.ory_ssot.summary.cache_keys", "Cache keys")}
+
+ -
+ {identityCache?.keyCount ?? 0}
-
{t(
- "ui.admin.user_projection.summary.last_synced",
- "Last synced",
+ "ui.admin.ory_ssot.summary.last_refreshed",
+ "Last refreshed",
)}
-
- {formatDateTime(data?.lastSyncedAt)}
-
-
-
- -
- {t("ui.admin.user_projection.summary.updated_at", "Updated at")}
-
- -
- {formatDateTime(data?.updatedAt)}
+ {formatDateTime(identityCache?.lastRefreshedAt)}
)}
- {data?.lastError ? (
+ {identityCache?.lastError ? (
- {data.lastError}
+ {identityCache.lastError}
) : null}
@@ -280,11 +322,11 @@ export default function UserProjectionPage() {
- {t("ui.admin.user_projection.forbidden.title", "Access denied")}
+ {t("ui.admin.ory_ssot.forbidden.title", "Access denied")}
{t(
- "msg.admin.user_projection.forbidden.description",
+ "msg.admin.ory_ssot.forbidden.description",
"This screen is only available to super_admin users.",
)}
diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx
index fd5df2db..3b8830b7 100644
--- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx
+++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx
@@ -161,6 +161,8 @@ export function ParentTenantSelector({
setLocalSearch(event.target.value)}
diff --git a/adminfront/src/features/tenants/routes/TenantListPage.test.ts b/adminfront/src/features/tenants/routes/TenantListPage.test.ts
index 8ce57dec..7c10f5af 100644
--- a/adminfront/src/features/tenants/routes/TenantListPage.test.ts
+++ b/adminfront/src/features/tenants/routes/TenantListPage.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
filterTenantsByScope,
+ getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
tenantMatchesListSearch,
@@ -69,6 +70,7 @@ describe("TenantListPage tenant list helpers", () => {
expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true);
+ expect(tenantMatchesListSearch(tenants[2], "삼안")).toBe(false);
});
it("can return tree rows or same-level table rows", () => {
@@ -79,4 +81,20 @@ describe("TenantListPage tenant list helpers", () => {
[0, 0, 0, 0],
);
});
+
+ it("marks only direct search matches when tree search includes ancestors", () => {
+ const treeRows = getTenantViewRows(
+ tenants.filter((item) => item.id !== "company-2"),
+ "tree",
+ "",
+ true,
+ );
+
+ expect(treeRows.map((row) => row.id)).toEqual([
+ "company-1",
+ "dept-1",
+ "team-1",
+ ]);
+ expect(getTenantSearchMatchIds(treeRows, "platform")).toEqual(["team-1"]);
+ });
});
diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx
index 8f9900fe..40fa5418 100644
--- a/adminfront/src/features/tenants/routes/TenantListPage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx
@@ -107,6 +107,7 @@ import {
} from "../utils/tenantCsvImport";
import {
filterTenantsByScope,
+ getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
type TenantViewMode,
@@ -842,6 +843,7 @@ function TenantListPage() {
|