import { type AuditDetails, type CommonAuditLog, formatAuditValue, parseAuditDetails, resolveAuditActor, } from "../../../../common/core/audit"; import type { ClientSummary, DevAuditLog } from "../../lib/devApi"; import { t } from "../../lib/i18n"; export type RecentClientChange = { eventId: string; clientId: string; clientName: string; actorId: string; action: string; actionLabel: string; timestamp: string; detailLabels: Array<{ label: string; value: string }>; }; const recentClientActions = new Set([ "CREATE_CLIENT", "UPDATE_CLIENT", "UPDATE_CLIENT_STATUS", "ROTATE_SECRET", "ADD_RELATION", "REMOVE_RELATION", "DELETE_CLIENT", ]); function isRecord(value: unknown): value is Record { 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": 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", "클라이언트 시크릿", ); 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, ) { const before = isRecord(details.before) ? details.before : {}; const after = isRecord(details.after) ? details.after : {}; const sourceDetails = action === "ADD_RELATION" ? { ...after, ...details } : action === "REMOVE_RELATION" ? { ...before, ...details } : {}; 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 = sourceDetails as Record; const relation = source.relation; const subject = source.subject; return [ ...(typeof relation === "string" && relation ? [ { label: getRecentClientFieldLabel("relation"), value: formatAuditValue(relation), }, ] : []), ...(typeof subject === "string" && subject ? [ { label: getRecentClientFieldLabel("subject"), value: formatAuditValue(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 (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 ( auditValueSignature(beforeValue) === auditValueSignature(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)); if (changes.length === 0) { return []; } 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, details, ), action, actionLabel: getRecentClientActionLabel(action), timestamp: item.timestamp, detailLabels: buildRecentClientChangeDetails(action, details), } satisfies RecentClientChange; }) .filter((item): item is RecentClientChange => { if (!item) { return false; } return item.detailLabels.length > 0; }) .sort( (left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime(), ); }