1
0
forked from baron/baron-sso

adminfront 상단 화면 i18n 정리

This commit is contained in:
2026-05-18 13:14:19 +09:00
parent 279bfae9ec
commit 222dc6f4a4
16 changed files with 1090 additions and 67 deletions

View File

@@ -6,8 +6,11 @@ import {
reconcileUserProjection,
resetUserProjection,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import UserProjectionPage from "./UserProjectionPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
@@ -52,18 +55,19 @@ describe("UserProjectionPage", () => {
currentRole = "super_admin";
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
window.localStorage.setItem("locale", "ko");
});
it("renders projection status for super_admin", async () => {
renderPage();
expect(
await screen.findByText("사용자 Projection 관리"),
await screen.findByText("사용자 동기화 관리"),
).toBeInTheDocument();
expect(
await screen.findByText("Kratos users projection"),
await screen.findByText("Kratos 사용자 동기화"),
).toBeInTheDocument();
expect(screen.getByText("ready")).toBeInTheDocument();
expect(screen.getByText("준비됨")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
expect(fetchUserProjectionStatus).toHaveBeenCalled();
});
@@ -71,7 +75,7 @@ describe("UserProjectionPage", () => {
it("runs reconcile and reset actions for super_admin", async () => {
renderPage();
await screen.findByText("사용자 Projection 관리");
await screen.findByText("사용자 동기화 관리");
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
await waitFor(() => {
@@ -92,8 +96,19 @@ describe("UserProjectionPage", () => {
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(
screen.queryByText("사용자 Projection 관리"),
screen.queryByText("사용자 동기화 관리"),
).not.toBeInTheDocument();
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
});
it("renders localized labels in English", async () => {
window.localStorage.setItem("locale", "en");
renderPage();
expect(
await screen.findByText("User Projection Management"),
).toBeInTheDocument();
expect(screen.getByText("Re-sync")).toBeInTheDocument();
expect(await screen.findByText("ready")).toBeInTheDocument();
});
});

View File

@@ -8,6 +8,8 @@ import {
reconcileUserProjection,
resetUserProjection,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
function formatDateTime(value?: string) {
if (!value) {
@@ -17,7 +19,7 @@ function formatDateTime(value?: string) {
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("ko-KR", {
return new Intl.DateTimeFormat(getAdminDateLocale(), {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
@@ -31,12 +33,26 @@ function ProjectionStatusBadge({
status: string;
}) {
if (ready) {
return <Badge variant="success">ready</Badge>;
return (
<Badge variant="success">
{t("ui.admin.user_projection.status.ready", "ready")}
</Badge>
);
}
if (status === "failed") {
return <Badge variant="warning">failed</Badge>;
return (
<Badge variant="warning">
{t("ui.admin.user_projection.status.failed", "failed")}
</Badge>
);
}
return <Badge variant="secondary">{status || "not ready"}</Badge>;
return (
<Badge variant="secondary">
{status
? status
: t("ui.admin.user_projection.status.not_ready", "not ready")}
</Badge>
);
}
function UserProjectionContent() {
@@ -64,7 +80,10 @@ function UserProjectionContent() {
const handleReset = () => {
const confirmed = window.confirm(
"사용자 projection을 Kratos 기준으로 다시 구축하시겠습니까?",
t(
"msg.admin.user_projection.reset_confirm",
"Rebuild user projection from the Kratos source of truth?",
),
);
if (confirmed) {
resetMutation.mutate();
@@ -79,9 +98,11 @@ function UserProjectionContent() {
<main className="space-y-6 p-6 md:p-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-sm text-muted-foreground">System</p>
<h2 className="text-2xl font-semibold tracking-tight">
Projection
{t(
"ui.admin.user_projection.title",
"User Projection Management",
)}
</h2>
</div>
<div className="flex flex-wrap gap-2">
@@ -92,7 +113,7 @@ function UserProjectionContent() {
disabled={isWorking}
>
<RefreshCw size={16} />
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
</Button>
<Button
type="button"
@@ -101,7 +122,10 @@ function UserProjectionContent() {
disabled={isWorking}
>
<RotateCcw size={16} />
{t(
"ui.admin.user_projection.actions.reset",
"Reset and rebuild",
)}
</Button>
</div>
</div>
@@ -109,19 +133,30 @@ function UserProjectionContent() {
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
"projection 상태를 불러오지 못했습니다."}
t(
"msg.admin.user_projection.load_error",
"Failed to load projection status.",
)}
</section>
) : null}
{actionResult ? (
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{actionResult.syncedUsers} projection을 .
{t(
"msg.admin.user_projection.action_success",
"Refreshed the projection for {{count}} users.",
{ count: actionResult.syncedUsers },
)}
</section>
) : null}
{actionError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(actionError as Error)?.message || "projection 작업에 실패했습니다."}
{(actionError as Error)?.message ||
t(
"msg.admin.user_projection.action_error",
"Projection operation failed.",
)}
</section>
) : null}
@@ -131,19 +166,31 @@ function UserProjectionContent() {
<Database size={18} />
</div>
<div>
<h3 className="text-base font-semibold">Kratos users projection</h3>
<h3 className="text-base font-semibold">
{t(
"ui.admin.user_projection.card.title",
"Kratos users projection",
)}
</h3>
<p className="text-sm text-muted-foreground">
Backend DB read model .
{t(
"ui.admin.user_projection.card.description",
"Current user read model state referenced by backend DB statistics.",
)}
</p>
</div>
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground"> </div>
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.user_projection.loading", "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"></dt>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.user_projection.summary.status", "Status")}
</dt>
<dd className="mt-1">
<ProjectionStatusBadge
ready={data?.ready ?? false}
@@ -153,20 +200,33 @@ function UserProjectionContent() {
</div>
<div>
<dt className="text-sm text-muted-foreground">
Projection
{t(
"ui.admin.user_projection.summary.projected_users",
"Projected users",
)}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.projectedUsers ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.user_projection.summary.last_synced",
"Last synced",
)}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.lastSyncedAt)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground"> </dt>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.user_projection.summary.updated_at",
"Updated at",
)}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.updatedAt)}
</dd>
@@ -190,14 +250,22 @@ export default function UserProjectionPage() {
<RoleGuard
roles={["super_admin"]}
fallback={
<main className="p-6 md:p-8">
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="text-lg font-semibold"> </h2>
<p className="mt-2 text-sm text-muted-foreground">
super_admin .
</p>
</section>
</main>
<main className="p-6 md:p-8">
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="text-lg font-semibold">
{t(
"ui.admin.user_projection.forbidden.title",
"Access denied",
)}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.forbidden.description",
"This screen is only available to super_admin users.",
)}
</p>
</section>
</main>
}
>
<UserProjectionContent />