From e88c69be70233acf82f64e4140946b4bd0361353 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Wed, 29 Oct 2025 17:36:38 +0000 Subject: [PATCH] update full UI --- .../proprietary/config/AuditJpaConfig.java | 8 +- .../controller/api/AuditRestController.java | 434 ++++++++++++++++++ .../api/ProprietaryUIDataController.java | 27 +- .../controller/api/UsageRestController.java | 191 ++++++++ .../model/UserLicenseSettings.java | 65 +++ .../security/InitialSecuritySetup.java | 8 + .../configuration/DatabaseConfig.java | 3 +- .../configuration/ee/LicenseKeyChecker.java | 36 +- .../controller/api/UserController.java | 43 +- .../UserLicenseSettingsRepository.java | 21 + .../service/UserLicenseSettingsService.java | 411 +++++++++++++++++ .../public/locales/en-GB/translation.json | 192 ++++++++ .../core/components/shared/AppConfigModal.tsx | 84 +++- .../core/components/shared/QuickAccessBar.tsx | 10 + .../shared/config/configNavSections.tsx | 128 ++++-- .../configSections/AdminAuditSection.tsx | 99 ++++ .../configSections/AdminUsageSection.tsx | 196 ++++++++ .../config/configSections/PeopleSection.tsx | 286 +++++++++--- .../configSections/TeamDetailsSection.tsx | 187 +++++--- .../config/configSections/TeamsSection.tsx | 76 ++- .../audit/AuditChartsSection.tsx | 165 +++++++ .../configSections/audit/AuditEventsTable.tsx | 300 ++++++++++++ .../audit/AuditExportSection.tsx | 182 ++++++++ .../audit/AuditSystemStatus.tsx | 64 +++ .../usage/UsageAnalyticsChart.tsx | 89 ++++ .../usage/UsageAnalyticsTable.tsx | 126 +++++ .../core/components/shared/config/types.ts | 3 + frontend/src/core/services/auditService.ts | 115 +++++ .../core/services/usageAnalyticsService.ts | 62 +++ .../core/services/userManagementService.ts | 6 + frontend/src/core/utils/settingsNavigation.ts | 53 +++ 31 files changed, 3445 insertions(+), 225 deletions(-) create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/repository/UserLicenseSettingsRepository.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java create mode 100644 frontend/src/core/components/shared/config/configSections/AdminAuditSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/AdminUsageSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/audit/AuditChartsSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/audit/AuditEventsTable.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/audit/AuditExportSection.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/audit/AuditSystemStatus.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/usage/UsageAnalyticsChart.tsx create mode 100644 frontend/src/core/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx create mode 100644 frontend/src/core/services/auditService.ts create mode 100644 frontend/src/core/services/usageAnalyticsService.ts create mode 100644 frontend/src/core/utils/settingsNavigation.ts diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java index a43f6b69d..e69ef8c3c 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/config/AuditJpaConfig.java @@ -1,17 +1,15 @@ package stirling.software.proprietary.config; import org.springframework.context.annotation.Configuration; -import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.transaction.annotation.EnableTransactionManagement; -/** Configuration to explicitly enable JPA repositories and scheduling for the audit system. */ +/** Configuration to enable scheduling for the audit system. */ @Configuration @EnableTransactionManagement -@EnableJpaRepositories(basePackages = "stirling.software.proprietary.repository") @EnableScheduling public class AuditJpaConfig { - // This configuration enables JPA repositories in the specified package - // and enables scheduling for audit cleanup tasks + // This configuration enables scheduling for audit cleanup tasks + // JPA repositories are now managed by DatabaseConfig to avoid conflicts // No additional beans or methods needed } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java new file mode 100644 index 000000000..d3a176b68 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/AuditRestController.java @@ -0,0 +1,434 @@ +package stirling.software.proprietary.controller.api; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.annotations.api.ProprietaryUiDataApi; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** REST API controller for audit data used by React frontend. */ +@Slf4j +@ProprietaryUiDataApi +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +@EnterpriseEndpoint +public class AuditRestController { + + private final PersistentAuditEventRepository auditRepository; + private final ObjectMapper objectMapper; + + /** + * Get audit events with pagination and filters. Maps to frontend's getEvents() call. + * + * @param page Page number (0-indexed) + * @param pageSize Number of items per page + * @param eventType Filter by event type + * @param username Filter by username (principal) + * @param startDate Filter start date + * @param endDate Filter end date + * @return Paginated audit events response + */ + @GetMapping("/audit-events") + public ResponseEntity getAuditEvents( + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "pageSize", defaultValue = "30") int pageSize, + @RequestParam(value = "eventType", required = false) String eventType, + @RequestParam(value = "username", required = false) String username, + @RequestParam(value = "startDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + @RequestParam(value = "endDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate) { + + Pageable pageable = PageRequest.of(page, pageSize, Sort.by("timestamp").descending()); + Page events; + + // Apply filters based on provided parameters + if (eventType != null && username != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = + auditRepository.findByPrincipalAndTypeAndTimestampBetween( + username, eventType, start, end, pageable); + } else if (eventType != null && username != null) { + events = auditRepository.findByPrincipalAndType(username, eventType, pageable); + } else if (eventType != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findByTypeAndTimestampBetween(eventType, start, end, pageable); + } else if (username != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = + auditRepository.findByPrincipalAndTimestampBetween( + username, start, end, pageable); + } else if (startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findByTimestampBetween(start, end, pageable); + } else if (eventType != null) { + events = auditRepository.findByType(eventType, pageable); + } else if (username != null) { + events = auditRepository.findByPrincipal(username, pageable); + } else { + events = auditRepository.findAll(pageable); + } + + // Convert to response format expected by frontend + List eventDtos = + events.getContent().stream().map(this::convertToDto).collect(Collectors.toList()); + + AuditEventsResponse response = + AuditEventsResponse.builder() + .events(eventDtos) + .totalEvents((int) events.getTotalElements()) + .page(events.getNumber()) + .pageSize(events.getSize()) + .totalPages(events.getTotalPages()) + .build(); + + return ResponseEntity.ok(response); + } + + /** + * Get chart data for dashboard. Maps to frontend's getChartsData() call. + * + * @param period Time period for charts (day/week/month) + * @return Chart data for events by type, user, and over time + */ + @GetMapping("/audit-charts") + public ResponseEntity getAuditCharts( + @RequestParam(value = "period", defaultValue = "week") String period) { + + // Calculate days based on period + int days; + switch (period.toLowerCase()) { + case "day": + days = 1; + break; + case "month": + days = 30; + break; + case "week": + default: + days = 7; + break; + } + + // Get events from the specified period + Instant startDate = Instant.now().minus(java.time.Duration.ofDays(days)); + List events = auditRepository.findByTimestampAfter(startDate); + + // Count events by type + Map eventsByType = + events.stream() + .collect( + Collectors.groupingBy( + PersistentAuditEvent::getType, Collectors.counting())); + + // Count events by principal (user) + Map eventsByUser = + events.stream() + .collect( + Collectors.groupingBy( + PersistentAuditEvent::getPrincipal, Collectors.counting())); + + // Count events by day + Map eventsByDay = + events.stream() + .collect( + Collectors.groupingBy( + e -> + LocalDateTime.ofInstant( + e.getTimestamp(), + ZoneId.systemDefault()) + .format(DateTimeFormatter.ISO_LOCAL_DATE), + Collectors.counting())); + + // Convert to ChartData format + ChartData eventsByTypeChart = + ChartData.builder() + .labels(new ArrayList<>(eventsByType.keySet())) + .values( + eventsByType.values().stream() + .map(Long::intValue) + .collect(Collectors.toList())) + .build(); + + ChartData eventsByUserChart = + ChartData.builder() + .labels(new ArrayList<>(eventsByUser.keySet())) + .values( + eventsByUser.values().stream() + .map(Long::intValue) + .collect(Collectors.toList())) + .build(); + + // Sort events by day for time series + TreeMap sortedEventsByDay = new TreeMap<>(eventsByDay); + ChartData eventsOverTimeChart = + ChartData.builder() + .labels(new ArrayList<>(sortedEventsByDay.keySet())) + .values( + sortedEventsByDay.values().stream() + .map(Long::intValue) + .collect(Collectors.toList())) + .build(); + + AuditChartsData chartsData = + AuditChartsData.builder() + .eventsByType(eventsByTypeChart) + .eventsByUser(eventsByUserChart) + .eventsOverTime(eventsOverTimeChart) + .build(); + + return ResponseEntity.ok(chartsData); + } + + /** + * Get available event types for filtering. Maps to frontend's getEventTypes() call. + * + * @return List of unique event types + */ + @GetMapping("/audit-event-types") + public ResponseEntity> getEventTypes() { + // Get distinct event types from the database + List dbTypes = auditRepository.findDistinctEventTypes(); + + // Include standard enum types in case they're not in the database yet + List enumTypes = + Arrays.stream(AuditEventType.values()) + .map(AuditEventType::name) + .collect(Collectors.toList()); + + // Combine both sources, remove duplicates, and sort + Set combinedTypes = new HashSet<>(); + combinedTypes.addAll(dbTypes); + combinedTypes.addAll(enumTypes); + + List result = combinedTypes.stream().sorted().collect(Collectors.toList()); + + return ResponseEntity.ok(result); + } + + /** + * Get list of users for filtering. Maps to frontend's getUsers() call. + * + * @return List of unique usernames + */ + @GetMapping("/audit-users") + public ResponseEntity> getUsers() { + // Use the countByPrincipal query to get unique principals + List principalCounts = auditRepository.countByPrincipal(); + + List users = + principalCounts.stream() + .map(arr -> (String) arr[0]) + .sorted() + .collect(Collectors.toList()); + + return ResponseEntity.ok(users); + } + + /** + * Export audit data in CSV or JSON format. Maps to frontend's exportData() call. + * + * @param format Export format (csv or json) + * @param eventType Filter by event type + * @param username Filter by username + * @param startDate Filter start date + * @param endDate Filter end date + * @return File download response + */ + @GetMapping("/audit-export") + public ResponseEntity exportAuditData( + @RequestParam(value = "format", defaultValue = "csv") String format, + @RequestParam(value = "eventType", required = false) String eventType, + @RequestParam(value = "username", required = false) String username, + @RequestParam(value = "startDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate startDate, + @RequestParam(value = "endDate", required = false) + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) + LocalDate endDate) { + + // Get data with same filtering as getAuditEvents + List events; + + if (eventType != null && username != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = + auditRepository.findAllByPrincipalAndTypeAndTimestampBetweenForExport( + username, eventType, start, end); + } else if (eventType != null && username != null) { + events = auditRepository.findAllByPrincipalAndTypeForExport(username, eventType); + } else if (eventType != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = + auditRepository.findAllByTypeAndTimestampBetweenForExport( + eventType, start, end); + } else if (username != null && startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = + auditRepository.findAllByPrincipalAndTimestampBetweenForExport( + username, start, end); + } else if (startDate != null && endDate != null) { + Instant start = startDate.atStartOfDay(ZoneId.systemDefault()).toInstant(); + Instant end = endDate.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant(); + events = auditRepository.findAllByTimestampBetweenForExport(start, end); + } else if (eventType != null) { + events = auditRepository.findByTypeForExport(eventType); + } else if (username != null) { + events = auditRepository.findAllByPrincipalForExport(username); + } else { + events = auditRepository.findAll(); + } + + // Export based on format + if ("json".equalsIgnoreCase(format)) { + return exportAsJson(events); + } else { + return exportAsCsv(events); + } + } + + // Helper methods + + private AuditEventDto convertToDto(PersistentAuditEvent event) { + // Parse the JSON data field if present + Map details = new HashMap<>(); + if (event.getData() != null && !event.getData().isEmpty()) { + try { + @SuppressWarnings("unchecked") + Map parsed = objectMapper.readValue(event.getData(), Map.class); + details = parsed; + } catch (JsonProcessingException e) { + log.warn("Failed to parse audit event data as JSON: {}", event.getData()); + details.put("rawData", event.getData()); + } + } + + return AuditEventDto.builder() + .id(String.valueOf(event.getId())) + .timestamp(event.getTimestamp().toString()) + .eventType(event.getType()) + .username(event.getPrincipal()) + .ipAddress((String) details.getOrDefault("ipAddress", "")) // Extract if available + .details(details) + .build(); + } + + private ResponseEntity exportAsCsv(List events) { + StringBuilder csv = new StringBuilder(); + csv.append("ID,Principal,Type,Timestamp,Data\n"); + + DateTimeFormatter formatter = DateTimeFormatter.ISO_INSTANT; + + for (PersistentAuditEvent event : events) { + csv.append(event.getId()).append(","); + csv.append(escapeCSV(event.getPrincipal())).append(","); + csv.append(escapeCSV(event.getType())).append(","); + csv.append(formatter.format(event.getTimestamp())).append(","); + csv.append(escapeCSV(event.getData())).append("\n"); + } + + byte[] csvBytes = csv.toString().getBytes(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_OCTET_STREAM); + headers.setContentDispositionFormData("attachment", "audit_export.csv"); + + return ResponseEntity.ok().headers(headers).body(csvBytes); + } + + private ResponseEntity exportAsJson(List events) { + try { + byte[] jsonBytes = objectMapper.writeValueAsBytes(events); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setContentDispositionFormData("attachment", "audit_export.json"); + + return ResponseEntity.ok().headers(headers).body(jsonBytes); + } catch (JsonProcessingException e) { + log.error("Error serializing audit events to JSON", e); + return ResponseEntity.internalServerError().build(); + } + } + + private String escapeCSV(String field) { + if (field == null) { + return ""; + } + // Replace double quotes with two double quotes and wrap in quotes + return "\"" + field.replace("\"", "\"\"") + "\""; + } + + // DTOs for response formatting + + @lombok.Data + @lombok.Builder + public static class AuditEventsResponse { + private List events; + private int totalEvents; + private int page; + private int pageSize; + private int totalPages; + } + + @lombok.Data + @lombok.Builder + public static class AuditEventDto { + private String id; + private String timestamp; + private String eventType; + private String username; + private String ipAddress; + private Map details; + } + + @lombok.Data + @lombok.Builder + public static class AuditChartsData { + private ChartData eventsByType; + private ChartData eventsByUser; + private ChartData eventsOverTime; + } + + @lombok.Data + @lombok.Builder + public static class ChartData { + private List labels; + private List values; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java index 5735027f6..2937f2b5e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/ProprietaryUIDataController.java @@ -39,6 +39,7 @@ import stirling.software.proprietary.audit.AuditLevel; import stirling.software.proprietary.config.AuditConfigurationProperties; import stirling.software.proprietary.model.Team; import stirling.software.proprietary.model.dto.TeamWithUserCountDTO; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; import stirling.software.proprietary.security.config.EnterpriseEndpoint; import stirling.software.proprietary.security.database.repository.SessionRepository; import stirling.software.proprietary.security.database.repository.UserRepository; @@ -50,6 +51,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin import stirling.software.proprietary.security.service.DatabaseService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; +import stirling.software.proprietary.service.UserLicenseSettingsService; @Slf4j @ProprietaryUiDataApi @@ -64,6 +66,8 @@ public class ProprietaryUIDataController { private final DatabaseService databaseService; private final boolean runningEE; private final ObjectMapper objectMapper; + private final UserLicenseSettingsService licenseSettingsService; + private final PersistentAuditEventRepository auditRepository; public ProprietaryUIDataController( ApplicationProperties applicationProperties, @@ -74,7 +78,9 @@ public class ProprietaryUIDataController { SessionRepository sessionRepository, DatabaseService databaseService, ObjectMapper objectMapper, - @Qualifier("runningEE") boolean runningEE) { + @Qualifier("runningEE") boolean runningEE, + UserLicenseSettingsService licenseSettingsService, + PersistentAuditEventRepository auditRepository) { this.applicationProperties = applicationProperties; this.auditConfig = auditConfig; this.sessionPersistentRegistry = sessionPersistentRegistry; @@ -84,6 +90,8 @@ public class ProprietaryUIDataController { this.databaseService = databaseService; this.objectMapper = objectMapper; this.runningEE = runningEE; + this.licenseSettingsService = licenseSettingsService; + this.auditRepository = auditRepository; } @GetMapping("/audit-dashboard") @@ -262,6 +270,13 @@ public class ProprietaryUIDataController { .filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) .toList(); + // Calculate license limits + int maxAllowedUsers = licenseSettingsService.calculateMaxAllowedUsers(); + long availableSlots = licenseSettingsService.getAvailableUserSlots(); + int grandfatheredCount = licenseSettingsService.getDisplayGrandfatheredCount(); + int licenseMaxUsers = licenseSettingsService.getSettings().getLicenseMaxUsers(); + boolean premiumEnabled = applicationProperties.getPremium().isEnabled(); + AdminSettingsData data = new AdminSettingsData(); data.setUsers(sortedUsers); data.setCurrentUsername(authentication.getName()); @@ -273,6 +288,11 @@ public class ProprietaryUIDataController { data.setDisabledUsers(disabledUsers); data.setTeams(allTeams); data.setMaxPaidUsers(applicationProperties.getPremium().getMaxUsers()); + data.setMaxAllowedUsers(maxAllowedUsers); + data.setAvailableSlots(availableSlots); + data.setGrandfatheredUserCount(grandfatheredCount); + data.setLicenseMaxUsers(licenseMaxUsers); + data.setPremiumEnabled(premiumEnabled); return ResponseEntity.ok(data); } @@ -445,6 +465,11 @@ public class ProprietaryUIDataController { private int disabledUsers; private List teams; private int maxPaidUsers; + private int maxAllowedUsers; + private long availableSlots; + private int grandfatheredUserCount; + private int licenseMaxUsers; + private boolean premiumEnabled; } @Data diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java new file mode 100644 index 000000000..521dd32d5 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java @@ -0,0 +1,191 @@ +package stirling.software.proprietary.controller.api; + +import java.util.*; +import java.util.stream.Collectors; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.annotations.api.ProprietaryUiDataApi; +import stirling.software.proprietary.audit.AuditEventType; +import stirling.software.proprietary.model.security.PersistentAuditEvent; +import stirling.software.proprietary.repository.PersistentAuditEventRepository; +import stirling.software.proprietary.security.config.EnterpriseEndpoint; + +/** REST API controller for usage analytics data used by React frontend. */ +@Slf4j +@ProprietaryUiDataApi +@PreAuthorize("hasRole('ADMIN')") +@RequiredArgsConstructor +@EnterpriseEndpoint +public class UsageRestController { + + private final PersistentAuditEventRepository auditRepository; + private final ObjectMapper objectMapper; + + /** + * Get endpoint statistics derived from audit events. This endpoint analyzes HTTP_REQUEST audit + * events to generate usage statistics. + * + * @param limit Optional limit on number of endpoints to return + * @param includeHome Whether to include homepage ("/") in results + * @param includeLogin Whether to include login page ("/login") in results + * @return Endpoint statistics response + */ + @GetMapping("/usage-endpoint-statistics") + public ResponseEntity getEndpointStatistics( + @RequestParam(value = "limit", required = false) Integer limit, + @RequestParam(value = "includeHome", defaultValue = "true") boolean includeHome, + @RequestParam(value = "includeLogin", defaultValue = "true") boolean includeLogin) { + + // Get all HTTP_REQUEST audit events + List httpEvents = + auditRepository.findByTypeForExport(AuditEventType.HTTP_REQUEST.name()); + + // Count visits per endpoint + Map endpointCounts = new HashMap<>(); + + for (PersistentAuditEvent event : httpEvents) { + String endpoint = extractEndpointFromAuditData(event.getData()); + if (endpoint != null) { + // Apply filters + if (!includeHome && "/".equals(endpoint)) { + continue; + } + if (!includeLogin && "/login".equals(endpoint)) { + continue; + } + + endpointCounts.merge(endpoint, 1L, Long::sum); + } + } + + // Calculate totals + long totalVisits = endpointCounts.values().stream().mapToLong(Long::longValue).sum(); + int totalEndpoints = endpointCounts.size(); + + // Convert to list and sort by visit count (descending) + List statistics = + endpointCounts.entrySet().stream() + .map( + entry -> { + String endpoint = entry.getKey(); + long visits = entry.getValue(); + double percentage = + totalVisits > 0 ? (visits * 100.0 / totalVisits) : 0.0; + + return EndpointStatistic.builder() + .endpoint(endpoint) + .visits((int) visits) + .percentage(Math.round(percentage * 10.0) / 10.0) + .build(); + }) + .sorted(Comparator.comparingInt(EndpointStatistic::getVisits).reversed()) + .collect(Collectors.toList()); + + // Apply limit if specified + if (limit != null && limit > 0 && statistics.size() > limit) { + statistics = statistics.subList(0, limit); + } + + EndpointStatisticsResponse response = + EndpointStatisticsResponse.builder() + .endpoints(statistics) + .totalEndpoints(totalEndpoints) + .totalVisits((int) totalVisits) + .build(); + + return ResponseEntity.ok(response); + } + + /** + * Extract the endpoint path from the audit event's data field. The data field contains JSON + * with an "endpoint" or "path" key. + * + * @param dataJson JSON string from audit event + * @return Endpoint path or null if not found + */ + private String extractEndpointFromAuditData(String dataJson) { + if (dataJson == null || dataJson.isEmpty()) { + return null; + } + + try { + @SuppressWarnings("unchecked") + Map data = objectMapper.readValue(dataJson, Map.class); + + // Try common keys for endpoint path + Object endpoint = data.get("endpoint"); + if (endpoint != null) { + return normalizeEndpoint(endpoint.toString()); + } + + Object path = data.get("path"); + if (path != null) { + return normalizeEndpoint(path.toString()); + } + + // Fallback: check if there's a request-related key + Object requestUri = data.get("requestUri"); + if (requestUri != null) { + return normalizeEndpoint(requestUri.toString()); + } + + } catch (JsonProcessingException e) { + log.debug("Failed to parse audit data JSON: {}", dataJson, e); + } + + return null; + } + + /** + * Normalize endpoint paths by removing query strings and standardizing format. + * + * @param endpoint Raw endpoint path + * @return Normalized endpoint path + */ + private String normalizeEndpoint(String endpoint) { + if (endpoint == null) { + return null; + } + + // Remove query string + int queryIndex = endpoint.indexOf('?'); + if (queryIndex != -1) { + endpoint = endpoint.substring(0, queryIndex); + } + + // Ensure it starts with / + if (!endpoint.startsWith("/")) { + endpoint = "/" + endpoint; + } + + return endpoint; + } + + // DTOs for response formatting + + @lombok.Data + @lombok.Builder + public static class EndpointStatisticsResponse { + private List endpoints; + private int totalEndpoints; + private int totalVisits; + } + + @lombok.Data + @lombok.Builder + public static class EndpointStatistic { + private String endpoint; + private int visits; + private double percentage; + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java new file mode 100644 index 000000000..bb7f52142 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/model/UserLicenseSettings.java @@ -0,0 +1,65 @@ +package stirling.software.proprietary.model; + +import java.io.Serializable; + +import jakarta.persistence.*; + +import lombok.*; + +/** + * Entity to store user license settings in the database. This is a singleton entity (only one row + * should exist). Tracks grandfathered user counts and license limits. + */ +@Entity +@Table(name = "user_license_settings") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@ToString +public class UserLicenseSettings implements Serializable { + + private static final long serialVersionUID = 1L; + + public static final Long SINGLETON_ID = 1L; + + @Id + @Column(name = "id") + private Long id = SINGLETON_ID; + + /** + * The number of users that existed in the database when grandfathering was initialized. This + * value is set once during initial setup and should NEVER be modified afterwards. + */ + @Column(name = "grandfathered_user_count", nullable = false) + private int grandfatheredUserCount = 0; + + /** + * Flag to indicate that grandfathering has been initialized and locked. Once true, the + * grandfatheredUserCount should never change. This prevents manipulation by deleting/recreating + * the table. + */ + @Column(name = "grandfathering_locked", nullable = false) + private boolean grandfatheringLocked = false; + + /** + * Maximum number of users allowed by the current license. This is updated when the license key + * is validated. + */ + @Column(name = "license_max_users", nullable = false) + private int licenseMaxUsers = 0; + + /** + * Random salt used when generating signatures. Makes it harder to recompute the signature when + * manually editing the table. + */ + @Column(name = "integrity_salt", nullable = false, length = 64) + private String integritySalt = ""; + + /** + * Signed representation of {@code grandfatheredUserCount}. Stores the original value alongside + * a secret-backed HMAC so we can detect tampering and restore the correct count. + */ + @Column(name = "grandfathered_user_signature", nullable = false, length = 256) + private String grandfatheredUserSignature = ""; +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java index e145e2754..45abae98e 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/InitialSecuritySetup.java @@ -20,6 +20,7 @@ import stirling.software.proprietary.security.model.User; import stirling.software.proprietary.security.service.DatabaseServiceInterface; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; +import stirling.software.proprietary.service.UserLicenseSettingsService; @Slf4j @Component @@ -30,6 +31,7 @@ public class InitialSecuritySetup { private final TeamService teamService; private final ApplicationProperties applicationProperties; private final DatabaseServiceInterface databaseService; + private final UserLicenseSettingsService licenseSettingsService; @PostConstruct public void init() { @@ -45,12 +47,18 @@ public class InitialSecuritySetup { assignUsersToDefaultTeamIfMissing(); initializeInternalApiUser(); + initializeUserLicenseSettings(); } catch (IllegalArgumentException | SQLException | UnsupportedProviderException e) { log.error("Failed to initialize security setup.", e); System.exit(1); } } + private void initializeUserLicenseSettings() { + licenseSettingsService.initializeGrandfatheredCount(); + licenseSettingsService.updateLicenseMaxUsers(); + } + private void assignUsersToDefaultTeamIfMissing() { Team defaultTeam = teamService.getOrCreateDefaultTeam(); Team internalTeam = teamService.getOrCreateInternalTeam(); diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java index e6afa6e40..085c08070 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/DatabaseConfig.java @@ -25,7 +25,8 @@ import stirling.software.common.model.exception.UnsupportedProviderException; @EnableJpaRepositories( basePackages = { "stirling.software.proprietary.security.database.repository", - "stirling.software.proprietary.security.repository" + "stirling.software.proprietary.security.repository", + "stirling.software.proprietary.repository" }) @EntityScan({"stirling.software.proprietary.security.model", "stirling.software.proprietary.model"}) public class DatabaseConfig { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java index 15baef7db..212922d55 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java @@ -5,14 +5,20 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.annotation.Lazy; +import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; + import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.GeneralUtils; import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License; +import stirling.software.proprietary.service.UserLicenseSettingsService; @Slf4j @Component @@ -24,21 +30,36 @@ public class LicenseKeyChecker { private final ApplicationProperties applicationProperties; + private final UserLicenseSettingsService licenseSettingsService; + private License premiumEnabledResult = License.NORMAL; public LicenseKeyChecker( - KeygenLicenseVerifier licenseService, ApplicationProperties applicationProperties) { + KeygenLicenseVerifier licenseService, + ApplicationProperties applicationProperties, + @Lazy UserLicenseSettingsService licenseSettingsService) { this.licenseService = licenseService; this.applicationProperties = applicationProperties; - this.checkLicense(); + this.licenseSettingsService = licenseSettingsService; + } + + @PostConstruct + public void init() { + evaluateLicense(); + } + + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + synchronizeLicenseSettings(); } @Scheduled(initialDelay = 604800000, fixedRate = 604800000) // 7 days in milliseconds public void checkLicensePeriodically() { - checkLicense(); + evaluateLicense(); + synchronizeLicenseSettings(); } - private void checkLicense() { + private void evaluateLicense() { if (!applicationProperties.getPremium().isEnabled()) { premiumEnabledResult = License.NORMAL; } else { @@ -59,6 +80,10 @@ public class LicenseKeyChecker { } } + private void synchronizeLicenseSettings() { + licenseSettingsService.updateLicenseMaxUsers(); + } + private String getLicenseKeyContent(String keyOrFilePath) { if (keyOrFilePath == null || keyOrFilePath.trim().isEmpty()) { log.error("License key is not specified"); @@ -89,7 +114,8 @@ public class LicenseKeyChecker { public void updateLicenseKey(String newKey) throws IOException { applicationProperties.getPremium().setKey(newKey); GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey); - checkLicense(); + evaluateLicense(); + synchronizeLicenseSettings(); } public License getPremiumLicenseEnabledResult() { diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java index bee830430..81e1b5652 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/UserController.java @@ -40,6 +40,7 @@ import stirling.software.proprietary.security.service.EmailService; import stirling.software.proprietary.security.service.TeamService; import stirling.software.proprietary.security.service.UserService; import stirling.software.proprietary.security.session.SessionPersistentRegistry; +import stirling.software.proprietary.service.UserLicenseSettingsService; @UserApi @Slf4j @@ -53,6 +54,7 @@ public class UserController { private final TeamRepository teamRepository; private final UserRepository userRepository; private final Optional emailService; + private final UserLicenseSettingsService licenseSettingsService; @PreAuthorize("!hasAuthority('ROLE_DEMO_USER')") @PostMapping("/register") @@ -309,11 +311,17 @@ public class UserController { "error", "Invalid username format. Username must be 3-50 characters.")); } - if (applicationProperties.getPremium().isEnabled() - && applicationProperties.getPremium().getMaxUsers() - <= userService.getTotalUsersCount()) { + if (licenseSettingsService.wouldExceedLimit(1)) { + long availableSlots = licenseSettingsService.getAvailableUserSlots(); + int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers(); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Maximum number of users reached for your license.")); + .body( + Map.of( + "error", + "Maximum number of users reached. Allowed: " + + maxAllowed + + ", Available slots: " + + availableSlots)); } Optional userOpt = userService.findByUsernameIgnoreCase(username); if (userOpt.isPresent()) { @@ -406,20 +414,19 @@ public class UserController { } // Check license limits - if (applicationProperties.getPremium().isEnabled()) { - long currentUserCount = userService.getTotalUsersCount(); - int maxUsers = applicationProperties.getPremium().getMaxUsers(); - long availableSlots = maxUsers - currentUserCount; - if (availableSlots < emailArray.length) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body( - Map.of( - "error", - "Not enough user slots available. Available: " - + availableSlots - + ", Requested: " - + emailArray.length)); - } + if (licenseSettingsService.wouldExceedLimit(emailArray.length)) { + long availableSlots = licenseSettingsService.getAvailableUserSlots(); + int maxAllowed = licenseSettingsService.calculateMaxAllowedUsers(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "Not enough user slots available. Allowed: " + + maxAllowed + + ", Available: " + + availableSlots + + ", Requested: " + + emailArray.length)); } // Validate role diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/UserLicenseSettingsRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/UserLicenseSettingsRepository.java new file mode 100644 index 000000000..15b35e1bf --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/UserLicenseSettingsRepository.java @@ -0,0 +1,21 @@ +package stirling.software.proprietary.security.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.model.UserLicenseSettings; + +@Repository +public interface UserLicenseSettingsRepository extends JpaRepository { + + /** + * Finds the singleton UserLicenseSettings record. + * + * @return Optional containing the settings if they exist + */ + default Optional findSettings() { + return findById(UserLicenseSettings.SINGLETON_ID); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java new file mode 100644 index 000000000..44efce960 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/service/UserLicenseSettingsService.java @@ -0,0 +1,411 @@ +package stirling.software.proprietary.service; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Optional; +import java.util.UUID; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.model.UserLicenseSettings; +import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository; +import stirling.software.proprietary.security.service.UserService; + +/** + * Service for managing user license settings and grandfathering logic. + * + *

User limit calculation: + * + *

    + *
  • Default limit: 5 users + *
  • Grandfathered limit: max(5, existing user count at initialization) + *
  • With pro license: grandfathered limit + license maxUsers + *
  • Without pro license: grandfathered limit + *
+ */ +@Service +@Slf4j +@RequiredArgsConstructor +public class UserLicenseSettingsService { + + private static final int DEFAULT_USER_LIMIT = 5; + private static final String SIGNATURE_SEPARATOR = ":"; + private static final String DEFAULT_INTEGRITY_SECRET = "stirling-pdf-user-license-guard"; + + private final UserLicenseSettingsRepository settingsRepository; + private final UserService userService; + private final ApplicationProperties applicationProperties; + + /** + * Gets the current user license settings, creating them if they don't exist. + * + * @return The current settings + */ + @Transactional + public UserLicenseSettings getOrCreateSettings() { + return settingsRepository + .findSettings() + .orElseGet( + () -> { + log.info("Initializing user license settings"); + UserLicenseSettings settings = new UserLicenseSettings(); + settings.setId(UserLicenseSettings.SINGLETON_ID); + settings.setGrandfatheredUserCount(0); + settings.setLicenseMaxUsers(0); + settings.setGrandfatheringLocked(false); + settings.setIntegritySalt(UUID.randomUUID().toString()); + settings.setGrandfatheredUserSignature(""); + return settingsRepository.save(settings); + }); + } + + /** + * Initializes the grandfathered user count if not already set. This should be called on + * application startup. + * + *

IMPORTANT: Once grandfathering is locked, this value can NEVER be changed. This prevents + * manipulation by deleting the settings table. + * + *

Logic: + * + *

    + *
  • If grandfatheringLocked is true: Skip initialization (already set permanently) + *
  • If users exist in database: Set to max(5, current user count) - this is an existing + * installation + *
  • If no users exist: Set to 5 (default) - this is a fresh installation + *
  • Lock grandfathering immediately after setting + *
+ */ + @Transactional + public void initializeGrandfatheredCount() { + UserLicenseSettings settings = getOrCreateSettings(); + + boolean changed = ensureIntegritySalt(settings); + + // CRITICAL: Never change grandfathering once it's locked + if (settings.isGrandfatheringLocked()) { + if (settings.getGrandfatheredUserSignature() == null + || settings.getGrandfatheredUserSignature().isBlank()) { + settings.setGrandfatheredUserSignature( + generateSignature(settings.getGrandfatheredUserCount(), settings)); + changed = true; + } + if (changed) { + settingsRepository.save(settings); + } + log.debug( + "Grandfathering is locked. Current grandfathered count: {}", + settings.getGrandfatheredUserCount()); + return; + } + + // Determine if this is an existing installation or fresh install + long currentUserCount = userService.getTotalUsersCount(); + boolean isExistingInstallation = currentUserCount > 0; + + int grandfatheredCount; + if (isExistingInstallation) { + // Existing installation (v2.0+ or has users) - grandfather current user count + grandfatheredCount = Math.max(DEFAULT_USER_LIMIT, (int) currentUserCount); + log.info( + "Existing installation detected. Grandfathering {} users (current: {}, minimum:" + + " {})", + grandfatheredCount, + currentUserCount, + DEFAULT_USER_LIMIT); + } else { + // Fresh installation - set to default + grandfatheredCount = DEFAULT_USER_LIMIT; + log.info( + "Fresh installation detected. Setting default grandfathered limit: {}", + grandfatheredCount); + } + + // Set and LOCK the grandfathering permanently + settings.setGrandfatheredUserCount(grandfatheredCount); + settings.setGrandfatheringLocked(true); + settings.setGrandfatheredUserSignature(generateSignature(grandfatheredCount, settings)); + settingsRepository.save(settings); + + log.warn( + "GRANDFATHERING LOCKED: {} users. This value can never be changed.", + grandfatheredCount); + } + + /** + * Updates the license max users from the application properties. This should be called when the + * license is validated. + */ + @Transactional + public void updateLicenseMaxUsers() { + UserLicenseSettings settings = getOrCreateSettings(); + + int licenseMaxUsers = 0; + if (applicationProperties.getPremium().isEnabled()) { + licenseMaxUsers = applicationProperties.getPremium().getMaxUsers(); + } + + if (settings.getLicenseMaxUsers() != licenseMaxUsers) { + settings.setLicenseMaxUsers(licenseMaxUsers); + settingsRepository.save(settings); + log.info("Updated license max users to: {}", licenseMaxUsers); + } + } + + /** + * Validates and enforces the integrity of license settings. This ensures that even if someone + * manually modifies the database, the grandfathering rules are still enforced. + */ + @Transactional + public void validateSettingsIntegrity() { + UserLicenseSettings settings = getOrCreateSettings(); + boolean changed = ensureIntegritySalt(settings); + + Optional signedCountOpt = extractSignedCount(settings); + boolean signatureValid = + signedCountOpt.isPresent() + && signatureMatches( + signedCountOpt.get(), + settings.getGrandfatheredUserSignature(), + settings); + + int targetCount = settings.getGrandfatheredUserCount(); + String targetSignature = settings.getGrandfatheredUserSignature(); + + if (!signatureValid) { + int restoredCount = + signedCountOpt.orElseGet( + () -> + Math.max( + DEFAULT_USER_LIMIT, + (int) userService.getTotalUsersCount())); + log.error( + "Grandfathered user signature invalid or missing. Restoring locked count to {}.", + restoredCount); + targetCount = restoredCount; + targetSignature = generateSignature(targetCount, settings); + changed = true; + } else { + int signedCount = signedCountOpt.get(); + if (targetCount != signedCount) { + log.error( + "Grandfathered user count ({}) was modified without signature update. Restoring to {}.", + targetCount, + signedCount); + targetCount = signedCount; + targetSignature = generateSignature(targetCount, settings); + changed = true; + } + } + + if (targetCount < DEFAULT_USER_LIMIT) { + if (targetCount != DEFAULT_USER_LIMIT) { + log.warn( + "Grandfathered count ({}) is below minimum ({}). Enforcing minimum.", + targetCount, + DEFAULT_USER_LIMIT); + } + targetCount = DEFAULT_USER_LIMIT; + targetSignature = generateSignature(targetCount, settings); + changed = true; + } + + if (targetSignature == null || targetSignature.isBlank()) { + targetSignature = generateSignature(targetCount, settings); + changed = true; + } + + if (changed + || settings.getGrandfatheredUserCount() != targetCount + || (targetSignature != null + && !targetSignature.equals(settings.getGrandfatheredUserSignature()))) { + settings.setGrandfatheredUserCount(targetCount); + settings.setGrandfatheredUserSignature(targetSignature); + settingsRepository.save(settings); + } + } + + /** + * Calculates the maximum allowed users based on grandfathering rules. + * + *

Logic: + * + *

    + *
  • Grandfathered limit = max(5, existing user count at initialization) + *
  • If premium enabled: total limit = grandfathered limit + license maxUsers + *
  • If premium disabled: total limit = grandfathered limit + *
+ * + * @return Maximum number of users allowed + */ + public int calculateMaxAllowedUsers() { + validateSettingsIntegrity(); + UserLicenseSettings settings = getOrCreateSettings(); + + int grandfatheredLimit = settings.getGrandfatheredUserCount(); + if (grandfatheredLimit == 0) { + // Fallback if not initialized yet - should not happen with validation + log.warn("Grandfathered limit is 0, using default: {}", DEFAULT_USER_LIMIT); + grandfatheredLimit = DEFAULT_USER_LIMIT; + } + + int totalLimit = grandfatheredLimit; + + if (applicationProperties.getPremium().isEnabled()) { + totalLimit = grandfatheredLimit + settings.getLicenseMaxUsers(); + } + + log.debug( + "Calculated max allowed users: {} (grandfathered: {}, license: {}, premium enabled: {})", + totalLimit, + grandfatheredLimit, + settings.getLicenseMaxUsers(), + applicationProperties.getPremium().isEnabled()); + + return totalLimit; + } + + /** + * Checks if adding new users would exceed the limit. + * + * @param newUsersCount Number of new users to add + * @return true if the addition would exceed the limit + */ + public boolean wouldExceedLimit(int newUsersCount) { + long currentUserCount = userService.getTotalUsersCount(); + int maxAllowed = calculateMaxAllowedUsers(); + return (currentUserCount + newUsersCount) > maxAllowed; + } + + /** + * Gets the number of available user slots. + * + * @return Number of users that can still be added + */ + public long getAvailableUserSlots() { + long currentUserCount = userService.getTotalUsersCount(); + int maxAllowed = calculateMaxAllowedUsers(); + return Math.max(0, maxAllowed - currentUserCount); + } + + /** + * Gets the grandfathered user count for display purposes. Returns only the excess users beyond + * the base limit (5). + * + *

Examples: + * + *

    + *
  • If grandfathered = 5: returns 0 (base amount, nothing special) + *
  • If grandfathered = 10: returns 5 (5 extra users) + *
  • If grandfathered = 15: returns 10 (10 extra users) + *
+ * + * @return Number of grandfathered users beyond the base limit + */ + public int getDisplayGrandfatheredCount() { + UserLicenseSettings settings = getOrCreateSettings(); + int totalGrandfathered = settings.getGrandfatheredUserCount(); + return Math.max(0, totalGrandfathered - DEFAULT_USER_LIMIT); + } + + /** Gets the current settings. */ + public UserLicenseSettings getSettings() { + return getOrCreateSettings(); + } + + private boolean ensureIntegritySalt(UserLicenseSettings settings) { + if (settings.getIntegritySalt() == null || settings.getIntegritySalt().isBlank()) { + settings.setIntegritySalt(UUID.randomUUID().toString()); + return true; + } + return false; + } + + private Optional extractSignedCount(UserLicenseSettings settings) { + String signature = settings.getGrandfatheredUserSignature(); + if (signature == null || signature.isBlank()) { + return Optional.empty(); + } + + String[] parts = signature.split(SIGNATURE_SEPARATOR, 2); + if (parts.length != 2) { + log.warn("Invalid grandfathered user signature format detected"); + return Optional.empty(); + } + + try { + return Optional.of(Integer.parseInt(parts[0])); + } catch (NumberFormatException ex) { + log.warn("Unable to parse grandfathered user signature count", ex); + return Optional.empty(); + } + } + + private boolean signatureMatches(int count, String signature, UserLicenseSettings settings) { + if (signature == null || signature.isBlank()) { + return false; + } + return generateSignature(count, settings).equals(signature); + } + + private String generateSignature(int count, UserLicenseSettings settings) { + if (settings.getIntegritySalt() == null || settings.getIntegritySalt().isBlank()) { + throw new IllegalStateException("Integrity salt must be initialized before signing."); + } + String payload = buildSignaturePayload(count, settings.getIntegritySalt()); + String secret = deriveIntegritySecret(); + String digest = computeHmac(payload, secret); + return count + SIGNATURE_SEPARATOR + digest; + } + + private String buildSignaturePayload(int count, String salt) { + return count + SIGNATURE_SEPARATOR + salt; + } + + private String deriveIntegritySecret() { + StringBuilder builder = new StringBuilder(); + appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getKey()); + appendIfPresent(builder, applicationProperties.getAutomaticallyGenerated().getUUID()); + appendIfPresent(builder, applicationProperties.getPremium().getKey()); + + if (builder.length() == 0) { + builder.append(DEFAULT_INTEGRITY_SECRET); + } + + return builder.toString(); + } + + private void appendIfPresent(StringBuilder builder, String value) { + if (value != null && !value.isBlank()) { + if (builder.length() > 0) { + builder.append(SIGNATURE_SEPARATOR); + } + builder.append(value); + } + } + + private String computeHmac(String payload, String secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + SecretKeySpec keySpec = + new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + mac.init(keySpec); + byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Failed to compute grandfathered user signature", e); + } catch (InvalidKeyException e) { + throw new IllegalStateException("Invalid key for grandfathered user signature", e); + } + } +} diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 7ca6ab9c8..c5cbd61ba 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -4514,6 +4514,16 @@ "username": "Username", "email": "Email", "emailDisabled": "Email invites require SMTP configuration and mail.enableInvites=true in settings" + }, + "license": { + "users": "users", + "availableSlots": "Available Slots", + "grandfathered": "Grandfathered", + "grandfatheredShort": "{{count}} grandfathered", + "fromLicense": "from license", + "slotsAvailable": "{{count}} user slot(s) available", + "noSlotsAvailable": "No slots available", + "currentUsage": "Currently using {{current}} of {{max}} user licences" } }, "teams": { @@ -4597,6 +4607,89 @@ } } }, + "plan": { + "currency": "Currency", + "popular": "Popular", + "current": "Current Plan", + "upgrade": "Upgrade", + "contact": "Contact Us", + "customPricing": "Custom", + "showComparison": "Compare All Features", + "hideComparison": "Hide Feature Comparison", + "featureComparison": "Feature Comparison", + "activePlan": { + "title": "Active Plan", + "subtitle": "Your current subscription details" + }, + "availablePlans": { + "title": "Available Plans", + "subtitle": "Choose the plan that fits your needs" + }, + "static": { + "title": "Billing Information", + "message": "Online billing is not currently configured. To upgrade your plan or manage subscriptions, please contact us directly.", + "contactSales": "Contact Sales", + "contactToUpgrade": "Contact us to upgrade or customize your plan", + "maxUsers": "Max Users", + "upTo": "Up to" + }, + "period": { + "month": "month" + }, + "free": { + "name": "Free", + "highlight1": "Limited Tool Usage Per week", + "highlight2": "Access to all tools", + "highlight3": "Community support" + }, + "pro": { + "name": "Pro", + "highlight1": "Unlimited Tool Usage", + "highlight2": "Advanced PDF tools", + "highlight3": "No watermarks" + }, + "enterprise": { + "name": "Enterprise", + "highlight1": "Custom pricing", + "highlight2": "Dedicated support", + "highlight3": "Latest features" + }, + "feature": { + "title": "Feature", + "pdfTools": "Basic PDF Tools", + "fileSize": "File Size Limit", + "automation": "Automate tool workflows", + "api": "API Access", + "priority": "Priority Support", + "customPricing": "Custom Pricing" + } + }, + "subscription": { + "status": { + "active": "Active", + "pastDue": "Past Due", + "canceled": "Canceled", + "incomplete": "Incomplete", + "trialing": "Trial", + "none": "No Subscription" + }, + "renewsOn": "Renews on {{date}}", + "cancelsOn": "Cancels on {{date}}" + }, + "billing": { + "manageBilling": "Manage Billing", + "portal": { + "error": "Failed to open billing portal" + } + }, + "payment": { + "preparing": "Preparing your checkout...", + "upgradeTitle": "Upgrade to {{planName}}", + "success": "Payment Successful!", + "successMessage": "Your subscription has been activated successfully. You will receive a confirmation email shortly.", + "autoClose": "This window will close automatically...", + "error": "Payment Error" + }, "firstLogin": { "title": "First Time Login", "welcomeTitle": "Welcome!", @@ -4644,5 +4737,104 @@ "creating": "Creating Account...", "alreadyHaveAccount": "Already have an account?", "signIn": "Sign in" + }, + "audit": { + "error": { + "title": "Error loading audit system" + }, + "notAvailable": "Audit system not available", + "notAvailableMessage": "The audit system is not configured or not available.", + "disabled": "Audit logging is disabled", + "disabledMessage": "Enable audit logging in your application configuration to track system events.", + "systemStatus": { + "title": "System Status", + "status": "Audit Logging", + "enabled": "Enabled", + "disabled": "Disabled", + "level": "Audit Level", + "retention": "Retention Period", + "days": "days", + "totalEvents": "Total Events" + }, + "tabs": { + "dashboard": "Dashboard", + "events": "Audit Events", + "export": "Export" + }, + "charts": { + "title": "Audit Dashboard", + "error": "Error loading charts", + "day": "Day", + "week": "Week", + "month": "Month", + "byType": "Events by Type", + "byUser": "Events by User", + "overTime": "Events Over Time" + }, + "events": { + "title": "Audit Events", + "filterByType": "Filter by type", + "filterByUser": "Filter by user", + "startDate": "Start date", + "endDate": "End date", + "clearFilters": "Clear", + "error": "Error loading events", + "noEvents": "No events found", + "timestamp": "Timestamp", + "type": "Type", + "user": "User", + "ipAddress": "IP Address", + "actions": "Actions", + "viewDetails": "View Details", + "eventDetails": "Event Details", + "details": "Details" + }, + "export": { + "title": "Export Audit Data", + "description": "Export audit events to CSV or JSON format. Use filters to limit the exported data.", + "format": "Export Format", + "filters": "Filters (Optional)", + "filterByType": "Filter by type", + "filterByUser": "Filter by user", + "startDate": "Start date", + "endDate": "End date", + "clearFilters": "Clear", + "exportButton": "Export Data", + "error": "Failed to export data" + } + }, + "usage": { + "noData": "No data available", + "error": "Error loading usage statistics", + "noDataMessage": "No usage statistics are currently available.", + "controls": { + "top10": "Top 10", + "top20": "Top 20", + "all": "All", + "refresh": "Refresh", + "includeHome": "Include Homepage ('/')", + "includeLogin": "Include Login Page ('/login')" + }, + "showing": { + "top10": "Top 10", + "top20": "Top 20", + "all": "All" + }, + "stats": { + "totalEndpoints": "Total Endpoints", + "totalVisits": "Total Visits", + "showing": "Showing", + "selectedVisits": "Selected Visits" + }, + "chart": { + "title": "Endpoint Usage Chart" + }, + "table": { + "title": "Detailed Statistics", + "endpoint": "Endpoint", + "visits": "Visits", + "percentage": "Percentage", + "noData": "No data available" + } } } diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index 09bcd20fe..f2a4ec6de 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -1,13 +1,14 @@ import React, { useMemo, useState, useEffect } from 'react'; -import { Modal, Text, ActionIcon } from '@mantine/core'; +import { Modal, Text, ActionIcon, Tooltip } from '@mantine/core'; import { useMediaQuery } from '@mantine/hooks'; +import { useNavigate, useLocation } from 'react-router-dom'; import LocalIcon from '@app/components/shared/LocalIcon'; import Overview from '@app/components/shared/config/configSections/Overview'; import { createConfigNavSections } from '@app/components/shared/config/configNavSections'; import { NavKey } from '@app/components/shared/config/types'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import '@app/components/shared/AppConfigModal.css'; -import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE, Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex'; interface AppConfigModalProps { opened: boolean; @@ -15,20 +16,50 @@ interface AppConfigModalProps { } const AppConfigModal: React.FC = ({ opened, onClose }) => { + const navigate = useNavigate(); + const location = useLocation(); const [active, setActive] = useState('overview'); const isMobile = useMediaQuery("(max-width: 1024px)"); const { config } = useAppConfig(); + // Extract section from URL path (e.g., /settings/people -> people) + const getSectionFromPath = (pathname: string): NavKey | null => { + const match = pathname.match(/\/settings\/([^/]+)/); + if (match && match[1]) { + const validSections: NavKey[] = [ + 'overview', 'people', 'teams', 'general', 'hotkeys', + 'adminGeneral', 'adminSecurity', 'adminConnections', 'adminLegal', + 'adminPrivacy', 'adminDatabase', 'adminPremium', 'adminFeatures', + 'adminPlan', 'adminAudit', 'adminUsage', 'adminEndpoints', 'adminAdvanced' + ]; + const section = match[1] as NavKey; + return validSections.includes(section) ? section : null; + } + return null; + }; + + // Sync active state with URL path + useEffect(() => { + const section = getSectionFromPath(location.pathname); + if (opened && section) { + setActive(section); + } else if (opened && location.pathname.startsWith('/settings') && !section) { + // If at /settings without a section, redirect to overview + navigate('/settings/overview', { replace: true }); + } + }, [location.pathname, opened, navigate]); + + // Handle custom events for backwards compatibility useEffect(() => { const handler = (ev: Event) => { const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined; if (detail?.key) { - setActive(detail.key); + navigate(`/settings/${detail.key}`); } }; window.addEventListener('appConfig:navigate', handler as EventListener); return () => window.removeEventListener('appConfig:navigate', handler as EventListener); - }, []); + }, [navigate]); const colors = useMemo(() => ({ navBg: 'var(--modal-nav-bg)', @@ -46,17 +77,19 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { console.log('Logout placeholder for SaaS compatibility'); }; - // Get isAdmin from app config (based on JWT role) + // Get isAdmin and runningEE from app config const isAdmin = config?.isAdmin ?? false; + const runningEE = config?.runningEE ?? false; // Left navigation structure and icons const configNavSections = useMemo(() => createConfigNavSections( Overview, handleLogout, - isAdmin + isAdmin, + runningEE ), - [isAdmin] + [isAdmin, runningEE] ); const activeLabel = useMemo(() => { @@ -75,10 +108,16 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { return null; }, [configNavSections, active]); + const handleClose = () => { + // Navigate back to home when closing modal + navigate('/', { replace: true }); + onClose(); + }; + return ( = ({ opened, onClose }) => {
{section.items.map(item => { const isActive = active === item.key; + const isDisabled = item.disabled ?? false; const color = isActive ? colors.navItemActive : colors.navItem; const iconSize = isMobile ? 28 : 18; - return ( + + const navItemContent = (
setActive(item.key)} + onClick={() => { + if (!isDisabled) { + setActive(item.key); + navigate(`/settings/${item.key}`); + } + }} className={`modal-nav-item ${isMobile ? 'mobile' : ''}`} style={{ background: isActive ? colors.navItemActiveBg : 'transparent', + opacity: isDisabled ? 0.5 : 1, + cursor: isDisabled ? 'not-allowed' : 'pointer', }} > @@ -128,6 +176,20 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { )}
); + + return isDisabled && item.disabledTooltip ? ( + + {navItemContent} + + ) : ( + {navItemContent} + ); })}
@@ -147,7 +209,7 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { }} > {activeLabel} - + diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index d10d4df07..b82fb09b0 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, forwardRef, useEffect } from "react"; import { ActionIcon, Stack, Divider } from "@mantine/core"; import { useTranslation } from 'react-i18next'; +import { useNavigate, useLocation } from 'react-router-dom'; import LocalIcon from '@app/components/shared/LocalIcon'; import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvider"; import { useIsOverflowing } from '@app/hooks/useIsOverflowing'; @@ -23,6 +24,8 @@ import { const QuickAccessBar = forwardRef((_, ref) => { const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); @@ -34,6 +37,12 @@ const QuickAccessBar = forwardRef((_, ref) => { const scrollableRef = useRef(null); const isOverflow = useIsOverflowing(scrollableRef); + // Open modal if URL is at /settings/* + useEffect(() => { + const isSettings = location.pathname.startsWith('/settings'); + setConfigModalOpen(isSettings); + }, [location.pathname]); + useEffect(() => { const next = getActiveNavButton(selectedToolKey, readerMode); setActiveButton(next); @@ -180,6 +189,7 @@ const QuickAccessBar = forwardRef((_, ref) => { size: 'lg', type: 'modal', onClick: () => { + navigate('/settings/overview'); setConfigModalOpen(true); } } diff --git a/frontend/src/core/components/shared/config/configNavSections.tsx b/frontend/src/core/components/shared/config/configNavSections.tsx index cb967378b..f91fd9725 100644 --- a/frontend/src/core/components/shared/config/configNavSections.tsx +++ b/frontend/src/core/components/shared/config/configNavSections.tsx @@ -14,12 +14,17 @@ import AdminLegalSection from '@app/components/shared/config/configSections/Admi import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection'; import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection'; import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection'; +import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection'; +import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection'; +import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection'; export interface ConfigNavItem { key: NavKey; label: string; icon: string; component: React.ReactNode; + disabled?: boolean; + disabledTooltip?: string; } export interface ConfigNavSection { @@ -40,7 +45,8 @@ export interface ConfigColors { export const createConfigNavSections = ( Overview: React.ComponentType<{ onLogoutClick: () => void }>, onLogoutClick: () => void, - isAdmin: boolean = false + isAdmin: boolean = false, + runningEE: boolean = false ): ConfigNavSection[] => { const sections: ConfigNavSection[] = [ { @@ -90,10 +96,11 @@ export const createConfigNavSections = ( }, ]; - // Add Admin Settings section if user is admin + // Add Admin sections if user is admin if (isAdmin) { + // Configuration sections.push({ - title: 'Admin Settings', + title: 'Configuration', items: [ { key: 'adminGeneral', @@ -101,42 +108,6 @@ export const createConfigNavSections = ( icon: 'settings-rounded', component: }, - { - key: 'adminSecurity', - label: 'Security', - icon: 'shield-rounded', - component: - }, - { - key: 'adminConnections', - label: 'Connections', - icon: 'link-rounded', - component: - }, - { - key: 'adminLegal', - label: 'Legal', - icon: 'gavel-rounded', - component: - }, - { - key: 'adminPrivacy', - label: 'Privacy', - icon: 'visibility-rounded', - component: - }, - { - key: 'adminDatabase', - label: 'Database', - icon: 'storage-rounded', - component: - }, - { - key: 'adminPremium', - label: 'Premium', - icon: 'star-rounded', - component: - }, { key: 'adminFeatures', label: 'Features', @@ -149,6 +120,12 @@ export const createConfigNavSections = ( icon: 'api-rounded', component: }, + { + key: 'adminDatabase', + label: 'Database', + icon: 'storage-rounded', + component: + }, { key: 'adminAdvanced', label: 'Advanced', @@ -157,6 +134,79 @@ export const createConfigNavSections = ( }, ], }); + + // Security & Authentication + sections.push({ + title: 'Security & Authentication', + items: [ + { + key: 'adminSecurity', + label: 'Security', + icon: 'shield-rounded', + component: + }, + { + key: 'adminConnections', + label: 'Connections', + icon: 'link-rounded', + component: + }, + ], + }); + + // Licensing & Analytics + sections.push({ + title: 'Licensing & Analytics', + items: [ + { + key: 'adminPremium', + label: 'Premium', + icon: 'star-rounded', + component: + }, + { + key: 'adminPlan', + label: 'Plan', + icon: 'receipt-long-rounded', + component: + }, + { + key: 'adminAudit', + label: 'Audit', + icon: 'fact-check-rounded', + component: , + disabled: !runningEE, + disabledTooltip: 'Requires Enterprise license' + }, + { + key: 'adminUsage', + label: 'Usage Analytics', + icon: 'analytics-rounded', + component: , + disabled: !runningEE, + disabledTooltip: 'Requires Enterprise license' + }, + ], + }); + + // Policies & Privacy + sections.push({ + title: 'Policies & Privacy', + items: [ + { + key: 'adminLegal', + label: 'Legal', + icon: 'gavel-rounded', + component: + }, + { + key: 'adminPrivacy', + label: 'Privacy', + icon: 'visibility-rounded', + component: + }, + ], + }); } return sections; diff --git a/frontend/src/core/components/shared/config/configSections/AdminAuditSection.tsx b/frontend/src/core/components/shared/config/configSections/AdminAuditSection.tsx new file mode 100644 index 000000000..f03bd529a --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/AdminAuditSection.tsx @@ -0,0 +1,99 @@ +import React, { useState, useEffect } from 'react'; +import { Tabs, Loader, Alert, Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import auditService, { AuditSystemStatus as AuditStatus } from '@app/services/auditService'; +import AuditSystemStatus from './audit/AuditSystemStatus'; +import AuditChartsSection from './audit/AuditChartsSection'; +import AuditEventsTable from './audit/AuditEventsTable'; +import AuditExportSection from './audit/AuditExportSection'; + +const AdminAuditSection: React.FC = () => { + const { t } = useTranslation(); + const [systemStatus, setSystemStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchSystemStatus = async () => { + try { + setLoading(true); + setError(null); + const status = await auditService.getSystemStatus(); + setSystemStatus(status); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load audit system status'); + } finally { + setLoading(false); + } + }; + + fetchSystemStatus(); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!systemStatus) { + return ( + + {t('audit.notAvailableMessage', 'The audit system is not configured or not available.')} + + ); + } + + return ( + + + + {systemStatus.enabled ? ( + + + + {t('audit.tabs.dashboard', 'Dashboard')} + + + {t('audit.tabs.events', 'Audit Events')} + + + {t('audit.tabs.export', 'Export')} + + + + + + + + + + + + + + + + ) : ( + + {t( + 'audit.disabledMessage', + 'Enable audit logging in your application configuration to track system events.' + )} + + )} + + ); +}; + +export default AdminAuditSection; diff --git a/frontend/src/core/components/shared/config/configSections/AdminUsageSection.tsx b/frontend/src/core/components/shared/config/configSections/AdminUsageSection.tsx new file mode 100644 index 000000000..2af5e375f --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/AdminUsageSection.tsx @@ -0,0 +1,196 @@ +import React, { useState, useEffect } from 'react'; +import { + Stack, + Group, + Text, + Button, + SegmentedControl, + Loader, + Alert, + Card, + Checkbox, +} from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import usageAnalyticsService, { EndpointStatisticsResponse } from '@app/services/usageAnalyticsService'; +import UsageAnalyticsChart from './usage/UsageAnalyticsChart'; +import UsageAnalyticsTable from './usage/UsageAnalyticsTable'; +import LocalIcon from '@app/components/shared/LocalIcon'; + +const AdminUsageSection: React.FC = () => { + const { t } = useTranslation(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [displayMode, setDisplayMode] = useState<'top10' | 'top20' | 'all'>('top10'); + const [includeHome, setIncludeHome] = useState(true); + const [includeLogin, setIncludeLogin] = useState(true); + + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + const limit = displayMode === 'all' ? undefined : displayMode === 'top10' ? 10 : 20; + const response = await usageAnalyticsService.getEndpointStatistics( + limit, + includeHome, + includeLogin + ); + + setData(response); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load usage statistics'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, [displayMode, includeHome, includeLogin]); + + const handleRefresh = () => { + fetchData(); + }; + + const getDisplayModeLabel = () => { + switch (displayMode) { + case 'top10': + return t('usage.showing.top10', 'Top 10'); + case 'top20': + return t('usage.showing.top20', 'Top 20'); + case 'all': + return t('usage.showing.all', 'All'); + default: + return ''; + } + }; + + // Early returns for loading/error states + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!data) { + return ( + + {t('usage.noDataMessage', 'No usage statistics are currently available.')} + + ); + } + + const chartData = data.endpoints.map((e) => ({ label: e.endpoint, value: e.visits })); + + const displayedVisits = data.endpoints.reduce((sum, e) => sum + e.visits, 0); + + const displayedPercentage = data.totalVisits > 0 + ? ((displayedVisits / data.totalVisits) * 100).toFixed(1) + : '0'; + + return ( + + {/* Controls */} + + + + + setDisplayMode(value as 'top10' | 'top20' | 'all')} + data={[ + { + value: 'top10', + label: t('usage.controls.top10', 'Top 10'), + }, + { + value: 'top20', + label: t('usage.controls.top20', 'Top 20'), + }, + { + value: 'all', + label: t('usage.controls.all', 'All'), + }, + ]} + /> + + + + + + setIncludeHome(event.currentTarget.checked)} + /> + setIncludeLogin(event.currentTarget.checked)} + /> + + + {/* Statistics Summary */} + +
+ + {t('usage.stats.totalEndpoints', 'Total Endpoints')} + + + {data.totalEndpoints} + +
+
+ + {t('usage.stats.totalVisits', 'Total Visits')} + + + {data.totalVisits.toLocaleString()} + +
+
+ + {t('usage.stats.showing', 'Showing')} + + + {getDisplayModeLabel()} + +
+
+ + {t('usage.stats.selectedVisits', 'Selected Visits')} + + + {displayedVisits.toLocaleString()} ({displayedPercentage}%) + +
+
+
+
+ + {/* Chart and Table */} + + +
+ ); +}; + +export default AdminUsageSection; diff --git a/frontend/src/core/components/shared/config/configSections/PeopleSection.tsx b/frontend/src/core/components/shared/config/configSections/PeopleSection.tsx index 3eb90db6e..1a5b8b722 100644 --- a/frontend/src/core/components/shared/config/configSections/PeopleSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/PeopleSection.tsx @@ -40,6 +40,16 @@ export default function PeopleSection() { const [processing, setProcessing] = useState(false); const [inviteMode, setInviteMode] = useState<'email' | 'direct'>('direct'); + // License information + const [licenseInfo, setLicenseInfo] = useState<{ + maxAllowedUsers: number; + availableSlots: number; + grandfatheredUserCount: number; + licenseMaxUsers: number; + premiumEnabled: boolean; + totalUsers: number; + } | null>(null); + // Form state for direct invite const [inviteForm, setInviteForm] = useState({ username: '', @@ -89,6 +99,16 @@ export default function PeopleSection() { setUsers(enrichedUsers); setTeams(teamsData); + + // Store license information + setLicenseInfo({ + maxAllowedUsers: adminData.maxAllowedUsers, + availableSlots: adminData.availableSlots, + grandfatheredUserCount: adminData.grandfatheredUserCount, + licenseMaxUsers: adminData.licenseMaxUsers, + premiumEnabled: adminData.premiumEnabled, + totalUsers: adminData.totalUsers, + }); } catch (error) { console.error('Failed to fetch people data:', error); alert({ alertType: 'error', title: 'Failed to load people data' }); @@ -325,6 +345,39 @@ export default function PeopleSection() { + {/* License Information - Compact */} + {licenseInfo && ( + + + {licenseInfo.totalUsers} + / + {licenseInfo.maxAllowedUsers} + {t('workspace.people.license.users', 'users')} + + + {licenseInfo.availableSlots === 0 && ( + + {t('workspace.people.license.noSlotsAvailable', 'No slots available')} + + )} + + {licenseInfo.grandfatheredUserCount > 0 && ( + + • + + {t('workspace.people.license.grandfatheredShort', '{{count}} grandfathered', { count: licenseInfo.grandfatheredUserCount })} + + + )} + + {licenseInfo.premiumEnabled && licenseInfo.licenseMaxUsers > 0 && ( + + +{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')} + + )} + + )} + {/* Header Actions */} setSearchQuery(e.currentTarget.value)} style={{ maxWidth: 300 }} /> - + 0} + position="bottom" + withArrow + > + + {/* Members Table */} - - - +
+ + + + {t('workspace.people.user')} + + + {t('workspace.people.role')} + + + {t('workspace.people.team')} + + + + + + {filteredUsers.length === 0 ? ( - {t('workspace.people.user')} - {t('workspace.people.role')} - {t('workspace.people.team')} - {t('workspace.people.status')} - + + + {t('workspace.people.noMembersFound')} + + - - - {filteredUsers.length === 0 ? ( - - - - {t('workspace.people.noMembersFound')} - - - - ) : ( - filteredUsers.map((user) => ( - - -
- {user.isActive && ( -
( + + + +
+ {user.username.charAt(0).toUpperCase()} +
+
+ + - )} -
- + > {user.username} - {user.email && ( - - {user.email} - - )} -
+
+ {user.email && ( + + {user.email} + + )}
-
- - - {(user.rolesAsString || '').includes('ROLE_ADMIN') ? t('workspace.people.admin') : t('workspace.people.member')} - - - - {user.team?.name || '—'} - - - - {user.enabled ? t('workspace.people.active') : t('workspace.people.disabled')} - - + + + + + {(user.rolesAsString || '').includes('ROLE_ADMIN') + ? t('workspace.people.admin', 'Admin') + : t('workspace.people.member', 'Member')} + + + + {user.team?.name ? ( + + + {user.team.name} + + + ) : ( + + )} + {/* Info icon with tooltip */} @@ -456,9 +581,8 @@ export default function PeopleSection() {
)) )} - -
-
+ + {/* Add Member Modal */} @@ -495,6 +619,32 @@ export default function PeopleSection() { )} + {/* License Warning/Info */} + {licenseInfo && ( + + + + 0 ? 'info' : 'warning'} width="1rem" height="1rem" /> + + {licenseInfo.availableSlots > 0 + ? t('workspace.people.license.slotsAvailable', { + count: licenseInfo.availableSlots, + defaultValue: `${licenseInfo.availableSlots} user slot(s) available` + }) + : t('workspace.people.license.noSlotsAvailable', 'No user slots available')} + + + + {t('workspace.people.license.currentUsage', { + current: licenseInfo.totalUsers, + max: licenseInfo.maxAllowedUsers, + defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses` + })} + + + + )} + {/* Mode Toggle */} diff --git a/frontend/src/core/components/shared/config/configSections/TeamDetailsSection.tsx b/frontend/src/core/components/shared/config/configSections/TeamDetailsSection.tsx index 61dfe2a84..dd6e14bef 100644 --- a/frontend/src/core/components/shared/config/configSections/TeamDetailsSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/TeamDetailsSection.tsx @@ -42,6 +42,11 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio const [selectedTeamId, setSelectedTeamId] = useState(''); const [processing, setProcessing] = useState(false); + // License information + const [licenseInfo, setLicenseInfo] = useState<{ + availableSlots: number; + } | null>(null); + useEffect(() => { fetchTeamDetails(); fetchAllTeams(); @@ -50,12 +55,20 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio const fetchTeamDetails = async () => { try { setLoading(true); - const data = await teamService.getTeamDetails(teamId); + const [data, adminData] = await Promise.all([ + teamService.getTeamDetails(teamId), + userManagementService.getUsers(), + ]); console.log('[TeamDetailsSection] Raw data:', data); setTeam(data.team); setTeamUsers(Array.isArray(data.teamUsers) ? data.teamUsers : []); setAvailableUsers(Array.isArray(data.availableUsers) ? data.availableUsers : []); setUserLastRequest(data.userLastRequest || {}); + + // Store license information + setLicenseInfo({ + availableSlots: adminData.availableSlots, + }); } catch (error) { console.error('Failed to fetch team details:', error); alert({ alertType: 'error', title: 'Failed to load team details' }); @@ -227,65 +240,131 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio {/* Add Member Button */} - + + {/* Members Table */} - - - - - {t('workspace.people.user')} - {t('workspace.people.role')} - {t('workspace.people.status')} - - - +
+ + + + {t('workspace.people.user')} + + + {t('workspace.people.role')} + + + + {teamUsers.length === 0 ? ( - + {t('workspace.teams.noMembers', 'No members in this team')} ) : ( - teamUsers.map((user) => ( - - -
- - {user.username} - - {user.email && ( - - {user.email} - - )} -
-
- - - {(user.rolesAsString || '').includes('ROLE_ADMIN') - ? t('workspace.people.admin') - : t('workspace.people.member')} - - - - - {user.enabled ? t('workspace.people.active') : t('workspace.people.disabled')} - - + teamUsers.map((user) => { + const isActive = userLastRequest[user.username] && + (Date.now() - userLastRequest[user.username]) < 5 * 60 * 1000; // Active within last 5 minutes + + return ( + + + +
+ {user.username.charAt(0).toUpperCase()} +
+
+ + + {user.username} + + + {user.email && ( + + {user.email} + + )} +
+
+
+ + + {(user.rolesAsString || '').includes('ROLE_ADMIN') + ? t('workspace.people.admin') + : t('workspace.people.member')} + + {/* Info icon with tooltip */} @@ -352,11 +431,11 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio
- )) + ); + }) )}
-
-
+ {/* Add Member Modal */} @@ -433,8 +512,8 @@ export default function TeamDetailsSection({ teamId, onBack }: TeamDetailsSectio size="lg" style={{ position: 'absolute', - top: '-8px', - right: '-8px', + top: -8, + right: -8, zIndex: 1, }} /> diff --git a/frontend/src/core/components/shared/config/configSections/TeamsSection.tsx b/frontend/src/core/components/shared/config/configSections/TeamsSection.tsx index e8b3b5b41..bc932e4dc 100644 --- a/frontend/src/core/components/shared/config/configSections/TeamsSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/TeamsSection.tsx @@ -15,6 +15,7 @@ import { Paper, Select, CloseButton, + Tooltip, } from '@mantine/core'; import LocalIcon from '@app/components/shared/LocalIcon'; import { alert } from '@app/components/toast'; @@ -224,15 +225,24 @@ export default function TeamsSection() { {/* Teams Table */} - - - - - {t('workspace.teams.teamName')} - {t('workspace.teams.totalMembers')} - - - +
+ + + + {t('workspace.teams.teamName')} + + + {t('workspace.teams.totalMembers')} + + + + {teams.length === 0 ? ( @@ -246,23 +256,44 @@ export default function TeamsSection() { teams.map((team) => ( setViewingTeamId(team.id)} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-0)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} > - - {team.name} - + + + {team.name} + + {team.name === 'Internal' && ( - + {t('workspace.teams.system')} )} - {team.userCount || 0} + {team.userCount || 0} e.stopPropagation()}> @@ -297,8 +328,7 @@ export default function TeamsSection() { )) )} -
-
+ {/* Create Team Modal */} @@ -361,8 +391,8 @@ export default function TeamsSection() { size="lg" style={{ position: 'absolute', - top: '-8px', - right: '-8px', + top: -8, + right: -8, zIndex: 1 }} /> @@ -409,8 +439,8 @@ export default function TeamsSection() { size="lg" style={{ position: 'absolute', - top: '-8px', - right: '-8px', + top: -8, + right: -8, zIndex: 1 }} /> diff --git a/frontend/src/core/components/shared/config/configSections/audit/AuditChartsSection.tsx b/frontend/src/core/components/shared/config/configSections/audit/AuditChartsSection.tsx new file mode 100644 index 000000000..671092945 --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/audit/AuditChartsSection.tsx @@ -0,0 +1,165 @@ +import React, { useState, useEffect } from 'react'; +import { Card, Text, Group, Stack, SegmentedControl, Loader, Alert } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import auditService, { AuditChartsData } from '@app/services/auditService'; + +interface SimpleBarChartProps { + data: { label: string; value: number }[]; + title: string; + color?: string; +} + +const SimpleBarChart: React.FC = ({ data, title, color = 'blue' }) => { + const maxValue = Math.max(...data.map((d) => d.value), 1); + + return ( + + + {title} + +
+ {data.map((item, index) => ( +
+ + + {item.label} + + + {item.value} + + +
+
+
+
+ ))} +
+ + ); +}; + +interface AuditChartsSectionProps {} + +const AuditChartsSection: React.FC = () => { + const { t } = useTranslation(); + const [timePeriod, setTimePeriod] = useState<'day' | 'week' | 'month'>('week'); + const [chartsData, setChartsData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchChartsData = async () => { + try { + setLoading(true); + setError(null); + const data = await auditService.getChartsData(timePeriod); + setChartsData(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load charts'); + } finally { + setLoading(false); + } + }; + + fetchChartsData(); + }, [timePeriod]); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!chartsData) { + return null; + } + + const eventsByTypeData = chartsData.eventsByType.labels.map((label, index) => ({ + label, + value: chartsData.eventsByType.values[index], + })); + + const eventsByUserData = chartsData.eventsByUser.labels.map((label, index) => ({ + label, + value: chartsData.eventsByUser.values[index], + })); + + const eventsOverTimeData = chartsData.eventsOverTime.labels.map((label, index) => ({ + label, + value: chartsData.eventsOverTime.values[index], + })); + + return ( + + + + + {t('audit.charts.title', 'Audit Dashboard')} + + setTimePeriod(value as 'day' | 'week' | 'month')} + data={[ + { label: t('audit.charts.day', 'Day'), value: 'day' }, + { label: t('audit.charts.week', 'Week'), value: 'week' }, + { label: t('audit.charts.month', 'Month'), value: 'month' }, + ]} + /> + + +
+ + + +
+
+
+ ); +}; + +export default AuditChartsSection; diff --git a/frontend/src/core/components/shared/config/configSections/audit/AuditEventsTable.tsx b/frontend/src/core/components/shared/config/configSections/audit/AuditEventsTable.tsx new file mode 100644 index 000000000..cfd3fcd1d --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/audit/AuditEventsTable.tsx @@ -0,0 +1,300 @@ +import React, { useState, useEffect } from 'react'; +import { + Card, + Text, + Group, + Stack, + Select, + TextInput, + Button, + Pagination, + Modal, + Code, + Loader, + Alert, +} from '@mantine/core'; +import { DateInput } from '@mantine/dates'; +import { useTranslation } from 'react-i18next'; +import auditService, { AuditEvent, AuditFilters } from '@app/services/auditService'; + +interface AuditEventsTableProps {} + +const AuditEventsTable: React.FC = () => { + const { t } = useTranslation(); + const [events, setEvents] = useState([]); + const [totalPages, setTotalPages] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedEvent, setSelectedEvent] = useState(null); + const [eventTypes, setEventTypes] = useState([]); + const [users, setUsers] = useState([]); + + // Filters + const [filters, setFilters] = useState({ + eventType: undefined, + username: undefined, + startDate: undefined, + endDate: undefined, + page: 0, + pageSize: 20, + }); + + 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 metadata:', err); + } + }; + + fetchMetadata(); + }, []); + + useEffect(() => { + const fetchEvents = async () => { + try { + setLoading(true); + setError(null); + const response = await auditService.getEvents({ + ...filters, + page: currentPage - 1, + }); + setEvents(response.events); + setTotalPages(response.totalPages); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load events'); + } finally { + setLoading(false); + } + }; + + fetchEvents(); + }, [filters, currentPage]); + + const handleFilterChange = (key: keyof AuditFilters, value: any) => { + setFilters((prev) => ({ ...prev, [key]: value })); + setCurrentPage(1); + }; + + const handleClearFilters = () => { + setFilters({ + eventType: undefined, + username: undefined, + startDate: undefined, + endDate: undefined, + page: 0, + pageSize: 20, + }); + setCurrentPage(1); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleString(); + }; + + return ( + + + + {t('audit.events.title', 'Audit Events')} + + + {/* Filters */} + + ({ value: user, label: user }))} + value={filters.username} + onChange={(value) => handleFilterChange('username', value || undefined)} + clearable + searchable + style={{ flex: 1, minWidth: 200 }} + /> + + handleFilterChange('startDate', value ? value.toISOString() : undefined) + } + clearable + style={{ flex: 1, minWidth: 150 }} + /> + + handleFilterChange('endDate', value ? value.toISOString() : undefined) + } + clearable + style={{ flex: 1, minWidth: 150 }} + /> + + + + {/* Table */} + {loading ? ( +
+ +
+ ) : error ? ( + + {error} + + ) : ( + <> +
+ + + + + + + + + + + + {events.length === 0 ? ( + + + + ) : ( + events.map((event) => ( + { + e.currentTarget.style.backgroundColor = + 'var(--mantine-color-gray-0)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + + + + + + + )) + )} + +
+ {t('audit.events.timestamp', 'Timestamp')} + + {t('audit.events.type', 'Type')} + + {t('audit.events.user', 'User')} + + {t('audit.events.ipAddress', 'IP Address')} + + {t('audit.events.actions', 'Actions')} +
+ {t('audit.events.noEvents', 'No events found')} +
+ {formatDate(event.timestamp)} + + {event.eventType} + + {event.username} + + {event.ipAddress} + + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( + + + + )} + + )} +
+ + {/* Event Details Modal */} + setSelectedEvent(null)} + title={t('audit.events.eventDetails', 'Event Details')} + size="lg" + > + {selectedEvent && ( + +
+ + {t('audit.events.timestamp', 'Timestamp')} + + {formatDate(selectedEvent.timestamp)} +
+
+ + {t('audit.events.type', 'Type')} + + {selectedEvent.eventType} +
+
+ + {t('audit.events.user', 'User')} + + {selectedEvent.username} +
+
+ + {t('audit.events.ipAddress', 'IP Address')} + + {selectedEvent.ipAddress} +
+
+ + {t('audit.events.details', 'Details')} + + + {JSON.stringify(selectedEvent.details, null, 2)} + +
+
+ )} +
+
+ ); +}; + +export default AuditEventsTable; diff --git a/frontend/src/core/components/shared/config/configSections/audit/AuditExportSection.tsx b/frontend/src/core/components/shared/config/configSections/audit/AuditExportSection.tsx new file mode 100644 index 000000000..501c4536e --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/audit/AuditExportSection.tsx @@ -0,0 +1,182 @@ +import React, { useState } from 'react'; +import { + Card, + Text, + Group, + Stack, + Select, + Button, + SegmentedControl, +} from '@mantine/core'; +import { DateInput } from '@mantine/dates'; +import { useTranslation } from 'react-i18next'; +import auditService, { AuditFilters } from '@app/services/auditService'; +import LocalIcon from '@app/components/shared/LocalIcon'; + +interface AuditExportSectionProps {} + +const AuditExportSection: React.FC = () => { + const { t } = useTranslation(); + const [exportFormat, setExportFormat] = useState<'csv' | 'json'>('csv'); + const [exporting, setExporting] = useState(false); + const [eventTypes, setEventTypes] = useState([]); + const [users, setUsers] = useState([]); + + // Filters for export + const [filters, setFilters] = useState({ + eventType: undefined, + username: undefined, + startDate: undefined, + endDate: undefined, + }); + + React.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 metadata:', err); + } + }; + + fetchMetadata(); + }, []); + + const handleExport = async () => { + try { + setExporting(true); + + const blob = await auditService.exportData(exportFormat, filters); + + // Create download link + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `audit-export-${new Date().toISOString()}.${exportFormat}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + } catch (err) { + console.error('Export failed:', err); + alert(t('audit.export.error', 'Failed to export data')); + } finally { + setExporting(false); + } + }; + + const handleFilterChange = (key: keyof AuditFilters, value: any) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }; + + const handleClearFilters = () => { + setFilters({ + eventType: undefined, + username: undefined, + startDate: undefined, + endDate: undefined, + }); + }; + + return ( + + + + {t('audit.export.title', 'Export Audit Data')} + + + + {t( + 'audit.export.description', + 'Export audit events to CSV or JSON format. Use filters to limit the exported data.' + )} + + + {/* Format Selection */} +
+ + {t('audit.export.format', 'Export Format')} + + setExportFormat(value as 'csv' | 'json')} + data={[ + { label: 'CSV', value: 'csv' }, + { label: 'JSON', value: 'json' }, + ]} + /> +
+ + {/* Filters */} +
+ + {t('audit.export.filters', 'Filters (Optional)')} + + + + ({ value: user, label: user }))} + value={filters.username} + onChange={(value) => handleFilterChange('username', value || undefined)} + clearable + searchable + style={{ flex: 1, minWidth: 200 }} + /> + + + + handleFilterChange('startDate', value ? value.toISOString() : undefined) + } + clearable + style={{ flex: 1, minWidth: 200 }} + /> + + handleFilterChange('endDate', value ? value.toISOString() : undefined) + } + clearable + style={{ flex: 1, minWidth: 200 }} + /> + + + +
+ + {/* Export Button */} + + + +
+
+ ); +}; + +export default AuditExportSection; diff --git a/frontend/src/core/components/shared/config/configSections/audit/AuditSystemStatus.tsx b/frontend/src/core/components/shared/config/configSections/audit/AuditSystemStatus.tsx new file mode 100644 index 000000000..c17109253 --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/audit/AuditSystemStatus.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Card, Group, Stack, Badge, Text } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { AuditSystemStatus as AuditStatus } from '@app/services/auditService'; + +interface AuditSystemStatusProps { + status: AuditStatus; +} + +const AuditSystemStatus: React.FC = ({ status }) => { + const { t } = useTranslation(); + + return ( + + + + {t('audit.systemStatus.title', 'System Status')} + + + +
+ + {t('audit.systemStatus.status', 'Audit Logging')} + + + {status.enabled + ? t('audit.systemStatus.enabled', 'Enabled') + : t('audit.systemStatus.disabled', 'Disabled')} + +
+ +
+ + {t('audit.systemStatus.level', 'Audit Level')} + + + {status.level} + +
+ +
+ + {t('audit.systemStatus.retention', 'Retention Period')} + + + {status.retentionDays} {t('audit.systemStatus.days', 'days')} + +
+ +
+ + {t('audit.systemStatus.totalEvents', 'Total Events')} + + + {status.totalEvents.toLocaleString()} + +
+
+
+
+ ); +}; + +export default AuditSystemStatus; diff --git a/frontend/src/core/components/shared/config/configSections/usage/UsageAnalyticsChart.tsx b/frontend/src/core/components/shared/config/configSections/usage/UsageAnalyticsChart.tsx new file mode 100644 index 000000000..5cb99b9ed --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/usage/UsageAnalyticsChart.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { Card, Text, Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; + +interface SimpleBarChartProps { + data: { label: string; value: number }[]; + maxValue: number; +} + +const SimpleBarChart: React.FC = ({ data, maxValue }) => { + const { t } = useTranslation(); + + return ( +
+ {data.length === 0 ? ( + + {t('usage.noData', 'No data available')} + + ) : ( + data.map((item, index) => ( +
+
+ + {item.label} + +
+ + {item.value} + + + ({((item.value / maxValue) * 100).toFixed(1)}%) + +
+
+
+
+
+
+ )) + )} +
+ ); +}; + +interface UsageAnalyticsChartProps { + data: { label: string; value: number }[]; + totalVisits: number; +} + +const UsageAnalyticsChart: React.FC = ({ data, totalVisits }) => { + const { t } = useTranslation(); + + return ( + + + + {t('usage.chart.title', 'Endpoint Usage Chart')} + + d.value), 1)} /> + + + ); +}; + +export default UsageAnalyticsChart; diff --git a/frontend/src/core/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx b/frontend/src/core/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx new file mode 100644 index 000000000..e2b74f88b --- /dev/null +++ b/frontend/src/core/components/shared/config/configSections/usage/UsageAnalyticsTable.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { Card, Text, Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { EndpointStatistic } from '@app/services/usageAnalyticsService'; + +interface UsageAnalyticsTableProps { + data: EndpointStatistic[]; + totalVisits: number; +} + +const UsageAnalyticsTable: React.FC = ({ data, totalVisits }) => { + const { t } = useTranslation(); + + return ( + + + + {t('usage.table.title', 'Detailed Statistics')} + + +
+ + + + + + + + + + + {data.length === 0 ? ( + + + + ) : ( + data.map((stat, index) => ( + { + e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-0)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + + + + + + )) + )} + +
+ # + + {t('usage.table.endpoint', 'Endpoint')} + + {t('usage.table.visits', 'Visits')} + + {t('usage.table.percentage', 'Percentage')} +
+ {t('usage.table.noData', 'No data available')} +
+ + {index + 1} + + + + {stat.endpoint} + + + + {stat.visits.toLocaleString()} + + + + {stat.percentage.toFixed(2)}% + +
+
+
+
+ ); +}; + +export default UsageAnalyticsTable; diff --git a/frontend/src/core/components/shared/config/types.ts b/frontend/src/core/components/shared/config/types.ts index 2765258de..047a4272c 100644 --- a/frontend/src/core/components/shared/config/types.ts +++ b/frontend/src/core/components/shared/config/types.ts @@ -23,6 +23,9 @@ export type NavKey = | 'adminLegal' | 'adminPremium' | 'adminFeatures' + | 'adminPlan' + | 'adminAudit' + | 'adminUsage' | 'adminEndpoints'; diff --git a/frontend/src/core/services/auditService.ts b/frontend/src/core/services/auditService.ts new file mode 100644 index 000000000..ac2da176b --- /dev/null +++ b/frontend/src/core/services/auditService.ts @@ -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; +} + +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 { + const response = await apiClient.get('/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 { + const response = await apiClient.get('/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 { + const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-charts', { + params: { period: timePeriod }, + }); + return response.data; + }, + + /** + * Export audit data + */ + async exportData( + format: 'csv' | 'json', + filters: AuditFilters = {} + ): Promise { + 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 { + const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-event-types'); + return response.data; + }, + + /** + * Get list of users for filtering + */ + async getUsers(): Promise { + const response = await apiClient.get('/api/v1/proprietary/ui-data/audit-users'); + return response.data; + }, +}; + +export default auditService; diff --git a/frontend/src/core/services/usageAnalyticsService.ts b/frontend/src/core/services/usageAnalyticsService.ts new file mode 100644 index 000000000..d88dfc3d6 --- /dev/null +++ b/frontend/src/core/services/usageAnalyticsService.ts @@ -0,0 +1,62 @@ +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, + includeHome: boolean = true, + includeLogin: boolean = true + ): Promise { + const params: Record = { + includeHome, + includeLogin, + }; + + if (limit !== undefined) { + params.limit = limit; + } + + const response = await apiClient.get( + '/api/v1/proprietary/ui-data/usage-endpoint-statistics', + { params } + ); + return response.data; + }, + + /** + * Get chart data for endpoint usage + */ + async getChartData( + limit?: number, + includeHome: boolean = true, + includeLogin: boolean = true + ): Promise { + const stats = await this.getEndpointStatistics(limit, includeHome, includeLogin); + + return { + labels: stats.endpoints.map((e) => e.endpoint), + values: stats.endpoints.map((e) => e.visits), + }; + }, +}; + +export default usageAnalyticsService; diff --git a/frontend/src/core/services/userManagementService.ts b/frontend/src/core/services/userManagementService.ts index 8e92cf350..a7b53014e 100644 --- a/frontend/src/core/services/userManagementService.ts +++ b/frontend/src/core/services/userManagementService.ts @@ -31,6 +31,12 @@ export interface AdminSettingsData { roleDetails?: Record; teams?: any[]; maxPaidUsers?: number; + // License information + maxAllowedUsers: number; + availableSlots: number; + grandfatheredUserCount: number; + licenseMaxUsers: number; + premiumEnabled: boolean; } export interface CreateUserRequest { diff --git a/frontend/src/core/utils/settingsNavigation.ts b/frontend/src/core/utils/settingsNavigation.ts new file mode 100644 index 000000000..f910002fc --- /dev/null +++ b/frontend/src/core/utils/settingsNavigation.ts @@ -0,0 +1,53 @@ +import { NavKey } from '@app/components/shared/config/types'; + +/** + * Navigate to a specific settings section + * + * @param section - The settings section key to navigate to + * + * @example + * // Navigate to People section + * navigateToSettings('people'); + * + * // Navigate to Admin Premium section + * navigateToSettings('adminPremium'); + */ +export function navigateToSettings(section: NavKey) { + const basePath = window.location.pathname.split('/settings')[0] || ''; + const newPath = `${basePath}/settings/${section}`; + window.history.pushState({}, '', newPath); + + // Trigger a popstate event to notify components + window.dispatchEvent(new PopStateEvent('popstate')); +} + +/** + * Get the URL path for a settings section + * Useful for creating links + * + * @param section - The settings section key + * @returns The URL path for the settings section + * + * @example + * Go to People Settings + * // Returns: "/settings/people" + */ +export function getSettingsUrl(section: NavKey): string { + return `/settings/${section}`; +} + +/** + * Check if currently viewing a settings section + * + * @param section - Optional section key to check for specific section + * @returns True if in settings (and matching specific section if provided) + */ +export function isInSettings(section?: NavKey): boolean { + const pathname = window.location.pathname; + + if (!section) { + return pathname.startsWith('/settings'); + } + + return pathname === `/settings/${section}`; +}