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
+115
View File
@@ -0,0 +1,115 @@
import apiClient from '@app/services/apiClient';
export interface AuditSystemStatus {
enabled: boolean;
level: string;
retentionDays: number;
totalEvents: number;
}
export interface AuditEvent {
id: string;
timestamp: string;
eventType: string;
username: string;
ipAddress: string;
details: Record<string, any>;
}
export interface AuditEventsResponse {
events: AuditEvent[];
totalEvents: number;
page: number;
pageSize: number;
totalPages: number;
}
export interface ChartData {
labels: string[];
values: number[];
}
export interface AuditChartsData {
eventsByType: ChartData;
eventsByUser: ChartData;
eventsOverTime: ChartData;
}
export interface AuditFilters {
eventType?: string;
username?: string;
startDate?: string;
endDate?: string;
page?: number;
pageSize?: number;
}
const auditService = {
/**
* Get audit system status
*/
async getSystemStatus(): Promise<AuditSystemStatus> {
const response = await apiClient.get<any>('/api/v1/proprietary/ui-data/audit-dashboard');
const data = response.data;
// Map V1 response to expected format
return {
enabled: data.auditEnabled,
level: data.auditLevel,
retentionDays: data.retentionDays,
totalEvents: 0, // Will be fetched separately
};
},
/**
* Get audit events with pagination and filters
*/
async getEvents(filters: AuditFilters = {}): Promise<AuditEventsResponse> {
const response = await apiClient.get<AuditEventsResponse>('/api/v1/proprietary/ui-data/audit-events', {
params: filters,
});
return response.data;
},
/**
* Get chart data for dashboard
*/
async getChartsData(timePeriod: 'day' | 'week' | 'month' = 'week'): Promise<AuditChartsData> {
const response = await apiClient.get<AuditChartsData>('/api/v1/proprietary/ui-data/audit-charts', {
params: { period: timePeriod },
});
return response.data;
},
/**
* Export audit data
*/
async exportData(
format: 'csv' | 'json',
filters: AuditFilters = {}
): Promise<Blob> {
const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-export', {
params: { format, ...filters },
responseType: 'blob',
});
return response.data;
},
/**
* Get available event types for filtering
*/
async getEventTypes(): Promise<string[]> {
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-event-types');
return response.data;
},
/**
* Get list of users for filtering
*/
async getUsers(): Promise<string[]> {
const response = await apiClient.get<string[]>('/api/v1/proprietary/ui-data/audit-users');
return response.data;
},
};
export default auditService;
-107
View File
@@ -1,107 +0,0 @@
import apiClient from '@app/services/apiClient';
export interface Team {
id: number;
name: string;
userCount?: number;
}
export interface TeamMember {
id: number;
username: string;
email?: string;
roleName: string;
enabled: boolean;
team?: {
id: number;
name: string;
};
lastRequest?: Date | null;
}
export interface TeamDetailsResponse {
team: Team;
members: TeamMember[];
availableUsers: TeamMember[];
}
/**
* Team Management Service
* Provides functions to interact with team-related backend APIs
*/
export const teamService = {
/**
* Get all teams with user counts
*/
async getTeams(): Promise<Team[]> {
const response = await apiClient.get<{ teamsWithCounts: Team[] }>('/api/v1/proprietary/ui-data/teams');
return response.data.teamsWithCounts;
},
/**
* Get team details including members
*/
async getTeamDetails(teamId: number): Promise<any> {
const response = await apiClient.get(`/api/v1/proprietary/ui-data/teams/${teamId}`);
return response.data;
},
/**
* Create a new team
*/
async createTeam(name: string): Promise<void> {
const formData = new FormData();
formData.append('name', name);
await apiClient.post('/api/v1/team/create', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Rename an existing team
*/
async renameTeam(teamId: number, newName: string): Promise<void> {
const formData = new FormData();
formData.append('teamId', teamId.toString());
formData.append('newName', newName);
await apiClient.post('/api/v1/team/rename', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Delete a team (only if it has no members)
*/
async deleteTeam(teamId: number): Promise<void> {
const formData = new FormData();
formData.append('teamId', teamId.toString());
await apiClient.post('/api/v1/team/delete', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Add a user to a team
*/
async addUserToTeam(teamId: number, userId: number): Promise<void> {
const formData = new FormData();
formData.append('teamId', teamId.toString());
formData.append('userId', userId.toString());
await apiClient.post('/api/v1/team/addUser', formData, {
suppressErrorToast: true,
} as any);
},
/**
* Move a user to a specific team (used when "removing" from a team - moves to Default)
*/
async moveUserToTeam(username: string, currentRole: string, teamId: number): Promise<void> {
const formData = new FormData();
formData.append('username', username);
formData.append('role', currentRole);
formData.append('teamId', teamId.toString());
await apiClient.post('/api/v1/user/admin/changeRole', formData, {
suppressErrorToast: true,
} as any);
},
};
@@ -0,0 +1,61 @@
import apiClient from '@app/services/apiClient';
export interface EndpointStatistic {
endpoint: string;
visits: number;
percentage: number;
}
export interface EndpointStatisticsResponse {
endpoints: EndpointStatistic[];
totalEndpoints: number;
totalVisits: number;
}
export interface UsageChartData {
labels: string[];
values: number[];
}
const usageAnalyticsService = {
/**
* Get endpoint statistics
*/
async getEndpointStatistics(
limit?: number,
dataType: 'all' | 'api' | 'ui' = 'all'
): Promise<EndpointStatisticsResponse> {
const params: Record<string, any> = {};
if (limit !== undefined) {
params.limit = limit;
}
if (dataType !== 'all') {
params.dataType = dataType;
}
const response = await apiClient.get<EndpointStatisticsResponse>(
'/api/v1/proprietary/ui-data/usage-endpoint-statistics',
{ params }
);
return response.data;
},
/**
* Get chart data for endpoint usage
*/
async getChartData(
limit?: number,
dataType: 'all' | 'api' | 'ui' = 'all'
): Promise<UsageChartData> {
const stats = await this.getEndpointStatistics(limit, dataType);
return {
labels: stats.endpoints.map((e) => e.endpoint),
values: stats.endpoints.map((e) => e.visits),
};
},
};
export default usageAnalyticsService;
@@ -31,6 +31,12 @@ export interface AdminSettingsData {
roleDetails?: Record<string, string>;
teams?: any[];
maxPaidUsers?: number;
// License information
maxAllowedUsers: number;
availableSlots: number;
grandfatheredUserCount: number;
licenseMaxUsers: number;
premiumEnabled: boolean;
}
export interface CreateUserRequest {
@@ -62,6 +68,35 @@ export interface InviteUsersResponse {
error?: string;
}
export interface InviteLinkRequest {
email: string;
role: string;
teamId?: number;
expiryHours?: number;
sendEmail?: boolean;
}
export interface InviteLinkResponse {
token: string;
inviteUrl: string;
email: string;
expiresAt: string;
expiryHours: number;
emailSent?: boolean;
emailError?: string;
error?: string;
}
export interface InviteToken {
id: number;
email: string;
role: string;
teamId?: number;
createdBy: string;
createdAt: string;
expiresAt: string;
}
/**
* User Management Service
* Provides functions to interact with user management backend APIs
@@ -163,4 +198,60 @@ export const userManagementService = {
return response.data;
},
/**
* Generate an invite link (admin only)
*/
async generateInviteLink(data: InviteLinkRequest): Promise<InviteLinkResponse> {
const formData = new FormData();
// Only append email if it's provided and not empty
if (data.email && data.email.trim()) {
formData.append('email', data.email);
}
formData.append('role', data.role);
if (data.teamId) {
formData.append('teamId', data.teamId.toString());
}
if (data.expiryHours) {
formData.append('expiryHours', data.expiryHours.toString());
}
if (data.sendEmail !== undefined) {
formData.append('sendEmail', data.sendEmail.toString());
}
const response = await apiClient.post<InviteLinkResponse>(
'/api/v1/invite/generate',
formData,
{
suppressErrorToast: true,
} as any
);
return response.data;
},
/**
* Get list of active invite links (admin only)
*/
async getInviteLinks(): Promise<InviteToken[]> {
const response = await apiClient.get<{ invites: InviteToken[] }>('/api/v1/invite/list');
return response.data.invites;
},
/**
* Revoke an invite link (admin only)
*/
async revokeInviteLink(inviteId: number): Promise<void> {
await apiClient.delete(`/api/v1/invite/revoke/${inviteId}`, {
suppressErrorToast: true,
} as any);
},
/**
* Clean up expired invite links (admin only)
*/
async cleanupExpiredInvites(): Promise<{ deletedCount: number }> {
const response = await apiClient.post<{ deletedCount: number }>('/api/v1/invite/cleanup');
return response.data;
},
};