Add audit system, invite links, and usage analytics (#4749)

# Description of Changes

New Features
Audit System: Complete audit logging with dashboard, event tracking, and
export capabilities

Invite Links: Secure invite system with email notifications and
expiration

Usage Analytics: Endpoint usage statistics and visualization

License Management: User counting with grandfathering and license
enforcement
## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
Anthony Stirling
2025-11-06 17:29:34 +00:00
committed by GitHub
co-authored by James Brunton
parent f5c67a3239
commit ac3e10eb99
64 changed files with 5269 additions and 461 deletions
+40 -9
View File
@@ -71,15 +71,15 @@ export function useAdminSettings<T = any>(
// Store raw settings (includes _pending if present)
setRawSettings(rawData);
// Extract active settings (without _pending) for delta comparison
const { _pending, ...activeOnly } = rawData;
setOriginalSettings(activeOnly as T);
console.log(`[useAdminSettings:${sectionName}] Original active settings:`, JSON.stringify(activeOnly, null, 2));
// Merge pending changes into settings for display
const mergedSettings = mergePendingSettings(rawData);
console.log(`[useAdminSettings:${sectionName}] Merged settings:`, JSON.stringify(mergedSettings, null, 2));
// Store merged settings as original for delta comparison
// This ensures we compare against what the user SAW (with pending), not raw active values
setOriginalSettings(mergedSettings as T);
console.log(`[useAdminSettings:${sectionName}] Original settings (for comparison):`, JSON.stringify(mergedSettings, null, 2));
setSettings(mergedSettings as T);
} catch (error) {
console.error(`[useAdminSettings:${sectionName}] Failed to fetch:`, error);
@@ -106,15 +106,46 @@ export function useAdminSettings<T = any>(
// Use custom save logic for complex sections
const { sectionData, deltaSettings } = saveTransformer(settings);
// Save section data (with delta applied)
const sectionDelta = computeDelta(originalSettings, sectionData);
// Get original sectionData using same transformer for fair comparison
const { sectionData: originalSectionData } = saveTransformer(originalSettings);
// Save section data (with delta applied) - compare transformed vs transformed
const sectionDelta = computeDelta(originalSectionData, sectionData);
if (Object.keys(sectionDelta).length > 0) {
await apiClient.put(`/api/v1/admin/settings/section/${sectionName}`, sectionDelta);
}
// Save delta settings if provided
// Save delta settings if provided (filter to only changed values)
if (deltaSettings && Object.keys(deltaSettings).length > 0) {
await apiClient.put('/api/v1/admin/settings', { settings: deltaSettings });
// Build deltaSettings from original using same transformer to get correct structure
const { deltaSettings: originalDeltaSettings } = saveTransformer(originalSettings);
console.log(`[useAdminSettings:${sectionName}] Comparing deltaSettings:`, {
original: originalDeltaSettings,
current: deltaSettings
});
// Compare current vs original deltaSettings (both have same backend paths)
const changedDeltaSettings: Record<string, any> = {};
for (const [key, value] of Object.entries(deltaSettings)) {
const originalValue = originalDeltaSettings?.[key];
// Only include if value actually changed
if (JSON.stringify(value) !== JSON.stringify(originalValue)) {
changedDeltaSettings[key] = value;
console.log(`[useAdminSettings:${sectionName}] Delta field changed: ${key}`, {
original: originalValue,
new: value
});
}
}
if (Object.keys(changedDeltaSettings).length > 0) {
console.log(`[useAdminSettings:${sectionName}] Sending delta settings:`, changedDeltaSettings);
await apiClient.put('/api/v1/admin/settings', { settings: changedDeltaSettings });
} else {
console.log(`[useAdminSettings:${sectionName}] No delta settings changed, skipping`);
}
}
} else {
// Simple single-endpoint save with delta
@@ -0,0 +1,59 @@
import { useState, useEffect } from 'react';
import auditService, { AuditFilters } from '@app/services/auditService';
/**
* Shared hook for managing audit filters across components
*/
export function useAuditFilters(initialFilters: Partial<AuditFilters> = {}) {
const [eventTypes, setEventTypes] = useState<string[]>([]);
const [users, setUsers] = useState<string[]>([]);
const [filters, setFilters] = useState<AuditFilters>({
eventType: undefined,
username: undefined,
startDate: undefined,
endDate: undefined,
...initialFilters,
});
// Fetch metadata on mount
useEffect(() => {
const fetchMetadata = async () => {
try {
const [types, usersList] = await Promise.all([
auditService.getEventTypes(),
auditService.getUsers(),
]);
setEventTypes(types);
setUsers(usersList);
} catch (err) {
console.error('Failed to fetch audit metadata:', err);
}
};
fetchMetadata();
}, []);
const handleFilterChange = (key: keyof AuditFilters, value: any) => {
setFilters((prev) => ({ ...prev, [key]: value }));
};
const handleClearFilters = () => {
setFilters({
eventType: undefined,
username: undefined,
startDate: undefined,
endDate: undefined,
page: initialFilters.page,
pageSize: initialFilters.pageSize,
});
};
return {
filters,
setFilters,
eventTypes,
users,
handleFilterChange,
handleClearFilters,
};
}