diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 7e3857bd0..aeaa2776b 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -30,29 +30,30 @@ work extends shared components instead of creating new local variants. 8. `frontend-modern/src/components/Settings/AISettingsDialogs.tsx` 9. `frontend-modern/src/components/Settings/aiSettingsModel.ts` 10. `frontend-modern/src/components/Settings/AuditLogPanel.tsx` -11. `frontend-modern/src/components/Settings/AuditWebhookPanel.tsx` -12. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` -13. `frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx` -14. `frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx` -15. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` -16. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` -17. `frontend-modern/src/components/Settings/settingsHeaderMeta.ts` -18. `frontend-modern/src/components/Settings/SettingsPageShell.tsx` -19. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts` -20. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx` -21. `frontend-modern/src/components/Settings/useSSOProvidersState.ts` -22. `frontend-modern/src/components/Settings/ssoProvidersModel.ts` -23. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` -24. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts` -25. `tests/integration/tests/15-settings-shell-consistency.spec.ts` -26. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts` -27. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts` -28. `frontend-modern/src/features/` -29. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx` -30. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx` -31. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx` -32. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx` -33. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` +11. `frontend-modern/src/components/Settings/useAuditLogPanelState.ts` +12. `frontend-modern/src/components/Settings/AuditWebhookPanel.tsx` +13. `frontend-modern/src/components/Settings/GeneralSettingsPanel.tsx` +14. `frontend-modern/src/components/Settings/NetworkSettingsPanel.tsx` +15. `frontend-modern/src/components/Settings/RecoverySettingsPanel.tsx` +16. `frontend-modern/src/components/Settings/SecurityAuthPanel.tsx` +17. `frontend-modern/src/components/Settings/SecurityOverviewPanel.tsx` +18. `frontend-modern/src/components/Settings/settingsHeaderMeta.ts` +19. `frontend-modern/src/components/Settings/SettingsPageShell.tsx` +20. `frontend-modern/src/components/Settings/settingsPanelRegistry.ts` +21. `frontend-modern/src/components/Settings/SSOProvidersPanel.tsx` +22. `frontend-modern/src/components/Settings/useSSOProvidersState.ts` +23. `frontend-modern/src/components/Settings/ssoProvidersModel.ts` +24. `frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx` +25. `frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts` +26. `tests/integration/tests/15-settings-shell-consistency.spec.ts` +27. `frontend-modern/src/components/shared/PageControls.guardrails.test.ts` +28. `frontend-modern/src/components/shared/TypeColumn.guardrails.test.ts` +29. `frontend-modern/src/features/` +30. `frontend-modern/src/components/SetupWizard/SetupWizard.tsx` +31. `frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx` +32. `frontend-modern/src/components/SetupWizard/__tests__/SetupWizard.test.tsx` +33. `frontend-modern/src/components/SetupWizard/__tests__/SetupCompletionPreview.test.tsx` +34. `frontend-modern/src/components/shared/MonitoredSystemLimitWarningBanner.tsx` ## Shared Boundaries @@ -114,6 +115,14 @@ Those same surfaces also share the trimmed-string helper from `frontend-modern/src/utils/stringUtils.ts` so shared components do not keep their own copy of the same whitespace-trimming identity logic. +The audit log settings surface now follows that same owner split. +`frontend-modern/src/components/Settings/AuditLogPanel.tsx` stays the canonical +`SettingsPanel` shell, while +`frontend-modern/src/components/Settings/useAuditLogPanelState.ts` owns the +license/paywall lifecycle, persisted filters, verification flow, and audit-log +fetch orchestration. The shell must not re-accumulate localStorage or API +runtime logic inline. + General settings segmented selectors for theme preference and temperature unit must now also route through the shared `FilterButtonGroup` primitive instead of maintaining local button-group styling forks inside diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index b0faf4d72..c282fd8c5 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -1659,6 +1659,7 @@ "frontend-modern/src/components/Settings/ssoProvidersModel.ts", "frontend-modern/src/components/Settings/SSOProvidersPanel.tsx", "frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx", + "frontend-modern/src/components/Settings/useAuditLogPanelState.ts", "frontend-modern/src/components/Settings/useSSOProvidersState.ts", "frontend-modern/src/components/SetupWizard/SetupCompletionPreview.tsx", "frontend-modern/src/components/SetupWizard/SetupWizard.tsx" @@ -1716,6 +1717,7 @@ "frontend-modern/src/components/Settings/ssoProvidersModel.ts", "frontend-modern/src/components/Settings/SSOProvidersPanel.tsx", "frontend-modern/src/components/Settings/UpdatesSettingsPanel.tsx", + "frontend-modern/src/components/Settings/useAuditLogPanelState.ts", "frontend-modern/src/components/Settings/useSSOProvidersState.ts" ], "allow_same_subsystem_tests": false, diff --git a/frontend-modern/src/components/Settings/AuditLogPanel.tsx b/frontend-modern/src/components/Settings/AuditLogPanel.tsx index 625eb77c7..f812d858f 100644 --- a/frontend-modern/src/components/Settings/AuditLogPanel.tsx +++ b/frontend-modern/src/components/Settings/AuditLogPanel.tsx @@ -1,4 +1,4 @@ -import { createSignal, Show, For, onMount, createMemo, onCleanup, createEffect } from 'solid-js'; +import { For, Show } from 'solid-js'; import Shield from 'lucide-solid/icons/shield'; import RefreshCw from 'lucide-solid/icons/refresh-cw'; import Filter from 'lucide-solid/icons/filter'; @@ -10,23 +10,6 @@ import { showTooltip, hideTooltip } from '@/components/shared/Tooltip'; import Toggle from '@/components/shared/Toggle'; import SettingsPanel from '@/components/shared/SettingsPanel'; import { PulseDataGrid } from '@/components/shared/PulseDataGrid'; -import { - createLocalStorageBooleanSignal, - createLocalStorageNumberSignal, - createLocalStorageStringSignal, - STORAGE_KEYS, -} from '@/utils/localStorage'; -import { apiFetch } from '@/utils/apiClient'; -import { showSuccess, showWarning, showToast } from '@/utils/toast'; -import { - getUpgradeActionUrlOrFallback, - hasFeature, - loadLicenseStatus, - licenseLoaded, - entitlements, - startProTrial, -} from '@/stores/license'; -import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; import { AUDIT_REFRESH_BUTTON_CLASS, AUDIT_VERIFY_ALL_BUTTON_CLASS, @@ -39,552 +22,141 @@ import { getAuditVerificationBadgePresentation, } from '@/utils/auditLogPresentation'; import { - getProTrialStartedMessage, - getTrialAlreadyUsedMessage, - getTrialStartErrorMessage, getUpgradeActionButtonClass, UPGRADE_ACTION_LABEL, UPGRADE_TRIAL_LABEL, UPGRADE_TRIAL_LINK_CLASS, } from '@/utils/upgradePresentation'; - -interface AuditEvent { - id: string; - timestamp: string; - event: string; - user: string; - ip: string; - path: string; - success: boolean; - details: string; - signature?: string; -} - -interface AuditResponse { - events: AuditEvent[]; - total: number; - persistentLogging: boolean; -} - -interface VerifyResponse { - available: boolean; - verified?: boolean; - message?: string; -} - -type VerificationState = { - status: 'verified' | 'failed' | 'unavailable' | 'error'; - message: string; -}; +import { useAuditLogPanelState } from '@/components/Settings/useAuditLogPanelState'; export default function AuditLogPanel() { - const [events, setEvents] = createSignal([]); - const [totalEvents, setTotalEvents] = createSignal(0); - const [loading, setLoading] = createSignal(true); - const [error, setError] = createSignal(null); - const [isPersistent, setIsPersistent] = createSignal(false); - const [eventFilter, setEventFilter] = createLocalStorageStringSignal( - STORAGE_KEYS.AUDIT_EVENT_FILTER, - '', - ); - const [userFilter, setUserFilter] = createLocalStorageStringSignal( - STORAGE_KEYS.AUDIT_USER_FILTER, - '', - ); - const [successFilter, setSuccessFilter] = createLocalStorageStringSignal( - STORAGE_KEYS.AUDIT_SUCCESS_FILTER, - 'all', - ); - const [verificationFilter, setVerificationFilter] = createLocalStorageStringSignal( - STORAGE_KEYS.AUDIT_VERIFICATION_FILTER, - 'all', - ); - - const canStartTrial = () => entitlements()?.trial_eligible !== false; - const [startingTrial, setStartingTrial] = createSignal(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); - } - }; - - // Track when the in-panel Audit Logging paywall is actually shown. - // Note: settings tab gating is tracked separately via settingsFeatureGates. - createEffect((wasPaywallVisible) => { - const isPaywallVisible = licenseLoaded() && !hasFeature('audit_logging') && !loading(); - if (isPaywallVisible && !wasPaywallVisible) { - trackPaywallViewed('audit_logging', 'settings_audit_log_panel'); - } - return isPaywallVisible; - }, false); - const allowedVerificationFilters = new Set(['all', 'needs', 'verified', 'failed']); - const allowedSuccessFilters = new Set(['all', 'success', 'failed']); - const [pageSize, setPageSize] = createLocalStorageNumberSignal(STORAGE_KEYS.AUDIT_PAGE_SIZE, 100); - const [pageOffset, setPageOffset] = createLocalStorageNumberSignal( - STORAGE_KEYS.AUDIT_PAGE_OFFSET, - 0, - ); - const [verification, setVerification] = createSignal>({}); - const [verifying, setVerifying] = createSignal>({}); - const [verifyingAll, setVerifyingAll] = createSignal(false); - const [verifyAllTotal, setVerifyAllTotal] = createSignal(0); - const [verifyAllDone, setVerifyAllDone] = createSignal(0); - const [autoVerifyEnabled, setAutoVerifyEnabled] = createLocalStorageBooleanSignal( - STORAGE_KEYS.AUDIT_AUTO_VERIFY, - true, - ); - const [autoVerifyLimit, setAutoVerifyLimit] = createLocalStorageNumberSignal( - STORAGE_KEYS.AUDIT_AUTO_VERIFY_LIMIT, - 50, - ); - const [pageInput, setPageInput] = createSignal(''); - const [isMounted, setIsMounted] = createSignal(false); - const [cancelVerifyAll, setCancelVerifyAll] = createSignal(false); - const [verifyCanceled, setVerifyCanceled] = createSignal(false); - const [verifyControllers, setVerifyControllers] = createSignal>( - {}, - ); - const auditLoggingEnabled = createMemo(() => licenseLoaded() && hasFeature('audit_logging')); - - const fetchAuditEvents = async (options?: { limit?: number; offset?: number }) => { - if (!auditLoggingEnabled()) { - setEvents([]); - setTotalEvents(0); - setIsPersistent(false); - setError(null); - setLoading(false); - return; - } - - const limit = options?.limit ?? pageSize(); - const offset = options?.offset ?? pageOffset(); - - setLoading(true); - setError(null); - setVerification({}); - setVerifying({}); - setVerifyAllTotal(0); - setVerifyAllDone(0); - try { - const params = new URLSearchParams(); - params.set('limit', String(limit)); - params.set('offset', String(Math.max(0, offset))); - if (eventFilter()) params.set('event', eventFilter()); - if (userFilter()) params.set('user', userFilter()); - if (successFilter() === 'success') params.set('success', 'true'); - if (successFilter() === 'failed') params.set('success', 'false'); - - const response = await apiFetch(`/api/audit?${params.toString()}`); - if (response.status === 402) { - setEvents([]); - setTotalEvents(0); - setIsPersistent(false); - setError(null); - return; - } - if (!response.ok) { - throw new Error(`Failed to fetch audit events: ${response.statusText}`); - } - const data: AuditResponse = await response.json(); - setEvents(data.events || []); - setIsPersistent(data.persistentLogging); - setTotalEvents(data.total ?? 0); - if (data.total && offset >= data.total) { - const maxOffset = Math.max(0, Math.floor((data.total - 1) / limit) * limit); - if (maxOffset !== offset) { - setPageOffset(maxOffset); - void fetchAuditEvents({ limit, offset: maxOffset }); - return; - } - } - if (data.persistentLogging && autoVerifyEnabled()) { - const limit = autoVerifyLimit(); - if (limit <= 0) return; - setTimeout(() => { - if (!isMounted()) return; - void verifyAllEvents({ limit, showToast: false }); - }, 0); - } - } catch (err) { - const msg = err instanceof Error ? err.message : 'Unknown error'; - if (typeof msg === 'string' && /feature not included in license/i.test(msg)) { - setEvents([]); - setTotalEvents(0); - setIsPersistent(false); - setError(null); - return; - } - setError(msg); - showWarning('Audit Log Error', msg); - } finally { - setLoading(false); - } - }; - - const verifyEvent = async (event: AuditEvent) => { - if (!auditLoggingEnabled()) return; - if (!event.signature) return; - setVerifying((prev) => ({ ...prev, [event.id]: true })); - try { - const controller = new AbortController(); - setVerifyControllers((prev) => ({ ...prev, [event.id]: controller })); - if (!isMounted()) { - controller.abort(); - } - const response = await apiFetch(`/api/audit/${event.id}/verify`, { - signal: controller.signal, - }); - if (!response.ok) { - throw new Error(`Failed to verify signature: ${response.statusText}`); - } - const data: VerifyResponse = await response.json(); - let status: VerificationState['status'] = 'unavailable'; - if (!data.available) { - status = 'unavailable'; - } else if (data.verified) { - status = 'verified'; - } else { - status = 'failed'; - } - setVerification((prev) => ({ - ...prev, - [event.id]: { status, message: data.message || '' }, - })); - } catch (err) { - if ((err as { name?: string })?.name === 'AbortError') { - return; - } - setVerification((prev) => ({ - ...prev, - [event.id]: { - status: 'error', - message: err instanceof Error ? err.message : 'Unknown error', - }, - })); - } finally { - setVerifying((prev) => ({ ...prev, [event.id]: false })); - setVerifyControllers((prev) => { - const next = { ...prev }; - delete next[event.id]; - return next; - }); - } - }; - - const verifyAllEvents = async (options?: { - limit?: number; - showToast?: boolean; - resume?: boolean; - }) => { - if (!auditLoggingEnabled()) return; - const limit = options?.limit; - let signedEvents = events().filter((event) => event.signature); - if (options?.resume) { - signedEvents = signedEvents.filter((event) => { - const state = verification()[event.id]; - return !state || state.status === 'failed' || state.status === 'error'; - }); - } - if (limit !== undefined) { - signedEvents = signedEvents.slice(0, Math.max(0, limit)); - } - if (signedEvents.length === 0) return; - setVerifyingAll(true); - setVerifyCanceled(false); - setCancelVerifyAll(false); - setVerifyAllTotal(signedEvents.length); - setVerifyAllDone(0); - for (const event of signedEvents) { - if (cancelVerifyAll()) { - setVerifyingAll(false); - setVerifyCanceled(true); - if (options?.showToast) { - showToast('info', 'Signature verification canceled'); - } - const controllers = verifyControllers(); - for (const controller of Object.values(controllers)) { - controller.abort(); - } - return; - } - await verifyEvent(event); - setVerifyAllDone((prev) => prev + 1); - } - setVerifyingAll(false); - setVerifyCanceled(false); - - if (options?.showToast) { - let verified = 0; - let failed = 0; - let errors = 0; - let unavailable = 0; - - for (const event of signedEvents) { - const state = verification()[event.id]; - if (!state) { - continue; - } - switch (state.status) { - case 'verified': - verified += 1; - break; - case 'failed': - failed += 1; - break; - case 'error': - errors += 1; - break; - case 'unavailable': - unavailable += 1; - break; - default: - break; - } - } - - if (failed > 0 || errors > 0) { - showWarning( - 'Signature verification completed', - `Verified ${verified}, failed ${failed}, errors ${errors}, unavailable ${unavailable}.`, - ); - } else { - showSuccess('Signature verification completed', `Verified ${verified} events.`); - } - } - }; - - onMount(() => { - setIsMounted(true); - void loadLicenseStatus(); - }); - - createEffect(() => { - if (!licenseLoaded()) { - setLoading(true); - return; - } - if (!hasFeature('audit_logging')) { - setEvents([]); - setTotalEvents(0); - setIsPersistent(false); - setError(null); - setLoading(false); - return; - } - void fetchAuditEvents(); - }); - - createEffect(() => { - const current = verificationFilter(); - if (!allowedVerificationFilters.has(current)) { - setVerificationFilter('all'); - } - }); - - createEffect(() => { - const current = successFilter(); - if (!allowedSuccessFilters.has(current)) { - setSuccessFilter('all'); - } - }); - - onCleanup(() => { - setIsMounted(false); - setCancelVerifyAll(true); - const controllers = verifyControllers(); - for (const controller of Object.values(controllers)) { - controller.abort(); - } - }); + const { + activeFilterChips, + activeFilterCount, + applyFilters, + auditLoggingEnabled, + autoVerifyEnabled, + autoVerifyLimit, + canStartTrial, + cancelVerification, + clearFilterChip, + clearFilters, + error, + eventFilter, + filteredEvents, + goToFirstPage, + goToLastPage, + goToNextPage, + goToPreviousPage, + handleStartTrial, + handleUpgradeClick, + hasNextPage, + hasResumeEvents, + hasSignedEvents, + isPersistent, + loading, + pageInput, + pageNumber, + pageRangeText, + pageSize, + refresh, + resumeCount, + resumeVerification, + resetPreferences, + setAutoVerifyEnabled, + setAutoVerifyLimit, + setEventFilter, + setPageInput, + setPageSize, + setSuccessFilter, + setUserFilter, + setVerificationFilter, + showUpgradePaywall, + startingTrial, + submitPageInput, + successFilter, + totalEvents, + totalPages, + upgradeActionUrl, + verification, + verificationFilter, + verificationSummary, + verifyAll, + verifyAllLabel, + verifyCanceled, + verifyEvent, + verifying, + verifyingAll, + userFilter, + } = useAuditLogPanelState(); const formatTimestamp = (ts: string) => { const date = new Date(ts); return date.toLocaleString(); }; - const hasSignedEvents = () => events().some((event) => event.signature); - const hasResumeEvents = () => - events().some((event) => { - if (!event.signature) return false; - const state = verification()[event.id]; - return !state || state.status === 'failed' || state.status === 'error'; - }); - const resumeCount = () => - events().filter((event) => { - if (!event.signature) return false; - const state = verification()[event.id]; - return !state || state.status === 'failed' || state.status === 'error'; - }).length; - const verifyAllLabel = () => { - if (!verifyingAll()) return 'Verify All'; - if (verifyAllTotal() === 0) return 'Verifying…'; - return `Verifying ${verifyAllDone()} of ${verifyAllTotal()}`; - }; - const hasNextPage = () => events().length === pageSize(); - const pageNumber = () => Math.floor(pageOffset() / pageSize()) + 1; - const totalPages = () => Math.max(1, Math.ceil(totalEvents() / pageSize())); - const pageRangeText = () => { - if (totalEvents() === 0) return 'Showing 0 of 0'; - const start = pageOffset() + 1; - const end = Math.min(totalEvents(), pageOffset() + events().length); - return `Showing ${start}-${end} of ${totalEvents()}`; - }; - - const verificationSummary = createMemo(() => { - const summary = { - total: events().length, - signed: 0, - verified: 0, - failed: 0, - error: 0, - unavailable: 0, - unchecked: 0, - }; - - for (const event of events()) { - if (!event.signature) continue; - summary.signed += 1; - const state = verification()[event.id]; - if (!state) { - summary.unchecked += 1; - continue; - } - switch (state.status) { - case 'verified': - summary.verified += 1; - break; - case 'failed': - summary.failed += 1; - break; - case 'error': - summary.error += 1; - break; - case 'unavailable': - summary.unavailable += 1; - break; - default: - summary.unchecked += 1; - } - } - - return summary; - }); - - const activeFilterCount = () => { - let count = 0; - if (eventFilter()) count += 1; - if (userFilter()) count += 1; - if (successFilter() !== 'all') count += 1; - if (verificationFilter() !== 'all') count += 1; - return count; - }; - - const activeFilterChips = () => { - const chips: { label: string; key: 'event' | 'user' | 'success' | 'verification' }[] = []; - if (eventFilter()) chips.push({ label: `Event: ${eventFilter()}`, key: 'event' }); - if (userFilter()) chips.push({ label: `User: ${userFilter()}`, key: 'user' }); - if (successFilter() !== 'all') - chips.push({ label: `Success: ${successFilter()}`, key: 'success' }); - if (verificationFilter() !== 'all') - chips.push({ label: `Verification: ${verificationFilter()}`, key: 'verification' }); - return chips; - }; - - const filteredEvents = createMemo(() => { - const filter = verificationFilter(); - if (filter === 'all') return events(); - return events().filter((event) => { - if (!event.signature) return false; - const state = verification()[event.id]; - if (!state) { - return filter === 'needs'; - } - if (filter === 'verified') return state.status === 'verified'; - if (filter === 'failed') return state.status === 'failed' || state.status === 'error'; - return false; - }); - }); - return ( } - noPadding - bodyClass="space-y-6 p-4 sm:p-6" - action={ -
- - - - - - Verification canceled - -
- } - > - {/* Upgrade CTA */} - + title="Audit Log" + description="Persistent, searchable audit events with optional signature verification." + icon={} + noPadding + bodyClass="space-y-6 p-4 sm:p-6" + action={ +
+ + + + + + Verification canceled + +
+ } + > +
@@ -595,11 +167,11 @@ export default function AuditLogPanel() {
-
+
- {/* Filters */} - +
@@ -673,27 +244,13 @@ export default function AuditLogPanel() {
-
+
- {/* Error State */} - +
{error()}
-
+
- {/* Loading State */} - +
{getAuditLogLoadingState().text}
-
+
- {/* Events Table */} - 0}> + 0}>
Total: {totalEvents()} Signed: {verificationSummary().signed} @@ -937,7 +472,7 @@ export default function AuditLogPanel() { Verifying…
-
+
- {/* Empty State */} - +
0}>
-
+
- {/* Pagination */} - +
-
-
+ + ); } diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 915011ae1..debc275d2 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -12,6 +12,7 @@ import infrastructureOperationsStateSource from '../useInfrastructureOperationsS import nodeModalStateSource from '../useNodeModalState.ts?raw'; 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 billingAdminPanelSource from '../BillingAdminPanel.tsx?raw'; import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw'; @@ -50,6 +51,7 @@ const extractedModules = [ '../InfrastructureOperationsController.tsx', '../infrastructureOperationsModel.tsx', '../useInfrastructureOperationsState.tsx', + '../useAuditLogPanelState.ts', '../NodeModal.tsx', '../nodeModalModel.ts', '../useNodeModalState.ts', @@ -347,6 +349,19 @@ describe('Settings architecture guardrails', () => { expect(aiSettingsModelSource).toContain('export const AI_SETUP_PROVIDER_OPTIONS'); }); + it('keeps the audit log shell behind an extracted runtime owner', () => { + expect(auditLogPanelSource).toContain('@/components/Settings/useAuditLogPanelState'); + expect(auditLogPanelSource).not.toContain('createLocalStorageStringSignal'); + expect(auditLogPanelSource).not.toContain('const fetchAuditEvents = async ('); + expect(auditLogPanelSource).not.toContain('const verifyAllEvents = async ('); + expect(auditLogPanelSource).not.toContain('trackPaywallViewed'); + expect(auditLogStateSource).toContain('export const useAuditLogPanelState ='); + expect(auditLogStateSource).toContain('createLocalStorageStringSignal'); + expect(auditLogStateSource).toContain('const fetchAuditEvents = async ('); + expect(auditLogStateSource).toContain('const verifyAllEvents = async ('); + expect(auditLogStateSource).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/useAuditLogPanelState.ts b/frontend-modern/src/components/Settings/useAuditLogPanelState.ts new file mode 100644 index 000000000..c54a8a818 --- /dev/null +++ b/frontend-modern/src/components/Settings/useAuditLogPanelState.ts @@ -0,0 +1,664 @@ +import { createEffect, createMemo, createSignal, onCleanup, onMount, untrack } from 'solid-js'; +import { + createLocalStorageBooleanSignal, + createLocalStorageNumberSignal, + createLocalStorageStringSignal, + STORAGE_KEYS, +} from '@/utils/localStorage'; +import { apiFetch } from '@/utils/apiClient'; +import { showSuccess, showToast, showWarning } from '@/utils/toast'; +import { + entitlements, + getUpgradeActionUrlOrFallback, + hasFeature, + licenseLoaded, + loadLicenseStatus, + startProTrial, +} from '@/stores/license'; +import { trackPaywallViewed, trackUpgradeClicked } from '@/utils/upgradeMetrics'; +import { + getProTrialStartedMessage, + getTrialAlreadyUsedMessage, + getTrialStartErrorMessage, +} from '@/utils/upgradePresentation'; + +export interface AuditEvent { + id: string; + timestamp: string; + event: string; + user: string; + ip: string; + path: string; + success: boolean; + details: string; + signature?: string; +} + +interface AuditResponse { + events: AuditEvent[]; + total: number; + persistentLogging: boolean; +} + +interface VerifyResponse { + available: boolean; + verified?: boolean; + message?: string; +} + +type VerificationStatus = 'verified' | 'failed' | 'unavailable' | 'error'; + +export type VerificationState = { + status: VerificationStatus; + message: string; +}; + +type AuditFilterChipKey = 'event' | 'user' | 'success' | 'verification'; + +type AuditFilterChip = { + label: string; + key: AuditFilterChipKey; +}; + +type VerifyAllOptions = { + limit?: number; + showToast?: boolean; + resume?: boolean; +}; + +const ALLOWED_VERIFICATION_FILTERS = new Set(['all', 'needs', 'verified', 'failed']); +const ALLOWED_SUCCESS_FILTERS = new Set(['all', 'success', 'failed']); + +export const useAuditLogPanelState = () => { + const [events, setEvents] = createSignal([]); + const [totalEvents, setTotalEvents] = createSignal(0); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(null); + const [isPersistent, setIsPersistent] = createSignal(false); + const [eventFilter, setEventFilter] = createLocalStorageStringSignal( + STORAGE_KEYS.AUDIT_EVENT_FILTER, + '', + ); + const [userFilter, setUserFilter] = createLocalStorageStringSignal( + STORAGE_KEYS.AUDIT_USER_FILTER, + '', + ); + const [successFilter, setSuccessFilter] = createLocalStorageStringSignal( + STORAGE_KEYS.AUDIT_SUCCESS_FILTER, + 'all', + ); + const [verificationFilter, setVerificationFilter] = createLocalStorageStringSignal( + STORAGE_KEYS.AUDIT_VERIFICATION_FILTER, + 'all', + ); + const [pageSize, setPageSize] = createLocalStorageNumberSignal(STORAGE_KEYS.AUDIT_PAGE_SIZE, 100); + const [pageOffset, setPageOffset] = createLocalStorageNumberSignal( + STORAGE_KEYS.AUDIT_PAGE_OFFSET, + 0, + ); + const [verification, setVerification] = createSignal>({}); + const [verifying, setVerifying] = createSignal>({}); + const [verifyingAll, setVerifyingAll] = createSignal(false); + const [verifyAllTotal, setVerifyAllTotal] = createSignal(0); + const [verifyAllDone, setVerifyAllDone] = createSignal(0); + const [autoVerifyEnabled, setAutoVerifyEnabled] = createLocalStorageBooleanSignal( + STORAGE_KEYS.AUDIT_AUTO_VERIFY, + true, + ); + const [autoVerifyLimit, setAutoVerifyLimit] = createLocalStorageNumberSignal( + STORAGE_KEYS.AUDIT_AUTO_VERIFY_LIMIT, + 50, + ); + const [pageInput, setPageInput] = createSignal(''); + const [isMounted, setIsMounted] = createSignal(false); + const [cancelVerifyAll, setCancelVerifyAll] = createSignal(false); + const [verifyCanceled, setVerifyCanceled] = createSignal(false); + const [verifyControllers, setVerifyControllers] = createSignal>( + {}, + ); + const [startingTrial, setStartingTrial] = createSignal(false); + + const auditLoggingEnabled = createMemo(() => licenseLoaded() && hasFeature('audit_logging')); + const showUpgradePaywall = createMemo( + () => licenseLoaded() && !auditLoggingEnabled() && !loading(), + ); + const canStartTrial = () => entitlements()?.trial_eligible !== false; + const upgradeActionUrl = createMemo(() => getUpgradeActionUrlOrFallback('audit_logging')); + + const fetchAuditEvents = async (options?: { limit?: number; offset?: number }) => { + if (!auditLoggingEnabled()) { + setEvents([]); + setTotalEvents(0); + setIsPersistent(false); + setError(null); + setLoading(false); + return; + } + + const limit = options?.limit ?? pageSize(); + const offset = options?.offset ?? pageOffset(); + + setLoading(true); + setError(null); + setVerification({}); + setVerifying({}); + setVerifyAllTotal(0); + setVerifyAllDone(0); + + try { + const params = new URLSearchParams(); + params.set('limit', String(limit)); + params.set('offset', String(Math.max(0, offset))); + if (eventFilter()) params.set('event', eventFilter()); + if (userFilter()) params.set('user', userFilter()); + if (successFilter() === 'success') params.set('success', 'true'); + if (successFilter() === 'failed') params.set('success', 'false'); + + const response = await apiFetch(`/api/audit?${params.toString()}`); + if (response.status === 402) { + setEvents([]); + setTotalEvents(0); + setIsPersistent(false); + setError(null); + return; + } + if (!response.ok) { + throw new Error(`Failed to fetch audit events: ${response.statusText}`); + } + + const data: AuditResponse = await response.json(); + setEvents(data.events || []); + setIsPersistent(data.persistentLogging); + setTotalEvents(data.total ?? 0); + + if (data.total && offset >= data.total) { + const maxOffset = Math.max(0, Math.floor((data.total - 1) / limit) * limit); + if (maxOffset !== offset) { + setPageOffset(maxOffset); + void fetchAuditEvents({ limit, offset: maxOffset }); + return; + } + } + + if (data.persistentLogging && autoVerifyEnabled()) { + const verificationLimit = autoVerifyLimit(); + if (verificationLimit <= 0) return; + setTimeout(() => { + if (!isMounted()) return; + void verifyAllEvents({ limit: verificationLimit, showToast: false }); + }, 0); + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + if (typeof message === 'string' && /feature not included in license/i.test(message)) { + setEvents([]); + setTotalEvents(0); + setIsPersistent(false); + setError(null); + return; + } + setError(message); + showWarning('Audit Log Error', message); + } finally { + setLoading(false); + } + }; + + const verifyEvent = async (event: AuditEvent) => { + if (!auditLoggingEnabled() || !event.signature) return; + + setVerifying((prev) => ({ ...prev, [event.id]: true })); + + try { + const controller = new AbortController(); + setVerifyControllers((prev) => ({ ...prev, [event.id]: controller })); + if (!isMounted()) { + controller.abort(); + } + + const response = await apiFetch(`/api/audit/${event.id}/verify`, { + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Failed to verify signature: ${response.statusText}`); + } + + const data: VerifyResponse = await response.json(); + let status: VerificationStatus = 'unavailable'; + if (!data.available) { + status = 'unavailable'; + } else if (data.verified) { + status = 'verified'; + } else { + status = 'failed'; + } + + setVerification((prev) => ({ + ...prev, + [event.id]: { status, message: data.message || '' }, + })); + } catch (err) { + if ((err as { name?: string })?.name === 'AbortError') { + return; + } + + setVerification((prev) => ({ + ...prev, + [event.id]: { + status: 'error', + message: err instanceof Error ? err.message : 'Unknown error', + }, + })); + } finally { + setVerifying((prev) => ({ ...prev, [event.id]: false })); + setVerifyControllers((prev) => { + const next = { ...prev }; + delete next[event.id]; + return next; + }); + } + }; + + const verifyAllEvents = async (options?: VerifyAllOptions) => { + if (!auditLoggingEnabled()) return; + + const limit = options?.limit; + let signedEvents = events().filter((event) => event.signature); + if (options?.resume) { + signedEvents = signedEvents.filter((event) => { + const state = verification()[event.id]; + return !state || state.status === 'failed' || state.status === 'error'; + }); + } + if (limit !== undefined) { + signedEvents = signedEvents.slice(0, Math.max(0, limit)); + } + if (signedEvents.length === 0) return; + + setVerifyingAll(true); + setVerifyCanceled(false); + setCancelVerifyAll(false); + setVerifyAllTotal(signedEvents.length); + setVerifyAllDone(0); + + for (const event of signedEvents) { + if (cancelVerifyAll()) { + setVerifyingAll(false); + setVerifyCanceled(true); + if (options?.showToast) { + showToast('info', 'Signature verification canceled'); + } + for (const controller of Object.values(verifyControllers())) { + controller.abort(); + } + return; + } + + await verifyEvent(event); + setVerifyAllDone((prev) => prev + 1); + } + + setVerifyingAll(false); + setVerifyCanceled(false); + + if (options?.showToast) { + let verified = 0; + let failed = 0; + let errors = 0; + let unavailable = 0; + + for (const event of signedEvents) { + const state = verification()[event.id]; + if (!state) continue; + switch (state.status) { + case 'verified': + verified += 1; + break; + case 'failed': + failed += 1; + break; + case 'error': + errors += 1; + break; + case 'unavailable': + unavailable += 1; + break; + default: + break; + } + } + + if (failed > 0 || errors > 0) { + showWarning( + 'Signature verification completed', + `Verified ${verified}, failed ${failed}, errors ${errors}, unavailable ${unavailable}.`, + ); + } else { + showSuccess('Signature verification completed', `Verified ${verified} events.`); + } + } + }; + + const hasSignedEvents = () => events().some((event) => event.signature); + const hasResumeEvents = () => + events().some((event) => { + if (!event.signature) return false; + const state = verification()[event.id]; + return !state || state.status === 'failed' || state.status === 'error'; + }); + const resumeCount = () => + events().filter((event) => { + if (!event.signature) return false; + const state = verification()[event.id]; + return !state || state.status === 'failed' || state.status === 'error'; + }).length; + const verifyAllLabel = () => { + if (!verifyingAll()) return 'Verify All'; + if (verifyAllTotal() === 0) return 'Verifying…'; + return `Verifying ${verifyAllDone()} of ${verifyAllTotal()}`; + }; + const hasNextPage = () => pageNumber() < totalPages(); + const pageNumber = () => Math.floor(pageOffset() / pageSize()) + 1; + const totalPages = () => Math.max(1, Math.ceil(totalEvents() / pageSize())); + const pageRangeText = () => { + if (totalEvents() === 0) return 'Showing 0 of 0'; + const start = pageOffset() + 1; + const end = Math.min(totalEvents(), pageOffset() + events().length); + return `Showing ${start}-${end} of ${totalEvents()}`; + }; + + const verificationSummary = createMemo(() => { + const summary = { + total: events().length, + signed: 0, + verified: 0, + failed: 0, + error: 0, + unavailable: 0, + unchecked: 0, + }; + + for (const event of events()) { + if (!event.signature) continue; + summary.signed += 1; + const state = verification()[event.id]; + if (!state) { + summary.unchecked += 1; + continue; + } + switch (state.status) { + case 'verified': + summary.verified += 1; + break; + case 'failed': + summary.failed += 1; + break; + case 'error': + summary.error += 1; + break; + case 'unavailable': + summary.unavailable += 1; + break; + default: + summary.unchecked += 1; + } + } + + return summary; + }); + + const activeFilterCount = () => { + let count = 0; + if (eventFilter()) count += 1; + if (userFilter()) count += 1; + if (successFilter() !== 'all') count += 1; + if (verificationFilter() !== 'all') count += 1; + return count; + }; + + const activeFilterChips = createMemo(() => { + const chips: AuditFilterChip[] = []; + if (eventFilter()) chips.push({ label: `Event: ${eventFilter()}`, key: 'event' }); + if (userFilter()) chips.push({ label: `User: ${userFilter()}`, key: 'user' }); + if (successFilter() !== 'all') { + chips.push({ label: `Success: ${successFilter()}`, key: 'success' }); + } + if (verificationFilter() !== 'all') { + chips.push({ label: `Verification: ${verificationFilter()}`, key: 'verification' }); + } + return chips; + }); + + const filteredEvents = createMemo(() => { + const filter = verificationFilter(); + if (filter === 'all') return events(); + return events().filter((event) => { + if (!event.signature) return false; + const state = verification()[event.id]; + if (!state) { + return filter === 'needs'; + } + if (filter === 'verified') return state.status === 'verified'; + if (filter === 'failed') return state.status === 'failed' || state.status === 'error'; + return false; + }); + }); + + const resetPaging = () => { + setPageOffset(0); + setPageInput(''); + }; + + const applyFilters = () => { + resetPaging(); + void fetchAuditEvents({ offset: 0 }); + }; + + const clearFilters = () => { + const hadFilters = activeFilterCount() > 0; + setEventFilter(''); + setUserFilter(''); + setSuccessFilter('all'); + setVerificationFilter('all'); + resetPaging(); + void fetchAuditEvents({ offset: 0 }); + if (hadFilters) { + showSuccess('Audit filters cleared'); + } + }; + + const clearFilterChip = (key: AuditFilterChipKey) => { + if (key === 'event') setEventFilter(''); + if (key === 'user') setUserFilter(''); + if (key === 'success') setSuccessFilter('all'); + if (key === 'verification') setVerificationFilter('all'); + resetPaging(); + void fetchAuditEvents({ offset: 0 }); + }; + + const resetPreferences = () => { + setAutoVerifyEnabled(true); + setAutoVerifyLimit(50); + setPageSize(100); + resetPaging(); + showSuccess('Audit preferences reset'); + }; + + const goToOffset = (offset: number) => { + const maxOffset = Math.max(0, (totalPages() - 1) * pageSize()); + const nextOffset = Math.min(maxOffset, Math.max(0, offset)); + setPageOffset(nextOffset); + void fetchAuditEvents({ offset: nextOffset }); + }; + + const submitPageInput = () => { + const parsed = Number(pageInput()); + if (!Number.isFinite(parsed)) return; + const clamped = Math.max(1, Math.min(totalPages(), Math.floor(parsed))); + goToOffset((clamped - 1) * pageSize()); + }; + + const refresh = () => { + void fetchAuditEvents(); + }; + + const verifyAll = () => { + void verifyAllEvents({ showToast: true }); + }; + + const resumeVerification = () => { + void verifyAllEvents({ showToast: true, resume: true }); + }; + + const cancelVerification = () => { + setCancelVerifyAll(true); + }; + + const goToFirstPage = () => goToOffset(0); + const goToPreviousPage = () => goToOffset(pageOffset() - pageSize()); + const goToNextPage = () => goToOffset(pageOffset() + pageSize()); + const goToLastPage = () => goToOffset((totalPages() - 1) * pageSize()); + + const handleUpgradeClick = () => { + trackUpgradeClicked('settings_audit_log_panel', '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 (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); + } + }; + + createEffect((wasPaywallVisible) => { + const isPaywallVisible = showUpgradePaywall(); + if (isPaywallVisible && !wasPaywallVisible) { + trackPaywallViewed('audit_logging', 'settings_audit_log_panel'); + } + return isPaywallVisible; + }, false); + + onMount(() => { + setIsMounted(true); + void loadLicenseStatus(); + }); + + createEffect(() => { + if (!licenseLoaded()) { + setLoading(true); + return; + } + if (!hasFeature('audit_logging')) { + setEvents([]); + setTotalEvents(0); + setIsPersistent(false); + setError(null); + setLoading(false); + return; + } + untrack(() => { + void fetchAuditEvents(); + }); + }); + + createEffect(() => { + const current = verificationFilter(); + if (!ALLOWED_VERIFICATION_FILTERS.has(current)) { + setVerificationFilter('all'); + } + }); + + createEffect(() => { + const current = successFilter(); + if (!ALLOWED_SUCCESS_FILTERS.has(current)) { + setSuccessFilter('all'); + } + }); + + onCleanup(() => { + setIsMounted(false); + setCancelVerifyAll(true); + for (const controller of Object.values(verifyControllers())) { + controller.abort(); + } + }); + + return { + activeFilterChips, + activeFilterCount, + applyFilters, + auditLoggingEnabled, + autoVerifyEnabled, + autoVerifyLimit, + canStartTrial, + cancelVerification, + clearFilterChip, + clearFilters, + error, + eventFilter, + events, + filteredEvents, + goToFirstPage, + goToLastPage, + goToNextPage, + goToPreviousPage, + handleStartTrial, + handleUpgradeClick, + hasNextPage, + hasResumeEvents, + hasSignedEvents, + isPersistent, + loading, + pageInput, + pageNumber, + pageOffset, + pageRangeText, + pageSize, + refresh, + resumeCount, + resumeVerification, + resetPreferences, + setAutoVerifyEnabled, + setAutoVerifyLimit, + setEventFilter, + setPageInput, + setPageSize, + setSuccessFilter, + setUserFilter, + setVerificationFilter, + showUpgradePaywall, + startingTrial, + submitPageInput, + successFilter, + totalEvents, + totalPages, + upgradeActionUrl, + verification, + verificationFilter, + verificationSummary, + verifyAll, + verifyAllDone, + verifyAllLabel, + verifyAllTotal, + verifyCanceled, + verifyEvent, + verifying, + verifyingAll, + userFilter, + }; +}; diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 8ad240100..8f177fe85 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -283,6 +283,7 @@ import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw'; import auditWebhookPanelSource from '@/components/Settings/AuditWebhookPanel.tsx?raw'; import auditWebhookPresentationSource from '@/utils/auditWebhookPresentation.ts?raw'; import auditLogPanelSource from '@/components/Settings/AuditLogPanel.tsx?raw'; +import auditLogStateSource from '@/components/Settings/useAuditLogPanelState.ts?raw'; import auditLogPresentationSource from '@/utils/auditLogPresentation.ts?raw'; import ssoProvidersPanelSource from '@/components/Settings/SSOProvidersPanel.tsx?raw'; import ssoProvidersStateSource from '@/components/Settings/useSSOProvidersState.ts?raw'; @@ -2635,9 +2636,19 @@ describe('frontend resource type boundaries', () => { expect(auditWebhookPresentationSource).toContain('export function getAuditWebhookLoadingState'); expect(auditLogPanelSource).toContain('getAuditLogLoadingState'); expect(auditLogPanelSource).toContain('getAuditLogEmptyState'); + expect(auditLogPanelSource).toContain('@/components/Settings/useAuditLogPanelState'); expect(auditLogPanelSource).not.toContain('No audit events found'); + expect(auditLogPanelSource).not.toContain('createLocalStorageStringSignal'); + expect(auditLogPanelSource).not.toContain('const fetchAuditEvents = async ('); + expect(auditLogPanelSource).not.toContain('const verifyAllEvents = async ('); + expect(auditLogPanelSource).not.toContain('trackPaywallViewed'); expect(auditLogPresentationSource).toContain('export function getAuditLogLoadingState'); expect(auditLogPresentationSource).toContain('export function getAuditLogEmptyState'); + expect(auditLogStateSource).toContain('export const useAuditLogPanelState ='); + expect(auditLogStateSource).toContain('createLocalStorageStringSignal'); + expect(auditLogStateSource).toContain('const fetchAuditEvents = async ('); + expect(auditLogStateSource).toContain('const verifyAllEvents = async ('); + expect(auditLogStateSource).toContain('trackPaywallViewed'); expect(auditLogPanelSource).toContain('getAuditEventTypeBadgeClass'); expect(auditLogPanelSource).toContain('getAuditVerificationBadgePresentation'); expect(auditLogPanelSource).toContain('getAuditEventStatusPresentation');