1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/rbac-simplification-and-remove-dev-switcher

This commit is contained in:
2026-06-02 18:36:44 +09:00
88 changed files with 7453 additions and 2180 deletions

View File

@@ -13,7 +13,7 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
const skipWebServer =
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5176";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4174";
/**
* Read environment variables from file.
@@ -74,7 +74,7 @@ export default defineConfig({
? undefined
: {
command:
"VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 5176",
"VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 4174",
url: baseURL,
reuseExistingServer: false,
},

View File

@@ -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");
});
});

View File

@@ -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();
});
});

View File

@@ -0,0 +1,212 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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");
});
});

View File

@@ -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");
});
});

View File

@@ -0,0 +1,280 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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");
});
});

View File

@@ -1,6 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Filter, Info, Plus, Search, ShieldHalf, X } from "lucide-react";
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";
@@ -44,12 +44,8 @@ import {
import { Textarea } from "../../components/ui/textarea";
import {
type ClientSummary,
type DevAuditLog,
fetchDevUser,
fetchClients,
fetchDevAuditLogs,
fetchDeveloperRequestStatus,
fetchDevStats,
fetchMyTenants,
requestDeveloperAccess,
} from "../../lib/devApi";
@@ -59,196 +55,9 @@ import { cn } from "../../lib/utils";
import { fetchMe } from "../auth/authApi";
import { resolveClientCreateAccess } from "./clientCreateAccess";
import { ClientLogo } from "./components/ClientLogo";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditActor,
} from "../../../../common/core/audit";
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
type RecentClientChange = {
eventId: string;
clientId: string;
clientName: string;
actorId: string;
actorName: string;
action: string;
actionLabel: string;
timestamp: string;
detailLabels: Array<{ label: string; value: string }>;
};
const recentClientChangesInitialCount = 5;
const recentClientChangesBatchSize = 5;
const recentClientActions = new Set([
"CREATE_CLIENT",
"UPDATE_CLIENT",
"UPDATE_CLIENT_STATUS",
"ROTATE_SECRET",
"ADD_RELATION",
"REMOVE_RELATION",
"DELETE_CLIENT",
]);
const recentChangeGuideItems = [
{
titleKey: "ui.dev.clients.recent_changes.guide.create",
titleFallback: "앱 생성",
descriptionKey: "msg.dev.clients.recent_changes.guide.create_desc",
descriptionFallback:
"새 애플리케이션이 등록되면 이름, 유형, 기본 상태와 함께 표시됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.settings",
titleFallback: "설정 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.settings_desc",
descriptionFallback:
"앱 이름, 스코프, 테넌트 접근 제한, 커스텀 클레임, 보안 설정, 로그아웃 URI, JWKS 변경이 포함됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.status",
titleFallback: "상태 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.status_desc",
descriptionFallback: "Active / Inactive 전환이 여기에 포함됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.relation",
titleFallback: "관계 변경",
descriptionKey: "msg.dev.clients.recent_changes.guide.relation_desc",
descriptionFallback: "관계 추가와 삭제가 함께 표시됩니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.secret",
titleFallback: "클라이언트 시크릿 재발급",
descriptionKey: "msg.dev.clients.recent_changes.guide.secret_desc",
descriptionFallback: "시크릿 재발급 이력이 보입니다.",
},
{
titleKey: "ui.dev.clients.recent_changes.guide.delete",
titleFallback: "앱 삭제",
descriptionKey: "msg.dev.clients.recent_changes.guide.delete_desc",
descriptionFallback: "앱 삭제도 최근 변경 이력에 포함됩니다.",
},
] as const;
const recentClientFieldLabels: Record<string, string> = {
name: "이름",
type: "유형",
status: "상태",
scopes: "스코프",
tenant_access_restricted: "테넌트 접근 제한",
allowed_tenants: "허용 테넌트",
id_token_claims: "커스텀 클레임",
token_endpoint_auth_method: "인증 방식",
jwks_uri: "JWKS URI",
backchannel_logout_uri: "Backchannel Logout URI",
backchannel_logout_session_required: "세션 필수",
headless_login_enabled: "헤드리스 로그인",
headless_token_endpoint_auth_method: "헤드리스 인증 방식",
headless_jwks_uri: "헤드리스 JWKS URI",
redirect_uri_count: "Redirect URI 수",
scope_count: "Scope 수",
relation: "관계",
subject: "대상",
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function getRecentClientActionLabel(action: string) {
switch (action) {
case "CREATE_CLIENT":
return "클라이언트 생성";
case "UPDATE_CLIENT":
return "설정 변경";
case "UPDATE_CLIENT_STATUS":
return "상태 변경";
case "ROTATE_SECRET":
return "클라이언트 시크릿 재발급";
case "ADD_RELATION":
return "관계 추가";
case "REMOVE_RELATION":
return "관계 삭제";
case "DELETE_CLIENT":
return "클라이언트 삭제";
default:
return action;
}
}
function buildRecentClientChangeDetails(
action: string,
details: ReturnType<typeof parseAuditDetails>,
) {
const before = isRecord(details.before) ? details.before : {};
const after = isRecord(details.after) ? details.after : {};
if (action === "ROTATE_SECRET") {
return [{ label: "클라이언트 시크릿", value: "재발급" }];
}
if (action === "ADD_RELATION" || action === "REMOVE_RELATION") {
const source = action === "ADD_RELATION" ? after : before;
return [
...(source.relation
? [{ label: "관계", value: formatAuditValue(source.relation) }]
: []),
...(source.subject
? [{ label: "대상", 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 = recentClientFieldLabels[key] ?? 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);
}
const clientListPreviewCount = 5;
function ClientsPage() {
const navigate = useNavigate();
@@ -269,12 +78,6 @@ function ClientsPage() {
enabled: hasAccessToken,
});
const { data: statsData, isLoading: isLoadingStats } = useQuery({
queryKey: ["dev-stats"],
queryFn: fetchDevStats,
enabled: hasAccessToken,
});
const { data: me, isLoading: isLoadingMe } = useQuery({
queryKey: ["userMe"],
queryFn: fetchMe,
@@ -314,10 +117,7 @@ function ClientsPage() {
const [statusFilter, setStatusFilter] = useState("all");
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const [isRequestModalOpen, setIsRequestModalOpen] = useState(false);
const [isRecentChangesGuideOpen, setIsRecentChangesGuideOpen] =
useState(false);
const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] =
useState(recentClientChangesInitialCount);
const [isClientListExpanded, setIsClientListExpanded] = useState(false);
const [sortConfig, setSortConfig] =
useState<SortConfig<ClientSortKey> | null>({
key: "createdAt",
@@ -325,61 +125,6 @@ function ClientsPage() {
});
const clients = data?.items || [];
const visibleClientIds = useMemo(
() => clients.map((client) => client.id).filter(Boolean),
[clients],
);
const { data: recentAuditData, isLoading: isLoadingRecentAudit } = useQuery({
queryKey: ["dev-audit-logs", "clients-recent", visibleClientIds.join("|")],
queryFn: async () => {
const globalLogs = await fetchDevAuditLogs(50);
if (globalLogs.items.length > 0 || profileRole === "super_admin") {
return globalLogs;
}
if (visibleClientIds.length === 0) {
return globalLogs;
}
const perClientLogs = await Promise.all(
visibleClientIds.slice(0, 20).map(async (clientId) => {
try {
const result = await fetchDevAuditLogs(5, undefined, {
client_id: clientId,
});
return result.items;
} catch {
return [];
}
}),
);
const merged = perClientLogs
.flat()
.filter(
(item, index, self) =>
self.findIndex(
(candidate) => candidate.event_id === item.event_id,
) === index,
)
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
new Date(left.timestamp).getTime(),
)
.slice(0, 50);
return {
items: merged,
limit: 50,
cursor: globalLogs.cursor,
next_cursor: globalLogs.next_cursor,
};
},
enabled: hasAccessToken && clients.length > 0 && Boolean(profileRole),
retry: false,
});
const clientSortResolvers = useMemo<
SortResolverMap<ClientSummary, ClientSortKey>
@@ -420,11 +165,16 @@ function ClientsPage() {
typeFilter,
]);
const totalClients = statsData?.total_clients ?? clients.length;
const activeSessions = statsData?.active_sessions ?? 0;
const authFailures = statsData?.auth_failures_24h ?? 0;
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,
);
@@ -438,145 +188,8 @@ function ClientsPage() {
"";
const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole);
type StatTone = "up" | "down" | "stable";
type StatItem = {
labelKey: string;
labelFallback: string;
value: string;
deltaKey: string;
deltaFallback: string;
tone: StatTone;
};
const stats: StatItem[] = [
{
labelKey: "ui.dev.clients.stats.total",
labelFallback: "Total Applications",
value: totalClients.toString(),
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
tone: "up" as const,
},
{
labelKey: "ui.dev.clients.stats.active_sessions",
labelFallback: "Active Sessions",
value: activeSessions.toString(),
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
tone: "up" as const,
},
{
labelKey: "ui.dev.clients.stats.auth_failures",
labelFallback: "Auth Failures (24h)",
value: authFailures.toString(),
deltaKey:
authFailures > 0
? "ui.dev.clients.stats.alert"
: "ui.dev.clients.stats.stable",
deltaFallback: authFailures > 0 ? "Check Logs" : "Stable",
tone: authFailures > 0 ? ("down" as const) : ("stable" as const),
},
];
const recentClientChanges = useMemo<RecentClientChange[]>(() => {
const clientNameById = new Map(
clients.map((client) => [client.id, client.name || client.id]),
);
return (recentAuditData?.items || [])
.map((item: DevAuditLog) => {
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, details),
actorName: "",
action,
actionLabel: getRecentClientActionLabel(action),
timestamp: item.timestamp,
detailLabels: buildRecentClientChangeDetails(action, details),
};
})
.filter((item): item is RecentClientChange => Boolean(item))
.sort(
(left, right) =>
new Date(right.timestamp).getTime() -
new Date(left.timestamp).getTime(),
);
}, [clients, recentAuditData?.items]);
const recentClientActorIds = useMemo(() => {
return Array.from(
new Set(
recentClientChanges
.map((item) => item.actorId.trim())
.filter((actorId) => actorId && actorId !== "-"),
),
);
}, [recentClientChanges]);
const { data: recentClientActors } = useQuery({
queryKey: ["recent-client-actors", recentClientActorIds],
queryFn: async () => {
const entries = await Promise.all(
recentClientActorIds.map(async (actorId) => {
try {
const user = await fetchDevUser(actorId);
return [actorId, user.name || actorId] as const;
} catch {
return [actorId, actorId] as const;
}
}),
);
return Object.fromEntries(entries);
},
enabled: recentClientActorIds.length > 0,
});
const recentClientChangesWithActors = useMemo(() => {
return recentClientChanges.map((item) => ({
...item,
actorName: recentClientActors?.[item.actorId] || item.actorId,
}));
}, [recentClientActors, recentClientChanges]);
const recentChangedClientCount = useMemo(() => {
return new Set(recentClientChangesWithActors.map((item) => item.clientId))
.size;
}, [recentClientChangesWithActors]);
const visibleRecentClientChanges = useMemo(() => {
return recentClientChangesWithActors.slice(
0,
visibleRecentClientChangesCount,
);
}, [recentClientChangesWithActors, visibleRecentClientChangesCount]);
const hasMoreRecentClientChanges =
recentClientChangesWithActors.length > visibleRecentClientChanges.length;
useEffect(() => {
if (
visibleRecentClientChangesCount > recentClientChangesWithActors.length
) {
setVisibleRecentClientChangesCount(
Math.max(
recentClientChangesInitialCount,
recentClientChangesWithActors.length,
),
);
}
}, [recentClientChangesWithActors.length, visibleRecentClientChangesCount]);
const isLoading =
isLoadingClients ||
isLoadingStats ||
isLoadingRecentAudit ||
isLoadingRequest ||
(hasAccessToken && !profileRole && isLoadingMe);
@@ -621,7 +234,7 @@ function ClientsPage() {
canCreateClient ? (
<Button
size="sm"
className="shadow-lg shadow-primary/30"
className="mt-1 shadow-lg shadow-primary/30"
onClick={() => navigate("/clients/new")}
>
<Plus className="h-4 w-4" />
@@ -679,7 +292,19 @@ function ClientsPage() {
/>
<Card className="glass-panel">
<CardHeader className="pb-4 pt-6">
<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">
@@ -696,34 +321,21 @@ function ClientsPage() {
</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>
<div className="hidden items-center gap-2 md:flex">
<Badge variant="muted">
{t(
"ui.dev.clients.badge.tenant_selected",
"테넌트: 선택됨",
)}
</Badge>
<Badge variant="success">
{t("ui.dev.clients.badge.dev_session", "DevFront 세션")}
</Badge>
</div>
</>
<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={
@@ -783,54 +395,6 @@ function ClientsPage() {
}
/>
</CardHeader>
<CardContent className="pt-0">
<div className="grid gap-4 md:grid-cols-3">
{stats.map((item) => (
<Card key={item.labelKey} className="border border-border/60">
<CardHeader className="pb-2">
<CardDescription>
{t(item.labelKey, item.labelFallback)}
</CardDescription>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-3xl font-bold">{item.value}</span>
<Badge
variant={
item.tone === "up"
? "success"
: item.tone === "down"
? "warning"
: "muted"
}
className={cn(
"px-2",
item.tone === "stable" && "bg-muted/40 text-foreground",
)}
>
{t(item.deltaKey, item.deltaFallback)}
</Badge>
</div>
</CardHeader>
</Card>
))}
</div>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.list.title", "클라이언트 목록")}
</CardTitle>
<CardDescription>
{t(
"msg.dev.clients.showing",
"총 {{shown}}개의 애플리케이션이 등록되어 있습니다.",
{ shown: totalClients },
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
@@ -954,7 +518,7 @@ function ClientsPage() {
</TableCell>
</TableRow>
)}
{filteredClients.map((client) => (
{visibleClients.map((client) => (
<TableRow key={client.id}>
<TableCell>
<Link
@@ -968,10 +532,12 @@ function ClientsPage() {
t("ui.dev.clients.untitled", "Untitled")}
</p>
<p className="text-xs text-muted-foreground">
{t(
"ui.dev.clients.tenant_scoped",
"Tenant-scoped",
)}
<span aria-hidden="true">
{t(
"ui.dev.clients.tenant_scoped",
"Tenant-scoped",
)}
</span>
</p>
</div>
</Link>
@@ -1039,161 +605,25 @@ function ClientsPage() {
</Table>
</div>
</div>
</CardContent>
</Card>
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between gap-4 pb-4">
<div>
<div className="flex items-center gap-1.5">
<CardTitle className="text-xl font-semibold">
{t("ui.dev.clients.recent_changes.title", "최근 변경된 앱")}
</CardTitle>
<Button
type="button"
variant="ghost"
size="icon"
className="-ml-1 h-8 w-8 translate-y-px text-muted-foreground hover:text-primary"
aria-label={t(
"ui.dev.clients.recent_changes.guide_button",
"최근 변경 항목 안내 열기",
)}
aria-expanded={isRecentChangesGuideOpen}
onClick={() =>
setIsRecentChangesGuideOpen((current) => !current)
}
>
<Info className="h-4 w-4" />
</Button>
</div>
<CardDescription>
{t(
"msg.dev.clients.recent_changes.description",
"총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다.",
{ count: recentChangedClientCount },
)}
</CardDescription>
<p className="mt-1 text-xs font-medium text-blue-600 dark:text-blue-400">
{t(
"msg.dev.clients.recent_changes.permission_note",
"'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다.",
)}
</p>
{isRecentChangesGuideOpen && (
<div className="mt-3 rounded-xl border border-border/60 bg-muted/20 p-4">
<p className="text-sm font-semibold text-foreground">
{t(
"ui.dev.clients.recent_changes.guide_title",
"최근 변경 항목 안내",
)}
</p>
<div className="mt-3 space-y-3">
{recentChangeGuideItems.map((item) => (
<div key={item.titleKey} className="space-y-1">
<p className="text-sm font-medium text-foreground">
{t(item.titleKey, item.titleFallback)}
</p>
<p className="text-xs leading-5 text-muted-foreground">
{t(item.descriptionKey, item.descriptionFallback)}
</p>
</div>
))}
<p className="text-xs leading-5 text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.guide.audit_only",
"동의 철회는 최근 변경된 앱 카드에 포함하지 않고, 감사 로그에서 확인합니다.",
)}
</p>
</div>
</div>
)}
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/audit-logs">
{t("ui.common.audit.title", "Audit Logs")}
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3 pt-0">
{visibleRecentClientChanges.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/60 bg-muted/20 p-5 text-sm text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.empty",
"최근 변경 로그가 아직 없습니다.",
)}
</div>
) : (
visibleRecentClientChanges.map((item) => {
const { date, time } = formatAuditDateParts(item.timestamp);
return (
<div
key={item.eventId}
className="flex flex-col gap-3 rounded-xl border border-border/60 bg-card/40 p-4 md:flex-row md:items-start md:justify-between"
>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/clients/${item.clientId}`}
className="font-semibold transition-colors hover:text-primary"
>
{item.clientName}
</Link>
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{item.clientId}
</code>
<span className="font-semibold">{item.actorName}</span>
<code className="rounded-md bg-secondary/60 px-2 py-1 font-mono text-xs text-muted-foreground">
{item.actorId}
</code>
<Badge variant="muted">{item.actionLabel}</Badge>
</div>
<div className="flex flex-wrap gap-2">
{item.detailLabels.length > 0 ? (
item.detailLabels.map((detail) => (
<Badge
key={`${item.eventId}-${detail.label}`}
variant="outline"
>
{detail.label}: {detail.value}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground">
{t(
"msg.dev.clients.recent_changes.no_detail",
"변경 항목을 확인할 수 없습니다.",
)}
</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{date} {time}
</p>
</div>
<Button variant="ghost" size="sm" asChild>
<Link to={`/clients/${item.clientId}`}>
{t("ui.common.view", "View")}
</Link>
</Button>
</div>
);
})
)}
{hasMoreRecentClientChanges ? (
<div className="pt-2 text-center">
{canToggleClientList ? (
<div className="mt-4 flex justify-center">
<Button
type="button"
variant="outline"
onClick={() =>
setVisibleRecentClientChangesCount((current) =>
Math.min(
current + recentClientChangesBatchSize,
recentClientChangesWithActors.length,
),
)
size="sm"
aria-label={
isClientListExpanded
? t(
"ui.dev.clients.list.collapse_aria",
"연동 앱 목록 접기",
)
: t("ui.dev.clients.list.more_aria", "연동 앱 목록 더보기")
}
onClick={() => setIsClientListExpanded((current) => !current)}
>
{t("ui.common.load_more", "더보기")}
{isClientListExpanded
? t("ui.common.collapse", "접기")
: t("ui.common.load_more", "더보기")}
</Button>
</div>
) : null}

View File

@@ -0,0 +1,65 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import type { ReactNode, ComponentProps } from "react";
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");
});
});

View File

@@ -0,0 +1,179 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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",
}),
);
});
});

View File

@@ -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);
});
});

View File

@@ -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("-");
});
});

View File

@@ -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);
});
});

View File

@@ -13,6 +13,11 @@ 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",
@@ -282,6 +287,19 @@ vi.mock("../../lib/devApi", () => ({
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" })),
@@ -348,6 +366,16 @@ async function renderPage(
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 />);
@@ -397,4 +425,65 @@ describe("devfront coverage smoke pages", () => {
});
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();
});
});

View File

@@ -0,0 +1,188 @@
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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",
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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" },
],
});
});
});

View File

@@ -0,0 +1,203 @@
import {
formatAuditValue,
parseAuditDetails,
resolveAuditActor,
type AuditDetails,
type CommonAuditLog,
} from "../../../../common/core/audit";
import { t } from "../../lib/i18n";
import type { ClientSummary, DevAuditLog } from "../../lib/devApi";
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(),
);
}

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { normalizeRole, resolveProfileRole } from "./role";
describe("normalizeRole", () => {
it("normalizes known role aliases", () => {
expect(normalizeRole("tenant_member")).toBe("user");
expect(normalizeRole("admin")).toBe("tenant_admin");
expect(normalizeRole("superadmin")).toBe("super_admin");
expect(normalizeRole("tenantadmin")).toBe("tenant_admin");
expect(normalizeRole("rpadmin")).toBe("rp_admin");
});
it("returns a trimmed lowercase role for unknown values", () => {
expect(normalizeRole(" custom_role ")).toBe("custom_role");
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("");
});
});

View File

@@ -544,6 +544,10 @@ new_client = "Configure redirect URIs, grant types, and authentication methods."
empty = "Review the relying parties this account can access."
none = "No connected applications to display."
[msg.dev.dashboard.recent_changes]
description = "Review trends for changed or deleted applications on the dashboard."
empty = "There are no recent change logs yet."
[msg.dev.dashboard.notice]
consent_audit = "Consent Audit"
dev_scope = "Dev Scope"
@@ -1597,6 +1601,7 @@ revoke_cache = "Revoke Cache"
[ui.dev.clients.relationships]
title = "Client Relationships"
add_title = "Add Relationship"
remove_title = "Remove Relationship"
relation = "Relation"
user_id = "User ID"
user_id_placeholder = "kratos user id"
@@ -1741,6 +1746,20 @@ title = "Quick links"
[ui.dev.dashboard.recent]
title = "My Applications"
[ui.dev.dashboard.recent_changes]
deleted_group = "Deleted applications"
aria = "Recent application changes"
period = "Recent change aggregation period"
series = "Changes {{changes}} / Actors {{actors}}"
title = "Recently Changed Applications"
y_axis = "Y axis: change count"
[ui.dev.dashboard.recent_changes.summary]
changed_clients = "Changed apps"
deleted_clients = "Deleted applications"
latest_change = "Latest change"
total_changes = "Recent changes"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"

View File

@@ -544,6 +544,10 @@ new_client = "redirect URI, grant type, 인증 방식을 설정합니다."
empty = "현재 계정이 접근할 수 있는 RP를 확인합니다."
none = "표시할 연동 앱이 없습니다."
[msg.dev.dashboard.recent_changes]
description = "변경 또는 삭제된 애플리케이션을 대시보드에서 추이를 확인합니다."
empty = "최근 변경 로그가 아직 없습니다."
[msg.dev.dashboard.notice]
consent_audit = "Consent 회수는 감사 로그와 연계"
dev_scope = "RP 정책은 dev scope에서만 적용"
@@ -1596,6 +1600,7 @@ revoke_cache = "캐시 삭제"
[ui.dev.clients.relationships]
title = "클라이언트 관계"
add_title = "관계 추가"
remove_title = "관계 삭제"
relation = "관계"
user_id = "사용자 ID"
user_id_placeholder = "kratos 사용자 id"
@@ -1740,6 +1745,20 @@ title = "빠른 이동"
[ui.dev.dashboard.recent]
title = "내 애플리케이션"
[ui.dev.dashboard.recent_changes]
deleted_group = "삭제된 앱"
aria = "최근 변경된 앱 현황"
period = "최근 변경 집계 단위"
series = "변경 {{changes}} / 작업자 {{actors}}"
title = "최근 변경된 앱"
y_axis = "Y축: 변경 수"
[ui.dev.dashboard.recent_changes.summary]
changed_clients = "변경된 앱 수"
deleted_clients = "삭제된 앱 수"
latest_change = "마지막 변경일"
total_changes = "최근 변경 건수"
[ui.dev.dashboard.stack]
notes = "Setup notes"
subtitle = "Devfront baseline"

View File

@@ -582,6 +582,10 @@ new_client = ""
empty = ""
none = ""
[msg.dev.dashboard.recent_changes]
description = ""
empty = ""
[msg.dev.dashboard.notice]
consent_audit = ""
dev_scope = ""
@@ -1651,6 +1655,7 @@ revoke_cache = ""
[ui.dev.clients.relationships]
title = ""
add_title = ""
remove_title = ""
relation = ""
user_id = ""
user_id_placeholder = ""
@@ -1797,6 +1802,20 @@ title = ""
[ui.dev.dashboard.recent]
title = ""
[ui.dev.dashboard.recent_changes]
deleted_group = ""
aria = ""
period = ""
series = ""
title = ""
y_axis = ""
[ui.dev.dashboard.recent_changes.summary]
changed_clients = ""
deleted_clients = ""
latest_change = ""
total_changes = ""
[ui.dev.dashboard.stack]
notes = ""
subtitle = ""

View File

@@ -37,6 +37,9 @@ test("clients page loads correctly", async ({ page }) => {
// 페이지 내 주요 텍스트 확인
await expect(page.getByText("연동 앱 목록")).toBeVisible();
await expect(
page.getByText("Total Applications", { exact: true }),
).toHaveCount(0);
// 테이블 헤더 확인
await expect(
@@ -47,7 +50,7 @@ test("clients page loads correctly", async ({ page }) => {
).toBeVisible();
});
test("clients page shows recent RP changes", async ({ page }) => {
test("overview page shows recent RP changes", async ({ page }) => {
await seedAuth(page, "super_admin");
await installDevApiMock(page, {
clients: [
@@ -89,7 +92,7 @@ test("clients page shows recent RP changes", async ({ page }) => {
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await page.goto("/");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
@@ -100,7 +103,64 @@ test("clients page shows recent RP changes", async ({ page }) => {
).toBeVisible();
});
test("clients page shows user-delete relation cleanup in recent changes", async ({
test("clients page shows only five apps by default and expands with more button", async ({
page,
}) => {
await seedAuth(page, "super_admin");
const clients = Array.from({ length: 6 }, (_, index) =>
makeClient(`client-${index + 1}`, {
name: `Preview App ${index + 1}`,
createdAt: new Date(Date.UTC(2026, 2, 3, 9, 10 - index, 0)).toISOString(),
}),
);
await installDevApiMock(page, {
clients,
consents: [] as Consent[],
auditLogs: [] as AuditLog[],
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await expect(
page.getByRole("heading", { name: "연동 앱 목록" }),
).toBeVisible();
await expect(
page
.locator("table")
.first()
.locator("tbody tr")
.filter({
hasText: /Preview App \d/,
}),
).toHaveCount(5);
await expect(
page.getByText("Preview App 6", { exact: true }),
).not.toBeVisible();
const moreButton = page.getByRole("button", {
name: "연동 앱 목록 더보기",
});
await expect(moreButton).toBeVisible();
await expect(moreButton).toHaveCount(1);
await moreButton.click();
await expect(
page
.locator("table")
.first()
.locator("tbody tr")
.filter({
hasText: /Preview App \d/,
}),
).toHaveCount(6);
await expect(page.getByText("Preview App 6", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "연동 앱 목록 더보기" }),
).toHaveCount(0);
});
test("overview page shows user-delete relation cleanup in recent changes", async ({
page,
}) => {
await seedAuth(page, "super_admin");
@@ -142,7 +202,7 @@ test("clients page shows user-delete relation cleanup in recent changes", async
auditLogsByCursor: undefined,
});
await page.goto("/clients");
await page.goto("/");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
@@ -151,15 +211,13 @@ test("clients page shows user-delete relation cleanup in recent changes", async
).toBeVisible();
await expect(page.getByText("관계 삭제", { exact: true })).toBeVisible();
await expect(page.getByText(/관계:\s*config_editor/)).toBeVisible();
await expect(page.getByText(/대상:\s*User:deleted-user/)).toBeVisible();
await expect(page.getByText(/주체:\s*User:deleted-user/)).toBeVisible();
await expect(
page.getByText("cleanup-actor", { exact: true }).first(),
).toBeVisible();
});
test("clients page expands recent changes with more button", async ({
page,
}) => {
test("clients page no longer shows recent changes card", async ({ page }) => {
await seedAuth(page, "super_admin");
const clients = Array.from({ length: 6 }, (_, index) =>
makeClient(`client-${index + 1}`, {
@@ -193,23 +251,8 @@ test("clients page expands recent changes with more button", async ({
await page.goto("/clients");
await expect(
page.getByRole("heading", { name: "최근 변경된 앱" }),
).toBeVisible();
).toHaveCount(0);
await expect(
page.getByRole("link", { name: "Recent App 1", exact: true }),
page.getByRole("heading", { name: "연동 앱 목록" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Recent App 5", exact: true }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Recent App 6", exact: true }),
).not.toBeVisible();
const moreButton = page.getByRole("button", { name: "더 보기" });
await expect(moreButton).toBeVisible();
await moreButton.click();
await expect(
page.getByRole("link", { name: "Recent App 6", exact: true }),
).toBeVisible();
await expect(moreButton).toHaveCount(0);
});