첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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
@@ -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>,
|
||||
);
|
||||
Reference in new issue
Block a user