1
0
forked from baron/baron-sso

네이버 계정 정합성 맞춤

This commit is contained in:
2026-06-15 19:54:09 +09:00
parent 8e9d015443
commit 4d468cd39f
97 changed files with 5837 additions and 2031 deletions

Binary file not shown.

View File

@@ -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: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/ory-ssot", element: <UserProjectionPage /> },
{ path: "system/ory-ssot", element: <OrySSOTPage /> },
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
],
},

View File

@@ -31,4 +31,27 @@ describe("LocaleRefreshBoundary", () => {
expect(screen.getByText("2")).toBeInTheDocument();
});
it("ignores storage events unrelated to locale changes", async () => {
render(
<LocaleRefreshBoundary>
<RenderCounter />
</LocaleRefreshBoundary>,
);
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();
});
});

View File

@@ -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);
};
}, []);

View File

@@ -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();

View File

@@ -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(
[],
);
});
});

View File

@@ -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();

View File

@@ -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<string[]>([]);
const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error"
@@ -373,243 +361,210 @@ function DataIntegrityContent() {
</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}
<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"
>
<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}
{recheckMessage}
</output>
) : null}
</div>
</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_ory_ssot", "Ory SSOT 시스템")}
</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>
)}
<div className="space-y-4 pb-6">
{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}
<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>
))}
<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>
<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>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.integrity.loading", "불러오는 중")}
</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 대상을 불러오지 못했습니다.",
)}
) : (
<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>
) : 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>
<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>
) : null}
<OrphanLoginIDTable
items={orphanItems}
selectedIds={selectedOrphanIds}
onToggle={toggleOrphanID}
/>
</section>
<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>
) : (
<div className="animate-in fade-in duration-500">
<UserProjectionContent embedded />
</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>
</main>
);
}

View File

@@ -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(
<QueryClientProvider client={queryClient}>
<UserProjectionPage />
<OrySSOTPage />
</QueryClientProvider>,
);
}
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);
});
});

View File

