diff --git a/devfront/src/features/overview/recentClientChanges.test.ts b/devfront/src/features/overview/recentClientChanges.test.ts index b69c9a28..243bfb08 100644 --- a/devfront/src/features/overview/recentClientChanges.test.ts +++ b/devfront/src/features/overview/recentClientChanges.test.ts @@ -91,6 +91,117 @@ describe("recent client changes", () => { ]); }); + it("ignores audit object key order changes in update details", () => { + mockLocale("ko"); + + expect( + buildRecentClientChangeDetails("UPDATE_CLIENT", { + before: { + id_token_claims: [ + { + key: "license", + namespace: "rp_claims", + nullable: true, + readPermission: "admin_only", + value: "", + valueType: "text", + writePermission: "admin_only", + }, + { + key: "date", + namespace: "rp_claims", + nullable: true, + readPermission: "admin_only", + value: "", + valueType: "date", + writePermission: "admin_only", + }, + ], + }, + after: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + { + namespace: "rp_claims", + key: "date", + value: "", + valueType: "date", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + }), + ).toEqual([]); + }); + + it("summarizes id_token_claims additions and removals", () => { + mockLocale("ko"); + + expect( + buildRecentClientChangeDetails("UPDATE_CLIENT", { + before: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + { + namespace: "rp_claims", + key: "date", + value: "", + valueType: "date", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + after: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + { + namespace: "rp_claims", + key: "test", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + }), + ).toEqual([ + { + label: "커스텀 클레임", + value: "+ test (text), - date (date)", + }, + ]); + }); + it("builds recent client changes with sorting, filtering, and detail slicing", () => { mockLocale("ko"); @@ -192,6 +303,40 @@ describe("recent client changes", () => { after: { name: "Ignored" }, }, ), + makeAuditLog( + "evt-9", + "2026-05-27T15:00:00.000Z", + "UPDATE_CLIENT", + "client-a", + { + before: { + id_token_claims: [ + { + key: "license", + namespace: "rp_claims", + nullable: true, + readPermission: "admin_only", + value: "", + valueType: "text", + writePermission: "admin_only", + }, + ], + }, + after: { + id_token_claims: [ + { + namespace: "rp_claims", + key: "license", + value: "", + valueType: "text", + nullable: true, + readPermission: "admin_only", + writePermission: "admin_only", + }, + ], + }, + }, + ), ]; const changes = buildRecentClientChanges(auditLogs, clients); diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts index 3e6614a0..b02c34be 100644 --- a/devfront/src/features/overview/recentClientChanges.ts +++ b/devfront/src/features/overview/recentClientChanges.ts @@ -33,6 +33,27 @@ function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } +function normalizeAuditValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => normalizeAuditValue(item)); + } + + if (isRecord(value)) { + return Object.keys(value) + .sort() + .reduce>((acc, key) => { + acc[key] = normalizeAuditValue(value[key]); + return acc; + }, {}); + } + + return value; +} + +function auditValueSignature(value: unknown) { + return JSON.stringify(normalizeAuditValue(value)); +} + export function getRecentClientActionLabel(action: string) { switch (action) { case "CREATE_CLIENT": @@ -74,11 +95,169 @@ function getRecentClientFieldLabel(key: string) { "ui.dev.clients.details.credentials.client_secret", "클라이언트 시크릿", ); + case "id_token_claims": + return t( + "ui.dev.clients.general.id_token_claims.title", + "Custom Claims", + ); default: return key; } } +function getIdTokenClaimIdentity(claim: Record) { + const namespace = + typeof claim.namespace === "string" && claim.namespace + ? claim.namespace + : null; + const key = typeof claim.key === "string" && claim.key ? claim.key : null; + + if (!namespace || !key) { + return null; + } + + return { namespace, key }; +} + +function formatIdTokenClaimDisplayName(claim: Record) { + const identity = getIdTokenClaimIdentity(claim); + if (!identity) { + return "unknown"; + } + if (identity.namespace === "rp_claims") { + return identity.key; + } + return `${identity.namespace}:${identity.key}`; +} + +function isSimpleAuditScalar(value: unknown) { + return ( + value === null || + value === undefined || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +function formatIdTokenClaimChangeSummary( + beforeValue: unknown, + afterValue: unknown, +) { + if (!isRecord(beforeValue) || !isRecord(afterValue)) { + return null; + } + + const beforeDisplayName = formatIdTokenClaimDisplayName(beforeValue); + const afterDisplayName = formatIdTokenClaimDisplayName(afterValue); + if (beforeDisplayName !== afterDisplayName) { + return `~ ${beforeDisplayName} → ${afterDisplayName}`; + } + + const beforeValueType = + typeof beforeValue.valueType === "string" ? beforeValue.valueType : null; + const afterValueType = + typeof afterValue.valueType === "string" ? afterValue.valueType : null; + + if ( + beforeValueType && + afterValueType && + beforeValueType !== afterValueType + ) { + return `~ ${beforeDisplayName}: ${beforeValueType} → ${afterValueType}`; + } + + const beforeScalar = beforeValue.value; + const afterScalar = afterValue.value; + if ( + isSimpleAuditScalar(beforeScalar) && + isSimpleAuditScalar(afterScalar) && + formatAuditValue(beforeScalar) !== formatAuditValue(afterScalar) + ) { + return `~ ${beforeDisplayName}: ${formatAuditValue(beforeScalar)} → ${formatAuditValue(afterScalar)}`; + } + + return `~ ${beforeDisplayName}`; +} + +function summarizeIdTokenClaimArrayChange( + beforeValue: unknown, + afterValue: unknown, +) { + if (!Array.isArray(beforeValue) || !Array.isArray(afterValue)) { + return null; + } + + const beforeClaims = beforeValue.filter(isRecord); + const afterClaims = afterValue.filter(isRecord); + const beforeByIdentity = new Map>(); + const afterByIdentity = new Map>(); + + for (const claim of beforeClaims) { + const identity = getIdTokenClaimIdentity(claim); + if (identity) { + beforeByIdentity.set(`${identity.namespace}:${identity.key}`, claim); + } + } + + for (const claim of afterClaims) { + const identity = getIdTokenClaimIdentity(claim); + if (identity) { + afterByIdentity.set(`${identity.namespace}:${identity.key}`, claim); + } + } + + const additions: string[] = []; + const removals: string[] = []; + const updates: string[] = []; + + for (const [identity, afterClaim] of afterByIdentity.entries()) { + const beforeClaim = beforeByIdentity.get(identity); + const displayName = formatIdTokenClaimDisplayName(afterClaim); + + if (!beforeClaim) { + const valueType = + typeof afterClaim.valueType === "string" ? afterClaim.valueType : null; + additions.push( + valueType ? `+ ${displayName} (${valueType})` : `+ ${displayName}`, + ); + continue; + } + + if (auditValueSignature(beforeClaim) === auditValueSignature(afterClaim)) { + continue; + } + + const summary = formatIdTokenClaimChangeSummary(beforeClaim, afterClaim); + if (summary) { + updates.push(summary); + } + } + + for (const [identity, beforeClaim] of beforeByIdentity.entries()) { + if (afterByIdentity.has(identity)) { + continue; + } + const displayName = formatIdTokenClaimDisplayName(beforeClaim); + const valueType = + typeof beforeClaim.valueType === "string" ? beforeClaim.valueType : null; + removals.push( + valueType ? `- ${displayName} (${valueType})` : `- ${displayName}`, + ); + } + + const parts = [...additions, ...removals, ...updates].slice(0, 4); + if (parts.length === 0) { + return null; + } + + if (additions.length + removals.length + updates.length > parts.length) { + parts.push("..."); + } + + return parts.join(", "); +} + export function buildRecentClientChangeDetails( action: string, details: AuditDetails, @@ -126,8 +305,19 @@ export function buildRecentClientChangeDetails( const beforeValue = before[key]; const afterValue = after[key]; + if (key === "id_token_claims") { + const value = summarizeIdTokenClaimArrayChange(beforeValue, afterValue); + if (!value) { + return null; + } + return { + label: getRecentClientFieldLabel(key), + value, + }; + } + if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") { - if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) { + if (auditValueSignature(beforeValue) === auditValueSignature(afterValue)) { return null; } } @@ -161,6 +351,10 @@ export function buildRecentClientChangeDetails( }) .filter((item): item is { label: string; value: string } => Boolean(item)); + if (changes.length === 0) { + return []; + } + return changes.slice(0, 3); } @@ -194,7 +388,12 @@ export function buildRecentClientChanges( detailLabels: buildRecentClientChangeDetails(action, details), } satisfies RecentClientChange; }) - .filter((item): item is RecentClientChange => Boolean(item)) + .filter((item): item is RecentClientChange => { + if (!item) { + return false; + } + return item.detailLabels.length > 0; + }) .sort( (left, right) => new Date(right.timestamp).getTime() -