첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
송대일 committed 2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 files changed
+312864

No files matched your search

@@ -0,0 +1,7 @@
import { QueryClient } from "@tanstack/react-query";
import { queryClientDefaultOptions } from "../../../common/core/query/queryClient";
export const queryClient = new QueryClient({
defaultOptions: queryClientDefaultOptions,
});
@@ -0,0 +1,13 @@
import { matchRoutes } from "react-router-dom";
import { describe, expect, it } from "vitest";
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
import { devFrontRoutes } from "./routes";
describe("devfront routes", () => {
it("accepts the auth callback path used by the OIDC redirect URI", () => {
const matches = matchRoutes(devFrontRoutes, DEVFRONT_AUTH_CALLBACK_PATH);
expect(matches).not.toBeNull();
expect(matches?.at(-1)?.route.path).toBe(DEVFRONT_AUTH_CALLBACK_PATH);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { createBrowserRouter, type RouteObject } from "react-router-dom";
import AppLayout from "../components/layout/AppLayout";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard";
import LoginPage from "../features/auth/LoginPage";
import ClientConsentsPage from "../features/clients/ClientConsentsPage";
import ClientDetailsPage from "../features/clients/ClientDetailsPage";
import ClientGeneralPage from "../features/clients/ClientGeneralPage";
import ClientRelationsPage from "../features/clients/ClientRelationsPage";
import ClientsPage from "../features/clients/ClientsPage";
import DeveloperRequestPage from "../features/developer-request/DeveloperRequestPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import ProfilePage from "../features/profile/ProfilePage";
import { DEVFRONT_AUTH_CALLBACK_PATH } from "../lib/authConfig";
const devFrontAppChildren: RouteObject[] = [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "clients", element: <ClientsPage /> },
{ path: "clients/new", element: <ClientGeneralPage /> },
{ path: "clients/:id", element: <ClientDetailsPage /> },
{ path: "clients/:id/consents", element: <ClientConsentsPage /> },
{ path: "clients/:id/settings", element: <ClientGeneralPage /> },
{
path: "clients/:id/relationships",
element: <ClientRelationsPage />,
},
{ path: "developer-requests", element: <DeveloperRequestPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "profile", element: <ProfilePage /> },
];
export const devFrontRoutes: RouteObject[] = [
{
path: "/login",
element: <LoginPage />,
},
{
path: DEVFRONT_AUTH_CALLBACK_PATH,
element: <AuthCallbackPage />,
},
{
path: "/",
element:
import.meta.env.MODE === "development" ? <AppLayout /> : <AuthGuard />,
children:
import.meta.env.MODE === "development"
? devFrontAppChildren
: [
{
element: <AppLayout />,
children: devFrontAppChildren,
},
],
},
];
export const router = createBrowserRouter(
devFrontRoutes,
// React Router v7 플래그 사전 적용 (현재 타입 정의에 없어 any 캐스팅)
{
future: {
v7_startTransition: true,
},
} as unknown as Parameters<typeof createBrowserRouter>[1],
);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,66 @@
import { createRoot } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DeveloperAccessRequestCard } from "./DeveloperAccessRequestCard";
describe("DeveloperAccessRequestCard", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("renders the request CTA for pending and denied states", () => {
const onAction = vi.fn();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<DeveloperAccessRequestCard
title="운영 현황"
isPending={true}
canRequest={false}
pendingMessage="검토 중"
deniedMessage="거부됨"
pendingDetailMessage="승인 대기"
deniedDetailMessage="신청 필요"
actionLabel="개발자 권한 신청"
onAction={onAction}
/>,
);
});
expect(container.querySelector("h2")?.textContent).toBe("운영 현황");
expect(container.textContent).toContain("검토 중");
expect(container.textContent).toContain("승인 대기");
const button = container.querySelector("button");
expect(button?.textContent).toBe("개발자 권한 신청");
act(() => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onAction).toHaveBeenCalledTimes(1);
act(() => {
root.render(
<DeveloperAccessRequestCard
title="감사 로그"
isPending={false}
canRequest={true}
pendingMessage="검토 중"
deniedMessage="거부됨"
pendingDetailMessage="승인 대기"
deniedDetailMessage="신청 필요"
actionLabel="개발자 권한 신청"
onAction={onAction}
/>,
);
});
expect(container.querySelector("h2")?.textContent).toBe("감사 로그");
expect(container.textContent).toContain("거부됨");
expect(container.textContent).toContain("신청 필요");
expect(container.querySelector("button")).not.toBeNull();
});
});
@@ -0,0 +1,48 @@
interface DeveloperAccessRequestCardProps {
title: string;
isPending: boolean;
canRequest: boolean;
pendingMessage: string;
deniedMessage: string;
pendingDetailMessage: string;
deniedDetailMessage: string;
actionLabel: string;
onAction: () => void;
}
export function DeveloperAccessRequestCard({
title,
isPending,
canRequest,
pendingMessage,
deniedMessage,
pendingDetailMessage,
deniedDetailMessage,
actionLabel,
onAction,
}: DeveloperAccessRequestCardProps) {
const showAction = isPending || canRequest;
return (
<div className="rounded-xl border border-border/60 bg-card p-8 text-center">
<div className="space-y-3">
<h2 className="text-2xl font-semibold tracking-tight">{title}</h2>
<p className="font-medium text-foreground">
{isPending ? pendingMessage : deniedMessage}
</p>
<p className="text-sm text-muted-foreground">
{isPending ? pendingDetailMessage : deniedDetailMessage}
</p>
{showAction && (
<button
type="button"
className="font-bold text-primary hover:underline"
onClick={onAction}
>
{actionLabel}
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,81 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ForbiddenMessage } from "./ForbiddenMessage";
const authState = {
user: {
profile: {
role: "user",
},
},
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
});
async function renderMessage(resourceToken: "audit" | "clients" | "consents") {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
await act(async () => {
root.render(<ForbiddenMessage resourceToken={resourceToken} />);
});
return container;
}
describe("ForbiddenMessage", () => {
it("renders resource-specific user guidance", async () => {
authState.user.profile.role = "user";
const audit = await renderMessage("audit");
expect(audit.textContent).toContain("Audit Logs");
expect(audit.textContent).toContain("audit read relationship");
const consents = await renderMessage("consents");
expect(consents.textContent).toContain("User Consent Grants");
expect(consents.textContent).toContain("operational relationship");
const clients = await renderMessage("clients");
expect(clients.textContent).toContain("Connected Applications");
expect(clients.textContent).toContain("target application");
});
it("renders specific guidance for privileged admin roles", async () => {
authState.user.profile.role = "rp_admin";
const rpAdmin = await renderMessage("clients");
expect(rpAdmin.textContent).toContain(
"RP administrators can only access resources for their assigned applications.",
);
authState.user.profile.role = "tenant_admin";
const tenantAdmin = await renderMessage("clients");
expect(tenantAdmin.textContent).toContain(
"Tenant administrator permissions are not configured correctly or have expired.",
);
});
});
@@ -0,0 +1,67 @@
import { ShieldAlert } from "lucide-react";
import { useAuth } from "react-oidc-context";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
interface Props {
resourceToken: "audit" | "clients" | "consents";
}
export function ForbiddenMessage({ resourceToken }: Props) {
const auth = useAuth();
const rawProfile = auth.user?.profile as Record<string, unknown> | undefined;
const role = resolveProfileRole(rawProfile);
let explanation = t(
"msg.dev.forbidden.default",
"You do not have permission to access this resource. Contact your administrator.",
);
if (role === "user") {
if (resourceToken === "consents") {
explanation = t(
"msg.dev.forbidden.user.consents",
"Viewing consent records for this application requires an operational relationship. Request access from an administrator if needed.",
);
} else if (resourceToken === "audit") {
explanation = t(
"msg.dev.forbidden.user.audit",
"Viewing audit logs for this application requires an audit read relationship. Request access from an administrator if needed.",
);
} else {
explanation = t(
"msg.dev.forbidden.user.clients",
"Standard user accounts can use this feature only when an operational or administrative relationship is granted for the target application. Request access from an administrator if needed.",
);
}
} else if (role === "rp_admin") {
explanation = t(
"msg.dev.forbidden.rp_admin",
"RP administrators can only access resources for their assigned applications.",
);
} else if (role === "tenant_admin") {
explanation = t(
"msg.dev.forbidden.tenant_admin",
"Tenant administrator permissions are not configured correctly or have expired.",
);
}
const resourceLabel =
resourceToken === "audit"
? t("ui.dev.audit.title", "Audit Logs")
: resourceToken === "consents"
? t("ui.dev.clients.consents.title", "User Consent Grants")
: t("ui.dev.clients.registry.subtitle", "Connected Applications");
const title = t("msg.dev.forbidden.title", "Access denied: {{resource}}", {
resource: resourceLabel,
});
return (
<div className="flex flex-col items-center justify-center p-12 text-center text-red-500/90 gap-3">
<ShieldAlert className="h-10 w-10 text-red-500/80 mb-2" />
<h3 className="text-xl font-bold text-foreground">{title}</h3>
<p className="text-sm text-muted-foreground max-w-md">{explanation}</p>
</div>
);
}
@@ -0,0 +1,77 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../lib/i18n", () => ({
t: (_key: string, fallback?: string) => fallback ?? _key,
}));
import LanguageSelector from "./LanguageSelector";
const roots: Root[] = [];
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState({}, "", "/");
document.body.innerHTML = "";
});
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
});
function renderSelector() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<LanguageSelector />);
});
return container;
}
describe("LanguageSelector", () => {
it("prefers the locale stored in localStorage", () => {
window.localStorage.setItem("locale", "en");
const container = renderSelector();
const select = container.querySelector("select") as HTMLSelectElement;
expect(select.value).toBe("en");
});
it("falls back to the path locale when storage is empty", () => {
window.history.replaceState({}, "", "/ko");
const container = renderSelector();
const select = container.querySelector("select") as HTMLSelectElement;
expect(select.value).toBe("ko");
});
it("saves the selected locale and dispatches a development event", () => {
vi.stubEnv("MODE", "development");
const dispatchEvent = vi.spyOn(window, "dispatchEvent");
window.history.replaceState({}, "", "/ko");
const container = renderSelector();
const select = container.querySelector("select") as HTMLSelectElement;
act(() => {
select.value = "en";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(window.localStorage.getItem("locale")).toBe("en");
expect(dispatchEvent).toHaveBeenCalled();
expect(select.value).toBe("en");
});
});
@@ -0,0 +1,58 @@
import { useState } from "react";
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];
function resolveLocale(): Locale {
if (typeof window === "undefined") {
return "ko";
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (stored === "ko" || stored === "en") {
return stored;
}
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale === "ko" || pathLocale === "en") {
return pathLocale;
}
const browserLang = window.navigator.language.toLowerCase();
return browserLang.startsWith("ko") ? "ko" : "en";
}
function LanguageSelector() {
const [locale, setLocale] = useState<Locale>(resolveLocale());
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();
};
return (
<select
value={locale}
onChange={(event) => handleChange(event.target.value as Locale)}
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"
aria-label={t("ui.common.language", "언어")}
>
<option value="ko">{t("ui.common.language_ko", "한국어")}</option>
<option value="en">{t("ui.common.language_en", "English")}</option>
</select>
);
}
export default LanguageSelector;
@@ -0,0 +1,184 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AppLayout from "./AppLayout";
const authState = {
isAuthenticated: true,
isLoading: false,
activeNavigator: undefined as string | undefined,
error: null as Error | null,
user: {
access_token: "access-token",
expires_at: Math.floor(Date.now() / 1000) + 120,
profile: {
sub: "user-1",
name: "Dev Admin",
email: "dev@example.com",
role: "super_admin",
},
},
signinSilent: vi.fn(),
removeUser: vi.fn(),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../../features/auth/authApi", () => ({
fetchMe: vi.fn(async () => ({
id: "user-1",
name: "Fetched Dev Admin",
email: "fetched@example.com",
role: "super_admin",
})),
}));
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const roots: Root[] = [];
beforeEach(() => {
authState.isAuthenticated = true;
authState.isLoading = false;
authState.activeNavigator = undefined;
authState.error = null;
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
authState.signinSilent.mockReset();
authState.signinSilent.mockResolvedValue(undefined);
authState.removeUser.mockReset();
window.localStorage.clear();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
});
async function renderLayout(initialEntry = "/clients") {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/" element={<AppLayout />}>
<Route path="clients" element={<div>Client outlet</div>} />
<Route path="profile" element={<div>Profile outlet</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("devfront AppLayout", () => {
it("renders shell navigation, profile summary, and outlet content", async () => {
const container = await renderLayout();
expect(container.textContent).toContain("Developer Console");
expect(container.textContent).toContain("Clients");
expect(container.textContent).toContain("Client outlet");
expect(container.textContent).toContain("Fetched Dev Admin");
expect(document.documentElement.classList.contains("light")).toBe(true);
});
it("toggles the sidebar and persists the collapsed state", async () => {
const container = await renderLayout();
const collapseButton = container.querySelector(
'button[aria-label="사이드바 접기"]',
) as HTMLButtonElement;
await act(async () => {
collapseButton.click();
});
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
"true",
);
expect(
container.querySelector('button[aria-label="사이드바 펼치기"]'),
).not.toBeNull();
});
it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
const container = await renderLayout();
const themeButton = container.querySelector(
'button[aria-label="Toggle theme"]',
) as HTMLButtonElement;
await act(async () => {
themeButton.click();
});
expect(document.documentElement.classList.contains("dark")).toBe(true);
const profileButton = container.querySelector(
'button[aria-label="Open account menu"]',
) as HTMLButtonElement;
await act(async () => {
profileButton.click();
});
expect(container.textContent).toContain("My Profile");
const profileMenuItem = Array.from(
container.querySelectorAll('button[role="menuitem"]'),
).find((button) => button.textContent?.includes("My Profile"));
await act(async () => {
(profileMenuItem as HTMLButtonElement).click();
});
expect(container.textContent).toContain("Profile outlet");
const logoutButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Logout"),
);
await act(async () => {
(logoutButton as HTMLButtonElement).click();
});
expect(window.confirm).toHaveBeenCalled();
expect(authState.removeUser).toHaveBeenCalled();
});
it("attempts silent renewal after user action when the session is expiring", async () => {
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
await renderLayout();
await act(async () => {
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
});
expect(authState.signinSilent).toHaveBeenCalled();
});
});
@@ -0,0 +1,601 @@
import { useQuery } from "@tanstack/react-query";
import {
ChevronDown,
ClipboardCheck,
LayoutDashboard,
LogOut,
Moon,
NotebookTabs,
ShieldHalf,
Sun,
User as UserIcon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
AppSidebar,
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellSidebarCollapsed,
readShellTheme,
type ShellSidebarNavItem,
type ShellTranslator,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
writeShellSidebarCollapsed,
} from "../../../../common/shell";
import { fetchMe } from "../../features/auth/authApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
const LOCALE_CHANGED_EVENT = "baron_locale_changed";
const navItems: ShellSidebarNavItem[] = [
{
labelKey: "ui.dev.nav.overview",
labelFallback: "Overview",
to: "/",
icon: LayoutDashboard,
end: true,
},
{
labelKey: "ui.dev.nav.clients",
labelFallback: "Clients",
to: "/clients",
icon: ShieldHalf,
},
{
labelKey: "ui.dev.nav.audit_logs",
labelFallback: "Audit Logs",
to: "/audit-logs",
icon: NotebookTabs,
},
{
labelKey: "ui.dev.nav.developer_request",
labelFallback: "Developer Access Request",
to: "/developer-requests",
icon: ClipboardCheck,
},
];
type SessionStatusProps = {
expiresAtSec?: number | null;
t: ShellTranslator;
};
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
}
function SessionStatusBadge(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return (
<span
className={[
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionStatus.text}
</span>
);
}
function SessionStatusText(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return <>{sessionStatus.text}</>;
}
function AppLayout() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const profileMenuRef = useRef<HTMLDivElement>(null);
const isRenewInFlightRef = useRef(false);
const lastRenewAttemptAtRef = useRef(0);
const lastVisitedRouteRef = useRef<string | null>(null);
const isDevelopmentRuntime = import.meta.env.MODE === "development";
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
readShellSidebarCollapsed(false),
);
const [, setDevelopmentRenderRevision] = useState(0);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
);
const hasAccessToken = Boolean(auth.user?.access_token);
const { data: profile } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const handleLogout = () => {
if (
window.confirm(
t("msg.dev.logout_confirm", "Are you sure you want to log out?"),
)
) {
auth.removeUser();
navigate("/login");
}
};
useEffect(() => {
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
if (!isDevelopmentRuntime) {
return;
}
const rerenderDevelopmentShell = () => {
setDevelopmentRenderRevision((value) => value + 1);
};
window.addEventListener(LOCALE_CHANGED_EVENT, rerenderDevelopmentShell);
return () => {
window.removeEventListener(
LOCALE_CHANGED_EVENT,
rerenderDevelopmentShell,
);
};
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
profileMenuRef.current &&
!profileMenuRef.current.contains(event.target as Node)
) {
setIsProfileMenuOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
useEffect(() => {
const maybeRenewSession = async () => {
const now = Date.now();
if (
!shouldAttemptSlidingSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
try {
await auth.signinSilent();
} catch (error) {
console.error("Silent session renewal failed.", error);
} finally {
isRenewInFlightRef.current = false;
}
};
const handleUserAction = () => {
void maybeRenewSession();
};
window.addEventListener("pointerdown", handleUserAction);
window.addEventListener("keydown", handleUserAction);
return () => {
window.removeEventListener("pointerdown", handleUserAction);
window.removeEventListener("keydown", handleUserAction);
};
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
]);
useEffect(() => {
if (isDevelopmentRuntime) {
return;
}
const maybeKeepSessionAlive = async () => {
const now = Date.now();
if (
!shouldAttemptUnlimitedSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
try {
await auth.signinSilent();
} catch (error) {
console.error("Unlimited session keepalive renewal failed.", error);
} finally {
isRenewInFlightRef.current = false;
}
};
const timer = window.setInterval(() => {
void maybeKeepSessionAlive();
}, 30_000);
void maybeKeepSessionAlive();
return () => {
window.clearInterval(timer);
};
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
]);
useEffect(() => {
const routeKey = `${location.pathname}${location.search}${location.hash}`;
if (lastVisitedRouteRef.current === null) {
lastVisitedRouteRef.current = routeKey;
return;
}
if (lastVisitedRouteRef.current === routeKey) {
return;
}
lastVisitedRouteRef.current = routeKey;
const now = Date.now();
if (
!shouldAttemptSlidingSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
void auth
.signinSilent()
.catch((error) => {
console.error("Silent session renewal failed.", error);
})
.finally(() => {
isRenewInFlightRef.current = false;
});
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
location.hash,
location.pathname,
location.search,
]);
const toggleTheme = () => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
};
const profileSummary = buildShellProfileSummary({
profileName:
profile?.name ||
auth.user?.profile?.name?.toString() ||
auth.user?.profile?.preferred_username?.toString() ||
auth.user?.profile?.nickname?.toString(),
profileEmail: profile?.email || auth.user?.profile?.email?.toString(),
fallbackName: t("ui.shell.profile.unknown_name", "Unknown User"),
fallbackEmail: t("ui.shell.profile.unknown_email", "unknown@example.com"),
});
const currentRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const displayRoleKey = profile?.role || currentRole;
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
writeShellSessionExpiryEnabled(next);
return next;
});
};
const handleSidebarToggle = () => {
setIsSidebarCollapsed((prev) => {
const next = !prev;
writeShellSidebarCollapsed(next);
return next;
});
};
const sidebarNavContent = (
<div className={shellLayoutClasses.navList}>
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => {
const label = t(labelKey, labelFallback);
return (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
title={label}
aria-label={label}
>
<Icon size={18} />
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
</NavLink>
);
})}
</div>
);
const sidebarFooterContent = (
<div className="border-t border-border/50 px-3 pt-4">
<button
type="button"
onClick={handleLogout}
className={
isSidebarCollapsed
? shellLayoutClasses.logoutButtonCollapsed
: shellLayoutClasses.logoutButton
}
title={t("ui.shell.nav.logout", "Logout")}
>
<LogOut size={18} />
<span className={isSidebarCollapsed ? "sr-only" : ""}>
{t("ui.shell.nav.logout", "Logout")}
</span>
</button>
</div>
);
return (
<div
className={
isSidebarCollapsed
? shellLayoutClasses.rootCollapsed
: shellLayoutClasses.root
}
>
<AppSidebar
brandLabel={t("ui.dev.brand", "Baron Sign In")}
brandTitle={t("ui.dev.console_title", "Developer Console")}
brandIcon={<ShieldHalf size={20} />}
navContent={sidebarNavContent}
footerContent={sidebarFooterContent}
collapsed={isSidebarCollapsed}
onToggleCollapsed={handleSidebarToggle}
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
/>
<div className={shellLayoutClasses.content}>
<header className={shellLayoutClasses.header}>
<div className={shellLayoutClasses.headerInner}>
<div className={shellLayoutClasses.headerTitleWrap}>
<p className="text-xs uppercase tracking-[0.22em] text-muted-foreground">
{t("ui.dev.header.plane", "Dev Plane")}
</p>
<span className="text-lg font-semibold">
{t("ui.dev.header.subtitle", "Manage your applications")}
</span>
</div>
<div className={shellLayoutClasses.headerActions}>
<LanguageSelector />
<button
type="button"
onClick={toggleTheme}
className={shellLayoutClasses.actionButton}
aria-label={t("ui.common.theme_toggle", "Toggle theme")}
>
{theme === "light" ? <Sun size={16} /> : <Moon size={16} />}
{theme === "light"
? t("ui.common.theme_light", "Light")
: t("ui.common.theme_dark", "Dark")}
</button>
{isSessionExpiryEnabled ? (
<SessionStatusBadge
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : null}
<div className="relative" ref={profileMenuRef}>
<button
type="button"
onClick={() => setIsProfileMenuOpen((prev) => !prev)}
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={isProfileMenuOpen}
aria-label={t(
"ui.shell.profile.menu_aria",
"Open account menu",
)}
>
<div className={shellLayoutClasses.profileInitial}>
{profileSummary.initial}
</div>
<div className="hidden min-w-0 text-left md:block">
<p className="truncate text-xs font-medium text-foreground">
{profileSummary.name}
</p>
<p className="truncate text-[11px] text-muted-foreground">
{profileSummary.email}
</p>
</div>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${isProfileMenuOpen ? "rotate-180" : ""}`}
/>
</button>
{isProfileMenuOpen ? (
<div role="menu" className={shellLayoutClasses.profileMenu}>
<p className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
{t("ui.shell.profile.menu_title", "Account")}
</p>
<div className={shellLayoutClasses.profileCard}>
<div>
<p className="truncate text-sm font-semibold text-foreground">
{profileSummary.name}
</p>
<p className="truncate text-xs text-muted-foreground">
{profileSummary.email}
</p>
</div>
<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.shell.role.${displayRoleKey}`,
displayRoleKey.toUpperCase(),
)}
</span>
</div>
</div>
<div className={shellLayoutClasses.settingsCard}>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium text-foreground">
{t(
"ui.shell.session.auto_extend",
"Session expiry",
)}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled ? (
<SessionStatusText
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : (
t(
"ui.shell.session.disabled",
"Session expiry disabled",
)
)}
</p>
</div>
<button
type="button"
role="switch"
aria-checked={isSessionExpiryEnabled}
onClick={handleSessionExpiryToggle}
className={[
"relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition",
isSessionExpiryEnabled ? "bg-primary" : "bg-muted",
].join(" ")}
>
<span
className={[
"inline-block h-5 w-5 rounded-full bg-white transition",
isSessionExpiryEnabled
? "translate-x-5"
: "translate-x-1",
].join(" ")}
/>
</button>
</div>
</div>
<button
type="button"
role="menuitem"
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"
onClick={() => {
navigate("/profile");
setIsProfileMenuOpen(false);
}}
>
<UserIcon size={16} className="text-muted-foreground" />
<span>{t("ui.shell.nav.profile", "My Profile")}</span>
</button>
<button
type="button"
role="menuitem"
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"
onClick={handleLogout}
>
<LogOut size={16} />
<span>{t("ui.shell.nav.logout", "Logout")}</span>
</button>
</div>
) : null}
</div>
</div>
</div>
</header>
<main className={shellLayoutClasses.main}>
<Outlet />
</main>
</div>
<Toaster />
</div>
);
}
export default AppLayout;
@@ -0,0 +1,47 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { cn } from "../../lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className,
)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted text-sm font-semibold text-foreground",
className,
)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarFallback, AvatarImage };
@@ -0,0 +1,21 @@
import type * as React from "react";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
import { cn } from "../../lib/utils";
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: CommonBadgeVariant;
}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div
className={cn(getCommonBadgeClasses({ variant }), className)}
{...props}
/>
);
}
export { Badge };
@@ -0,0 +1,31 @@
import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import {
type CommonButtonSize,
type CommonButtonVariant,
getCommonButtonClasses,
} from "../../../../common/ui/button";
import { cn } from "../../lib/utils";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: CommonButtonVariant;
size?: CommonButtonSize;
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(getCommonButtonClasses({ variant, size }), className)}
ref={ref}
{...props}
/>
);
},
);
Button.displayName = "Button";
export { Button };
@@ -0,0 +1,58 @@
import type * as React from "react";
import {
commonCardClass,
commonCardContentClass,
commonCardDescriptionClass,
commonCardFooterClass,
commonCardHeaderClass,
commonCardTitleClass,
} from "../../../../common/ui/card";
import { cn } from "../../lib/utils";
function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn(commonCardClass, className)} {...props} />;
}
function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn(commonCardHeaderClass, className)} {...props} />;
}
function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn(commonCardTitleClass, className)} {...props} />;
}
function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn(commonCardDescriptionClass, className)} {...props} />;
}
function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn(commonCardContentClass, className)} {...props} />;
}
function CardFooter({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn(commonCardFooterClass, className)} {...props} />;
}
export {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
};
@@ -0,0 +1,107 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CopyButton } from "./copy-button";
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
delete (navigator as Navigator & { clipboard?: unknown }).clipboard;
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
document.body.innerHTML = "";
});
function renderCopyButton(value: string, onCopy = vi.fn()) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<CopyButton value={value} onCopy={onCopy} />);
});
return { container, onCopy };
}
describe("CopyButton", () => {
it("copies with the clipboard API when secure context is available", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
Object.defineProperty(window, "isSecureContext", {
value: true,
configurable: true,
});
const { container, onCopy } = renderCopyButton("client-secret");
const button = container.querySelector("button");
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(writeText).toHaveBeenCalledWith("client-secret");
expect(onCopy).toHaveBeenCalledTimes(1);
});
it("falls back to execCommand when clipboard API is unavailable", async () => {
const execCommand = vi.fn(() => true);
Object.defineProperty(document, "execCommand", {
value: execCommand,
configurable: true,
});
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
const { container, onCopy } = renderCopyButton("client-secret");
const button = container.querySelector("button");
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(execCommand).toHaveBeenCalledWith("copy");
expect(onCopy).toHaveBeenCalledTimes(1);
});
it("keeps running when the fallback copy flow fails", async () => {
const execCommand = vi.fn(() => false);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
Object.defineProperty(document, "execCommand", {
value: execCommand,
configurable: true,
});
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
const { container, onCopy } = renderCopyButton("client-secret");
const button = container.querySelector("button");
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(execCommand).toHaveBeenCalledWith("copy");
expect(onCopy).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
});
});
@@ -0,0 +1,75 @@
import { Check, Copy } from "lucide-react";
import * as React from "react";
import { cn } from "../../lib/utils";
import { Button, type ButtonProps } from "./button";
interface CopyButtonProps extends ButtonProps {
value: string;
onCopy?: () => void;
}
export function CopyButton({
value,
onCopy,
className,
variant = "secondary",
size = "icon",
...props
}: CopyButtonProps) {
const [hasCopied, setHasCopied] = React.useState(false);
React.useEffect(() => {
if (hasCopied) {
const timer = setTimeout(() => setHasCopied(false), 1500);
return () => clearTimeout(timer);
}
}, [hasCopied]);
const copyToClipboard = async () => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
// Fallback for non-secure contexts (HTTP) or missing navigator.clipboard
const textArea = document.createElement("textarea");
textArea.value = value;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
textArea.style.top = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand("copy");
if (!successful) throw new Error("execCommand copy failed");
} catch (err) {
console.error("Fallback: Oops, unable to copy", err);
throw err;
} finally {
document.body.removeChild(textArea);
}
}
setHasCopied(true);
if (onCopy) onCopy();
} catch (err) {
console.error("Failed to copy text: ", err);
}
};
return (
<Button
size={size}
variant={variant}
className={cn("relative z-10", className)}
onClick={copyToClipboard}
{...props}
>
<span className="sr-only">Copy</span>
{hasCopied ? (
<Check className="h-4 w-4 text-emerald-500 transition-all scale-110" />
) : (
<Copy className="h-4 w-4 transition-all" />
)}
</Button>
);
}
@@ -0,0 +1,22 @@
import * as React from "react";
import { commonInputClass } from "../../../../common/ui/input";
import { cn } from "../../lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(commonInputClass, className)}
ref={ref}
{...props}
/>
);
},
);
Input.displayName = "Input";
export { Input };
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };
@@ -0,0 +1,44 @@
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import * as React from "react";
import { cn } from "../../lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" && "h-2.5 border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };
@@ -0,0 +1,16 @@
import * as React from "react";
import { cn } from "../../lib/utils";
const Separator = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("shrink-0 bg-border", "h-px w-full", className)}
{...props}
/>
));
Separator.displayName = "Separator";
export { Separator };
@@ -0,0 +1,26 @@
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "../../lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };
@@ -0,0 +1,102 @@
import * as React from "react";
import {
commonTableBodyClass,
commonTableCaptionClass,
commonTableCellClass,
commonTableClass,
commonTableFooterClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { cn } from "../../lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className={commonTableWrapperClass}>
<table ref={ref} className={cn(commonTableClass, className)} {...props} />
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead
ref={ref}
className={cn(commonTableHeaderClass, className)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn(commonTableBodyClass, className)} {...props} />
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(commonTableFooterClass, className)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr ref={ref} className={cn(commonTableRowClass, className)} {...props} />
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th ref={ref} className={cn(commonTableHeadClass, className)} {...props} />
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={cn(commonTableCellClass, className)} {...props} />
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn(commonTableCaptionClass, className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
};
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "../../lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";
export { Textarea };
@@ -0,0 +1,35 @@
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
import { cn } from "../../lib/utils";
import { useToastState } from "./use-toast";
export function Toaster() {
const toasts = useToastState();
if (toasts.length === 0) return null;
return (
<div className="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-[320px]">
{toasts.map((t) => (
<div
key={t.id}
className={cn(
"flex items-center gap-3 rounded-lg border p-4 shadow-lg animate-in slide-in-from-right-full duration-300",
t.type === "success" &&
"bg-emerald-50 border-emerald-200 text-emerald-800 dark:bg-emerald-950 dark:border-emerald-800 dark:text-emerald-200",
t.type === "error" &&
"bg-rose-50 border-rose-200 text-rose-800 dark:bg-rose-950 dark:border-rose-800 dark:text-rose-200",
t.type === "info" &&
"bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-950 dark:border-blue-800 dark:text-blue-200",
)}
>
{t.type === "success" && (
<CheckCircle2 className="h-5 w-5 shrink-0" />
)}
{t.type === "error" && <AlertCircle className="h-5 w-5 shrink-0" />}
{t.type === "info" && <Info className="h-5 w-5 shrink-0" />}
<p className="text-sm font-medium leading-none">{t.message}</p>
</div>
))}
</div>
);
}
@@ -0,0 +1,42 @@
import * as React from "react";
type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
message: string;
type: ToastType;
}
let subscribers: ((toasts: Toast[]) => void)[] = [];
let toasts: Toast[] = [];
const notify = () => {
for (const sub of subscribers) {
sub(toasts);
}
};
export const toast = (message: string, type: ToastType = "success") => {
const id = Math.random().toString(36).substring(2, 9);
toasts = [...toasts, { id, message, type }];
notify();
setTimeout(() => {
toasts = toasts.filter((t) => t.id !== id);
notify();
}, 3000);
};
export const useToastState = () => {
const [state, setState] = React.useState<Toast[]>(toasts);
React.useEffect(() => {
subscribers.push(setState);
return () => {
subscribers = subscribers.filter((sub) => sub !== setState);
};
}, []);
return state;
};
@@ -0,0 +1,212 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AuditLogsPage from "./AuditLogsPage";
const navigateMock = vi.fn();
const fetchMeMock = vi.fn();
const fetchDevAuditLogsMock = vi.fn();
let gateState = {
hasDeveloperAccess: true,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
isLoadingDeveloperAccessGate: false,
};
vi.mock("react-oidc-context", () => ({
useAuth: () => ({
isAuthenticated: true,
isLoading: false,
user: {
access_token: "access-token",
profile: {
role: "super_admin",
tenant_id: "tenant-1",
},
},
}),
}));
vi.mock("react-router-dom", () => ({
useNavigate: () => navigateMock,
}));
vi.mock("../developer-access/developerAccessGate", () => ({
useDeveloperAccessGate: () => gateState,
}));
vi.mock("../../lib/devApi", () => ({
fetchDevAuditLogs: (...args: unknown[]) => fetchDevAuditLogsMock(...args),
}));
vi.mock("../auth/authApi", () => ({
fetchMe: (...args: unknown[]) => fetchMeMock(...args),
}));
vi.mock("../../../../common/core/components/audit", () => ({
AuditLogTable: ({
logs,
onLoadMore,
}: {
logs: Array<{ event_id: string }>;
onLoadMore: () => void;
}) => (
<div>
<div>table:{logs.length}</div>
<button type="button" onClick={onLoadMore}>
Load more
</button>
</div>
),
}));
vi.mock("../../components/common/ForbiddenMessage", () => ({
ForbiddenMessage: ({ resourceToken }: { resourceToken: string }) => (
<div>Forbidden:{resourceToken}</div>
),
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.clearAllMocks();
document.body.innerHTML = "";
});
beforeEach(() => {
gateState = {
hasDeveloperAccess: true,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
isLoadingDeveloperAccessGate: false,
};
fetchMeMock.mockResolvedValue({
id: "user-1",
role: "super_admin",
});
fetchDevAuditLogsMock.mockResolvedValue({
items: [
{
event_id: "evt-1",
timestamp: "2026-05-28T06:07:18.000Z",
user_id: "user-1",
event_type: "CLIENT_UPDATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
details: JSON.stringify({
action: "업데이트",
target_id: "client-a",
}),
},
],
limit: 50,
});
});
async function renderPage() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<AuditLogsPage />
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("AuditLogsPage", () => {
it("shows the loading gate state", async () => {
gateState = {
hasDeveloperAccess: false,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
isLoadingDeveloperAccessGate: true,
};
const container = await renderPage();
expect(container.textContent).toContain("로딩 중...");
});
it("renders the access request card when access is denied", async () => {
gateState = {
hasDeveloperAccess: false,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: true,
isLoadingDeveloperAccessGate: false,
};
const container = await renderPage();
expect(container.textContent).toContain(
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
);
const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent?.includes("개발자 권한 신청"),
);
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
});
it("exports the fetched logs as CSV", async () => {
const createObjectURL = vi
.spyOn(URL, "createObjectURL")
.mockReturnValue("blob:csv");
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockReturnValue();
const clickSpy = vi
.spyOn(HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
const container = await renderPage();
expect(container.textContent).toContain("table:1");
const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent === "CSV 내보내기",
);
expect(button).toBeTruthy();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(createObjectURL).toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalled();
expect(revokeObjectURL).toHaveBeenCalledWith("blob:csv");
});
it("renders the forbidden state on 403 errors", async () => {
fetchDevAuditLogsMock.mockRejectedValueOnce({
response: { status: 403 },
message: "Forbidden",
});
const container = await renderPage();
expect(container.textContent).toContain("Forbidden:audit");
});
});
@@ -0,0 +1,311 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
import * as React from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import { parseAuditDetails } from "../../../../common/core/audit";
import { AuditLogTable } from "../../../../common/core/components/audit";
import { PageHeader } from "../../../../common/core/components/page";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import type { DevAuditLog } from "../../lib/devApi";
import { fetchDevAuditLogs } from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
function toCsv(logs: DevAuditLog[]) {
const header = [
"timestamp",
"user_id",
"status",
"event_type",
"action",
"target_id",
"tenant_id",
"request_id",
];
const rows = logs.map((logItem) => {
const details = parseAuditDetails(logItem.details);
return [
logItem.timestamp,
logItem.user_id || "",
logItem.status,
logItem.event_type,
details.action || "",
details.target_id || "",
details.tenant_id || "",
details.request_id || "",
];
});
return [header, ...rows]
.map((line) =>
line.map((cell) => `"${String(cell).replaceAll('"', '""')}"`).join(","),
)
.join("\n");
}
function downloadCsv(content: string, filename: string) {
const blob = new Blob([content], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}
function AuditLogsPage() {
const navigate = useNavigate();
const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token);
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
const role = resolveProfileRole(userProfile);
const tenantId = userProfile?.tenant_id as string | undefined;
const [searchClientId, setSearchClientId] = React.useState("");
const [searchAction, setSearchAction] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
// Use deferred values to avoid UI lag during rapid typing
const deferredSearchClientId = React.useDeferredValue(searchClientId.trim());
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
const { data: me, isLoading: isLoadingMe } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const profileRole = me?.role?.trim() || role;
const {
hasDeveloperAccess,
isDeveloperRequestPending,
canRequestDeveloperAccess,
isLoadingDeveloperAccessGate,
} = useDeveloperAccessGate({
hasAccessToken,
profileRole,
tenantId,
isLoadingIdentity: isLoadingMe,
});
const query = useInfiniteQuery({
queryKey: [
"dev-audit-logs",
deferredSearchClientId,
deferredSearchAction,
statusFilter,
],
queryFn: ({ pageParam }) =>
fetchDevAuditLogs(50, pageParam, {
client_id: deferredSearchClientId || undefined,
action: deferredSearchAction || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: hasDeveloperAccess,
});
const logs =
query.data?.pages.flatMap((page) =>
page.items.filter((item): item is DevAuditLog => Boolean(item)),
) ?? [];
const handleExportCsv = () => {
const csv = toCsv(logs);
const stamp = new Date().toISOString().replaceAll(":", "-");
downloadCsv(csv, `dev-audit-logs-${stamp}.csv`);
};
if (isLoadingDeveloperAccessGate) {
return (
<div className="p-8 text-center">
{t("ui.common.loading", "Loading...")}
</div>
);
}
if (!hasDeveloperAccess) {
return (
<DeveloperAccessRequestCard
title={t("ui.common.audit.title", "Audit Logs")}
isPending={isDeveloperRequestPending}
canRequest={canRequestDeveloperAccess}
pendingMessage={t(
"msg.dev.dashboard.access_pending",
"개발자 권한 신청을 검토 중입니다.",
)}
deniedMessage={t(
"msg.dev.audit.access_denied",
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
)}
pendingDetailMessage={t(
"msg.dev.dashboard.access_pending_detail",
"super admin이 승인하면 개요와 개발자 기능을 사용할 수 있습니다.",
)}
deniedDetailMessage={t(
"msg.dev.audit.access_denied_detail",
"개발자 권한 신청 페이지에서 신청을 등록한 뒤 승인을 받아주세요.",
)}
actionLabel={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
onAction={() => navigate("/developer-requests")}
/>
);
}
if (query.error) {
const axiosError = query.error as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
return <ForbiddenMessage resourceToken="audit" />;
}
const errMsg =
axiosError.response?.data?.error ?? (query.error as Error).message;
return (
<div className="p-8 text-center text-red-500">
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
error: errMsg,
})}
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
icon={<NotebookTabs size={20} />}
title={t("ui.common.audit.title", "Audit Logs")}
description={t(
"msg.dev.audit.subtitle",
"현재 앱 범위의 개발자 작업 이력을 조회합니다.",
)}
actions={
<>
<Badge variant="muted">
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
count: logs.length,
})}
</Badge>
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button
className="shadow-sm shadow-primary/30"
onClick={handleExportCsv}
>
<Download size={16} />
{t("ui.common.export_csv", "CSV 내보내기")}
</Button>
</>
}
/>
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>
{t("ui.common.audit.registry.title", "Audit registry")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.audit.registry_description",
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4 pt-0">
<SearchFilterBar
primary={
<form
onSubmit={(e) => {
e.preventDefault();
query.refetch();
}}
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
value={searchClientId}
onChange={(e) => setSearchClientId(e.target.value)}
placeholder={t(
"ui.common.audit.filters.client_id",
"Filter by Client ID",
)}
/>
</div>
<Input
value={searchAction}
onChange={(e) =>
setSearchAction(e.target.value.toUpperCase())
}
placeholder={t(
"ui.common.audit.filters.action",
"Filter by Action (e.g. ROTATE_SECRET)",
)}
/>
<select
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">
{t("ui.common.audit.filters.status_all", "All Status")}
</option>
<option value="success">
{t("ui.common.status.success", "Success")}
</option>
<option value="failure">
{t("ui.common.status.failure", "Failure")}
</option>
</select>
</form>
}
/>
<div
className={
query.isFetching && !query.isFetchingNextPage
? "opacity-50 transition-opacity"
: ""
}
>
<AuditLogTable
logs={logs}
t={t}
loading={query.isLoading}
hasNextPage={Boolean(query.hasNextPage)}
isFetchingNextPage={query.isFetchingNextPage}
onLoadMore={() => query.fetchNextPage()}
/>
</div>
</CardContent>
</Card>
</div>
);
}
export default AuditLogsPage;
@@ -0,0 +1,35 @@
import { useEffect } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import { userManager } from "../../lib/auth";
export default function AuthCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
useEffect(() => {
// 팝업으로 열린 경우 signinPopupCallback 처리
if (window.opener) {
userManager.signinPopupCallback().catch((error) => {
console.error("Popup callback failed:", error);
});
return;
}
if (auth.isAuthenticated) {
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
"returnTo" in auth.user.state &&
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/clients";
navigate(returnTo, { replace: true });
} else if (auth.error) {
console.error("Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
return <div>Loading Auth...</div>;
}
@@ -0,0 +1,84 @@
import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet } from "react-router-dom";
import { userManager } from "../../lib/auth";
import { findPersistedOidcUser } from "../../lib/oidcStorage";
export default function AuthGuard() {
const auth = useAuth();
const [hasStoredUser, setHasStoredUser] = useState<boolean | null>(() =>
findPersistedOidcUser() ? true : null,
);
const isDevelopmentMode = import.meta.env.MODE === "development";
const isTestMode =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true || navigator.webdriver === true;
useEffect(() => {
let cancelled = false;
if (isDevelopmentMode || isTestMode) {
setHasStoredUser(true);
return () => {
cancelled = true;
};
}
const persistedUser = findPersistedOidcUser();
if (persistedUser) {
setHasStoredUser(true);
return () => {
cancelled = true;
};
}
void userManager
.getUser()
.then((user) => {
if (!cancelled) {
setHasStoredUser(Boolean(user && !user.expired));
}
})
.catch(() => {
if (!cancelled) {
setHasStoredUser(false);
}
});
return () => {
cancelled = true;
};
}, [isTestMode]);
if (isDevelopmentMode || isTestMode) {
return <Outlet />;
}
if (auth.isLoading || auth.activeNavigator || hasStoredUser === null) {
return <div>Loading...</div>;
}
if (auth.error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
<div className="mb-4 text-red-500">
<h2 className="text-xl font-bold">Authentication Error</h2>
<p>{auth.error.message}</p>
</div>
<button
type="button"
onClick={() => void auth.signinRedirect()}
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
Start Login Again
</button>
</div>
);
}
if (!auth.isAuthenticated && !hasStoredUser) {
return <Navigate to="/login" replace />;
}
return <Outlet />;
}
@@ -0,0 +1,111 @@
import { ArrowRight, Fingerprint, Smartphone, Sparkles } from "lucide-react";
const flows = [
{
title: "Admin login",
description:
"Enforce short TTL and step-up MFA. Keep admin session separate from app session.",
pill: "15m TTL",
},
{
title: "Tenant pick",
description:
"Admin chooses target tenant before hitting APIs. Propagate X-Tenant-ID on every call.",
pill: "Header-ready",
},
{
title: "Device approval",
description:
"If app session exists and user opts in, use push/deeplink approval as MFA replacement.",
pill: "App session",
},
];
function AuthPage() {
return (
<div className="space-y-8">
<section className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6 shadow-[var(--shadow-card)]">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-muted)]">
Admin auth
</p>
<h2 className="text-2xl font-semibold">Admin auth guardrails</h2>
<p className="text-sm text-[var(--color-muted)]">
Build the admin-only login flow first, keeping app login separate.
Respect the “fallback only when user chooses” rule for SMS/email
vs app approval.
</p>
</div>
<div className="flex items-center gap-2">
<span className="rounded-full border border-[var(--color-border)] px-3 py-2 text-sm text-[var(--color-muted)]">
IDP session placeholder
</span>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full bg-[var(--color-accent)] px-4 py-2 text-sm font-semibold text-black"
>
<Sparkles size={14} />
Connect auth layer
</button>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-3">
{flows.map((flow) => (
<div
key={flow.title}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-panel)] p-5"
>
<div className="flex items-center justify-between text-xs uppercase tracking-[0.16em] text-[var(--color-muted)]">
<span>{flow.pill}</span>
<Fingerprint size={14} />
</div>
<h3 className="mt-3 text-lg font-semibold">{flow.title}</h3>
<p className="text-sm text-[var(--color-muted)]">
{flow.description}
</p>
</div>
))}
</section>
<section className="grid gap-6 md:grid-cols-[1fr,0.9fr]">
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<Smartphone size={16} />
<span className="text-xs uppercase tracking-[0.18em]">
App-based approvals
</span>
</div>
<h3 className="mt-2 text-xl font-semibold">
App session as MFA replacement
</h3>
<p className="text-sm text-[var(--color-muted)]">
If the admin keeps the mobile app signed in and opts in, use
push/deeplink approval instead of OTP. Otherwise fall back to
SMS/email based on user choice.
</p>
</div>
<div className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-panel)] p-6">
<div className="flex items-center gap-2 text-[var(--color-muted)]">
<ArrowRight size={16} />
<span className="text-xs uppercase tracking-[0.18em]">
TTL discipline
</span>
</div>
<h3 className="mt-2 text-xl font-semibold">
Keep admin sessions short
</h3>
<p className="text-sm text-[var(--color-muted)]">
Default admin TTL is 15 minutes. Show countdown and nudge re-auth
with step-up MFA when critical actions (rotate secret, export logs)
happen.
</p>
</div>
</section>
</div>
);
}
export default AuthPage;
@@ -0,0 +1,177 @@
import { AlertTriangle, ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { canStartBrowserPkceLogin } from "../../lib/authConfig";
const insecurePkceMessage =
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
function isPkceSetupFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message);
}
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const [loginError, setLoginError] = useState<string | null>(null);
const returnTo = searchParams.get("returnTo") || "/clients";
const shouldAutoLogin = searchParams.get("auto") === "1";
const authErrorMessage = useMemo(() => {
const message = auth.error?.message;
if (!message) {
return null;
}
if (message.includes("Crypto.subtle")) {
return insecurePkceMessage;
}
return message;
}, [auth.error?.message]);
const visibleLoginError = loginError || authErrorMessage;
useEffect(() => {
if (auth.isAuthenticated) {
navigate(returnTo, { replace: true });
}
}, [auth.isAuthenticated, navigate, returnTo]);
useEffect(() => {
if (!shouldAutoLogin) {
return;
}
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
return;
}
if (!canStartBrowserPkceLogin()) {
setLoginError(insecurePkceMessage);
return;
}
autoStartedRef.current = true;
void auth
.signinRedirect({
state: {
returnTo,
},
})
.catch((error) => {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Auto login redirect failed", error);
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = async () => {
try {
setLoginError(null);
if (!canStartBrowserPkceLogin()) {
setLoginError(insecurePkceMessage);
return;
}
await auth.signinRedirect({
state: {
returnTo: "/clients",
},
});
} catch (error) {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Redirect login failed", error);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
<div className="w-full max-w-md space-y-8">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
<ShieldHalf size={32} />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
Developer Control Plane
</p>
</div>
</div>
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2">
<LogIn size={20} className="text-primary" />
개발자 포털 로그인
</CardTitle>
<CardDescription>
Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
</CardDescription>
</CardHeader>
<CardContent className="pt-4 pb-8 space-y-3">
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={auth.isLoading}
>
{auth.isLoading ? (
<>
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
로그인 진행 중...
</>
) : (
<>
<ShieldHalf size={22} />
SSO 계정으로 로그인
<ExternalLink size={16} className="opacity-50" />
</>
)}
</Button>
{visibleLoginError ? (
<div
role="alert"
className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm leading-5 text-destructive"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{visibleLoginError}</span>
</div>
) : null}
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
<br />
민감한 작업 시 재인증을 요구할 수 있습니다.
</p>
</CardContent>
</Card>
<div className="flex justify-center gap-4">
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
인증 정보가 없거나 로그인이 되지 않는 경우
<br />
시스템 관리자에게 문의하세요.
</p>
</div>
</div>
);
}
export default LoginPage;
@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { fetchMe } from "./authApi";
const getMock = vi.fn();
vi.mock("../../lib/apiClient", () => ({
default: {
get: (...args: unknown[]) => getMock(...args),
},
}));
describe("fetchMe", () => {
it("returns the response payload from the API client", async () => {
getMock.mockResolvedValueOnce({ data: { id: "user-1", name: "Dev" } });
await expect(fetchMe()).resolves.toEqual({ id: "user-1", name: "Dev" });
expect(getMock).toHaveBeenCalledWith("/user/me");
});
});
@@ -0,0 +1,24 @@
import apiClient from "../../lib/apiClient";
export interface Tenant {
id: string;
name: string;
phone?: string;
slug: string;
}
export interface UserProfile {
id: string;
email: string;
name: string;
phone?: string;
role: string;
companyCode?: string;
tenantId?: string;
tenant?: Tenant;
}
export async function fetchMe() {
const { data } = await apiClient.get<UserProfile>("/user/me");
return data;
}
@@ -0,0 +1,162 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import AuthCallbackPage from "./AuthCallbackPage";
import AuthGuard from "./AuthGuard";
import AuthPage from "./AuthPage";
import LoginPage from "./LoginPage";
const authState = {
isAuthenticated: false,
isLoading: false,
activeNavigator: undefined as string | undefined,
error: null as Error | null,
user: undefined as
| {
state?: unknown;
}
| undefined,
signinRedirect: vi.fn(),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../../lib/auth", () => ({
userManager: {
getUser: vi.fn(async () => undefined),
signinPopupCallback: vi.fn(async () => undefined),
},
}));
const roots: Root[] = [];
beforeEach(() => {
authState.isAuthenticated = false;
authState.isLoading = false;
authState.activeNavigator = undefined;
authState.error = null;
authState.user = undefined;
authState.signinRedirect.mockReset();
authState.signinRedirect.mockResolvedValue(undefined);
});
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
});
async function renderWithRouter(
element: React.ReactElement,
{
entry = "/",
path = "*",
}: {
entry?: string;
path?: string;
} = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
await act(async () => {
root.render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path={path} element={element}>
<Route index element={<div>Protected outlet</div>} />
</Route>
<Route path="/login" element={<div>Login route</div>} />
<Route path="/clients" element={<div>Clients route</div>} />
<Route path="/profile" element={<div>Profile route</div>} />
</Routes>
</MemoryRouter>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("devfront auth pages", () => {
it("renders the static auth planning page", async () => {
const container = await renderWithRouter(<AuthPage />);
expect(container.textContent).toContain("Admin auth guardrails");
expect(container.textContent).toContain("Device approval");
});
it("renders login page and starts SSO redirect from the action button", async () => {
const container = await renderWithRouter(<LoginPage />, {
entry: "/login?returnTo=/profile",
path: "/login",
});
expect(container.textContent).toContain("개발자 포털 로그인");
const loginButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("SSO 계정으로 로그인"),
);
await act(async () => {
(loginButton as HTMLButtonElement).click();
});
expect(authState.signinRedirect).toHaveBeenCalledWith({
state: { returnTo: "/clients" },
});
});
it("shows AuthGuard loading, error, redirect, and protected outlet states", async () => {
authState.isLoading = true;
const loading = await renderWithRouter(<AuthGuard />);
expect(loading.textContent).toContain("Loading...");
authState.isLoading = false;
authState.error = new Error("OIDC failed");
const error = await renderWithRouter(<AuthGuard />);
expect(error.textContent).toContain("Authentication Error");
const retryButton = error.querySelector("button") as HTMLButtonElement;
await act(async () => {
retryButton.click();
});
expect(authState.signinRedirect).toHaveBeenCalled();
authState.error = null;
const redirected = await renderWithRouter(<AuthGuard />);
expect(redirected.textContent).toContain("Login route");
authState.isAuthenticated = true;
const protectedPage = await renderWithRouter(<AuthGuard />);
expect(protectedPage.textContent).toContain("Protected outlet");
});
it("navigates from callback by auth result and stored return target", async () => {
authState.isAuthenticated = true;
authState.user = { state: { returnTo: "/profile" } };
const authenticated = await renderWithRouter(<AuthCallbackPage />, {
entry: "/auth/callback",
path: "/auth/callback",
});
expect(authenticated.textContent).toContain("Profile route");
authState.isAuthenticated = false;
authState.error = new Error("callback failed");
const failed = await renderWithRouter(<AuthCallbackPage />, {
entry: "/auth/callback",
path: "/auth/callback",
});
expect(failed.textContent).toContain("Login route");
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,29 @@
import { renderToString } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { ClientDetailTabs } from "./ClientDetailTabs";
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string) =>
({
"ui.dev.clients.details.tab.connection": "연동 설정",
"ui.dev.clients.details.tab.user_claims": "사용자 Claim",
"ui.dev.clients.details.tab.settings": "설정",
"ui.dev.clients.details.tab.relationships": "관계",
})[key] ??
fallback ??
key,
}));
describe("ClientDetailTabs", () => {
it("exposes the RP user custom claim screen as a first-class tab", () => {
const html = renderToString(
<MemoryRouter>
<ClientDetailTabs activeTab="connection" clientId="client-a" />
</MemoryRouter>,
);
expect(html).toContain("사용자 Claim");
expect(html).toContain('href="/clients/client-a/consents"');
});
});
@@ -0,0 +1,61 @@
import { Link } from "react-router-dom";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
type ClientDetailTab = "connection" | "consents" | "settings" | "relationships";
interface ClientDetailTabsProps {
activeTab: ClientDetailTab;
clientId: string;
}
const tabOrder: Array<{
key: ClientDetailTab;
href: (clientId: string) => string;
labelKey?: string;
}> = [
{ key: "connection", href: (clientId) => `/clients/${clientId}` },
{
key: "consents",
href: (clientId) => `/clients/${clientId}/consents`,
labelKey: "ui.dev.clients.details.tab.user_claims",
},
{ key: "settings", href: (clientId) => `/clients/${clientId}/settings` },
{
key: "relationships",
href: (clientId) => `/clients/${clientId}/relationships`,
},
];
export function ClientDetailTabs({
activeTab,
clientId,
}: ClientDetailTabsProps) {
return (
<div className="flex gap-6 overflow-x-auto border-b border-border pb-3 text-sm font-bold">
{tabOrder.map((tab) => {
const isActive = tab.key === activeTab;
const labelKey =
tab.labelKey ?? `ui.dev.clients.details.tab.${tab.key}`;
return isActive ? (
<span
key={tab.key}
className="whitespace-nowrap border-b-2 border-primary pb-1 text-primary"
>
{t(labelKey)}
</span>
) : (
<Link
key={tab.key}
to={tab.href(clientId)}
className={cn(
"whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground",
)}
>
{t(labelKey)}
</Link>
);
})}
</div>
);
}
@@ -0,0 +1,547 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Eye,
EyeOff,
Link2,
RefreshCw,
Save,
Shield,
ShieldHalf,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { CopyButton } from "../../components/ui/copy-button";
import { Label } from "../../components/ui/label";
import { Separator } from "../../components/ui/separator";
import {
Table,
TableBody,
TableCell,
TableRow,
} from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea";
import { toast } from "../../components/ui/use-toast";
import {
fetchClient,
rotateClientSecret,
updateClient,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { ClientDetailTabs } from "./ClientDetailTabs";
import { canDisplayClientSecret } from "./clientSecretPolicy";
function ClientDetailsPage() {
const params = useParams();
const queryClient = useQueryClient();
const clientId = params.id ?? "";
const { data, error, isLoading } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId),
enabled: clientId.length > 0,
});
const [redirectUris, setRedirectUris] = useState("");
const [showSecret, setShowSecret] = useState(false);
const redirectUrisHydratedRef = useRef(false);
useEffect(() => {
if (
!redirectUrisHydratedRef.current &&
data?.client?.redirectUris &&
redirectUris === ""
) {
setRedirectUris(data.client.redirectUris.join(", "));
redirectUrisHydratedRef.current = true;
}
}, [data, redirectUris]);
const mutation = useMutation({
mutationFn: () => {
const uriList = redirectUris
.split(",")
.map((u) => u.trim())
.filter(Boolean);
return updateClient(clientId, { redirectUris: uriList });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
toast(
t(
"msg.dev.clients.details.redirect_saved",
"Redirect URIs가 저장되었습니다.",
),
);
},
onError: (err) => {
const axiosError = err as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
toast(
t(
"msg.dev.clients.details.save_forbidden",
"이 RP 설정을 수정할 권한이 없습니다.\n관리자에게 RP 일반 설정 또는 RP 관리자 관계 부여를 요청해 주세요.",
),
"error",
);
return;
}
toast(
t("msg.dev.clients.details.save_error", "저장 실패: {{error}}", {
error:
axiosError.response?.data?.error ??
(err as Error).message ??
t("msg.common.unknown_error", "unknown error"),
}),
"error",
);
},
});
const rotateMutation = useMutation({
mutationFn: () => rotateClientSecret(clientId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["client", clientId] });
toast(
t(
"msg.dev.clients.details.secret_rotated",
"Client Secret이 재발급되었습니다.",
),
);
setShowSecret(true); // 재발급 후 바로 보여줌
},
onError: (err) => {
toast(
t("msg.dev.clients.details.rotate_error", "재발급 실패: {{error}}", {
error: (err as Error).message,
}),
"error",
);
},
});
const handleRotateSecret = () => {
if (
window.confirm(
t(
"msg.dev.clients.details.rotate_confirm",
"경고: Client Secret을 재발급하면 기존 시크릿은 즉시 무효화됩니다.\n연동된 애플리케이션이 중단될 수 있습니다. 계속하시겠습니까?",
),
)
) {
rotateMutation.mutate();
}
};
if (!clientId) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
</div>
);
}
if (error && !data) {
const errMsg =
(error as AxiosError<{ error?: string }>).response?.data?.error ??
(error as Error)?.message;
return (
<div className="p-8 text-center text-red-500">
{t(
"msg.dev.clients.details.load_error",
"Error loading app: {{error}}",
{ error: errMsg || t("msg.common.unknown_error", "unknown error") },
)}
</div>
);
}
if (isLoading && !data) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.loading", "Loading app details...")}
</div>
);
}
const client = data?.client;
if (!client) {
return null;
}
const endpointValues = data?.endpoints ?? {
discovery: "-",
issuer: "-",
authorization: "-",
token: "-",
userinfo: "-",
};
const endpoints = [
{
labelKey: "ui.dev.clients.details.endpoint.discovery",
labelFallback: "Discovery Endpoint",
value: endpointValues.discovery,
},
{
labelKey: "ui.dev.clients.details.endpoint.issuer",
labelFallback: "Issuer URL",
value: endpointValues.issuer,
},
{
labelKey: "ui.dev.clients.details.endpoint.authorization",
labelFallback: "Authorization Endpoint",
value: endpointValues.authorization,
},
{
labelKey: "ui.dev.clients.details.endpoint.token",
labelFallback: "Token Endpoint",
value: endpointValues.token,
},
{
labelKey: "ui.dev.clients.details.endpoint.userinfo",
labelFallback: "UserInfo Endpoint",
value: endpointValues.userinfo,
},
];
const hasClientSecret = canDisplayClientSecret(client);
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
const clientSecret = hasClientSecret
? client?.clientSecret || secretPlaceholder
: t("ui.common.na", "N/A");
const displaySecret = !hasClientSecret
? t(
"msg.dev.clients.details.secret_not_applicable",
"PKCE 앱에는 Client Secret이 없습니다.",
)
: clientSecret === secretPlaceholder
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
: clientSecret;
return (
<div className="space-y-8">
<div className="space-y-3">
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{client?.name || clientId}</span>
<span>/</span>
<span className="text-foreground font-semibold">
{t("ui.dev.clients.details.tab.connection", "Federation")}
</span>
</nav>
<PageHeader
icon={<ShieldHalf size={20} />}
title={client?.name || client?.id || clientId}
description={t(
"msg.dev.clients.details.subtitle",
"Manage OIDC credentials and endpoints.",
)}
actions={
<Badge
variant={client?.status === "active" ? "info" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{client?.status === "active"
? t("ui.common.status.active", "Active")
: client?.status === "inactive"
? t("ui.common.status.inactive", "Inactive")
: t("msg.common.loading", "Loading...")}
</Badge>
}
/>
<ClientDetailTabs activeTab="connection" clientId={clientId} />
</div>
<div className="grid gap-8 lg:grid-cols-2">
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-bold">
{t(
"ui.dev.clients.details.credentials.title",
"Client Credentials",
)}
</h2>
<Card className="glass-panel">
<CardContent className="flex flex-col gap-4 p-6">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t(
"ui.dev.clients.details.credentials.client_id",
"Client ID",
)}
</p>
<div className="flex items-center justify-between gap-2">
<p className="font-mono text-lg truncate">
{client?.id || clientId}
</p>
<CopyButton
value={client?.id || clientId}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_id",
"Client ID가 복사되었습니다.",
),
)
}
/>
</div>
</div>
<Separator />
<div>
<p className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t(
"ui.dev.clients.details.credentials.client_secret",
"Client Secret",
)}
</p>
<div className="flex items-center justify-between gap-2">
<p
className={cn(
"font-mono text-lg",
!showSecret && "tracking-widest",
)}
>
{showSecret ? displaySecret : "••••••••••••••••"}
</p>
<div className="flex gap-2 shrink-0">
{hasClientSecret ? (
<>
<Button
variant="secondary"
size="icon"
onClick={() => setShowSecret(!showSecret)}
aria-label={
showSecret
? t(
"ui.dev.clients.details.secret.hide",
"비밀키 숨기기",
)
: t(
"ui.dev.clients.details.secret.show",
"비밀키 보기",
)
}
>
{showSecret ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
variant="secondary"
size="icon"
onClick={handleRotateSecret}
disabled={rotateMutation.isPending}
title={t(
"ui.dev.clients.details.secret.rotate",
"비밀키 재발급 (Rotate)",
)}
>
<RefreshCw
className={cn(
"h-4 w-4",
rotateMutation.isPending && "animate-spin",
)}
/>
</Button>
<CopyButton
value={clientSecret}
disabled={
!showSecret && clientSecret === secretPlaceholder
}
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_client_secret",
"Client Secret이 복사되었습니다.",
),
)
}
/>
</>
) : null}
</div>
</div>
{!hasClientSecret ? (
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.dev.clients.details.secret_not_applicable",
"PKCE 앱에는 Client Secret이 없습니다.",
)}
</p>
) : null}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold">
{t("ui.dev.clients.details.endpoints.title", "OIDC 엔드포인트")}
</h2>
<Badge variant="muted" className="gap-1">
<Link2 className="h-3 w-3" />
{t("ui.dev.clients.details.endpoints.read_only", "읽기 전용")}
</Badge>
</div>
<Card className="glass-panel">
<Table>
<TableBody>
{endpoints.map((endpoint) => (
<TableRow
key={endpoint.labelKey}
className="border-border/70"
>
<TableCell className="w-1/3">
<p className="text-xs font-bold uppercase tracking-[0.12em] text-muted-foreground">
{t(endpoint.labelKey, endpoint.labelFallback)}
</p>
</TableCell>
<TableCell className="flex items-center justify-between gap-3">
<span className="break-all font-mono text-sm">
{endpoint.value}
</span>
<CopyButton
value={endpoint.value}
className="h-8 w-8 shrink-0"
onCopy={() =>
toast(
t(
"msg.dev.clients.details.copy_endpoint",
"{{label}}가 복사되었습니다.",
{
label: t(
endpoint.labelKey,
endpoint.labelFallback,
),
},
),
)
}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
</div>
<div className="space-y-6">
<div className="space-y-4">
<h2 className="text-xl font-bold">
{t("ui.dev.clients.details.redirect.title", "리디렉션 URI 설정")}
</h2>
<Card className="glass-panel border-primary/20">
<CardHeader>
<CardTitle className="text-lg">
{t("ui.dev.clients.details.redirect.label", "Redirect URIs")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.details.redirect.description",
"인증 성공 후 사용자를 리다이렉트할 허용된 URL 목록입니다. 콤마(,)로 구분하여 여러 개 입력할 수 있습니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label
htmlFor="redirect-uris"
className="text-sm font-semibold"
>
{t(
"ui.dev.clients.details.redirect.callback_label",
"인증 콜백 URL",
)}
</Label>
<Textarea
id="redirect-uris"
placeholder={t(
"ui.dev.clients.details.redirect.placeholder",
"https://your-app.com/callback, http://localhost:3000/auth/callback",
)}
rows={5}
value={redirectUris}
onChange={(e) => {
redirectUrisHydratedRef.current = true;
setRedirectUris(e.target.value);
}}
className="font-mono text-sm"
/>
</div>
<Button
className="w-full gap-2"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
<Save className="h-4 w-4" />
{mutation.isPending
? t("msg.common.saving", "저장 중...")
: t(
"ui.dev.clients.details.redirect.save",
"Redirect URIs 저장",
)}
</Button>
</CardContent>
</Card>
</div>
<div className="glass-panel p-6 opacity-80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/15 text-primary">
<Shield className="h-6 w-6" />
</div>
<div>
<p className="text-lg font-semibold">
{t("ui.dev.clients.details.security.title", "보안 메모")}
</p>
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.details.security.note",
"엔드포인트는 읽기 전용으로 유지하고, 비밀키 재발행/복사는 감사 로그와 연계하세요.",
)}
</p>
</div>
</div>
</div>
<Separator className="my-4" />
<p className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.details.security.footer",
"비밀키 재발행 작업에는 관리자 세션 TTL 확인과 레이트리밋, 알림 연동을 권장합니다.",
)}
</p>
</div>
</div>
</div>
</div>
);
}
export default ClientDetailsPage;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,766 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Info, Link2, Plus, ShieldHalf, Trash2, X } from "lucide-react";
import { useDeferredValue, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useParams } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { toast } from "../../components/ui/use-toast";
import {
addClientRelation,
type DevAssignableUser,
fetchClient,
fetchClientRelations,
fetchDevUsers,
removeClientRelation,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
import { ClientDetailTabs } from "./ClientDetailTabs";
const relationOptions = [
"admins",
"config_editor",
"secret_viewer",
"secret_rotator",
"jwks_viewer",
"jwks_operator",
"consent_viewer",
"consent_revoker",
"relationship_viewer",
"audit_viewer",
] as const;
type RelationOption = (typeof relationOptions)[number];
function relationLabel(relation: RelationOption) {
return t(`ui.dev.clients.relationships.option.${relation}.label`, relation);
}
function relationDescription(relation: RelationOption) {
return t(
`ui.dev.clients.relationships.option.${relation}.description`,
relation,
);
}
function relationPermitsInfo(relation: RelationOption) {
return t(`ui.dev.clients.relationships.option.${relation}.permits_info`, "");
}
function formatUserLabel(user: DevAssignableUser) {
const primary = user.name.trim() || user.email.trim();
return `${primary} (${user.email.trim()})`;
}
function ClientRelationsPage() {
const params = useParams();
const auth = useAuth();
const queryClient = useQueryClient();
const clientId = params.id ?? "";
const [selectedRelations, setSelectedRelations] = useState<RelationOption[]>(
[],
);
const [userSearch, setUserSearch] = useState("");
const deferredUserSearch = useDeferredValue(userSearch.trim());
const [selectedUser, setSelectedUser] = useState<DevAssignableUser | null>(
null,
);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [infoRelation, setInfoRelation] = useState<RelationOption | null>(null);
const systemRole = resolveProfileRole(
auth.user?.profile as Record<string, unknown> | undefined,
);
const hasAccessToken = Boolean(auth.user?.access_token);
const { data: me } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const resolvedSystemRole = me?.role?.trim() || systemRole;
const { data: clientData } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId),
enabled: clientId.length > 0,
});
const {
data: relationData,
isLoading,
error,
} = useQuery({
queryKey: ["client-relations", clientId],
queryFn: () => fetchClientRelations(clientId),
enabled: clientId.length > 0,
});
// Calculate permissions for UI hints and button states
const isSuperAdmin = resolvedSystemRole === "super_admin";
const myUserId = auth.user?.profile.sub;
const isRpAdmin = useMemo(() => {
if (isSuperAdmin) return true;
if (!relationData?.items || !myUserId) return false;
return relationData.items.some(
(item) =>
item.subject === `User:${myUserId}` && item.relation === "admins",
);
}, [relationData?.items, myUserId, isSuperAdmin]);
const canManageRelations = isRpAdmin || isSuperAdmin;
const isRelationshipViewForbidden =
(error as AxiosError | null)?.response?.status === 403;
const relationshipViewForbiddenMessage = t(
"msg.dev.clients.relationships.view_forbidden",
"이 RP의 관계를 조회할 권한이 없습니다. 관리자에게 관계 조회 또는 RP 관리자 관계 부여를 요청해 주세요.",
);
const {
data: userSearchData,
isFetching: isUserSearchLoading,
error: userSearchError,
} = useQuery({
queryKey: ["dev-users", deferredUserSearch],
queryFn: () => fetchDevUsers(deferredUserSearch, 10, clientId),
enabled:
clientId.length > 0 &&
deferredUserSearch.length > 0 &&
selectedUser == null &&
!isRelationshipViewForbidden,
});
const sortedItems = useMemo(() => {
return [...(relationData?.items ?? [])].sort((a, b) => {
const relationCompare = a.relation.localeCompare(b.relation);
if (relationCompare !== 0) {
return relationCompare;
}
return a.subject.localeCompare(b.subject);
});
}, [relationData?.items]);
const selectedUserExistingRelations = useMemo(() => {
if (!selectedUser) {
return new Set<string>();
}
return new Set(
sortedItems
.filter((item) => item.subjectId === selectedUser.id)
.map((item) => item.relation),
);
}, [selectedUser, sortedItems]);
const addMutation = useMutation({
mutationFn: async () => {
if (!selectedUser) {
throw new Error(
t(
"msg.dev.clients.relationships.user_required",
"추가할 사용자를 선택하세요.",
),
);
}
const pendingRelations = selectedRelations.filter(
(relation) => !selectedUserExistingRelations.has(relation),
);
for (const relation of pendingRelations) {
await addClientRelation(clientId, {
relation,
userId: selectedUser.id,
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["client-relations", clientId],
});
setSelectedRelations([]);
setSelectedUser(null);
setUserSearch("");
setIsSearchOpen(false);
toast(
t(
"msg.dev.clients.relationships.added",
"Relationship가 추가되었습니다.",
),
);
},
onError: (err) => {
toast(
t(
"msg.dev.clients.relationships.add_error",
"Relationship 추가 실패: {{error}}",
{
error:
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error).message,
},
),
"error",
);
},
});
const removeMutation = useMutation({
mutationFn: (payload: { relation: string; subject: string }) =>
removeClientRelation(clientId, payload.relation, payload.subject),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["client-relations", clientId],
});
toast(
t(
"msg.dev.clients.relationships.removed",
"Relationship가 제거되었습니다.",
),
);
},
onError: (err) => {
toast(
t(
"msg.dev.clients.relationships.remove_error",
"Relationship 제거 실패: {{error}}",
{
error:
(err as AxiosError<{ error?: string }>).response?.data?.error ??
(err as Error).message,
},
),
"error",
);
},
});
const handleAdd = () => {
if (!canManageRelations) {
toast(
t(
"msg.dev.clients.relationships.add_forbidden_viewer",
"'관계 조회' 권한만으로는 새로운 관계를 추가하거나 사용자를 검색할 수 없습니다. 'RP 관리자' 권한이 필요합니다.",
),
"error",
);
return;
}
if (!selectedUser) {
toast(
t(
"msg.dev.clients.relationships.user_required",
"추가할 사용자를 선택하세요.",
),
"error",
);
return;
}
const pendingRelations = selectedRelations.filter(
(relation) => !selectedUserExistingRelations.has(relation),
);
if (pendingRelations.length === 0) {
toast(
t(
"msg.dev.clients.relationships.relation_required",
"추가할 관계를 하나 이상 선택하세요.",
),
"error",
);
return;
}
addMutation.mutate();
};
const handleRelationToggle = (relation: RelationOption) => {
setSelectedRelations((current) =>
current.includes(relation)
? current.filter((item) => item !== relation)
: [...current, relation],
);
};
const handleSelectUser = (user: DevAssignableUser) => {
setSelectedUser(user);
setUserSearch(formatUserLabel(user));
setIsSearchOpen(false);
};
const handleRemove = (targetRelation: string, subject: string) => {
if (
window.confirm(
t(
"msg.dev.clients.relationships.remove_confirm",
"이 relationship를 제거하시겠습니까?",
),
)
) {
removeMutation.mutate({ relation: targetRelation, subject });
}
};
const handleInfoToggle = (
event: React.MouseEvent,
relation: RelationOption,
) => {
event.preventDefault();
event.stopPropagation();
setInfoRelation((prev) => (prev === relation ? null : relation));
};
if (!clientId) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.missing_id", "Client ID가 필요합니다.")}
</div>
);
}
const isUserSearchForbidden =
(userSearchError as AxiosError | null)?.response?.status === 403;
return (
<div className="space-y-8">
<header className="space-y-4">
<div className="flex flex-wrap justify-between gap-4">
<div className="space-y-2">
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{clientData?.client?.name || clientId}</span>
<span>/</span>
<span className="text-foreground font-semibold">
{t("ui.dev.clients.details.tab.relationships", "Relationships")}
</span>
</nav>
<PageHeader
icon={<ShieldHalf size={20} />}
title={t(
"ui.dev.clients.relationships.title",
"Client Relationships",
)}
description={t(
"msg.dev.clients.relationships.subtitle",
"RP direct operator relation을 조회하고 User 단위로 추가·삭제합니다.",
)}
/>
</div>
<div className="flex items-center gap-3">
<Badge
variant={
clientData?.client?.status === "active" ? "info" : "muted"
}
>
{clientData?.client?.status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
</div>
</div>
<ClientDetailTabs activeTab="relationships" clientId={clientId} />
</header>
<Card className="glass-panel">
<CardHeader>
<CardTitle>
{t("ui.dev.clients.relationships.add_title", "Add Relationship")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.relationships.add_description",
"사용자를 검색해 선택하고, 하나 이상의 운영 관계를 한 번에 부여할 수 있습니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t(
"msg.dev.clients.relationships.loading",
"Loading relationships...",
)}
</div>
) : isRelationshipViewForbidden ? (
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
{relationshipViewForbiddenMessage}
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="user-search-input">
{t("ui.dev.clients.relationships.user_search", "사용자")}
</Label>
<div className="relative">
<Input
id="user-search-input"
value={userSearch}
onFocus={() => {
if (!selectedUser && userSearch.trim() !== "") {
setIsSearchOpen(true);
}
}}
onChange={(event) => {
setSelectedUser(null);
setUserSearch(event.target.value);
setIsSearchOpen(true);
}}
placeholder={t(
"ui.dev.clients.relationships.user_search_placeholder",
"이름 또는 이메일 검색...",
)}
/>
{isSearchOpen &&
selectedUser == null &&
userSearch.trim() !== "" && (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-y-auto rounded-md border border-border bg-background shadow-lg">
{isUserSearchLoading ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{t(
"msg.dev.clients.relationships.search_loading",
"사용자를 찾는 중입니다...",
)}
</div>
) : isUserSearchForbidden ? (
<div className="px-4 py-8 text-center text-sm text-destructive font-medium border-b border-border/40 bg-destructive/5 flex flex-col gap-2">
<p>
{t(
"msg.dev.clients.relationships.search_forbidden_user",
"일반 사용자는 관계 추가를 위한 사용자 검색을 사용할 수 없습니다.",
)}
</p>
<p className="text-xs text-muted-foreground/80 font-normal">
{t(
"msg.dev.clients.relationships.search_forbidden_user_hint",
"'관계 조회' 권한만으로는 사용자 검색이 제한됩니다. 'RP 관리자' 관계가 필요합니다.",
)}
</p>
</div>
) : (userSearchData?.items ?? []).length > 0 ? (
(userSearchData?.items ?? []).map((user) => (
<button
key={user.id}
type="button"
className="flex w-full flex-col gap-1 px-3 py-2 text-left hover:bg-muted/40 border-b border-border/40 last:border-b-0"
onMouseDown={(event) => {
event.preventDefault();
handleSelectUser(user);
}}
>
<span className="text-sm font-semibold">
{user.name || user.email}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
{user.loginId ? ` · ${user.loginId}` : ""}
</span>
</button>
))
) : (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
{t(
"msg.dev.clients.relationships.search_empty",
"검색 결과가 없습니다.",
)}
</div>
)}
</div>
)}
</div>
{selectedUser && (
<p className="text-xs text-muted-foreground">
{t(
"msg.dev.clients.relationships.selected_user",
"선택된 사용자: {{user}}",
{ user: formatUserLabel(selectedUser) },
)}
</p>
)}
</div>
<div className="space-y-3">
<Label>
{t("ui.dev.clients.relationships.relation", "Relation")}
</Label>
<div className="grid gap-3 md:grid-cols-2">
{relationOptions.map((relation) => {
const disabled =
selectedUserExistingRelations.has(relation);
const isSelected = selectedRelations.includes(relation);
const isInfoVisible = infoRelation === relation;
return (
<div key={relation} className="relative">
<label
className={`flex gap-3 rounded-xl border p-4 transition-all ${
disabled
? "border-border/60 bg-muted/30 opacity-60"
: isSelected
? "border-primary bg-primary/10 shadow-[0_0_0_1px_rgba(59,130,246,0.35)] ring-1 ring-primary/30"
: "border-border bg-background hover:border-primary/40 hover:bg-muted/20"
}`}
>
<input
type="checkbox"
className="mt-1 h-4 w-4 accent-primary"
checked={isSelected || disabled}
disabled={disabled}
onChange={() => handleRelationToggle(relation)}
/>
<div className="flex-1 space-y-1">
<div className="flex items-start justify-between gap-2">
<div
className={`text-sm font-semibold ${
isSelected && !disabled ? "text-primary" : ""
}`}
>
{relationLabel(relation)}
</div>
<button
type="button"
className={`rounded-full p-0.5 transition-colors ${
isInfoVisible
? "text-primary"
: "text-muted-foreground/60 hover:text-primary"
}`}
onClick={(e) => handleInfoToggle(e, relation)}
>
{isInfoVisible ? (
<X className="h-3.5 w-3.5" />
) : (
<Info className="h-3.5 w-3.5" />
)}
</button>
</div>
<div className="text-xs text-muted-foreground">
{relationDescription(relation)}
</div>
<div
className={`text-[11px] uppercase tracking-wide ${
isSelected && !disabled
? "text-primary/80"
: "text-muted-foreground/80"
}`}
>
{relation}
</div>
</div>
</label>
{isInfoVisible && (
<div className="mt-2 animate-in fade-in slide-in-from-top-1 rounded-lg border border-primary/20 bg-primary/5 p-3 text-xs leading-relaxed text-foreground shadow-sm">
<div className="flex items-center gap-1.5 font-bold text-primary mb-1">
<Info className="h-3 w-3" />
{t("ui.common.info", "상세 권한 안내")}
</div>
{relationPermitsInfo(relation)}
</div>
)}
</div>
);
})}
</div>
</div>
<div className="flex justify-end">
<Button
onClick={handleAdd}
disabled={addMutation.isPending || !canManageRelations}
className="gap-2"
>
<Plus className="h-4 w-4" />
{addMutation.isPending
? t("msg.common.loading", "Loading...")
: t("ui.dev.clients.relationships.add", "Add")}
</Button>
</div>
</>
)}
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Link2 className="h-5 w-5" />
{t(
"ui.dev.clients.relationships.list_title",
"Assigned Relationships",
)}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.relationships.list_description",
"현재 RP에 직접 부여된 operator relation 목록입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent>
{isRelationshipViewForbidden ? (
<div className="rounded-md border border-border bg-muted/30 p-4 text-sm text-muted-foreground">
{relationshipViewForbiddenMessage}
</div>
) : error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
{t(
"msg.dev.clients.relationships.load_error",
"Relationship 조회 실패: {{error}}",
{
error:
(error as AxiosError<{ error?: string }>).response?.data
?.error ?? (error as Error).message,
},
)}
</div>
) : isLoading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t(
"msg.dev.clients.relationships.loading",
"Relationship를 불러오는 중입니다...",
)}
</div>
) : sortedItems.length === 0 ? (
<div className="py-8 text-center text-sm text-muted-foreground">
{t(
"msg.dev.clients.relationships.empty",
"직접 부여된 relationship가 없습니다.",
)}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.dev.clients.relationships.relation", "Relation")}
</TableHead>
<TableHead>
{t("ui.dev.clients.relationships.subject", "Subject")}
</TableHead>
<TableHead>
{t("ui.dev.clients.relationships.subject_type", "Type")}
</TableHead>
<TableHead className="w-[120px] text-right">
{t("ui.dev.clients.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedItems.map((item) => (
<TableRow key={`${item.relation}:${item.subject}`}>
<TableCell>
<div className="space-y-1">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 font-medium">
<span>
{relationLabel(item.relation as RelationOption)}
</span>
<button
type="button"
className={`rounded-full p-0.5 transition-colors ${
infoRelation === item.relation
? "text-primary"
: "text-muted-foreground/60 hover:text-primary"
}`}
onClick={(e) =>
handleInfoToggle(
e,
item.relation as RelationOption,
)
}
>
<Info className="h-3.5 w-3.5" />
</button>
</div>
{infoRelation === item.relation && (
<div className="animate-in fade-in slide-in-from-top-1 rounded border border-primary/20 bg-primary/5 p-2 text-[11px] leading-relaxed text-foreground max-w-[250px]">
{relationPermitsInfo(
item.relation as RelationOption,
)}
</div>
)}
</div>
<div className="text-xs text-muted-foreground">
{relationDescription(item.relation as RelationOption)}
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="font-medium">
{item.userName || item.userEmail || item.subject}
</div>
{(item.userEmail || item.userLoginId) && (
<div className="text-xs text-muted-foreground">
{[item.userEmail, item.userLoginId]
.filter(Boolean)
.join(" · ")}
</div>
)}
<div className="font-mono text-xs text-muted-foreground">
{item.subject}
</div>
{item.subjectId && (
<div className="text-xs text-muted-foreground">
ID: {item.subjectId}
</div>
)}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{item.subjectType || "-"}</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="gap-2 text-destructive hover:text-destructive"
disabled={
removeMutation.isPending || !canManageRelations
}
onClick={() =>
handleRemove(item.relation, item.subject)
}
>
<Trash2 className="h-4 w-4" />
{t("ui.common.delete", "Delete")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
export default ClientRelationsPage;
@@ -0,0 +1,280 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { MemoryRouter } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ClientsPage from "./ClientsPage";
const navigateMock = vi.fn();
const fetchClientsMock = vi.fn();
const fetchMeMock = vi.fn();
const fetchDeveloperRequestStatusMock = vi.fn();
const fetchMyTenantsMock = vi.fn();
const requestDeveloperAccessMock = vi.fn();
let authState = {
user: {
access_token: "access-token",
profile: {
role: "super_admin",
tenant_id: "tenant-1",
companyCode: "HANMAC",
name: "Dev Admin",
email: "dev@example.com",
phone: "010-0000-0000",
},
},
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("react-router-dom", async () => {
const actual =
await vi.importActual<typeof import("react-router-dom")>(
"react-router-dom",
);
return {
...actual,
useNavigate: () => navigateMock,
};
});
vi.mock("../../lib/devApi", () => ({
fetchClients: () => fetchClientsMock(),
fetchMe: () => fetchMeMock(),
fetchDeveloperRequestStatus: () => fetchDeveloperRequestStatusMock(),
fetchMyTenants: () => fetchMyTenantsMock(),
requestDeveloperAccess: (...args: unknown[]) =>
requestDeveloperAccessMock(...args),
}));
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.clearAllMocks();
document.body.innerHTML = "";
});
beforeEach(() => {
authState = {
user: {
access_token: "access-token",
profile: {
role: "super_admin",
tenant_id: "tenant-1",
companyCode: "HANMAC",
name: "Dev Admin",
email: "dev@example.com",
phone: "010-0000-0000",
},
},
};
fetchClientsMock.mockResolvedValue({
items: [],
limit: 100,
offset: 0,
});
fetchMeMock.mockResolvedValue({
role: "super_admin",
name: "Dev Admin",
email: "dev@example.com",
phone: "010-0000-0000",
});
fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" });
fetchMyTenantsMock.mockResolvedValue([
{
id: "tenant-1",
name: "Hanmac",
slug: "hanmac",
type: "COMPANY",
parentId: null,
description: "",
status: "active",
memberCount: 10,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]);
});
function makeClients(count: number) {
return Array.from({ length: count }, (_, index) => ({
id: `client-${index + 1}`,
name: `App ${index + 1}`,
type: index % 2 === 0 ? "private" : "pkce",
status: index % 2 === 0 ? "active" : "inactive",
createdAt: `2026-05-${String(index + 1).padStart(2, "0")}T00:00:00Z`,
redirectUris: [],
scopes: [],
metadata: {},
}));
}
async function setInputValue(input: HTMLInputElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
);
descriptor?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function renderPage() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ClientsPage />
</MemoryRouter>
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("ClientsPage", () => {
it("expands the list and applies search filters", async () => {
fetchClientsMock.mockResolvedValue({
items: makeClients(6),
limit: 100,
offset: 0,
});
const container = await renderPage();
expect(container.textContent).toContain(
"총 6개의 애플리케이션이 등록되어 있습니다.",
);
expect(container.textContent).toContain("App 6");
expect(container.textContent).toContain("App 2");
expect(container.textContent).not.toContain("App 1");
const moreButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "더보기",
);
expect(moreButton).toBeTruthy();
await act(async () => {
moreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("App 6");
expect(container.textContent).toContain("접기");
const advancedButton = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent === "Advanced Filters");
expect(advancedButton).toBeTruthy();
await act(async () => {
advancedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
const searchInput = Array.from(container.querySelectorAll("input")).find(
(input) =>
input
.getAttribute("placeholder")
?.includes("클라이언트 이름/ID로 검색"),
) as HTMLInputElement | undefined;
if (!searchInput) {
throw new Error("Expected search input to be rendered");
}
await act(async () => {
await setInputValue(searchInput, "missing-client");
});
expect(container.textContent).toContain("조건에 맞는 연동 앱이 없습니다.");
const resetButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "초기화",
);
expect(resetButton).toBeTruthy();
await act(async () => {
resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await act(async () => {
await setInputValue(searchInput, "");
});
expect(container.textContent).toContain("App 1");
});
it("navigates to the developer request page from empty states", async () => {
authState = {
user: {
access_token: "access-token",
profile: {
role: "user",
tenant_id: "tenant-1",
companyCode: "HANMAC",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
},
},
};
fetchClientsMock.mockResolvedValue({
items: [],
limit: 100,
offset: 0,
});
fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" });
fetchMeMock.mockResolvedValue({
role: "user",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
});
const container = await renderPage();
expect(container.textContent).toContain("개발자 등록 신청하기");
const requestButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "개발자 등록 신청하기",
);
expect(requestButton).toBeTruthy();
await act(async () => {
requestButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(navigateMock).toHaveBeenCalledWith("/developer-requests");
});
});
@@ -0,0 +1,828 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Filter, Plus, Search, ShieldHalf, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useAuth } from "react-oidc-context";
import { Link, useNavigate } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import {
SortableTableHead,
sortableTableHeadBaseClassName,
sortableTableHeaderClassName,
} from "../../../../common/core/components/sort";
import {
type SortConfig,
type SortResolverMap,
sortItems,
toggleSort,
} from "../../../../common/core/utils";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import {
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea";
import {
type ClientSummary,
fetchClients,
fetchDeveloperRequestStatus,
fetchMyTenants,
requestDeveloperAccess,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { cn } from "../../lib/utils";
import { fetchMe } from "../auth/authApi";
import { resolveClientCreateAccess } from "./clientCreateAccess";
import { ClientLogo } from "./components/ClientLogo";
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
const clientListPreviewCount = 5;
function ClientsPage() {
const navigate = useNavigate();
const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token);
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
const role = resolveProfileRole(userProfile);
const tenantId = userProfile?.tenant_id as string | undefined;
const companyCode = userProfile?.companyCode as string | undefined;
const {
data,
isLoading: isLoadingClients,
error: clientError,
} = useQuery({
queryKey: ["clients"],
queryFn: fetchClients,
enabled: hasAccessToken,
});
const { data: me, isLoading: isLoadingMe } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const profileRole = me?.role?.trim() || role;
const {
data: requestStatus,
isLoading: isLoadingRequest,
refetch: refetchRequest,
} = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
enabled:
hasAccessToken &&
(profileRole === "user" || profileRole === "tenant_member"),
});
const { data: tenants } = useQuery({
queryKey: ["myTenants"],
queryFn: fetchMyTenants,
enabled: hasAccessToken,
});
const createAccessState = resolveClientCreateAccess({
role: profileRole,
requestStatus: requestStatus?.status,
});
const canCreateClient = createAccessState === "can_create";
const isDeveloperRequestPending = createAccessState === "pending";
const canRequestDeveloperAccess =
createAccessState === "request_required" && !isLoadingRequest;
const [searchQuery, setSearchQuery] = useState("");
const [typeFilter, setTypeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [isClientListExpanded, setIsClientListExpanded] = useState(false);
const [sortConfig, setSortConfig] =
useState<SortConfig<ClientSortKey> | null>({
key: "createdAt",
direction: "desc",
});
const clients = data?.items || [];
const clientSortResolvers = useMemo<
SortResolverMap<ClientSummary, ClientSortKey>
>(
() => ({
application: (client) => client.name || client.id,
id: (client) => client.id,
type: (client) =>
client.metadata?.headless_login_enabled
? "private-headless"
: client.type,
status: (client) => client.status,
createdAt: (client) =>
client.createdAt ? new Date(client.createdAt) : null,
}),
[],
);
const filteredClients = useMemo(() => {
const nextClients = clients.filter((client) => {
const matchesSearch =
!searchQuery ||
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = typeFilter === "all" || client.type === typeFilter;
const matchesStatus =
statusFilter === "all" || client.status === statusFilter;
return matchesSearch && matchesType && matchesStatus;
});
return sortItems(nextClients, sortConfig, clientSortResolvers);
}, [
clientSortResolvers,
clients,
searchQuery,
sortConfig,
statusFilter,
typeFilter,
]);
const hasFilterResult = filteredClients.length > 0;
const isFilteredOut = clients.length > 0 && !hasFilterResult;
const visibleClients = useMemo(() => {
if (isClientListExpanded) {
return filteredClients;
}
return filteredClients.slice(0, clientListPreviewCount);
}, [filteredClients, isClientListExpanded]);
const canToggleClientList = filteredClients.length > clientListPreviewCount;
const currentTenant = tenants?.find(
(tenant) => tenant.id === tenantId || tenant.slug === companyCode,
);
const organizationName = currentTenant?.name || companyCode || "";
const profileName = me?.name || (userProfile?.name as string) || "";
const profileEmail = me?.email || (userProfile?.email as string) || "";
const profilePhone =
me?.phone ||
(userProfile?.phone as string | undefined) ||
(userProfile?.phone_number as string | undefined) ||
"";
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
const isLoading =
isLoadingClients ||
isLoadingRequest ||
(hasAccessToken && !profileRole && isLoadingMe);
const requestSort = (key: ClientSortKey) => {
setSortConfig((current) => toggleSort(current, key));
};
if (auth.isLoading || !hasAccessToken || isLoading) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.loading", "Loading clients...")}
</div>
);
}
if (clientError) {
const axiosError = clientError as AxiosError<{ error?: string }>;
if (axiosError.response?.status === 403) {
return <ForbiddenMessage resourceToken="clients" />;
}
const errMsg =
axiosError.response?.data?.error ?? (clientError as Error).message;
return (
<div className="p-8 text-center text-red-500">
{t("msg.dev.clients.load_error", "Error loading clients: {{error}}", {
error: errMsg,
})}
</div>
);
}
return (
<div className="space-y-8">
<PageHeader
icon={<ShieldHalf size={20} />}
title={t("ui.dev.clients.registry.subtitle", "연동 앱")}
description={t(
"msg.dev.clients.registry.description",
"OIDC 클라이언트, 인증 방식, 리다이렉트 URI, 비밀키 재발행을 감사 로그와 함께 관리합니다.",
)}
actions={
canCreateClient ? (
<Button
size="sm"
className="mt-1 shadow-lg shadow-primary/30"
onClick={() => navigate("/clients/new")}
>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
) : isDeveloperRequestPending ? (
<div className="flex items-center justify-end gap-3">
<p className="max-w-xs text-right text-sm text-muted-foreground">
{t(
"msg.dev.clients.create_pending_detail",
"개발자 권한 신청을 검토 중입니다. 승인되면 연동 앱을 추가할 수 있습니다.",
)}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate("/developer-requests")}
>
{t("ui.dev.nav.developer_request", "개발자 권한 신청")}
</Button>
</div>
) : canRequestDeveloperAccess ? (
<div className="flex items-center justify-end gap-3">
<p className="max-w-xs whitespace-pre-line text-right text-sm text-muted-foreground">
{t(
"msg.dev.clients.create_requires_request",
"연동 앱을 생성할 권한이 없습니다.\n개발자 권한 신청을 요청한 뒤 승인 받아주세요.",
).replaceAll("\\n", "\n")}
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => navigate("/developer-requests")}
>
{t("ui.dev.welcome.btn_request", "개발자 권한 신청")}
</Button>
</div>
) : (
<div className="flex flex-col items-end gap-2 text-right">
<p className="max-w-xs text-sm text-muted-foreground">
{t(
"msg.dev.clients.create_forbidden_detail",
"연동 앱을 생성할 권한이 없습니다. 관리자에게 개발자 권한 또는 적절한 RP 권한 부여를 요청해 주세요.",
)}
</p>
<Button type="button" variant="outline" size="sm" disabled>
<Plus className="h-4 w-4" />
{t("ui.dev.clients.new", "새 클라이언트")}
</Button>
</div>
)
}
/>
<Card className="glass-panel">
<CardHeader className="space-y-4 pb-4 pt-6">
<div>
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.list.title", "클라이언트 목록")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.showing",
"총 {{shown}}개의 애플리케이션이 등록되어 있습니다.",
{ shown: clients.length },
)}
</CardDescription>
</div>
<SearchFilterBar
primary={
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
placeholder={t(
"ui.dev.clients.search_placeholder",
"클라이언트 이름/ID로 검색...",
)}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
}
actions={
<Button
variant="ghost"
size="sm"
className={cn(
"gap-1 text-muted-foreground",
isAdvancedFilterOpen && "text-primary bg-primary/10",
)}
onClick={() => setIsAdvancedFilterOpen(!isAdvancedFilterOpen)}
>
<Filter className="h-4 w-4" />
{t(
"ui.dev.clients.consents.filters.advanced",
"Advanced Filters",
)}
</Button>
}
advancedOpen={isAdvancedFilterOpen}
advanced={
<div className="flex flex-wrap items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
{t("ui.dev.clients.filter.type_label", "Type:")}
</span>
<select
className="h-9 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.clients.filter.type_all", "모든 유형")}
</option>
<option value="private">
{t("ui.dev.clients.type.private", "Server side App")}
</option>
<option value="pkce">
{t("ui.dev.clients.type.pkce", "PKCE")}
</option>
</select>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
{t("ui.dev.clients.consents.status_label", "Status:")}
</span>
<select
className="h-9 min-w-[140px] rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.clients.filter.status_all", "모든 상태")}
</option>
<option value="active">
{t("ui.common.status.active", "Active")}
</option>
<option value="inactive">
{t("ui.common.status.inactive", "Inactive")}
</option>
</select>
</div>
<Button
variant="ghost"
size="sm"
className="ml-auto text-xs text-muted-foreground"
onClick={() => {
setTypeFilter("all");
setStatusFilter("all");
}}
>
{t("ui.common.reset", "초기화")}
</Button>
</div>
}
/>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table className="min-w-[1180px]">
<TableHeader className={sortableTableHeaderClassName}>
<TableRow>
<SortableTableHead
label={t(
"ui.dev.clients.table.application",
"애플리케이션",
)}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="application"
/>
<SortableTableHead
label={t("ui.dev.clients.table.client_id", "Client ID")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="id"
/>
<SortableTableHead
label={t("ui.dev.clients.table.type", "유형")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="type"
/>
<SortableTableHead
label={t("ui.dev.clients.table.status", "상태")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="status"
/>
<SortableTableHead
label={t("ui.dev.clients.table.created_at", "생성일")}
onSort={requestSort}
sortConfig={sortConfig}
sortKey="createdAt"
/>
<TableHead
className={`${sortableTableHeadBaseClassName} text-right`}
>
{t("ui.dev.clients.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!hasFilterResult && (
<TableRow>
<TableCell
colSpan={6}
className="h-32 text-center text-muted-foreground"
>
<div className="space-y-1">
<p className="font-medium text-foreground">
{isFilteredOut
? t(
"msg.dev.clients.empty_filtered",
"조건에 맞는 연동 앱이 없습니다.",
)
: canCreateClient
? t(
"msg.dev.clients.empty_can_create",
"아직 등록된 연동 앱이 없습니다.",
)
: isDeveloperRequestPending
? t(
"msg.dev.clients.empty_pending",
"개발자 권한 신청을 검토 중입니다.",
)
: t(
"msg.dev.clients.empty",
"조회 가능한 RP가 없습니다.",
)}
</p>
<div className="text-sm space-y-2">
<p className="text-muted-foreground">
{isFilteredOut
? t(
"msg.dev.clients.empty_filtered_detail",
"검색어나 필터 조건을 변경해 보세요.",
)
: canCreateClient
? t(
"msg.dev.clients.empty_can_create_detail",
"연동 앱 추가 버튼으로 새 RP를 생성하면 이 목록에 표시됩니다.",
)
: isDeveloperRequestPending
? t(
"msg.dev.clients.empty_pending_detail",
"super admin이 승인하면 연동 앱을 추가할 수 있습니다.",
)
: t(
"msg.dev.clients.empty_detail",
"RP 관계가 부여되면 이 목록에 해당 RP가 표시됩니다.",
)}
</p>
{!isFilteredOut && canCreateClient && (
<button
type="button"
className="text-primary font-bold hover:underline"
onClick={() => navigate("/clients/new")}
>
{t("ui.dev.clients.new", "연동 앱 추가")}
</button>
)}
{!isFilteredOut && canRequestDeveloperAccess && (
<button
type="button"
className="text-primary font-bold hover:underline"
onClick={() => navigate("/developer-requests")}
>
{t(
"ui.dev.welcome.btn_request",
"개발자 등록 신청하기",
)}
</button>
)}
</div>
</div>
</TableCell>
</TableRow>
)}
{visibleClients.map((client) => (
<TableRow key={client.id}>
<TableCell>
<Link
to={`/clients/${client.id}`}
className="flex items-center gap-3 transition-colors hover:text-primary"
>
<ClientLogo client={client} />
<div>
<p className="font-semibold">
{client.name ||
t("ui.dev.clients.untitled", "Untitled")}
</p>
<p className="text-xs text-muted-foreground">
<span aria-hidden="true">
{t(
"ui.dev.clients.tenant_scoped",
"Tenant-scoped",
)}
</span>
</p>
</div>
</Link>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{client.id}
</code>
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={
client.type === "private" ||
client.metadata?.headless_login_enabled
? "success"
: "muted"
}
>
{client.metadata?.headless_login_enabled
? t(
"ui.dev.clients.type.private_headless",
"Server side App (Headless Login)",
)
: client.type === "private"
? t(
"ui.dev.clients.type.private",
"Server side App",
)
: t("ui.dev.clients.type.pkce", "PKCE")}
</Badge>
</div>
</TableCell>
<TableCell>
<Badge
variant={
client.status === "active" ? "info" : "muted"
}
className="px-3 py-1 text-xs uppercase"
>
{client.status === "active"
? t("ui.common.status.active", "Active")
: t("ui.common.status.inactive", "Inactive")}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{client.createdAt
? new Date(client.createdAt).toLocaleDateString()
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to={`/clients/${client.id}`}>
{t("ui.common.view", "View")}
</Link>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
{canToggleClientList ? (
<div className="mt-4 flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
aria-label={
isClientListExpanded
? t(
"ui.dev.clients.list.collapse_aria",
"연동 앱 목록 접기",
)
: t("ui.dev.clients.list.more_aria", "연동 앱 목록 더보기")
}
onClick={() => setIsClientListExpanded((current) => !current)}
>
{isClientListExpanded
? t("ui.common.collapse", "접기")
: t("ui.common.load_more", "더보기")}
</Button>
</div>
) : null}
</CardContent>
</Card>
<RequestAccessModal
isOpen={isRequestModalOpen}
onClose={() => setIsRequestModalOpen(false)}
onSuccess={() => {
refetchRequest();
setIsRequestModalOpen(false);
}}
tenantId={tenantId || ""}
initialName={profileName}
initialOrg={organizationName}
initialEmail={profileEmail}
initialPhone={profilePhone}
initialRole={profileRoleLabel}
/>
</div>
);
}
interface RequestAccessModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
tenantId: string;
initialName: string;
initialOrg: string;
initialEmail: string;
initialPhone: string;
initialRole: string;
}
function RequestAccessModal({
isOpen,
onClose,
onSuccess,
tenantId,
initialName,
initialOrg,
initialEmail,
initialPhone,
initialRole,
}: RequestAccessModalProps) {
const [name, setName] = useState(initialName);
const [organization, setOrganization] = useState(initialOrg);
const [reason, setReason] = useState("");
useEffect(() => {
if (!isOpen) return;
setName(initialName);
setOrganization(initialOrg);
}, [initialName, initialOrg, isOpen]);
const mutation = useMutation({
mutationFn: requestDeveloperAccess,
onSuccess: () => {
onSuccess();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({
name,
organization,
reason,
tenantId,
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="relative w-full max-w-lg bg-card border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between p-6 border-b border-border/40">
<div>
<h2 className="text-xl font-bold tracking-tight">
{t("ui.dev.request.modal.title", "개발자 등록 신청")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t(
"msg.dev.request.modal.desc",
"신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다.",
)}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">
{t("ui.dev.request.modal.name", "성함")}
</Label>
<Input
id="name"
value={name}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="org">
{t("ui.dev.request.modal.org", "소속")}
</Label>
<Input
id="org"
value={organization}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">
{t("ui.dev.request.modal.email", "이메일")}
</Label>
<Input
id="email"
value={initialEmail}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone">
{t("ui.dev.request.modal.phone", "전화번호")}
</Label>
<Input
id="phone"
value={initialPhone}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="role">
{t("ui.dev.request.modal.role", "역할")}
</Label>
<Input
id="role"
value={initialRole}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="reason">
{t("ui.dev.request.modal.reason", "신청 사유")}{" "}
<span className="text-destructive">*</span>
</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t(
"ui.dev.request.modal.reason_placeholder",
"예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다.",
)}
className="min-h-[120px] resize-none border-primary/50 bg-background focus-visible:ring-primary/40"
required
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
type="submit"
disabled={mutation.isPending}
className="px-8 font-bold"
>
{mutation.isPending
? t("ui.common.submitting", "제출 중...")
: t("ui.common.submit", "신청하기")}
</Button>
</div>
</form>
</div>
</div>
);
}
export default ClientsPage;
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { resolveClientCreateAccess } from "./clientCreateAccess";
describe("client create access", () => {
it("allows privileged roles to create clients without developer request approval", () => {
expect(
resolveClientCreateAccess({
role: "rp_admin",
}),
).toBe("can_create");
});
it("requires a developer request for basic users without approval", () => {
expect(
resolveClientCreateAccess({
role: "user",
requestStatus: "none",
}),
).toBe("request_required");
});
it("treats unresolved roles as request required instead of allowing creation", () => {
expect(
resolveClientCreateAccess({
role: "",
requestStatus: undefined,
}),
).toBe("request_required");
});
it("shows pending state while a developer request is under review", () => {
expect(
resolveClientCreateAccess({
role: "tenant_member",
requestStatus: "pending",
}),
).toBe("pending");
});
it("allows client creation after developer request approval", () => {
expect(
resolveClientCreateAccess({
role: "user",
requestStatus: "approved",
}),
).toBe("can_create");
});
it("routes cancelled or rejected requests back to requestable state", () => {
expect(
resolveClientCreateAccess({
role: "user",
requestStatus: "cancelled",
}),
).toBe("request_required");
expect(
resolveClientCreateAccess({
role: "user",
requestStatus: "rejected",
}),
).toBe("request_required");
});
});
@@ -0,0 +1,48 @@
import type { DeveloperRequestStatus } from "../../lib/devApi";
export type ClientCreateAccessState =
| "can_create"
| "pending"
| "request_required"
| "forbidden";
type ResolveClientCreateAccessParams = {
role: string;
requestStatus?: DeveloperRequestStatus;
};
function canSelfRequestDeveloperAccess(role: string) {
return role === "user" || role === "tenant_member";
}
export function resolveClientCreateAccess({
role,
requestStatus,
}: ResolveClientCreateAccessParams): ClientCreateAccessState {
if (!role.trim()) {
return "request_required";
}
if (!canSelfRequestDeveloperAccess(role)) {
return "can_create";
}
if (requestStatus === "approved") {
return "can_create";
}
if (requestStatus === "pending") {
return "pending";
}
if (
requestStatus === "none" ||
requestStatus === "rejected" ||
requestStatus === "cancelled" ||
typeof requestStatus === "undefined"
) {
return "request_required";
}
return "forbidden";
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { canDisplayClientSecret } from "./clientSecretPolicy";
describe("client secret policy", () => {
it("allows client secret display for server-side apps", () => {
expect(
canDisplayClientSecret({
type: "private",
}),
).toBe(true);
});
it("still allows client secret display for server-side apps even when headless login is enabled in metadata", () => {
expect(
canDisplayClientSecret({
type: "private",
}),
).toBe(true);
});
it("does not allow client secret display for PKCE apps", () => {
expect(
canDisplayClientSecret({
type: "pkce",
}),
).toBe(false);
});
});
@@ -0,0 +1,7 @@
type ClientSecretPolicyTarget = {
type: string;
};
export function canDisplayClientSecret(client: ClientSecretPolicyTarget) {
return client.type === "private";
}
@@ -0,0 +1,82 @@
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AllowedTenantBadge } from "./AllowedTenantBadge";
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
delete (navigator as Navigator & { clipboard?: unknown }).clipboard;
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
document.body.innerHTML = "";
});
function renderAllowedTenantBadge() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(
<AllowedTenantBadge
tenant={{
id: "11111111-2222-3333-4444-555555555555",
name: "한맥기술",
slug: "hanmac",
}}
onRemove={vi.fn()}
/>,
);
});
return container;
}
describe("AllowedTenantBadge", () => {
it("renders tenant name, slug, full UUID, and copies the UUID", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
Object.defineProperty(window, "isSecureContext", {
value: true,
configurable: true,
});
const container = renderAllowedTenantBadge();
const badge = container.querySelector(
'[data-testid="allowed-tenant-11111111-2222-3333-4444-555555555555"]',
);
expect(container.textContent).toContain("한맥기술");
expect(container.textContent).toContain("hanmac");
expect(container.textContent).toContain(
"11111111-2222-3333-4444-555555555555",
);
expect(badge).not.toBeNull();
const copyButton = container.querySelector(
'[data-testid="allowed-tenant-copy-11111111-2222-3333-4444-555555555555"]',
);
expect(copyButton).not.toBeNull();
await act(async () => {
copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(writeText).toHaveBeenCalledWith(
"11111111-2222-3333-4444-555555555555",
);
});
});
@@ -0,0 +1,59 @@
import { Check, X } from "lucide-react";
import { Badge } from "../../../components/ui/badge";
import { CopyButton } from "../../../components/ui/copy-button";
import type { MyTenantSummary, TenantSummary } from "../../../lib/devApi";
import { t } from "../../../lib/i18n";
type AllowedTenant = Pick<TenantSummary | MyTenantSummary, "id" | "name"> & {
slug?: string;
};
type AllowedTenantBadgeProps = {
disabled?: boolean;
onRemove: () => void;
tenant: AllowedTenant;
};
export function AllowedTenantBadge({
disabled = false,
onRemove,
tenant,
}: AllowedTenantBadgeProps) {
return (
<Badge
variant="secondary"
className="flex max-w-full items-center gap-2 px-3 py-1.5"
data-testid={`allowed-tenant-${tenant.id}`}
data-tenant-id={tenant.id}
>
<Check className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-44 truncate">{tenant.name}</span>
{tenant.slug ? (
<span className="shrink-0 text-[11px] text-muted-foreground">
{tenant.slug}
</span>
) : null}
<span className="max-w-64 truncate font-mono text-[11px] text-muted-foreground">
{tenant.id}
</span>
<CopyButton
aria-label="테넌트 UUID 복사"
className="h-6 w-6 shrink-0"
data-testid={`allowed-tenant-copy-${tenant.id}`}
size="icon"
value={tenant.id}
variant="ghost"
/>
<button
type="button"
aria-label={t("ui.common.delete", "삭제")}
onClick={onRemove}
className="shrink-0 text-muted-foreground transition hover:text-destructive"
data-testid={`allowed-tenant-remove-${tenant.id}`}
disabled={disabled}
>
<X className="h-3.5 w-3.5" />
</button>
</Badge>
);
}
@@ -0,0 +1,65 @@
import type { ComponentProps, ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ClientLogo } from "./ClientLogo";
vi.mock("../../../components/ui/avatar", () => ({
Avatar: ({ children }: { children: ReactNode }) => (
<div data-testid="avatar">{children}</div>
),
AvatarImage: (props: ComponentProps<"img">) => <img alt="" {...props} />,
AvatarFallback: ({ children }: { children: ReactNode }) => (
<div data-testid="fallback">{children}</div>
),
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
document.body.innerHTML = "";
});
function renderLogo(client: Parameters<typeof ClientLogo>[0]["client"]) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<ClientLogo client={client} />);
});
return container;
}
describe("ClientLogo", () => {
it("renders the fallback icon when no logo url exists", () => {
const container = renderLogo({
name: "",
type: "private",
metadata: {},
});
expect(container.querySelectorAll("svg").length).toBeGreaterThan(0);
});
it("uses the logo image when a trimmed url is provided", () => {
const container = renderLogo({
name: "Gitea",
type: "pkce",
metadata: { logo_url: " https://example.com/logo.png " },
});
const image = container.querySelector("img");
expect(image).not.toBeNull();
expect(container.querySelector("[data-testid='fallback']")).not.toBeNull();
expect(image?.getAttribute("alt")).toContain("Gitea");
expect(image?.getAttribute("src")).toBe("https://example.com/logo.png");
});
});
@@ -0,0 +1,59 @@
import { ServerCog, ShieldHalf } from "lucide-react";
import { useMemo, useState } from "react";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "../../../components/ui/avatar";
import type { ClientSummary, ClientType } from "../../../lib/devApi";
import { t } from "../../../lib/i18n";
type ClientLogoProps = {
client: Pick<ClientSummary, "name" | "type" | "metadata">;
};
function readLogoUrl(metadata?: Record<string, unknown>): string | undefined {
const logoUrl = metadata?.logo_url;
if (typeof logoUrl !== "string") {
return undefined;
}
const trimmedLogoUrl = logoUrl.trim();
return trimmedLogoUrl.length > 0 ? trimmedLogoUrl : undefined;
}
function TypeFallbackIcon({ type }: { type: ClientType }) {
if (type === "private") {
return <ServerCog className="h-4 w-4" aria-hidden="true" />;
}
return <ShieldHalf className="h-4 w-4" aria-hidden="true" />;
}
export function ClientLogo({ client }: ClientLogoProps) {
const [didImageFail, setDidImageFail] = useState(false);
const logoUrl = useMemo(
() => readLogoUrl(client.metadata),
[client.metadata],
);
const showImage = Boolean(logoUrl) && !didImageFail;
const clientName = client.name || t("ui.dev.clients.untitled", "Untitled");
return (
<Avatar className="h-9 w-9 rounded-lg border border-border/60 bg-primary/10 text-primary">
{showImage ? (
<AvatarImage
src={logoUrl}
alt={t("ui.dev.clients.logo_alt", "{{name}} 로고", {
name: clientName,
})}
className="object-contain p-1"
onError={() => setDidImageFail(true)}
/>
) : null}
<AvatarFallback className="rounded-lg bg-primary/10 text-primary">
<TypeFallbackIcon type={client.type} />
</AvatarFallback>
</Avatar>
);
}
@@ -0,0 +1,179 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ClientFederationPage } from "./ClientFederationPage";
let params: { id?: string } = { id: "client-a" };
const listIdpConfigsMock = vi.fn();
const createIdpConfigMock = vi.fn();
vi.mock("react-router-dom", async () => {
const actual =
await vi.importActual<typeof import("react-router-dom")>(
"react-router-dom",
);
return {
...actual,
useParams: () => params,
};
});
vi.mock("../../../lib/devApi", () => ({
listIdpConfigsForClient: (clientId: string) => listIdpConfigsMock(clientId),
createIdpConfigForClient: (payload: unknown) => createIdpConfigMock(payload),
}));
vi.mock("../../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.clearAllMocks();
document.body.innerHTML = "";
});
beforeEach(() => {
params = { id: "client-a" };
listIdpConfigsMock.mockResolvedValue([
{
id: "idp-1",
client_id: "client-a",
provider_type: "oidc",
display_name: "Workspace OIDC",
status: "active",
issuer_url: "https://accounts.example",
oidc_client_id: "oidc-client",
scopes: "openid email profile",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]);
createIdpConfigMock.mockResolvedValue({
id: "idp-2",
client_id: "client-a",
provider_type: "oidc",
display_name: "New Provider",
status: "active",
createdAt: "2026-05-02T00:00:00Z",
updatedAt: "2026-05-02T00:00:00Z",
});
});
async function setInputValue(input: HTMLInputElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
);
descriptor?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function renderPage() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<ClientFederationPage />
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("ClientFederationPage", () => {
it("shows a missing client id message when no route param exists", async () => {
params = {};
const container = await renderPage();
expect(container.textContent).toContain("Client ID is missing");
});
it("opens the create modal and submits a new IdP config", async () => {
const container = await renderPage();
expect(container.textContent).toContain("Workspace OIDC");
const addButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Add Provider",
);
expect(addButton).toBeTruthy();
await act(async () => {
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Add Identity Provider");
const displayName = container.querySelector(
'input[name="display_name"]',
) as HTMLInputElement | null;
const issuerUrl = container.querySelector(
'input[name="issuer_url"]',
) as HTMLInputElement | null;
const clientId = container.querySelector(
'input[name="oidc_client_id"]',
) as HTMLInputElement | null;
const clientSecret = container.querySelector(
'input[name="oidc_client_secret"]',
) as HTMLInputElement | null;
if (!displayName || !issuerUrl || !clientId || !clientSecret) {
throw new Error("Expected federation form inputs to be rendered");
}
await act(async () => {
await setInputValue(displayName, "New Provider");
await setInputValue(issuerUrl, "https://login.example");
await setInputValue(clientId, "client-oidc");
await setInputValue(clientSecret, "secret-value");
});
const submitButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Save Configuration",
);
expect(submitButton).toBeTruthy();
await act(async () => {
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(createIdpConfigMock).toHaveBeenCalledWith(
expect.objectContaining({
client_id: "client-a",
display_name: "New Provider",
issuer_url: "https://login.example",
oidc_client_id: "client-oidc",
oidc_client_secret: "secret-value",
}),
);
});
});
@@ -0,0 +1,305 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit, Plus, Save, ShieldHalf, Trash2 } from "lucide-react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { PageHeader } from "../../../../../common/core/components/page";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import type { IdpConfig, IdpConfigCreateRequest } from "../../../lib/devApi";
import {
createIdpConfigForClient,
listIdpConfigsForClient,
} from "../../../lib/devApi";
import { t } from "../../../lib/i18n";
// Proper Modal Component with Form
const CreateIdpModal = ({
isOpen,
onClose,
clientId,
}: {
isOpen: boolean;
onClose: () => void;
clientId: string;
}) => {
const queryClient = useQueryClient();
const [formData, setFormData] = useState<IdpConfigCreateRequest>({
client_id: clientId,
provider_type: "oidc",
display_name: "",
status: "active",
issuer_url: "",
oidc_client_id: "",
oidc_client_secret: "",
scopes: "openid email profile",
});
const mutation = useMutation({
mutationFn: (newData: IdpConfigCreateRequest) =>
createIdpConfigForClient(newData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["idpConfigs", clientId] });
onClose();
},
onError: (error) => {
alert(`Failed to create configuration: ${error.message}`);
},
});
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate(formData);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 backdrop-blur-sm">
<Card className="w-full max-w-lg shadow-2xl animate-in zoom-in-95 duration-200">
<CardHeader>
<CardTitle>
{t("ui.dev.clients.federation.add_title", "Add Identity Provider")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.federation.add_subtitle",
"Connect an external OIDC provider.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-semibold">Display Name</label>
<Input
name="display_name"
value={formData.display_name}
onChange={handleChange}
placeholder="e.g. Google Workspace"
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Issuer URL</label>
<Input
type="url"
name="issuer_url"
value={formData.issuer_url}
onChange={handleChange}
placeholder="https://accounts.google.com"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-semibold">Client ID</label>
<Input
name="oidc_client_id"
value={formData.oidc_client_id}
onChange={handleChange}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Client Secret</label>
<Input
type="password"
name="oidc_client_secret"
value={formData.oidc_client_secret}
onChange={handleChange}
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold">Scopes</label>
<Input
name="scopes"
value={formData.scopes}
onChange={handleChange}
/>
</div>
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
{t("ui.common.cancel", "Cancel")}
</Button>
<Button
type="submit"
disabled={
mutation.isPending ||
formData.display_name.trim() === "" ||
(formData.issuer_url?.trim() ?? "") === ""
}
>
{mutation.isPending ? (
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2" />
) : (
<Save size={16} className="mr-2" />
)}
{mutation.isPending
? t("msg.common.saving", "Saving...")
: t("ui.common.save", "Save Configuration")}
</Button>
</div>
</form>
</CardContent>
</Card>
</div>
);
};
export function ClientFederationPage() {
const { id: clientIdParam } = useParams<{ id: string }>();
const clientId = clientIdParam ?? "";
const [isCreateModalOpen, setCreateModalOpen] = useState(false);
const { data, isLoading, error } = useQuery({
queryKey: ["idpConfigs", clientId],
queryFn: () => listIdpConfigsForClient(clientId),
enabled: clientId.length > 0,
});
if (!clientId) {
return (
<div className="p-8 text-center text-destructive">
Client ID is missing
</div>
);
}
return (
<div className="space-y-6 p-1">
<PageHeader
icon={<ShieldHalf size={20} />}
title={t("ui.dev.clients.federation.title", "Identity Federation")}
description={t(
"msg.dev.clients.federation.subtitle",
"Manage external identity providers for this application.",
)}
actions={
<Button onClick={() => setCreateModalOpen(true)} className="gap-2">
<Plus className="h-4 w-4" />
{t("ui.dev.clients.federation.add_btn", "Add Provider")}
</Button>
}
/>
<Card className="glass-panel">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Display Name</TableHead>
<TableHead>Provider Type</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
{t("msg.common.loading", "Loading...")}
</TableCell>
</TableRow>
) : error ? (
<TableRow>
<TableCell
colSpan={4}
className="h-24 text-center text-destructive"
>
{(error as Error).message}
</TableCell>
</TableRow>
) : data?.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="h-24 text-center text-muted-foreground"
>
{t(
"msg.dev.clients.federation.empty",
"No IdP configurations found.",
)}
</TableCell>
</TableRow>
) : (
data?.map((config: IdpConfig) => (
<tr
key={config.id}
className="border-b transition-colors hover:bg-muted/50"
>
<TableCell className="font-medium">
{config.display_name}
</TableCell>
<TableCell>{config.provider_type.toUpperCase()}</TableCell>
<TableCell>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
config.status === "active"
? "bg-blue-500 text-white"
: "bg-muted text-muted-foreground"
}`}
>
{config.status}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8">
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</tr>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<CreateIdpModal
isOpen={isCreateModalOpen}
onClose={() => setCreateModalOpen(false)}
clientId={clientId}
/>
</div>
);
}
@@ -0,0 +1,123 @@
import { act } from "../../../../common/node_modules/react-dom/test-utils";
import { createRoot, type Root } from "../../../../common/node_modules/react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CommonAuditLog } from "../../../../common/core/audit";
import { AuditLogTable } from "../../../../common/core/components/audit";
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.restoreAllMocks();
document.body.innerHTML = "";
});
function renderTable(props: Parameters<typeof AuditLogTable>[0]) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
act(() => {
root.render(<AuditLogTable {...props} />);
});
return { container };
}
const logs: CommonAuditLog[] = [
{
event_id: "evt-1",
timestamp: "2026-05-28T06:07:18.000Z",
user_id: "user-1",
event_type: "CLIENT_UPDATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
device_id: "device-1",
details: JSON.stringify({
request_id: "req-1",
method: "POST",
path: "/api/v1/clients",
latency_ms: 120,
tenant_id: "tenant-1",
actor_id: "user-1",
action: "업데이트",
target_id: "client-a",
before: { status: "inactive" },
after: { status: "active" },
}),
},
];
describe("AuditLogTable", () => {
it("renders rows, expands details, copies fields, and loads more", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
const onLoadMore = vi.fn();
const { container } = renderTable({
logs,
t: (key, fallback, vars) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
loading: false,
hasNextPage: true,
isFetchingNextPage: false,
onLoadMore,
});
expect(container.textContent).toContain("user-1");
expect(container.textContent).toContain("업데이트");
expect(container.textContent).toContain("client-a");
expect(container.textContent).toContain("success");
const buttons = Array.from(container.querySelectorAll("button"));
const actorCopyButton = buttons.find(
(button) => button.getAttribute("aria-label") === "Copy User ID",
);
const targetCopyButton = buttons.find(
(button) => button.getAttribute("aria-label") === "Copy Client ID",
);
const expandButton = buttons.find(
(button) => !button.getAttribute("aria-label") && !button.textContent,
);
const loadMoreButton = buttons.find(
(button) => button.textContent === "Load more",
);
expect(actorCopyButton).toBeTruthy();
expect(targetCopyButton).toBeTruthy();
expect(expandButton).toBeTruthy();
expect(loadMoreButton).toBeTruthy();
await act(async () => {
actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(writeText).toHaveBeenCalledWith("user-1");
expect(writeText).toHaveBeenCalledWith("client-a");
expect(container.textContent).toContain("Request ID · req-1");
expect(container.textContent).toContain("Actor");
expect(container.textContent).toContain("Result");
await act(async () => {
loadMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(onLoadMore).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
resolveAuditTarget,
} from "../../../../common/core/audit";
describe("common audit helpers", () => {
it("parses audit details and falls back on invalid payloads", () => {
expect(parseAuditDetails()).toEqual({});
expect(parseAuditDetails("not-json")).toEqual({});
expect(parseAuditDetails('{"action":"ADD_RELATION"}')).toEqual({
action: "ADD_RELATION",
});
});
it("formats audit values and dates", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(formatAuditValue(null)).toBe("-");
expect(formatAuditValue("hello")).toBe("hello");
expect(formatAuditValue({ a: 1 })).toBe('{"a":1}');
expect(formatAuditValue(circular)).toBe("[object Object]");
expect(formatAuditDateParts("")).toEqual({ date: "-", time: "-" });
expect(formatAuditDateParts("invalid")).toEqual({
date: "invalid",
time: "-",
});
const parsed = formatAuditDateParts("2026-05-27T07:43:39.000Z");
expect(parsed.date).toBe("2026-05-27");
expect(parsed.time).not.toBe("-");
});
it("resolves audit actor, action, and target consistently", () => {
expect(
resolveAuditActor(
{ user_id: "actor-1" },
{ actor_id: "actor-2" },
),
).toBe("actor-1");
expect(
resolveAuditActor({ user_id: "" }, { actor_id: "actor-2" }),
).toBe("actor-2");
expect(resolveAuditActor({ user_id: "" }, {})).toBe("-");
expect(
resolveAuditAction(
{ event_type: "UPDATE_CLIENT" },
{ action: "ADD_RELATION" },
),
).toBe("ADD_RELATION");
expect(
resolveAuditAction(
{ event_type: "UPDATE_CLIENT" },
{ method: "POST", path: "/dev/clients" },
),
).toBe("POST /dev/clients");
expect(resolveAuditAction({ event_type: "UPDATE_CLIENT" }, {})).toBe(
"UPDATE_CLIENT",
);
expect(resolveAuditTarget({ target: "target-1" })).toBe("target-1");
expect(resolveAuditTarget({ target_id: "target-2" })).toBe("target-2");
expect(resolveAuditTarget({})).toBe("-");
});
});
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_OIDC_REDIRECT_PATH,
DEFAULT_OIDC_SCOPE,
buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings,
shouldStartLoginRedirect,
} from "../../../../common/core/auth";
describe("common auth helpers", () => {
it("builds the runtime OIDC config with sensible defaults", () => {
const config = buildCommonOidcRuntimeConfig({
authority: "https://issuer.example.com",
clientId: "client-1",
userStore: { kind: "store" },
});
expect(config).toEqual({
authority: "https://issuer.example.com",
client_id: "client-1",
redirect_uri: `${window.location.origin}${DEFAULT_OIDC_REDIRECT_PATH}`,
response_type: "code",
scope: DEFAULT_OIDC_SCOPE,
post_logout_redirect_uri: window.location.origin,
popup_redirect_uri: `${window.location.origin}${DEFAULT_OIDC_REDIRECT_PATH}`,
userStore: { kind: "store" },
automaticSilentRenew: false,
});
});
it("copies user manager config and fills missing string fields", () => {
expect(
buildCommonUserManagerSettings({
authority: "https://issuer.example.com",
}),
).toEqual({
authority: "https://issuer.example.com",
client_id: "",
redirect_uri: "",
});
});
it("decides when to start login redirects", () => {
expect(
shouldStartLoginRedirect({
pathname: "/clients",
isRedirecting: false,
}),
).toBe(true);
expect(
shouldStartLoginRedirect({
pathname: "/login",
isRedirecting: false,
}),
).toBe(false);
expect(
shouldStartLoginRedirect({
pathname: `${DEFAULT_OIDC_REDIRECT_PATH}/callback`,
isRedirecting: false,
}),
).toBe(false);
expect(
shouldStartLoginRedirect({
pathname: "/clients",
isRedirecting: true,
}),
).toBe(false);
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import {
compareNullableValues,
sortItems,
toggleSort,
} from "../../../../common/core/utils/sort";
describe("common sort utilities in devfront coverage", () => {
it("keeps nullish values last and compares normalized primitive values", () => {
expect(compareNullableValues(null, "alpha", "asc")).toBe(1);
expect(compareNullableValues("alpha", undefined, "asc")).toBe(-1);
expect(compareNullableValues("Beta", "alpha", "asc")).toBe(1);
expect(compareNullableValues("Beta", "alpha", "desc")).toBe(-1);
expect(compareNullableValues(true, false, "asc")).toBe(1);
expect(
compareNullableValues(
new Date("2026-05-02T00:00:00Z"),
new Date("2026-05-01T00:00:00Z"),
"asc",
),
).toBe(1);
});
it("toggles sort direction and sorts with default and custom resolvers", () => {
const firstSort = toggleSort(null, "name");
expect(firstSort).toEqual({ key: "name", direction: "asc" });
expect(toggleSort(firstSort, "name")).toEqual({
key: "name",
direction: "desc",
});
expect(toggleSort(firstSort, "createdAt")).toEqual({
key: "createdAt",
direction: "asc",
});
const rows = [
{ name: "charlie", rank: 3, nested: { score: 20 } },
{ name: "Alpha", rank: 1, nested: { score: 30 } },
{ name: "bravo", rank: 2, nested: { score: 10 } },
];
expect(
sortItems(rows, { key: "name", direction: "asc" }).map(
(row) => row.name,
),
).toEqual(["Alpha", "bravo", "charlie"]);
expect(
sortItems(rows, { key: "score", direction: "desc" }, {
score: (row) => row.nested.score,
}).map((row) => row.name),
).toEqual(["Alpha", "charlie", "bravo"]);
expect(sortItems(rows, null)).not.toBe(rows);
});
});
@@ -0,0 +1,513 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, describe, expect, it, vi } from "vitest";
import AuditLogsPage from "../audit/AuditLogsPage";
import ClientConsentsPage from "../clients/ClientConsentsPage";
import ClientDetailsPage from "../clients/ClientDetailsPage";
import ClientGeneralPage from "../clients/ClientGeneralPage";
import ClientRelationsPage from "../clients/ClientRelationsPage";
import ClientsPage from "../clients/ClientsPage";
import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
import DeveloperRequestPage from "../developer-request/DeveloperRequestPage";
import GlobalOverviewPage from "../overview/GlobalOverviewPage";
import ProfilePage from "../profile/ProfilePage";
import {
approveDeveloperRequest,
cancelDeveloperRequestApproval,
rejectDeveloperRequest,
} from "../../lib/devApi";
const authProfile = {
sub: "user-1",
role: "super_admin",
tenant_id: "tenant-1",
companyCode: "HANMAC",
};
vi.mock("react-oidc-context", () => ({
useAuth: () => ({
isAuthenticated: true,
isLoading: false,
user: {
access_token: "access-token",
profile: authProfile,
},
signinRedirect: vi.fn(),
removeUser: vi.fn(),
}),
}));
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
vi.mock("../../../../common/core/components/audit", () => ({
AuditLogTable: ({
logs,
}: {
logs: Array<{ user_id: string; event_type: string }>;
}) => (
<div>
{logs.map((log) => (
<div key={`${log.user_id}-${log.event_type}`}>
<span>{log.user_id}</span>
<span>{log.event_type}</span>
</div>
))}
</div>
),
}));
const clientSummary = {
id: "client-a",
name: "Console App",
type: "private" as const,
status: "active" as const,
createdAt: "2026-05-01T00:00:00Z",
redirectUris: ["https://app.example/callback"],
scopes: ["openid", "profile"],
tokenEndpointAuthMethod: "client_secret_basic",
metadata: {
headless_login_enabled: true,
headless_login_jwks_uri: "https://app.example/jwks.json",
id_token_claims: [
{
namespace: "rp_claims",
key: "employee_id",
value: "E001",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
},
],
},
};
const clientDetail = {
client: {
...clientSummary,
clientSecret: "secret-value",
jwksUri: "https://app.example/jwks.json",
backchannelLogoutUri: "https://app.example/logout",
backchannelLogoutSessionRequired: true,
grantTypes: ["authorization_code"],
responseTypes: ["code"],
},
endpoints: {
discovery: "https://sso.example/.well-known/openid-configuration",
issuer: "https://sso.example",
authorization: "https://sso.example/oauth2/auth",
token: "https://sso.example/oauth2/token",
userinfo: "https://sso.example/userinfo",
},
headlessJwksCache: {
clientId: "client-a",
jwksUri: "https://app.example/jwks.json",
cachedAt: "2026-05-01T00:00:00Z",
expiresAt: "2026-05-02T00:00:00Z",
lastCheckedAt: "2026-05-01T01:00:00Z",
lastSuccessfulVerificationAt: "2026-05-01T01:00:00Z",
lastRefreshStatus: "success" as const,
cachedKids: ["kid-1"],
parsedKeys: [{ kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256" }],
},
};
vi.mock("../../lib/devApi", () => ({
fetchClients: vi.fn(async () => ({
items: [clientSummary],
limit: 100,
offset: 0,
})),
fetchDevStats: vi.fn(async () => ({
total_clients: 1,
active_sessions: 12,
auth_failures_24h: 2,
})),
fetchDevRPUsageDaily: vi.fn(async () => ({
days: 14,
period: "day",
items: [
{
date: "2026-05-01",
tenantId: "tenant-1",
tenantType: "COMPANY",
tenantName: "Hanmac",
clientId: "client-a",
clientName: "Console App",
loginRequests: 10,
otherRequests: 4,
uniqueSubjects: 3,
},
{
date: "2026-05-08",
tenantId: "tenant-1",
tenantType: "COMPANY",
tenantName: "Hanmac",
clientId: "client-a",
clientName: "Console App",
loginRequests: 8,
otherRequests: 5,
uniqueSubjects: 4,
},
],
})),
fetchClient: vi.fn(async () => clientDetail),
fetchClientRelations: vi.fn(async () => ({
items: [
{
relation: "admins",
subject: "User:user-1",
subjectType: "User",
subjectId: "user-1",
userName: "Dev Admin",
userEmail: "dev@example.com",
},
],
})),
fetchRPUserMetadata: vi.fn(async () => ({
clientId: "client-a",
userId: "user-1",
metadata: {
approvalLevel: "A",
},
})),
updateRPUserMetadata: vi.fn(async (_clientId: string, userId: string, metadata: Record<string, unknown>) => ({
clientId: "client-a",
userId,
metadata,
})),
fetchDevUsers: vi.fn(async () => ({
items: [
{
id: "user-2",
name: "Editor User",
email: "editor@example.com",
loginId: "editor",
},
],
})),
addClientRelation: vi.fn(async () => ({
relation: "admins",
subject: "User:user-2",
subjectType: "User",
subjectId: "user-2",
})),
removeClientRelation: vi.fn(async () => undefined),
updateClientStatus: vi.fn(async () => clientDetail),
createClient: vi.fn(async () => clientDetail),
updateClient: vi.fn(async () => clientDetail),
rotateClientSecret: vi.fn(async () => clientDetail),
refreshHeadlessJwksCache: vi.fn(async () => clientDetail),
revokeHeadlessJwksCache: vi.fn(async () => undefined),
deleteClient: vi.fn(async () => undefined),
fetchConsents: vi.fn(async () => ({
items: [
{
subject: "user-1",
userName: "Consent User",
clientId: "client-a",
clientName: "Console App",
grantedScopes: ["openid", "profile"],
authenticatedAt: "2026-05-01T02:00:00Z",
createdAt: "2026-05-01T00:00:00Z",
status: "active",
tenantId: "tenant-1",
tenantName: "Hanmac",
rpMetadata: {
approvalLevel: "A",
reviewedAt: "2026-06-09T09:30:00+09:00",
},
},
],
})),
revokeConsent: vi.fn(async () => undefined),
listIdpConfigsForClient: vi.fn(async () => [
{
id: "idp-1",
client_id: "client-a",
provider_type: "oidc",
display_name: "Workspace OIDC",
status: "active",
issuer_url: "https://accounts.example",
oidc_client_id: "oidc-client",
scopes: "openid email profile",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
createIdpConfigForClient: vi.fn(async (payload) => ({
id: "idp-1",
...payload,
status: "active",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
})),
updateIdpConfig: vi.fn(async (_clientId, idpId, payload) => ({
id: idpId,
client_id: "client-a",
provider_type: "oidc",
display_name: "Provider",
status: "active",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
...payload,
})),
deleteIdpConfig: vi.fn(async () => undefined),
fetchDevAuditLogs: vi.fn(async () => ({
items: [
{
event_id: "event-1",
timestamp: "2026-05-01T00:00:00Z",
user_id: "user-1",
event_type: "client.update",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
details: "{\"client_id\":\"client-a\"}",
},
],
limit: 50,
})),
fetchMyTenants: vi.fn(async () => [
{
id: "tenant-1",
name: "Hanmac",
slug: "hanmac",
type: "COMPANY",
status: "active",
description: "",
memberCount: 10,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
fetchDeveloperRequestStatus: vi.fn(async () => ({ status: "approved" })),
requestDeveloperAccess: vi.fn(async () => ({ status: "pending" })),
fetchDeveloperRequests: vi.fn(async () => [
{
id: 1,
userId: "user-3",
tenantId: "tenant-1",
name: "Requester",
organization: "Hanmac",
email: "requester@example.com",
reason: "Need RP access",
status: "pending",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: 2,
userId: "user-4",
tenantId: "tenant-1",
name: "Approved Requester",
organization: "Hanmac",
email: "approved@example.com",
reason: "Need elevated access",
status: "approved",
adminNotes: "Reviewed and approved",
createdAt: "2026-05-02T00:00:00Z",
updatedAt: "2026-05-02T00:00:00Z",
},
]),
approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })),
rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })),
cancelDeveloperRequestApproval: vi.fn(async () => ({ status: "cancelled" })),
}));
vi.mock("../auth/authApi", () => ({
fetchMe: vi.fn(async () => ({
id: "user-1",
email: "dev@example.com",
name: "Dev Admin",
role: "super_admin",
tenantId: "tenant-1",
})),
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
});
async function renderPage(
element: React.ReactElement,
{
path = "/",
entry = path,
}: {
path?: string;
entry?: string;
} = {},
) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path={path} element={element} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
async function setInputValue(input: HTMLInputElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
);
descriptor?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("devfront coverage smoke pages", () => {
it("renders overview, client list, audit, developer request, and profile pages", async () => {
const overview = await renderPage(<GlobalOverviewPage />);
expect(overview.textContent).toContain("Console App");
const clients = await renderPage(<ClientsPage />);
expect(clients.textContent).toContain("Console App");
const audit = await renderPage(<AuditLogsPage />);
expect(audit.textContent).toContain("client.update");
const requests = await renderPage(<DeveloperRequestPage />);
expect(requests.textContent).toContain("Requester");
const profile = await renderPage(<ProfilePage />);
expect(profile.textContent).toContain("Dev Admin");
});
it("renders client detail, settings, consent, federation, and relationship pages", async () => {
const details = await renderPage(<ClientDetailsPage />, {
path: "/clients/:id",
entry: "/clients/client-a",
});
expect(details.textContent).toContain("Console App");
const settings = await renderPage(<ClientGeneralPage />, {
path: "/clients/:id/settings",
entry: "/clients/client-a/settings",
});
expect(settings.textContent).toContain("Console App");
expect(settings.textContent).not.toContain("top-level");
expect(settings.textContent).toContain("Date");
expect(settings.textContent).toContain("Datetime");
expect(settings.textContent).toContain("관리자만 가능");
const consents = await renderPage(<ClientConsentsPage />, {
path: "/clients/:id/consents",
entry: "/clients/client-a/consents",
});
expect(consents.textContent).toContain("Consent User");
expect(consents.textContent).toContain("approvalLevel");
expect(consents.textContent).toContain("A");
const federation = await renderPage(<ClientFederationPage />, {
path: "/clients/:id/federation",
entry: "/clients/client-a/federation",
});
expect(federation.textContent).toContain("Workspace OIDC");
const relations = await renderPage(<ClientRelationsPage />, {
path: "/clients/:id/relationships",
entry: "/clients/client-a/relationships",
});
expect(relations.textContent).toContain("Dev Admin");
});
it("covers developer request actions", async () => {
const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => undefined);
const requests = await renderPage(<DeveloperRequestPage />);
expect(requests.textContent).toContain("Requester");
expect(requests.textContent).toContain("Approved Requester");
const pendingNote = Array.from(
requests.querySelectorAll("input"),
).find((input) => input.getAttribute("placeholder") === "메모 입력 (선택)...") as HTMLInputElement | undefined;
const cancelNote = Array.from(
requests.querySelectorAll("input"),
).find(
(input) => input.getAttribute("placeholder") === "승인 취소 사유 입력...",
) as HTMLInputElement | undefined;
expect(pendingNote).toBeTruthy();
expect(cancelNote).toBeTruthy();
await act(async () => {
await setInputValue(pendingNote!, "");
});
const buttons = Array.from(requests.querySelectorAll("button"));
const rejectButton = buttons.find((button) => button.textContent === "반려");
const approveButton = buttons.find((button) => button.textContent === "승인");
const cancelButton = buttons.find(
(button) => button.textContent === "승인 취소",
);
expect(rejectButton).toBeTruthy();
expect(approveButton).toBeTruthy();
expect(cancelButton).toBeTruthy();
await act(async () => {
rejectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(alertSpy).toHaveBeenCalledWith("반려 사유를 입력해주세요.");
await act(async () => {
await setInputValue(pendingNote!, "Need more context");
approveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
await act(async () => {
await setInputValue(cancelNote!, "Approve needs revision");
cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(approveDeveloperRequest).toHaveBeenCalledWith(1, "Need more context");
expect(rejectDeveloperRequest).not.toHaveBeenCalled();
expect(cancelDeveloperRequestApproval).toHaveBeenCalledWith(
2,
"Approve needs revision",
);
alertSpy.mockRestore();
});
});
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import {
resolveDeveloperAccessGate,
shouldFetchDeveloperRequestStatus,
shouldShowDeveloperAccessLoading,
} from "./developerAccessGate";
describe("developer access gate", () => {
it("fetches request status only for user roles", () => {
expect(shouldFetchDeveloperRequestStatus("user")).toBe(true);
expect(shouldFetchDeveloperRequestStatus("tenant_admin")).toBe(false);
expect(shouldFetchDeveloperRequestStatus("rp_admin")).toBe(false);
});
it("resolves access and request states from the request status", () => {
expect(resolveDeveloperAccessGate("super_admin", "pending")).toEqual({
hasDeveloperAccess: true,
isDeveloperRequestPending: true,
canRequestDeveloperAccess: false,
});
expect(resolveDeveloperAccessGate("user", "approved")).toEqual({
hasDeveloperAccess: true,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: false,
});
expect(resolveDeveloperAccessGate("user", "pending")).toEqual({
hasDeveloperAccess: false,
isDeveloperRequestPending: true,
canRequestDeveloperAccess: false,
});
expect(resolveDeveloperAccessGate("user", "none")).toEqual({
hasDeveloperAccess: false,
isDeveloperRequestPending: false,
canRequestDeveloperAccess: true,
});
});
it("shows the loading gate only for user requests", () => {
expect(shouldShowDeveloperAccessLoading("user", true, false)).toBe(true);
expect(shouldShowDeveloperAccessLoading("user", false, true)).toBe(true);
expect(shouldShowDeveloperAccessLoading("tenant_admin", true, true)).toBe(
false,
);
});
});
@@ -0,0 +1,85 @@
import { useQuery } from "@tanstack/react-query";
import {
type DeveloperRequestStatus,
fetchDeveloperRequestStatus,
} from "../../lib/devApi";
export type DeveloperAccessGateState = {
hasDeveloperAccess: boolean;
isDeveloperRequestPending: boolean;
canRequestDeveloperAccess: boolean;
isLoadingDeveloperAccessGate: boolean;
};
function isPrivilegedDeveloperRole(profileRole: string) {
return (
profileRole === "super_admin" ||
profileRole === "rp_admin" ||
profileRole === "tenant_admin"
);
}
export function resolveDeveloperAccessGate(
profileRole: string,
requestStatus?: DeveloperRequestStatus,
): Omit<DeveloperAccessGateState, "isLoadingDeveloperAccessGate"> {
const hasDeveloperAccess =
isPrivilegedDeveloperRole(profileRole) || requestStatus === "approved";
const isDeveloperRequestPending = requestStatus === "pending";
const canRequestDeveloperAccess =
profileRole === "user" && !hasDeveloperAccess && !isDeveloperRequestPending;
return {
hasDeveloperAccess,
isDeveloperRequestPending,
canRequestDeveloperAccess,
};
}
export function shouldFetchDeveloperRequestStatus(profileRole: string) {
return profileRole === "user";
}
export function shouldShowDeveloperAccessLoading(
profileRole: string,
isLoadingIdentity: boolean,
isLoadingRequestStatus: boolean,
) {
return (
profileRole === "user" && (isLoadingIdentity || isLoadingRequestStatus)
);
}
export function useDeveloperAccessGate({
hasAccessToken,
profileRole,
tenantId,
isLoadingIdentity = false,
}: {
hasAccessToken: boolean;
profileRole: string;
tenantId?: string;
isLoadingIdentity?: boolean;
}) {
const shouldFetchRequestStatus =
shouldFetchDeveloperRequestStatus(profileRole);
const { data: requestStatus, isLoading: isLoadingRequestStatus } = useQuery({
queryKey: ["developer-request", tenantId],
queryFn: () => fetchDeveloperRequestStatus(tenantId),
enabled: hasAccessToken && shouldFetchRequestStatus,
});
const resolvedGate = resolveDeveloperAccessGate(
profileRole,
requestStatus?.status,
);
return {
...resolvedGate,
isLoadingDeveloperAccessGate: shouldShowDeveloperAccessLoading(
profileRole,
isLoadingIdentity,
isLoadingRequestStatus,
),
} satisfies DeveloperAccessGateState;
}
@@ -0,0 +1,188 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import DeveloperRequestPage from "./DeveloperRequestPage";
const fetchDeveloperRequestsMock = vi.fn();
const fetchMyTenantsMock = vi.fn();
const fetchMeMock = vi.fn();
const requestDeveloperAccessMock = vi.fn();
let authState = {
user: {
access_token: "access-token",
profile: {
role: "user",
tenant_id: "tenant-1",
companyCode: "HANMAC",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
},
},
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../auth/authApi", () => ({
fetchMe: () => fetchMeMock(),
}));
vi.mock("../../lib/devApi", () => ({
fetchDeveloperRequests: () => fetchDeveloperRequestsMock(),
fetchMyTenants: () => fetchMyTenantsMock(),
requestDeveloperAccess: (...args: unknown[]) =>
requestDeveloperAccessMock(...args),
approveDeveloperRequest: vi.fn(),
rejectDeveloperRequest: vi.fn(),
cancelDeveloperRequestApproval: vi.fn(),
}));
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string, vars?: Record<string, unknown>) => {
let text = fallback ?? key;
for (const [name, value] of Object.entries(vars ?? {})) {
text = text.replaceAll(`{{${name}}}`, String(value));
}
return text;
},
}));
const roots: Root[] = [];
afterEach(() => {
for (const root of roots.splice(0)) {
act(() => {
root.unmount();
});
}
vi.clearAllMocks();
document.body.innerHTML = "";
});
beforeEach(() => {
authState = {
user: {
access_token: "access-token",
profile: {
role: "user",
tenant_id: "tenant-1",
companyCode: "HANMAC",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
},
},
};
fetchDeveloperRequestsMock.mockResolvedValue([]);
fetchMyTenantsMock.mockResolvedValue([
{
id: "tenant-1",
name: "Hanmac",
slug: "hanmac",
type: "COMPANY",
parentId: null,
description: "",
status: "active",
memberCount: 10,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]);
fetchMeMock.mockResolvedValue({
id: "user-1",
name: "Requester",
email: "requester@example.com",
phone: "010-1234-5678",
role: "user",
});
requestDeveloperAccessMock.mockResolvedValue({ status: "pending" });
});
async function setTextAreaValue(input: HTMLTextAreaElement, value: string) {
const descriptor = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
);
descriptor?.set?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function renderPage() {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<DeveloperRequestPage />
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return container;
}
describe("DeveloperRequestPage", () => {
it("opens the request modal and submits a request", async () => {
const container = await renderPage();
expect(container.textContent).toContain("신규 신청하기");
const actionButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("신규 신청하기"),
);
expect(actionButton).toBeTruthy();
await act(async () => {
actionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("개발자 등록 신청");
const reasonField = container.querySelector(
"textarea",
) as HTMLTextAreaElement | null;
if (!reasonField) {
throw new Error("Expected reason textarea to be rendered");
}
await act(async () => {
await setTextAreaValue(reasonField, "Need RP access");
});
const submitButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "신청하기",
);
expect(submitButton).toBeTruthy();
await act(async () => {
submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(requestDeveloperAccessMock).toHaveBeenCalled();
expect(requestDeveloperAccessMock.mock.calls[0]?.[0]).toEqual({
name: "Requester",
organization: "Hanmac",
reason: "Need RP access",
tenantId: "tenant-1",
});
});
});
@@ -0,0 +1,599 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
CheckCircle2,
ClipboardCheck,
Clock,
Plus,
ShieldAlert,
X,
XCircle,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context";
import { PageHeader } from "../../../../common/core/components/page";
import {
commonStickyTableHeaderClass,
commonTableShellClass,
commonTableViewportClass,
} from "../../../../common/ui/table";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import { Textarea } from "../../components/ui/textarea";
import {
approveDeveloperRequest,
cancelDeveloperRequestApproval,
fetchDeveloperRequests,
fetchMyTenants,
rejectDeveloperRequest,
requestDeveloperAccess,
} from "../../lib/devApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { fetchMe } from "../auth/authApi";
export default function DeveloperRequestPage() {
const auth = useAuth();
const queryClient = useQueryClient();
const hasAccessToken = Boolean(auth.user?.access_token);
const userProfile = auth.user?.profile as Record<string, unknown> | undefined;
const role = resolveProfileRole(userProfile);
const tenantId = userProfile?.tenant_id as string | undefined;
const companyCode = userProfile?.companyCode as string | undefined;
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [adminNotes, setAdminNotes] = useState<Record<number, string>>({});
const { data: requests, isLoading } = useQuery({
queryKey: ["developer-requests"],
queryFn: () => fetchDeveloperRequests(),
enabled: !!auth.user?.access_token,
});
const { data: tenants } = useQuery({
queryKey: ["myTenants"],
queryFn: fetchMyTenants,
enabled: !!auth.user?.access_token,
});
const { data: me } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const currentTenant = tenants?.find(
(tenant) => tenant.id === tenantId || tenant.slug === companyCode,
);
const organizationName = currentTenant?.name || companyCode || "";
const profileName = me?.name || (userProfile?.name as string) || "";
const profileEmail = me?.email || (userProfile?.email as string) || "";
const profilePhone =
me?.phone ||
(userProfile?.phone as string | undefined) ||
(userProfile?.phone_number as string | undefined) ||
"";
const profileRole = me?.role?.trim() || role;
const isSuperAdmin = profileRole === "super_admin";
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
const approveMutation = useMutation({
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
approveDeveloperRequest(id, adminNotes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
alert(t("msg.dev.request.approved", "승인되었습니다."));
},
});
const rejectMutation = useMutation({
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
rejectDeveloperRequest(id, adminNotes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
alert(t("msg.dev.request.rejected", "반려되었습니다."));
},
});
const cancelApprovalMutation = useMutation({
mutationFn: ({ id, adminNotes }: { id: number; adminNotes: string }) =>
cancelDeveloperRequestApproval(id, adminNotes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
queryClient.invalidateQueries({ queryKey: ["developer-request"] });
alert(t("msg.dev.request.cancelled", "승인이 취소되었습니다."));
},
});
const handleApprove = (id: number) => {
approveMutation.mutate({ id, adminNotes: adminNotes[id] || "" });
};
const handleReject = (id: number) => {
if (!adminNotes[id]) {
alert(t("msg.dev.request.need_notes", "반려 사유를 입력해주세요."));
return;
}
rejectMutation.mutate({ id, adminNotes: adminNotes[id] });
};
const handleCancelApproval = (id: number) => {
if (!adminNotes[id]) {
alert(
t(
"msg.dev.request.need_cancel_notes",
"승인 취소 사유를 입력해주세요.",
),
);
return;
}
cancelApprovalMutation.mutate({ id, adminNotes: adminNotes[id] });
};
if (isLoading) {
return (
<div className="p-8 text-center">
{t("ui.common.loading", "Loading...")}
</div>
);
}
const hasActiveRequest = requests?.some(
(r) => r.status === "pending" || r.status === "approved",
);
const approvedRequestCount =
requests?.filter((request) => request.status === "approved").length ?? 0;
const isActionPending =
approveMutation.isPending ||
rejectMutation.isPending ||
cancelApprovalMutation.isPending;
return (
<div className="space-y-6">
<PageHeader
icon={<ClipboardCheck size={20} />}
title={t("ui.dev.nav.developer_request", "개발자 권한 신청")}
description={
isSuperAdmin
? t(
"msg.dev.request.admin_desc",
"사용자들의 개발자 권한 신청 내역을 관리합니다.",
)
: t(
"msg.dev.request.user_desc",
"내 신청 내역을 확인하고 새로운 권한을 신청할 수 있습니다.",
)
}
actions={
!isSuperAdmin && !hasActiveRequest ? (
<Button onClick={() => setIsRequestModalOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("ui.dev.welcome.btn_request", "신규 신청하기")}
</Button>
) : null
}
/>
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-xl">
{t("ui.dev.request.list.title", "신청 내역")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.request.list.approved_count",
"총 {{count}}명의 사용자가 승인되었습니다.",
{ count: approvedRequestCount },
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
{isSuperAdmin && (
<TableHead>
{t("ui.dev.request.table.user", "사용자")}
</TableHead>
)}
<TableHead>
{t("ui.dev.request.table.org", "소속")}
</TableHead>
<TableHead>
{t("ui.dev.request.table.reason", "신청 사유")}
</TableHead>
<TableHead>
{t("ui.dev.request.table.status", "상태")}
</TableHead>
<TableHead>
{t("ui.dev.request.table.date", "신청일")}
</TableHead>
{isSuperAdmin && (
<TableHead className="text-right">
{t("ui.dev.request.table.actions", "관리")}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{!requests || requests.length === 0 ? (
<TableRow>
<TableCell
colSpan={isSuperAdmin ? 6 : 4}
className="h-32 text-center text-muted-foreground"
>
{t("msg.dev.request.empty", "신청 내역이 없습니다.")}
</TableCell>
</TableRow>
) : (
requests.map((req) => (
<TableRow key={req.id}>
{isSuperAdmin && (
<TableCell className="font-medium">
<div>{req.name}</div>
<div className="text-xs text-muted-foreground">
{req.email || req.userId}
</div>
{(req.phone || req.role) && (
<div className="mt-1 text-xs text-muted-foreground">
{[req.phone, req.role]
.filter(Boolean)
.join(" / ")}
</div>
)}
</TableCell>
)}
<TableCell>{req.organization}</TableCell>
<TableCell className="max-w-md">
<div className="truncate" title={req.reason}>
{req.reason}
</div>
{req.adminNotes && (
<div className="mt-1 rounded bg-amber-50 p-1.5 text-xs text-amber-600 dark:bg-amber-900/20">
<strong>Admin:</strong> {req.adminNotes}
</div>
)}
</TableCell>
<TableCell>
<StatusBadge status={req.status} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(req.createdAt).toLocaleDateString()}
</TableCell>
{isSuperAdmin && (
<TableCell className="text-right">
{req.status === "pending" ? (
<div className="ml-auto flex min-w-[200px] flex-col items-end gap-2">
<Input
placeholder={t(
"ui.dev.request.admin_notes_placeholder",
"메모 입력 (선택)...",
)}
className="h-8 text-xs"
value={adminNotes[req.id] || ""}
onChange={(e) =>
setAdminNotes({
...adminNotes,
[req.id]: e.target.value,
})
}
/>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleReject(req.id)}
disabled={isActionPending}
>
<XCircle className="mr-1 h-3 w-3" />
{t("ui.common.reject", "반려")}
</Button>
<Button
size="sm"
className="bg-emerald-600 hover:bg-emerald-700"
onClick={() => handleApprove(req.id)}
disabled={isActionPending}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
{t("ui.common.approve", "승인")}
</Button>
</div>
</div>
) : req.status === "approved" ? (
<div className="ml-auto flex min-w-[200px] flex-col items-end gap-2">
<Input
placeholder={t(
"ui.dev.request.cancel_notes_placeholder",
"승인 취소 사유 입력...",
)}
className="h-8 text-xs"
value={adminNotes[req.id] || ""}
onChange={(e) =>
setAdminNotes({
...adminNotes,
[req.id]: e.target.value,
})
}
/>
<Button
size="sm"
variant="outline"
className="text-destructive hover:bg-destructive/10"
onClick={() => handleCancelApproval(req.id)}
disabled={isActionPending}
>
<XCircle className="mr-1 h-3 w-3" />
{t(
"ui.dev.request.cancel_approval",
"승인 취소",
)}
</Button>
</div>
) : (
<span className="text-xs italic text-muted-foreground">
{req.status === "cancelled"
? t(
"ui.dev.request.status.cancelled",
"승인 취소됨",
)
: t("ui.common.rejected", "반려됨")}
</span>
)}
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
<RequestAccessModal
isOpen={isRequestModalOpen}
onClose={() => setIsRequestModalOpen(false)}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ["developer-requests"] });
setIsRequestModalOpen(false);
}}
tenantId={tenantId || ""}
initialName={profileName}
initialOrg={organizationName}
initialEmail={profileEmail}
initialPhone={profilePhone}
initialRole={profileRoleLabel}
/>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
switch (status) {
case "pending":
return (
<Badge variant="warning" className="gap-1">
<Clock className="h-3 w-3" />
{t("ui.dev.request.status.pending", "대기 중")}
</Badge>
);
case "approved":
return (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
{t("ui.dev.request.status.approved", "승인됨")}
</Badge>
);
case "rejected":
return (
<Badge variant="muted" className="gap-1">
<ShieldAlert className="h-3 w-3" />
{t("ui.dev.request.status.rejected", "반려됨")}
</Badge>
);
case "cancelled":
return (
<Badge variant="muted" className="gap-1">
<XCircle className="h-3 w-3" />
{t("ui.dev.request.status.cancelled", "승인 취소됨")}
</Badge>
);
default:
return <Badge variant="muted">{status}</Badge>;
}
}
interface RequestAccessModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
tenantId: string;
initialName: string;
initialOrg: string;
initialEmail: string;
initialPhone: string;
initialRole: string;
}
function RequestAccessModal({
isOpen,
onClose,
onSuccess,
tenantId,
initialName,
initialOrg,
initialEmail,
initialPhone,
initialRole,
}: RequestAccessModalProps) {
const [name, setName] = useState(initialName);
const [organization, setOrganization] = useState(initialOrg);
const [reason, setReason] = useState("");
useEffect(() => {
if (!isOpen) return;
setName(initialName);
setOrganization(initialOrg);
}, [initialName, initialOrg, isOpen]);
const mutation = useMutation({
mutationFn: requestDeveloperAccess,
onSuccess: () => {
onSuccess();
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({
name,
organization,
reason,
tenantId,
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in duration-200">
<div className="relative w-full max-w-lg bg-card border border-border shadow-2xl rounded-2xl overflow-hidden animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between p-6 border-b border-border/40">
<div>
<h2 className="text-xl font-bold tracking-tight">
{t("ui.dev.request.modal.title", "개발자 등록 신청")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t(
"msg.dev.request.modal.desc",
"신청 사유를 입력해 주세요. 관리자 확인 후 승인됩니다.",
)}
</p>
</div>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name">
{t("ui.dev.request.modal.name", "성함")}
</Label>
<Input
id="name"
value={name}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="org">
{t("ui.dev.request.modal.org", "소속")}
</Label>
<Input
id="org"
value={organization}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
required
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="email">
{t("ui.dev.request.modal.email", "이메일")}
</Label>
<Input
id="email"
value={initialEmail}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="phone">
{t("ui.dev.request.modal.phone", "전화번호")}
</Label>
<Input
id="phone"
value={initialPhone}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="role">
{t("ui.dev.request.modal.role", "역할")}
</Label>
<Input
id="role"
value={initialRole}
readOnly
className="focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:border-input"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="reason">
{t("ui.dev.request.modal.reason", "신청 사유")}{" "}
<span className="text-destructive">*</span>
</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t(
"ui.dev.request.modal.reason_placeholder",
"예: 자체 서비스 연동 및 테스트용 OIDC 클라이언트 생성이 필요합니다.",
)}
className="min-h-[120px] resize-none border-primary/50 bg-background focus-visible:ring-primary/40"
required
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<Button type="button" variant="outline" onClick={onClose}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
type="submit"
disabled={mutation.isPending}
className="px-8 font-bold"
>
{mutation.isPending
? t("ui.common.submitting", "제출 중...")
: t("ui.common.submit", "신청하기")}
</Button>
</div>
</form>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,253 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ClientSummary, DevAuditLog } from "../../lib/devApi";
import {
buildRecentClientChangeDetails,
buildRecentClientChanges,
getRecentClientActionLabel,
} from "./recentClientChanges";
function makeClient(id: string, name = id): ClientSummary {
return {
id,
name,
type: "private",
status: "active",
createdAt: "2026-05-27T00:00:00.000Z",
redirectUris: [],
scopes: [],
};
}
function makeAuditLog(
eventId: string,
timestamp: string,
action: string,
targetId: string,
details: Record<string, unknown>,
): DevAuditLog {
return {
event_id: eventId,
timestamp,
user_id: "actor-1",
event_type: "AUDIT",
status: "success",
ip_address: "127.0.0.1",
user_agent: "vitest",
details: JSON.stringify({
action,
target_id: targetId,
...details,
}),
};
}
describe("recent client changes", () => {
beforeEach(() => {
window.localStorage.clear();
window.history.replaceState({}, "", "/");
});
function mockLocale(locale: "ko" | "en") {
window.localStorage.clear();
window.history.replaceState({}, "", `/${locale}`);
}
it("translates action labels and relation details by locale", () => {
mockLocale("en");
expect(getRecentClientActionLabel("CREATE_CLIENT")).toBe("App creation");
expect(getRecentClientActionLabel("UPDATE_CLIENT")).toBe(
"Settings changes",
);
expect(getRecentClientActionLabel("UPDATE_CLIENT_STATUS")).toBe(
"Status changes",
);
expect(getRecentClientActionLabel("ROTATE_SECRET")).toBe(
"Client secret rotation",
);
expect(getRecentClientActionLabel("ADD_RELATION")).toBe("Add Relationship");
expect(getRecentClientActionLabel("REMOVE_RELATION")).toBe(
"Remove Relationship",
);
expect(getRecentClientActionLabel("DELETE_CLIENT")).toBe("App deletion");
expect(getRecentClientActionLabel("OTHER_ACTION")).toBe("OTHER_ACTION");
expect(
buildRecentClientChangeDetails("ROTATE_SECRET", {
after: {},
}),
).toEqual([{ label: "Client Secret", value: "Secret Rotated" }]);
expect(
buildRecentClientChangeDetails("ADD_RELATION", {
after: {
relation: "admins",
subject: "User:1",
},
}),
).toEqual([
{ label: "Relation", value: "admins" },
{ label: "Subject", value: "User:1" },
]);
});
it("builds recent client changes with sorting, filtering, and detail slicing", () => {
mockLocale("ko");
const clients = [
makeClient("client-a", "Alpha"),
makeClient("client-b", ""),
];
const auditLogs = [
makeAuditLog(
"evt-1",
"2026-05-27T07:00:00.000Z",
"CREATE_CLIENT",
"client-a",
{
after: { name: "Alpha", type: "private", status: "active" },
},
),
makeAuditLog(
"evt-2",
"2026-05-27T08:00:00.000Z",
"UPDATE_CLIENT",
"client-a",
{
before: {
name: "Alpha old",
status: "inactive",
sameField: "same",
oldField: "old-value",
},
after: {
name: "Alpha new",
status: "active",
sameField: "same",
newField: "new-value",
},
},
),
makeAuditLog(
"evt-3",
"2026-05-27T09:00:00.000Z",
"UPDATE_CLIENT_STATUS",
"client-a",
{
before: { status: "inactive" },
after: { status: "active" },
},
),
makeAuditLog(
"evt-4",
"2026-05-27T10:00:00.000Z",
"ADD_RELATION",
"client-b",
{
after: {
relation: "audit_viewer",
subject: "User:89692983-f512-4d96-845d-ac6123d08b95",
},
},
),
makeAuditLog(
"evt-5",
"2026-05-27T11:00:00.000Z",
"REMOVE_RELATION",
"client-b",
{
before: {
relation: "admins",
subject: "User:89692983-f512-4d96-845d-ac6123d08b95",
},
},
),
makeAuditLog(
"evt-6",
"2026-05-27T12:00:00.000Z",
"ROTATE_SECRET",
"client-a",
{
after: {},
},
),
makeAuditLog(
"evt-7",
"2026-05-27T13:00:00.000Z",
"DELETE_CLIENT",
"client-a",
{
before: {
name: "Alpha",
status: "inactive",
},
},
),
makeAuditLog(
"evt-8",
"2026-05-27T14:00:00.000Z",
"UNSUPPORTED_ACTION",
"client-a",
{
after: { name: "Ignored" },
},
),
];
const changes = buildRecentClientChanges(auditLogs, clients);
expect(changes).toHaveLength(7);
expect(changes[0]).toMatchObject({
eventId: "evt-7",
clientName: "Alpha",
actionLabel: "앱 삭제",
});
expect(changes[1]).toMatchObject({
eventId: "evt-6",
clientName: "Alpha",
actionLabel: "클라이언트 시크릿 재발급",
detailLabels: [
{
label: "클라이언트 시크릿",
value: "Client Secret이 재발급되었습니다.",
},
],
});
expect(changes[2]).toMatchObject({
eventId: "evt-5",
clientName: "client-b",
actionLabel: "관계 삭제",
detailLabels: [
{ label: "관계", value: "admins" },
{
label: "주체",
value: "User:89692983-f512-4d96-845d-ac6123d08b95",
},
],
});
expect(changes[4]).toMatchObject({
eventId: "evt-3",
actionLabel: "상태 변경",
clientName: "Alpha",
detailLabels: [{ value: "inactive → active" }],
});
expect(changes[5]).toMatchObject({
eventId: "evt-2",
actionLabel: "설정 변경",
detailLabels: [
{ label: "애플리케이션", value: "Alpha old → Alpha new" },
{ label: "상태", value: "inactive → active" },
{ label: "oldField", value: "old-value" },
],
});
expect(changes[6]).toMatchObject({
eventId: "evt-1",
actionLabel: "앱 생성",
detailLabels: [
{ label: "애플리케이션", value: "Alpha" },
{ label: "유형", value: "private" },
{ label: "상태", value: "active" },
],
});
});
});
@@ -0,0 +1,203 @@
import {
type AuditDetails,
type CommonAuditLog,
formatAuditValue,
parseAuditDetails,
resolveAuditActor,
} from "../../../../common/core/audit";
import type { ClientSummary, DevAuditLog } from "../../lib/devApi";
import { t } from "../../lib/i18n";
export type RecentClientChange = {
eventId: string;
clientId: string;
clientName: string;
actorId: string;
action: string;
actionLabel: string;
timestamp: string;
detailLabels: Array<{ label: string; value: string }>;
};
const recentClientActions = new Set([
"CREATE_CLIENT",
"UPDATE_CLIENT",
"UPDATE_CLIENT_STATUS",
"ROTATE_SECRET",
"ADD_RELATION",
"REMOVE_RELATION",
"DELETE_CLIENT",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export function getRecentClientActionLabel(action: string) {
switch (action) {
case "CREATE_CLIENT":
return t("ui.dev.clients.recent_changes.guide.create", "앱 생성");
case "UPDATE_CLIENT":
return t("ui.dev.clients.recent_changes.guide.settings", "설정 변경");
case "UPDATE_CLIENT_STATUS":
return t("ui.dev.clients.recent_changes.guide.status", "상태 변경");
case "ROTATE_SECRET":
return t(
"ui.dev.clients.recent_changes.guide.secret",
"클라이언트 시크릿 재발급",
);
case "ADD_RELATION":
return t("ui.dev.clients.relationships.add_title", "관계 추가");
case "REMOVE_RELATION":
return t("ui.dev.clients.relationships.remove_title", "관계 삭제");
case "DELETE_CLIENT":
return t("ui.dev.clients.recent_changes.guide.delete", "앱 삭제");
default:
return action;
}
}
function getRecentClientFieldLabel(key: string) {
switch (key) {
case "name":
return t("ui.dev.clients.table.application", "Application");
case "type":
return t("ui.dev.clients.table.type", "Type");
case "status":
return t("ui.dev.clients.table.status", "Status");
case "relation":
return t("ui.dev.clients.relationships.relation", "관계");
case "subject":
return t("ui.dev.clients.relationships.subject", "주체");
case "client_secret":
return t(
"ui.dev.clients.details.credentials.client_secret",
"클라이언트 시크릿",
);
default:
return key;
}
}
export function buildRecentClientChangeDetails(
action: string,
details: AuditDetails,
) {
const before = isRecord(details.before) ? details.before : {};
const after = isRecord(details.after) ? details.after : {};
if (action === "ROTATE_SECRET") {
return [
{
label: getRecentClientFieldLabel("client_secret"),
value: t("msg.dev.clients.details.secret_rotated", "재발급"),
},
];
}
if (action === "ADD_RELATION" || action === "REMOVE_RELATION") {
const source = action === "ADD_RELATION" ? after : before;
return [
...(source.relation
? [
{
label: getRecentClientFieldLabel("relation"),
value: formatAuditValue(source.relation),
},
]
: []),
...(source.subject
? [
{
label: getRecentClientFieldLabel("subject"),
value: formatAuditValue(source.subject),
},
]
: []),
];
}
const keys = Array.from(
new Set([...Object.keys(before), ...Object.keys(after)]),
);
const changes = keys
.map((key) => {
const beforeValue = before[key];
const afterValue = after[key];
if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") {
if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) {
return null;
}
}
const label = getRecentClientFieldLabel(key);
if (action === "CREATE_CLIENT") {
if (afterValue === undefined) {
return null;
}
return { label, value: formatAuditValue(afterValue) };
}
if (action === "DELETE_CLIENT") {
if (beforeValue === undefined) {
return null;
}
return { label, value: formatAuditValue(beforeValue) };
}
if (beforeValue === undefined && afterValue === undefined) {
return null;
}
if (beforeValue === undefined) {
return { label, value: formatAuditValue(afterValue) };
}
if (afterValue === undefined) {
return { label, value: formatAuditValue(beforeValue) };
}
return {
label,
value: `${formatAuditValue(beforeValue)} → ${formatAuditValue(afterValue)}`,
};
})
.filter((item): item is { label: string; value: string } => Boolean(item));
return changes.slice(0, 3);
}
export function buildRecentClientChanges(
auditLogs: DevAuditLog[],
clients: ClientSummary[],
) {
const clientNameById = new Map(
clients.map((client) => [client.id, client.name || client.id]),
);
return auditLogs
.map((item) => {
const details = parseAuditDetails(item.details);
const action = details.action || "";
const clientId = String(details.target_id || "");
if (!recentClientActions.has(action) || !clientId) {
return null;
}
return {
eventId: item.event_id,
clientId,
clientName: clientNameById.get(clientId) || clientId,
actorId: resolveAuditActor(
item as Pick<CommonAuditLog, "user_id">,
details,
),
action,
actionLabel: getRecentClientActionLabel(action),
timestamp: item.timestamp,
detailLabels: buildRecentClientChangeDetails(action, details),
} satisfies RecentClientChange;
})
.filter((item): item is RecentClientChange => Boolean(item))
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
new Date(left.timestamp).getTime(),
);
}
@@ -0,0 +1,224 @@
import { useQuery } from "@tanstack/react-query";
import {
Briefcase,
Building2,
Fingerprint,
Mail,
Shield,
User,
} from "lucide-react";
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { t } from "../../lib/i18n";
import { fetchMe } from "../auth/authApi";
import ProfileTenantSwitcher from "./ProfileTenantSwitcher";
function ProfilePage() {
const auth = useAuth();
const hasAccessToken = Boolean(auth.user?.access_token);
const {
data: profile,
isLoading,
error,
} = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
enabled: hasAccessToken,
});
const [activeTab, setActiveTab] = useState<"basic" | "role">("basic");
if (isLoading) {
return (
<div className="p-8 text-center">
{t("ui.dev.profile.loading", "Loading profile...")}
</div>
);
}
if (error || !profile) {
return (
<div className="p-8 text-center text-red-500">
{t("ui.dev.profile.error", "Failed to load profile information.")}
</div>
);
}
// Fallback to token information if API data is incomplete
const displayTenant =
profile.tenant?.name ||
profile.tenantId ||
auth.user?.profile?.tenant_id?.toString() ||
"-";
const displayCompanyCode =
profile.companyCode || auth.user?.profile?.companyCode?.toString() || "-";
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div>
<div className="flex items-center 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">
<User className="h-5 w-5" />
</div>
<h1 className="text-3xl font-semibold tracking-tight">
{t("ui.dev.profile.title", "내 정보")}
</h1>
</div>
<p className="text-muted-foreground mt-2">
{t(
"ui.dev.profile.subtitle",
"사용자 상세 정보 및 할당된 역할(Role)을 확인합니다.",
)}
</p>
</div>
<div className="flex space-x-1 border-b border-border pb-px">
<button
type="button"
onClick={() => setActiveTab("basic")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "basic"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
{t("ui.dev.profile.tab.basic", "기본 정보")}
</button>
<button
type="button"
onClick={() => setActiveTab("role")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "role"
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground hover:border-border"
}`}
>
{t("ui.dev.profile.tab.role", "권한 및 역할")}
</button>
</div>
<div className="pt-4">
{activeTab === "basic" && (
<div className="grid gap-6 md:grid-cols-2">
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<User className="h-5 w-5 text-primary" />
{t("ui.dev.profile.basic.title", "사용자 정보")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Fingerprint className="h-4 w-4" />
{t("ui.dev.profile.basic.id", "User ID")}
</p>
<p className="text-sm break-all font-mono bg-muted/50 p-2 rounded-md">
{profile.id}
</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<User className="h-4 w-4" />
{t("ui.dev.profile.basic.name", "Name")}
</p>
<p className="text-sm">{profile.name}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Mail className="h-4 w-4" />
{t("ui.dev.profile.basic.email", "Email")}
</p>
<p className="text-sm">{profile.email}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Briefcase className="h-4 w-4" />
{t("ui.dev.profile.basic.phone", "Phone")}
</p>
<p className="text-sm">{profile.phone || "-"}</p>
</div>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Building2 className="h-5 w-5 text-primary" />
{t("ui.dev.profile.org.title", "조직 정보")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">
{t("ui.dev.profile.org.tenant", "테넌트")}
</p>
<p className="text-sm">{displayTenant}</p>
</div>
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">
{t("ui.dev.profile.org.company_code", "회사 코드")}
</p>
<p className="text-sm">{displayCompanyCode}</p>
</div>
</CardContent>
</Card>
<ProfileTenantSwitcher />
</div>
)}
{activeTab === "role" && (
<Card className="glass-panel">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
{t("ui.dev.profile.role.title", "시스템 역할")}
</CardTitle>
<CardDescription>
{t(
"ui.dev.profile.role.description",
"현재 계정에 부여된 권한 등급입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 bg-muted/30 p-4 rounded-lg border border-border">
<div className="h-12 w-12 rounded-full bg-primary/20 flex items-center justify-center text-primary shrink-0">
<Briefcase className="h-6 w-6" />
</div>
<div className="flex flex-col gap-1 w-full">
<p className="text-sm text-muted-foreground font-medium uppercase tracking-wider">
{t("ui.dev.profile.role.current", "Current Role")}
</p>
<p className="text-xl font-bold mt-1">
{t(
`ui.common.role.${profile.role}`,
profile.role.toUpperCase(),
)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{t(
`ui.dev.profile.role.desc_${profile.role}`,
"시스템 역할에 대한 설명이 제공되지 않았습니다.",
)}
</p>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}
export default ProfilePage;
@@ -0,0 +1,92 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Building2, Save } from "lucide-react";
import { useState } from "react";
import { Button } from "../../components/ui/button";
import { toast } from "../../components/ui/use-toast";
import { fetchMyTenants } from "../../lib/devApi";
import { t } from "../../lib/i18n";
export default function ProfileTenantSwitcher() {
const queryClient = useQueryClient();
const { data: tenants, isLoading } = useQuery({
queryKey: ["myTenants"],
queryFn: fetchMyTenants,
});
const [selectedTenantId, setSelectedTenantId] = useState<string>(() => {
return window.localStorage.getItem("dev_tenant_id") || "";
});
const handleSave = () => {
window.localStorage.setItem("dev_tenant_id", selectedTenantId);
// Invalidate queries to refresh data with new tenant context
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] !== "userMe" && query.queryKey[0] !== "myTenants",
});
toast(t("ui.dev.tenant.switch_success", "테넌트 전환 완료"), "success");
};
if (isLoading || !tenants || tenants.length === 0) {
return null;
}
// If there's only one tenant, the user doesn't need to switch.
// Still show it as read-only or hidden. Let's just show it as disabled.
const isSingleTenant = tenants.length <= 1;
return (
<div className="flex flex-col gap-4 mt-6 p-4 rounded-lg border border-border bg-card">
<div className="flex items-center gap-2 mb-2">
<Building2 className="h-5 w-5 text-primary" />
<h3 className="font-semibold">
{t("ui.dev.tenant.workspace", "작업 테넌트 (컨텍스트)")}
</h3>
</div>
<p className="text-sm text-muted-foreground -mt-2 mb-2">
{t(
"ui.dev.tenant.workspace_desc",
"현재 작업 중인 테넌트를 선택하고 저장하여 API 요청 컨텍스트를 변경합니다.",
)}
</p>
<div className="flex items-center gap-3">
<select
aria-label={t("ui.dev.tenant.workspace", "작업 테넌트 (컨텍스트)")}
value={selectedTenantId}
onChange={(e) => setSelectedTenantId(e.target.value)}
disabled={isSingleTenant}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
{tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name}
</option>
))}
</select>
<Button
type="button"
onClick={handleSave}
disabled={isSingleTenant}
className="gap-2"
>
<Save size={16} />
{t("ui.common.save", "저장")}
</Button>
</div>
{isSingleTenant && (
<p className="text-xs text-muted-foreground mt-1">
{t(
"ui.dev.tenant.single_notice",
"단일 테넌트에 소속되어 전환할 필요가 없습니다.",
)}
</p>
)}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
@import "../../common/theme/base.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 210 25% 6%;
--foreground: 210 35% 96%;
--card: 215 32% 9%;
--card-foreground: 210 35% 96%;
--popover: 215 32% 9%;
--popover-foreground: 210 35% 96%;
--primary: 209 79% 52%;
--primary-foreground: 210 35% 96%;
--secondary: 215 25% 16%;
--secondary-foreground: 210 35% 96%;
--muted: 215 15% 65%;
--muted-foreground: 215 15% 65%;
--accent: 42 95% 57%;
--accent-foreground: 215 25% 10%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 35% 96%;
--border: 215 25% 24%;
--input: 215 25% 24%;
--ring: 209 79% 52%;
--radius: 0.75rem;
--app-background-image:
radial-gradient(
circle at 10% 18%,
rgba(54, 211, 153, 0.16),
transparent 28%
),
radial-gradient(
circle at 78% 4%,
rgba(249, 168, 38, 0.14),
transparent 24%
),
radial-gradient(
circle at 50% 90%,
rgba(54, 211, 153, 0.08),
transparent 30%
);
}
}
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const getUserMock = vi.fn();
const findPersistedOidcUserMock = vi.fn();
const removeUserMock = vi.fn();
const shouldStartLoginRedirectMock = vi.fn();
const shouldSuppressDevelopmentSessionRedirectMock = vi.fn();
vi.mock("./auth", () => ({
userManager: {
getUser: (...args: unknown[]) => getUserMock(...args),
removeUser: (...args: unknown[]) => removeUserMock(...args),
},
}));
vi.mock("./oidcStorage", () => ({
findPersistedOidcUser: (...args: unknown[]) =>
findPersistedOidcUserMock(...args),
}));
vi.mock("../../../common/core/auth", () => ({
shouldStartLoginRedirect: (...args: unknown[]) =>
shouldStartLoginRedirectMock(...args),
}));
vi.mock("../../../common/core/session", () => ({
shouldSuppressDevelopmentSessionRedirect: (...args: unknown[]) =>
shouldSuppressDevelopmentSessionRedirectMock(...args),
}));
describe("apiClient", () => {
beforeEach(() => {
vi.resetModules();
vi.stubEnv("MODE", "test");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
window.localStorage.clear();
getUserMock.mockResolvedValue(null);
findPersistedOidcUserMock.mockReturnValue(undefined);
removeUserMock.mockResolvedValue(undefined);
shouldStartLoginRedirectMock.mockReturnValue(true);
shouldSuppressDevelopmentSessionRedirectMock.mockReturnValue(false);
});
it("injects authorization and tenant headers into requests", async () => {
getUserMock.mockResolvedValueOnce({ access_token: "live-token" });
window.localStorage.setItem("dev_tenant_id", "tenant-1");
const { default: apiClient } = await import("./apiClient");
const requestHandler =
apiClient.interceptors.request.handlers[0]?.fulfilled;
const result = await requestHandler?.({ headers: {} });
expect(result.headers.Authorization).toBe("Bearer live-token");
expect(result.headers["X-Tenant-ID"]).toBe("tenant-1");
});
it("rejects non-auth response errors without redirecting", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { default: apiClient } = await import("./apiClient");
const responseHandler =
apiClient.interceptors.response.handlers[0]?.rejected;
const error = { response: { status: 500, data: { error: "boom" } } };
await expect(responseHandler?.(error)).rejects.toBe(error);
expect(warnSpy).not.toHaveBeenCalled();
expect(removeUserMock).not.toHaveBeenCalled();
});
it("warns and rejects auth failures in test mode", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { default: apiClient } = await import("./apiClient");
const responseHandler =
apiClient.interceptors.response.handlers[0]?.rejected;
const error = {
response: {
status: 403,
data: { error: "authentication required" },
},
};
await expect(responseHandler?.(error)).rejects.toBe(error);
expect(warnSpy).toHaveBeenCalled();
expect(removeUserMock).not.toHaveBeenCalled();
});
});
+88
View File
@@ -0,0 +1,88 @@
import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
import { userManager } from "./auth";
import { findPersistedOidcUser } from "./oidcStorage";
let isRedirectingToLogin = false;
const apiClient = axios.create({
baseURL:
import.meta.env.VITE_DEV_API_BASE ??
import.meta.env.VITE_ADMIN_API_BASE ??
"/api/v1",
});
const isDevelopmentMode = import.meta.env.MODE === "development";
const isTestMode =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true || navigator.webdriver === true;
apiClient.interceptors.request.use(async (config) => {
// OIDC Access Token 주입
const user = (await userManager.getUser()) ?? findPersistedOidcUser();
if (user?.access_token) {
config.headers.Authorization = `Bearer ${user.access_token}`;
}
// TODO: 테넌트 선택 값을 보관하고 헤더로 전달한다.
const tenantId = window.localStorage.getItem("dev_tenant_id"); // 키 이름을 좀 더 명확하게 변경 고려
if (tenantId) {
config.headers["X-Tenant-ID"] = tenantId;
}
return config;
});
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const status = error.response?.status;
const message =
error.response?.data?.error?.toString().toLowerCase() ??
error.response?.data?.message?.toString().toLowerCase() ??
"";
const shouldRedirectToLogin =
status === 401 ||
(status === 403 &&
(message.includes("authentication required") ||
message.includes("invalid session") ||
message.includes("token is not active")));
if (!shouldRedirectToLogin) {
return Promise.reject(error);
}
if (isDevelopmentMode || isTestMode) {
console.warn(
"[apiClient] Auth failure detected, but local redirects are disabled.",
);
return Promise.reject(error);
}
if (
shouldSuppressDevelopmentSessionRedirect({
appMode: import.meta.env.MODE,
})
) {
console.warn(
"[apiClient] Auth failure detected, but development session redirects are disabled.",
);
return Promise.reject(error);
}
if (
shouldStartLoginRedirect({
pathname: window.location.pathname,
isRedirecting: isRedirectingToLogin,
})
) {
isRedirectingToLogin = true;
await userManager.removeUser();
window.location.href = "/login";
}
return Promise.reject(error);
},
);
export default apiClient;
+23
View File
@@ -0,0 +1,23 @@
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
import type { AuthProviderProps } from "react-oidc-context";
import {
buildCommonOidcRuntimeConfig,
buildCommonUserManagerSettings,
} from "../../../common/core/auth";
import { resolveDevFrontPublicOrigin } from "./authConfig";
const devFrontPublicOrigin = resolveDevFrontPublicOrigin(
import.meta.env.VITE_DEVFRONT_PUBLIC_URL,
window.location.origin,
);
export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
authority: import.meta.env.VITE_OIDC_AUTHORITY || "https://sso.hmac.kr/oidc",
clientId: import.meta.env.VITE_OIDC_CLIENT_ID || "devfront",
origin: devFrontPublicOrigin,
userStore: new WebStorageStateStore({ store: window.localStorage }),
});
export const userManager = new UserManager(
buildCommonUserManagerSettings(oidcConfig),
);
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import {
buildDevFrontAuthRedirectUris,
canStartBrowserPkceLogin,
DEVFRONT_AUTH_CALLBACK_PATH,
resolveDevFrontPublicOrigin,
} from "./authConfig";
describe("devfront auth config", () => {
it("builds callback URLs from the public origin", () => {
expect(buildDevFrontAuthRedirectUris("https://sdev.hmac.kr")).toEqual({
redirectUri: "https://sdev.hmac.kr/auth/callback",
postLogoutRedirectUri: "https://sdev.hmac.kr",
popupRedirectUri: "https://sdev.hmac.kr/auth/callback",
});
});
it("uses the browser origin when the configured origin is empty or invalid", () => {
expect(resolveDevFrontPublicOrigin("", "http://localhost:5173")).toBe(
"http://localhost:5173",
);
expect(
resolveDevFrontPublicOrigin("not a url", "http://localhost:5173"),
).toBe("http://localhost:5173");
});
it("keeps the callback path aligned with the registered redirect path", () => {
expect(DEVFRONT_AUTH_CALLBACK_PATH).toBe("/auth/callback");
});
it("blocks browser PKCE login in an insecure context", () => {
expect(
canStartBrowserPkceLogin({
isSecureContext: false,
origin: "http://localhost:5174",
cryptoSubtleAvailable: false,
}),
).toBe(false);
expect(
canStartBrowserPkceLogin({
isSecureContext: false,
origin: "http://172.16.9.189:5174",
cryptoSubtleAvailable: false,
}),
).toBe(false);
expect(
canStartBrowserPkceLogin({
isSecureContext: true,
origin: "http://172.16.9.189:5174",
cryptoSubtleAvailable: true,
}),
).toBe(true);
});
it("allows host.docker.internal when WebCrypto is enabled by the browser", () => {
expect(
canStartBrowserPkceLogin({
isSecureContext: false,
origin: "http://host.docker.internal:5000",
cryptoSubtleAvailable: true,
}),
).toBe(true);
expect(
canStartBrowserPkceLogin({
isSecureContext: false,
origin: "http://host.docker.internal:5000",
cryptoSubtleAvailable: false,
}),
).toBe(false);
});
it("allows private network IPv4 origins when WebCrypto is enabled by the browser", () => {
for (const origin of [
"http://10.0.0.10:5000",
"http://172.16.9.189:5000",
"http://172.31.255.255:5000",
"http://192.168.0.20:5000",
]) {
expect(
canStartBrowserPkceLogin({
isSecureContext: false,
origin,
cryptoSubtleAvailable: true,
}),
).toBe(true);
}
for (const origin of [
"http://172.15.255.255:5000",
"http://172.32.0.1:5000",
"http://8.8.8.8:5000",
]) {
expect(
canStartBrowserPkceLogin({
isSecureContext: false,
origin,
cryptoSubtleAvailable: true,
}),
).toBe(false);
}
});
});
+88
View File
@@ -0,0 +1,88 @@
export interface DevFrontAuthRedirectUris {
redirectUri: string;
postLogoutRedirectUri: string;
popupRedirectUri: string;
}
export const DEVFRONT_AUTH_CALLBACK_PATH = "/auth/callback";
export function resolveDevFrontPublicOrigin(
configuredOrigin: string | undefined,
browserOrigin: string,
) {
const trimmed = configuredOrigin?.trim();
if (!trimmed) {
return browserOrigin;
}
try {
return new URL(trimmed).origin;
} catch {
return browserOrigin;
}
}
export function buildDevFrontAuthRedirectUris(
publicOrigin: string,
): DevFrontAuthRedirectUris {
return {
redirectUri: `${publicOrigin}${DEVFRONT_AUTH_CALLBACK_PATH}`,
postLogoutRedirectUri: publicOrigin,
popupRedirectUri: `${publicOrigin}${DEVFRONT_AUTH_CALLBACK_PATH}`,
};
}
export type BrowserPkceLoginCheck = {
isSecureContext?: boolean;
origin?: string;
cryptoSubtleAvailable?: boolean;
};
const devTrustedPkceHosts = new Set([
"localhost",
"127.0.0.1",
"::1",
"host.docker.internal",
]);
function isPrivateIPv4(hostname: string) {
const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
if (
parts.length !== 4 ||
parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)
) {
return false;
}
const [first, second] = parts;
return (
first === 10 ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && second === 168)
);
}
function isDevTrustedPkceOrigin(origin: string) {
try {
const hostname = new URL(origin).hostname;
return devTrustedPkceHosts.has(hostname) || isPrivateIPv4(hostname);
} catch {
return false;
}
}
export function canStartBrowserPkceLogin({
isSecureContext = window.isSecureContext,
origin = window.location.origin,
cryptoSubtleAvailable = Boolean(window.crypto?.subtle),
}: BrowserPkceLoginCheck = {}) {
if (!cryptoSubtleAvailable) {
return false;
}
if (isSecureContext) {
return true;
}
return isDevTrustedPkceOrigin(origin);
}
+261
View File
@@ -0,0 +1,261 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiClient = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
vi.mock("./apiClient", () => ({
default: apiClient,
}));
describe("devApi", () => {
beforeEach(() => {
apiClient.get.mockReset();
apiClient.post.mockReset();
apiClient.put.mockReset();
apiClient.patch.mockReset();
apiClient.delete.mockReset();
});
it("fetches list and detail resources with expected query parameters", async () => {
const {
fetchClients,
fetchDevStats,
fetchDevRPUsageDaily,
fetchTenants,
fetchClient,
fetchClientRelations,
fetchRPUserMetadata,
fetchDevUsers,
fetchConsents,
fetchDevAuditLogs,
fetchMyTenants,
fetchDeveloperRequestStatus,
fetchDeveloperRequests,
listIdpConfigsForClient,
} = await import("./devApi");
apiClient.get.mockResolvedValue({ data: { ok: true } });
await fetchClients();
await fetchDevStats();
await fetchDevRPUsageDaily({ days: 30, period: "week" });
await fetchTenants(25, 50, "tenant-parent");
await fetchClient("client-a");
await fetchClientRelations("client-a");
await fetchRPUserMetadata("client-a", "user-a");
await fetchDevUsers("admin", 5, "client-a");
await fetchConsents("user-a", "client-a", "active");
await fetchDevAuditLogs(10, "cursor-a", {
action: "client.update",
client_id: "client-a",
status: "success",
tenant_id: "tenant-a",
});
await fetchMyTenants();
await fetchDeveloperRequestStatus("tenant-a");
await fetchDeveloperRequests("pending");
await listIdpConfigsForClient("client-a");
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients");
expect(apiClient.get).toHaveBeenCalledWith("/dev/stats");
expect(apiClient.get).toHaveBeenCalledWith("/dev/rp-usage/daily", {
params: { days: 30, period: "week" },
});
expect(apiClient.get).toHaveBeenCalledWith("/tenants", {
params: { limit: 25, offset: 50, parentId: "tenant-parent" },
});
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a");
expect(apiClient.get).toHaveBeenCalledWith(
"/dev/clients/client-a/relations",
);
expect(apiClient.get).toHaveBeenCalledWith(
"/dev/clients/client-a/users/user-a/metadata",
);
expect(apiClient.get).toHaveBeenCalledWith("/dev/users", {
params: { search: "admin", limit: 5, clientId: "client-a" },
});
expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
params: { subject: "user-a", client_id: "client-a", status: "active" },
});
expect(apiClient.get).toHaveBeenCalledWith("/dev/audit-logs", {
params: {
limit: 10,
cursor: "cursor-a",
action: "client.update",
client_id: "client-a",
status: "success",
tenant_id: "tenant-a",
},
});
expect(apiClient.get).toHaveBeenCalledWith("/dev/my-tenants");
expect(apiClient.get).toHaveBeenCalledWith(
"/dev/developer-request/status",
{
params: { tenantId: "tenant-a" },
},
);
expect(apiClient.get).toHaveBeenCalledWith("/dev/developer-request/list", {
params: { status: "pending" },
});
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a/idps");
});
it("omits optional consent filters when they are empty or all", async () => {
const { fetchConsents, revokeConsent } = await import("./devApi");
apiClient.get.mockResolvedValue({ data: { items: [] } });
apiClient.delete.mockResolvedValue({ data: {} });
await fetchConsents("user-a", undefined, "all");
await revokeConsent("user-a");
expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
params: { subject: "user-a" },
});
expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
params: { subject: "user-a" },
});
});
it("sends mutation requests to the documented dev endpoints", async () => {
const {
addClientRelation,
removeClientRelation,
updateRPUserMetadata,
updateClientStatus,
createClient,
updateClient,
rotateClientSecret,
refreshHeadlessJwksCache,
revokeHeadlessJwksCache,
deleteClient,
revokeConsent,
createIdpConfigForClient,
updateIdpConfig,
deleteIdpConfig,
requestDeveloperAccess,
approveDeveloperRequest,
rejectDeveloperRequest,
cancelDeveloperRequestApproval,
} = await import("./devApi");
apiClient.post.mockResolvedValue({ data: { ok: true } });
apiClient.put.mockResolvedValue({ data: { ok: true } });
apiClient.patch.mockResolvedValue({ data: { ok: true } });
apiClient.delete.mockResolvedValue({ data: {} });
await addClientRelation("client-a", {
relation: "admins",
userId: "user-a",
});
await removeClientRelation("client-a", "admins", "User:user-a");
await updateRPUserMetadata("client-a", "user-a", { approvalLevel: "A" });
await updateClientStatus("client-a", "inactive");
await createClient({ id: "client-a", name: "Console App" });
await updateClient("client-a", { name: "Console App Updated" });
await rotateClientSecret("client-a");
await refreshHeadlessJwksCache("client-a");
await revokeHeadlessJwksCache("client-a");
await deleteClient("client-a");
await revokeConsent("user-a", "client-a");
await createIdpConfigForClient({
client_id: "client-a",
provider_type: "oidc",
display_name: "OIDC Provider",
status: "active",
});
await updateIdpConfig("client-a", "idp-a", { status: "inactive" });
await deleteIdpConfig("client-a", "idp-a");
await requestDeveloperAccess({
name: "Dev User",
organization: "Hanmac",
reason: "Need RP access",
tenantId: "tenant-a",
});
await approveDeveloperRequest(1, "approved");
await rejectDeveloperRequest(2, "rejected");
await cancelDeveloperRequestApproval(3, "cancelled");
expect(apiClient.post).toHaveBeenCalledWith(
"/dev/clients/client-a/relations",
{ relation: "admins", userId: "user-a" },
);
expect(apiClient.delete).toHaveBeenCalledWith(
"/dev/clients/client-a/relations",
{
params: { relation: "admins", subject: "User:user-a" },
},
);
expect(apiClient.put).toHaveBeenCalledWith(
"/dev/clients/client-a/users/user-a/metadata",
{ metadata: { approvalLevel: "A" } },
);
expect(apiClient.patch).toHaveBeenCalledWith(
"/dev/clients/client-a/status",
{
status: "inactive",
},
);
expect(apiClient.post).toHaveBeenCalledWith("/dev/clients", {
id: "client-a",
name: "Console App",
});
expect(apiClient.put).toHaveBeenCalledWith("/dev/clients/client-a", {
name: "Console App Updated",
});
expect(apiClient.post).toHaveBeenCalledWith(
"/dev/clients/client-a/secret/rotate",
);
expect(apiClient.post).toHaveBeenCalledWith(
"/dev/clients/client-a/headless-jwks/refresh",
);
expect(apiClient.delete).toHaveBeenCalledWith(
"/dev/clients/client-a/headless-jwks/cache",
);
expect(apiClient.delete).toHaveBeenCalledWith("/dev/clients/client-a");
expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
params: { subject: "user-a", client_id: "client-a" },
});
expect(apiClient.post).toHaveBeenCalledWith("/dev/clients/client-a/idps", {
client_id: "client-a",
provider_type: "oidc",
display_name: "OIDC Provider",
status: "active",
});
expect(apiClient.put).toHaveBeenCalledWith(
"/dev/clients/client-a/idps/idp-a",
{
status: "inactive",
},
);
expect(apiClient.delete).toHaveBeenCalledWith(
"/dev/clients/client-a/idps/idp-a",
);
expect(apiClient.post).toHaveBeenCalledWith("/dev/developer-request", {
name: "Dev User",
organization: "Hanmac",
reason: "Need RP access",
tenantId: "tenant-a",
});
expect(apiClient.post).toHaveBeenCalledWith(
"/dev/developer-request/1/approve",
{
adminNotes: "approved",
},
);
expect(apiClient.post).toHaveBeenCalledWith(
"/dev/developer-request/2/reject",
{
adminNotes: "rejected",
},
);
expect(apiClient.post).toHaveBeenCalledWith(
"/dev/developer-request/3/cancel-approval",
{
adminNotes: "cancelled",
},
);
});
});
+597
View File
@@ -0,0 +1,597 @@
import apiClient from "./apiClient";
export type ClientStatus = "active" | "inactive";
export type ClientType = "private" | "pkce";
export type ClientSummary = {
id: string;
name: string;
type: ClientType;
status: ClientStatus;
createdAt?: string;
clientSecret?: string;
tokenEndpointAuthMethod?: string;
jwksUri?: string;
backchannelLogoutUri?: string;
backchannelLogoutSessionRequired?: boolean;
redirectUris: string[];
scopes: string[];
metadata?: Record<string, unknown>;
};
export type ClientListResponse = {
items: ClientSummary[];
limit: number;
offset: number;
};
export type TenantSummary = {
id: string;
type: string;
parentId?: string | null;
name: string;
slug: string;
description: string;
status: string;
domains?: string[];
config?: Record<string, unknown>;
memberCount: number;
createdAt: string;
updatedAt: string;
};
export type TenantListResponse = {
items: TenantSummary[];
limit: number;
offset: number;
total: number;
};
export type DevStats = {
total_clients: number;
active_sessions: number;
auth_failures_24h: number;
};
export type RPUsageDailyMetric = {
date: string;
tenantId: string;
tenantType: string;
tenantName?: string;
clientId: string;
clientName: string;
loginRequests: number;
otherRequests: number;
uniqueSubjects: number;
};
export type RPUsagePeriod = "day" | "week" | "month";
export type RPUsageDailyResponse = {
items: RPUsageDailyMetric[];
days: number;
period: RPUsagePeriod;
tenantId?: string;
};
export type DevAuditLog = {
event_id: string;
timestamp: string;
user_id: string;
event_type: string;
status: string;
ip_address: string;
user_agent: string;
device_id?: string;
details?: string;
};
export type DevAuditLogListResponse = {
items: DevAuditLog[];
limit: number;
cursor?: string;
next_cursor?: string;
};
export type ClientEndpoints = {
discovery: string;
issuer: string;
authorization: string;
token: string;
userinfo: string;
};
export type ClientDetailResponse = {
client: ClientSummary & {
clientSecret?: string;
metadata?: Record<string, unknown>;
};
endpoints: ClientEndpoints;
headlessJwksCache?: {
clientId: string;
jwksUri: string;
cachedAt: string;
expiresAt: string;
lastCheckedAt?: string;
lastSuccessfulVerificationAt?: string;
lastRefreshStatus?: "success" | "failure" | "pending";
lastError?: string;
consecutiveFailures?: number;
cachedKids?: string[];
etag?: string;
lastModified?: string;
parsedKeys?: Array<{
kid?: string;
kty?: string;
use?: string;
alg?: string;
n?: string;
}>;
};
};
export type ClientUpsertRequest = {
id?: string;
name?: string;
type?: ClientType;
status?: ClientStatus;
redirectUris?: string[];
scopes?: string[];
grantTypes?: string[];
responseTypes?: string[];
tokenEndpointAuthMethod?: string;
jwksUri?: string;
backchannelLogoutUri?: string;
backchannelLogoutSessionRequired?: boolean;
metadata?: Record<string, unknown>;
};
export type ClientRelation = {
relation: string;
subject: string;
subjectType: string;
subjectId: string;
userName?: string;
userEmail?: string;
userLoginId?: string;
};
export type ClientRelationListResponse = {
items: ClientRelation[];
};
export type ClientRelationUpsertRequest = {
relation: string;
subject?: string;
userId?: string;
};
export type DevAssignableUser = {
id: string;
name: string;
email: string;
loginId?: string;
};
export type DevAssignableUserListResponse = {
items: DevAssignableUser[];
};
export type DevUserSummary = {
id: string;
email: string;
loginId?: string;
name: string;
phone?: string;
role: string;
status: string;
tenantSlug?: string;
companyCode?: string;
tenant?: TenantSummary;
joinedTenants?: TenantSummary[];
metadata?: Record<string, unknown>;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
createdAt: string;
updatedAt: string;
};
export type ConsentSummary = {
subject: string;
userName?: string;
clientId: string;
clientName?: string;
grantedScopes: string[];
authenticatedAt?: string;
createdAt: string;
deletedAt?: string;
status: "active" | "revoked";
tenantId?: string;
tenantName?: string;
rpMetadata?: Record<string, unknown>;
};
export type ConsentListResponse = {
items: ConsentSummary[];
};
export type RPUserMetadataResponse = {
clientId: string;
userId: string;
metadata: Record<string, unknown>;
};
// --- Federation / IdP Config Types ---
export type ProviderType = "oidc" | "saml";
export type IdpConfig = {
id: string;
client_id: string; // Changed from tenant_id
provider_type: ProviderType;
display_name: string;
status: "active" | "inactive";
issuer_url?: string;
// OIDC specific fields
oidc_client_id?: string;
oidc_client_secret?: string;
scopes?: string;
// SAML specific fields
metadata_url?: string;
metadata_xml?: string;
entity_id?: string;
acs_url?: string;
createdAt: string;
updatedAt: string;
};
export type IdpConfigCreateRequest = Omit<
IdpConfig,
"id" | "createdAt" | "updatedAt"
>;
export type IdpConfigUpdateRequest = Partial<IdpConfigCreateRequest>;
// --- End Federation Types ---
export async function fetchClients() {
const { data } = await apiClient.get<ClientListResponse>("/dev/clients");
return data;
}
export async function fetchDevStats() {
const { data } = await apiClient.get<DevStats>("/dev/stats");
return data;
}
export async function fetchDevRPUsageDaily({
days = 14,
period = "day",
}: {
days?: number;
period?: RPUsagePeriod;
} = {}) {
const { data } = await apiClient.get<RPUsageDailyResponse>(
"/dev/rp-usage/daily",
{
params: { days, period },
},
);
return data;
}
export async function fetchTenants(
limit = 1000,
offset = 0,
parentId?: string,
) {
const { data } = await apiClient.get<TenantListResponse>("/tenants", {
params: { limit, offset, parentId },
});
return data;
}
export async function fetchClient(clientId: string) {
const { data } = await apiClient.get<ClientDetailResponse>(
`/dev/clients/${clientId}`,
);
return data;
}
export async function fetchClientRelations(clientId: string) {
const { data } = await apiClient.get<ClientRelationListResponse>(
`/dev/clients/${clientId}/relations`,
);
return data;
}
export async function fetchRPUserMetadata(clientId: string, userId: string) {
const { data } = await apiClient.get<RPUserMetadataResponse>(
`/dev/clients/${clientId}/users/${userId}/metadata`,
);
return data;
}
export async function updateRPUserMetadata(
clientId: string,
userId: string,
metadata: Record<string, unknown>,
) {
const { data } = await apiClient.put<RPUserMetadataResponse>(
`/dev/clients/${clientId}/users/${userId}/metadata`,
{ metadata },
);
return data;
}
export async function fetchDevUsers(
search: string,
limit = 10,
clientId?: string,
) {
const { data } = await apiClient.get<DevAssignableUserListResponse>(
"/dev/users",
{
params: { search, limit, clientId },
},
);
return data;
}
export async function fetchDevUser(userId: string) {
const { data } = await apiClient.get<DevUserSummary>(
`/admin/users/${userId}`,
);
return data;
}
export async function addClientRelation(
clientId: string,
payload: ClientRelationUpsertRequest,
) {
const { data } = await apiClient.post<ClientRelation>(
`/dev/clients/${clientId}/relations`,
payload,
);
return data;
}
export async function removeClientRelation(
clientId: string,
relation: string,
subject: string,
) {
await apiClient.delete(`/dev/clients/${clientId}/relations`, {
params: { relation, subject },
});
}
export async function updateClientStatus(
clientId: string,
status: ClientStatus,
) {
const { data } = await apiClient.patch<ClientDetailResponse>(
`/dev/clients/${clientId}/status`,
{ status },
);
return data;
}
export async function createClient(payload: ClientUpsertRequest) {
const { data } = await apiClient.post<ClientDetailResponse>(
"/dev/clients",
payload,
);
return data;
}
export async function updateClient(
clientId: string,
payload: ClientUpsertRequest,
) {
const { data } = await apiClient.put<ClientDetailResponse>(
`/dev/clients/${clientId}`,
payload,
);
return data;
}
export async function rotateClientSecret(clientId: string) {
const { data } = await apiClient.post<ClientDetailResponse>(
`/dev/clients/${clientId}/secret/rotate`,
);
return data;
}
export async function refreshHeadlessJwksCache(clientId: string) {
const { data } = await apiClient.post<ClientDetailResponse>(
`/dev/clients/${clientId}/headless-jwks/refresh`,
);
return data;
}
export async function revokeHeadlessJwksCache(clientId: string) {
await apiClient.delete(`/dev/clients/${clientId}/headless-jwks/cache`);
}
export async function deleteClient(clientId: string) {
await apiClient.delete(`/dev/clients/${clientId}`);
}
export async function fetchConsents(
subject: string,
clientId?: string,
status?: string,
) {
const params: Record<string, string> = { subject };
if (clientId) {
params.client_id = clientId;
}
if (status && status !== "all") {
params.status = status;
}
const { data } = await apiClient.get<ConsentListResponse>("/dev/consents", {
params,
});
return data;
}
export async function revokeConsent(subject: string, clientId?: string) {
const params: Record<string, string> = { subject };
if (clientId) {
params.client_id = clientId;
}
await apiClient.delete("/dev/consents", { params });
}
// --- Federation / IdP Config API Calls ---
export async function listIdpConfigsForClient(clientId: string) {
const { data } = await apiClient.get<IdpConfig[]>(
`/dev/clients/${clientId}/idps`,
);
return data;
}
export async function createIdpConfigForClient(
payload: IdpConfigCreateRequest,
) {
const { data } = await apiClient.post<IdpConfig>(
`/dev/clients/${payload.client_id}/idps`,
payload,
);
return data;
}
export async function updateIdpConfig(
clientId: string,
idpId: string,
payload: IdpConfigUpdateRequest,
) {
const { data } = await apiClient.put<IdpConfig>(
`/dev/clients/${clientId}/idps/${idpId}`,
payload,
);
return data;
}
export async function deleteIdpConfig(clientId: string, idpId: string) {
await apiClient.delete(`/dev/clients/${clientId}/idps/${idpId}`);
}
export async function fetchDevAuditLogs(
limit = 50,
cursor?: string,
filters?: {
action?: string;
client_id?: string;
status?: string;
tenant_id?: string;
},
) {
const { data } = await apiClient.get<DevAuditLogListResponse>(
"/dev/audit-logs",
{
params: {
limit,
cursor,
action: filters?.action,
client_id: filters?.client_id,
status: filters?.status,
tenant_id: filters?.tenant_id,
},
},
);
return data;
}
export type MyTenantSummary = Pick<TenantSummary, "id" | "name" | "slug"> &
Partial<TenantSummary>;
export async function fetchMyTenants() {
const { data } = await apiClient.get<MyTenantSummary[]>("/dev/my-tenants");
return data;
}
// --- Developer Request API ---
export type DeveloperRequestStatus =
| "pending"
| "approved"
| "rejected"
| "cancelled"
| "none";
export type DeveloperRequest = {
id: number;
userId: string;
tenantId: string;
name: string;
organization: string;
email?: string;
phone?: string;
role?: string;
reason: string;
status: DeveloperRequestStatus;
adminNotes?: string;
createdAt: string;
updatedAt: string;
};
export async function fetchDeveloperRequestStatus(tenantId?: string) {
const { data } = await apiClient.get<DeveloperRequest | { status: "none" }>(
"/dev/developer-request/status",
{
params: { tenantId },
},
);
return data;
}
export async function requestDeveloperAccess(payload: {
name: string;
organization: string;
reason: string;
tenantId: string;
}) {
const { data } = await apiClient.post<{ status: string }>(
"/dev/developer-request",
payload,
);
return data;
}
export async function fetchDeveloperRequests(status?: string) {
const { data } = await apiClient.get<DeveloperRequest[]>(
"/dev/developer-request/list",
{
params: { status },
},
);
return data;
}
export async function approveDeveloperRequest(id: number, adminNotes: string) {
const { data } = await apiClient.post<{ status: string }>(
`/dev/developer-request/${id}/approve`,
{ adminNotes },
);
return data;
}
export async function rejectDeveloperRequest(id: number, adminNotes: string) {
const { data } = await apiClient.post<{ status: string }>(
`/dev/developer-request/${id}/reject`,
{ adminNotes },
);
return data;
}
export async function cancelDeveloperRequestApproval(
id: number,
adminNotes: string,
) {
const { data } = await apiClient.post<{ status: string }>(
`/dev/developer-request/${id}/cancel-approval`,
{ adminNotes },
);
return data;
}
+16
View File
@@ -0,0 +1,16 @@
import { createTomlTranslator } from "../../../common/core/i18n";
// eslint-disable-next-line import/no-unresolved
import commonEnRaw from "../../../common/locales/en.toml?raw";
// eslint-disable-next-line import/no-unresolved
import commonKoRaw from "../../../common/locales/ko.toml?raw";
// eslint-disable-next-line import/no-unresolved
import enRaw from "../locales/en.toml?raw";
// Vite ?raw import는 런타임 상수로 번들됩니다.
// eslint-disable-next-line import/no-unresolved
import koRaw from "../locales/ko.toml?raw";
export const t = createTomlTranslator({
ko: [commonKoRaw, koRaw],
en: [commonEnRaw, enRaw],
});
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { findPersistedOidcUser } from "./oidcStorage";
class MemoryStorage implements Storage {
private data = new Map<string, string>();
get length() {
return this.data.size;
}
clear(): void {
this.data.clear();
}
getItem(key: string): string | null {
return this.data.get(key) ?? null;
}
key(index: number): string | null {
return Array.from(this.data.keys())[index] ?? null;
}
removeItem(key: string): void {
this.data.delete(key);
}
setItem(key: string, value: string): void {
this.data.set(key, value);
}
}
describe("findPersistedOidcUser", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-01T00:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("returns the first valid, unexpired devfront user entry", () => {
const storage = new MemoryStorage();
storage.setItem("oidc.user:issuer:other-client", JSON.stringify({}));
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
storage.setItem(
"oidc.user:issuer:devfront",
JSON.stringify({
access_token: "token-1",
expires_at: expiresAt,
profile: { name: "Dev Admin" },
}),
);
expect(findPersistedOidcUser(storage)).toEqual({
access_token: "token-1",
expires_at: expiresAt,
profile: { name: "Dev Admin" },
});
});
it("skips malformed, empty, and expired entries", () => {
const storage = new MemoryStorage();
storage.setItem("random", "value");
storage.setItem("oidc.user:issuer:devfront", "not-json");
storage.setItem(
"oidc.user:issuer:devfront",
JSON.stringify({
access_token: "expired",
expires_at: Math.floor(Date.now() / 1000) - 1,
}),
);
expect(findPersistedOidcUser(storage)).toBeNull();
});
});
+42
View File
@@ -0,0 +1,42 @@
export type PersistedOidcUser = {
access_token?: string;
expires_at?: number;
profile?: Record<string, unknown>;
};
const OIDC_USER_KEY_PREFIX = "oidc.user:";
const OIDC_CLIENT_ID = "devfront";
export function findPersistedOidcUser(
storage: Storage = window.localStorage,
): PersistedOidcUser | null {
for (let index = 0; index < storage.length; index += 1) {
const key = storage.key(index);
if (
key === null ||
!key.startsWith(OIDC_USER_KEY_PREFIX) ||
!key.endsWith(`:${OIDC_CLIENT_ID}`)
) {
continue;
}
const rawValue = storage.getItem(key);
if (!rawValue) {
continue;
}
try {
const parsed = JSON.parse(rawValue) as PersistedOidcUser;
if (
typeof parsed.expires_at === "number" &&
parsed.expires_at * 1000 > Date.now()
) {
return parsed;
}
} catch {
// Ignore malformed storage entries and keep scanning.
}
}
return null;
}
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { normalizeRole, resolveProfileRole } from "./role";
describe("normalizeRole", () => {
it("normalizes known role aliases", () => {
expect(normalizeRole("tenant_member")).toBe("user");
expect(normalizeRole("admin")).toBe("user");
expect(normalizeRole("superadmin")).toBe("super_admin");
expect(normalizeRole("tenantadmin")).toBe("tenant_admin");
expect(normalizeRole("rpadmin")).toBe("rp_admin");
});
it("returns 'user' for unknown string values and empty string for non-strings", () => {
expect(normalizeRole(" custom_role ")).toBe("user");
expect(normalizeRole(123)).toBe("");
});
});
describe("resolveProfileRole", () => {
it("prefers the first non-empty normalized role candidate", () => {
expect(
resolveProfileRole({
role: " ",
grade: "tenant_member",
"custom:role": "admin",
}),
).toBe("user");
});
it("returns an empty string when no role is present", () => {
expect(resolveProfileRole(undefined)).toBe("");
});
});
+38
View File
@@ -0,0 +1,38 @@
export function normalizeRole(rawRole: unknown): string {
if (typeof rawRole !== "string") return "";
const role = rawRole.trim().toLowerCase();
switch (role) {
case "super_admin":
case "superadmin":
case "super-admin":
return "super_admin";
case "rp_admin":
case "rpadmin":
case "rp-admin":
return "rp_admin";
case "tenant_admin":
case "tenantadmin":
case "tenant-admin":
return "tenant_admin";
default:
return "user";
}
}
export function resolveProfileRole(
profile: Record<string, unknown> | undefined,
) {
if (!profile) return "";
const candidates = [
profile.role,
profile.grade,
profile["custom:role"],
profile["custom:grade"],
];
for (const candidate of candidates) {
const normalized = normalizeRole(candidate);
if (normalized) return normalized;
}
return "";
}
@@ -0,0 +1,6 @@
export {
DEFAULT_SESSION_RENEW_THRESHOLD_MS as SESSION_RENEW_THRESHOLD_MS,
DEFAULT_SESSION_RENEW_THROTTLE_MS as SESSION_RENEW_THROTTLE_MS,
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../../common/core/session";
+8
View File
@@ -0,0 +1,8 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { mergeClassNames } from "../../../common/core/utils";
export function cn(...inputs: ClassValue[]) {
return mergeClassNames(twMerge, [clsx(inputs)]);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { AuthProvider } from "react-oidc-context";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/routes";
import { Toaster } from "./components/ui/toaster";
import "./index.css";
import { oidcConfig, userManager } from "./lib/auth";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("Root element not found");
}
createRoot(rootElement).render(
<StrictMode>
<AuthProvider {...oidcConfig} userManager={userManager}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster />
</QueryClientProvider>
</AuthProvider>
</StrictMode>,
);