@@ -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 = (
<header
className={
embedded
? "flex flex-shrink-0 flex-wrap items-start justify-between gap-4"
: "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} />
return (
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<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.ory_ssot.title", "Ory SSOT System")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.ory_ssot.subtitle",
"Review Kratos source-of-truth and Redis identity cache status separately.",
)}
</p>
</div>
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.ory_ssot.subtitle",
"Review Kratos source-of-truth and Redis identity cache status separately.",
)}
</p>
</div>
</div>
<Button
type="button"
variant="destructive"
onClick={handleFlush}
disabled={flushMutation.isPending}
>
<Trash2 size={16} />
{t(
"ui.admin.ory_ssot.actions.flush_identity_cache",
"Redis cache flush",
)}
</Button>
</header>
);
<Button
type="button"
variant="destructive"
onClick={handleFlush}
disabled={flushMutation.isPending}
>
<Trash2 size={16} />
{t(
"ui.admin.ory_ssot.actions.flush_identity_cache",
"Redis cache flush",
)}
</Button>
</header>
const body = (
<>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
@@ -220,27 +208,11 @@ export function UserProjectionContent({
</div>
) : null}
</section>
</>
);
if (embedded) {
return (
<div className="space-y-4 pb-6">
{header}
{body}
</div>
);
}
return (
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
{header}
{body}
</main>
);
}
export default function UserProjectionPage() {
export default function OrySSOTPage() {
return (
<RoleGuard
roles={["super_admin"]}
@@ -260,7 +232,7 @@ export default function UserProjectionPage() {
</main>
}
>
<UserProjectionContent />
<OrySSOTContent />
</RoleGuard>
);
}

View File

@@ -506,7 +506,7 @@ function GlobalOverviewPage() {
);
return (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<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">

View File

@@ -46,8 +46,10 @@ describe("ParentTenantSelector picker", () => {
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src");
expect(pickerSrc).toContain("/login");
const pickerSrc = screen
.getByTestId("parent-tenant-picker-frame")
.getAttribute("src");
expect(pickerSrc).toContain("http://localhost:5175/login");
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
fireEvent(
@@ -71,6 +73,30 @@ describe("ParentTenantSelector picker", () => {
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
});
it("scopes the org-chart picker to the requested tenant root", () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
orgChartTenantId="group-1"
orgChartPickerLabel="한맥가족에서 선택"
/>,
);
fireEvent.click(screen.getByRole("button", { name: "한맥가족에서 선택" }));
const pickerSrc = screen
.getByTestId("parent-tenant-picker-frame")
.getAttribute("src");
expect(decodeURIComponent(pickerSrc ?? "")).toContain("tenantId=group-1");
});
it("keeps the current tenant out of picker message selections", async () => {
const onChange = vi.fn();

View File

@@ -31,6 +31,7 @@ type ParentTenantSelectorProps = {
labelAction?: ReactNode;
contextLabel?: string;
orgChartPickerLabel?: string;
orgChartTenantId?: string;
localPickerLabel?: string;
localTenantFilter?: (tenant: TenantSummary) => boolean;
compact?: boolean;
@@ -49,6 +50,7 @@ export function ParentTenantSelector({
labelAction,
contextLabel,
orgChartPickerLabel,
orgChartTenantId,
localPickerLabel,
localTenantFilter,
compact = false,
@@ -66,6 +68,9 @@ export function ParentTenantSelector({
);
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
{
tenantId: orgChartTenantId,
},
);
useEffect(() => {
@@ -135,6 +140,7 @@ export function ParentTenantSelector({
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
data-testid="parent-tenant-picker-frame"
/>
</DialogContent>
</Dialog>

View File

@@ -61,6 +61,13 @@ function TenantCreatePage() {
});
const tenants = parentQuery.data?.items ?? [];
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
const hanmacFamilyTenantId = useMemo(
() =>
tenants.find(
(tenant) => tenant.slug.trim().toLowerCase() === "hanmac-family",
)?.id ?? "",
[tenants],
);
const canConfigureHanmacOrg = useMemo(() => {
if (!selectedParentTenant) return false;
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
@@ -206,6 +213,7 @@ function TenantCreatePage() {
"ui.admin.tenants.create.form.pick_hanmac_parent",
"한맥가족에서 선택",
)}
orgChartTenantId={hanmacFamilyTenantId}
localPickerLabel={t(
"ui.admin.tenants.create.form.pick_other_parent",
"다른 테넌트 선택",

View File

@@ -125,7 +125,7 @@ function TenantDetailPage() {
</div>
{/* Outlet for nested routes */}
<div className="animate-in fade-in duration-500">
<div>
<Outlet />
</div>
</div>

View File

@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
buildWorksmobilePasswordManageUrl,
@@ -28,6 +30,18 @@ import {
} from "./worksmobileComparison";
describe("TenantWorksmobilePage comparison helpers", () => {
it("does not apply page-level enter animations to Worksmobile tab panels", () => {
const source = readFileSync(
join(
process.cwd(),
"src/features/tenants/routes/TenantWorksmobilePage.tsx",
),
"utf8",
);
expect(source).not.toContain("space-y-4 animate-in fade-in duration-500");
});
it("summarizes comparison rows by status", () => {
const summary = summarizeWorksmobileComparison([
{ resourceType: "USER", status: "matched" },
@@ -594,6 +608,41 @@ describe("TenantWorksmobilePage comparison helpers", () => {
]);
});
it("formats backend update reasons when value diff details are not directly visible", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
baronName: "신현우",
worksmobileName: "신현우",
baronEmail: "hwshin2@hanmaceng.co.kr",
worksmobileEmail: "hwshin2@hanmaceng.co.kr",
externalKey: "user-1",
updateReasons: ["organization"],
}),
).toEqual(["조직: Baron 소속 정보를 WORKS에 반영해야 합니다."]);
});
it("does not format phone update details for spaced Korean country code formatting only", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "USER",
status: "needs_update",
baronId: "user-1",
baronName: "강명진",
worksmobileName: "강명진",
baronEmail: "mjkang4@hanmaceng.co.kr",
worksmobileEmail: "mjkang4@hanmaceng.co.kr",
externalKey: "user-1",
baronPhone: "+821041585840",
worksmobilePhone: "+82 1041585840",
baronEmployeeNumber: "mjkang4",
worksmobileEmployeeNumber: "M17205",
}),
).toEqual(["사번: M17205 -> mjkang4"]);
});
it("formats WORKS account name with level on one line", () => {
expect(
formatWorksmobilePersonName({

View File

@@ -520,7 +520,7 @@ export function TenantWorksmobilePage() {
</div>
{activeTab === "history" ? (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="space-y-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
@@ -627,7 +627,7 @@ export function TenantWorksmobilePage() {
) : null}
{activeTab === "users" ? (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="space-y-4">
<ComparisonSummary
title={t(
"ui.admin.tenants.worksmobile.compare",
@@ -715,7 +715,7 @@ export function TenantWorksmobilePage() {
) : null}
{activeTab === "groups" ? (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="space-y-4">
<ComparisonSummary
title={t(
"ui.admin.tenants.worksmobile.compare_groups",

View File

@@ -374,28 +374,39 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
}
const details: string[] = [];
const renderedReasons = new Set<string>();
const addDetail = (reason: string, detail: string) => {
details.push(detail);
renderedReasons.add(reason);
};
const baronName = row.baronName?.trim();
const worksmobileName = row.worksmobileName?.trim();
if (baronName && worksmobileName && baronName !== worksmobileName) {
details.push(`이름: ${worksmobileName} -> ${baronName}`);
addDetail("name", `이름: ${worksmobileName} -> ${baronName}`);
}
if (row.resourceType === "USER") {
const expectedExternalKey = row.baronId?.trim() ?? "";
const actualExternalKey = row.externalKey?.trim() ?? "";
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
details.push(
addDetail(
"external_key",
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
);
}
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
details.push(`이메일: ${actualEmail} -> ${expectedEmail}`);
addDetail("email", `이메일: ${actualEmail} -> ${expectedEmail}`);
}
const expectedPhone = row.baronPhone?.trim() ?? "";
const actualPhone = row.worksmobilePhone?.trim() ?? "";
if (expectedPhone && actualPhone && expectedPhone !== actualPhone) {
details.push(`전화번호: ${actualPhone} -> ${expectedPhone}`);
if (
expectedPhone &&
actualPhone &&
normalizeWorksmobilePhoneForCompare(expectedPhone) !==
normalizeWorksmobilePhoneForCompare(actualPhone)
) {
addDetail("phone", `전화번호: ${actualPhone} -> ${expectedPhone}`);
}
const expectedEmployeeNumber = row.baronEmployeeNumber?.trim() ?? "";
const actualEmployeeNumber = row.worksmobileEmployeeNumber?.trim() ?? "";
@@ -404,10 +415,12 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
actualEmployeeNumber &&
expectedEmployeeNumber !== actualEmployeeNumber
) {
details.push(
addDetail(
"employee_number",
`사번: ${actualEmployeeNumber} -> ${expectedEmployeeNumber}`,
);
}
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
return details;
}
@@ -427,14 +440,86 @@ export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
const actualParentKey =
row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? "";
if (expectedParentKey !== actualParentKey) {
details.push(
addDetail(
"organization",
`상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`,
);
}
appendWorksmobileUpdateReasonFallbacks(details, row, renderedReasons);
return details;
}
function appendWorksmobileUpdateReasonFallbacks(
details: string[],
row: WorksmobileComparisonItem,
renderedReasons: Set<string>,
) {
for (const reason of row.updateReasons ?? []) {
const normalizedReason = reason.trim();
if (!normalizedReason || renderedReasons.has(normalizedReason)) {
continue;
}
const detail = formatWorksmobileUpdateReasonFallback(normalizedReason, row);
if (!detail) {
continue;
}
details.push(detail);
renderedReasons.add(normalizedReason);
}
}
function formatWorksmobileUpdateReasonFallback(
reason: string,
row: WorksmobileComparisonItem,
) {
switch (reason) {
case "name":
return "이름: Baron 사용자명을 WORKS에 반영해야 합니다.";
case "external_key":
return "external_key: Baron 사용자 ID를 WORKS 외부 키로 반영해야 합니다.";
case "email":
return "이메일: Baron 이메일을 WORKS에 반영해야 합니다.";
case "phone":
return "전화번호: Baron 전화번호를 WORKS에 반영해야 합니다.";
case "employee_number":
return "사번: Baron 사번을 WORKS에 반영해야 합니다.";
case "organization":
return row.resourceType === "GROUP"
? "조직: Baron 조직 정보를 WORKS에 반영해야 합니다."
: "조직: Baron 소속 정보를 WORKS에 반영해야 합니다.";
case "manager":
return "조직장: Baron 조직장 설정을 WORKS에 반영해야 합니다.";
default:
return `업데이트 사유: ${reason}`;
}
}
function normalizeWorksmobilePhoneForCompare(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const digits = trimmed.replace(/\D/g, "");
if (!digits) {
return "";
}
if (digits.startsWith("010")) {
return `+82${digits.slice(1)}`;
}
if (digits.startsWith("82")) {
let rest = digits.slice(2);
while (rest.startsWith("82")) {
rest = rest.slice(2);
}
if (rest.startsWith("0")) {
rest = rest.slice(1);
}
return `+82${rest}`;
}
return `+${digits}`;
}
export function buildWorksmobilePasswordManageUrl({
tenantId,
domainId,

View File

@@ -61,6 +61,7 @@ import {
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import type { UserSchemaField } from "./userSchemaFields";
import { resolvePersonalTenant } from "./utils/personalTenant";
@@ -399,7 +400,7 @@ function UserCreatePage() {
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(
err.response?.data?.error ||
formatUserPolicyMessage(err.response?.data?.error) ||
t("msg.admin.users.create.error", "사용자 생성에 실패했습니다."),
);
},
@@ -943,8 +944,12 @@ function UserCreatePage() {
data-testid={`appointment-tenant-picker-${index}`}
>
<Building2 className="mr-2 h-4 w-4 shrink-0" />
<span className="truncate">
{appointment.tenantName || "테넌트 선택"}
<span className="pointer-events-none truncate">
{appointment.tenantName ||
t(
"ui.admin.users.create.form.pick_from_hanmac_family",
"한맥가족에서 선택",
)}
</span>
</Button>
{appointment.tenantSlug && (

View File

@@ -93,6 +93,7 @@ import {
type OrgChartTenantSelection,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import type { UserSchemaField } from "./userSchemaFields";
import {
normalizeUserStatusValue,
@@ -1012,7 +1013,7 @@ function UserDetailPage() {
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
formatUserPolicyMessage(err.response?.data?.error) ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
@@ -1078,6 +1079,7 @@ function UserDetailPage() {
try {
const tenant = await ensurePersonalTenant();
payload.tenantSlug = tenant.slug;
payload.isPrimaryTenant = true;
payload.department = undefined;
payload.grade = undefined;
payload.position = undefined;
@@ -1111,6 +1113,7 @@ function UserDetailPage() {
const primary = appointments.find((a) => a.isPrimary);
if (primary) {
payload.tenantSlug = primary.tenantSlug;
payload.isPrimaryTenant = true;
payload.primaryTenantId = primary.tenantId;
payload.primaryTenantName = primary.tenantName;
metadata.primaryTenantId = primary.tenantId;
@@ -1133,6 +1136,7 @@ function UserDetailPage() {
primaryTenantSlug: primary?.tenantSlug,
};
payload.tenantSlug = primary?.tenantSlug;
payload.isPrimaryTenant = primary ? true : undefined;
payload.primaryTenantId = primary?.tenantId;
payload.primaryTenantName = primary?.tenantName;
}

View File

@@ -139,6 +139,19 @@ describe("UserListPage search rendering", () => {
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
});
it("describes the user list as identity mirror backed, not local DB backed", async () => {
renderUserListPage();
await screen.findByText("User 0");
expect(
screen.getByText(
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
),
).toBeInTheDocument();
expect(screen.queryByText(/Local DB/)).not.toBeInTheDocument();
});
it("keeps rendered row controls below the full 200-user result set", async () => {
renderUserListPage();

View File

@@ -102,6 +102,7 @@ import {
downloadUserTemplate,
UserBulkUploadModal,
} from "./components/UserBulkUploadModal";
import { formatUserPolicyMessage } from "./userPolicyMessages";
import {
normalizeUserStatusValue,
type UserStatusValue,
@@ -652,7 +653,21 @@ function UserListPage() {
const bulkUpdateMutation = useMutation({
mutationFn: bulkUpdateUsers,
onSuccess: () => {
onSuccess: (data) => {
const failed = data.results?.filter((result) => !result.success) ?? [];
if (failed.length > 0) {
toast.error(
t(
"msg.admin.users.bulk.update_partial_error",
"{{count}}명의 사용자 정보 수정에 실패했습니다.",
{ count: failed.length },
),
{
description: formatUserPolicyMessage(failed[0]?.message),
},
);
return;
}
query.refetch();
setSelectedUserIds([]);
setSelectedBulkStatus("");
@@ -725,7 +740,7 @@ function UserListPage() {
}
description={t(
"msg.admin.users.list.subtitle",
"시스템 사용자를 조회하고 관리합니다.",
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
)}
actions={
<>

View File

@@ -35,6 +35,7 @@ import {
type TenantImportPreviewRow,
} from "../../tenants/utils/tenantCsvImport";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
import { formatUserPolicyMessage } from "../userPolicyMessages";
import { parseUserCSV } from "../utils/csvParser";
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
import {
@@ -768,7 +769,7 @@ export function UserBulkUploadModal({
)}
{!r.success && (
<div className="text-xs text-destructive">
{r.message}
{formatUserPolicyMessage(r.message)}
</div>
)}
</div>

View File

@@ -51,6 +51,16 @@ describe("orgChartPicker", () => {
);
});
it("falls back to the orgfront development origin for authenticated picker URLs", () => {
expect(
buildAuthenticatedOrgChartTenantPickerUrl(undefined, {
tenantId: "hanmac-family-id",
}),
).toBe(
"http://localhost:5175/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id%26includeInternal%3Dtrue",
);
});
it("builds an authenticated multi picker URL for tenant member selection", () => {
expect(
buildAuthenticatedOrgChartUserMultiPickerUrl(

View File

@@ -52,6 +52,8 @@ type OrgChartLoginOptions = {
returnTo?: string;
};
const DEFAULT_ORGFRONT_BASE_URL = "http://localhost:5175";
export const GPDTDC_GRADE_OPTIONS = [
"연구원",
"선임",
@@ -348,7 +350,8 @@ export function buildAuthenticatedOrgChartUrl(
baseUrl?: string,
options: OrgChartLoginOptions = { includeInternal: true },
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const normalizedBase =
baseUrl?.trim().replace(/\/+$/, "") || DEFAULT_ORGFRONT_BASE_URL;
let returnTo = options.returnTo?.trim() || "/chart";
if (options.includeInternal && returnTo.startsWith("/chart")) {
const [path, query = ""] = returnTo.split("?", 2);

View File

@@ -0,0 +1,20 @@
const INTERNAL_DOMAIN_PERSONAL_POLICY_PATTERNS = [
"internal email domain cannot be assigned to personal tenant",
"내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다",
];
export function formatUserPolicyMessage(message?: string | null) {
const raw = String(message ?? "").trim();
if (!raw) {
return "";
}
const normalized = raw.toLowerCase();
if (
INTERNAL_DOMAIN_PERSONAL_POLICY_PATTERNS.some((pattern) =>
normalized.includes(pattern.toLowerCase()),
)
) {
return "내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다. 대표소속을 회사 또는 조직 소속으로 지정해 주세요.";
}
return raw;
}

View File

@@ -36,11 +36,17 @@ describe("adminApi user tenant payloads", () => {
const { updateUser } = await import("./adminApi");
apiClient.put.mockResolvedValue({ data: {} });
await updateUser("user-id", { tenantSlug: "new-tenant" });
await updateUser("user-id", {
tenantSlug: "new-tenant",
isPrimaryTenant: true,
});
expect(apiClient.put).toHaveBeenCalledWith(
"/v1/admin/users/user-id",
expect.objectContaining({ tenantSlug: "new-tenant" }),
expect.objectContaining({
tenantSlug: "new-tenant",
isPrimaryTenant: true,
}),
);
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
@@ -61,6 +67,7 @@ describe("adminApi user tenant payloads", () => {
await bulkUpdateUsers({
userIds: ["user-id"],
tenantSlug: "new-tenant",
isPrimaryTenant: true,
});
expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({
@@ -71,6 +78,7 @@ describe("adminApi user tenant payloads", () => {
);
expect(apiClient.put.mock.calls[0][1]).toMatchObject({
tenantSlug: "new-tenant",
isPrimaryTenant: true,
});
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});

View File

@@ -146,16 +146,6 @@ export type AdminOverviewStats = {
auditEvents24h: number;
};
export type UserProjectionStatus = {
name: string;
status: "ready" | "failed" | "syncing" | string;
ready: boolean;
lastSyncedAt?: string;
lastError?: string;
updatedAt?: string;
projectedUsers: number;
};
export type IdentityCacheStatus = {
status: string;
redisReady: boolean;
@@ -270,13 +260,6 @@ export async function deleteOrphanUserLoginIDs(ids: string[]) {
return data;
}
export async function fetchUserProjectionStatus() {
const { data } = await apiClient.get<UserProjectionStatus>(
"/v1/admin/projections/users",
);
return data;
}
export async function fetchOrySSOTSystemStatus() {
const { data } =
await apiClient.get<OrySSOTSystemStatus>("/v1/admin/ory/ssot");
@@ -718,6 +701,7 @@ export type UserUpdateRequest = {
role?: string;
status?: string;
tenantSlug?: string;
isPrimaryTenant?: boolean;
isAddTenant?: boolean;
isRemoveTenant?: boolean;
department?: string;
@@ -920,6 +904,7 @@ export type WorksmobileComparisonItem = {
worksmobileJobRetryCount?: number;
worksmobileLastError?: string;
worksmobileLastAttemptAt?: string;
updateReasons?: string[];
status: string;
};
@@ -1144,13 +1129,17 @@ export async function bulkUpdateUsers(payload: {
status?: string;
role?: string;
tenantSlug?: string;
isPrimaryTenant?: boolean;
isAddTenant?: boolean;
department?: string;
position?: string;
grade?: string;
jobTitle?: string;
}) {
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
const { data } = await apiClient.put<BulkUserResponse>(
"/v1/admin/users/bulk",
payload,
);
return data;
}

View File

@@ -4,7 +4,10 @@ import {
buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings,
} from "../../../common/core/auth";
import { resolveAdminPublicOrigin } from "./authConfig";
import {
resolveAdminOidcAuthority,
resolveAdminPublicOrigin,
} from "./authConfig";
const adminPublicOrigin = resolveAdminPublicOrigin(
import.meta.env.VITE_ADMIN_PUBLIC_URL,
@@ -12,7 +15,10 @@ const adminPublicOrigin = resolveAdminPublicOrigin(
);
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc",
authority: resolveAdminOidcAuthority(
import.meta.env.VITE_OIDC_AUTHORITY,
window.location.origin,
),
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "adminfront",
origin: adminPublicOrigin,
userStore: new WebStorageStateStore({ store: window.localStorage }),

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
buildAdminAuthRedirectUris,
canStartBrowserPkceLogin,
resolveAdminOidcAuthority,
resolveAdminPublicOrigin,
} from "./authConfig";
@@ -26,6 +27,12 @@ describe("admin auth config", () => {
);
});
it("uses the local OIDC authority for localhost when no explicit authority is set", () => {
expect(resolveAdminOidcAuthority(undefined, "http://localhost:5173")).toBe(
"http://localhost:5000/oidc",
);
});
it("blocks browser PKCE login when WebCrypto is unavailable", () => {
expect(
canStartBrowserPkceLogin({

View File

@@ -5,6 +5,8 @@ export interface AdminAuthRedirectUris {
}
export const ADMIN_AUTH_CALLBACK_PATH = "/auth/callback";
const ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY = "https://sso.hmac.kr/oidc";
const ADMIN_LOCAL_OIDC_PORT = "5000";
export function resolveAdminPublicOrigin(
configuredOrigin: string | undefined,
@@ -71,6 +73,27 @@ function isDevTrustedPkceOrigin(origin: string) {
}
}
export function resolveAdminOidcAuthority(
configuredAuthority: string | undefined,
browserOrigin: string,
) {
const trimmed = configuredAuthority?.trim();
if (trimmed) {
return trimmed;
}
try {
const originUrl = new URL(browserOrigin);
if (isDevTrustedPkceOrigin(originUrl.origin)) {
return `${originUrl.protocol}//${originUrl.hostname}:${ADMIN_LOCAL_OIDC_PORT}/oidc`;
}
} catch {
return ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY;
}
return ADMIN_DEFAULT_PRODUCTION_OIDC_AUTHORITY;
}
export function canStartBrowserPkceLogin({
isSecureContext = window.isSecureContext,
origin = window.location.origin,

View File

@@ -313,6 +313,7 @@ move_description = "Bulk move selected users to another tenant."
move_error = "Error moving users."
move_success = "{{count}} users moved successfully."
parsed_count = "Parsed {{count}} rows."
update_partial_error = "Failed to update {{count}} users."
update_success = "User info updated successfully."
[msg.admin.users.create]

View File

@@ -317,6 +317,7 @@ move_description = "선택한 사용자를 다른 테넌트로 일괄 이동합
move_error = "사용자 이동 중 오류가 발생했습니다."
move_success = "{{count}}명의 사용자가 성공적으로 이동되었습니다."
parsed_count = "{{count}}행의 데이터가 파싱되었습니다."
update_partial_error = "{{count}}명의 사용자 정보 수정에 실패했습니다."
update_success = "사용자 정보가 일괄 업데이트되었습니다."
[msg.admin.users.create]
@@ -378,7 +379,7 @@ self_password_reset_blocked = "본인 계정의 비밀번호는 사용자 포털
delete_confirm = "사용자 \"{{name}}\"을(를) 정말 삭제하시겠습니까?"
empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다."
subtitle = "시스템 사용자를 조회하고 관리합니다. (Local DB)"
subtitle = "Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다."
[msg.admin.users.list.columns]
description = "테이블에 표시할 컬럼을 선택합니다."

View File

@@ -335,6 +335,7 @@ move_description = ""
move_error = ""
move_success = ""
parsed_count = ""
update_partial_error = ""
update_success = ""
[msg.admin.users.create]

View File

@@ -77,7 +77,8 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"Ory SSOT 시스템 상태를 불러오지 못했습니다.",
"msg.admin.ory_ssot.subtitle":
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
"msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.",
"msg.admin.users.list.subtitle":
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
"msg.admin.users.list.registry.count":
"총 {{count}}명의 사용자가 등록되어 있습니다.",
"msg.admin.integrity.check.duplicate_tenant_slugs.description":
@@ -168,7 +169,7 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"msg.admin.ory_ssot.subtitle":
"Review Kratos source-of-truth and Redis identity cache status separately.",
"msg.admin.users.list.subtitle":
"Search and manage users registered in the current tenant.",
"Search and manage users from the Kratos identity mirror.",
"msg.admin.users.list.registry.count": "{{count}} users loaded.",
"msg.admin.integrity.check.duplicate_tenant_slugs.description":
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",

View File

@@ -1,7 +1,10 @@
import { expect, test } from "@playwright/test";
import { installAdminFrontStaticRoutes } from "./helpers/static-adminfront";
test.describe("Bulk Actions and Tree Search", () => {
test.beforeEach(async ({ page }) => {
await installAdminFrontStaticRoutes(page);
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
@@ -196,6 +199,55 @@ test.describe("Bulk Actions and Tree Search", () => {
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
});
test("should show a failure toast when bulk update returns blocked rows", async ({
page,
}) => {
await page.route("**/api/v1/admin/users/bulk", async (route) => {
if (route.request().method() === "PUT") {
return route.fulfill({
json: {
results: [
{
id: "u-1",
success: false,
message:
"internal email domain cannot be assigned to personal tenant: u1@brsw.kr",
},
],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
}
return route.fallback();
});
await page.goto("/users");
await expect(page.locator("table")).toContainText("User One", {
timeout: 20000,
});
await page.locator('table input[type="checkbox"]').nth(1).click();
const selectionBar = page.getByTestId("bulk-action-bar");
await expect(selectionBar).toBeVisible({ timeout: 15000 });
await page.getByTestId("bulk-status-select").click();
await page.getByRole("option", { name: /입사대기|Preboarding/i }).click();
await page.getByTestId("bulk-apply-btn").click();
await expect(
page.getByText(/1명의 사용자 정보 수정에 실패했습니다/),
).toBeVisible();
await expect(
page.getByText(
/내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다/,
),
).toBeVisible();
await expect(
page.getByText(/선택한 사용자들의 정보가 수정되었습니다/),
).not.toBeVisible();
await expect(selectionBar).toBeVisible();
});
test("should let super admins apply selected admin permission to selected users", async ({
page,
}) => {

View File

@@ -925,6 +925,17 @@ test.describe("Tenants Management", () => {
await expect(
page.getByRole("button", { name: "다른 테넌트 선택" }),
).toBeVisible();
await page.getByRole("button", { name: "한맥가족에서 선택" }).click();
await expect(page.getByRole("dialog")).toBeVisible();
const hanmacPickerSrc = await page
.getByTestId("parent-tenant-picker-frame")
.getAttribute("src");
expect(hanmacPickerSrc).toContain("http://localhost:5175/login");
expect(decodeURIComponent(hanmacPickerSrc ?? "")).toContain(
"tenantId=family-1",
);
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
const parentLabelTop = await page
.getByText(/상위 테넌트/)
.first()
@@ -1945,6 +1956,22 @@ test.describe("Tenants Management", () => {
expect(topColumns.split(" ").length).toBe(3);
expect(configColumns.split(" ").length).toBe(4);
await page
.getByTestId("tenant-parent-picker-slot")
.getByRole("button")
.first()
.click();
await expect(page.getByRole("dialog")).toBeVisible();
const detailPickerSrc = await page
.getByTestId("parent-tenant-picker-frame")
.getAttribute("src");
expect(detailPickerSrc).toContain("http://localhost:5175/login");
expect(decodeURIComponent(detailPickerSrc ?? "")).toContain(
"/embed/picker",
);
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toHaveCount(0);
const nameTop = await page
.getByTestId("tenant-name-slot")
.evaluate((element) => element.getBoundingClientRect().top);

View File

@@ -689,6 +689,37 @@ test.describe("User Management", () => {
await expect(page).toHaveURL(/.*\/users$/, { timeout: 10000 });
});
test("should show a Korean policy message when an internal domain user is created as personal", async ({
page,
}) => {
await page.route(/\/admin\/users$/, async (route) => {
if (route.request().method() !== "POST") {
return route.fallback();
}
return route.fulfill({
status: 400,
json: {
error:
"internal email domain cannot be assigned to personal tenant: user@hanmaceng.co.kr",
},
});
});
await page.goto("/users/new");
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
await page.getByRole("tab", { name: /개인 회원/i }).click();
await page.locator('input[name="name"]').fill("Internal User");
await page.locator('input[name="email"]').fill("user@hanmaceng.co.kr");
await page.getByRole("button", { name: /생성/i }).click();
await expect(
page.getByText(
/내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다/,
),
).toBeVisible();
});
test("should export users through the authenticated API client", async ({
page,
}) => {
@@ -1032,6 +1063,43 @@ test.describe("User Management", () => {
expect(createPayload).toBeUndefined();
});
test("should open Hanmac family tenant picker without submitting the user create form", async ({
page,
}) => {
let createRequests = 0;
await page.route(/\/admin\/users(\?.*)?$/, async (route) => {
if (route.request().method() === "POST") {
createRequests += 1;
return route.fulfill({
status: 201,
json: {
id: "new-user-id",
name: "Family User",
email: "family@test.com",
},
});
}
return route.fallback();
});
await page.goto("/users/new");
await page.getByTestId("add-appointment-btn").click();
await expect(page.getByTestId("appointment-row-0")).toBeVisible();
await page.getByRole("button", { name: "한맥가족에서 선택" }).click();
await expect(page).toHaveURL(/\/users\/new$/);
await expect(page.getByRole("dialog")).toBeVisible();
const pickerSrc = await page
.getByTestId("appointment-tenant-picker-frame")
.getAttribute("src");
expect(decodeURIComponent(pickerSrc ?? "")).toContain(
"tenantId=hanmac-family-id",
);
expect(createRequests).toBe(0);
});
test("should hide Hanmac family subtree and system tenants when creating a non-family user", async ({
page,
}) => {

View File

@@ -1,7 +1,10 @@
import { expect, test } from "@playwright/test";
import { installAdminFrontStaticRoutes } from "./helpers/static-adminfront";
test.describe("Users Bulk Upload", () => {
test.beforeEach(async ({ page }) => {
await installAdminFrontStaticRoutes(page);
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
@@ -117,6 +120,56 @@ test.describe("Users Bulk Upload", () => {
await expect(uploadBtn).toBeDisabled();
});
test("should show Korean policy message for internal domain personal failures", async ({
page,
}) => {
await page.route("**/api/v1/admin/users/bulk", async (route) => {
if (route.request().method() === "POST") {
await route.fulfill({
json: {
results: [
{
email: "user@pre-cast.co.kr",
success: false,
message:
"internal email domain cannot be assigned to personal tenant: user@pre-cast.co.kr",
},
],
},
headers: { "Access-Control-Allow-Origin": "*" },
});
return;
}
await route.continue();
});
await page.goto("/users");
await expect(page.getByTestId("page-title")).toContainText(
/사용자|Users/i,
{ timeout: 20000 },
);
await page.getByTestId("user-data-mgmt-btn").click();
await page
.getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i })
.click();
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: "users.csv",
mimeType: "text/csv",
buffer: Buffer.from("email,name\nuser@pre-cast.co.kr,Internal User\n"),
});
await page.getByTestId("bulk-start-btn").click();
await expect(
page.getByText(
/내부 도메인 사용자는 개인 소속으로 생성하거나 변경할 수 없습니다/,
),
).toBeVisible();
});
test("should create missing tenant before user bulk import", async ({
page,
}) => {