mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-07 13:21:22 +00:00
Extract audit log settings state owner
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AuditEvent[]>([]);
|
||||
const [totalEvents, setTotalEvents] = createSignal(0);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(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<Record<string, VerificationState>>({});
|
||||
const [verifying, setVerifying] = createSignal<Record<string, boolean>>({});
|
||||
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<Record<string, AbortController>>(
|
||||
{},
|
||||
);
|
||||
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 (
|
||||
<SettingsPanel
|
||||
title="Audit Log"
|
||||
description="Persistent, searchable audit events with optional signature verification."
|
||||
icon={<Shield class="w-5 h-5" strokeWidth={2} />}
|
||||
noPadding
|
||||
bodyClass="space-y-6 p-4 sm:p-6"
|
||||
action={
|
||||
<div class="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
||||
<button
|
||||
onClick={() => fetchAuditEvents()}
|
||||
disabled={loading() || !auditLoggingEnabled()}
|
||||
class={AUDIT_REFRESH_BUTTON_CLASS}
|
||||
>
|
||||
<RefreshCw class={`w-4 h-4 ${loading() ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => verifyAllEvents({ showToast: true })}
|
||||
disabled={!isPersistent() || loading() || verifyingAll() || !hasSignedEvents()}
|
||||
class={AUDIT_VERIFY_ALL_BUTTON_CLASS}
|
||||
>
|
||||
{(() => {
|
||||
const presentation = getAuditEventStatusPresentation(true);
|
||||
const Icon = presentation.icon;
|
||||
return <Icon class="w-4 h-4" />;
|
||||
})()}
|
||||
{verifyAllLabel()}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCancelVerifyAll(true)}
|
||||
disabled={!verifyingAll()}
|
||||
class={`${AUDIT_TOOLBAR_BUTTON_CLASS} text-muted`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => verifyAllEvents({ showToast: true, resume: true })}
|
||||
disabled={verifyingAll() || !hasResumeEvents()}
|
||||
class={`${AUDIT_TOOLBAR_BUTTON_CLASS} text-muted`}
|
||||
onMouseEnter={(e) => {
|
||||
if (!hasResumeEvents()) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
showTooltip(
|
||||
'Retries failed, error, or unchecked signatures on this page.',
|
||||
rect.left + rect.width / 2,
|
||||
rect.top,
|
||||
{ align: 'center', direction: 'up', maxWidth: 260 },
|
||||
);
|
||||
}}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
>
|
||||
<Play class="w-4 h-4" />
|
||||
Resume{resumeCount() > 0 ? ` (${resumeCount()})` : ''}
|
||||
</button>
|
||||
<Show when={verifyCanceled() && !verifyingAll()}>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">Verification canceled</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Upgrade CTA */}
|
||||
<Show when={licenseLoaded() && !hasFeature('audit_logging') && !loading()}>
|
||||
title="Audit Log"
|
||||
description="Persistent, searchable audit events with optional signature verification."
|
||||
icon={<Shield class="w-5 h-5" strokeWidth={2} />}
|
||||
noPadding
|
||||
bodyClass="space-y-6 p-4 sm:p-6"
|
||||
action={
|
||||
<div class="flex w-full flex-wrap items-center gap-2 sm:w-auto">
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={loading() || !auditLoggingEnabled()}
|
||||
class={AUDIT_REFRESH_BUTTON_CLASS}
|
||||
>
|
||||
<RefreshCw class={`w-4 h-4 ${loading() ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={verifyAll}
|
||||
disabled={!isPersistent() || loading() || verifyingAll() || !hasSignedEvents()}
|
||||
class={AUDIT_VERIFY_ALL_BUTTON_CLASS}
|
||||
>
|
||||
{(() => {
|
||||
const presentation = getAuditEventStatusPresentation(true);
|
||||
const Icon = presentation.icon;
|
||||
return <Icon class="w-4 h-4" />;
|
||||
})()}
|
||||
{verifyAllLabel()}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelVerification}
|
||||
disabled={!verifyingAll()}
|
||||
class={`${AUDIT_TOOLBAR_BUTTON_CLASS} text-muted`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={resumeVerification}
|
||||
disabled={verifyingAll() || !hasResumeEvents()}
|
||||
class={`${AUDIT_TOOLBAR_BUTTON_CLASS} text-muted`}
|
||||
onMouseEnter={(e) => {
|
||||
if (!hasResumeEvents()) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
showTooltip(
|
||||
'Retries failed, error, or unchecked signatures on this page.',
|
||||
rect.left + rect.width / 2,
|
||||
rect.top,
|
||||
{ align: 'center', direction: 'up', maxWidth: 260 },
|
||||
);
|
||||
}}
|
||||
onMouseLeave={() => hideTooltip()}
|
||||
>
|
||||
<Play class="w-4 h-4" />
|
||||
Resume{resumeCount() > 0 ? ` (${resumeCount()})` : ''}
|
||||
</button>
|
||||
<Show when={verifyCanceled() && !verifyingAll()}>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">Verification canceled</span>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={showUpgradePaywall()}>
|
||||
<div class="p-6 bg-surface-alt border border-border rounded-md">
|
||||
<div class="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div class="flex-1">
|
||||
@@ -595,11 +167,11 @@ export default function AuditLogPanel() {
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<a
|
||||
href={getUpgradeActionUrlOrFallback('audit_logging')}
|
||||
href={upgradeActionUrl()}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class={getUpgradeActionButtonClass({ mobileFullWidth: false })}
|
||||
onClick={() => trackUpgradeClicked('settings_audit_log_panel', 'audit_logging')}
|
||||
onClick={handleUpgradeClick}
|
||||
>
|
||||
{UPGRADE_ACTION_LABEL}
|
||||
</a>
|
||||
@@ -616,10 +188,9 @@ export default function AuditLogPanel() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* Filters */}
|
||||
<Show when={isPersistent()}>
|
||||
<Show when={isPersistent()}>
|
||||
<div class="flex flex-wrap gap-3 p-4 bg-surface-alt rounded-md">
|
||||
<div class="flex items-center gap-2">
|
||||
<Filter class="w-4 h-4 text-slate-400" />
|
||||
@@ -673,27 +244,13 @@ export default function AuditLogPanel() {
|
||||
<option value="200">200 / page</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => {
|
||||
setPageOffset(0);
|
||||
void fetchAuditEvents({ offset: 0 });
|
||||
}}
|
||||
onClick={applyFilters}
|
||||
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const hadFilters = activeFilterCount() > 0;
|
||||
setEventFilter('');
|
||||
setUserFilter('');
|
||||
setSuccessFilter('all');
|
||||
setVerificationFilter('all');
|
||||
setPageOffset(0);
|
||||
void fetchAuditEvents({ offset: 0 });
|
||||
if (hadFilters) {
|
||||
showSuccess('Audit filters cleared');
|
||||
}
|
||||
}}
|
||||
onClick={clearFilters}
|
||||
class="w-full sm:w-auto min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover"
|
||||
>
|
||||
Clear{activeFilterCount() > 0 ? ` (${activeFilterCount()})` : ''}
|
||||
@@ -705,14 +262,7 @@ export default function AuditLogPanel() {
|
||||
{(chip) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (chip.key === 'event') setEventFilter('');
|
||||
if (chip.key === 'user') setUserFilter('');
|
||||
if (chip.key === 'success') setSuccessFilter('all');
|
||||
if (chip.key === 'verification') setVerificationFilter('all');
|
||||
setPageOffset(0);
|
||||
void fetchAuditEvents({ offset: 0 });
|
||||
}}
|
||||
onClick={() => clearFilterChip(chip.key)}
|
||||
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-surface-alt text-base-content hover:bg-surface-hover"
|
||||
title="Click to clear filter"
|
||||
aria-label={`Clear ${chip.label}`}
|
||||
@@ -727,12 +277,7 @@ export default function AuditLogPanel() {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
if (chip.key === 'event') setEventFilter('');
|
||||
if (chip.key === 'user') setUserFilter('');
|
||||
if (chip.key === 'success') setSuccessFilter('all');
|
||||
if (chip.key === 'verification') setVerificationFilter('all');
|
||||
setPageOffset(0);
|
||||
void fetchAuditEvents({ offset: 0 });
|
||||
clearFilterChip(chip.key);
|
||||
}}
|
||||
>
|
||||
{chip.label}
|
||||
@@ -790,39 +335,29 @@ export default function AuditLogPanel() {
|
||||
<Info class="w-3 h-3" />
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAutoVerifyEnabled(true);
|
||||
setAutoVerifyLimit(50);
|
||||
setPageSize(100);
|
||||
setPageOffset(0);
|
||||
setPageInput('');
|
||||
showSuccess('Audit preferences reset');
|
||||
}}
|
||||
onClick={resetPreferences}
|
||||
class="w-full sm:w-auto sm:ml-2 min-h-10 sm:min-h-10 px-3 py-2 text-sm font-medium text-muted border border-border rounded-md hover:bg-surface-hover"
|
||||
>
|
||||
Reset preferences
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* Error State */}
|
||||
<Show when={error()}>
|
||||
<Show when={error()}>
|
||||
<div class="p-4 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded-md text-red-700 dark:text-red-300">
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* Loading State */}
|
||||
<Show when={loading()}>
|
||||
<Show when={loading()}>
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<RefreshCw class="w-8 h-8 text-blue-500 animate-spin" />
|
||||
<span class="sr-only">{getAuditLogLoadingState().text}</span>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* Events Table */}
|
||||
<Show when={!loading() && isPersistent() && filteredEvents().length > 0}>
|
||||
<Show when={!loading() && isPersistent() && filteredEvents().length > 0}>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-muted">
|
||||
<span>Total: {totalEvents()}</span>
|
||||
<span>Signed: {verificationSummary().signed}</span>
|
||||
@@ -937,7 +472,7 @@ export default function AuditLogPanel() {
|
||||
<span class="text-xs text-muted">Verifying…</span>
|
||||
</Show>
|
||||
<button
|
||||
onClick={() => verifyEvent(event)}
|
||||
onClick={() => void verifyEvent(event)}
|
||||
disabled={isVerifying}
|
||||
class={AUDIT_VERIFY_ROW_BUTTON_CLASS}
|
||||
>
|
||||
@@ -952,10 +487,9 @@ export default function AuditLogPanel() {
|
||||
desktopMinWidth="900px"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* Empty State */}
|
||||
<Show when={!loading() && isPersistent() && filteredEvents().length === 0}>
|
||||
<Show when={!loading() && isPersistent() && filteredEvents().length === 0}>
|
||||
<div class="text-center py-12 px-4 bg-surface-alt rounded-md border border-dashed border-border">
|
||||
<div class="flex flex-col items-center max-w-sm mx-auto">
|
||||
<Show
|
||||
@@ -972,14 +506,7 @@ export default function AuditLogPanel() {
|
||||
</p>
|
||||
<Show when={activeFilterCount() > 0}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEventFilter('');
|
||||
setUserFilter('');
|
||||
setSuccessFilter('all');
|
||||
setVerificationFilter('all');
|
||||
setPageOffset(0);
|
||||
void fetchAuditEvents({ offset: 0 });
|
||||
}}
|
||||
onClick={clearFilters}
|
||||
class="mt-6 px-4 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800 rounded-md hover:bg-blue-50 dark:hover:bg-blue-900"
|
||||
>
|
||||
Clear all filters
|
||||
@@ -987,10 +514,9 @@ export default function AuditLogPanel() {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* Pagination */}
|
||||
<Show when={!loading() && isPersistent()}>
|
||||
<Show when={!loading() && isPersistent()}>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-end">
|
||||
<div class="flex items-center gap-2 text-xs text-muted">
|
||||
<label class="flex items-center gap-2">
|
||||
@@ -1000,27 +526,12 @@ export default function AuditLogPanel() {
|
||||
min="1"
|
||||
value={pageInput()}
|
||||
onInput={(e) => setPageInput(e.currentTarget.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
const parsed = Number(pageInput());
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
const clamped = Math.max(1, Math.min(totalPages(), Math.floor(parsed)));
|
||||
const nextOffset = (clamped - 1) * pageSize();
|
||||
setPageOffset(nextOffset);
|
||||
void fetchAuditEvents({ offset: nextOffset });
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && submitPageInput()}
|
||||
class="min-h-10 sm:min-h-10 w-20 rounded-md border border-border bg-surface px-2.5 text-sm text-base-content"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => {
|
||||
const parsed = Number(pageInput());
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
const clamped = Math.max(1, Math.min(totalPages(), Math.floor(parsed)));
|
||||
const nextOffset = (clamped - 1) * pageSize();
|
||||
setPageOffset(nextOffset);
|
||||
void fetchAuditEvents({ offset: nextOffset });
|
||||
}}
|
||||
onClick={submitPageInput}
|
||||
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover"
|
||||
>
|
||||
Go
|
||||
@@ -1028,43 +539,28 @@ export default function AuditLogPanel() {
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap sm:items-center sm:justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setPageOffset(0);
|
||||
void fetchAuditEvents({ offset: 0 });
|
||||
}}
|
||||
disabled={pageOffset() === 0}
|
||||
onClick={goToFirstPage}
|
||||
disabled={pageNumber() === 1}
|
||||
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
First
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextOffset = Math.max(0, pageOffset() - pageSize());
|
||||
setPageOffset(nextOffset);
|
||||
void fetchAuditEvents({ offset: nextOffset });
|
||||
}}
|
||||
disabled={pageOffset() === 0}
|
||||
onClick={goToPreviousPage}
|
||||
disabled={pageNumber() === 1}
|
||||
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const nextOffset = pageOffset() + pageSize();
|
||||
setPageOffset(nextOffset);
|
||||
void fetchAuditEvents({ offset: nextOffset });
|
||||
}}
|
||||
onClick={goToNextPage}
|
||||
disabled={!hasNextPage()}
|
||||
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const lastOffset = Math.max(0, (totalPages() - 1) * pageSize());
|
||||
setPageOffset(lastOffset);
|
||||
void fetchAuditEvents({ offset: lastOffset });
|
||||
}}
|
||||
onClick={goToLastPage}
|
||||
disabled={!hasNextPage()}
|
||||
class="min-h-10 sm:min-h-10 px-3 py-2.5 text-sm font-medium text-base-content bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
@@ -1072,7 +568,7 @@ export default function AuditLogPanel() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</SettingsPanel>
|
||||
</Show>
|
||||
</SettingsPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () =>');
|
||||
|
||||
@@ -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<AuditEvent[]>([]);
|
||||
const [totalEvents, setTotalEvents] = createSignal(0);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [error, setError] = createSignal<string | null>(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<Record<string, VerificationState>>({});
|
||||
const [verifying, setVerifying] = createSignal<Record<string, boolean>>({});
|
||||
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<Record<string, AbortController>>(
|
||||
{},
|
||||
);
|
||||
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<AuditFilterChip[]>(() => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user