1
0
forked from baron/baron-sso

Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
2026-05-20 18:16:03 +09:00
79 changed files with 6977 additions and 1099 deletions

View File

@@ -0,0 +1,32 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LanguageSelector from "./LanguageSelector";
vi.mock("../../lib/i18n", () => ({
t: (_key: string, fallback?: string) => fallback ?? "",
}));
describe("LanguageSelector", () => {
beforeEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
});
it("updates locale without reloading the page", () => {
const dispatchSpy = vi.spyOn(window, "dispatchEvent");
window.localStorage.setItem("locale", "ko");
render(<LanguageSelector />);
fireEvent.change(screen.getByRole("combobox"), {
target: { value: "en" },
});
expect(window.localStorage.getItem("locale")).toBe("en");
expect(
dispatchSpy.mock.calls.some(
([event]) => event instanceof Event && event.type === "localechange",
),
).toBe(true);
});
});

View File

@@ -1,8 +1,6 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { LOCALE_STORAGE_KEY } from "../../../../common/core/i18n";
import { t } from "../../lib/i18n";
const LOCALE_STORAGE_KEY = "locale";
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const SUPPORTED_LOCALES = ["ko", "en"] as const;
type Locale = (typeof SUPPORTED_LOCALES)[number];
@@ -29,17 +27,27 @@ function resolveLocale(): Locale {
function LanguageSelector() {
const [locale, setLocale] = useState<Locale>(resolveLocale());
useEffect(() => {
const syncLocale = () => {
setLocale(resolveLocale());
};
window.addEventListener("localechange", syncLocale);
window.addEventListener("storage", syncLocale);
return () => {
window.removeEventListener("localechange", syncLocale);
window.removeEventListener("storage", syncLocale);
};
}, []);
const handleChange = (next: Locale) => {
if (next === locale) {
return;
}
window.localStorage.setItem(LOCALE_STORAGE_KEY, next);
setLocale(next);
if (import.meta.env.MODE === "development") {
window.dispatchEvent(new Event(LOCALE_CHANGED_EVENT));
return;
}
window.location.reload();
window.dispatchEvent(new Event("localechange"));
};
return (

View File

@@ -0,0 +1,34 @@
import { act, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import LocaleRefreshBoundary from "./LocaleRefreshBoundary";
let renderCount = 0;
function RenderCounter() {
renderCount += 1;
return <span>{renderCount}</span>;
}
describe("LocaleRefreshBoundary", () => {
beforeEach(() => {
window.localStorage.clear();
renderCount = 0;
});
it("re-renders children when locale changes", async () => {
render(
<LocaleRefreshBoundary>
<RenderCounter />
</LocaleRefreshBoundary>,
);
expect(screen.getByText("1")).toBeInTheDocument();
await act(async () => {
window.localStorage.setItem("locale", "en");
window.dispatchEvent(new Event("localechange"));
});
expect(screen.getByText("2")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,27 @@
import { Fragment, type ReactNode, useEffect, useState } from "react";
type LocaleRefreshBoundaryProps = {
children: ReactNode;
};
function LocaleRefreshBoundary({ children }: LocaleRefreshBoundaryProps) {
const [localeVersion, setLocaleVersion] = useState(0);
useEffect(() => {
const syncLocale = () => {
setLocaleVersion((current) => current + 1);
};
window.addEventListener("localechange", syncLocale);
window.addEventListener("storage", syncLocale);
return () => {
window.removeEventListener("localechange", syncLocale);
window.removeEventListener("storage", syncLocale);
};
}, []);
return <Fragment key={localeVersion}>{children}</Fragment>;
}
export default LocaleRefreshBoundary;

View File

@@ -493,8 +493,8 @@ function AppLayout() {
auth.user?.profile.name?.toString() ||
auth.user?.profile.preferred_username?.toString(),
profileEmail: profile?.email || auth.user?.profile.email?.toString(),
fallbackName: t("ui.dev.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
});
const profileRoleKey = mockRoleOverride || profile?.role || "user";
const handleSessionExpiryToggle = () => {
@@ -560,7 +560,7 @@ function AppLayout() {
className={shellLayoutClasses.logoutButton}
>
<LogOut size={18} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
<span>{t("ui.shell.nav.logout", "Logout")}</span>
</button>
</div>
);
@@ -621,7 +621,7 @@ function AppLayout() {
className="inline-flex items-center gap-3 rounded-full border border-border bg-card px-3 py-2 transition hover:bg-muted/20"
aria-haspopup="menu"
aria-expanded={isProfileOpen}
aria-label={t("ui.dev.profile.menu_aria", "계정 메뉴 열기")}
aria-label={t("ui.shell.profile.menu_aria", "계정 메뉴 열기")}
>
<div className={shellLayoutClasses.profileInitial}>
{profileSummary.initial}
@@ -643,7 +643,7 @@ function AppLayout() {
{isProfileOpen ? (
<div role="menu" className={shellLayoutClasses.profileMenu}>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.dev.profile.menu_title", "Account")}
{t("ui.shell.profile.menu_title", "Account")}
</p>
<div className={shellLayoutClasses.profileCard}>
<div>
@@ -657,7 +657,7 @@ function AppLayout() {
<div className="flex items-center pt-1">
<span className="inline-flex items-center rounded-full bg-sky-500/10 px-2.5 py-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300">
{t(
`ui.admin.role.${profileRoleKey}`,
`ui.shell.role.${profileRoleKey}`,
profileRoleKey.toUpperCase(),
)}
</span>
@@ -668,7 +668,7 @@ function AppLayout() {
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
{t("ui.shell.session.auto_extend", "세션 만료 관리")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled ? (
@@ -677,7 +677,7 @@ function AppLayout() {
t={t}
/>
) : (
t("ui.dev.session.disabled", "세션 만료 비활성화")
t("ui.shell.session.disabled", "세션 만료 비활성화")
)}
</p>
</div>
@@ -755,7 +755,7 @@ function AppLayout() {
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-foreground transition hover:bg-muted/20"
>
<UserIcon size={16} className="text-muted-foreground" />
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
<span>{t("ui.shell.nav.profile", "내 정보")}</span>
</button>
<button
type="button"
@@ -766,7 +766,7 @@ function AppLayout() {
className="mt-2 flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
>
<LogOut size={16} />
<span>{t("ui.admin.nav.logout", "Logout")}</span>
<span>{t("ui.shell.nav.logout", "Logout")}</span>
</button>
</div>
) : null}

View File

@@ -10,6 +10,10 @@ import {
} from "../../lib/adminApi";
import ApiKeyListPage from "./ApiKeyListPage";
vi.mock("../../lib/i18n", () => ({
t: (_key: string, fallback?: string) => fallback ?? "",
}));
vi.mock("../../lib/adminApi", () => ({
fetchApiKeys: vi.fn(async () => ({
items: [
@@ -102,4 +106,20 @@ describe("ApiKeyListPage", () => {
).toBeInTheDocument();
expect(fetchApiKeys).toHaveBeenCalled();
});
it("refresh button refetches the list without navigation", async () => {
const user = userEvent.setup();
renderPage();
await screen.findByText("client-id-stable");
const refreshButton = screen.getByRole("button", { name: /새로고침/ });
expect(refreshButton).toHaveAttribute("type", "button");
await user.click(refreshButton);
await waitFor(() => {
expect(fetchApiKeys).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -164,6 +164,7 @@ function ApiKeyListPage() {
<PageHeader
sticky
titleAs="h2"
icon={<Key size={20} />}
title={t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
description={t(
"msg.admin.api_keys.list.subtitle",
@@ -172,6 +173,7 @@ function ApiKeyListPage() {
actions={
<>
<Button
type="button"
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
@@ -192,7 +194,7 @@ function ApiKeyListPage() {
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
<CardTitle className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
</CardTitle>
<CardDescription>

View File

@@ -1,6 +1,6 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Download, RefreshCw, Search } from "lucide-react";
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
import * as React from "react";
import {
formatAuditValue,
@@ -16,6 +16,7 @@ import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
@@ -99,6 +100,7 @@ function AuditLogsPage() {
"msg.admin.audit.subtitle",
"관리자 작업 이력을 조회합니다.",
)}
icon={<NotebookTabs size={20} />}
actions={
<>
<Badge variant="muted">
@@ -125,9 +127,15 @@ function AuditLogsPage() {
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>
<CardTitle className="text-lg font-bold flex items-center gap-2">
{t("ui.common.audit.registry.title", "Audit registry")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.audit.registry.description",
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4 pt-0">

View File

@@ -0,0 +1,36 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import AuthPage from "./AuthPage";
vi.mock("../../lib/i18n", () => createI18nMock());
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<AuthPage />
</QueryClientProvider>,
);
}
describe("AuthPage", () => {
beforeEach(() => {
window.localStorage.setItem("locale", "en");
});
it("renders localized auth guard labels in English", () => {
renderPage();
expect(screen.getByText("Auth Guard")).toBeInTheDocument();
expect(screen.getByText("ReBAC permission checker")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Check permission" })).toBeInTheDocument();
});
});

View File

@@ -1,23 +1,20 @@
import { KeyRound } from "lucide-react";
import { ShieldHalf } from "lucide-react";
import { PageHeader } from "../../../../common/core/components/page";
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
function AuthPage() {
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Admin auth
</p>
<h2 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<KeyRound size={22} className="text-primary" />
</h2>
<p className="text-sm text-muted-foreground">
ReBAC .
</p>
</div>
</div>
<PageHeader
titleAs="h2"
icon={<ShieldHalf size={20} />}
title={t("ui.admin.auth_guard.title", "Auth Guard")}
description={t(
"ui.admin.auth_guard.subtitle",
"Verify admin privileges and ReBAC relationships against the policy engine.",
)}
/>
<PermissionChecker />
</div>

View File

@@ -1,5 +1,5 @@
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, ShieldAlert, XCircle } from "lucide-react";
import { CheckCircle2, XCircle } from "lucide-react";
import { useState } from "react";
import { Button } from "../../../components/ui/button";
import {
@@ -12,6 +12,7 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import apiClient from "../../../lib/apiClient";
import { t } from "../../../lib/i18n";
type CheckPermissionResponse = {
allowed: boolean;
@@ -46,50 +47,84 @@ function PermissionChecker() {
return (
<Card className="border-primary/20 bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldAlert size={20} className="text-primary" />
ReBAC
<CardTitle className="text-lg font-bold">
{t(
"ui.admin.auth_guard.checker.title",
"ReBAC permission checker",
)}
</CardTitle>
<CardDescription>
(Subject) (Object) Ory
Keto를 .
{t(
"ui.admin.auth_guard.checker.description",
"Check in real time whether a subject has access to a resource through Ory Keto.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label>Namespace</Label>
<Label>
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
</Label>
<select
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
<option value="Tenant">Tenant</option>
<option value="TenantGroup">TenantGroup</option>
<option value="RelyingParty">RelyingParty</option>
<option value="System">System</option>
<option value="Tenant">
{t("ui.admin.auth_guard.checker.namespace.tenant", "Tenant")}
</option>
<option value="TenantGroup">
{t(
"ui.admin.auth_guard.checker.namespace.tenant_group",
"TenantGroup",
)}
</option>
<option value="RelyingParty">
{t(
"ui.admin.auth_guard.checker.namespace.relying_party",
"RelyingParty",
)}
</option>
<option value="System">
{t("ui.admin.auth_guard.checker.namespace.system", "System")}
</option>
</select>
</div>
<div className="space-y-2">
<Label>Relation</Label>
<Label>{t("ui.admin.auth_guard.checker.relation", "Relation")}</Label>
<Input
placeholder="view, manage, admins..."
placeholder={t(
"ui.admin.auth_guard.checker.relation_placeholder",
"view, manage, admins...",
)}
value={relation}
onChange={(e) => setRelation(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Object ID</Label>
<Label>{t("ui.admin.auth_guard.checker.object_id", "Object ID")}</Label>
<Input
placeholder="Tenant UUID 등"
placeholder={t(
"ui.admin.auth_guard.checker.object_id_placeholder",
"Tenant UUID, etc.",
)}
value={object}
onChange={(e) => setObject(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Subject (User:ID)</Label>
<Label>
{t(
"ui.admin.auth_guard.checker.subject",
"Subject (User:ID)",
)}
</Label>
<Input
placeholder="User:uuid 또는 Namespace:ID#Relation"
placeholder={t(
"ui.admin.auth_guard.checker.subject_placeholder",
"User:uuid or Namespace:ID#Relation",
)}
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
@@ -102,7 +137,9 @@ function PermissionChecker() {
disabled={!object || !subject || checkMutation.isPending}
className="w-full px-12 md:w-auto"
>
{checkMutation.isPending ? "검증 중..." : "권한 확인 실행"}
{checkMutation.isPending
? t("ui.admin.auth_guard.checker.checking", "Checking...")
: t("ui.admin.auth_guard.checker.check", "Check permission")}
</Button>
</div>
@@ -117,18 +154,33 @@ function PermissionChecker() {
{result.allowed ? (
<>
<CheckCircle2 size={48} />
<div className="text-xl font-bold">Access ALLOWED</div>
<div className="text-lg font-bold">
{t(
"ui.admin.auth_guard.checker.allowed",
"Access ALLOWED",
)}
</div>
<p className="text-center text-sm opacity-80">
. (
)
{t(
"ui.admin.auth_guard.checker.allowed_description",
"The subject has access to the requested resource, including inherited permissions.",
)}
</p>
</>
) : (
<>
<XCircle size={48} />
<div className="text-xl font-bold">Access DENIED</div>
<div className="text-lg font-bold">
{t(
"ui.admin.auth_guard.checker.denied",
"Access DENIED",
)}
</div>
<p className="text-center text-sm opacity-80">
.
{t(
"ui.admin.auth_guard.checker.denied_description",
"The subject does not have access to the requested resource.",
)}
</p>
</>
)}

View File

@@ -7,8 +7,11 @@ import {
fetchMe,
fetchOrphanUserLoginIDs,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import DataIntegrityPage from "./DataIntegrityPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
const integrityReport = {
@@ -92,12 +95,18 @@ describe("DataIntegrityPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
window.localStorage.setItem("locale", "ko");
});
it("renders integrity report for super_admin", async () => {
renderPage();
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
expect(
await screen.findByText(
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
),
).toBeInTheDocument();
expect(await screen.findByText("테넌트 정합성")).toBeInTheDocument();
expect(screen.getByText("중복 테넌트 slug")).toBeInTheDocument();
expect(screen.getAllByText("1").length).toBeGreaterThan(0);
@@ -161,4 +170,25 @@ describe("DataIntegrityPage", () => {
expect(fetchMe).toHaveBeenCalled();
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
});
it("renders localized integrity labels in English", async () => {
window.localStorage.setItem("locale", "en");
renderPage();
expect(
await screen.findByText("Data Integrity Check"),
).toBeInTheDocument();
expect(
await screen.findByText(
"Review integrity status and inspect checks across the admin data model.",
),
).toBeInTheDocument();
expect(await screen.findByText("Tenant integrity")).toBeInTheDocument();
expect(await screen.findByText("Duplicate tenant slug")).toBeInTheDocument();
expect(
await screen.findByText(
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",
),
).toBeInTheDocument();
});
});

View File

@@ -18,6 +18,7 @@ import {
fetchOrphanUserLoginIDs,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
function statusLabel(status: DataIntegrityStatus) {
switch (status) {
@@ -47,7 +48,7 @@ function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ko-KR", {
return new Intl.DateTimeFormat(getAdminDateLocale(), {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
@@ -78,6 +79,98 @@ function reasonLabel(reason: string) {
}
}
function integritySectionLabel(key: string, fallback: string) {
switch (key) {
case "tenant_integrity":
return t("ui.admin.integrity.section.tenant_integrity", fallback);
case "user_integrity":
return t("ui.admin.integrity.section.user_integrity", fallback);
default:
return fallback;
}
}
function integritySectionDescription(key: string) {
switch (key) {
case "tenant_integrity":
return t(
"msg.admin.integrity.section.tenant_integrity.description",
"테넌트 slug 중복과 부모 관계 이상을 확인합니다.",
);
case "user_integrity":
return t(
"msg.admin.integrity.section.user_integrity.description",
"사용자와 로그인 ID 참조의 고아 레코드를 확인합니다.",
);
default:
return "";
}
}
function integrityCheckLabel(key: string, fallback: string) {
switch (key) {
case "duplicate_tenant_slugs":
return t(
"ui.admin.integrity.check.duplicate_tenant_slugs.title",
fallback,
);
case "orphan_tenant_parents":
return t(
"ui.admin.integrity.check.orphan_tenant_parents.title",
fallback,
);
case "orphan_user_tenant_memberships":
return t(
"ui.admin.integrity.check.orphan_user_tenant_memberships.title",
fallback,
);
case "orphan_user_login_id_tenants":
return t(
"ui.admin.integrity.check.orphan_user_login_id_tenants.title",
fallback,
);
case "orphan_user_login_id_users":
return t(
"ui.admin.integrity.check.orphan_user_login_id_users.title",
fallback,
);
default:
return fallback;
}
}
function integrityCheckDescription(key: string, fallback: string) {
switch (key) {
case "duplicate_tenant_slugs":
return t(
"msg.admin.integrity.check.duplicate_tenant_slugs.description",
fallback,
);
case "orphan_tenant_parents":
return t(
"msg.admin.integrity.check.orphan_tenant_parents.description",
fallback,
);
case "orphan_user_tenant_memberships":
return t(
"msg.admin.integrity.check.orphan_user_tenant_memberships.description",
fallback,
);
case "orphan_user_login_id_tenants":
return t(
"msg.admin.integrity.check.orphan_user_login_id_tenants.description",
fallback,
);
case "orphan_user_login_id_users":
return t(
"msg.admin.integrity.check.orphan_user_login_id_users.description",
fallback,
);
default:
return fallback;
}
}
function recheckStatusText(status: "idle" | "running" | "success" | "error") {
switch (status) {
case "running":
@@ -249,15 +342,23 @@ function DataIntegrityContent() {
const recheckMessage = recheckStatusText(recheckStatus);
return (
<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">
{t("ui.admin.integrity.kicker", "System")}
</p>
<h2 className="text-2xl font-semibold tracking-tight">
{t("ui.admin.integrity.title", "데이터 정합성 검증")}
</h2>
<main className="space-y-6">
<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.integrity.title", "데이터 정합성 검증")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.subtitle",
"Review integrity status and inspect checks across the admin data model.",
)}
</p>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Button
@@ -280,26 +381,23 @@ function DataIntegrityContent() {
</output>
) : null}
</div>
</div>
</header>
{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 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}
<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 className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
<ShieldAlert size={18} />
</div>
<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-base font-semibold">
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
@@ -312,148 +410,160 @@ function DataIntegrityContent() {
)}
</p>
</div>
</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>
)}
</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">
<h3 className="text-base font-semibold">{section.label}</h3>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
{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>
<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">{check.label}</div>
<p className="mt-1 text-sm text-muted-foreground">
{check.description}
</p>
) : (
<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>
)}
</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 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>
<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-base font-semibold">
{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>
</section>
))}
</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 대상을 불러오지 못했습니다.",
)}
<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>
) : 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>
{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

@@ -7,9 +7,12 @@ import {
fetchAdminRPUsageDaily,
fetchDataIntegrityReport,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import AuthPage from "../auth/AuthPage";
import GlobalOverviewPage from "./GlobalOverviewPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
@@ -227,7 +230,6 @@ describe("admin overview and auth guard pages", () => {
).not.toBeInTheDocument();
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
});
it("shows the latest integrity summary at the bottom for super admins only", async () => {
@@ -253,7 +255,7 @@ describe("admin overview and auth guard pages", () => {
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
renderWithProviders(<AuthPage />);
expect(screen.getByText("인증가드")).toBeInTheDocument();
expect(screen.getByText("인증 가드")).toBeInTheDocument();
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
expect(

View File

@@ -2,9 +2,9 @@ import { useQuery } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
BarChart3,
CheckCircle2,
Database,
LayoutDashboard,
ShieldCheck,
Users,
} from "lucide-react";
@@ -195,15 +195,18 @@ function IntegrityOverviewSummary() {
return (
<section className="border-t border-border/60 pt-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2">
{data.status === "pass" ? (
<CheckCircle2 size={18} className="text-emerald-600" />
) : (
<AlertTriangle size={18} className="text-amber-600" />
)}
<h3 className="text-base font-semibold">
{t("ui.admin.integrity.summary.title", "정합성 최종 검증")}
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.summary.title",
"정합성 최종 검증",
)}
</h3>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm">
@@ -288,23 +291,17 @@ function RPUsageMixedChart({
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 size={18} className="text-primary" />
<div className="space-y-1">
<h3 className="text-base font-semibold">
{t(
"ui.admin.overview.chart.title",
"회사별 앱별 로그인 요청 현황",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 회사 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황")}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
{periodControls}
</div>
@@ -514,17 +511,22 @@ function GlobalOverviewPage() {
return (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight">
{t("ui.common.overview.title", "운영 현황")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
)}
</p>
<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">
<LayoutDashboard size={20} />
</div>
<div className="space-y-1">
<h2 className="text-3xl font-semibold">
{t("ui.common.overview.title", "운영 현황")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
)}
</p>
</div>
</div>
</div>
@@ -569,23 +571,20 @@ function GlobalOverviewPage() {
{usageQuery.isError ? (
<section className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 size={18} className="text-primary" />
<div className="space-y-1">
<h3 className="text-base font-semibold">
{t(
"ui.admin.overview.chart.title",
"회사별 앱별 로그인 요청 현황",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 회사 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.overview.chart.title",
"회사별 앱별 로그인 요청 현황",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
{periodControls}
</div>

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,24 @@ 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 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
),
).toBeInTheDocument();
expect(screen.getByText("ready")).toBeInTheDocument();
expect(
await screen.findByText("Kratos 사용자 동기화"),
).toBeInTheDocument();
expect(screen.getByText("준비됨")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
expect(fetchUserProjectionStatus).toHaveBeenCalled();
});
@@ -71,7 +80,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 +101,22 @@ 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(
await screen.findByText("Review and sync the Kratos user read model."),
).toBeInTheDocument();
expect(screen.getByText("Re-sync")).toBeInTheDocument();
expect(await screen.findByText("ready")).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Database, RefreshCw, RotateCcw } from "lucide-react";
import { AlertTriangle, RefreshCw, RotateCcw, Users } from "lucide-react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
@@ -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();
@@ -76,13 +95,26 @@ function UserProjectionContent() {
const actionError = reconcileMutation.error ?? resetMutation.error;
return (
<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
</h2>
<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">
<Users size={20} />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t(
"ui.admin.user_projection.title",
"User Projection Management",
)}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.subtitle",
"Review and sync the Kratos user read model.",
)}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
@@ -92,7 +124,7 @@ function UserProjectionContent() {
disabled={isWorking}
>
<RefreshCw size={16} />
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
</Button>
<Button
type="button"
@@ -101,49 +133,72 @@ function UserProjectionContent() {
disabled={isWorking}
>
<RotateCcw size={16} />
{t(
"ui.admin.user_projection.actions.reset",
"Reset and rebuild",
)}
</Button>
</div>
</div>
</header>
{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}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 border-b border-border pb-4">
<div className="grid h-10 w-10 place-items-center rounded-lg bg-primary/10 text-primary">
<Database size={18} />
</div>
<div>
<h3 className="text-base font-semibold">Kratos users projection</h3>
<h3 className="text-lg font-bold flex items-center gap-2">
{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 +208,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 +258,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 />

View File

@@ -7,6 +7,7 @@ import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTrigger,
DialogTitle,
} from "../../../components/ui/dialog";
import { Label } from "../../../components/ui/label";
@@ -87,27 +88,100 @@ export function ParentTenantSelector({
</div>
<input id={id} name={id} type="hidden" value={value} readOnly />
<div className="flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setPickerOpen(true)}
>
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
selectedTenant?.name ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</Button>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
selectedTenant?.name ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.picker_description",
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
)}
</DialogDescription>
</DialogHeader>
<iframe
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
/>
</DialogContent>
</Dialog>
{localPickerLabel && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setLocalPickerOpen(true)}
>
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>
</DialogTrigger>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{localPickerLabel ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.local_picker_description",
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={localSearch}
onChange={(event) => setLocalSearch(event.target.value)}
placeholder={t(
"ui.admin.tenants.parent.local_search_placeholder",
"테넌트 이름 또는 슬러그 검색",
)}
/>
<div className="max-h-[360px] space-y-2 overflow-y-auto">
{localCandidates.map((tenant) => (
<Button
key={tenant.id}
type="button"
variant="outline"
className="h-auto w-full justify-start px-3 py-2 text-left"
onClick={() => {
onChange(tenant.id);
setLocalPickerOpen(false);
setLocalSearch("");
}}
>
<span>
<span className="block text-sm font-medium">
{tenant.name}
</span>
<span className="block text-xs text-muted-foreground">
{tenant.slug} · {tenant.type}
</span>
</span>
</Button>
))}
{localCandidates.length === 0 && (
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.parent.local_picker_empty",
"선택할 수 있는 테넌트가 없습니다.",
)}
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
)}
{selectedTenant ? (
<>
@@ -137,85 +211,6 @@ export function ParentTenantSelector({
{helpText && (
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
)}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.picker_description",
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
)}
</DialogDescription>
</DialogHeader>
<iframe
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
/>
</DialogContent>
</Dialog>
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{localPickerLabel ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.local_picker_description",
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<input
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={localSearch}
onChange={(event) => setLocalSearch(event.target.value)}
placeholder={t(
"ui.admin.tenants.parent.local_search_placeholder",
"테넌트 이름 또는 슬러그 검색",
)}
/>
<div className="max-h-[360px] space-y-2 overflow-y-auto">
{localCandidates.map((tenant) => (
<Button
key={tenant.id}
type="button"
variant="outline"
className="h-auto w-full justify-start px-3 py-2 text-left"
onClick={() => {
onChange(tenant.id);
setLocalPickerOpen(false);
setLocalSearch("");
}}
>
<span>
<span className="block text-sm font-medium">
{tenant.name}
</span>
<span className="block text-xs text-muted-foreground">
{tenant.slug} · {tenant.type}
</span>
</span>
</Button>
))}
{localCandidates.length === 0 && (
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.parent.local_picker_empty",
"선택할 수 있는 테넌트가 없습니다.",
)}
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useCallback, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
@@ -30,12 +30,19 @@ import {
shouldAllowHanmacOrgConfig,
} from "../utils/orgConfig";
type AdminFrontTestHooks = {
selectTenantParent?: (tenantId: string) => Promise<void>;
};
function TenantCreatePage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [parentId, setParentId] = useState("");
const [parentId, setParentId] = useState(
() => searchParams.get("parentId") ?? "",
);
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
const [orgUnitType, setOrgUnitType] = useState("");
const [visibility, setVisibility] = useState<TenantVisibility>("public");
@@ -74,10 +81,22 @@ function TenantCreatePage() {
"ui.admin.tenants.create.parent_context.pick_required",
"상위 테넌트 선택 필요",
);
const handleParentChange = (nextParentId: string) => {
const handleParentChange = useCallback((nextParentId: string) => {
setParentId(nextParentId);
setParentStepConfirmed(false);
};
}, []);
if (typeof window !== "undefined") {
const testWindow = window as Window &
typeof globalThis & {
__adminfrontTestHooks?: AdminFrontTestHooks;
};
const hooks = testWindow.__adminfrontTestHooks ?? {};
hooks.selectTenantParent = async (tenantId: string) => {
handleParentChange(tenantId);
};
testWindow.__adminfrontTestHooks = hooks;
}
const mutation = useMutation({
mutationFn: (overrideForceDomains?: string[]) =>
@@ -205,6 +224,14 @@ function TenantCreatePage() {
) : null
}
/>
<button
type="button"
data-testid="tenant-test-select-hanmac-parent"
hidden
onClick={() => handleParentChange("family-1")}
>
test-select-hanmac-parent
</button>
</div>
{canConfigureHanmacOrg && (
<>

View File

@@ -38,6 +38,7 @@ import {
sortItems,
toggleSort,
} from "../../../../../common/core/utils";
import { PageHeader } from "../../../../../common/core/components/page";
import {
commonStickyTableHeaderClass,
commonTableShellClass,
@@ -459,7 +460,7 @@ function TenantListPage() {
) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
<h3 className="text-xl font-bold">
<h3 className="text-lg font-bold">
{t("msg.admin.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<Button onClick={() => navigate("/")}>
@@ -745,194 +746,144 @@ function TenantListPage() {
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.title", "테넌트 목록")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
{t(
"msg.admin.tenants.subtitle",
"시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
<div className="relative w-64 mr-2">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.list.search_placeholder",
"테넌트 이름, 슬러그, UUID 검색...",
)}
className="pl-9 h-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div
className="flex rounded-md border bg-background p-0.5"
data-testid="tenant-view-mode-toggle"
>
<Button
type="button"
variant={viewMode === "tree" ? "default" : "ghost"}
size="sm"
className="h-8 gap-1.5"
onClick={() => setViewMode("tree")}
data-testid="tenant-view-tree-btn"
>
<Network size={14} />
{t("ui.admin.tenants.view.tree", "트리")}
</Button>
<Button
type="button"
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
className="h-8 gap-1.5"
onClick={() => setViewMode("table")}
data-testid="tenant-view-table-btn"
>
<List size={14} />
{t("ui.admin.tenants.view.table", "평면")}
</Button>
</div>
<Button
type="button"
variant={scopeTenantId ? "default" : "outline"}
size="sm"
className="h-9 gap-2"
onClick={() => setScopePickerOpen(true)}
data-testid="tenant-scope-picker-btn"
>
<Network size={16} />
{selectedScopeTenant
? t("ui.admin.tenants.scope.active", "{{name}} 하위", {
name: selectedScopeTenant.name,
})
: t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
</Button>
{scopeTenantId && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-9"
onClick={() => setScopeTenantId("")}
data-testid="tenant-scope-clear-btn"
>
{t("ui.common.clear", "초기화")}
</Button>
)}
<RoleGuard roles={["super_admin"]}>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
data-testid="tenant-import-input"
onChange={handleImportFile}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
data-testid="tenant-data-mgmt-btn"
className="gap-2"
>
<LayoutDashboard size={16} />
{t("ui.admin.tenants.data_mgmt", "데이터 관리")}
<ChevronDown size={14} className="opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={handleTemplateDownload}
data-testid="tenant-template-menu-item"
className="cursor-pointer"
>
<FileSpreadsheet size={16} className="mr-2 opacity-50" />
{t("ui.admin.tenants.csv_template", "템플릿 다운로드")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
data-testid="tenant-import-menu-item"
className="cursor-pointer"
>
<Upload size={16} className="mr-2 opacity-50" />
{t("ui.admin.tenants.import", "CSV 가져오기")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => exportMutation.mutate(false)}
disabled={exportMutation.isPending}
data-testid="tenant-export-menu-item"
className="cursor-pointer"
>
<Download size={16} className="mr-2 opacity-50" />
{t(
"ui.admin.tenants.export_without_ids",
"UUID 제외 내보내기",
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => exportMutation.mutate(true)}
disabled={exportMutation.isPending}
data-testid="tenant-export-with-ids-menu-item"
className="cursor-pointer"
>
<Download size={16} className="mr-2 opacity-50" />
{t("ui.admin.tenants.export_with_ids", "UUID 포함 내보내기")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</RoleGuard>
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
className="w-9 px-0"
title={t("ui.common.refresh", "새로고침")}
>
<RefreshCw size={16} />
<span className="sr-only">
{t("ui.common.refresh", "새로고침")}
</span>
</Button>
<RoleGuard roles={["super_admin"]}>
<Button asChild>
<Link to="/tenants/new">
<Plus size={16} />
{t("ui.admin.tenants.add", "테넌트 추가")}
</Link>
</Button>
</RoleGuard>
</div>
{importMessage && (
<div
className="basis-full rounded-md border border-border bg-secondary px-3 py-2 text-sm"
data-testid="tenant-import-result"
>
{importMessage}
</div>
<PageHeader
sticky
titleAs="h2"
icon={<Building2 size={20} />}
title={t("ui.admin.tenants.title", "테넌트 목록")}
description={t(
"msg.admin.tenants.subtitle",
"시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.",
)}
</header>
actions={
<>
<div className="flex items-center gap-2">
<div className="relative mr-2 w-64">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.list.search_placeholder",
"테넌트 이름 또는 슬러그 검색...",
)}
className="h-9 pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<RoleGuard roles={["super_admin"]}>
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
data-testid="tenant-import-input"
onChange={handleImportFile}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
data-testid="tenant-data-mgmt-btn"
className="gap-2"
>
<LayoutDashboard size={16} />
{t("ui.admin.tenants.data_mgmt", "데이터 관리")}
<ChevronDown size={14} className="opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={handleTemplateDownload}
data-testid="tenant-template-menu-item"
className="cursor-pointer"
>
<FileSpreadsheet size={16} className="mr-2 opacity-50" />
{t("ui.admin.tenants.csv_template", "템플릿 다운로드")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => fileInputRef.current?.click()}
disabled={importMutation.isPending}
data-testid="tenant-import-menu-item"
className="cursor-pointer"
>
<Upload size={16} className="mr-2 opacity-50" />
{t("ui.admin.tenants.import", "CSV 가져오기")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => exportMutation.mutate(false)}
disabled={exportMutation.isPending}
data-testid="tenant-export-menu-item"
className="cursor-pointer"
>
<Download size={16} className="mr-2 opacity-50" />
{t(
"ui.admin.tenants.export_without_ids",
"UUID 제외 내보내기",
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => exportMutation.mutate(true)}
disabled={exportMutation.isPending}
data-testid="tenant-export-with-ids-menu-item"
className="cursor-pointer"
>
<Download size={16} className="mr-2 opacity-50" />
{t(
"ui.admin.tenants.export_with_ids",
"UUID 포함 내보내기",
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</RoleGuard>
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
className="w-9 px-0"
title={t("ui.common.refresh", "새로고침")}
>
<RefreshCw size={16} />
<span className="sr-only">
{t("ui.common.refresh", "새로고침")}
</span>
</Button>
<RoleGuard roles={["super_admin"]}>
<Button asChild>
<Link to="/tenants/new">
<Plus size={16} />
{t("ui.admin.tenants.add", "테넌트 추가")}
</Link>
</Button>
</RoleGuard>
</div>
{importMessage ? (
<div
className="rounded-md border border-border bg-secondary px-3 py-2 text-sm"
data-testid="tenant-import-result"
>
{importMessage}
</div>
) : null}
</>
}
/>
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div className="flex items-center gap-6">
<div>
<CardTitle>
<CardTitle className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.tenants.registry.count",
"총 {{count}}개 테넌트",
"총 {{count}}개 테넌트가 등록되어 있습니다.",
{
count: scopeTenantId ? scopedTenants.length : tenantTotal,
},

View File

@@ -67,6 +67,13 @@ type AppointmentDraft = UserAppointment & {
draftId: string;
};
type AdminFrontTestHooks = {
selectUserAppointmentTenant?: (
selection: OrgChartTenantSelection,
index?: number,
) => Promise<void>;
};
function createDraftId() {
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
}
@@ -276,6 +283,21 @@ function UserCreatePage() {
return () => window.removeEventListener("message", onMessage);
}, [applyTenantSelection, pickerTarget]);
if (typeof window !== "undefined") {
const testWindow = window as Window &
typeof globalThis & {
__adminfrontTestHooks?: AdminFrontTestHooks;
};
const hooks = testWindow.__adminfrontTestHooks ?? {};
hooks.selectUserAppointmentTenant = async (selection, index = 0) => {
await applyTenantSelection(selection, {
kind: "appointment",
index,
});
};
testWindow.__adminfrontTestHooks = hooks;
}
const addAppointment = () => {
setAdditionalAppointments((current) => [
...current,
@@ -777,6 +799,7 @@ function UserCreatePage() {
})
}
disabled={isResolvingTenant}
data-testid={`appointment-tenant-picker-${index}`}
>
<Building2 className="mr-2 h-4 w-4" />
{appointment.tenantName || "테넌트 선택"}
@@ -988,6 +1011,7 @@ function UserCreatePage() {
title={t("ui.admin.users.create.form.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
data-testid="appointment-tenant-picker-frame"
/>
</DialogContent>
</Dialog>

View File

@@ -7,14 +7,17 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
Users,
Download,
FileDown,
LayoutDashboard,
FileSpreadsheet,
Plus,
RefreshCw,
Search,
Settings2,
ShieldCheck,
Trash2,
Upload,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
@@ -90,7 +93,10 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole } from "../../lib/roles";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
import {
UserBulkUploadModal,
downloadUserTemplate,
} from "./components/UserBulkUploadModal";
import {
normalizeUserStatusValue,
type UserStatusValue,
@@ -140,6 +146,7 @@ function UserListPage() {
React.useState("");
const [sortConfig, setSortConfig] =
React.useState<SortConfig<UserSortKey> | null>(null);
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
const limit = 1000;
const offset = (page - 1) * limit;
@@ -416,6 +423,7 @@ function UserListPage() {
<PageHeader
sticky
titleAs="h2"
icon={<Users size={20} />}
title={
<span data-testid="page-title">
{t("ui.admin.users.list.title", "사용자 관리")}
@@ -485,27 +493,65 @@ function UserListPage() {
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button
variant="outline"
onClick={() => handleExport(false)}
className="gap-2"
disabled={exportMutation.isPending}
data-testid="user-export-without-ids-btn"
>
<FileDown size={16} />
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</Button>
<Button
variant="outline"
onClick={() => handleExport(true)}
className="gap-2"
disabled={exportMutation.isPending}
data-testid="user-export-with-ids-btn"
>
<FileDown size={16} />
{t("ui.common.export_with_ids", "UUID 포함")}
</Button>
<UserBulkUploadModal onSuccess={() => query.refetch()} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
data-testid="user-data-mgmt-btn"
className="gap-2 h-9"
>
<LayoutDashboard size={16} />
{t("ui.admin.users.data_mgmt", "데이터 관리")}
<ChevronDown size={14} className="opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={downloadUserTemplate}
data-testid="user-template-menu-item"
className="cursor-pointer"
>
<FileSpreadsheet size={16} className="mr-2 opacity-50" />
{t("ui.admin.users.csv_template", "템플릿 다운로드")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
setBulkUploadOpen(true);
}}
className="cursor-pointer"
>
<Upload size={16} className="mr-2 opacity-50" />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleExport(false)}
disabled={exportMutation.isPending}
data-testid="user-export-menu-item"
className="cursor-pointer"
>
<FileDown size={16} className="mr-2 opacity-50" />
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport(true)}
disabled={exportMutation.isPending}
data-testid="user-export-with-ids-menu-item"
className="cursor-pointer"
>
<FileDown size={16} className="mr-2 opacity-50" />
{t("ui.common.export_with_ids", "UUID 포함 내보내기")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<UserBulkUploadModal
variant="custom"
open={bulkUploadOpen}
onOpenChange={setBulkUploadOpen}
onSuccess={() => query.refetch()}
/>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
@@ -577,7 +623,7 @@ function UserListPage() {
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
<CardTitle className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.users.list.registry.title", "User Registry")}
</CardTitle>
<CardDescription>

View File

@@ -18,6 +18,7 @@ import {
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { DropdownMenuItem } from "../../../components/ui/dropdown-menu";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
type BulkUserItem,
@@ -42,7 +43,9 @@ import {
interface UserBulkUploadModalProps {
onSuccess?: () => void;
variant?: "button" | "dropdown";
variant?: "button" | "dropdown" | "custom";
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
function buildUserTenantPreviewRows(
@@ -121,11 +124,34 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
return "text-muted-foreground";
}
export const downloadUserTemplate = () => {
const headers =
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
const example =
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "user_bulk_template.csv";
a.click();
URL.revokeObjectURL(url);
};
export function UserBulkUploadModal({
onSuccess,
variant = "button",
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: UserBulkUploadModalProps) {
const [open, setOpen] = React.useState(false);
const [localOpen, setLocalOpen] = React.useState(false);
const open = controlledOpen !== undefined ? controlledOpen : localOpen;
const setOpen = (val: boolean) => {
setLocalOpen(val);
controlledOnOpenChange?.(val);
};
const [file, setFile] = React.useState<File | null>(null);
const [parsing, setParsing] = React.useState(false);
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
@@ -334,17 +360,15 @@ export function UserBulkUploadModal({
const triggerNode =
variant === "dropdown" ? (
<div
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
role="menuitem"
tabIndex={-1}
<DropdownMenuItem
onClick={() => setOpen(true)}
className="cursor-pointer"
{...triggerProps}
>
<Upload size={16} className="mr-2 opacity-50" />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</div>
) : (
</DropdownMenuItem>
) : variant === "custom" ? null : (
<DialogTrigger asChild>
<Button variant="outline" className="gap-2" {...triggerProps}>
<Upload size={16} />
@@ -363,7 +387,7 @@ export function UserBulkUploadModal({
if (!val) reset();
}}
>
{variant !== "dropdown" && triggerNode}
{variant !== "dropdown" && variant !== "custom" && triggerNode}
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle data-testid="bulk-upload-title">
@@ -392,20 +416,23 @@ export function UserBulkUploadModal({
"템플릿 다운로드",
)}
</Button>
<input
type="file"
accept=".csv"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
<Button
onClick={() => fileInputRef.current?.click()}
variant="secondary"
>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
<Button asChild variant="secondary" className="cursor-pointer">
<label>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
<input
type="file"
accept=".csv"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
onClick={(e) => {
// Allow picking the same file again if it was cleared
(e.target as HTMLInputElement).value = "";
}}
/>
</label>
</Button>
</div>

View File

@@ -32,6 +32,11 @@ describe("userStatus", () => {
expect(normalizeUserStatusValue("baron_only")).toBe("baron_guest");
});
it("falls back to preboarding when status is missing", () => {
expect(normalizeUserStatusValue(undefined)).toBe("preboarding");
expect(normalizeUserStatusValue(null)).toBe("preboarding");
});
it("uses canonical labels for legacy status values", () => {
expect(userStatusLabel("baron_only")).toBe("baron_guest");
});

View File

@@ -12,8 +12,8 @@ export const userStatusValues = [
export type UserStatusValue = (typeof userStatusValues)[number];
export function normalizeUserStatusValue(status: string): UserStatusValue {
switch (status.trim().toLowerCase()) {
export function normalizeUserStatusValue(status?: string | null): UserStatusValue {
switch ((status ?? "").trim().toLowerCase()) {
case "active":
return "active";
case "temporary_leave":

View File

@@ -25,8 +25,6 @@ describe("i18n utility", () => {
it("respects locale in localStorage", () => {
window.localStorage.setItem("locale", "en");
// We expect some key that exists in en.toml
// Let's use a common one or a fallback if we don't know the content
expect(t("ui.common.save", "Save")).toBe("Save");
});

View File

@@ -0,0 +1,32 @@
import { DEFAULT_LOCALE, LOCALE_STORAGE_KEY, type Locale } from "../../../common/core/i18n";
function isLocale(value: string): value is Locale {
return value === "ko" || value === "en";
}
export function getAdminLocale(): Locale {
if (typeof window === "undefined") {
return DEFAULT_LOCALE;
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored && isLocale(stored)) {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale && isLocale(pathLocale)) {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
if (browserLang.startsWith("ko")) {
return "ko";
}
return DEFAULT_LOCALE;
}
export function getAdminDateLocale() {
return getAdminLocale() === "ko" ? "ko-KR" : "en-US";
}

View File

@@ -80,27 +80,27 @@ forbidden = "You do not have permission to perform this action."
[msg.admin.api_keys]
[msg.admin.api_keys.create]
error = "Error"
name_required = "Name Required"
scope_required = "Scope Required"
scopes_count = "Scopes Count"
scopes_hint = "Scopes Hint"
subtitle = "Subtitle"
error = "Failed to create an API key."
name_required = "Name is required."
scope_required = "Select at least one scope."
scopes_count = "A total of {{count}} scopes will be assigned."
scopes_hint = "The key will be active immediately after creation."
subtitle = "Configure a secure credential for system-to-system integration."
[msg.admin.api_keys.create.success]
copy_hint = "Copy Hint"
notice = "Notice"
notice_emphasis = "Notice Emphasis"
notice_suffix = "Notice Suffix"
copy_hint = "Use the copy button and store it somewhere safe, such as a password manager."
notice = "The secret below"
notice_emphasis = "is shown only once"
notice_suffix = "for security reasons."
[msg.admin.api_keys.list]
delete_confirm = "Delete Confirm"
empty = "Empty"
fetch_error = "Fetch Error"
subtitle = "Subtitle"
delete_confirm = 'Delete API key "{{name}}"?'
empty = "There are no registered API keys."
fetch_error = "Failed to load the API key list."
subtitle = "Issue and manage API keys for machine-to-machine communication."
[msg.admin.api_keys.list.registry]
count = "Count"
count = "Total {{count}} API keys"
[msg.admin.audit]
empty = "Empty"
@@ -114,6 +114,7 @@ empty = "Empty"
[msg.admin.audit.registry]
count = "Count"
description = "Filter recent audit logs by search criteria and review action history quickly."
[msg.admin.groups]
@@ -162,6 +163,32 @@ success = "Check completed."
[msg.admin.integrity.report]
load_error = "Failed to load the integrity report."
[msg.admin.integrity.check.duplicate_tenant_slugs]
description = "Checks duplicate active tenant slugs using LOWER(TRIM(slug))."
[msg.admin.integrity.check.orphan_tenant_parents]
description = "Checks whether tenants.parent_id points to a missing or soft-deleted tenant."
[msg.admin.integrity.check.orphan_user_login_id_tenants]
description = "Checks whether user_login_ids.tenant_id points to a missing or soft-deleted tenant."
[msg.admin.integrity.check.orphan_user_login_id_users]
description = "Checks whether user_login_ids.user_id points to a missing or soft-deleted user."
[msg.admin.integrity.check.orphan_user_tenant_memberships]
description = "Checks whether users.tenant_id points to a missing or soft-deleted tenant."
[msg.admin.user_projection]
action_error = "Projection operation failed."
action_success = "Refreshed the projection for {{count}} users."
forbidden_description = "This screen is only available to super_admin users."
load_error = "Failed to load projection status."
reset_confirm = "Rebuild user projection from the Kratos source of truth?"
subtitle = "Review and sync the Kratos user read model."
[msg.admin.user_projection.forbidden]
description = "This screen is only available to super_admin users."
[msg.admin.groups.prompt]
user_id = "User Id"
@@ -218,7 +245,7 @@ import_result = "Created {{created}}, updated {{updated}}, failed {{failed}}"
missing_id = "No Tenant ID."
not_found = "Tenant not found."
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
subtitle = "Subtitle"
subtitle = "Review and manage the tenants registered in the system."
[msg.admin.tenants.import_preview]
description = "Rows without tenant_id are compared with existing tenant candidates, then imported as new tenants or updates."
@@ -262,7 +289,7 @@ empty = "No members found."
limit_notice = "Showing members from the first 10 descendant organizations due to size limits."
[msg.admin.tenants.registry]
count = "Count"
count = "Total {{count}} tenants are registered."
[msg.admin.tenants.schema]
empty = "No custom fields defined. Click \"Add Field\" to begin."
@@ -339,14 +366,14 @@ self_password_reset_blocked = "Please change your own password from the UserFron
delete_confirm = "Delete Confirm"
empty = "Empty"
fetch_error = "Fetch Error"
subtitle = "Subtitle"
subtitle = "View and manage system users."
[msg.admin.users.list.columns]
description = "Select columns to display in the table."
no_custom = "No custom fields defined for this tenant."
[msg.admin.users.list.registry]
count = "Count"
count = "{{count}} users loaded."
[msg.dev]
logout_confirm = "Are you sure you want to log out?"
@@ -738,21 +765,21 @@ title = "Admin Control"
[ui.admin.api_keys]
[ui.admin.api_keys.create]
name_label = "Name Label"
name_placeholder = "Name Placeholder"
section_name = "Section Name"
section_scopes = "Section Scopes"
submit = "Submit"
title = "Title"
name_label = "Service or purpose name"
name_placeholder = "e.g. Jenkins-CI, Grafana-Dashboard"
section_name = "Key name"
section_scopes = "Scopes"
submit = "Issue API Key"
title = "Create New API Key"
[ui.admin.api_keys.create.success]
copy_secret = "Copy Secret"
go_list = "Go List"
title = "Title"
go_list = "Back to list"
title = "API Key Created"
[ui.admin.api_keys.list]
add = "Add"
title = "Title"
add = "Create API Key"
title = "API Key Management (M2M)"
[ui.admin.api_keys.list.breadcrumb]
list = "List"
@@ -863,6 +890,7 @@ kicker = "System"
loading = "Loading data integrity report..."
title = "Data Integrity Check"
fetch_error = "Unable to load the final integrity check result."
subtitle = "Review integrity status and inspect checks across the admin data model."
[ui.admin.integrity.forbidden]
title = "Access denied"
@@ -910,6 +938,27 @@ user = "User"
tenant_integrity = "Tenant integrity"
user_integrity = "User integrity"
[msg.admin.integrity.section.tenant_integrity]
description = "Checks duplicate tenant slugs and orphan parent relationships."
[msg.admin.integrity.section.user_integrity]
description = "Checks orphan records for users and login ID references."
[ui.admin.integrity.check.duplicate_tenant_slugs]
title = "Duplicate tenant slug"
[ui.admin.integrity.check.orphan_tenant_parents]
title = "Orphan tenant parents"
[ui.admin.integrity.check.orphan_user_login_id_tenants]
title = "Orphan user login ID tenants"
[ui.admin.integrity.check.orphan_user_login_id_users]
title = "Orphan user login ID users"
[ui.admin.integrity.check.orphan_user_tenant_memberships]
title = "Orphan user tenant memberships"
[ui.admin.nav]
org_chart = "Org Chart"
api_keys = "API Keys"
@@ -925,6 +974,61 @@ tenants = "Tenants"
user_projection = "User Projection"
users = "Users"
[ui.admin.user_projection]
loading = "Loading user projection data..."
subtitle = "Review and sync the Kratos user read model."
title = "User Projection Management"
[ui.admin.user_projection.actions]
reconcile = "Re-sync"
reset = "Reset and rebuild"
[ui.admin.user_projection.card]
description = "Current user read model state referenced by backend DB statistics."
title = "Kratos users projection"
[ui.admin.user_projection.forbidden]
title = "Access denied"
[ui.admin.user_projection.status]
failed = "failed"
not_ready = "not ready"
ready = "ready"
[ui.admin.user_projection.summary]
last_synced = "Last synced"
projected_users = "Projected users"
status = "Status"
updated_at = "Updated at"
[ui.admin.auth_guard]
subtitle = "Verify admin privileges and ReBAC relationships against the policy engine."
title = "Auth Guard"
[ui.admin.auth_guard.checker]
check = "Check permission"
checking = "Checking..."
denied = "Access DENIED"
denied_description = "The subject does not have access to the requested resource."
description = "Check in real time whether a subject has access to a resource through Ory Keto."
object_id = "Object ID"
object_id_placeholder = "Tenant UUID, etc."
allowed = "Access ALLOWED"
allowed_description = "The subject has access to the requested resource, including inherited permissions."
namespace = "Namespace"
relation = "Relation"
relation_placeholder = "view, manage, admins..."
subject = "Subject (User:ID)"
subject_placeholder = "User:uuid or Namespace:ID#Relation"
title = "ReBAC permission checker"
[ui.admin.auth_guard.checker.namespace]
label = "Namespace"
relying_party = "RelyingParty"
system = "System"
tenant = "Tenant"
tenant_group = "TenantGroup"
[ui.admin.org]
download_template = "Download Template"
import_btn = "Org/User Import"
@@ -1125,7 +1229,7 @@ local_search_placeholder = "Search tenant name or slug"
pick_tenant = "Pick tenant"
[ui.admin.tenants.registry]
title = "Tenant registry"
title = "Tenant Registry"
[ui.admin.tenants.schema]
add_field = "Add Field"
@@ -1334,7 +1438,7 @@ title = "Column Settings"
tenant = "Tenant Filter"
[ui.admin.users.list.registry]
count = "Count"
count = "Registered users"
title = "User Registry"
[ui.admin.users.list.table]
@@ -1417,6 +1521,22 @@ menu_title = "Account"
unknown_email = "unknown@example.com"
unknown_name = "Unknown User"
[ui.shell.profile]
menu_aria = "Open account menu"
menu_title = "Account"
unknown_email = "unknown@example.com"
unknown_name = "Unknown User"
[ui.shell.nav]
logout = "Logout"
profile = "My Profile"
[ui.shell.role]
rp_admin = "Service Administrator (RP Admin)"
super_admin = "System Administrator (Super Admin)"
tenant_admin = "Tenant Administrator (Tenant Admin)"
user = "General User (Tenant Member)"
[ui.dev.clients]
new = "Add Connected Application"
search_placeholder = "Search by app name or ID..."
@@ -1623,6 +1743,15 @@ expired = "Session expired"
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"
remaining = "Expires in {{minutes}}m {{seconds}}s"
[ui.shell.session]
auto_extend = "Session expiry"
active = "Session active"
disabled = "Session expiry disabled"
unknown = "Unknown"
expired = "Session expired"
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"
remaining = "Expires in {{minutes}}m {{seconds}}s"
[ui.userfront]
app_title = "Baron SW Portal"

View File

@@ -114,6 +114,7 @@ empty = "필터 없음"
[msg.admin.audit.registry]
count = "로드된 로그 {{count}}건"
description = "최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다."
[msg.admin.groups]
@@ -162,6 +163,35 @@ success = "검사가 완료되었습니다."
[msg.admin.integrity.report]
load_error = "정합성 리포트를 불러오지 못했습니다."
[msg.admin.integrity.check.duplicate_tenant_slugs]
description = "삭제되지 않은 tenant의 LOWER(TRIM(slug)) 기준 중복을 검사합니다."
[msg.admin.integrity.check.orphan_tenant_parents]
description = "tenants.parent_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다."
[msg.admin.integrity.check.orphan_user_login_id_tenants]
description = "user_login_ids.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다."
[msg.admin.integrity.check.orphan_user_login_id_users]
description = "user_login_ids.user_id가 존재하지 않거나 soft-deleted user를 참조하는지 검사합니다."
[msg.admin.integrity.check.orphan_user_tenant_memberships]
description = "users.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다."
[msg.admin.integrity]
subtitle = "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다."
[msg.admin.user_projection]
action_error = "사용자 동기화 작업에 실패했습니다."
action_success = "{{count}}명 기준으로 사용자 동기화를 갱신했습니다."
forbidden_description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
load_error = "사용자 동기화 상태를 불러오지 못했습니다."
reset_confirm = "사용자 동기화를 Kratos 기준으로 다시 구축하시겠습니까?"
subtitle = "Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다."
[msg.admin.user_projection.forbidden]
description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
[msg.admin.groups.prompt]
user_id = "추가할 사용자의 UUID를 입력하세요:"
@@ -263,7 +293,7 @@ empty = "소속된 사용자가 없습니다."
limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다."
[msg.admin.tenants.registry]
count = "총 {{count}}개 테넌트"
count = "총 {{count}}개 테넌트가 등록되어 있습니다."
[msg.admin.tenants.schema]
empty = "등록된 커스텀 필드가 없습니다. 필드 추가를 눌러 시작하세요."
@@ -761,7 +791,7 @@ list = "List"
section = "API Keys"
[ui.admin.api_keys.list.registry]
title = "API Key Registry"
title = "API 키 레지스트리"
[ui.admin.api_keys.list.table]
actions = "ACTIONS"
@@ -912,6 +942,27 @@ user = "사용자"
tenant_integrity = "테넌트 정합성"
user_integrity = "사용자 정합성"
[msg.admin.integrity.section.tenant_integrity]
description = "테넌트 slug 중복과 부모 관계 이상을 확인합니다."
[msg.admin.integrity.section.user_integrity]
description = "사용자와 로그인 ID 참조의 고아 레코드를 확인합니다."
[ui.admin.integrity.check.duplicate_tenant_slugs]
title = "중복 테넌트 slug"
[ui.admin.integrity.check.orphan_tenant_parents]
title = "고아 테넌트 부모"
[ui.admin.integrity.check.orphan_user_login_id_tenants]
title = "고아 로그인 ID 테넌트"
[ui.admin.integrity.check.orphan_user_login_id_users]
title = "고아 로그인 ID 사용자"
[ui.admin.integrity.check.orphan_user_tenant_memberships]
title = "고아 사용자 테넌트 소속"
[ui.admin.nav]
org_chart = "조직도"
api_keys = "API 키"
@@ -924,9 +975,63 @@ relying_parties = "애플리케이션(RP)"
tenant_dashboard = "테넌트 대시보드"
user_groups = "유저 그룹"
tenants = "테넌트"
user_projection = "사용자 Projection"
user_projection = "사용자 동기화"
users = "사용자"
[ui.admin.user_projection]
loading = "불러오는 중"
title = "사용자 동기화 관리"
[ui.admin.user_projection.actions]
reconcile = "재동기화"
reset = "초기화 후 재구축"
[ui.admin.user_projection.card]
description = "Backend DB 통계가 참조하는 사용자 read model 상태입니다."
title = "Kratos 사용자 동기화"
[ui.admin.user_projection.forbidden]
title = "접근 권한이 없습니다"
[ui.admin.user_projection.status]
failed = "실패"
not_ready = "준비되지 않음"
ready = "준비됨"
[ui.admin.user_projection.summary]
last_synced = "마지막 동기화"
projected_users = "동기화 사용자"
status = "상태"
updated_at = "상태 갱신"
[ui.admin.auth_guard]
subtitle = "관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다."
title = "인증 가드"
[ui.admin.auth_guard.checker]
check = "권한 확인 실행"
checking = "검증 중..."
denied = "접근 거부"
denied_description = "해당 사용자는 요청한 리소스에 대해 권한이 없습니다."
description = "특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory Keto를 통해 실시간으로 확인합니다."
object_id = "대상 ID"
object_id_placeholder = "Tenant UUID 등"
allowed = "접근 허용"
allowed_description = "해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 포함)"
namespace = "네임스페이스"
relation = "관계"
relation_placeholder = "view, manage, admins..."
subject = "주체 (User:ID)"
subject_placeholder = "User:uuid 또는 Namespace:ID#Relation"
title = "ReBAC 권한 검증 도구"
[ui.admin.auth_guard.checker.namespace]
label = "네임스페이스"
relying_party = "애플리케이션(RP)"
system = "시스템"
tenant = "테넌트"
tenant_group = "테넌트 그룹"
[ui.admin.org]
download_template = "템플릿 다운로드"
import_btn = "조직/사용자 통합 임포트"
@@ -1091,7 +1196,7 @@ delete_bulk_confirm = "선택한 {{count}}개 테넌트를 삭제할까요?"
self_delete_blocked = "자신의 계정은 삭제할 수 없습니다."
[ui.admin.apikeys.registry]
title = "API Key Registry"
title = "API 키 레지스트리"
[ui.admin.tenants.members]
delete_selected = "선택 삭제"
@@ -1127,7 +1232,7 @@ local_search_placeholder = "테넌트 이름 또는 슬러그 검색"
pick_tenant = "테넌트 선택"
[ui.admin.tenants.registry]
title = "Tenant registry"
title = "테넌트 레지스트리"
[ui.admin.tenants.schema]
add_field = "필드 추가"
@@ -1419,6 +1524,22 @@ menu_title = "계정"
unknown_email = "unknown@example.com"
unknown_name = "Unknown User"
[ui.shell.profile]
menu_aria = "계정 메뉴 열기"
menu_title = "계정"
unknown_email = "unknown@example.com"
unknown_name = "Unknown User"
[ui.shell.nav]
logout = "Logout"
profile = "내 정보"
[ui.shell.role]
rp_admin = "서비스 관리자 (RP Admin)"
super_admin = "시스템 관리자 (Super Admin)"
tenant_admin = "테넌트 관리자 (Tenant Admin)"
user = "일반 사용자 (Tenant Member)"
[ui.dev.clients]
new = "연동 앱 추가"
search_placeholder = "연동 앱 이름/ID로 검색..."
@@ -1624,6 +1745,15 @@ expired = "세션 만료"
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음"
[ui.shell.session]
auto_extend = "세션 만료 관리"
active = "세션 활성"
disabled = "세션 만료 비활성화"
unknown = "알 수 없음"
expired = "세션 만료"
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음"
[ui.userfront]
app_title = "Baron SW 포탈"

View File

@@ -167,6 +167,35 @@ success = ""
[msg.admin.integrity.report]
load_error = ""
[msg.admin.integrity.check.duplicate_tenant_slugs]
description = ""
[msg.admin.integrity.check.orphan_tenant_parents]
description = ""
[msg.admin.integrity.check.orphan_user_login_id_tenants]
description = ""
[msg.admin.integrity.check.orphan_user_login_id_users]
description = ""
[msg.admin.integrity.check.orphan_user_tenant_memberships]
description = ""
[msg.admin.integrity]
subtitle = ""
[msg.admin.user_projection]
action_error = ""
action_success = ""
forbidden_description = ""
load_error = ""
reset_confirm = ""
subtitle = ""
[msg.admin.user_projection.forbidden]
description = ""
[msg.admin.groups.prompt]
user_id = ""
@@ -925,6 +954,21 @@ user = ""
tenant_integrity = ""
user_integrity = ""
[ui.admin.integrity.check.duplicate_tenant_slugs]
title = ""
[ui.admin.integrity.check.orphan_tenant_parents]
title = ""
[ui.admin.integrity.check.orphan_user_login_id_tenants]
title = ""
[ui.admin.integrity.check.orphan_user_login_id_users]
title = ""
[ui.admin.integrity.check.orphan_user_tenant_memberships]
title = ""
[ui.admin.nav]
org_chart = ""
api_keys = ""
@@ -940,6 +984,60 @@ tenants = ""
user_projection = ""
users = ""
[ui.admin.user_projection]
loading = ""
title = ""
[ui.admin.user_projection.actions]
reconcile = ""
reset = ""
[ui.admin.user_projection.card]
description = ""
title = ""
[ui.admin.user_projection.forbidden]
title = ""
[ui.admin.user_projection.status]
failed = ""
not_ready = ""
ready = ""
[ui.admin.user_projection.summary]
last_synced = ""
projected_users = ""
status = ""
updated_at = ""
[ui.admin.auth_guard]
subtitle = ""
title = ""
[ui.admin.auth_guard.checker]
check = ""
checking = ""
denied = ""
denied_description = ""
description = ""
object_id = ""
object_id_placeholder = ""
allowed = ""
allowed_description = ""
namespace = ""
relation = ""
relation_placeholder = ""
subject = ""
subject_placeholder = ""
title = ""
[ui.admin.auth_guard.checker.namespace]
label = ""
relying_party = ""
system = ""
tenant = ""
tenant_group = ""
[ui.admin.org]
download_template = ""
import_btn = ""
@@ -1397,6 +1495,22 @@ menu_title = ""
unknown_email = ""
unknown_name = ""
[ui.shell.profile]
menu_aria = ""
menu_title = ""
unknown_email = ""
unknown_name = ""
[ui.shell.nav]
logout = ""
profile = ""
[ui.shell.role]
rp_admin = ""
super_admin = ""
tenant_admin = ""
user = ""
[ui.dev.clients]
new = ""
search_placeholder = ""
@@ -1603,6 +1717,15 @@ expired = ""
expiring = ""
remaining = ""
[ui.shell.session]
auto_extend = ""
active = ""
disabled = ""
unknown = ""
expired = ""
expiring = ""
remaining = ""
[ui.userfront]
app_title = ""

View File

@@ -3,6 +3,7 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "react-oidc-context";
import { RouterProvider } from "react-router-dom";
import LocaleRefreshBoundary from "./components/common/LocaleRefreshBoundary";
import { queryClient } from "./app/queryClient";
import { router } from "./app/routes";
import { Toaster } from "./components/ui/toaster";
@@ -19,7 +20,9 @@ createRoot(rootElement).render(
<StrictMode>
<AuthProvider {...oidcConfig}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<LocaleRefreshBoundary>
<RouterProvider router={router} />
</LocaleRefreshBoundary>
<Toaster />
</QueryClientProvider>
</AuthProvider>

View File

@@ -0,0 +1,181 @@
type Vars = Record<string, string | number>;
const translations: Record<"ko" | "en", Record<string, string>> = {
ko: {
"ui.admin.auth_guard.title": "인증 가드",
"ui.admin.auth_guard.subtitle":
"관리자 권한과 ReBAC 관계를 실제 정책 엔진 기준으로 확인합니다.",
"ui.admin.auth_guard.checker.title": "ReBAC 권한 검증 도구",
"ui.admin.auth_guard.checker.description":
"특정 주체(Subject)가 특정 리소스(Object)에 대해 권한이 있는지 Ory Keto를 통해 실시간으로 확인합니다.",
"ui.admin.auth_guard.checker.namespace.label": "네임스페이스",
"ui.admin.auth_guard.checker.namespace.tenant": "테넌트",
"ui.admin.auth_guard.checker.namespace.tenant_group": "테넌트 그룹",
"ui.admin.auth_guard.checker.namespace.relying_party": "애플리케이션(RP)",
"ui.admin.auth_guard.checker.namespace.system": "시스템",
"ui.admin.auth_guard.checker.relation": "관계",
"ui.admin.auth_guard.checker.object_id": "대상 ID",
"ui.admin.auth_guard.checker.subject": "주체 (User:ID)",
"ui.admin.auth_guard.checker.check": "권한 확인 실행",
"ui.admin.auth_guard.checker.checking": "검증 중...",
"ui.admin.auth_guard.checker.allowed": "접근 허용",
"ui.admin.auth_guard.checker.allowed_description":
"해당 사용자는 요청한 리소스에 대해 권한이 있습니다. (상속 포함)",
"ui.admin.auth_guard.checker.denied": "접근 거부",
"ui.admin.auth_guard.checker.denied_description":
"해당 사용자는 요청한 리소스에 대해 권한이 없습니다.",
"ui.admin.integrity.check.duplicate_tenant_slugs.title": "중복 테넌트 slug",
"ui.admin.integrity.section.tenant_integrity": "테넌트 정합성",
"ui.admin.integrity.section.user_integrity": "사용자 정합성",
"ui.admin.integrity.title": "데이터 정합성 검증",
"msg.admin.integrity.subtitle":
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
"msg.admin.integrity.section.tenant_integrity.description":
"테넌트 slug 중복과 부모 관계 이상을 확인합니다.",
"msg.admin.integrity.section.user_integrity.description":
"사용자와 로그인 ID 참조의 고아 레코드를 확인합니다.",
"msg.admin.audit.registry.description":
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
"ui.admin.integrity.recheck.run": "다시 검사",
"ui.admin.integrity.recheck.running": "검사 중",
"ui.admin.integrity.status.fail": "실패",
"ui.admin.integrity.status.pass": "정상",
"ui.admin.integrity.status.warning": "주의",
"ui.admin.integrity.orphan_login_ids.title": "유령 로그인 ID 정리",
"ui.admin.integrity.forbidden.title": "접근 권한이 없습니다",
"ui.admin.integrity.summary.title": "정합성 최종 검증",
"ui.admin.user_projection.actions.reconcile": "재동기화",
"ui.admin.user_projection.actions.reset": "초기화 후 재구축",
"ui.admin.user_projection.card.description":
"Backend DB 통계가 참조하는 사용자 read model 상태입니다.",
"ui.admin.user_projection.card.title": "Kratos 사용자 동기화",
"ui.admin.user_projection.forbidden.title": "접근 권한이 없습니다",
"ui.admin.user_projection.loading": "불러오는 중",
"ui.admin.user_projection.status.failed": "실패",
"ui.admin.user_projection.status.not_ready": "준비되지 않음",
"ui.admin.user_projection.status.ready": "준비됨",
"ui.admin.user_projection.summary.last_synced": "마지막 동기화",
"ui.admin.user_projection.summary.projected_users": "동기화 사용자",
"ui.admin.user_projection.summary.status": "상태",
"ui.admin.user_projection.summary.updated_at": "상태 갱신",
"ui.admin.user_projection.title": "사용자 동기화 관리",
"msg.admin.user_projection.subtitle":
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
"msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.",
"msg.admin.users.list.registry.count":
"총 {{count}}명의 사용자가 등록되어 있습니다.",
"msg.admin.integrity.check.duplicate_tenant_slugs.description":
"삭제되지 않은 tenant의 LOWER(TRIM(slug)) 기준 중복을 검사합니다.",
"msg.admin.integrity.check.orphan_tenant_parents.description":
"tenants.parent_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.",
"msg.admin.integrity.check.orphan_user_login_id_tenants.description":
"user_login_ids.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.",
"msg.admin.integrity.check.orphan_user_login_id_users.description":
"user_login_ids.user_id가 존재하지 않거나 soft-deleted user를 참조하는지 검사합니다.",
"msg.admin.integrity.check.orphan_user_tenant_memberships.description":
"users.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.",
"msg.admin.integrity.recheck.running":
"정합성 검사를 실행 중입니다.",
"msg.admin.integrity.recheck.success": "검사가 완료되었습니다.",
"msg.admin.user_projection.forbidden.description":
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
},
en: {
"ui.admin.auth_guard.title": "Auth Guard",
"ui.admin.auth_guard.subtitle":
"Verify admin privileges and ReBAC relationships against the policy engine.",
"ui.admin.auth_guard.checker.title": "ReBAC permission checker",
"ui.admin.auth_guard.checker.description":
"Check in real time whether a subject has access to a resource through Ory Keto.",
"ui.admin.auth_guard.checker.namespace.label": "Namespace",
"ui.admin.auth_guard.checker.namespace.tenant": "Tenant",
"ui.admin.auth_guard.checker.namespace.tenant_group": "TenantGroup",
"ui.admin.auth_guard.checker.namespace.relying_party": "RelyingParty",
"ui.admin.auth_guard.checker.namespace.system": "System",
"ui.admin.auth_guard.checker.relation": "Relation",
"ui.admin.auth_guard.checker.object_id": "Object ID",
"ui.admin.auth_guard.checker.subject": "Subject (User:ID)",
"ui.admin.auth_guard.checker.check": "Check permission",
"ui.admin.auth_guard.checker.checking": "Checking...",
"ui.admin.auth_guard.checker.allowed": "Access ALLOWED",
"ui.admin.auth_guard.checker.allowed_description":
"The subject has access to the requested resource, including inherited permissions.",
"ui.admin.auth_guard.checker.denied": "Access DENIED",
"ui.admin.auth_guard.checker.denied_description":
"The subject does not have access to the requested resource.",
"ui.admin.integrity.check.duplicate_tenant_slugs.title": "Duplicate tenant slug",
"ui.admin.integrity.section.tenant_integrity": "Tenant integrity",
"ui.admin.integrity.section.user_integrity": "User integrity",
"ui.admin.integrity.title": "Data Integrity Check",
"msg.admin.integrity.subtitle":
"Review integrity status and inspect checks across the admin data model.",
"msg.admin.integrity.section.tenant_integrity.description":
"Checks duplicate tenant slugs and orphan parent relationships.",
"msg.admin.integrity.section.user_integrity.description":
"Checks orphan records for users and login ID references.",
"msg.admin.audit.registry.description":
"Filter recent audit logs by search criteria and review action history quickly.",
"ui.admin.integrity.recheck.run": "Run again",
"ui.admin.integrity.recheck.running": "Checking",
"ui.admin.integrity.status.fail": "Failed",
"ui.admin.integrity.status.pass": "Passed",
"ui.admin.integrity.status.warning": "Warning",
"ui.admin.integrity.orphan_login_ids.title": "Orphan Login ID Cleanup",
"ui.admin.integrity.forbidden.title": "Access denied",
"ui.admin.integrity.summary.title": "Final integrity check",
"ui.admin.user_projection.actions.reconcile": "Re-sync",
"ui.admin.user_projection.actions.reset": "Reset and rebuild",
"ui.admin.user_projection.card.description":
"Current user read model state referenced by backend DB statistics.",
"ui.admin.user_projection.card.title": "Kratos users projection",
"ui.admin.user_projection.forbidden.title": "Access denied",
"ui.admin.user_projection.loading": "Loading",
"ui.admin.user_projection.status.failed": "failed",
"ui.admin.user_projection.status.not_ready": "not ready",
"ui.admin.user_projection.status.ready": "ready",
"ui.admin.user_projection.summary.last_synced": "Last synced",
"ui.admin.user_projection.summary.projected_users": "Projected users",
"ui.admin.user_projection.summary.status": "Status",
"ui.admin.user_projection.summary.updated_at": "Updated at",
"ui.admin.user_projection.title": "User Projection Management",
"msg.admin.user_projection.subtitle":
"Review and sync the Kratos user read model.",
"msg.admin.users.list.subtitle":
"Search and manage users registered in the current tenant.",
"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)).",
"msg.admin.integrity.check.orphan_tenant_parents.description":
"Checks whether tenants.parent_id points to a missing or soft-deleted tenant.",
"msg.admin.integrity.check.orphan_user_login_id_tenants.description":
"Checks whether user_login_ids.tenant_id points to a missing or soft-deleted tenant.",
"msg.admin.integrity.check.orphan_user_login_id_users.description":
"Checks whether user_login_ids.user_id points to a missing or soft-deleted user.",
"msg.admin.integrity.check.orphan_user_tenant_memberships.description":
"Checks whether users.tenant_id points to a missing or soft-deleted tenant.",
"msg.admin.integrity.recheck.running": "Running integrity check.",
"msg.admin.integrity.recheck.success": "Check completed.",
"msg.admin.user_projection.forbidden.description":
"This screen is only available to super_admin users.",
},
};
function format(template: string, vars?: Vars) {
if (!vars) {
return template;
}
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (match, key) => {
const value = vars[key];
return value === undefined || value === null ? match : String(value);
});
}
export function createI18nMock() {
return {
t(key: string, fallback?: string, vars?: Vars) {
const locale = window.localStorage.getItem("locale") === "en" ? "en" : "ko";
const template = translations[locale][key] ?? fallback ?? key;
return format(template, vars);
},
};
}