diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index f02d9f8a..8ae2306b 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -1357,6 +1357,7 @@ func (h *DevHandler) ListClientRelations(c *fiber.Ctx) error { } func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error { + tenantID := h.injectTenantContextFromHeader(c) clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") @@ -1403,6 +1404,16 @@ func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + h.setAuditDetailsExtra(c, map[string]any{ + "action": "ADD_RELATION", + "target_id": clientID, + "tenant_id": tenantID, + "after": map[string]any{ + "relation": req.Relation, + "subject": req.Subject, + }, + }) + return c.Status(fiber.StatusCreated).JSON(mapRelationTupleSummary(service.RelationTuple{ Object: clientID, Relation: req.Relation, @@ -1411,6 +1422,7 @@ func (h *DevHandler) AddClientRelation(c *fiber.Ctx) error { } func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error { + tenantID := h.injectTenantContextFromHeader(c) clientID := strings.TrimSpace(c.Params("id")) if clientID == "" { return errorJSON(c, fiber.StatusBadRequest, "client id is required") @@ -1444,6 +1456,16 @@ func (h *DevHandler) RemoveClientRelation(c *fiber.Ctx) error { return errorJSON(c, fiber.StatusInternalServerError, err.Error()) } + h.setAuditDetailsExtra(c, map[string]any{ + "action": "REMOVE_RELATION", + "target_id": clientID, + "tenant_id": tenantID, + "before": map[string]any{ + "relation": relation, + "subject": subject, + }, + }) + return c.SendStatus(fiber.StatusNoContent) } diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 74e49484..037c08d6 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -29,11 +29,6 @@ import { commonTableViewportClass, } from "../../../../common/ui/table"; import { ForbiddenMessage } from "../../components/common/ForbiddenMessage"; -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "../../components/ui/avatar"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { @@ -45,7 +40,6 @@ import { } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; -import { Separator } from "../../components/ui/separator"; import { Table, TableBody, @@ -57,6 +51,7 @@ import { import { Textarea } from "../../components/ui/textarea"; import { type ClientSummary, + type DevAuditLog, fetchClients, fetchDeveloperRequestStatus, fetchDevStats, @@ -69,9 +64,143 @@ import { cn } from "../../lib/utils"; import { fetchMe } from "../auth/authApi"; import { resolveClientCreateAccess } from "./clientCreateAccess"; import { ClientLogo } from "./components/ClientLogo"; +import { + formatAuditDateParts, + formatAuditValue, + parseAuditDetails, +} from "../../../../common/core/audit"; type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt"; +type RecentClientChange = { + eventId: string; + clientId: string; + clientName: 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", +]); + +const recentClientFieldLabels: Record = { + name: "이름", + type: "유형", + status: "상태", + token_endpoint_auth_method: "인증 방식", + jwks_uri: "JWKS URI", + backchannel_logout_uri: "Backchannel Logout URI", + backchannel_logout_session_required: "세션 필수", + redirect_uri_count: "Redirect URI 수", + scope_count: "Scope 수", + relation: "관계", + subject: "대상", +}; + +function isRecord(value: unknown): value is Record { + 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, +) { + 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); +} + function ClientsPage() { const navigate = useNavigate(); const auth = useAuth(); @@ -141,6 +270,61 @@ 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 >( @@ -238,9 +422,40 @@ function ClientsPage() { }, ]; + const recentClientChanges = useMemo(() => { + 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, + action, + actionLabel: getRecentClientActionLabel(action), + timestamp: item.timestamp, + detailLabels: buildRecentClientChangeDetails(action, details), + }; + }) + .filter((item): item is RecentClientChange => Boolean(item)) + .slice(0, 4); + }, [clients, recentAuditData?.items]); + + const recentChangedClientCount = useMemo(() => { + return new Set(recentClientChanges.map((item) => item.clientId)).size; + }, [recentClientChanges]); + const isLoading = isLoadingClients || isLoadingStats || + isLoadingRecentAudit || isLoadingRequest || (hasAccessToken && !profileRole && isLoadingMe); @@ -706,82 +921,92 @@ function ClientsPage() { -
- - - - {t( - "ui.dev.clients.help.title", - "Need help with OIDC configuration?", - )} + + +
+ + {t("ui.dev.clients.recent_changes.title", "최근 변경된 앱")} {t( - "msg.dev.clients.help.subtitle", - "Developer guides for Confidential/Public clients, redirect URIs, and auth methods.", + "msg.dev.clients.recent_changes.description", + "총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다.", + { count: recentChangedClientCount }, )} - - -
-
- -
-
-

- {t("ui.dev.clients.help.docs_title", "Docs & Examples")} -

-

- {t( - "msg.dev.clients.help.docs_body", - "Includes PKCE, client_secret_basic, redirect URI validation tips.", - )} -

-
+

+ {t( + "msg.dev.clients.recent_changes.permission_note", + "'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다.", + )} +

+
+ + + + {recentClientChanges.length === 0 ? ( +
+ {t( + "msg.dev.clients.recent_changes.empty", + "최근 변경 로그가 아직 없습니다.", + )}
- -
- - - - - - {t("ui.dev.clients.owner.title", "Owner")} - - - {t("ui.dev.clients.owner.subtitle", "Tenant admin on-call")} - - - -
- - - AR - -
-

- {t("ui.dev.clients.owner.name", "AI Admin Bot")} -

-

- {t("ui.dev.clients.owner.email", "admin@brsw.kr")} -

-
-
- -
- - {t("ui.dev.clients.owner.role", "Role: Tenant Admin")} - - {t("ui.dev.clients.owner.scope", "Scope: TENANT-12")} -
-
-
-
+ ) : ( + recentClientChanges.map((item) => { + const { date, time } = formatAuditDateParts(item.timestamp); + return ( +
+
+
+ + {item.clientName} + + + {item.clientId} + + {item.actionLabel} +
+
+ {item.detailLabels.length > 0 ? ( + item.detailLabels.map((detail, index) => ( + + {detail.label}: {detail.value} + + )) + ) : ( + + {t( + "msg.dev.clients.recent_changes.no_detail", + "변경 항목을 확인할 수 없습니다.", + )} + + )} +
+

+ {date} {time} +

+
+ +
+ ); + }) + )} + +
{ }); test("clients page loads correctly", async ({ page }) => { - await seedAuth(page); + await seedAuth(page, "super_admin"); await installDevApiMock(page, { clients: [ makeClient("client-playwright", { @@ -44,3 +45,52 @@ test("clients page loads correctly", async ({ page }) => { page.locator("th").filter({ hasText: /클라이언트 ID|Client ID/i }), ).toBeVisible(); }); + +test("clients page shows recent RP changes", async ({ page }) => { + await seedAuth(page, "super_admin"); + await installDevApiMock(page, { + clients: [ + makeClient("client-recent", { + name: "Recent RP", + }), + ], + consents: [] as Consent[], + auditLogs: [ + { + event_id: "evt-1", + timestamp: "2026-03-03T09:00:00.000Z", + user_id: "actor-1", + event_type: "CLIENT_RELATION_CREATE", + status: "success", + ip_address: "127.0.0.1", + user_agent: "playwright", + details: JSON.stringify({ + action: "ADD_RELATION", + target_id: "client-recent", + relation: "config_editor", + subject: "User:user-2", + }), + }, + { + event_id: "evt-2", + timestamp: "2026-03-03T08:59:00.000Z", + user_id: "actor-2", + event_type: "CLIENT_ROTATE_SECRET", + status: "success", + ip_address: "127.0.0.1", + user_agent: "playwright", + details: JSON.stringify({ + action: "ROTATE_SECRET", + target_id: "client-recent", + }), + }, + ] as AuditLog[], + auditLogsByCursor: undefined, + }); + + await page.goto("/clients"); + await expect(page.getByText("최근 변경된 RP")).toBeVisible(); + await expect(page.getByText("클라이언트 시크릿 재발급")).toBeVisible(); + await expect(page.getByText("관계 추가")).toBeVisible(); + await expect(page.getByText("Recent RP")).toBeVisible(); +});