forked from baron/baron-sso
Merge branch 'dev' into feature/rbac-simplification-and-remove-dev-switcher
This commit is contained in:
212
devfront/src/features/audit/AuditLogsPage.test.tsx
Normal file
212
devfront/src/features/audit/AuditLogsPage.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
19
devfront/src/features/auth/authApi.test.ts
Normal file
19
devfront/src/features/auth/authApi.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
280
devfront/src/features/clients/ClientsPage.test.tsx
Normal file
280
devfront/src/features/clients/ClientsPage.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
65
devfront/src/features/clients/components/ClientLogo.test.tsx
Normal file
65
devfront/src/features/clients/components/ClientLogo.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
123
devfront/src/features/coverage/AuditLogTable.test.tsx
Normal file
123
devfront/src/features/coverage/AuditLogTable.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
72
devfront/src/features/coverage/commonAudit.test.ts
Normal file
72
devfront/src/features/coverage/commonAudit.test.ts
Normal 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("-");
|
||||
});
|
||||
});
|
||||
72
devfront/src/features/coverage/commonAuth.test.ts
Normal file
72
devfront/src/features/coverage/commonAuth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
253
devfront/src/features/overview/recentClientChanges.test.ts
Normal file
253
devfront/src/features/overview/recentClientChanges.test.ts
Normal 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" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
203
devfront/src/features/overview/recentClientChanges.ts
Normal file
203
devfront/src/features/overview/recentClientChanges.ts
Normal 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(),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user