forked from baron/baron-sso
네이버 계정 정합성 맞춤
This commit is contained in:
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
"다른 테넌트 선택",
|
||||
|
||||
@@ -125,7 +125,7 @@ function TenantDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Outlet for nested routes */}
|
||||
<div className="animate-in fade-in duration-500">
|
||||
<div>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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={
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
20
adminfront/src/features/users/userPolicyMessages.ts
Normal file
20
adminfront/src/features/users/userPolicyMessages.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 = "테이블에 표시할 컬럼을 선택합니다."
|
||||
|
||||
@@ -335,6 +335,7 @@ move_description = ""
|
||||
move_error = ""
|
||||
move_success = ""
|
||||
parsed_count = ""
|
||||
update_partial_error = ""
|
||||
update_success = ""
|
||||
|
||||
[msg.admin.users.create]
|
||||
|
||||
@@ -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)).",
|
||||
|
||||
Reference in New Issue
Block a user