Extract audit webhook runtime owner

This commit is contained in:
rcourtman
2026-03-20 22:00:43 +00:00
parent 68bae84bcf
commit 27a1d51c86
7 changed files with 249 additions and 132 deletions
@@ -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
@@ -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"
],
@@ -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<AuditWebhookPanelProps> = (props) => {
const [webhookUrls, setWebhookUrls] = createSignal<string[]>([]);
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 (
<SettingsPanel
title="Audit Webhooks"
@@ -168,7 +67,7 @@ export const AuditWebhookPanel: Component<AuditWebhookPanelProps> = (props) => {
</div>
<div class="flex flex-col sm:flex-row items-center gap-2">
<a
href={getUpgradeActionUrlOrFallback('audit_logging')}
href={upgradeActionUrl()}
target="_blank"
rel="noopener noreferrer"
class={getUpgradeActionButtonClass()}
@@ -279,12 +178,10 @@ export const AuditWebhookPanel: Component<AuditWebhookPanelProps> = (props) => {
</div>
<div>
<h3 class="text-base font-semibold text-amber-900 dark:text-amber-100 mb-1.5">
Security Note
{AUDIT_WEBHOOK_SECURITY_NOTE_TITLE}
</h3>
<p class="text-sm text-amber-800 dark:text-amber-200 leading-relaxed">
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}
</p>
</div>
</div>
@@ -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 () =>');
@@ -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<string[]>([]);
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,
};
};
@@ -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');
@@ -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';
}