From ac3e10eb9958e9df36c36b21582285ce25ae3885 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Thu, 6 Nov 2025 17:29:34 +0000 Subject: [PATCH] Add audit system, invite links, and usage analytics (#4749) # Description of Changes New Features Audit System: Complete audit logging with dashboard, event tracking, and export capabilities Invite Links: Secure invite system with email notifications and expiration Usage Analytics: Endpoint usage statistics and visualization License Management: User counting with grandfathering and license enforcement ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: James Brunton --- .../common/model/ApplicationProperties.java | 5 + .../software/common/util/JarPathUtil.java | 41 +- .../software/common/util/RequestUriUtils.java | 2 + .../software/common/util/YamlHelper.java | 49 ++ .../controller/api/misc/ConfigController.java | 6 +- .../src/main/resources/settings.yml.template | 1 + .../proprietary/config/AuditJpaConfig.java | 8 +- .../controller/api/AuditRestController.java | 434 ++++++++++++++++ .../api/ProprietaryUIDataController.java | 27 +- .../controller/api/UsageRestController.java | 236 +++++++++ .../model/UserLicenseSettings.java | 65 +++ .../security/InitialSecuritySetup.java | 8 + .../configuration/DatabaseConfig.java | 3 +- .../configuration/SecurityConfiguration.java | 1 - .../configuration/ee/LicenseKeyChecker.java | 41 +- .../controller/api/InviteLinkController.java | 488 ++++++++++++++++++ .../controller/api/UserController.java | 48 +- .../filter/UserAuthenticationFilter.java | 3 + .../security/model/InviteToken.java | 62 +++ .../repository/InviteTokenRepository.java | 32 ++ .../UserLicenseSettingsRepository.java | 21 + .../security/service/EmailService.java | 54 ++ .../service/UserLicenseSettingsService.java | 411 +++++++++++++++ .../ee/LicenseKeyCheckerTest.java | 18 +- docker/Dockerfile.unified | 2 +- .../public/locales/en-GB/translation.json | 286 +++++++++- .../{Workbench.css => Workbench.module.css} | 0 .../src/core/components/layout/Workbench.tsx | 7 +- .../core/components/shared/AppConfigModal.tsx | 93 +++- .../core/components/shared/QuickAccessBar.tsx | 10 + .../shared/config/configNavSections.tsx | 155 +++--- .../configSections/AdminAuditSection.tsx | 99 ++++ .../configSections/AdminGeneralSection.tsx | 4 +- .../configSections/AdminMailSection.tsx | 66 +++ .../configSections/AdminPremiumSection.tsx | 14 +- .../configSections/AdminSecuritySection.tsx | 234 ++++++--- .../configSections/AdminUsageSection.tsx | 201 ++++++++ .../config/configSections/GeneralSection.tsx | 85 ++- .../audit/AuditChartsSection.tsx | 157 ++++++ .../configSections/audit/AuditEventsTable.tsx | 229 ++++++++ .../audit/AuditExportSection.tsx | 106 ++++ .../configSections/audit/AuditFiltersForm.tsx | 76 +++ .../audit/AuditSystemStatus.tsx | 64 +++ .../usage/UsageAnalyticsChart.tsx | 88 ++++ .../usage/UsageAnalyticsTable.tsx | 87 ++++ .../core/components/shared/config/types.ts | 60 ++- .../shared/config/useRestartServer.ts | 17 +- frontend/src/core/hooks/useAdminSettings.ts | 49 +- frontend/src/core/hooks/useAuditFilters.ts | 59 +++ frontend/src/core/services/auditService.ts | 115 +++++ .../core/services/usageAnalyticsService.ts | 61 +++ .../core/services/userManagementService.ts | 91 ++++ frontend/src/core/utils/settingsNavigation.ts | 53 ++ .../src/core/utils/settingsPendingHelper.ts | 9 +- frontend/src/proprietary/App.tsx | 2 + .../shared/config/configNavSections.tsx | 44 ++ .../config/configSections/GeneralSection.tsx | 53 ++ .../config/configSections/PeopleSection.tsx | 463 ++++++++++++++--- .../configSections/TeamDetailsSection.tsx | 179 +++++-- .../config/configSections/TeamsSection.tsx | 68 ++- .../src/proprietary/routes/InviteAccept.tsx | 237 +++++++++ frontend/src/proprietary/routes/Login.tsx | 42 +- .../services/teamService.ts | 0 frontend/vite.config.ts | 1 + 64 files changed, 5269 insertions(+), 461 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/controller/api/InviteLinkController.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java create mode 100644 app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.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 rename frontend/src/core/components/layout/{Workbench.css => Workbench.module.css} (100%) 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/AuditFiltersForm.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/hooks/useAuditFilters.ts 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 create mode 100644 frontend/src/proprietary/components/shared/config/configNavSections.tsx create mode 100644 frontend/src/proprietary/components/shared/config/configSections/GeneralSection.tsx rename frontend/src/{core => proprietary}/components/shared/config/configSections/PeopleSection.tsx (57%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/TeamDetailsSection.tsx (75%) rename frontend/src/{core => proprietary}/components/shared/config/configSections/TeamsSection.tsx (89%) create mode 100644 frontend/src/proprietary/routes/InviteAccept.tsx rename frontend/src/{core => proprietary}/services/teamService.ts (100%) diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 44f8a0ee3..4b5a202c0 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -368,6 +368,10 @@ public class ApplicationProperties { private TempFileManagement tempFileManagement = new TempFileManagement(); private DatabaseBackup databaseBackup = new DatabaseBackup(); private List corsAllowedOrigins = new ArrayList<>(); + private String + frontendUrl; // Base URL for frontend (used for invite links, etc.). If not set, + + // falls back to backend URL. public boolean isAnalyticsEnabled() { return this.getEnableAnalytics() != null && this.getEnableAnalytics(); @@ -556,6 +560,7 @@ public class ApplicationProperties { public static class Mail { private boolean enabled; private boolean enableInvites = false; + private int inviteLinkExpiryHours = 72; // Default: 72 hours (3 days) private String host; private int port; private String username; diff --git a/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java index 738cde8e6..e5c6488eb 100644 --- a/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java +++ b/app/common/src/main/java/stirling/software/common/util/JarPathUtil.java @@ -43,26 +43,45 @@ public class JarPathUtil { } /** - * Gets the path to the restart-helper.jar file Expected to be in the same directory as the main - * JAR + * Gets the path to the restart-helper.jar file. Checks multiple possible locations: 1. Same + * directory as the main JAR (production deployment) 2. ./build/libs/restart-helper.jar + * (development build) 3. app/common/build/libs/restart-helper.jar (multi-module build) * * @return Path to restart-helper.jar, or null if not found */ public static Path restartHelperJar() { Path appJar = currentJar(); - if (appJar == null) { - return null; + + // Define possible locations to check (in order of preference) + Path[] possibleLocations = new Path[4]; + + // Location 1: Same directory as main JAR (production) + if (appJar != null) { + possibleLocations[0] = appJar.getParent().resolve("restart-helper.jar"); } - Path helperJar = appJar.getParent().resolve("restart-helper.jar"); + // Location 2: ./build/libs/ (development build) + possibleLocations[1] = Paths.get("build", "libs", "restart-helper.jar").toAbsolutePath(); - if (Files.isRegularFile(helperJar)) { - log.debug("Restart helper JAR located at: {}", helperJar); - return helperJar; - } else { - log.warn("Restart helper JAR not found at: {}", helperJar); - return null; + // Location 3: app/common/build/libs/ (multi-module build) + possibleLocations[2] = + Paths.get("app", "common", "build", "libs", "restart-helper.jar").toAbsolutePath(); + + // Location 4: Current working directory + possibleLocations[3] = Paths.get("restart-helper.jar").toAbsolutePath(); + + // Check each location + for (Path location : possibleLocations) { + if (location != null && Files.isRegularFile(location)) { + log.info("Restart helper JAR found at: {}", location); + return location; + } else if (location != null) { + log.debug("Restart helper JAR not found at: {}", location); + } } + + log.warn("Restart helper JAR not found in any expected location"); + return null; } /** diff --git a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java index 321606186..23d369bf3 100644 --- a/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/RequestUriUtils.java @@ -76,6 +76,8 @@ public class RequestUriUtils { || trimmedUri.startsWith("/api/v1/auth/refresh") || trimmedUri.startsWith("/api/v1/auth/logout") || trimmedUri.startsWith("/v1/api-docs") + || trimmedUri.startsWith("/api/v1/invite/validate") + || trimmedUri.startsWith("/api/v1/invite/accept") || trimmedUri.contains("/v1/api-docs"); } } diff --git a/app/common/src/main/java/stirling/software/common/util/YamlHelper.java b/app/common/src/main/java/stirling/software/common/util/YamlHelper.java index 4de2bd597..66e097fdc 100644 --- a/app/common/src/main/java/stirling/software/common/util/YamlHelper.java +++ b/app/common/src/main/java/stirling/software/common/util/YamlHelper.java @@ -10,6 +10,7 @@ import java.util.Arrays; import java.util.Deque; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Function; @@ -135,6 +136,17 @@ public class YamlHelper { } else if ("true".equals(newValue) || "false".equals(newValue)) { newValueNode = new ScalarNode(Tag.BOOL, String.valueOf(newValue), ScalarStyle.PLAIN); + } else if (newValue instanceof Map map) { + // Handle Map objects - convert to MappingNode + List mapTuples = new ArrayList<>(); + for (Map.Entry entry : map.entrySet()) { + ScalarNode mapKeyNode = + new ScalarNode( + Tag.STR, String.valueOf(entry.getKey()), ScalarStyle.PLAIN); + Node mapValueNode = convertValueToNode(entry.getValue()); + mapTuples.add(new NodeTuple(mapKeyNode, mapValueNode)); + } + newValueNode = new MappingNode(Tag.MAP, mapTuples, FlowStyle.BLOCK); } else if (newValue instanceof List list) { List sequenceNodes = new ArrayList<>(); for (Object item : list) { @@ -458,6 +470,43 @@ public class YamlHelper { return isInteger(object) || isShort(object) || isByte(object) || isLong(object); } + /** + * Converts a Java value to a YAML Node. + * + * @param value The value to convert. + * @return The corresponding YAML Node. + */ + private Node convertValueToNode(Object value) { + if (value == null) { + return new ScalarNode(Tag.NULL, "null", ScalarStyle.PLAIN); + } else if (isAnyInteger(value)) { + return new ScalarNode(Tag.INT, String.valueOf(value), ScalarStyle.PLAIN); + } else if (isFloat(value)) { + Object floatValue = Float.valueOf(String.valueOf(value)); + return new ScalarNode(Tag.FLOAT, String.valueOf(floatValue), ScalarStyle.PLAIN); + } else if (value instanceof Boolean || "true".equals(value) || "false".equals(value)) { + return new ScalarNode(Tag.BOOL, String.valueOf(value), ScalarStyle.PLAIN); + } else if (value instanceof Map map) { + // Recursively handle nested maps + List mapTuples = new ArrayList<>(); + for (Map.Entry entry : map.entrySet()) { + ScalarNode mapKeyNode = + new ScalarNode(Tag.STR, String.valueOf(entry.getKey()), ScalarStyle.PLAIN); + Node mapValueNode = convertValueToNode(entry.getValue()); + mapTuples.add(new NodeTuple(mapKeyNode, mapValueNode)); + } + return new MappingNode(Tag.MAP, mapTuples, FlowStyle.BLOCK); + } else if (value instanceof List list) { + List sequenceNodes = new ArrayList<>(); + for (Object item : list) { + sequenceNodes.add(convertValueToNode(item)); + } + return new SequenceNode(Tag.SEQ, sequenceNodes, FlowStyle.FLOW); + } else { + return new ScalarNode(Tag.STR, String.valueOf(value), ScalarStyle.PLAIN); + } + } + /** * Copies comments from an old node to a new one. * diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 3fc6e3e02..b578d7c42 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -62,8 +62,10 @@ public class ConfigController { // Security settings configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin()); - // Mail settings - configData.put("enableEmailInvites", applicationProperties.getMail().isEnableInvites()); + // Mail settings - check both SMTP enabled AND invites enabled + boolean smtpEnabled = applicationProperties.getMail().isEnabled(); + boolean invitesEnabled = applicationProperties.getMail().isEnableInvites(); + configData.put("enableEmailInvites", smtpEnabled && invitesEnabled); // Check if user is admin using UserServiceInterface boolean isAdmin = false; diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index 8c75da0da..5f8ca51be 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -128,6 +128,7 @@ system: disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML) maxDPI: 500 # Maximum allowed DPI for PDF to image conversion corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. + frontendUrl: '' # Base URL for frontend (e.g. 'https://pdf.example.com'). Used for generating invite links in emails. If empty, falls back to backend URL. serverCertificate: enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option organizationName: Stirling-PDF # Organization name for generated certificates 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 073b2fcc8..ed9106f2f 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..0baa1c6b1 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/controller/api/UsageRestController.java @@ -0,0 +1,236 @@ +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 dataType Type of data to include: "all" (default), "api" (API endpoints excluding + * auth), or "ui" (non-API endpoints) + * @return Endpoint statistics response + */ + @GetMapping("/usage-endpoint-statistics") + public ResponseEntity getEndpointStatistics( + @RequestParam(value = "limit", required = false) Integer limit, + @RequestParam(value = "dataType", defaultValue = "all") String dataType) { + + // 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 data type filter + if (!shouldIncludeEndpoint(endpoint, dataType)) { + 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; + } + + /** + * Determine if an endpoint should be included based on the data type filter. + * + * @param endpoint The endpoint path to check + * @param dataType The filter type: "all", "api", or "ui" + * @return true if the endpoint should be included, false otherwise + */ + private boolean shouldIncludeEndpoint(String endpoint, String dataType) { + if ("all".equalsIgnoreCase(dataType)) { + return true; + } + + boolean isApiEndpoint = isApiEndpoint(endpoint); + + if ("api".equalsIgnoreCase(dataType)) { + return isApiEndpoint; + } else if ("ui".equalsIgnoreCase(dataType)) { + return !isApiEndpoint; + } + + // Default to including all if unrecognized type + return true; + } + + /** + * Check if an endpoint is an API endpoint. API endpoints match /api/v1/* pattern but exclude + * /api/v1/auth/* paths. + * + * @param endpoint The endpoint path to check + * @return true if this is an API endpoint (excluding auth endpoints), false otherwise + */ + private boolean isApiEndpoint(String endpoint) { + if (endpoint == null) { + return false; + } + + // Check if it starts with /api/v1/ + if (!endpoint.startsWith("/api/v1/")) { + return false; + } + + // Exclude auth endpoints + if (endpoint.startsWith("/api/v1/auth/")) { + return false; + } + + return true; + } + + // 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 9bcecafc2..24326f31e 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 @@ -21,6 +21,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 @@ -34,6 +35,7 @@ public class InitialSecuritySetup { private final TeamService teamService; private final ApplicationProperties applicationProperties; private final DatabaseServiceInterface databaseService; + private final UserLicenseSettingsService licenseSettingsService; @PostConstruct public void init() { @@ -50,12 +52,18 @@ public class InitialSecuritySetup { configureJWTSettings(); 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 configureJWTSettings() { ApplicationProperties.Security.Jwt jwtProperties = applicationProperties.getSecurity().getJwt(); 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 625dc041a..86eca9b15 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/SecurityConfiguration.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java index 010c15e29..76c8dec30 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java @@ -315,7 +315,6 @@ public class SecurityConfiguration { req -> { String uri = req.getRequestURI(); String contextPath = req.getContextPath(); - // Check if it's a public auth endpoint or static // resource return RequestUriUtils.isStaticResource( 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 987d5fb6f..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,13 +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 @@ -23,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 { @@ -58,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"); @@ -85,6 +111,13 @@ public class LicenseKeyChecker { return keyOrFilePath; } + public void updateLicenseKey(String newKey) throws IOException { + applicationProperties.getPremium().setKey(newKey); + GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey); + evaluateLicense(); + synchronizeLicenseSettings(); + } + public License getPremiumLicenseEnabledResult() { return premiumEnabledResult; } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java new file mode 100644 index 000000000..5ce637259 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/InviteLinkController.java @@ -0,0 +1,488 @@ +package stirling.software.proprietary.security.controller.api; + +import java.security.Principal; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import jakarta.servlet.http.HttpServletRequest; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.annotations.api.UserApi; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.model.enumeration.Role; +import stirling.software.proprietary.model.Team; +import stirling.software.proprietary.security.model.InviteToken; +import stirling.software.proprietary.security.repository.InviteTokenRepository; +import stirling.software.proprietary.security.repository.TeamRepository; +import stirling.software.proprietary.security.service.EmailService; +import stirling.software.proprietary.security.service.TeamService; +import stirling.software.proprietary.security.service.UserService; + +@UserApi +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/invite") +public class InviteLinkController { + + private final InviteTokenRepository inviteTokenRepository; + private final TeamRepository teamRepository; + private final UserService userService; + private final ApplicationProperties applicationProperties; + private final Optional emailService; + + /** + * Generate a new invite link (admin only) + * + * @param email The email address to invite + * @param role The role to assign (default: ROLE_USER) + * @param teamId The team to assign (optional, uses default team if not provided) + * @param expiryHours Custom expiry hours (optional, uses default from config) + * @param sendEmail Whether to send the invite link via email (default: false) + * @param principal The authenticated admin user + * @param request The HTTP request + * @return ResponseEntity with the invite link or error + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/generate") + public ResponseEntity generateInviteLink( + @RequestParam(name = "email", required = false) String email, + @RequestParam(name = "role", defaultValue = "ROLE_USER") String role, + @RequestParam(name = "teamId", required = false) Long teamId, + @RequestParam(name = "expiryHours", required = false) Integer expiryHours, + @RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail, + Principal principal, + HttpServletRequest request) { + + try { + // Check if email invites are enabled + if (!applicationProperties.getMail().isEnableInvites()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Email invites are not enabled")); + } + + // If email is provided, validate and check for conflicts + if (email != null && !email.trim().isEmpty()) { + // Validate email format + if (!email.contains("@")) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid email address")); + } + + email = email.trim().toLowerCase(); + + // Check if user already exists + if (userService.usernameExistsIgnoreCase(email)) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "User already exists")); + } + + // Check if there's already an active invite for this email + Optional existingInvite = inviteTokenRepository.findByEmail(email); + if (existingInvite.isPresent() && existingInvite.get().isValid()) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body( + Map.of( + "error", + "An active invite already exists for this email address")); + } + + // If sendEmail is requested but no email provided, reject + if (sendEmail) { + // Email will be sent + } + } else { + // No email provided - this is a general invite link + email = null; // Ensure it's null, not empty string + + // Cannot send email if no email address provided + if (sendEmail) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot send email without an email address")); + } + } + + // Check license limits + if (applicationProperties.getPremium().isEnabled()) { + long currentUserCount = userService.getTotalUsersCount(); + long activeInvites = inviteTokenRepository.countActiveInvites(LocalDateTime.now()); + int maxUsers = applicationProperties.getPremium().getMaxUsers(); + + if (currentUserCount + activeInvites >= maxUsers) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body( + Map.of( + "error", + "License limit reached (" + + (currentUserCount + activeInvites) + + "/" + + maxUsers + + " users). Contact your administrator to upgrade your license.")); + } + } + + // Validate role + try { + Role roleEnum = Role.fromString(role); + if (roleEnum == Role.INTERNAL_API_USER) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign INTERNAL_API_USER role")); + } + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid role specified")); + } + + // Determine team + Long effectiveTeamId = teamId; + if (effectiveTeamId == null) { + Team defaultTeam = + teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); + if (defaultTeam != null) { + effectiveTeamId = defaultTeam.getId(); + } + } else { + Team selectedTeam = teamRepository.findById(effectiveTeamId).orElse(null); + if (selectedTeam != null + && TeamService.INTERNAL_TEAM_NAME.equals(selectedTeam.getName())) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Cannot assign users to Internal team")); + } + } + + // Generate token + String token = UUID.randomUUID().toString(); + + // Determine expiry time + int effectiveExpiryHours = + (expiryHours != null && expiryHours > 0) + ? expiryHours + : applicationProperties.getMail().getInviteLinkExpiryHours(); + LocalDateTime expiresAt = LocalDateTime.now().plusHours(effectiveExpiryHours); + + // Create invite token + InviteToken inviteToken = new InviteToken(); + inviteToken.setToken(token); + inviteToken.setEmail(email); + inviteToken.setRole(role); + inviteToken.setTeamId(effectiveTeamId); + inviteToken.setExpiresAt(expiresAt); + inviteToken.setCreatedBy(principal.getName()); + + inviteTokenRepository.save(inviteToken); + + // Build invite URL + // Use configured frontend URL if available, otherwise fall back to backend URL + String baseUrl; + String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl(); + if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) { + // Use configured frontend URL (remove trailing slash if present) + baseUrl = + configuredFrontendUrl.endsWith("/") + ? configuredFrontendUrl.substring( + 0, configuredFrontendUrl.length() - 1) + : configuredFrontendUrl; + } else { + // Fall back to backend URL from request + baseUrl = + request.getScheme() + + "://" + + request.getServerName() + + (request.getServerPort() != 80 && request.getServerPort() != 443 + ? ":" + request.getServerPort() + : ""); + } + String inviteUrl = baseUrl + "/invite?token=" + token; + + log.info("Generated invite link for {} by {}", email, principal.getName()); + + // Optionally send email + boolean emailSent = false; + String emailError = null; + if (sendEmail) { + if (!emailService.isPresent()) { + emailError = "Email service is not configured"; + log.warn("Cannot send invite email: Email service not configured"); + } else { + try { + emailService + .get() + .sendInviteLinkEmail(email, inviteUrl, expiresAt.toString()); + emailSent = true; + log.info("Sent invite link email to: {}", email); + } catch (Exception emailEx) { + emailError = emailEx.getMessage(); + log.error( + "Failed to send invite email to {}: {}", + email, + emailEx.getMessage()); + } + } + } + + Map response = new HashMap<>(); + response.put("token", token); + response.put("inviteUrl", inviteUrl); + response.put("email", email); + response.put("expiresAt", expiresAt.toString()); + response.put("expiryHours", effectiveExpiryHours); + if (sendEmail) { + response.put("emailSent", emailSent); + if (emailError != null) { + response.put("emailError", emailError); + } + } + + return ResponseEntity.ok(response); + + } catch (Exception e) { + log.error("Failed to generate invite link: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to generate invite link: " + e.getMessage())); + } + } + + /** + * List all active invite links (admin only) + * + * @return List of active invite tokens + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping("/list") + public ResponseEntity listInviteLinks() { + try { + List activeInvites = + inviteTokenRepository.findByUsedFalseAndExpiresAtAfter(LocalDateTime.now()); + + List> inviteList = + activeInvites.stream() + .map( + invite -> { + Map inviteMap = new HashMap<>(); + inviteMap.put("id", invite.getId()); + inviteMap.put("email", invite.getEmail()); + inviteMap.put("role", invite.getRole()); + inviteMap.put("teamId", invite.getTeamId()); + inviteMap.put("createdBy", invite.getCreatedBy()); + inviteMap.put( + "createdAt", invite.getCreatedAt().toString()); + inviteMap.put( + "expiresAt", invite.getExpiresAt().toString()); + return inviteMap; + }) + .collect(Collectors.toList()); + + return ResponseEntity.ok(Map.of("invites", inviteList)); + + } catch (Exception e) { + log.error("Failed to list invite links: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to list invite links")); + } + } + + /** + * Revoke an invite link (admin only) + * + * @param inviteId The invite token ID to revoke + * @return Success or error response + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @DeleteMapping("/revoke/{inviteId}") + public ResponseEntity revokeInviteLink(@PathVariable Long inviteId) { + try { + Optional inviteOpt = inviteTokenRepository.findById(inviteId); + if (inviteOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Invite not found")); + } + + inviteTokenRepository.deleteById(inviteId); + log.info("Revoked invite link ID: {}", inviteId); + + return ResponseEntity.ok(Map.of("message", "Invite link revoked successfully")); + + } catch (Exception e) { + log.error("Failed to revoke invite link: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to revoke invite link")); + } + } + + /** + * Clean up expired invite tokens (admin only) + * + * @return Number of deleted tokens + */ + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/cleanup") + public ResponseEntity cleanupExpiredInvites() { + try { + List expiredInvites = + inviteTokenRepository.findAll().stream() + .filter(invite -> !invite.isValid()) + .collect(Collectors.toList()); + + int count = expiredInvites.size(); + inviteTokenRepository.deleteAll(expiredInvites); + + log.info("Cleaned up {} expired invite tokens", count); + + return ResponseEntity.ok(Map.of("deletedCount", count)); + + } catch (Exception e) { + log.error("Failed to cleanup expired invites: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to cleanup expired invites")); + } + } + + /** + * Validate an invite token (public endpoint) + * + * @param token The invite token to validate + * @return Invite details if valid, error otherwise + */ + @GetMapping("/validate/{token}") + public ResponseEntity validateInviteToken(@PathVariable String token) { + try { + Optional inviteOpt = inviteTokenRepository.findByToken(token); + + if (inviteOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Invalid invite link")); + } + + InviteToken invite = inviteOpt.get(); + + if (invite.isUsed()) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Map.of("error", "This invite link has already been used")); + } + + if (invite.isExpired()) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Map.of("error", "This invite link has expired")); + } + + // Check if user already exists (only if email is pre-set) + if (invite.getEmail() != null + && userService.usernameExistsIgnoreCase(invite.getEmail())) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "User already exists")); + } + + Map response = new HashMap<>(); + response.put("email", invite.getEmail()); + response.put("role", invite.getRole()); + response.put("expiresAt", invite.getExpiresAt().toString()); + response.put("emailRequired", invite.getEmail() == null); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + log.error("Failed to validate invite token: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to validate invite link")); + } + } + + /** + * Accept an invite and create user account (public endpoint) + * + * @param token The invite token + * @param email The email address (required if not pre-set in invite) + * @param password The password to set for the new account + * @return Success or error response + */ + @PostMapping("/accept/{token}") + public ResponseEntity acceptInvite( + @PathVariable String token, + @RequestParam(name = "email", required = false) String email, + @RequestParam(name = "password") String password) { + try { + // Validate password + if (password == null || password.isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Password is required")); + } + + Optional inviteOpt = inviteTokenRepository.findByToken(token); + + if (inviteOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("error", "Invalid invite link")); + } + + InviteToken invite = inviteOpt.get(); + + if (invite.isUsed()) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Map.of("error", "This invite link has already been used")); + } + + if (invite.isExpired()) { + return ResponseEntity.status(HttpStatus.GONE) + .body(Map.of("error", "This invite link has expired")); + } + + // Determine the email to use + String effectiveEmail = invite.getEmail(); + if (effectiveEmail == null) { + // Email not pre-set, must be provided by user + if (email == null || email.trim().isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Email address is required")); + } + + // Validate email format + if (!email.contains("@")) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("error", "Invalid email address")); + } + + effectiveEmail = email.trim().toLowerCase(); + } + + // Check if user already exists + if (userService.usernameExistsIgnoreCase(effectiveEmail)) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("error", "User already exists")); + } + + // Create the user account + userService.saveUser( + effectiveEmail, + password, + invite.getTeamId(), + invite.getRole(), + false); // Don't force password change + + // Mark invite as used + invite.setUsed(true); + invite.setUsedAt(LocalDateTime.now()); + inviteTokenRepository.save(invite); + + log.info( + "User account created via invite link: {} with role: {}", + effectiveEmail, + invite.getRole()); + + return ResponseEntity.ok( + Map.of("message", "Account created successfully", "username", effectiveEmail)); + + } catch (Exception e) { + log.error("Failed to accept invite: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to create account: " + e.getMessage())); + } + } +} 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 a1cd0a928..92a1f82ac 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 @@ -43,6 +43,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 @@ -56,6 +57,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") @@ -80,10 +82,9 @@ public class UserController { .body(Map.of("error", "Invalid username format")); } - if (usernameAndPass.getPassword() == null - || usernameAndPass.getPassword().length() < 6) { + if (usernameAndPass.getPassword() == null || usernameAndPass.getPassword().isEmpty()) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "Password must be at least 6 characters")); + .body(Map.of("error", "Password is required")); } Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null); @@ -316,11 +317,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()) { @@ -413,20 +420,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/filter/UserAuthenticationFilter.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java index 6265281d9..b36dc36cc 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/filter/UserAuthenticationFilter.java @@ -251,6 +251,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { String[] permitAllPatterns = { contextPath + "/login", contextPath + "/register", + contextPath + "/invite", contextPath + "/error", contextPath + "/images/", contextPath + "/public/", @@ -263,6 +264,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter { contextPath + "/api/v1/auth/login", contextPath + "/api/v1/auth/refresh", contextPath + "/api/v1/auth/me", + contextPath + "/api/v1/invite/validate", + contextPath + "/api/v1/invite/accept", contextPath + "/site.webmanifest" }; diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java new file mode 100644 index 000000000..975220bf4 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/model/InviteToken.java @@ -0,0 +1,62 @@ +package stirling.software.proprietary.security.model; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; + +import jakarta.persistence.*; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "invite_tokens") +@NoArgsConstructor +@Getter +@Setter +public class InviteToken implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "token", unique = true, nullable = false, length = 100) + private String token; + + @Column(name = "email", nullable = true, length = 255) + private String email; // Optional - if not set, user can provide their own email + + @Column(name = "role", nullable = false, length = 50) + private String role; + + @Column(name = "team_id") + private Long teamId; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "used", nullable = false) + private boolean used = false; + + @Column(name = "created_by", nullable = false, length = 255) + private String createdBy; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + public boolean isExpired() { + return LocalDateTime.now().isAfter(expiresAt); + } + + public boolean isValid() { + return !used && !isExpired(); + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.java new file mode 100644 index 000000000..be3cd9c9e --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/repository/InviteTokenRepository.java @@ -0,0 +1,32 @@ +package stirling.software.proprietary.security.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import stirling.software.proprietary.security.model.InviteToken; + +@Repository +public interface InviteTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + Optional findByEmail(String email); + + List findByUsedFalseAndExpiresAtAfter(LocalDateTime now); + + List findByCreatedBy(String createdBy); + + @Modifying + @Query("DELETE FROM InviteToken it WHERE it.expiresAt < :now") + void deleteExpiredTokens(@Param("now") LocalDateTime now); + + @Query("SELECT COUNT(it) FROM InviteToken it WHERE it.used = false AND it.expiresAt > :now") + long countActiveInvites(@Param("now") LocalDateTime now); +} 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/security/service/EmailService.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java index 1b9fc19bd..870c96f23 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/service/EmailService.java @@ -159,4 +159,58 @@ public class EmailService { sendPlainEmail(to, subject, body, true); } + + /** + * Sends an invitation link email to a new user. + * + * @param to The recipient email address + * @param inviteUrl The full URL for accepting the invite + * @param expiresAt The expiration timestamp + * @throws MessagingException If there is an issue with creating or sending the email. + */ + @Async + public void sendInviteLinkEmail(String to, String inviteUrl, String expiresAt) + throws MessagingException { + String subject = "You've been invited to Stirling PDF"; + + String body = + """ + +
+
+ +
+ Stirling PDF +
+ +
+

Welcome to Stirling PDF!

+

Hi there,

+

You have been invited to join the Stirling PDF workspace. Click the button below to set up your account:

+ + +

Or copy and paste this link in your browser:

+
+ %s +
+
+

⚠️ Important: This invitation link will expire on %s. Please complete your registration before then.

+
+

If you didn't expect this invitation, you can safely ignore this email.

+

— The Stirling PDF Team

+
+ +
+ © 2025 Stirling PDF. All rights reserved. +
+
+
+ + """ + .formatted(inviteUrl, inviteUrl, expiresAt); + + sendPlainEmail(to, subject, body, true); + } } 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/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java index 4a6e7ad65..136bec9e5 100644 --- a/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java +++ b/app/proprietary/src/test/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyCheckerTest.java @@ -17,11 +17,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import stirling.software.common.model.ApplicationProperties; +import stirling.software.proprietary.service.UserLicenseSettingsService; @ExtendWith(MockitoExtension.class) class LicenseKeyCheckerTest { @Mock private KeygenLicenseVerifier verifier; + @Mock private UserLicenseSettingsService userLicenseSettingsService; @Test void premiumDisabled_skipsVerification() { @@ -29,7 +31,9 @@ class LicenseKeyCheckerTest { props.getPremium().setEnabled(false); props.getPremium().setKey("dummy"); - LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props); + LicenseKeyChecker checker = + new LicenseKeyChecker(verifier, props, userLicenseSettingsService); + checker.init(); assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult()); verifyNoInteractions(verifier); @@ -42,7 +46,9 @@ class LicenseKeyCheckerTest { props.getPremium().setKey("abc"); when(verifier.verifyLicense("abc")).thenReturn(License.PRO); - LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props); + LicenseKeyChecker checker = + new LicenseKeyChecker(verifier, props, userLicenseSettingsService); + checker.init(); assertEquals(License.PRO, checker.getPremiumLicenseEnabledResult()); verify(verifier).verifyLicense("abc"); @@ -58,7 +64,9 @@ class LicenseKeyCheckerTest { props.getPremium().setKey("file:" + file.toString()); when(verifier.verifyLicense("filekey")).thenReturn(License.ENTERPRISE); - LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props); + LicenseKeyChecker checker = + new LicenseKeyChecker(verifier, props, userLicenseSettingsService); + checker.init(); assertEquals(License.ENTERPRISE, checker.getPremiumLicenseEnabledResult()); verify(verifier).verifyLicense("filekey"); @@ -71,7 +79,9 @@ class LicenseKeyCheckerTest { props.getPremium().setEnabled(true); props.getPremium().setKey("file:" + file.toString()); - LicenseKeyChecker checker = new LicenseKeyChecker(verifier, props); + LicenseKeyChecker checker = + new LicenseKeyChecker(verifier, props, userLicenseSettingsService); + checker.init(); assertEquals(License.NORMAL, checker.getPremiumLicenseEnabledResult()); verifyNoInteractions(verifier); diff --git a/docker/Dockerfile.unified b/docker/Dockerfile.unified index 0f1c31cf8..cf7a051a2 100644 --- a/docker/Dockerfile.unified +++ b/docker/Dockerfile.unified @@ -10,7 +10,7 @@ COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci COPY frontend . -RUN npm run build +RUN DISABLE_ADDITIONAL_FEATURES=false npm run build # Stage 2: Build Backend FROM gradle:8.14-jdk21 AS backend-build diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 1ee4aeac2..e6a86e745 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -298,6 +298,20 @@ "general": { "title": "General", "description": "Configure general application preferences.", + "account": "Account", + "accountDescription": "Manage your account settings", + "user": "User", + "signedInAs": "Signed in as", + "logout": "Log out", + "enableFeatures": { + "title": "For System Administrators", + "intro": "Enable user authentication, team management, and workspace features for your organization.", + "action": "Configure", + "and": "and", + "benefit": "Enables user roles, team collaboration, admin controls, and enterprise features.", + "learnMore": "Learn more in documentation", + "dismiss": "Dismiss" + }, "autoUnzip": "Auto-unzip API responses", "autoUnzipDescription": "Automatically extract files from ZIP responses", "autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.", @@ -399,8 +413,10 @@ "top20": "Top 20", "all": "All", "refresh": "Refresh", - "includeHomepage": "Include Homepage ('/')", - "includeLoginPage": "Include Login Page ('/login')", + "dataTypeLabel": "Data Type:", + "dataTypeAll": "All", + "dataTypeApi": "API", + "dataTypeUi": "UI", "totalEndpoints": "Total Endpoints", "totalVisits": "Total Visits", "showing": "Showing", @@ -3087,7 +3103,10 @@ "magicLinkSent": "Magic link sent to {{email}}! Check your email and click the link to sign in.", "passwordResetSent": "Password reset link sent to {{email}}! Check your email and follow the instructions.", "failedToSignIn": "Failed to sign in with {{provider}}: {{message}}", - "unexpectedError": "Unexpected error: {{message}}" + "unexpectedError": "Unexpected error: {{message}}", + "accountCreatedSuccess": "Account created successfully! You can now sign in.", + "passwordChangedSuccess": "Password changed successfully! Please sign in with your new password.", + "credentialsUpdated": "Your credentials have been updated. Please sign in again." }, "signup": { "title": "Create an account", @@ -3547,8 +3566,8 @@ "restartingMessage": "The server is restarting. Please wait a moment...", "restartError": "Failed to restart server. Please restart manually.", "general": { - "title": "General", - "description": "Configure general application settings including branding and default behaviour.", + "title": "System Settings", + "description": "Configure system-wide application settings including branding and default behaviour.", "ui": "User Interface", "system": "System", "appName": "Application Name", @@ -3733,7 +3752,7 @@ "enableAnalytics": "Enable Analytics", "enableAnalytics.description": "Collect anonymous usage analytics to help improve the application", "metricsEnabled": "Enable Metrics", - "metricsEnabled.description": "Enable collection of performance and usage metrics", + "metricsEnabled.description": "Enable collection of performance and usage metrics. Provides API endpoint for admins to access metrics data", "searchEngine": "Search Engine Visibility", "googleVisibility": "Google Visibility", "googleVisibility.description": "Allow search engines to index this application" @@ -3808,7 +3827,9 @@ "from": "From Address", "from.description": "The email address to use as the sender", "enableInvites": "Enable Email Invites", - "enableInvites.description": "Allow admins to invite users via email with auto-generated passwords" + "enableInvites.description": "Allow admins to invite users via email with auto-generated passwords", + "frontendUrl": "Frontend URL", + "frontendUrl.description": "Base URL for frontend (e.g. https://pdf.example.com). Used for generating invite links in emails. Leave empty to use backend URL." }, "legal": { "title": "Legal Documents", @@ -4512,10 +4533,47 @@ "directInvite": { "tab": "Direct Create" }, + "inviteLinkTab": { + "tab": "Invite Link" + }, + "inviteLink": { + "description": "Generate a secure link that allows the user to set their own password", + "email": "Email Address", + "emailPlaceholder": "user@example.com (optional)", + "emailDescription": "Optional - leave blank for a general invite link that can be used by anyone", + "emailRequired": "Email address is required", + "emailOptional": "Optional - leave blank for a general invite link", + "emailRequiredForSend": "Email address is required to send email notification", + "expiryHours": "Expiry Hours", + "expiryDescription": "How many hours until the link expires", + "sendEmail": "Send invite link via email", + "sendEmailDescription": "If enabled, the invite link will be sent to the specified email address", + "smtpRequired": "SMTP not configured", + "generate": "Generate Link", + "generated": "Invite Link Generated", + "copied": "Link copied to clipboard", + "success": "Invite link generated successfully", + "successWithEmail": "Invite link generated and sent via email", + "emailFailed": "Invite link generated, but email failed", + "emailFailedDetails": "Error: {0}. Please share the invite link manually.", + "error": "Failed to generate invite link", + "submit": "Generate Invite Link" + }, "inviteMode": { "username": "Username", "email": "Email", + "link": "Link", "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": { @@ -4599,6 +4657,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!", @@ -4619,6 +4760,137 @@ "passwordChangedSuccess": "Password changed successfully! Please log in again.", "passwordChangeFailed": "Failed to change password. Please check your current password." }, + "invite": { + "welcome": "Welcome to Stirling PDF", + "invalidToken": "Invalid invitation link", + "validationError": "Failed to validate invitation link", + "passwordRequired": "Password is required", + "passwordTooShort": "Password must be at least 6 characters", + "passwordMismatch": "Passwords do not match", + "acceptError": "Failed to create account", + "validating": "Validating invitation...", + "invalidInvitation": "Invalid Invitation", + "goToLogin": "Go to Login", + "welcomeTitle": "You've been invited!", + "welcomeSubtitle": "Complete your account setup to get started", + "accountFor": "Creating account for", + "linkExpires": "Link expires", + "email": "Email address", + "emailPlaceholder": "Enter your email address", + "emailRequired": "Email address is required", + "invalidEmail": "Invalid email address", + "choosePassword": "Choose a password", + "passwordPlaceholder": "Enter your password", + "confirmPassword": "Confirm password", + "confirmPasswordPlaceholder": "Re-enter your password", + "createAccount": "Create Account", + "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", + "dataTypeLabel": "Data Type:", + "dataType": { + "all": "All", + "api": "API", + "ui": "UI" + } + }, + "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" + } + }, "backendHealth": { "checking": "Checking backend status...", "online": "Backend Online", diff --git a/frontend/src/core/components/layout/Workbench.css b/frontend/src/core/components/layout/Workbench.module.css similarity index 100% rename from frontend/src/core/components/layout/Workbench.css rename to frontend/src/core/components/layout/Workbench.module.css diff --git a/frontend/src/core/components/layout/Workbench.tsx b/frontend/src/core/components/layout/Workbench.tsx index 861e6a365..dcd9b5366 100644 --- a/frontend/src/core/components/layout/Workbench.tsx +++ b/frontend/src/core/components/layout/Workbench.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Box } from '@mantine/core'; import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; @@ -7,7 +8,7 @@ import { useNavigationState, useNavigationActions } from '@app/contexts/Navigati import { isBaseWorkbench } from '@app/types/workbench'; import { useViewer } from '@app/contexts/ViewerContext'; import { useAppConfig } from '@app/contexts/AppConfigContext'; -import '@app/components/layout/Workbench.css'; +import styles from '@app/components/layout/Workbench.module.css'; import TopControls from '@app/components/shared/TopControls'; import FileEditor from '@app/components/fileEditor/FileEditor'; @@ -181,7 +182,7 @@ export default function Workbench() { {/* Main content area */} 0 ? '3.5rem' : '0'), @@ -191,7 +192,7 @@ export default function Workbench() {