diff --git a/.gitea/workflows/staging_code_pull.yml b/.gitea/workflows/staging_code_pull.yml
index 6b971386..f648ada4 100644
--- a/.gitea/workflows/staging_code_pull.yml
+++ b/.gitea/workflows/staging_code_pull.yml
@@ -80,7 +80,6 @@ jobs:
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
- ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }}
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
@@ -143,10 +142,6 @@ jobs:
LOKI_URL=${{ vars.LOKI_URL || 'http://loki:3100/loki/api/v1/push' }}
EOF
- if ! grep -Eq "^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.+" .env; then
- sed -i "s/^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.*/ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600/" .env
- fi
-
# 코드 업데이트 (Git)
ssh "${STAGE_USER}@${STAGE_HOST}" "mkdir -p '${DEPLOY_PATH}' && cd '${DEPLOY_PATH}' && \
if [ ! -d .git ]; then
diff --git a/.gitea/workflows/staging_release.yml b/.gitea/workflows/staging_release.yml
index d15d1338..fa1c9eba 100644
--- a/.gitea/workflows/staging_release.yml
+++ b/.gitea/workflows/staging_release.yml
@@ -90,7 +90,6 @@ jobs:
AUDIT_WORKER_COUNT=5
AUDIT_QUEUE_SIZE=2000
PROFILE_CACHE_TTL=${{ vars.PROFILE_CACHE_TTL }}
- ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }}
NAVER_CLOUD_ACCESS_KEY=${{ vars.NAVER_CLOUD_ACCESS_KEY }}
NAVER_CLOUD_SECRET_KEY=${{ secrets.NAVER_CLOUD_SECRET_KEY }}
NAVER_CLOUD_SERVICE_ID=${{ vars.NAVER_CLOUD_SERVICE_ID }}
@@ -143,16 +142,11 @@ jobs:
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }}
EOF
- if ! grep -Eq "^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.+" .env; then
- sed -i "s/^ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=.*/ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600/" .env
- fi
-
required_dotenv_keys="
APP_ENV BACKEND_LOG_LEVEL CLIENT_LOG_DEBUG WORKS_ADMIN_API_BASE_URL WORKS_ADMIN_OAUTH_TOKEN_URL TZ IDP_PROVIDER
DB_PORT CLICKHOUSE_PORT_HTTP CLICKHOUSE_PORT_NATIVE CLICKHOUSE_HOST CLICKHOUSE_USER CLICKHOUSE_PASSWORD
BACKEND_PORT ADMINFRONT_PORT DEVFRONT_PORT ORGFRONT_PORT USERFRONT_PORT OATHKEEPER_API_URL
DB_USER DB_PASSWORD DB_NAME COOKIE_SECRET JWT_SECRET REDIS_ADDR CORS_ALLOWED_ORIGINS PROFILE_CACHE_TTL
- ORGFRONT_ORGCHART_CACHE_TTL_SECONDS
NAVER_CLOUD_ACCESS_KEY NAVER_CLOUD_SECRET_KEY NAVER_CLOUD_SERVICE_ID NAVER_SENDER_PHONE_NUMBER
AWS_REGION AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SES_SENDER ADMIN_EMAIL ADMIN_PASSWORD
USERFRONT_URL ORGFRONT_URL BACKEND_PUBLIC_URL BACKEND_URL OATHKEEPER_PUBLIC_URL
diff --git a/adminfront/Trace-20260615T113806.json.gz b/adminfront/Trace-20260615T113806.json.gz
new file mode 100644
index 00000000..e15ba30b
Binary files /dev/null and b/adminfront/Trace-20260615T113806.json.gz differ
diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx
index 339a14d7..8d72cf88 100644
--- a/adminfront/src/app/routes.tsx
+++ b/adminfront/src/app/routes.tsx
@@ -9,8 +9,8 @@ import AuthGuard from "../features/auth/AuthGuard";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import DataIntegrityPage from "../features/integrity/DataIntegrityPage";
+import OrySSOTPage from "../features/ory-ssot/OrySSOTPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
-import UserProjectionPage from "../features/projections/UserProjectionPage";
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
@@ -67,7 +67,7 @@ export const adminRoutes: RouteObject[] = [
},
{ path: "api-keys", element: },
{ path: "api-keys/new", element: },
- { path: "system/ory-ssot", element: },
+ { path: "system/ory-ssot", element: },
{ path: "system/data-integrity", element: },
],
},
diff --git a/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx b/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx
index 24e945d8..c1ab9f60 100644
--- a/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx
+++ b/adminfront/src/components/common/LocaleRefreshBoundary.test.tsx
@@ -31,4 +31,27 @@ describe("LocaleRefreshBoundary", () => {
expect(screen.getByText("2")).toBeInTheDocument();
});
+
+ it("ignores storage events unrelated to locale changes", async () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("1")).toBeInTheDocument();
+
+ await act(async () => {
+ window.dispatchEvent(
+ new StorageEvent("storage", {
+ key: "admin_session",
+ newValue: "token",
+ oldValue: null,
+ storageArea: window.localStorage,
+ }),
+ );
+ });
+
+ expect(screen.getByText("1")).toBeInTheDocument();
+ });
});
diff --git a/adminfront/src/components/common/LocaleRefreshBoundary.tsx b/adminfront/src/components/common/LocaleRefreshBoundary.tsx
index 64cc3841..370bed50 100644
--- a/adminfront/src/components/common/LocaleRefreshBoundary.tsx
+++ b/adminfront/src/components/common/LocaleRefreshBoundary.tsx
@@ -1,4 +1,5 @@
import { Fragment, type ReactNode, useEffect, useState } from "react";
+import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
type LocaleRefreshBoundaryProps = {
children: ReactNode;
@@ -12,12 +13,19 @@ function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) {
setLocaleVersion((current) => current + 1);
};
+ const syncLocaleFromStorage = (event: StorageEvent) => {
+ if (event.key !== LOCALE_STORAGE_KEY && event.key !== null) {
+ return;
+ }
+ syncLocale();
+ };
+
window.addEventListener("localechange", syncLocale);
- window.addEventListener("storage", syncLocale);
+ window.addEventListener("storage", syncLocaleFromStorage);
return () => {
window.removeEventListener("localechange", syncLocale);
- window.removeEventListener("storage", syncLocale);
+ window.removeEventListener("storage", syncLocaleFromStorage);
};
}, []);
diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx
index 43d43135..14b12389 100644
--- a/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx
+++ b/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx
@@ -74,7 +74,7 @@ describe("ApiKeyListPage", () => {
});
it("updates scopes without changing client_id", async () => {
- const user = userEvent.setup();
+ const user = userEvent.setup({ delay: null });
renderPage();
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
@@ -88,7 +88,7 @@ describe("ApiKeyListPage", () => {
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
});
});
- });
+ }, 15_000);
it("rotates only the secret and shows the one-time secret", async () => {
const user = userEvent.setup();
diff --git a/adminfront/src/features/coverage/adminPageAnimationPolicy.test.ts b/adminfront/src/features/coverage/adminPageAnimationPolicy.test.ts
new file mode 100644
index 00000000..6cf89235
--- /dev/null
+++ b/adminfront/src/features/coverage/adminPageAnimationPolicy.test.ts
@@ -0,0 +1,33 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+
+function listSourceFiles(directory: string): string[] {
+ const entries = readdirSync(directory);
+ const files: string[] = [];
+ for (const entry of entries) {
+ const path = join(directory, entry);
+ const stat = statSync(path);
+ if (stat.isDirectory()) {
+ files.push(...listSourceFiles(path));
+ continue;
+ }
+ if (path.endsWith(".tsx")) {
+ files.push(path);
+ }
+ }
+ return files;
+}
+
+describe("admin page animation policy", () => {
+ it("does not use long enter fade animations on stable page containers", () => {
+ const sourceRoot = join(process.cwd(), "src");
+ const offenders = listSourceFiles(sourceRoot).filter((file) =>
+ readFileSync(file, "utf8").includes("animate-in fade-in duration-500"),
+ );
+
+ expect(offenders.map((file) => file.replace(`${sourceRoot}/`, ""))).toEqual(
+ [],
+ );
+ });
+});
diff --git a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx
index 49de2a55..0405ce6f 100644
--- a/adminfront/src/features/integrity/DataIntegrityPage.test.tsx
+++ b/adminfront/src/features/integrity/DataIntegrityPage.test.tsx
@@ -6,8 +6,6 @@ import {
fetchDataIntegrityReport,
fetchMe,
fetchOrphanUserLoginIDs,
- fetchOrySSOTSystemStatus,
- flushIdentityCache,
} from "../../lib/adminApi";
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
import { createI18nMock } from "../../test/i18nMock";
@@ -63,21 +61,6 @@ vi.mock("../../lib/adminApi", () => ({
],
total: 1,
})),
- fetchOrySSOTSystemStatus: vi.fn(async () => ({
- identityCache: {
- status: "ready",
- redisReady: true,
- observedCount: 151,
- keyCount: 153,
- lastRefreshedAt: "2026-05-11T03:00:00Z",
- updatedAt: "2026-05-11T03:00:10Z",
- },
- })),
- flushIdentityCache: vi.fn(async () => ({
- status: "success",
- flushedKeys: 153,
- updatedAt: "2026-05-11T03:02:00Z",
- })),
deleteOrphanUserLoginIDs: vi.fn(async () => ({
deletedCount: 1,
deleted: [
@@ -121,12 +104,6 @@ describe("DataIntegrityPage", () => {
renderPage();
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
- expect(
- screen.getByRole("tab", { name: "정합성 검사" }),
- ).toBeInTheDocument();
- expect(
- screen.getByRole("tab", { name: "Ory SSOT 시스템" }),
- ).toBeInTheDocument();
expect(
await screen.findByText(
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
@@ -138,28 +115,6 @@ describe("DataIntegrityPage", () => {
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
- it("renders Ory SSOT cache management inside data integrity", async () => {
- renderPage();
-
- fireEvent.click(
- await screen.findByRole("tab", { name: "Ory SSOT 시스템" }),
- );
-
- expect(
- (await screen.findAllByText("Ory SSOT 시스템")).length,
- ).toBeGreaterThan(0);
- expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
- expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
- expect(screen.getByText("151")).toBeInTheDocument();
- expect(screen.queryByText("Local users")).not.toBeInTheDocument();
-
- fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
- await waitFor(() => {
- expect(flushIdentityCache).toHaveBeenCalledTimes(1);
- });
- expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
- });
-
it("shows orphan login ID targets and deletes selected rows", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
const { container } = renderPage();
diff --git a/adminfront/src/features/integrity/DataIntegrityPage.tsx b/adminfront/src/features/integrity/DataIntegrityPage.tsx
index 0159aea0..bb75cb55 100644
--- a/adminfront/src/features/integrity/DataIntegrityPage.tsx
+++ b/adminfront/src/features/integrity/DataIntegrityPage.tsx
@@ -19,7 +19,6 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
-import { UserProjectionContent } from "../projections/UserProjectionPage";
function statusLabel(status: DataIntegrityStatus) {
switch (status) {
@@ -188,14 +187,6 @@ function recheckStatusText(status: "idle" | "running" | "success" | "error") {
}
}
-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,
@@ -294,9 +285,6 @@ function OrphanLoginIDTable({
function DataIntegrityContent() {
const queryClient = useQueryClient();
- const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
- "integrity",
- );
const [selectedOrphanIds, setSelectedOrphanIds] = useState([]);
const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error"
@@ -373,243 +361,210 @@ function DataIntegrityContent() {
- {activeTab === "integrity" ? (
-
-
- {recheckMessage ? (
-
- ) : null}
-
- ) : null}
+ {recheckMessage}
+
+ ) : null}
+
-
- setActiveTab("integrity")}
- >
- {t("ui.admin.integrity.tab_checks", "정합성 검사")}
-
- setActiveTab("projection")}
- >
- {t("ui.admin.integrity.tab_ory_ssot", "Ory SSOT 시스템")}
-
-
-
- {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)}
-
-
-
- )}
+
+ {isError ? (
+
+ {(error as Error)?.message ||
+ t(
+ "msg.admin.integrity.report.load_error",
+ "정합성 리포트를 불러오지 못했습니다.",
+ )}
+ ) : null}
-
- {(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.read_model.title",
+ "Read model integrity",
+ )}
+
+
+ {t(
+ "msg.admin.integrity.read_model.description",
+ "Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
+ )}
+
+
+ {data ? (
+
+ {statusLabel(data.status)}
+
+ ) : null}
-
-
-
-
- {t(
- "ui.admin.integrity.orphan_login_ids.title",
- "유령 로그인 ID 정리",
- )}
-
-
- {t(
- "msg.admin.integrity.orphan_login_ids.description",
- "삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
- )}
-
-
-
- {t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
-
+ {isLoading ? (
+
+ {t("ui.admin.integrity.loading", "불러오는 중")}
- {orphanLoginIDsQuery.isError ? (
-
- {t(
- "msg.admin.integrity.orphan_login_ids.load_error",
- "유령 로그인 ID 대상을 불러오지 못했습니다.",
- )}
+ ) : (
+
+
+
-
+ {t("ui.admin.integrity.summary.total_checks", "검사 항목")}
+
+ -
+ {data?.summary.totalChecks ?? 0}
+
- ) : null}
- {deleteMutation.data ? (
-
- {t(
- "msg.admin.integrity.orphan_login_ids.delete_success",
- "{{count}}개의 유령 로그인 ID를 삭제했습니다.",
- { count: deleteMutation.data.deletedCount },
- )}
+
+
-
+ {t("ui.admin.integrity.summary.passed", "정상")}
+
+ -
+ {data?.summary.passed ?? 0}
+
- ) : null}
-
-
+
+
-
+ {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를 확인한 뒤 선택 삭제합니다.",
+ )}
+
+
+
+ {t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
+
+
+ {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}
+
+
+
);
}
diff --git a/adminfront/src/features/projections/UserProjectionPage.test.tsx b/adminfront/src/features/ory-ssot/OrySSOTPage.test.tsx
similarity index 57%
rename from adminfront/src/features/projections/UserProjectionPage.test.tsx
rename to adminfront/src/features/ory-ssot/OrySSOTPage.test.tsx
index 064d199a..65503e53 100644
--- a/adminfront/src/features/projections/UserProjectionPage.test.tsx
+++ b/adminfront/src/features/ory-ssot/OrySSOTPage.test.tsx
@@ -2,11 +2,12 @@ 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 {
+ fetchMe,
fetchOrySSOTSystemStatus,
flushIdentityCache,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
-import UserProjectionPage from "./UserProjectionPage";
+import OrySSOTPage from "./OrySSOTPage";
vi.mock("../../lib/i18n", () => createI18nMock());
@@ -19,9 +20,9 @@ vi.mock("../../lib/adminApi", () => ({
status: "ready",
redisReady: true,
observedCount: 151,
+ keyCount: 153,
lastRefreshedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
- keyCount: 153,
},
})),
flushIdentityCache: vi.fn(async () => ({
@@ -41,12 +42,12 @@ function renderPage() {
return render(
-
+
,
);
}
-describe("UserProjectionPage", () => {
+describe("OrySSOTPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
@@ -54,37 +55,22 @@ describe("UserProjectionPage", () => {
window.localStorage.setItem("locale", "ko");
});
- it("renders Ory SSOT and Redis identity cache status for super_admin", async () => {
+ it("renders identity cache status and flushes cache", async () => {
renderPage();
- expect(await screen.findByText("Ory SSOT 시스템")).toBeInTheDocument();
expect(
- await screen.findByText(
- "Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
- ),
- ).toBeInTheDocument();
+ (await screen.findAllByText("Ory SSOT 시스템")).length,
+ ).toBeGreaterThan(0);
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
- expect(screen.getByText("관측 identity")).toBeInTheDocument();
expect(screen.getByText("151")).toBeInTheDocument();
- expect(screen.queryByText("Local users")).not.toBeInTheDocument();
- expect(screen.queryByText("Backend 사용자 read model")).not.toBeInTheDocument();
- expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
- });
- it("flushes only the Redis identity cache for super_admin", async () => {
- renderPage();
-
- 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(flushIdentityCache).toHaveBeenCalledTimes(1);
});
+ expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
it("blocks non-super admins", async () => {
@@ -93,21 +79,7 @@ describe("UserProjectionPage", () => {
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
- expect(screen.queryByText("Ory SSOT 시스템")).not.toBeInTheDocument();
+ expect(fetchMe).toHaveBeenCalled();
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(
- "Review Kratos source-of-truth and Redis identity cache status separately.",
- ),
- ).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/ory-ssot/OrySSOTPage.tsx
similarity index 80%
rename from adminfront/src/features/projections/UserProjectionPage.tsx
rename to adminfront/src/features/ory-ssot/OrySSOTPage.tsx
index 8e663c00..bc0eb48f 100644
--- a/adminfront/src/features/projections/UserProjectionPage.tsx
+++ b/adminfront/src/features/ory-ssot/OrySSOTPage.tsx
@@ -42,11 +42,7 @@ function StatusBadge({ ready, status }: { ready: boolean; status: string }) {
);
}
-export function UserProjectionContent({
- embedded = false,
-}: {
- embedded?: boolean;
-}) {
+function OrySSOTContent() {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["ory-ssot-system-status"],
@@ -74,47 +70,39 @@ export function UserProjectionContent({
const identityCache = data?.identityCache;
- const header = (
-