From 27a1d51c86bb31856893998a5d2ca8b7c0efaeec Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 20 Mar 2026 22:00:43 +0000 Subject: [PATCH] Extract audit webhook runtime owner --- .../subsystems/frontend-primitives.md | 19 ++- .../v6/internal/subsystems/registry.json | 2 + .../components/Settings/AuditWebhookPanel.tsx | 151 +++--------------- .../__tests__/settingsArchitecture.test.ts | 15 ++ .../Settings/useAuditWebhookPanelState.ts | 149 +++++++++++++++++ .../frontendResourceTypeBoundaries.test.ts | 24 +++ .../src/utils/auditWebhookPresentation.ts | 21 +++ 7 files changed, 249 insertions(+), 132 deletions(-) create mode 100644 frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index fd20b0bed0..7352f68f65 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -32,11 +32,12 @@ work extends shared components instead of creating new local variants. 10. `frontend-modern/src/components/Settings/AuditLogPanel.tsx` 11. `frontend-modern/src/components/Settings/useAuditLogPanelState.ts` 12. `frontend-modern/src/components/Settings/AuditWebhookPanel.tsx` -13. `frontend-modern/src/components/Settings/CopyCommandBlock.tsx` -14. `frontend-modern/src/components/Settings/diagnosticsModel.ts` -15. `frontend-modern/src/components/Settings/DiagnosticsPanel.tsx` -16. `frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx` -17. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` +13. `frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts` +14. `frontend-modern/src/components/Settings/CopyCommandBlock.tsx` +15. `frontend-modern/src/components/Settings/diagnosticsModel.ts` +16. `frontend-modern/src/components/Settings/DiagnosticsPanel.tsx` +17. `frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx` +18. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` 18. `frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx` 19. `frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx` 20. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` @@ -136,6 +137,14 @@ license/paywall lifecycle, persisted filters, verification flow, and audit-log fetch orchestration. The shell must not re-accumulate localStorage or API runtime logic inline. +The audit webhook settings surface now follows that same owner split. +`frontend-modern/src/components/Settings/AuditWebhookPanel.tsx` stays the +canonical `SettingsPanel` shell, while +`frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts` owns the +license/paywall lifecycle, webhook fetch/save flow, validation, and trial +startup orchestration. The shell must not re-accumulate API calls or paywall +tracking inline. + The diagnostics settings surface now follows that same owner split. `frontend-modern/src/components/Settings/DiagnosticsPanel.tsx` stays the top-level diagnostics shell, while diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 8bc2d3c8d9..aeeb7a255c 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1668,6 +1668,7 @@ "frontend-modern/src/components/Settings/updatesSettingsModel.ts", "frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx", "frontend-modern/src/components/Settings/useAuditLogPanelState.ts", + "frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts", "frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts", "frontend-modern/src/components/Settings/useSSOProvidersState.ts", "frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx", @@ -1736,6 +1737,7 @@ "frontend-modern/src/components/Settings/updatesSettingsModel.ts", "frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx", "frontend-modern/src/components/Settings/useAuditLogPanelState.ts", + "frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts", "frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts", "frontend-modern/src/components/Settings/useSSOProvidersState.ts" ], diff --git a/frontend-modern/src/components/Settings/AuditWebhookPanel.tsx b/frontend-modern/src/components/Settings/AuditWebhookPanel.tsx index 95e0c367ae..fceb254cff 100644 --- a/frontend-modern/src/components/Settings/AuditWebhookPanel.tsx +++ b/frontend-modern/src/components/Settings/AuditWebhookPanel.tsx @@ -1,4 +1,4 @@ -import { createSignal, For, onMount, Show, createEffect, type Component } from 'solid-js'; +import { For, Show, type Component } from 'solid-js'; import Shield from 'lucide-solid/icons/shield'; import Globe from 'lucide-solid/icons/globe'; import Plus from 'lucide-solid/icons/plus'; @@ -7,150 +7,49 @@ import ExternalLink from 'lucide-solid/icons/external-link'; import { Card } from '@/components/shared/Card'; import SettingsPanel from '@/components/shared/SettingsPanel'; import { formControl } from '@/components/shared/Form'; -import { showSuccess, showWarning } from '@/utils/toast'; -import { apiFetchJSON } from '@/utils/apiClient'; import { AUDIT_WEBHOOK_ENDPOINT_CARD_CLASS, AUDIT_WEBHOOK_ENDPOINT_ICON_CLASS, AUDIT_WEBHOOK_READONLY_NOTICE_CLASS, + AUDIT_WEBHOOK_SECURITY_NOTE_BODY, + AUDIT_WEBHOOK_SECURITY_NOTE_TITLE, getAuditWebhookEmptyStateCopy, getAuditWebhookFeatureGateCopy, getAuditWebhookLoadingState, } from '@/utils/auditWebhookPresentation'; +import { trackUpgradeClicked } from '@/utils/upgradeMetrics'; import { - hasFeature, - licenseLoaded, - loadLicenseStatus, - getUpgradeActionUrlOrFallback, - startProTrial, - entitlements, -} from '@/stores/license'; -import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; -import { - getProTrialStartedMessage, - getTrialAlreadyUsedMessage, - getTrialStartErrorMessage, getUpgradeActionButtonClass, UPGRADE_ACTION_LABEL, UPGRADE_TRIAL_LABEL, UPGRADE_TRIAL_LINK_CLASS, } from '@/utils/upgradePresentation'; +import { useAuditWebhookPanelState } from '@/components/Settings/useAuditWebhookPanelState'; interface AuditWebhookPanelProps { canManage?: boolean; } export const AuditWebhookPanel: Component = (props) => { - const [webhookUrls, setWebhookUrls] = createSignal([]); - const [newUrl, setNewUrl] = createSignal(''); - const [saving, setSaving] = createSignal(false); - const [loading, setLoading] = createSignal(true); - const [startingTrial, setStartingTrial] = createSignal(false); - const canManage = () => props.canManage !== false; + const { + canManage, + canStartTrial, + handleAddWebhook, + handleRemoveWebhook, + handleStartTrial, + isAuditLoggingEnabled, + loading, + newUrl, + saving, + setNewUrl, + startingTrial, + upgradeActionUrl, + webhookUrls, + } = useAuditWebhookPanelState(props.canManage); const featureGateCopy = () => getAuditWebhookFeatureGateCopy(); const emptyStateCopy = () => getAuditWebhookEmptyStateCopy(); - const canStartTrial = () => entitlements()?.trial_eligible !== false; - - const handleStartTrial = async () => { - if (startingTrial()) return; - setStartingTrial(true); - try { - const result = await startProTrial(); - if (result?.outcome === 'redirect') { - window.location.href = result.actionUrl; - return; - } - showSuccess(getProTrialStartedMessage()); - } catch (err) { - const statusCode = (err as { status?: number } | null)?.status; - if (statusCode === 409) { - showWarning(getTrialAlreadyUsedMessage()); - } else { - showWarning(getTrialStartErrorMessage(err instanceof Error ? err.message : undefined)); - } - } finally { - setStartingTrial(false); - } - }; - - onMount(() => { - loadLicenseStatus(); - }); - - createEffect((wasPaywallVisible: boolean) => { - const isPaywallVisible = licenseLoaded() && !hasFeature('audit_logging'); - if (isPaywallVisible && !wasPaywallVisible) { - trackPaywallViewed('audit_logging', 'settings_audit_webhook_panel'); - } - return isPaywallVisible; - }, false); - - createEffect(() => { - if (hasFeature('audit_logging')) { - fetchWebhooks(); - } else { - setLoading(false); - } - }); - - const fetchWebhooks = async () => { - try { - const data = await apiFetchJSON<{ urls: string[] }>('/api/admin/webhooks/audit'); - setWebhookUrls(data.urls || []); - } catch (err) { - console.error('Failed to fetch audit webhooks:', err); - } finally { - setLoading(false); - } - }; - - const handleAddWebhook = async () => { - if (!canManage()) return; - const url = newUrl().trim(); - if (!url) return; - - try { - new URL(url); // basic validation - } catch { - showWarning('Please enter a valid URL'); - return; - } - - if (webhookUrls().includes(url)) { - showWarning('This URL is already configured'); - return; - } - - const updated = [...webhookUrls(), url]; - await saveWebhooks(updated); - setNewUrl(''); - }; - - const handleRemoveWebhook = async (url: string) => { - if (!canManage()) return; - const updated = webhookUrls().filter((u) => u !== url); - await saveWebhooks(updated); - }; - - const saveWebhooks = async (urls: string[]) => { - setSaving(true); - try { - await apiFetchJSON('/api/admin/webhooks/audit', { - method: 'POST', - body: JSON.stringify({ urls }), - }); - - setWebhookUrls(urls); - showSuccess('Audit webhooks updated'); - } catch (_err) { - showWarning('Failed to save webhook configuration'); - } finally { - setSaving(false); - } - }; - - if (!hasFeature('audit_logging')) { + if (!isAuditLoggingEnabled()) { return ( = (props) => {

- Security Note + {AUDIT_WEBHOOK_SECURITY_NOTE_TITLE}

- Audit webhooks are dispatched asynchronously to avoid blocking user operations. - Endpoints should still verify source trust (for example via an ingest secret) before - processing events. + {AUDIT_WEBHOOK_SECURITY_NOTE_BODY}

diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index b3546bccc6..ac41fbe20d 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -14,6 +14,7 @@ import apiAccessPanelSource from '../APIAccessPanel.tsx?raw'; import auditLogPanelSource from '../AuditLogPanel.tsx?raw'; import auditLogStateSource from '../useAuditLogPanelState.ts?raw'; import auditWebhookPanelSource from '../AuditWebhookPanel.tsx?raw'; +import auditWebhookStateSource from '../useAuditWebhookPanelState.ts?raw'; import billingAdminPanelSource from '../BillingAdminPanel.tsx?raw'; import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw'; import aiSettingsPanelSource from '../AISettings.tsx?raw'; @@ -59,6 +60,7 @@ const extractedModules = [ '../infrastructureOperationsModel.tsx', '../useInfrastructureOperationsState.tsx', '../useAuditLogPanelState.ts', + '../useAuditWebhookPanelState.ts', '../NodeModal.tsx', '../nodeModalModel.ts', '../useNodeModalState.ts', @@ -407,6 +409,19 @@ describe('Settings architecture guardrails', () => { expect(auditLogStateSource).toContain('trackPaywallViewed'); }); + it('keeps the audit webhook shell behind an extracted runtime owner', () => { + expect(auditWebhookPanelSource).toContain('@/components/Settings/useAuditWebhookPanelState'); + expect(auditWebhookPanelSource).not.toContain('loadLicenseStatus();'); + expect(auditWebhookPanelSource).not.toContain('const fetchWebhooks = async () =>'); + expect(auditWebhookPanelSource).not.toContain('const saveWebhooks = async (urls: string[]) =>'); + expect(auditWebhookPanelSource).not.toContain('trackPaywallViewed'); + expect(auditWebhookStateSource).toContain('export const useAuditWebhookPanelState ='); + expect(auditWebhookStateSource).toContain('loadLicenseStatus();'); + expect(auditWebhookStateSource).toContain('const fetchWebhooks = async () =>'); + expect(auditWebhookStateSource).toContain('const saveWebhooks = async (urls: string[]) =>'); + expect(auditWebhookStateSource).toContain('trackPaywallViewed'); + }); + it('keeps the SSO providers shell behind extracted runtime owners', () => { expect(ssoProvidersPanelSource).toContain('@/components/Settings/useSSOProvidersState'); expect(ssoProvidersPanelSource).not.toContain('const loadProviders = async () =>'); diff --git a/frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts b/frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts new file mode 100644 index 0000000000..a81412088d --- /dev/null +++ b/frontend-modern/src/components/Settings/useAuditWebhookPanelState.ts @@ -0,0 +1,149 @@ +import { createEffect, createSignal, onMount } from 'solid-js'; +import { apiFetchJSON } from '@/utils/apiClient'; +import { logger } from '@/utils/logger'; +import { showSuccess, showWarning } from '@/utils/toast'; +import { + entitlements, + getUpgradeActionUrlOrFallback, + hasFeature, + licenseLoaded, + loadLicenseStatus, + startProTrial, +} from '@/stores/license'; +import { trackPaywallViewed } from '@/utils/upgradeMetrics'; +import { + getProTrialStartedMessage, + getTrialAlreadyUsedMessage, + getTrialStartErrorMessage, +} from '@/utils/upgradePresentation'; +import { + getAuditWebhookDuplicateUrlMessage, + getAuditWebhookInvalidUrlMessage, + getAuditWebhookSaveErrorMessage, + getAuditWebhookSaveSuccessMessage, +} from '@/utils/auditWebhookPresentation'; + +export const useAuditWebhookPanelState = (canManageOverride?: boolean) => { + const [webhookUrls, setWebhookUrls] = createSignal([]); + const [newUrl, setNewUrl] = createSignal(''); + const [saving, setSaving] = createSignal(false); + const [loading, setLoading] = createSignal(true); + const [startingTrial, setStartingTrial] = createSignal(false); + + const canManage = () => canManageOverride !== false; + const canStartTrial = () => entitlements()?.trial_eligible !== false; + const isAuditLoggingEnabled = () => hasFeature('audit_logging'); + const upgradeActionUrl = () => getUpgradeActionUrlOrFallback('audit_logging'); + + const handleStartTrial = async () => { + if (startingTrial()) return; + setStartingTrial(true); + try { + const result = await startProTrial(); + if (result?.outcome === 'redirect') { + window.location.href = result.actionUrl; + return; + } + showSuccess(getProTrialStartedMessage()); + } catch (error) { + const statusCode = (error as { status?: number } | null)?.status; + if (statusCode === 409) { + showWarning(getTrialAlreadyUsedMessage()); + } else { + showWarning(getTrialStartErrorMessage(error instanceof Error ? error.message : undefined)); + } + } finally { + setStartingTrial(false); + } + }; + + const fetchWebhooks = async () => { + try { + const data = await apiFetchJSON<{ urls: string[] }>('/api/admin/webhooks/audit'); + setWebhookUrls(data.urls || []); + } catch (error) { + logger.error('[AuditWebhookPanel] Failed to fetch audit webhooks', error); + } finally { + setLoading(false); + } + }; + + const saveWebhooks = async (urls: string[]) => { + setSaving(true); + try { + await apiFetchJSON('/api/admin/webhooks/audit', { + method: 'POST', + body: JSON.stringify({ urls }), + }); + setWebhookUrls(urls); + showSuccess(getAuditWebhookSaveSuccessMessage()); + } catch (error) { + logger.error('[AuditWebhookPanel] Failed to save audit webhooks', error); + showWarning(getAuditWebhookSaveErrorMessage()); + } finally { + setSaving(false); + } + }; + + const handleAddWebhook = async () => { + if (!canManage()) return; + const url = newUrl().trim(); + if (!url) return; + + try { + new URL(url); + } catch { + showWarning(getAuditWebhookInvalidUrlMessage()); + return; + } + + if (webhookUrls().includes(url)) { + showWarning(getAuditWebhookDuplicateUrlMessage()); + return; + } + + await saveWebhooks([...webhookUrls(), url]); + setNewUrl(''); + }; + + const handleRemoveWebhook = async (url: string) => { + if (!canManage()) return; + await saveWebhooks(webhookUrls().filter((existingUrl) => existingUrl !== url)); + }; + + onMount(() => { + loadLicenseStatus(); + }); + + createEffect((wasPaywallVisible: boolean) => { + const isPaywallVisible = licenseLoaded() && !hasFeature('audit_logging'); + if (isPaywallVisible && !wasPaywallVisible) { + trackPaywallViewed('audit_logging', 'settings_audit_webhook_panel'); + } + return isPaywallVisible; + }, false); + + createEffect(() => { + if (hasFeature('audit_logging')) { + void fetchWebhooks(); + } else { + setLoading(false); + } + }); + + return { + canManage, + canStartTrial, + handleAddWebhook, + handleRemoveWebhook, + handleStartTrial, + isAuditLoggingEnabled, + loading, + newUrl, + saving, + setNewUrl, + startingTrial, + upgradeActionUrl, + webhookUrls, + }; +}; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 3d754bd26b..b2e7f750dd 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -290,6 +290,7 @@ const recoverySource = [ ].join('\n'); import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw'; import auditWebhookPanelSource from '@/components/Settings/AuditWebhookPanel.tsx?raw'; +import auditWebhookStateSource from '@/components/Settings/useAuditWebhookPanelState.ts?raw'; import auditWebhookPresentationSource from '@/utils/auditWebhookPresentation.ts?raw'; import auditLogPanelSource from '@/components/Settings/AuditLogPanel.tsx?raw'; import auditLogStateSource from '@/components/Settings/useAuditLogPanelState.ts?raw'; @@ -2627,6 +2628,7 @@ describe('frontend resource type boundaries', () => { expect(ssoProvidersModelSource).toContain('export const buildProviderPayload ='); expect(ssoProvidersModelSource).toContain('export const buildProviderTestPayload ='); expect(auditWebhookPanelSource).toContain('@/utils/auditWebhookPresentation'); + expect(auditWebhookPanelSource).toContain('@/components/Settings/useAuditWebhookPanelState'); expect(auditWebhookPanelSource).toContain('getAuditWebhookFeatureGateCopy'); expect(auditWebhookPanelSource).toContain('getAuditWebhookEmptyStateCopy'); expect(auditWebhookPanelSource).toContain('getAuditWebhookLoadingState'); @@ -2636,6 +2638,9 @@ describe('frontend resource type boundaries', () => { expect(auditWebhookPanelSource).not.toContain('No audit webhooks configured yet.'); expect(auditWebhookPanelSource).not.toContain('Loading audit webhooks…'); expect(auditWebhookPanelSource).not.toContain('Audit Webhooks (Pro)'); + expect(auditWebhookPanelSource).not.toContain('loadLicenseStatus();'); + expect(auditWebhookPanelSource).not.toContain('const fetchWebhooks = async () =>'); + expect(auditWebhookPanelSource).not.toContain('const saveWebhooks = async (urls: string[]) =>'); expect(auditWebhookPresentationSource).toContain( 'export function getAuditWebhookFeatureGateCopy', ); @@ -2643,6 +2648,25 @@ describe('frontend resource type boundaries', () => { 'export function getAuditWebhookEmptyStateCopy', ); expect(auditWebhookPresentationSource).toContain('export function getAuditWebhookLoadingState'); + expect(auditWebhookPresentationSource).toContain( + 'export function getAuditWebhookInvalidUrlMessage', + ); + expect(auditWebhookPresentationSource).toContain( + 'export function getAuditWebhookDuplicateUrlMessage', + ); + expect(auditWebhookPresentationSource).toContain( + 'export function getAuditWebhookSaveSuccessMessage', + ); + expect(auditWebhookPresentationSource).toContain( + 'export function getAuditWebhookSaveErrorMessage', + ); + expect(auditWebhookPresentationSource).toContain('AUDIT_WEBHOOK_SECURITY_NOTE_TITLE'); + expect(auditWebhookPresentationSource).toContain('AUDIT_WEBHOOK_SECURITY_NOTE_BODY'); + expect(auditWebhookStateSource).toContain('export const useAuditWebhookPanelState ='); + expect(auditWebhookStateSource).toContain('loadLicenseStatus();'); + expect(auditWebhookStateSource).toContain('trackPaywallViewed'); + expect(auditWebhookStateSource).toContain('const fetchWebhooks = async () =>'); + expect(auditWebhookStateSource).toContain('const saveWebhooks = async (urls: string[]) =>'); expect(auditLogPanelSource).toContain('getAuditLogLoadingState'); expect(auditLogPanelSource).toContain('getAuditLogEmptyState'); expect(auditLogPanelSource).toContain('@/components/Settings/useAuditLogPanelState'); diff --git a/frontend-modern/src/utils/auditWebhookPresentation.ts b/frontend-modern/src/utils/auditWebhookPresentation.ts index 2c2418299b..4e464d84b1 100644 --- a/frontend-modern/src/utils/auditWebhookPresentation.ts +++ b/frontend-modern/src/utils/auditWebhookPresentation.ts @@ -7,6 +7,11 @@ export const AUDIT_WEBHOOK_ENDPOINT_CARD_CLASS = export const AUDIT_WEBHOOK_ENDPOINT_ICON_CLASS = 'p-2 bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-300 rounded-md shrink-0'; +export const AUDIT_WEBHOOK_SECURITY_NOTE_TITLE = 'Security Note'; + +export const AUDIT_WEBHOOK_SECURITY_NOTE_BODY = + 'Audit webhooks are dispatched asynchronously to avoid blocking user operations. Endpoints should still verify source trust (for example via an ingest secret) before processing events.'; + export interface AuditWebhookFeatureGateCopy { title: string; body: string; @@ -34,3 +39,19 @@ export function getAuditWebhookLoadingState() { text: 'Loading audit webhooks…', } as const; } + +export function getAuditWebhookInvalidUrlMessage(): string { + return 'Please enter a valid URL'; +} + +export function getAuditWebhookDuplicateUrlMessage(): string { + return 'This URL is already configured'; +} + +export function getAuditWebhookSaveSuccessMessage(): string { + return 'Audit webhooks updated'; +} + +export function getAuditWebhookSaveErrorMessage(): string { + return 'Failed to save webhook configuration'; +}